Compare commits

...

8 Commits

Author SHA1 Message Date
jared 57da9a6ce8 feat(soundboard): clip duration, playing indicator, volume layout, name wrap
CI / Build & Quality Checks (push) Successful in 10m37s
CI / Trigger Desktop Build (push) Successful in 16s
Editor (SoundboardPackEditor): show each clip's length in seconds (stored on
upload via getAudioDurationMs, and captured on preview for existing clips); the
preview button now toggles play/stop with a 'now playing' equalizer indicator;
reworked the volume control into a fixed cell with a % readout so the slider's
max no longer collides with the delete button.

Call soundboard: clip names wrap (up to 3 lines, word-break) instead of being
truncated with an ellipsis; cards grow to fit.

TODO: logged the basic audio-editor / video->audio-extractor as a large project.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:44:09 -04:00
jared eb34b04708 feat(audio): play m.file audio messages inline like m.audio
Audio frequently arrives as m.file (bridges, other clients, or when the browser
reported a non-audio/* mime on upload) and only got a download button. Detect
audio in the m.file branch (by info.mimetype or filename extension) and render
the existing MAudio inline player, falling back to the file card otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:44:09 -04:00
jared fd9e4a9802 feat(download): show a toast + button check when a file is saved
The desktop (Tauri) app has no native download UI, so FileSaver.saveAs saved
files silently — no visual or audio confirmation. Users re-clicked because
nothing said it worked (one report: 5 copies of the same file). Add a small
useSaveFile() hook that saves AND raises a 'Downloaded <filename>' toast, and
route every download call site through it (file attachments, image viewer, PDF
viewer, plus the recovery-key / key-backup exports). The file-message download
button also shows a green check on success.

Toast system extended with an optional iconSrc so system toasts render an icon
instead of an avatar/initials, and an empty roomName is no longer rendered.

Tests: createDownloadToast covered; 701/701 pass; typecheck + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:30:57 -04:00
jared f12175e76f fix(unread): stop stuck/resurrecting read indicators
CI / Build & Quality Checks (push) Successful in 10m53s
CI / Trigger Desktop Build (push) Successful in 9s
handleReceipt recomputed unread from getUnreadNotificationCount, which is
server-computed and stale on the synchronous synthetic receipt echo (the SDK
only zeroes it immediately when the last event is our own message). Reading
someone else's message therefore PUT the stale non-zero count back -> dot stuck
or resurrected on the ack-sync ordering race. Restore upstream cinny's
optimistic DELETE on our own receipt; the UnreadNotifications listener re-asserts
the accurate badge on the server ack.

Also collapse a {total:0,highlight:0} PUT to a DELETE in the reducer (a present
map entry lights the dot via hasUnread=!!unread, so phantom {0,0} PUTs from the
UnreadNotifications listener left stuck dots).

Mark-as-Unread (MSC2867): clear the flag directly in markAsRead (opening an
already-read room sends no receipt, so the receipt-driven auto-clear never
fired), and gate the receipt auto-clear to main/unthreaded receipts so reading
one thread no longer wipes the whole-room flag.

Tests: 700/700 pass; typecheck + prod build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 22:07:21 -04:00
jared b5db617bd2 docs: log unread/read-receipt flakiness bug (investigating)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 21:49:52 -04:00
jared 4ecc173554 docs: record remaining spec/MSC gaps survey (buildable vs blocked)
CI / Build & Quality Checks (push) Successful in 10m53s
CI / Trigger Desktop Build (push) Successful in 6s
Full-surface protocol survey. Flags each remaining gap by what unblocks it:
buildable now (custom room tags/sections — the only substantive client-only one
left), needs infra (email/3PID invites → identity server; MSC4108/3814), and
blocked-until-Synapse-upgrade (live location 3489/3672, reaction redaction 3892,
room preview 3266, thread subs 4306). Space reordering already works (drag) — not
a gap. Corrected per user.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:51:47 -04:00
jared 44854a1529 docs: park Sliding Sync (evaluated — not viable for a safe rollout)
CI / Build & Quality Checks (push) Successful in 10m41s
CI / Trigger Desktop Build (push) Successful in 6s
Three research passes concluded ~10% confidence a full rollout wouldn't
break/regress (js-sdk SlidingSync is _internal_/experimental + labs-only at
Element, presence not delivered over sliding sync, no upstream Cinny reference,
and Cinny's nav is built from the full local room set — ~14 subsystems assume
completeness). Server side is GA. Parked; revisit on Rust SDK adoption or large
accounts. Full assessment in the plan history.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:45:44 -04:00
jared 43f4ceb45d feat(rooms): Room Widgets (MSC1236 im.vector.modular.widgets)
Phase C.1 of the protocol-gaps roadmap, gate-green (693 tests). Generalizes the
Element Call widget host into a general room-widget feature:
- StateEvent.Widget + widgetsPanelAtom + useRoomWidgets (WidgetParser).
- RoomWidgetView: sandboxed-iframe host via ClientWidgetApi with a conservative
  GeneralWidgetDriver (approves only benign display caps — no room-event
  send/read/to-device). Blocks same-origin widget URLs (sandbox breakout guard).
- WidgetsPanel: list / open / add / remove, PL-gated on im.vector.modular.widgets,
  https + non-same-origin URL validation. Mounted like the media gallery (header
  toggle + 3-way content-panel exclusivity + mobile full-screen overlay).
- Tested URL/capability/id helpers.

Requires the prod CSP frame-src widening (matrix repo) for external widgets.
v1 cuts (capability consent prompt, Jitsi/sticker types, user widgets) noted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 13:27:23 -04:00
33 changed files with 1050 additions and 71 deletions
+2
View File
@@ -675,6 +675,8 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
## Outstanding verification backlog ## Outstanding verification backlog
**Room Widgets (MSC1236, 2026-07 — needs the CSP `frame-src` widening + `nginx -s reload` first):** In a room, the header **Widgets** button (grid icon, desktop) opens a right-side panel. As an admin (PL to modify widgets): **Add Widget** with a name + an https URL (e.g. an Etherpad `https://…` or any embeddable page) → it appears in the list; click it → it renders in a sandboxed iframe in the panel; **Remove** clears it. A non-admin sees the list + can open widgets but has no Add/Remove. Check: a non-https or same-origin URL is rejected on Add with a clear message; the panel is a full-screen overlay on mobile and is mutually exclusive with the Thread/Gallery/Members panels; if a widget stays blank, the prod CSP `frame-src` still needs widening. Widgets get only benign display capabilities (they can't send/read room events in v1).
**QR Device Verification (2026-07):** With two logged-in Lotus sessions (or Lotus + Element), start a device verification. On the **Ready** step you now see your own QR code plus a **"Scan their QR code"** button and a **"Verify with emoji instead"** fallback. Have one device **scan** the other's code (grant camera permission) → the showing device asks you to **Confirm**, and both reach **verified**. Check: emoji-SAS still works unchanged; denying camera shows a graceful "verify with emojis instead" message; a deliberately-wrong scan cancels cleanly. Desktop (WebView2) auto-grants the camera; web needs the Permissions-Policy camera allowance (already set). **QR Device Verification (2026-07):** With two logged-in Lotus sessions (or Lotus + Element), start a device verification. On the **Ready** step you now see your own QR code plus a **"Scan their QR code"** button and a **"Verify with emoji instead"** fallback. Have one device **scan** the other's code (grant camera permission) → the showing device asks you to **Confirm**, and both reach **verified**. Check: emoji-SAS still works unchanged; denying camera shows a graceful "verify with emojis instead" message; a deliberately-wrong scan cancels cleanly. Desktop (WebView2) auto-grants the camera; web needs the Permissions-Policy camera allowance (already set).
**Disappearing Messages (MSC1763 `m.room.retention`, 2026-07):** In Room Settings → General → **Message Retention**, an admin picks Off / 1 Day / 1 Week / 1 Month (non-admins see the buttons disabled). After setting e.g. 1 Day, messages older than a day **vanish from the timeline** for everyone in Lotus (toggle Settings → General → **Show Hidden Events** to reveal them again). Setting back to **Off** restores them. Separately, each user can enable Settings → General → **Enforce Message Retention** (default OFF) → their OWN expired messages then get **permanently redacted** within ~30 s (verify: OTHER people's messages are NEVER redacted by this; only your own). Note true server-side purge also needs Synapse `retention:` configured. **Disappearing Messages (MSC1763 `m.room.retention`, 2026-07):** In Room Settings → General → **Message Retention**, an admin picks Off / 1 Day / 1 Week / 1 Month (non-admins see the buttons disabled). After setting e.g. 1 Day, messages older than a day **vanish from the timeline** for everyone in Lotus (toggle Settings → General → **Show Hidden Events** to reveal them again). Setting back to **Off** restores them. Separately, each user can enable Settings → General → **Enforce Message Retention** (default OFF) → their OWN expired messages then get **permanently redacted** within ~30 s (verify: OTHER people's messages are NEVER redacted by this; only your own). Note true server-side purge also needs Synapse `retention:` configured.
+39 -3
View File
@@ -62,6 +62,12 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then gr
## 🔴 Open — Actionable ## 🔴 Open — Actionable
### ✅ 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).
**Fix:** `roomToUnread.ts``handleReceipt` reverts to upstream's optimistic `DELETE` on own receipt; reducer collapses `{0,0}` PUT → DELETE. `notifications.ts markAsRead` clears the marked-unread flag directly. `markedUnread.ts onReceipt` gated to main/unthreaded receipts (`myMainReceiptPresent`). Unit tests added; 700/700 pass, typecheck + build clean. Deploy + manual QA (read → dot clears & stays; thread read; mark-unread → open → clears; reconnect no resurrect).
### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED ### 🧨 Encryption / E2EE — ⚠️ EXTREME COMPLEXITY · 🧠 PLANNING SESSION REQUIRED
Observed live in prod 2026-06-30 during a 2-person **Element Call** (E2EE). These span client rust-crypto (`matrix-js-sdk@41.7.0`) ↔ Synapse ↔ EC MatrixRTC E2EE and are **interrelated** — do NOT spot-fix. **Capture first:** run **Settings → Developer Tools → Crypto Diagnostics** during the next affected call + a synapse-side trace before any fix. (Full runbook was in `LOTUS_E2EE_INVESTIGATION.md`, now in git history.) None are caused by the EC fork work. Observed live in prod 2026-06-30 during a 2-person **Element Call** (E2EE). These span client rust-crypto (`matrix-js-sdk@41.7.0`) ↔ Synapse ↔ EC MatrixRTC E2EE and are **interrelated** — do NOT spot-fix. **Capture first:** run **Settings → Developer Tools → Crypto Diagnostics** during the next affected call + a synapse-side trace before any fix. (Full runbook was in `LOTUS_E2EE_INVESTIGATION.md`, now in git history.) None are caused by the EC fork work.
@@ -105,17 +111,47 @@ Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audi
- [x] **Disappearing Messages — MSC1763 `m.room.retention`.** PL-gated room-settings `SettingTile` to set `{ max_lifetime }`; retention badge; a client-side sweep hides/self-redacts own expired events (pattern like the mute-timer restore in `ClientNonUIFeatures.tsx`). True server deletion also wants Synapse `retention:` (LXC 151). - [x] **Disappearing Messages — MSC1763 `m.room.retention`.** PL-gated room-settings `SettingTile` to set `{ max_lifetime }`; retention badge; a client-side sweep hides/self-redacts own expired events (pattern like the mute-timer restore in `ClientNonUIFeatures.tsx`). True server deletion also wants Synapse `retention:` (LXC 151).
- [x] **QR Device Verification — reciprocate QR.** Add the QR path beside emoji-SAS in `components/DeviceVerification.tsx`: render with `qrcode.react` (already a dep), scan via `BarcodeDetector` (fallback `jsQR`); uses the SDK `VerificationRequest` QR/reciprocate support. - [x] **QR Device Verification — reciprocate QR.** Add the QR path beside emoji-SAS in `components/DeviceVerification.tsx`: render with `qrcode.react` (already a dep), scan via `BarcodeDetector` (fallback `jsQR`); uses the SDK `VerificationRequest` QR/reciprocate support.
**Phase C (large — each its own planning session):** **Phase C (Room Widgets ✅ 2026-07; Sliding Sync ❌ evaluated — parked):**
- [ ] **Room Widgets — MSC1236 + widget API.** No general widget UI exists (only the PL entry `im.vector.modular.widgets`; the EC call widget is hardcoded). Read `im.vector.modular.widgets`/`m.widget` state, add an Add/Manage panel + sandboxed iframe renderer via `matrix-widget-api`**extend the existing EC widget plumbing** (`plugins/call/CallEmbed.ts`). Enables Etherpad/notes/dashboards/integrations. - [x] **Room Widgets — MSC1236 + widget API.** No general widget UI exists (only the PL entry `im.vector.modular.widgets`; the EC call widget is hardcoded). Read `im.vector.modular.widgets`/`m.widget` state, add an Add/Manage panel + sandboxed iframe renderer via `matrix-widget-api`**extend the existing EC widget plumbing** (`plugins/call/CallEmbed.ts`). Enables Etherpad/notes/dashboards/integrations.
- [ ] **Sliding Sync — MSC3575 / simplified MSC4186.** Lotus is on **legacy full `/sync`** though the server advertises `simplified_msc3575`. matrix-js-sdk ships `SlidingSync`; migration → near-instant cold start + low memory + huge-account scale. Touches the sync/room-list/spaces/unread core — behind a feature flag with a legacy fallback. **Plan separately before touching.** - **[PARKED] Sliding Sync — MSC3575 / simplified MSC4186** (evaluated 2026-07, 3 research passes). Server side is GA (`simplified_msc3575`), but the **client** side is not viable for a safe rollout: matrix-js-sdk's `SlidingSync`/`SlidingSyncSdk` are `_internal_`/`@experimental` (Element shipped labs-only, never GA in ~2 yrs, moved to the Rust SDK); **presence isn't delivered over sliding sync** (regresses Lotus presence badges/rings/status); **no upstream Cinny impl** to follow; and Cinny's whole nav (sidebar/spaces/DM/unread) is derived from the **full local room set** (`allRoomsAtom``mx.getRooms()`), so ~14 subsystems (4 core) need re-architecting to a server-windowed list. ~10% confidence a full rollout wouldn't break/regress (missing rooms/messages/unread = worst failure class). **Revisit only if we adopt the Rust SDK or accounts grow large enough that startup latency is a real complaint; an off-by-default experimental spike is possible but not recommended.** Full assessment: git plan history.
**Room Widgets v1 follow-ups:** capability-approval consent prompt (let widgets request send/read room events); Jitsi/stickerpicker special types; account-data (user/sticker) widgets; per-widget popout / always-on-screen. Requires the prod CSP `frame-src` widening (done in `matrix/cinny/nginx.conf`**`nginx -s reload`**) or external widgets are blocked.
**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).
### Remaining spec/MSC gaps (2026-07 full-surface survey)
After Phases AC the client spec is ~complete. What's left, flagged by **what unblocks it**:
**✅ Buildable NOW (client-only, no server/infra change):**
- [ ] **Custom room tags / sections** — user-defined room categories in the sidebar via standard `u.*` room tags (beyond the built-in Favourite / Low-Priority). Mirrors the favourite/low-priority category pattern (`RoomNavItem` context-menu + `Home.tsx` categories). _Medium._ The only substantive client-only feature left.
**🔧 Needs INFRASTRUCTURE (NOT a Synapse-flag flip — you'd have to stand it up):**
- **Invite by email / 3PID invite** — we invite by Matrix user-ID only (`mx.invite` is user-ID-only). Email invites need an **identity server** (lotusguild runs none). Build only if an identity server is deployed.
- QR sign-in for a new device (**MSC4108**) — needs a **rendezvous** endpoint. Dehydrated devices (**MSC3814**) — needs server support. (Also listed above.)
**🚫 BLOCKED until a Synapse upgrade enables the flag** — re-run `/_matrix/client/versions` `unstable_features` after each upgrade; client work is ready the moment the flag flips. See the **Blocked Features** section below:
- Live Location Sharing (**MSC3489** + **MSC3672** — both `false`)
- Reaction / relation redaction (**MSC3892** — `false`)
- Room preview before joining (**MSC3266** — summary endpoint 404s on 1.155)
- Thread subscriptions (**MSC4306** — `false`)
**Niche / low-value (noted, not planned):** E2EE history-key-on-invite (MSC3061), voice broadcast (MSC3888), a native account-deactivation flow (currently delegated to the OIDC provider for OIDC accounts).
**Already implemented (verified, not gaps):** space reordering (drag — confirmed working in the desktop client), pinning, stickers + picker, room directory, mutual rooms (MSC2666), blurhash, key backup / recovery / SSSS / cross-signing / key export-import, SAS **and** QR verification, ignore list, invite spam-filter, voice messages, polls, threads + per-thread notifs, spaces, OIDC, extended profiles, delayed/scheduled events, authed media, report user/room/message, 3PID contact-info display, disappearing messages, mark-unread, low-priority, room widgets.
--- ---
## 📋 Open Feature Backlog ## 📋 Open Feature Backlog
### [ ] Basic in-app audio editor / video→audio extractor (LARGE PROJECT)
A minimal audio editor for soundboard clips and voice content. Scope: (1) **trim/clip** an audio file to a chosen start/end (waveform scrubber, in/out handles); (2) **upload a video file → strip and discard the video track, keep only the audio** (extract audio, then the source video is dropped — never uploaded/stored); (3) minimal edits only (trim, maybe gain/normalize, fade in/out) — not a full DAW. Likely Web Audio API (`AudioContext.decodeAudioData` → trim `AudioBuffer` → re-encode) + `MediaRecorder`/an encoder for output; video demux via a `<video>`+`MediaElementSource` capture or ffmpeg.wasm (weigh bundle cost). Feeds the soundboard uploader (`utils/soundboardClips.ts`, `SoundboardPackEditor`) and attachments. Design under TDS + native-cinny law. Big build — plan a dedicated session; evaluate ffmpeg.wasm size/CSP (wasm) before committing.
### [ ] P4-4 · Math / LaTeX Rendering (LOW PRIORITY) ### [ ] P4-4 · Math / LaTeX Rendering (LOW PRIORITY)
Render `$…$` / `$$…$$` via KaTeX; graceful fallback to raw text. **Sanitizer must be patched**`src/app/utils/sanitize.ts` (sanitize-html, `disallowedTagsMode:'discard'`) strips all MathML: add `<math><mi><mo><mn><mrow><mfrac><msqrt><mroot><msub><msup><msubsup><munder><mover><mtable><mtr><mtd>…` + `annotation` to `permittedHtmlTags`, and `xmlns`/`display`/`mathvariant` to `permittedTagToAttributes`. Parser: split text nodes on `/(\$\$.*?\$\$|\$.*?\$)/g` in `react-custom-html-parser.tsx``<KaTeX>`. Lazy-import `katex/dist/katex.min.css` only when a math block renders. Verify KaTeX bundle-size impact. Render `$…$` / `$$…$$` via KaTeX; graceful fallback to raw text. **Sanitizer must be patched**`src/app/utils/sanitize.ts` (sanitize-html, `disallowedTagsMode:'discard'`) strips all MathML: add `<math><mi><mo><mn><mrow><mfrac><msqrt><mroot><msub><msup><msubsup><munder><mover><mtable><mtr><mtd>…` + `annotation` to `permittedHtmlTags`, and `xmlns`/`display`/`mathvariant` to `permittedTagToAttributes`. Parser: split text nodes on `/(\$\$.*?\$\$|\$.*?\$)/g` in `react-custom-html-parser.tsx``<KaTeX>`. Lazy-import `katex/dist/katex.min.css` only when a math block renders. Verify KaTeX bundle-size impact.
@@ -13,9 +13,9 @@ import {
color, color,
Spinner, Spinner,
} from 'folds'; } from 'folds';
import FileSaver from 'file-saver';
import to from 'await-to-js'; import to from 'await-to-js';
import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk'; import { AuthDict, IAuthData, MatrixError, UIAuthCallback } from 'matrix-js-sdk';
import { useSaveFile } from '../hooks/useSaveFile';
import { useModalStyle } from '../hooks/useModalStyle'; import { useModalStyle } from '../hooks/useModalStyle';
import { PasswordInput } from './password-input'; import { PasswordInput } from './password-input';
import { ContainerColor } from '../styles/ContainerColor.css'; import { ContainerColor } from '../styles/ContainerColor.css';
@@ -230,6 +230,7 @@ type RecoveryKeyDisplayProps = {
}; };
function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) { function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const [show, setShow] = useState(false); const [show, setShow] = useState(false);
const saveFile = useSaveFile();
const handleCopy = () => { const handleCopy = () => {
copyToClipboard(recoveryKey); copyToClipboard(recoveryKey);
@@ -239,7 +240,7 @@ function RecoveryKeyDisplay({ recoveryKey }: RecoveryKeyDisplayProps) {
const blob = new Blob([recoveryKey], { const blob = new Blob([recoveryKey], {
type: 'text/plain;charset=us-ascii', type: 'text/plain;charset=us-ascii',
}); });
FileSaver.saveAs(blob, 'recovery-key.txt'); saveFile(blob, 'recovery-key.txt');
}; };
const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*'); const safeToDisplayKey = show ? recoveryKey : recoveryKey.replace(/[^\s]/g, '*');
+3 -2
View File
@@ -19,7 +19,7 @@ import {
config, config,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import FileSaver from 'file-saver'; import { useSaveFile } from '../../hooks/useSaveFile';
import * as css from './PdfViewer.css'; import * as css from './PdfViewer.css';
import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { AsyncStatus } from '../../hooks/useAsyncCallback';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
@@ -36,6 +36,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
({ className, name, src, requestClose, ...props }, ref) => { ({ className, name, src, requestClose, ...props }, ref) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const [pdfJSState, loadPdfJS] = usePdfJSLoader(); const [pdfJSState, loadPdfJS] = usePdfJSLoader();
@@ -76,7 +77,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
}, [docState, pageNo, zoom]); }, [docState, pageNo, zoom]);
const handleDownload = () => { const handleDownload = () => {
FileSaver.saveAs(src, name); saveFile(src, name);
}; };
const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => { const handleJumpSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
+46 -1
View File
@@ -31,7 +31,29 @@ import { ImageViewer } from './image-viewer';
import { PdfViewer } from './Pdf-viewer'; import { PdfViewer } from './Pdf-viewer';
import { TextViewer } from './text-viewer'; import { TextViewer } from './text-viewer';
import { testMatrixTo } from '../plugins/matrix-to'; import { testMatrixTo } from '../plugins/matrix-to';
import { IImageContent } from '../../types/matrix/common'; import { IAudioContent, IFileContent, IImageContent } from '../../types/matrix/common';
// Audio is frequently sent as m.file (bridges/other clients, or when the browser
// reported a non-audio/* mime on upload). Detect that so we can play it inline
// like m.audio instead of showing only a download button.
const AUDIO_EXT_MIME: Record<string, string> = {
mp3: 'audio/mpeg',
m4a: 'audio/mp4',
aac: 'audio/aac',
oga: 'audio/ogg',
ogg: 'audio/ogg',
opus: 'audio/ogg',
wav: 'audio/wav',
flac: 'audio/flac',
weba: 'audio/webm',
};
const resolveInlineAudioMime = (content: IFileContent): string | undefined => {
const mime = content.info?.mimetype;
if (typeof mime === 'string' && mime.startsWith('audio')) return mime;
const name = content.filename ?? content.body ?? '';
const ext = name.split('.').pop()?.toLowerCase();
return ext ? AUDIO_EXT_MIME[ext] : undefined;
};
type RenderMessageContentProps = { type RenderMessageContentProps = {
displayName: string; displayName: string;
@@ -276,6 +298,29 @@ export function RenderMessageContent({
} }
if (msgType === MsgType.File) { if (msgType === MsgType.File) {
// If an m.file is actually audio, play it inline (like m.audio) instead of
// only offering a download. MAudio falls back to renderFile if playback fails.
const audioMime = resolveInlineAudioMime(getContent<IFileContent>());
if (audioMime) {
const fileContent = getContent<IFileContent>();
const audioContent = {
...fileContent,
info: { ...(fileContent.info ?? {}), mimetype: audioMime },
} as unknown as IAudioContent;
return (
<>
<MAudio
content={audioContent}
renderAsFile={renderFile}
renderAudioContent={(props) => (
<AudioContent {...props} renderMediaControl={(p) => <MediaControl {...p} />} />
)}
outlined={outlineAttachment}
/>
{renderCaption()}
</>
);
}
return renderFile(); return renderFile();
} }
@@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import FileSaver from 'file-saver';
import classNames from 'classnames'; import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds'; import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
import { useSaveFile } from '../../hooks/useSaveFile';
import * as css from './ImageViewer.css'; import * as css from './ImageViewer.css';
import { useZoom } from '../../hooks/useZoom'; import { useZoom } from '../../hooks/useZoom';
import { usePan } from '../../hooks/usePan'; import { usePan } from '../../hooks/usePan';
@@ -17,12 +17,13 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>( export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => { ({ className, alt, src, requestClose, ...props }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const saveFile = useSaveFile();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2); const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1); const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
const handleDownload = async () => { const handleDownload = async () => {
const fileContent = await downloadMedia(src); const fileContent = await downloadMedia(src);
FileSaver.saveAs(fileContent, alt); saveFile(fileContent, alt);
}; };
return ( return (
+9 -5
View File
@@ -1,8 +1,8 @@
import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds'; import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'folds';
import React, { ReactNode, useCallback } from 'react'; import React, { ReactNode, useCallback } from 'react';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FileSaver from 'file-saver';
import { mimeTypeToExt } from '../../utils/mimeTypes'; import { mimeTypeToExt } from '../../utils/mimeTypes';
import { useSaveFile } from '../../hooks/useSaveFile';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
@@ -24,6 +24,7 @@ type FileDownloadButtonProps = {
export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) { export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDownloadButtonProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const saveFile = useSaveFile();
const [downloadState, download] = useAsyncCallback( const [downloadState, download] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
@@ -34,18 +35,19 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent); const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, filename); saveFile(fileURL, filename);
return fileURL; return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, filename]), }, [mx, url, useAuthentication, mimeType, encInfo, filename, saveFile]),
); );
const downloading = downloadState.status === AsyncStatus.Loading; const downloading = downloadState.status === AsyncStatus.Loading;
const hasError = downloadState.status === AsyncStatus.Error; const hasError = downloadState.status === AsyncStatus.Error;
const succeeded = downloadState.status === AsyncStatus.Success;
return ( return (
<IconButton <IconButton
disabled={downloading} disabled={downloading}
onClick={download} onClick={download}
variant={hasError ? 'Critical' : 'SurfaceVariant'} variant={hasError ? 'Critical' : succeeded ? 'Success' : 'SurfaceVariant'}
size="300" size="300"
radii="300" radii="300"
aria-label={ aria-label={
@@ -53,13 +55,15 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow
? 'Downloading...' ? 'Downloading...'
: hasError : hasError
? 'Download failed, click to retry' ? 'Download failed, click to retry'
: succeeded
? 'Downloaded — click to download again'
: 'Download file' : 'Download file'
} }
> >
{downloading ? ( {downloading ? (
<Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} /> <Spinner size="100" variant={hasError ? 'Critical' : 'Secondary'} />
) : ( ) : (
<Icon size="100" src={Icons.Download} /> <Icon size="100" src={succeeded ? Icons.Check : Icons.Download} />
)} )}
</IconButton> </IconButton>
); );
@@ -14,10 +14,10 @@ import {
TooltipProvider, TooltipProvider,
as, as,
} from 'folds'; } from 'folds';
import FileSaver from 'file-saver';
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { IFileInfo } from '../../../../types/matrix/common'; import { IFileInfo } from '../../../../types/matrix/common';
import { useSaveFile } from '../../../hooks/useSaveFile';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { bytesToSize } from '../../../utils/common'; import { bytesToSize } from '../../../utils/common';
@@ -252,6 +252,7 @@ export type DownloadFileProps = {
export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) { export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFileProps) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
const saveFile = useSaveFile();
const [downloadState, download] = useAsyncCallback( const [downloadState, download] = useAsyncCallback(
useCallback(async () => { useCallback(async () => {
@@ -262,9 +263,9 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
: await downloadMedia(mediaUrl); : await downloadMedia(mediaUrl);
const fileURL = URL.createObjectURL(fileContent); const fileURL = URL.createObjectURL(fileContent);
FileSaver.saveAs(fileURL, body); saveFile(fileURL, body);
return fileURL; return fileURL;
}, [mx, url, useAuthentication, mimeType, encInfo, body]), }, [mx, url, useAuthentication, mimeType, encInfo, body, saveFile]),
); );
return downloadState.status === AsyncStatus.Error ? ( return downloadState.status === AsyncStatus.Error ? (
@@ -277,7 +278,7 @@ export function DownloadFile({ body, mimeType, url, info, encInfo }: DownloadFil
size="400" size="400"
onClick={() => onClick={() =>
downloadState.status === AsyncStatus.Success downloadState.status === AsyncStatus.Success
? FileSaver.saveAs(downloadState.data, body) ? saveFile(downloadState.data, body)
: download() : download()
} }
disabled={downloadState.status === AsyncStatus.Loading} disabled={downloadState.status === AsyncStatus.Loading}
@@ -1,4 +1,4 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { import {
Box, Box,
Button, Button,
@@ -21,6 +21,7 @@ import { uniqueShortcode } from '../../plugins/soundboard/utils';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { import {
getAudioDurationMs,
playClipLocally, playClipLocally,
resolveClipObjectUrl, resolveClipObjectUrl,
SOUNDBOARD_ACCEPT, SOUNDBOARD_ACCEPT,
@@ -29,6 +30,49 @@ import {
} from '../../utils/soundboardClips'; } from '../../utils/soundboardClips';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
// Injected once: the little "now playing" equalizer bars animation.
const EQ_STYLE_ID = 'lotus-soundboard-eq-keyframes';
function ensureEqKeyframes() {
if (typeof document === 'undefined' || document.getElementById(EQ_STYLE_ID)) return;
const style = document.createElement('style');
style.id = EQ_STYLE_ID;
style.textContent = `
@keyframes lotusSbEq { 0%,100% { transform: scaleY(0.3); } 50% { transform: scaleY(1); } }
@media (prefers-reduced-motion: reduce) { @keyframes lotusSbEq { 0%,100% { transform: scaleY(0.6); } } }
`;
document.head.appendChild(style);
}
function PlayingBars() {
return (
<Box alignItems="Center" gap="100" style={{ height: toRem(14) }} aria-hidden>
{[0, 1, 2].map((i) => (
<span
key={i}
style={{
display: 'inline-block',
width: toRem(3),
height: toRem(14),
borderRadius: toRem(2),
background: color.Primary.Main,
transformOrigin: 'center bottom',
animation: `lotusSbEq 0.7s ease-in-out ${i * 0.15}s infinite`,
}}
/>
))}
</Box>
);
}
// Short clip length shown while adjusting a sound: "3.2s", or "1:04" if ≥ 60s.
const formatClipSeconds = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) return '';
if (seconds < 60) return `${seconds.toFixed(1)}s`;
const m = Math.floor(seconds / 60);
const s = Math.round(seconds % 60);
return `${m}:${s.toString().padStart(2, '0')}`;
};
type ClipDraft = { type ClipDraft = {
url: string; url: string;
body: string; body: string;
@@ -59,9 +103,20 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji const [emojiFor, setEmojiFor] = useState<string>(); // shortcode currently picking an emoji
const [busyPreview, setBusyPreview] = useState<string>(); const [busyPreview, setBusyPreview] = useState<string>();
const [playingKey, setPlayingKey] = useState<string>();
const [durations, setDurations] = useState<Map<string, number>>(new Map()); // shortcode -> seconds
const audioElRef = useRef<HTMLAudioElement | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
const emojiAnchorRef = useRef<HTMLElement | null>(null); const emojiAnchorRef = useRef<HTMLElement | null>(null);
useEffect(() => {
ensureEqKeyframes();
return () => {
audioElRef.current?.pause();
audioElRef.current = null;
};
}, []);
const existing = useMemo(() => pack.getClips(), [pack]); const existing = useMemo(() => pack.getClips(), [pack]);
const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length; const clipCount = existing.filter((c) => !deleted.has(c.shortcode)).length + uploads.length;
@@ -78,19 +133,47 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
}); });
}; };
const stopPlayback = useCallback(() => {
audioElRef.current?.pause();
audioElRef.current = null;
setPlayingKey(undefined);
}, []);
const preview = useCallback( const preview = useCallback(
async (id: string, mxc: string, volume: number) => { async (id: string, mxc: string, volume: number) => {
// Clicking the clip that's already playing stops it (toggle).
if (audioElRef.current && playingKey === id) {
stopPlayback();
return;
}
stopPlayback(); // stop any other clip first
setBusyPreview(id); setBusyPreview(id);
try { try {
const url = await resolveClipObjectUrl(mx, mxc); const url = await resolveClipObjectUrl(mx, mxc);
playClipLocally(url, volume / 100); const audio = playClipLocally(url, volume / 100);
if (audio) {
audioElRef.current = audio;
setPlayingKey(id);
audio.addEventListener('loadedmetadata', () => {
if (Number.isFinite(audio.duration)) {
setDurations((prev) => new Map(prev).set(id, audio.duration));
}
});
const clear = () => {
if (audioElRef.current === audio) audioElRef.current = null;
setPlayingKey((k) => (k === id ? undefined : k));
};
audio.addEventListener('ended', clear);
audio.addEventListener('pause', clear);
audio.addEventListener('error', clear);
}
} catch { } catch {
/* ignore preview errors */ /* ignore preview errors */
} finally { } finally {
setBusyPreview(undefined); setBusyPreview(undefined);
} }
}, },
[mx], [mx, playingKey, stopPlayback],
); );
const handleFiles = useCallback( const handleFiles = useCallback(
@@ -112,6 +195,8 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
throw new Error(`"${file.name}" is too large (max 1 MB).`); throw new Error(`"${file.name}" is too large (max 1 MB).`);
} }
// eslint-disable-next-line no-await-in-loop // eslint-disable-next-line no-await-in-loop
const durationMs = await getAudioDurationMs(file);
// eslint-disable-next-line no-await-in-loop
const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' }); const res = await mx.uploadContent(file, { type: file.type || 'audio/mpeg' });
const mxc = res.content_uri; const mxc = res.content_uri;
if (!mxc) throw new Error('Upload failed.'); if (!mxc) throw new Error('Upload failed.');
@@ -126,7 +211,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
body: name, body: name,
emoji: '', emoji: '',
volume: 100, volume: 100,
info: { mimetype: file.type || undefined, size: file.size }, info: { mimetype: file.type || undefined, size: file.size, duration: durationMs },
}, },
]); ]);
} }
@@ -182,6 +267,9 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
setDraft(key, patch, base); setDraft(key, patch, base);
} }
}; };
const isPlaying = playingKey === key;
const clipSeconds =
durations.get(key) ?? (base.info?.duration != null ? base.info.duration / 1000 : undefined);
return ( return (
<Box <Box
key={key} key={key}
@@ -197,12 +285,16 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
<IconButton <IconButton
size="300" size="300"
radii="300" radii="300"
variant="Secondary" variant={isPlaying ? 'Primary' : 'Secondary'}
disabled={busyPreview === key} disabled={busyPreview === key}
onClick={() => preview(key, base.url, rowVolume)} onClick={() => preview(key, base.url, rowVolume)}
aria-label={`Preview ${rowBody}`} aria-label={isPlaying ? `Stop ${rowBody}` : `Preview ${rowBody}`}
> >
{busyPreview === key ? <Spinner size="100" /> : <Icon size="100" src={Icons.Play} />} {busyPreview === key ? (
<Spinner size="100" />
) : (
<Icon size="100" src={isPlaying ? Icons.Pause : Icons.Play} filled={isPlaying} />
)}
</IconButton> </IconButton>
<IconButton <IconButton
size="300" size="300"
@@ -227,7 +319,24 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
aria-label="Clip name" aria-label="Clip name"
/> />
</Box> </Box>
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(120) }}> <Box
alignItems="Center"
justifyContent="End"
gap="100"
shrink="No"
style={{ width: toRem(52) }}
>
{isPlaying ? (
<PlayingBars />
) : (
clipSeconds !== undefined && (
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap' }}>
{formatClipSeconds(clipSeconds)}
</Text>
)
)}
</Box>
<Box alignItems="Center" gap="100" shrink="No" style={{ width: toRem(148) }}>
<Icon size="50" src={Icons.VolumeHigh} /> <Icon size="50" src={Icons.VolumeHigh} />
<input <input
type="range" type="range"
@@ -237,9 +346,12 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
defaultValue={rowVolume} defaultValue={rowVolume}
disabled={!canEdit || markedDeleted} disabled={!canEdit || markedDeleted}
onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })} onChange={(e) => commit({ volume: parseInt(e.target.value, 10) })}
style={{ flexGrow: 1 }} style={{ flexGrow: 1, minWidth: 0 }}
aria-label="Clip volume" aria-label="Clip volume"
/> />
<Text size="T200" priority="300" style={{ width: toRem(30), textAlign: 'right' }}>
{rowVolume}%
</Text>
</Box> </Box>
{canEdit && !isUpload && ( {canEdit && !isUpload && (
<IconButton <IconButton
@@ -308,7 +420,13 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
{existing.map((c) => {existing.map((c) =>
renderRow( renderRow(
c.shortcode, c.shortcode,
{ url: c.url, body: c.body ?? c.shortcode, emoji: c.emoji ?? '', volume: c.volume }, {
url: c.url,
body: c.body ?? c.shortcode,
emoji: c.emoji ?? '',
volume: c.volume,
info: c.info,
},
false, false,
deleted.has(c.shortcode), deleted.has(c.shortcode),
), ),
+15 -2
View File
@@ -196,7 +196,8 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
aria-label={`Play ${clip.name}`} aria-label={`Play ${clip.name}`}
style={{ style={{
width: toRem(76), width: toRem(76),
height: toRem(76), minHeight: toRem(76),
height: 'auto',
padding: config.space.S100, padding: config.space.S100,
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`, border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
@@ -215,7 +216,19 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
clip.emoji || '🔊' clip.emoji || '🔊'
)} )}
</Text> </Text>
<Text size="T200" truncate style={{ maxWidth: '100%' }}> <Text
size="T200"
style={{
maxWidth: '100%',
textAlign: 'center',
wordBreak: 'break-word',
lineHeight: 1.15,
display: '-webkit-box',
WebkitLineClamp: 3,
WebkitBoxOrient: 'vertical',
overflow: 'hidden',
}}
>
{clip.name} {clip.name}
</Text> </Text>
</Box> </Box>
+37 -11
View File
@@ -7,6 +7,8 @@ import { RoomView } from './RoomView';
import { MembersDrawer } from './MembersDrawer'; import { MembersDrawer } from './MembersDrawer';
import { MediaGallery } from './MediaGallery'; import { MediaGallery } from './MediaGallery';
import { mediaGalleryAtom } from '../../state/mediaGallery'; import { mediaGalleryAtom } from '../../state/mediaGallery';
import { WidgetsPanel } from './widgets/WidgetsPanel';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useSetting } from '../../state/hooks/settings'; import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings'; import { settingsAtom } from '../../state/settings';
@@ -39,6 +41,8 @@ export function Room() {
const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId)); const setActiveThreadId = useSetAtom(roomIdToActiveThreadIdAtomFamily(room.roomId));
const galleryOpen = useAtomValue(mediaGalleryAtom); const galleryOpen = useAtomValue(mediaGalleryAtom);
const setGalleryOpen = useSetAtom(mediaGalleryAtom); const setGalleryOpen = useSetAtom(mediaGalleryAtom);
const widgetsOpen = useAtomValue(widgetsPanelAtom);
const setWidgetsOpen = useSetAtom(widgetsPanelAtom);
const [hideActivity] = useSetting(settingsAtom, 'hideActivity'); const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const screenSize = useScreenSizeContext(); const screenSize = useScreenSizeContext();
const powerLevels = usePowerLevels(room); const powerLevels = usePowerLevels(room);
@@ -64,30 +68,40 @@ export function Room() {
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0; const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
// Thread panel and media gallery are mutually exclusive on every screen size: // The content panels (thread / media gallery / widgets) are mutually exclusive
// opening one closes the other. Detect the just-opened transition so whichever // on every screen size: opening one closes the others. Detect the just-opened
// was opened most recently wins. // transition so whichever was opened most recently wins.
const prevThreadRef = useRef(activeThreadId); const prevThreadRef = useRef(activeThreadId);
const prevGalleryRef = useRef(galleryOpen); const prevGalleryRef = useRef(galleryOpen);
const prevWidgetsRef = useRef(widgetsOpen);
useEffect(() => { useEffect(() => {
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current; const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
const galleryJustOpened = galleryOpen && !prevGalleryRef.current; const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
if (threadJustOpened && galleryOpen) { const widgetsJustOpened = widgetsOpen && !prevWidgetsRef.current;
setGalleryOpen(false); if (threadJustOpened) {
} else if (galleryJustOpened && activeThreadId) { if (galleryOpen) setGalleryOpen(false);
setActiveThreadId(null); if (widgetsOpen) setWidgetsOpen(false);
} else if (galleryJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (widgetsOpen) setWidgetsOpen(false);
} else if (widgetsJustOpened) {
if (activeThreadId) setActiveThreadId(null);
if (galleryOpen) setGalleryOpen(false);
} }
prevThreadRef.current = activeThreadId; prevThreadRef.current = activeThreadId;
prevGalleryRef.current = galleryOpen; prevGalleryRef.current = galleryOpen;
}, [activeThreadId, galleryOpen, setGalleryOpen, setActiveThreadId]); prevWidgetsRef.current = widgetsOpen;
}, [activeThreadId, galleryOpen, widgetsOpen, setGalleryOpen, setActiveThreadId, setWidgetsOpen]);
// On non-desktop screens at most one right-side panel may show, priority // On non-desktop screens at most one right-side panel may show, priority
// thread > gallery > members. On desktop thread + members may coexist while // thread > gallery > widgets > members. On desktop thread + members may coexist
// thread + gallery stay mutually exclusive (via the effect above). // while the content panels stay mutually exclusive (via the effect above).
const isDesktop = screenSize === ScreenSize.Desktop; const isDesktop = screenSize === ScreenSize.Desktop;
const showThreadPanel = !callView && Boolean(activeThreadId); const showThreadPanel = !callView && Boolean(activeThreadId);
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId); const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
const showMembers = !callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen)); const showWidgets = !callView && widgetsOpen && (isDesktop || (!activeThreadId && !galleryOpen));
const showMembers =
!callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen));
return ( return (
<PowerLevelsContextProvider value={powerLevels}> <PowerLevelsContextProvider value={powerLevels}>
@@ -125,6 +139,18 @@ export function Room() {
<MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} /> <MediaGallery key={room.roomId} room={room} onClose={() => setGalleryOpen(false)} />
</> </>
)} )}
{showWidgets && (
<>
{screenSize === ScreenSize.Desktop && (
<Line variant="Background" direction="Vertical" size="300" />
)}
<WidgetsPanel
key={room.roomId}
room={room}
requestClose={() => setWidgetsOpen(false)}
/>
</>
)}
{showThreadPanel && activeThreadId && ( {showThreadPanel && activeThreadId && (
<> <>
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
+25
View File
@@ -74,6 +74,7 @@ import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc'; import { webRTCSupported } from '../../utils/rtc';
import { mediaGalleryAtom } from '../../state/mediaGallery'; import { mediaGalleryAtom } from '../../state/mediaGallery';
import { widgetsPanelAtom } from '../../state/widgetsPanel';
import { usePendingKnocks } from '../../hooks/usePendingKnocks'; import { usePendingKnocks } from '../../hooks/usePendingKnocks';
import { bookmarksPanelAtom } from '../../state/bookmarksPanel'; import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
@@ -489,6 +490,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer'); const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom); const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
const [widgetsOpen, setWidgetsOpen] = useAtom(widgetsPanelAtom);
const pendingKnocks = usePendingKnocks(room); const pendingKnocks = usePendingKnocks(room);
const handleSearchClick = () => { const handleSearchClick = () => {
@@ -725,6 +727,29 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
)} )}
</TooltipProvider> </TooltipProvider>
)} )}
{screenSize === ScreenSize.Desktop && (
<TooltipProvider
position="Bottom"
offset={4}
tooltip={
<Tooltip>
<Text>{widgetsOpen ? 'Hide Widgets' : 'Widgets'}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={() => setWidgetsOpen(!widgetsOpen)}
aria-label="Toggle widgets"
aria-pressed={widgetsOpen}
>
<Icon size="400" src={Icons.Category} filled={widgetsOpen} />
</IconButton>
)}
</TooltipProvider>
)}
{screenSize === ScreenSize.Desktop && ( {screenSize === ScreenSize.Desktop && (
<TooltipProvider <TooltipProvider
position="Bottom" position="Bottom"
@@ -0,0 +1,15 @@
import { type Capability, WidgetDriver } from 'matrix-widget-api';
import { filterWidgetCapabilities } from './widgetUtils';
// A minimal, conservative WidgetDriver for general room widgets. It only narrows
// the capabilities a widget may hold (to a benign display-only subset — see
// widgetUtils). All data-access methods (sendEvent / readRoomState / sendToDevice
// / uploads …) are inherited from the base WidgetDriver and are never reached,
// because the capabilities that would gate them are denied here. A richer,
// consent-prompt-driven driver is a follow-up.
export class GeneralWidgetDriver extends WidgetDriver {
// eslint-disable-next-line class-methods-use-this
public async validateCapabilities(requested: Set<Capability>): Promise<Set<Capability>> {
return filterWidgetCapabilities(requested);
}
}
@@ -0,0 +1,78 @@
import React, { useEffect, useRef, useState } from 'react';
import { Box, Icon, Icons, Text, color } from 'folds';
import { Room } from 'matrix-js-sdk';
import { ClientWidgetApi, Widget } from 'matrix-widget-api';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { GeneralWidgetDriver } from './GeneralWidgetDriver';
import { isWidgetUrlSafe } from './widgetUtils';
type RoomWidgetViewProps = {
room: Room;
widget: Widget;
};
// Hosts one room widget in a sandboxed iframe via ClientWidgetApi (so widgets
// that wait on the client handshake load), with a conservative capability driver.
// Re-mounts only when the widget id or its (template) URL changes — not on every
// unrelated room-state update — so viewing a widget doesn't reload constantly.
export function RoomWidgetView({ room, widget }: RoomWidgetViewProps) {
const mx = useMatrixClient();
const containerRef = useRef<HTMLDivElement>(null);
const widgetRef = useRef(widget);
widgetRef.current = widget;
const [blocked, setBlocked] = useState(false);
useEffect(() => {
const container = containerRef.current;
if (!container) return undefined;
const current = widgetRef.current;
const completeUrl = current.getCompleteUrl({
currentUserId: mx.getSafeUserId(),
widgetRoomId: room.roomId,
deviceId: mx.getDeviceId() ?? undefined,
baseUrl: mx.baseUrl,
});
// Security: never render a same-origin widget with allow-same-origin (a
// same-origin frame could break out of the sandbox against our own origin).
if (!isWidgetUrlSafe(completeUrl, window.location.origin)) {
setBlocked(true);
return undefined;
}
setBlocked(false);
const iframe = document.createElement('iframe');
iframe.title = current.name || 'Widget';
iframe.sandbox.value =
'allow-forms allow-scripts allow-same-origin allow-popups allow-downloads';
iframe.allow = 'autoplay; clipboard-write;';
iframe.src = completeUrl;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
container.append(iframe);
const clientApi = new ClientWidgetApi(current, iframe, new GeneralWidgetDriver());
clientApi.setViewedRoomId(room.roomId);
return () => {
clientApi.stop();
iframe.remove();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mx, room.roomId, widget.id, widget.templateUrl]);
if (blocked) {
return (
<Box direction="Column" grow="Yes" alignItems="Center" justifyContent="Center" gap="200">
<Icon size="400" src={Icons.Warning} style={{ color: color.Warning.Main }} />
<Text size="T300" align="Center">
This widget can&apos;t be loaded because its URL is on this app&apos;s own origin.
</Text>
</Box>
);
}
return <Box ref={containerRef} grow="Yes" style={{ height: '100%', minHeight: 0 }} />;
}
@@ -0,0 +1,25 @@
import { style } from '@vanilla-extract/css';
import { config, toRem } from 'folds';
export const WidgetsPanel = style({
width: toRem(360),
'@media': {
'(max-width: 750px)': {
position: 'fixed',
inset: 0,
width: '100%',
zIndex: 500,
},
},
});
export const WidgetsPanelHeader = style({
flexShrink: 0,
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
borderBottomWidth: config.borderWidth.B300,
});
export const WidgetsPanelContent = style({
position: 'relative',
overflow: 'hidden',
});
@@ -0,0 +1,276 @@
import React, { FormEventHandler, useState } from 'react';
import {
Box,
Button,
Header,
Icon,
IconButton,
Icons,
Input,
Scroll,
Spinner,
Text,
Tooltip,
TooltipProvider,
color,
config,
} from 'folds';
import { Room } from 'matrix-js-sdk';
import classNames from 'classnames';
import * as css from './WidgetsPanel.css';
import { ContainerColor } from '../../../styles/ContainerColor.css';
import { RoomWidgetView } from './RoomWidgetView';
import { useRoomWidgets } from './useRoomWidgets';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { StateEvent } from '../../../../types/matrix/room';
import { generateWidgetId, validateWidgetUrl, WidgetUrlError } from './widgetUtils';
const urlErrorMessage = (err: WidgetUrlError): string => {
switch (err) {
case 'empty':
return 'Enter a widget URL.';
case 'not-https':
return 'Widget URLs must use https.';
case 'same-origin':
return 'That URL is not allowed (it is on this apps own origin).';
default:
return 'That is not a valid URL.';
}
};
type WidgetsPanelProps = {
room: Room;
requestClose: () => void;
};
export function WidgetsPanel({ room, requestClose }: WidgetsPanelProps) {
const mx = useMatrixClient();
const widgets = useRoomWidgets(room);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canModify = permissions.stateEvent(StateEvent.Widget, mx.getSafeUserId());
const [viewingId, setViewingId] = useState<string | null>(null);
const [adding, setAdding] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string>();
const viewing = widgets.find((w) => w.id === viewingId) ?? null;
const handleAdd: FormEventHandler<HTMLFormElement> = async (evt) => {
evt.preventDefault();
const target = evt.target as HTMLFormElement;
const nameInput = target.elements.namedItem('widgetName') as HTMLInputElement | null;
const urlInput = target.elements.namedItem('widgetUrl') as HTMLInputElement | null;
if (!urlInput) return;
const urlErr = validateWidgetUrl(urlInput.value, window.location.origin);
if (urlErr) {
setError(urlErrorMessage(urlErr));
return;
}
setError(undefined);
setSaving(true);
const id = generateWidgetId();
const content = {
id,
type: 'm.custom',
url: urlInput.value.trim(),
name: nameInput?.value.trim() || 'Widget',
creatorUserId: mx.getSafeUserId(),
data: {},
};
try {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
await mx.sendStateEvent(room.roomId, StateEvent.Widget as any, content as any, id);
setAdding(false);
} catch (e) {
setError((e as Error).message);
} finally {
setSaving(false);
}
};
const handleRemove = (id: string) => {
if (viewingId === id) setViewingId(null);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
mx.sendStateEvent(room.roomId, StateEvent.Widget as any, {} as any, id).catch(() => undefined);
};
return (
<Box
shrink="No"
className={classNames(css.WidgetsPanel, ContainerColor({ variant: 'Surface' }))}
direction="Column"
>
<Header className={css.WidgetsPanelHeader} variant="Background" size="600">
<Box grow="Yes" alignItems="Center" gap="200">
<Box grow="Yes" direction="Column">
<Text size="H5" truncate>
Widgets
</Text>
<Text size="T200" truncate style={{ opacity: 0.65 }}>
{room.name}
</Text>
</Box>
<Box shrink="No">
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>Close</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
ref={triggerRef}
variant="Background"
aria-label="Close widgets"
onClick={requestClose}
>
<Icon src={Icons.Cross} />
</IconButton>
)}
</TooltipProvider>
</Box>
</Box>
</Header>
<Box grow="Yes" className={css.WidgetsPanelContent}>
{viewing ? (
<Box grow="Yes" direction="Column">
<Box shrink="No" style={{ padding: config.space.S200 }}>
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
onClick={() => setViewingId(null)}
before={<Icon size="100" src={Icons.ArrowLeft} />}
>
<Text size="B300" truncate>
{viewing.name || 'Widget'}
</Text>
</Button>
</Box>
<RoomWidgetView room={room} widget={viewing} />
</Box>
) : (
<Scroll hideTrack visibility="Hover">
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
{widgets.length === 0 && (
<Text size="T200" style={{ opacity: 0.65 }}>
No widgets in this room yet.
</Text>
)}
{widgets.map((widget) => (
<Box key={widget.id} alignItems="Center" gap="200">
<Box
as="button"
type="button"
grow="Yes"
alignItems="Center"
gap="200"
onClick={() => setViewingId(widget.id)}
style={{ cursor: 'pointer', minWidth: 0 }}
>
<Icon size="100" src={Icons.Category} />
<Text size="T300" truncate>
{widget.name || widget.templateUrl}
</Text>
</Box>
{canModify && (
<IconButton
size="300"
radii="300"
variant="Background"
aria-label={`Remove ${widget.name || 'widget'}`}
onClick={() => handleRemove(widget.id)}
>
<Icon size="100" src={Icons.Delete} />
</IconButton>
)}
</Box>
))}
{canModify &&
(adding ? (
<Box
as="form"
direction="Column"
gap="200"
onSubmit={handleAdd}
style={{ marginTop: config.space.S200 }}
>
<Input
name="widgetName"
placeholder="Name (optional)"
variant="Secondary"
radii="300"
/>
<Input
name="widgetUrl"
placeholder="https://…"
variant="Secondary"
radii="300"
required
/>
<Box gap="200">
<Button
type="submit"
size="300"
variant="Primary"
fill="Solid"
radii="300"
disabled={saving}
before={
saving ? <Spinner size="100" variant="Primary" fill="Solid" /> : undefined
}
>
<Text size="B300">Add</Text>
</Button>
<Button
type="button"
size="300"
variant="Secondary"
fill="Soft"
radii="300"
onClick={() => {
setAdding(false);
setError(undefined);
}}
>
<Text size="B300">Cancel</Text>
</Button>
</Box>
</Box>
) : (
<Button
variant="Secondary"
fill="Soft"
size="300"
radii="300"
onClick={() => setAdding(true)}
before={<Icon size="100" src={Icons.Plus} />}
style={{ marginTop: config.space.S200 }}
>
<Text size="B300">Add Widget</Text>
</Button>
))}
{error && (
<Text size="T200" style={{ color: color.Critical.Main }}>
{error}
</Text>
)}
</Box>
</Scroll>
)}
</Box>
</Box>
);
}
@@ -0,0 +1,21 @@
import { Room } from 'matrix-js-sdk';
import { useMemo } from 'react';
import { Widget, WidgetParser, IStateEvent } from 'matrix-widget-api';
import { StateEvent } from '../../../../types/matrix/room';
import { useRoomState } from '../../../hooks/useRoomState';
/**
* All valid `im.vector.modular.widgets` room widgets, reactive on room state.
* `WidgetParser` drops empty/removed (`{}`) and malformed entries.
*/
export const useRoomWidgets = (room: Room): Widget[] => {
const state = useRoomState(room);
return useMemo(() => {
const widgetEvents = state.get(StateEvent.Widget);
if (!widgetEvents) return [];
const stateEvents = Array.from(widgetEvents.values()).map(
(event) => event.getEffectiveEvent() as unknown as IStateEvent,
);
return WidgetParser.parseWidgetsFromRoomState(stateEvents);
}, [state]);
};
@@ -0,0 +1,49 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MatrixCapabilities, Capability } from 'matrix-widget-api';
import {
validateWidgetUrl,
isWidgetUrlSafe,
filterWidgetCapabilities,
generateWidgetId,
} from './widgetUtils';
const APP = 'https://chat.lotusguild.org';
test('validateWidgetUrl accepts a cross-origin https url', () => {
assert.equal(validateWidgetUrl('https://pad.example.org/p/room', APP), undefined);
});
test('validateWidgetUrl rejects empty / invalid / http / same-origin', () => {
assert.equal(validateWidgetUrl(' ', APP), 'empty');
assert.equal(validateWidgetUrl('not a url', APP), 'invalid');
assert.equal(validateWidgetUrl('http://example.org', APP), 'not-https');
assert.equal(validateWidgetUrl('https://chat.lotusguild.org/evil', APP), 'same-origin');
});
test('isWidgetUrlSafe rejects same-origin + garbage, accepts cross-origin', () => {
assert.equal(isWidgetUrlSafe('https://chat.lotusguild.org/x', APP), false);
assert.equal(isWidgetUrlSafe('https://other.example/x', APP), true);
assert.equal(isWidgetUrlSafe('garbage', APP), false);
});
test('filterWidgetCapabilities keeps only the benign allowlist', () => {
const requested = new Set<Capability>([
MatrixCapabilities.AlwaysOnScreen,
'm.send.event:m.room.message',
'org.matrix.msc2762.receive.state_event:m.room.member',
MatrixCapabilities.Screenshots,
]);
const allowed = filterWidgetCapabilities(requested);
assert.ok(allowed.has(MatrixCapabilities.AlwaysOnScreen));
assert.ok(allowed.has(MatrixCapabilities.Screenshots));
assert.equal(allowed.has('m.send.event:m.room.message'), false);
assert.equal(allowed.size, 2);
});
test('generateWidgetId is prefixed and unique across calls', () => {
const a = generateWidgetId();
const b = generateWidgetId();
assert.match(a, /^lotus_/);
assert.notEqual(a, b);
});
@@ -0,0 +1,45 @@
import { Capability, MatrixCapabilities } from 'matrix-widget-api';
// Conservative v1 capability policy: approve only benign display capabilities.
// Everything else (room-event send/receive, to-device, uploads, user-directory,
// delayed events, TURN servers) is denied — a random widget must not be able to
// act as the user or read room data without an explicit consent flow (follow-up).
export const ALLOWED_WIDGET_CAPABILITIES: ReadonlySet<Capability> = new Set<Capability>([
MatrixCapabilities.AlwaysOnScreen,
MatrixCapabilities.RequiresClient,
MatrixCapabilities.Screenshots,
]);
export const filterWidgetCapabilities = (requested: Set<Capability>): Set<Capability> =>
new Set([...requested].filter((cap) => ALLOWED_WIDGET_CAPABILITIES.has(cap)));
export type WidgetUrlError = 'empty' | 'invalid' | 'not-https' | 'same-origin';
// A widget URL to ADD must be https and NOT our own origin: a same-origin frame
// with allow-same-origin + allow-scripts can break out of the sandbox against us.
export const validateWidgetUrl = (raw: string, appOrigin: string): WidgetUrlError | undefined => {
const trimmed = raw.trim();
if (!trimmed) return 'empty';
let url: URL;
try {
url = new URL(trimmed);
} catch {
return 'invalid';
}
if (url.protocol !== 'https:') return 'not-https';
if (url.origin === appOrigin) return 'same-origin';
return undefined;
};
// Is an already-resolved (complete) widget URL safe to render in a sandboxed
// iframe that carries allow-same-origin? Rejects same-origin URLs (breakout).
export const isWidgetUrlSafe = (completeUrl: string, appOrigin: string): boolean => {
try {
return new URL(completeUrl).origin !== appOrigin;
} catch {
return false;
}
};
export const generateWidgetId = (): string =>
`lotus_${Date.now()}_${Math.random().toString(36).slice(2, 10)}`;
@@ -1,6 +1,6 @@
import React, { FormEventHandler, useCallback, useEffect, useState } from 'react'; import React, { FormEventHandler, useCallback, useEffect, useState } from 'react';
import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds'; import { Box, Button, color, Icon, Icons, Spinner, Text, toRem } from 'folds';
import FileSaver from 'file-saver'; import { useSaveFile } from '../../../hooks/useSaveFile';
import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCard } from '../../../components/sequence-card';
import { SettingTile } from '../../../components/setting-tile'; import { SettingTile } from '../../../components/setting-tile';
import { SequenceCardStyle } from '../styles.css'; import { SequenceCardStyle } from '../styles.css';
@@ -15,6 +15,7 @@ import { useFilePicker } from '../../../hooks/useFilePicker';
function ExportKeys() { function ExportKeys() {
const mx = useMatrixClient(); const mx = useMatrixClient();
const alive = useAlive(); const alive = useAlive();
const saveFile = useSaveFile();
const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>( const [exportState, exportKeys] = useAsyncCallback<void, Error, [string]>(
useCallback( useCallback(
@@ -28,9 +29,9 @@ function ExportKeys() {
const blob = new Blob([encKeys], { const blob = new Blob([encKeys], {
type: 'text/plain;charset=us-ascii', type: 'text/plain;charset=us-ascii',
}); });
FileSaver.saveAs(blob, 'lotus-keys.txt'); saveFile(blob, 'lotus-keys.txt');
}, },
[mx], [mx, saveFile],
), ),
); );
+11 -3
View File
@@ -171,7 +171,11 @@ function ToastCard({ toast }: ToastCardProps) {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') handleCardClick(); if (e.key === 'Enter' || e.key === ' ') handleCardClick();
}} }}
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`} aria-label={
toast.roomName
? `Notification from ${toast.displayName} in ${toast.roomName}`
: `${toast.displayName}: ${toast.body}`
}
> >
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}> <span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
<IconButton <IconButton
@@ -187,7 +191,11 @@ function ToastCard({ toast }: ToastCardProps) {
</IconButton> </IconButton>
</span> </span>
<div style={rowStyle}> <div style={rowStyle}>
{toast.avatarUrl ? ( {toast.iconSrc ? (
<div style={initialsStyle} aria-hidden="true">
<Icon size="100" src={toast.iconSrc} />
</div>
) : toast.avatarUrl ? (
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" /> <img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
) : ( ) : (
<div style={initialsStyle} aria-hidden="true"> <div style={initialsStyle} aria-hidden="true">
@@ -197,7 +205,7 @@ function ToastCard({ toast }: ToastCardProps) {
<span style={nameStyle}>{toast.displayName}</span> <span style={nameStyle}>{toast.displayName}</span>
</div> </div>
<div style={bodyStyle}>{toast.body}</div> <div style={bodyStyle}>{toast.body}</div>
<div style={roomNameStyle}>{toast.roomName}</div> {toast.roomName && <div style={roomNameStyle}>{toast.roomName}</div>}
</div> </div>
); );
} }
+24
View File
@@ -0,0 +1,24 @@
import { useCallback } from 'react';
import { useSetAtom } from 'jotai';
import { Icons } from 'folds';
import FileSaver from 'file-saver';
import { createDownloadToast, toastQueueAtom } from '../state/toast';
/**
* Save a blob/URL to disk AND surface a "Downloaded <filename>" toast.
*
* The desktop (Tauri) app has no native download UI, so `FileSaver.saveAs` saved
* files silently — users re-clicked because nothing confirmed success. This gives
* uniform, visible feedback across web + desktop for every download call site.
*/
export const useSaveFile = () => {
const setToast = useSetAtom(toastQueueAtom);
return useCallback(
(data: Blob | string, filename: string) => {
FileSaver.saveAs(data, filename);
setToast(createDownloadToast(filename, Icons.Check));
},
[setToast],
);
};
+23 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { MatrixEvent } from 'matrix-js-sdk'; import { MatrixEvent } from 'matrix-js-sdk';
import { receiptIsMine, setMarkedUnread } from './markedUnread'; import { myMainReceiptPresent, receiptIsMine, setMarkedUnread } from './markedUnread';
// MSC2867 mark-as-unread: reading a room (our own receipt) clears the flag, so // MSC2867 mark-as-unread: reading a room (our own receipt) clears the flag, so
// `receiptIsMine` must detect only OUR receipt and ignore others'. And a write // `receiptIsMine` must detect only OUR receipt and ignore others'. And a write
@@ -33,6 +33,28 @@ test('receiptIsMine: tolerates empty / malformed content', () => {
assert.equal(receiptIsMine(receiptEvent({ $x: {} }), ME), false); assert.equal(receiptIsMine(receiptEvent({ $x: {} }), ME), false);
}); });
// myMainReceiptPresent gates the auto-clear to main-timeline reads, so reading a
// single thread does not wipe the whole-room marked-unread flag.
test('myMainReceiptPresent: true for an unthreaded receipt (no thread_id)', () => {
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1 } } } });
assert.equal(myMainReceiptPresent(event, ME), true);
});
test('myMainReceiptPresent: true for a thread_id "main" receipt', () => {
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1, thread_id: 'main' } } } });
assert.equal(myMainReceiptPresent(event, ME), true);
});
test('myMainReceiptPresent: false for a thread-scoped receipt', () => {
const event = receiptEvent({ $abc: { 'm.read': { [ME]: { ts: 1, thread_id: '$root:server' } } } });
assert.equal(myMainReceiptPresent(event, ME), false);
});
test('myMainReceiptPresent: false when only another user has a main receipt', () => {
const event = receiptEvent({ $abc: { 'm.read': { [OTHER]: { ts: 1 } } } });
assert.equal(myMainReceiptPresent(event, ME), false);
});
test('setMarkedUnread writes both the stable and unstable keys with the flag', async () => { test('setMarkedUnread writes both the stable and unstable keys with the flag', async () => {
const calls: Array<{ type: string; content: unknown }> = []; const calls: Array<{ type: string; content: unknown }> = [];
const mx = { const mx = {
+23 -4
View File
@@ -9,7 +9,7 @@ import { AccountDataEvent } from '../../../types/matrix/accountData';
// flag round-trips across the ecosystem. // flag round-trips across the ecosystem.
const UNSTABLE_MARKED_UNREAD = 'com.famedly.marked_unread'; const UNSTABLE_MARKED_UNREAD = 'com.famedly.marked_unread';
const readMarkedUnread = (room: Room): boolean => { export const readMarkedUnread = (room: Room): boolean => {
const stable = room.getAccountData(AccountDataEvent.MarkedUnread)?.getContent()?.unread; const stable = room.getAccountData(AccountDataEvent.MarkedUnread)?.getContent()?.unread;
if (typeof stable === 'boolean') return stable; if (typeof stable === 'boolean') return stable;
return room.getAccountData(UNSTABLE_MARKED_UNREAD)?.getContent()?.unread === true; return room.getAccountData(UNSTABLE_MARKED_UNREAD)?.getContent()?.unread === true;
@@ -41,6 +41,22 @@ export const receiptIsMine = (event: MatrixEvent, userId: string): boolean => {
); );
}; };
// True only when OUR receipt in this event is for the main timeline — either
// unthreaded (no thread_id) or thread_id "main". A receipt scoped to a specific
// thread (thread_id === <threadRootId>) must NOT clear the whole-room marked
// flag, since only that one thread was read.
export const myMainReceiptPresent = (event: MatrixEvent, userId: string): boolean => {
const content = event.getContent();
return Object.keys(content).some((eventId) =>
Object.keys(content[eventId] ?? {}).some((receiptType) => {
const receipt = content[eventId][receiptType]?.[userId];
if (!receipt) return false;
const threadId = (receipt as { thread_id?: string }).thread_id;
return threadId === undefined || threadId === 'main';
}),
);
};
export const useBindMarkedUnreadAtom = (mx: MatrixClient, anAtom: typeof markedUnreadAtom) => { export const useBindMarkedUnreadAtom = (mx: MatrixClient, anAtom: typeof markedUnreadAtom) => {
const setAtom = useSetAtom(anAtom); const setAtom = useSetAtom(anAtom);
@@ -65,12 +81,15 @@ export const useBindMarkedUnreadAtom = (mx: MatrixClient, anAtom: typeof markedU
const onAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (_event, room) => { const onAccountData: RoomEventHandlerMap[RoomEvent.AccountData] = (_event, room) => {
syncRoom(room); syncRoom(room);
}; };
// Reading a room clears its marked-unread flag (MSC2867): when our own read // Reading a room clears its marked-unread flag (MSC2867): when our own
// receipt lands for a room that's currently marked, clear it. // MAIN-timeline read receipt lands for a room that's currently marked, clear
// it. Gated to main/unthreaded receipts so reading a single thread doesn't
// wipe the whole-room flag. (This also fires for receipts from our other
// devices; the local read path clears via markAsRead in notifications.ts.)
const onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, room) => { const onReceipt: RoomEventHandlerMap[RoomEvent.Receipt] = (event, room) => {
const myId = mx.getUserId(); const myId = mx.getUserId();
if (!myId || !readMarkedUnread(room)) return; if (!myId || !readMarkedUnread(room)) return;
if (receiptIsMine(event, myId)) { if (myMainReceiptPresent(event, myId)) {
setMarkedUnread(mx, room.roomId, false).catch(() => undefined); setMarkedUnread(mx, room.roomId, false).catch(() => undefined);
} }
}; };
+17
View File
@@ -116,6 +116,23 @@ test('PUT with unchanged counts is skipped (same map reference)', () => {
assert.equal(before, after); assert.equal(before, after);
}); });
test('PUT of { total: 0, highlight: 0 } removes the room (collapses to DELETE)', () => {
const store = createStore();
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 3, 1) });
// A phantom zero-count PUT (e.g. UnreadNotifications after the server zeroes
// counts) must clear the entry, not leave a stuck dot.
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 0, 0) });
assert.equal(get(store).has('!r:s'), false);
});
test('PUT of { 0, 0 } on an absent room is a no-op (same map reference)', () => {
const store = createStore();
const before = get(store);
store.set(roomToUnreadAtom, { type: 'PUT', unreadInfo: info('!r:s', 0, 0) });
assert.equal(before, get(store));
assert.equal(get(store).has('!r:s'), false);
});
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// roomToUnreadAtom: PUT with parent aggregation // roomToUnreadAtom: PUT with parent aggregation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+30 -14
View File
@@ -24,7 +24,6 @@ import {
getUnreadInfo, getUnreadInfo,
getUnreadInfos, getUnreadInfos,
isNotificationEvent, isNotificationEvent,
roomHaveUnread,
} from '../../utils/room'; } from '../../utils/room';
import { roomToParentsAtom } from './roomToParents'; import { roomToParentsAtom } from './roomToParents';
import { useStateEventCallback } from '../../hooks/useStateEventCallback'; import { useStateEventCallback } from '../../hooks/useStateEventCallback';
@@ -139,6 +138,27 @@ export const roomToUnreadAtom = atom<RoomToUnread, [RoomToUnreadAction], undefin
} }
if (action.type === 'PUT') { if (action.type === 'PUT') {
const { unreadInfo } = action; const { unreadInfo } = action;
// A { total: 0, highlight: 0 } entry is still a *present* map key, and the
// nav dot lights on any present entry — so a phantom zero-count PUT (e.g.
// the UnreadNotifications listener firing once the server zeroes counts)
// would leave a stuck dot. Collapse it to a DELETE so a fully-read room
// actually clears. Done before the unreadEqual short-circuit so an
// already-stuck { 0, 0 } gets removed too.
if (unreadInfo.total === 0 && unreadInfo.highlight === 0) {
if (get(baseRoomToUnread).has(unreadInfo.roomId)) {
set(
baseRoomToUnread,
produce(get(baseRoomToUnread), (draftRoomToUnread) =>
deleteUnreadInfo(
draftRoomToUnread,
getAllParents(get(roomToParentsAtom), unreadInfo.roomId),
unreadInfo.roomId,
),
),
);
}
return;
}
const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId); const currentUnread = get(baseRoomToUnread).get(unreadInfo.roomId);
if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) { if (currentUnread && unreadEqual(currentUnread, unreadInfoToUnread(unreadInfo))) {
// Do not update if unread data has not changes // Do not update if unread data has not changes
@@ -256,20 +276,16 @@ export const useBindRoomToUnreadAtom = (mx: MatrixClient, unreadAtom: typeof roo
), ),
); );
if (isMyReceipt) { if (isMyReceipt) {
// Don't blanket-DELETE the room's unread on any receipt: a THREADED // Optimistically clear on our own receipt (upstream cinny behavior).
// receipt (reading one thread) would wipe the room's still-valid // Do NOT recompute from getUnreadInfo here: getUnreadNotificationCount is
// main-timeline badge, and if the room was already read no // server-computed and STALE on the synchronous synthetic receipt echo
// UnreadNotifications PUT follows to restore it. Recompute instead — // (the SDK only zeroes it immediately when the last live event is our own
// DELETE only when the room is genuinely fully read. // message), so recomputing PUTs the stale non-zero count back → the dot
const info = getUnreadInfo( // sticks / resurrects. The RoomEvent.UnreadNotifications listener below
room, // re-asserts the accurate badge (incl. restoring the main badge after a
getMutedThreads(threadNotificationsRef.current, room.roomId), // thread read) once the server acks, and a { 0, 0 } PUT collapses to a
); // DELETE in the reducer.
if (info.total === 0 && info.highlight === 0 && !roomHaveUnread(mx, room)) {
setUnreadAtom({ type: 'DELETE', roomId: room.roomId }); setUnreadAtom({ type: 'DELETE', roomId: room.roomId });
} else {
setUnreadAtom({ type: 'PUT', unreadInfo: info });
}
} }
}; };
mx.on(RoomEvent.Receipt, handleReceipt); mx.on(RoomEvent.Receipt, handleReceipt);
+13 -1
View File
@@ -1,7 +1,7 @@
import { test } from 'node:test'; import { test } from 'node:test';
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { createStore } from 'jotai'; import { createStore } from 'jotai';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from './toast'; import { toastQueueAtom, dismissToastAtom, ToastNotif, createDownloadToast } from './toast';
// The queue lives in an unexported baseAtom; we drive the two write-only setters // The queue lives in an unexported baseAtom; we drive the two write-only setters
// (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id) // (toastQueueAtom append + null no-op guard, dismissToastAtom remove-by-id)
@@ -85,3 +85,15 @@ test('dismissToastAtom for an unknown id is a no-op', () => {
['a'], ['a'],
); );
}); });
test('createDownloadToast: filename in body, no room navigation, unique ids', () => {
const a = createDownloadToast('photo.jpg');
assert.equal(a.displayName, 'Downloaded');
assert.equal(a.body, 'photo.jpg');
// roomId empty + an onClick present → clicking dismisses without navigating to a room.
assert.equal(a.roomId, '');
assert.equal(a.roomName, '');
assert.equal(typeof a.onClick, 'function');
const b = createDownloadToast('photo.jpg');
assert.notEqual(a.id, b.id);
});
+15
View File
@@ -1,8 +1,10 @@
import { atom } from 'jotai'; import { atom } from 'jotai';
import type { IconSrc } from 'folds';
export type ToastNotif = { export type ToastNotif = {
id: string; id: string;
avatarUrl?: string; avatarUrl?: string;
iconSrc?: IconSrc; // folds Icon src for a "system" toast (shown instead of an avatar/initials)
displayName: string; displayName: string;
body: string; body: string;
roomName: string; roomName: string;
@@ -12,6 +14,19 @@ export type ToastNotif = {
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
}; };
// Build a "download complete" system toast. Kept folds-free here (the icon src is
// passed in) so this stays a pure, testable builder. roomId is empty + onClick is
// set so a click only dismisses (never navigates to a room).
export const createDownloadToast = (filename: string, iconSrc?: IconSrc): ToastNotif => ({
id: `download-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
displayName: 'Downloaded',
body: filename,
roomName: '',
roomId: '',
iconSrc,
onClick: () => undefined,
});
const baseAtom = atom<ToastNotif[]>([]); const baseAtom = atom<ToastNotif[]>([]);
// Write-only setter used in ClientNonUIFeatures // Write-only setter used in ClientNonUIFeatures
+4
View File
@@ -0,0 +1,4 @@
import { atom } from 'jotai';
// Whether the room's Widgets side-panel is open (mirrors mediaGalleryAtom).
export const widgetsPanelAtom = atom<boolean>(false);
+32 -1
View File
@@ -20,16 +20,22 @@ type RoomOpts = {
readUpTo?: string | null; readUpTo?: string | null;
threads?: any[]; threads?: any[];
threadUnread?: Record<string, number>; threadUnread?: Record<string, number>;
markedUnread?: boolean;
}; };
const setup = (opts: RoomOpts) => { const setup = (opts: RoomOpts) => {
const calls: ReceiptCall[] = []; const calls: ReceiptCall[] = [];
const accountDataWrites: Array<{ type: string; content: any }> = [];
const room = { const room = {
getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }), getLiveTimeline: () => ({ getEvents: () => opts.timeline ?? [] }),
getEventReadUpTo: () => opts.readUpTo ?? null, getEventReadUpTo: () => opts.readUpTo ?? null,
getThreads: () => opts.threads ?? [], getThreads: () => opts.threads ?? [],
getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) => getThreadUnreadNotificationCount: (threadId: string, _type: NotificationCountType) =>
opts.threadUnread?.[threadId] ?? 0, opts.threadUnread?.[threadId] ?? 0,
getAccountData: (type: string) =>
opts.markedUnread && type === 'm.marked_unread'
? { getContent: () => ({ unread: true }) }
: undefined,
}; };
const mx = { const mx = {
getRoom: () => room, getRoom: () => room,
@@ -38,8 +44,12 @@ const setup = (opts: RoomOpts) => {
calls.push({ eventId: event.getId(), receiptType, unthreaded }); calls.push({ eventId: event.getId(), receiptType, unthreaded });
return {}; return {};
}, },
setRoomAccountData: async (_roomId: string, type: string, content: any) => {
accountDataWrites.push({ type, content });
return {};
},
} as any; } as any;
return { mx, calls }; return { mx, calls, accountDataWrites };
}; };
test('main timeline: unthreaded receipt at the latest event', async () => { test('main timeline: unthreaded receipt at the latest event', async () => {
@@ -107,6 +117,27 @@ test('everything read: no receipts sent', async () => {
assert.equal(calls.length, 0); assert.equal(calls.length, 0);
}); });
test('marked-unread + already fully read: clears the flag even though no receipt is sent', async () => {
const { mx, calls, accountDataWrites } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'b', // nothing newer → no receipt
markedUnread: true,
});
await markAsRead(mx, '!r:server', false);
assert.equal(calls.length, 0); // no receipt (the stuck-dot case)
// ...but the marked-unread flag is cleared directly (both keys, unread:false)
assert.ok(accountDataWrites.some((w) => w.type === 'm.marked_unread' && w.content.unread === false));
});
test('not marked-unread: markAsRead does not touch account data', async () => {
const { mx, accountDataWrites } = setup({
timeline: [evt('a'), evt('b')],
readUpTo: 'a',
});
await markAsRead(mx, '!r:server', false);
assert.equal(accountDataWrites.length, 0);
});
test('sending thread reply is skipped', async () => { test('sending thread reply is skipped', async () => {
const t = thread('$root', evt('$reply', true)); // isSending → skip const t = thread('$root', evt('$reply', true)); // isSending → skip
const { mx, calls } = setup({ const { mx, calls } = setup({
+8
View File
@@ -1,11 +1,19 @@
import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk'; import { MatrixClient, NotificationCountType, ReceiptType } from 'matrix-js-sdk';
import { getSettings } from '../state/settings'; import { getSettings } from '../state/settings';
import { readMarkedUnread, setMarkedUnread } from '../state/room/markedUnread';
export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) { export async function markAsRead(mx: MatrixClient, roomId: string, privateReceipt: boolean) {
const { privateReadReceipts } = getSettings(); const { privateReadReceipts } = getSettings();
const room = mx.getRoom(roomId); const room = mx.getRoom(roomId);
if (!room) return; if (!room) return;
// Reading a room clears an explicit "mark as unread" (MSC2867). The binder's
// receipt-driven auto-clear does NOT fire when the room is already fully read
// (no receipt is sent below in that case), so clear it directly here.
if (readMarkedUnread(room)) {
setMarkedUnread(mx, roomId, false).catch(() => undefined);
}
const receiptType = const receiptType =
privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read; privateReceipt || privateReadReceipts ? ReceiptType.ReadPrivate : ReceiptType.Read;
+17
View File
@@ -53,3 +53,20 @@ export const playClipLocally = (
return undefined; return undefined;
} }
}; };
/** Read an audio file's duration in milliseconds from its metadata (no playback). */
export const getAudioDurationMs = (file: Blob): Promise<number | undefined> =>
new Promise((resolve) => {
const url = URL.createObjectURL(file);
const audio = new Audio();
audio.preload = 'metadata';
const done = (ms: number | undefined) => {
URL.revokeObjectURL(url);
resolve(ms);
};
audio.addEventListener('loadedmetadata', () =>
done(Number.isFinite(audio.duration) ? Math.round(audio.duration * 1000) : undefined),
);
audio.addEventListener('error', () => done(undefined));
audio.src = url;
});
+3
View File
@@ -27,6 +27,9 @@ export enum StateEvent {
RoomTopic = 'm.room.topic', RoomTopic = 'm.room.topic',
RoomAvatar = 'm.room.avatar', RoomAvatar = 'm.room.avatar',
RoomPinnedEvents = 'm.room.pinned_events', RoomPinnedEvents = 'm.room.pinned_events',
// [MSC1236] Room widgets (embedded apps). One state event per widget,
// state_key = widget id; content is a matrix-widget-api IWidget.
Widget = 'im.vector.modular.widgets',
RoomEncryption = 'm.room.encryption', RoomEncryption = 'm.room.encryption',
RoomHistoryVisibility = 'm.room.history_visibility', RoomHistoryVisibility = 'm.room.history_visibility',
// [MSC1763] Per-room message retention policy (disappearing messages). // [MSC1763] Per-room message retention policy (disappearing messages).