Compare commits
136
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aef410f60 | ||
|
|
b2376513fd | ||
|
|
2bbd390a3b | ||
|
|
d47032a14f | ||
|
|
b2678d5c6d | ||
|
|
1176bea0ee | ||
|
|
477df4ae32 | ||
|
|
15d85f52c4 | ||
|
|
0ddf86c678 | ||
|
|
bd5f6a0855 | ||
|
|
5175c095b7 | ||
|
|
99629edd9c | ||
|
|
8a461610f4 | ||
|
|
53a2f738a9 | ||
|
|
08e191008b | ||
|
|
654466cf45 | ||
|
|
3ff8fb8e55 | ||
|
|
02089cf60e | ||
|
|
fff811cb2d | ||
|
|
f54c386f36 | ||
|
|
6dc0865965 | ||
|
|
5656162720 | ||
|
|
c6d558e5dd | ||
|
|
d416c62b4c | ||
|
|
a24c98b199 | ||
|
|
8fbde6df36 | ||
|
|
1963222d1e | ||
|
|
d07f16586a | ||
|
|
2c0cd0d26c | ||
|
|
b0a3c81b15 | ||
|
|
c9d9d91415 | ||
|
|
29ff16546a | ||
|
|
e1bb8301f0 | ||
|
|
098e3c900f | ||
|
|
6cbd7337f4 | ||
|
|
4154cae55a | ||
|
|
a5cc8a6d77 | ||
|
|
59ec42564d | ||
|
|
ef82650cf7 | ||
|
|
8e02cef658 | ||
|
|
bc608b377a | ||
|
|
f2673effe4 | ||
|
|
f03c0ef960 | ||
|
|
8cc8dfd796 | ||
|
|
386a297997 | ||
|
|
36369926ca | ||
|
|
015495c77d | ||
|
|
a267e9e960 | ||
|
|
291e14ab48 | ||
|
|
72e7447d28 | ||
|
|
37d647d931 | ||
|
|
c3e1fbfff5 | ||
|
|
8a1168bc5f | ||
|
|
1a3b1310b4 | ||
|
|
09f37f890f | ||
|
|
154e35ef9f | ||
|
|
36fdbdd399 | ||
|
|
09415f95c0 | ||
|
|
4c298a36b4 | ||
|
|
836e4a6679 | ||
|
|
d615999737 | ||
|
|
dcfee9f1df | ||
|
|
ab01d27aa7 | ||
|
|
3e1106b2d9 | ||
|
|
1b8f554584 | ||
|
|
4708a17961 | ||
|
|
8a15405189 | ||
|
|
fd3b8b421e | ||
|
|
1f80d1d129 | ||
|
|
726cefb5ab | ||
|
|
7c28ba58b2 | ||
|
|
c77ab346d3 | ||
|
|
ecb7b1a7fb | ||
|
|
539901ec64 | ||
|
|
0ce5e763ad | ||
|
|
a8c99f2a45 | ||
|
|
a4660a8163 | ||
|
|
85ac8de5d9 | ||
|
|
d727e7a7ab | ||
|
|
3a1c626bc8 | ||
|
|
fb8e0c6e14 | ||
|
|
360e72f73c | ||
|
|
1aaea09fc9 | ||
|
|
5cce94edba | ||
|
|
f155a4dc22 | ||
|
|
61f1733f50 | ||
|
|
e7e6d44a31 | ||
|
|
829525506c | ||
|
|
dcad282749 | ||
|
|
4d78d427d2 | ||
|
|
12aa49f054 | ||
|
|
df8afe4410 | ||
|
|
ea6e89ae56 | ||
|
|
75b94bd39f | ||
|
|
2d8bc487e5 | ||
|
|
cf4ee6618c | ||
|
|
23950a6797 | ||
|
|
a1107015fd | ||
|
|
4a3a80df9f | ||
|
|
7d67cdeef0 | ||
|
|
b6725a6ee3 | ||
|
|
23e264d179 | ||
|
|
e01b87b214 | ||
|
|
f9af363dd6 | ||
|
|
8a92821a48 | ||
|
|
68a88e84b8 | ||
|
|
82eb65b822 | ||
|
|
708a15d196 | ||
|
|
120ad2d1b5 | ||
|
|
b38df58b68 | ||
|
|
13304f1571 | ||
|
|
87b1c1a702 | ||
|
|
125af8446f | ||
|
|
9a4796c167 | ||
|
|
5b58e5fe43 | ||
|
|
eaf6853910 | ||
|
|
c44ef8d795 | ||
|
|
90e6901a60 | ||
|
|
961789fd71 | ||
|
|
b6413d763d | ||
|
|
d6d1f5a233 | ||
|
|
57f21e5cac | ||
|
|
629db9724f | ||
|
|
a739c25f10 | ||
|
|
53ce2e40a4 | ||
|
|
398e743cdd | ||
|
|
d0614710b0 | ||
|
|
39e75f4eea | ||
|
|
cb3cd30ab5 | ||
|
|
cacefb1f30 | ||
|
|
5d5ae0ee70 | ||
|
|
7d02f4e538 | ||
|
|
56863d2649 | ||
|
|
2d0c804abc | ||
|
|
28cb004e80 | ||
|
|
0b06158477 |
+24
-6
@@ -6,6 +6,18 @@ on:
|
||||
pull_request:
|
||||
branches: [lotus]
|
||||
|
||||
# Only the newest commit per ref needs to build: a superseded push cancels its
|
||||
# in-flight run. This keeps the shared act_runner free (web CI otherwise queues
|
||||
# behind long Tauri desktop builds) and — since `trigger-desktop` is `needs:
|
||||
# build` — means only the latest lotus commit ever kicks a desktop build,
|
||||
# instead of one per rapid push. Cancelling a superseded run is deploy-safe
|
||||
# ONLY because lotus_deploy.sh re-resolves origin/lotus each poll iteration and
|
||||
# retargets its CI gate to HEAD — otherwise a run cancelled mid-poll would
|
||||
# strand the newest commit undeployed. Keep those two in sync.
|
||||
concurrency:
|
||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build & Quality Checks
|
||||
@@ -53,26 +65,32 @@ jobs:
|
||||
- name: Unit tests
|
||||
run: npm test
|
||||
|
||||
# ── Quality checks (informational — pre-existing issues exist) ───────
|
||||
# ── Quality gates (hard — a failure fails the job and blocks deploy) ──
|
||||
# The tree is held clean (typecheck 0, eslint 0 errors, prettier
|
||||
# formatted), so these gate real regressions instead of relying on local
|
||||
# runs. NOTE: an upstream-stable merge (the lotus-build.sh path) could
|
||||
# introduce upstream type/lint/format issues; that path deploys without
|
||||
# CI, but a subsequent normal push would surface the failure here — fix
|
||||
# forward (or briefly re-soften a gate) rather than let it deploy broken.
|
||||
# eslint gates on errors only (existing `no-explicit-any` warnings stay
|
||||
# informational — `check:eslint` has no --max-warnings).
|
||||
- name: TypeScript
|
||||
run: npm run typecheck
|
||||
continue-on-error: true
|
||||
|
||||
- name: ESLint
|
||||
run: npm run check:eslint
|
||||
continue-on-error: true
|
||||
|
||||
- name: Prettier
|
||||
run: npm run check:prettier
|
||||
continue-on-error: true
|
||||
|
||||
# ── Security ─────────────────────────────────────────────────────────
|
||||
# ── Security (informational — findings shouldn't block a deploy) ─────
|
||||
- name: Audit (high/critical)
|
||||
run: npm audit --audit-level=high --omit=dev
|
||||
continue-on-error: true
|
||||
|
||||
# ── Bundle size report ───────────────────────────────────────────────
|
||||
# ── Bundle size report (informational — never blocks a deploy) ───────
|
||||
- name: Report bundle sizes
|
||||
continue-on-error: true
|
||||
run: |
|
||||
echo "### Bundle sizes" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
+140
-31
@@ -1,7 +1,7 @@
|
||||
# Lotus Chat — Feature Reference
|
||||
|
||||
Everything added to Lotus Chat beyond upstream Cinny v4.12.1.
|
||||
Last updated: June 2026.
|
||||
Last updated: July 2026.
|
||||
|
||||
---
|
||||
|
||||
@@ -294,6 +294,7 @@ A warm orange overlay rendered over the entire UI to reduce blue light emission.
|
||||
- CSS: `position: fixed; inset: 0; pointer-events: none; z-index: 9998`
|
||||
- Orange tint color with configurable opacity
|
||||
- **Controls:** Toggle to enable/disable + intensity slider ranging from 5% to 80% opacity
|
||||
- **Schedule (auto on at night):** an optional schedule with From/To time inputs; when on, the overlay only shows during that window and turns itself on/off automatically (the overlay re-checks every minute, no reload). Overnight windows that wrap midnight (e.g. 21:00 → 07:00) are handled. Window logic is the pure, unit-tested `isWithinTimeWindow`/`parseHHMM` in `src/app/utils/timeWindow.ts` (`timeWindow.test.ts`). Defaults: 21:00–07:00.
|
||||
- Settings persisted via the standard Lotus settings store
|
||||
|
||||
---
|
||||
@@ -660,6 +661,7 @@ The indicator is hidden once the server confirms the event (when the internal st
|
||||
|
||||
- API: `GET /_matrix/client/v1/rooms/{roomId}/relations/{eventId}/m.replace`
|
||||
- E2EE fix: the "Original" entry uses `getClearContent()` to retrieve the decrypted content rather than the encrypted payload
|
||||
- **Word-level diff**: a "Highlight changes" toggle (on by default) renders each edit as a word diff against the previous version — added words highlighted (green), removed words struck-through (red) — using semantic `<ins>`/`<del>`. Toggle off to see full text (formatted messages render rich there; the diff is plain-text only). Diff logic is the pure, unit-tested `diffWords` (LCS) in `src/app/utils/textDiff.ts`.
|
||||
- Accessible from the message context menu
|
||||
|
||||
### Inline GIF Preview
|
||||
@@ -673,18 +675,70 @@ The indicator is hidden once the server confirms the event (when the internal st
|
||||
- Giphy-powered picker accessible from the composer toolbar
|
||||
- The button is only shown when `gifApiKey` is set in `config.json`
|
||||
- Selected GIFs are sent as `m.image` events
|
||||
- **Recently used**: a "Recent" row at the top of the picker (shown on the default view, hidden while searching) surfaces the GIFs you last sent for one-click re-sending — no re-searching. Persisted in localStorage (`cinny_recent_gifs_v1`), deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentGif` (`src/app/state/recentGifs.ts`).
|
||||
- Picker UI is styled with TDS variables when the TDS theme is active
|
||||
- Located at `src/app/components/GifPicker.tsx`
|
||||
|
||||
### Sticker Picker — Recently used
|
||||
|
||||
The sticker tab of the shared `EmojiBoard` now has a **"Recent" group** (a sidebar
|
||||
`RecentClock` icon + top group), matching the emoji and GIF pickers — the stickers you last sent
|
||||
surface for one-click re-sending instead of hunting through packs. Only shown once you've sent at
|
||||
least one sticker (hidden otherwise). Persisted in localStorage (`cinny_recent_stickers_v1`),
|
||||
deduped by url, most-recent-first, capped at 16, via the pure/unit-tested `addRecentSticker`
|
||||
(`src/app/state/recentStickers.ts`). Recent entries are rebuilt into minimal `PackImageReader`s
|
||||
(`StickerItem` only needs `url`/`shortcode`/`body`) so they render and re-send exactly like pack
|
||||
stickers. Recorded on select for both the grouped and search paths (shared delegated click).
|
||||
|
||||
### Message Forwarding
|
||||
|
||||
Context menu → **Forward** allows forwarding a message to any room the user is a member of.
|
||||
|
||||
### Copy Message Text
|
||||
|
||||
Context menu → **Copy Text** copies a message's plain-text body to the clipboard (reply fallback stripped via `trimReplyFromBody`), complementing the existing **Copy Link** (permalink) action. It renders only when the event has a usable text body, so media without a caption doesn't show an empty action.
|
||||
|
||||
### On-Device Message Translation
|
||||
|
||||
Translate chat messages written in other languages into a language you choose,
|
||||
inline in the timeline — running **entirely on your device** so message text
|
||||
never leaves it.
|
||||
|
||||
- **Per-message translate** — a foreign-language message shows a **Translate**
|
||||
action in its message menu; once translated, the message displays an inline
|
||||
**"Translated from <language> · Show original"** toggle that swaps between
|
||||
the translation and the original text.
|
||||
- **Fully on-device / E2EE-preserving** — translation and language detection run
|
||||
through the browser's built-in **Translator** and **Language Detector** APIs
|
||||
(the Chromium on-device AI translation models). Message text is **never** sent
|
||||
to any cloud translation service — no Google / DeepL / Microsoft, not even a
|
||||
self-hosted server — and there is **no network fallback**, so end-to-end
|
||||
encryption is preserved. That privacy guarantee is the whole point of the
|
||||
feature.
|
||||
- **Automatic detection** — the language of each message is detected
|
||||
automatically; messages already in your target language are skipped (no
|
||||
Translate action is shown).
|
||||
- **Settings (Settings → General → Messages):**
|
||||
- **Translate Messages Into** — your target language (default **English**;
|
||||
~26 common languages).
|
||||
- **Auto-translate Incoming Messages** (default **off**) — automatically
|
||||
translates foreign-language messages whose on-device language model is
|
||||
already downloaded.
|
||||
- **One-time model download** — the first time you translate from a given
|
||||
language, a small on-device model (a few MB) downloads once. Because the
|
||||
browser requires a user gesture for that first download, the initial
|
||||
translation needs a click.
|
||||
- **Availability** — Chromium desktop browsers (**Chrome / Edge 138+**) and the
|
||||
**Lotus desktop app** (WebView2 / Chromium). Not available in Firefox, Safari,
|
||||
or mobile browsers; where the APIs are unavailable the feature hides itself and
|
||||
the settings tile shows a note.
|
||||
|
||||
### Draft Persistence
|
||||
|
||||
- Composer drafts are stored in `localStorage` keyed by `roomId`
|
||||
- Draft is cleared on successful send
|
||||
- The Jotai atom is the primary source of truth; `localStorage` is only read on room mount
|
||||
- **Room-nav draft indicator**: a subtle green dot (the composer's shared `DraftDot`, `color.Success.Main`) appears on a room's nav item when it has an unsent message draft (and isn't the open room), so you can see at a glance where you left half-written messages. The dot reuses the composer draft affordance's vocabulary (`role="img"`, aria-label "Unsent draft"). It reacts to the shared draft atom via a memoized `selectAtom(…, hasMsgDraft)` (re-renders only when the flag flips; the atom is written on room-leave, not per keystroke). `useHydrateMsgDrafts` (mounted in `ClientNonUIFeatures`) pre-fills the draft atoms from `draft-msg-*` localStorage on startup so indicators are correct after a reload. Emptiness check shared via the pure, unit-tested `hasMsgDraft` (`src/app/utils/draft.ts`), also used by the composer's `DraftIndicator`.
|
||||
|
||||
### Message Search Date Range
|
||||
|
||||
@@ -712,11 +766,15 @@ KaTeX-rendered math in messages, two paths:
|
||||
|
||||
### Image / Video Captions
|
||||
|
||||
Images and videos can be sent with a caption. The caption and media are sent as a single event.
|
||||
Images and videos can be sent with a caption. The caption and media are sent as a single event (caption = the event `body` when it differs from `filename`).
|
||||
|
||||
- **Edit caption**: an image/video you sent shows an **Edit caption** action (quick-actions pencil + message menu). It opens the message editor seeded with the current caption; saving sends an `m.replace` whose `m.new_content` preserves the media (`url`/`info`/encrypted `file`/`filename`) and only changes `body`/`formatted_body`. An empty caption removes it (`body` falls back to `filename`). Gated by `canEditCaption` (`utils/room.ts`) to your own image/video messages that carry an MSC2530 `filename`. Caption edits carry `m.mentions` (an @-mention in a caption notifies) and appear in Edit History (diffed by the word-diff) — even after a caption is removed, the "(edited)" marker remains so history stays reachable. No re-upload — encrypted media keeps its original file/key.
|
||||
|
||||
### Location Sharing
|
||||
|
||||
`m.location` events render an inline map tile using the coordinates from the event content.
|
||||
`m.location` events render an inline map tile using the coordinates from the event content. The renderer reads the top-level `geo_uri` and **falls back to the MSC3488 `org.matrix.msc3488.location`/`m.location` `uri`**, so locations from clients that send only the new shape still render (previously they showed as broken); an MSC3488 `description`, if present, is shown above the coordinates.
|
||||
|
||||
Sharing your location (composer → location button) sends an **MSC3488-compliant** `m.location` event: the legacy `geo_uri` plus the `org.matrix.msc3488.location` (uri), `org.matrix.msc3488.asset` (`m.self`), and `org.matrix.msc3488.ts`/`m.ts` blocks, and a human-readable `body`. This makes Lotus-shared locations render as proper pins on Element and other clients instead of falling back to plain text.
|
||||
|
||||
### Deleted Message Placeholders
|
||||
|
||||
@@ -727,15 +785,27 @@ Redacted events display "This message has been deleted" along with the redaction
|
||||
- Bookmarks are stored in `io.lotus.bookmarks` account data, syncing across all devices
|
||||
- Maximum of 500 bookmarked entries
|
||||
- `BookmarksPanel.tsx` is a sidebar panel accessible from the navigation rail
|
||||
- Live-renders edits/redactions, text search, jump-to-message, and remove
|
||||
- **Author attribution**: each saved-message card shows who wrote it (`{sender} · {time ago}`). The author is snapshotted at save time (`senderId`/`senderName` on the bookmark, optional for backward compatibility) and re-resolved live from the event when the room is joined; search also matches the author name.
|
||||
- **Sort & group**: a Newest / Oldest / By-room segmented control sorts the list; "By room" renders collapsible per-room sections (groups ordered by most-recent save). The chosen sort persists across panel opens (`cinny_bookmarks_sort_v1`). Ordering/grouping logic is pure and unit-tested in `src/app/utils/bookmarks.ts` (`bookmarks.test.ts`).
|
||||
- Hook: `src/app/hooks/useBookmarks.ts`
|
||||
|
||||
### Message Scheduling
|
||||
|
||||
- Implements MSC4140 delayed events for scheduling messages to be sent at a future time
|
||||
- `ScheduleMessageModal.tsx` provides the date/time picker UI
|
||||
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual cancel buttons
|
||||
- A collapsible "Scheduled" tray in the room shows all pending scheduled messages with individual send-now, edit, and cancel buttons
|
||||
- **Send now**: the tray's send button fires a pending message immediately via MSC4140 `action: 'send'` (the server dispatches the stored delayed event now, as a normal timeline event — no cancel+retype). The row is pruned only once the server confirms; a failed send leaves an inline "Could not send now" error with the message still sendable/editable/cancellable.
|
||||
- **Edit / reschedule**: the tray's edit button re-opens `ScheduleMessageModal` (seeded with the existing body + send-time) to change the text and/or time. Since MSC4140 has no in-place edit, this is implemented as schedule-new-then-cancel-old; the old copy is only removed once the server confirms cancellation, so a failed cancel leaves a visible, cancellable copy rather than losing the message. Edits go through the plain-text composer (rich content becomes `m.text`).
|
||||
- Utilities in `src/app/utils/scheduledMessages.ts`
|
||||
|
||||
### Message Reminders
|
||||
|
||||
- Message context menu → **Remind Me** sets a personal reminder to revisit a message; reminders are stored in `io.lotus.reminders` account data (sync across devices) via `useReminders`, and fire from `ClientNonUIFeatures`.
|
||||
- `RemindMeDialog.tsx` offers quick presets (in 20 min / 1 hour / 3 hours / tomorrow 9am) **plus a "Custom time…" option** that reveals date + time pickers for an arbitrary reminder time (validated to be ≥ 1 minute in the future).
|
||||
- **Manage existing reminders**: opening the dialog on a message that already has reminders lists them (soonest first, friendly time via `formatFriendlyDateTime`) each with a cancel (×) button, so you can see and remove pending reminders instead of silently stacking duplicates.
|
||||
- The date/time input helpers (`toLocalDate`, `toLocalTime`, `parseLocalDateTime`, `pickerInputStyle`) are shared, pure, and unit-tested in `src/app/utils/datetimeInput.ts` (`datetimeInput.test.ts`) — also used by `ScheduleMessageModal` (deduped from a prior inline copy).
|
||||
|
||||
### File Upload Compression (opt-in)
|
||||
|
||||
- Implemented in `UploadCardRenderer.tsx`
|
||||
@@ -775,12 +845,12 @@ resolver, `parseMediaEmbed(url, host)` in `src/app/utils/videoEmbed.ts`, maps a
|
||||
URL to `{ provider, kind, embedUrl }`; `MediaEmbedCard` / `TikTokEmbedCard` /
|
||||
`TwitterCard` in `UrlPreviewCard.tsx` render it. Four render `kind`s:
|
||||
|
||||
| kind | shape | providers |
|
||||
| ----------- | ------------------------ | ----------------------------------------------------------------- |
|
||||
| `landscape` | 16:9 video player | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) |
|
||||
| `portrait` | 9:16 video player | YouTube Shorts, TikTok |
|
||||
| `audio` | fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal |
|
||||
| `rich` | self-resizing post embed | X/Twitter, Instagram, Reddit, Bluesky |
|
||||
| kind | shape | providers |
|
||||
| ----------- | ------------------------- | ------------------------------------------------------------------ |
|
||||
| `landscape` | 16:9 video player | YouTube, Vimeo, Dailymotion, Streamable, Twitch, Loom, Kick (live) |
|
||||
| `portrait` | 9:16 video player | YouTube Shorts, TikTok |
|
||||
| `audio` | fixed-height audio player | Spotify, SoundCloud, Apple Music, Tidal |
|
||||
| `rich` | self-resizing post embed | X/Twitter, Instagram, Reddit, Bluesky |
|
||||
|
||||
**Privacy-friendly facade.** The tile first shows the homeserver's cached
|
||||
`og:image` thumbnail + a play button; the third-party `<iframe>` is only mounted
|
||||
@@ -829,18 +899,31 @@ player.kick).
|
||||
|
||||
### Poll Creation
|
||||
|
||||
- `PollCreator.tsx` creates stable `m.poll.start` events
|
||||
- Supports 2 to 10 answer options
|
||||
- Supports both single-choice and multiple-choice modes
|
||||
- `PollCreator.tsx` creates stable `m.poll.start` events (with a text fallback body for non-poll clients)
|
||||
- Supports 2 to 10 answer options; single-choice or multiple-choice
|
||||
- **Max selections** — for a multiple-choice poll, a "Voters can pick up to N of M options" control sets `max_selections` (2 … option count), so you can run "pick your top 2" polls rather than only "select all". Defaults to the option count (unchanged "select all that apply" behavior) until you lower it; the display side already enforces the cap ("Select up to N")
|
||||
- **Results visibility toggle** — _Show live results_ (disclosed, default) vs _Hidden until ended_ (undisclosed)
|
||||
- Accessible via the `Icons.OrderList` button in the composer toolbar
|
||||
|
||||
### Poll Display
|
||||
### Poll Display & Voting (MSC3381, full lifecycle)
|
||||
|
||||
`PollContent.tsx` renders polls in read-only mode. Handles both the stable `m.poll` format and the legacy MSC3381 unstable `org.matrix.msc3381.poll.start` format. Displays current vote counts and a note directing users to Element to cast votes.
|
||||
`PollContent.tsx` is a fully interactive, spec-correct poll card:
|
||||
|
||||
### Voice Message Playback Speed
|
||||
- **Vote / change / clear** in place — sends stable `m.poll.response` (`m.selections`); latest response per voter wins; clearing removes you from the tally. Multi-choice enforces `max_selections` ("Select up to N").
|
||||
- **Disclosed vs undisclosed** — undisclosed polls hide counts/percentages/bars (and the vote total) until the poll ends; disclosed polls show live results.
|
||||
- **See who voted** — a "Show who voted" toggle (shown only when results are visible, i.e. disclosed live or undisclosed-after-end) reveals the voter names under each answer. The voter list rides the same tally as the counts (`voters: Map<answerId, senderId[]>` populated in `tallyResponses`'s latest-response-per-sender loop), so it can never disagree with the numbers; a re-vote moves the voter, and a cleared vote drops them. Names via `getMemberName`; undisclosed polls stay secret until close.
|
||||
- **End a poll** — the poll's creator or a moderator (redact power) can end it via an inline confirm; sends stable `m.poll.end`. Ended polls lock voting, show "Poll closed · Final results", reveal results, and highlight the winner(s) (ties supported). Only responses cast on/before the end event count.
|
||||
- **Cross-client** — reads **both** the stable (`m.poll`/`m.id`/`m.selections`) and unstable (`org.matrix.msc3381.poll.*`) wire formats by hand (matrix-js-sdk 41.7.0's poll parsers only speak unstable), and uses the SDK `Poll` model for end-event validation (creator / redact-PL) + before-end response filtering. Polls authored in Element render/vote/end correctly and vice-versa.
|
||||
- **Accessibility** — `radiogroup`/`radio` (single, with arrow-key roving) or `group`/`checkbox` (multi) semantics, `aria-checked`/`aria-disabled`, winner announced to AT.
|
||||
- Pure tally/visibility/winner/voters + wire-format parsing live in `utils/poll.ts` (+ `poll.test.ts`, 18 tests incl. the stable/unstable round-trip and voter attribution).
|
||||
|
||||
`AudioContent.tsx` adds a playback speed cycle button to voice message players. Available speeds: `[0.75, 1, 1.5, 2]×`. A `useEffect` sets `audioElement.playbackRate` whenever the speed selection changes.
|
||||
### Voice Message Playback (waveform + speed)
|
||||
|
||||
`AudioContent.tsx` is the shared audio player (timeline + Media Gallery Audio tab):
|
||||
|
||||
- **Waveform scrubbing** — voice messages carry an MSC1767 waveform (`org.matrix.msc1767.audio.waveform`, sent by the recorder). Playback renders it as bars that fill with the accent as the clip plays (TDS green under Lotus Terminal), and the waveform **is** the seek control — click, drag, or keyboard (arrows ±5s, Home/End) to scrub (`role="slider"`, ARIA value text). Threaded through `MAudio` (`MsgTypeRenderers.tsx`) + passed directly by the gallery. Regular audio with no waveform keeps the plain seek bar.
|
||||
- **Playback speed** — a cycle button (`[0.75, 1, 1.5, 2]×`); a `useEffect` sets `audioElement.playbackRate` and re-applies it on (re)load (the browser resets it).
|
||||
- **Recording pause / resume** — `VoiceMessageRecorder.tsx` supports a `paused` state via `MediaRecorder.pause()/resume()`, so you can pause mid-recording and continue without a gap. The duration timer accumulates only active-recording time (paused time is excluded), the waveform/meters freeze while paused and the record dot stops pulsing, and the finish button (which advances to the preview/review step) is a checkmark distinct from the Pause control.
|
||||
|
||||
---
|
||||
|
||||
@@ -852,13 +935,21 @@ Full threaded-conversation support (`m.thread`, matrix-js-sdk `threadSupport`),
|
||||
|
||||
A right-side drawer (mirrors the members drawer; fullscreen on mobile) with the thread's root message emphasized at top, an "N replies" divider, the full reply timeline (virtualized, back-paginates via `/relations`, decrypts E2EE threads), reactions/edits/redactions, and its own composer. Open it from **Reply in Thread** in the message menu, a reply's thread indicator, or a summary chip; close with **×** or Escape. Reading the panel sends threaded read receipts so per-thread unread counts clear.
|
||||
|
||||
### Threads List Panel
|
||||
|
||||
A room-level overview of **all** threads, opened from a **Threads** button (🧵) in the room header (mirrors the gallery/widgets toggles). Each row shows the root sender + message snippet, an unread dot, a meta line ("N replies · last reply 5m ago") and a **participant avatar pile**. A segmented **filter** (All / Unread / Participating — the latter via `thread.hasCurrentUserParticipated`) and **sort** (Recent / Oldest, by last-reply time) sit in the toolbar; both persist in localStorage (`cinny_threads_filter_v1` / `cinny_threads_sort_v1`). Clicking a row opens the existing single-thread `ThreadPanel` (reuses `setActiveThreadId`), and reading it clears the row's unread badge live. The list stays live via `ThreadEvent.New/NewReply/Update/Delete` + `RoomEvent.UnreadNotifications` and is virtualized for busy rooms.
|
||||
|
||||
- Files: `features/room/thread/ThreadsListPanel.tsx`, `hooks/useRoomThreads.ts` (populates via `room.fetchRoomThreads()` + `room.getThreads()`; last-activity + reply-count + root-edit signature so rows refresh live), `state/threadsList.ts`, pure filter/sort in `utils/threadList.ts` (`filterThreads`/`sortThreads`, unit-tested). Unread mirrors `useThreadSummary`'s logic (`getThreadUnreadNotificationCount`, muted threads zeroed) and renders the app-wide `UnreadBadge` (red for mentions via the Highlight count). Reuses `StackedAvatar`/`useMemberAvatar` for the participant pile and the Bookmarks-panel segmented-control pattern.
|
||||
|
||||
### Summary Chips
|
||||
|
||||
Root messages in the main timeline show a **"N replies · time"** chip (server-aggregated `m.thread` bundle, or the live Thread once loaded) with an unread badge — threaded replies no longer render inline in the main timeline, so the chip is how conversations stay discoverable.
|
||||
|
||||
### Thread Composer
|
||||
|
||||
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). Scheduling and slash commands are disabled inside threads (v1).
|
||||
The panel embeds the full composer (uploads, emoji, stickers, GIFs, voice, location, polls) with drafts, reply state, and upload queues **isolated per thread** (`roomId::threadRootId` keys). Replies-to-replies produce spec-correct `m.thread` + `m.in_reply_to` (`is_falling_back: false`). **Slash commands work in threads** — content-transform commands (`/me`, `/notice`, `/shrug`, `/tableflip`, `/unflip`) route into the thread via the normal send path, and room-level commands (`/invite`, `/kick`, …) act on the room; this also matches the command autocomplete, which was already shown in the thread composer. Scheduling is still disabled inside threads (v1).
|
||||
|
||||
**↑ to edit last reply**: pressing Up-arrow in the empty thread composer opens the editor on your most recent editable reply _in that thread_ — parity with the main timeline. The thread composer carries a distinct `editableName="ThreadInput"` so the main timeline's global up-arrow handler and the thread's no longer cross-fire (previously the thread composer had the same name, so Up-arrow there wrongly targeted the main timeline's last message).
|
||||
|
||||
### Notifications (Slack-style, P4-1)
|
||||
|
||||
@@ -894,9 +985,11 @@ A presence status selector in the user panel offering five modes:
|
||||
### Custom Status Message
|
||||
|
||||
- Up to 64 characters of free text plus an emoji
|
||||
- **Emoji picker is unicode-only** (`EmojiBoard hideCustomEmojis`): a status is plain-text presence (`status_msg`) that can't render a custom mxc-image emoji, so the picker hides custom/image-pack emojis (packs, sidebar icons, search, and custom entries in Recent) — every emoji shown actually inserts. (Previously custom emojis were listed but silently did nothing when clicked, since there was no `onCustomEmojiSelect` on this field.)
|
||||
- Optional auto-clear timer with presets: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days
|
||||
- Status is broadcast via `mx.setPresence({ status_msg: ... })`
|
||||
- Character counter appears at 56/64 characters remaining to warn of the limit
|
||||
- **Status presets**: a "Quick statuses" row of 11 built-in presets spanning gaming/social/life/work — 🎮 Gaming, 🎧 In a party, 🏆 Ranked grind, 😴 AFK, 🍿 Watching, 🍽️ Lunch, 🗓️ In a meeting, 🏠 Working remotely, 🎯 Focusing, 🌴 On vacation, 🤒 Out sick (see `BUILT_IN_STATUS_PRESETS`). Clicking a preset applies its message + suggested auto-clear in one click. Users can also save the current status as a reusable custom preset (stored in `io.lotus.status_presets` account data, synced across devices, de-duped by label, capped at 20; a saved preset matching a built-in is hidden to avoid a duplicate chip) and delete presets inline. Built-in list + pure `upsertPreset` de-dupe/cap logic live in `src/app/utils/statusPresets.ts` (unit-tested); persistence in `src/app/hooks/useStatusPresets.ts`.
|
||||
|
||||
### Presence Badges
|
||||
|
||||
@@ -926,10 +1019,12 @@ Fixed by replacing the single read with a `readStatus()` function called inside
|
||||
|
||||
The browser tab title updates to reflect unread state:
|
||||
|
||||
- `(N) Lotus Chat` — N unread messages
|
||||
- `· Lotus Chat` — unread activity without a specific count
|
||||
- `(N) Lotus Chat` — N mentions / keyword highlights (the count is highlights, not total unread)
|
||||
- `· Lotus Chat` — unread messages without a mention (activity, no specific count)
|
||||
- `Lotus Chat` — no unread items
|
||||
|
||||
The favicon mirrors this (highlight badge / unread dot / default).
|
||||
|
||||
### Extended Profile Fields
|
||||
|
||||
Supports MSC4133 custom profile fields via `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`:
|
||||
@@ -969,6 +1064,10 @@ Hook: `src/app/hooks/useUserNotes.ts`
|
||||
|
||||
The Forward Message dialog is a checkbox multi-select: pick any number of rooms (search + select persist across queries) and **"Send to N rooms"** forwards in one batch (`Promise.allSettled`). Full success auto-closes; a partial failure keeps the dialog open with a "Forwarded to X/N — failed: …" summary. The forwarded content (latest edit via `m.new_content`, reply-quote stripped, undecryptable refused) is built by the shared, unit-tested `forwardContent.ts`.
|
||||
|
||||
- **Message preview**: a compact preview at the top of the dialog shows the sender + body (and a thumbnail for image/video) so you can see what you're forwarding.
|
||||
- **Optional comment**: an "Add a comment" field sends a short `m.text` note to each target room _before_ the forwarded message (sequenced per room; a room counts as failed if either send fails).
|
||||
- **Recent targets**: a "Recent" chip row (hidden while searching) surfaces the rooms you last forwarded to for one-tap selection. Successful targets are recorded most-recent-first, deduped, capped at 8, in localStorage (`cinny_recent_forward_targets_v1`) via the pure, unit-tested `addRecentForwardTarget` (`state/recentForwardTargets.ts`); rooms you've since left are dropped from the row.
|
||||
|
||||
### Live Bookmark Previews (P6-3)
|
||||
|
||||
`BookmarksPanel` resolves each saved message's **live event** (`useRoomEvent`) so previews reflect **edits** and show a **deleted** indicator for redactions, instead of the save-time snapshot. The stored snapshot (`previewText`) remains the fallback while loading, on fetch failure, or when you've **left the room**.
|
||||
@@ -997,10 +1096,9 @@ OS-level notifications are unchanged and still fire when the window is not focus
|
||||
|
||||
### Collapsible Long Messages
|
||||
|
||||
Messages exceeding a configurable line threshold are truncated with a "Show more" toggle.
|
||||
Messages exceeding a fixed height threshold are truncated with a "Show more" toggle.
|
||||
|
||||
- Default threshold: 20 lines
|
||||
- Threshold is configurable in **Settings → Appearance**
|
||||
- Threshold: a fixed `COLLAPSE_MAX_HEIGHT` of 320px (≈ 20 lines) — not currently user-configurable
|
||||
- Uses CSS `max-height` + `overflow: hidden` with a smooth transition
|
||||
- Transition is disabled when `prefers-reduced-motion: reduce` is active
|
||||
|
||||
@@ -1063,7 +1161,8 @@ Persists via the `homeRoomSort` setting.
|
||||
|
||||
`RoomShareInvite.tsx` provides a shareable invite UI:
|
||||
|
||||
- 160×160px QR code generated via `api.qrserver.com`
|
||||
- 160×160px QR code generated locally via `qrcode.react` (`QRCodeSVG`) — no third-party service, works offline and under strict CSP, on a white quiet-zone so it scans on any theme
|
||||
- **Download QR**: exports the code as a PNG via an offscreen high-resolution (1024px, spec 4-module margin) `QRCodeCanvas` + `canvas.toBlob`, saved through `useSaveFile` (filename derived from the room name) with the standard download toast
|
||||
- "Copy Link" button to copy the `matrix.to` URI
|
||||
- Also accessible via a toggle button (⊞) in the Invite modal
|
||||
|
||||
@@ -1075,10 +1174,13 @@ A toggle in **Settings → Privacy** switches between sending `m.read` (public r
|
||||
|
||||
`MediaGallery.tsx` — a right-side drawer for browsing room media.
|
||||
|
||||
- Three tabs: **Images**, **Videos**, **Files**
|
||||
- Reads already-decrypted events from the room timeline
|
||||
- Encrypted images show a lock placeholder rather than an error
|
||||
- "Load More" button triggers `mx.paginateEventTimeline()` to fetch older media
|
||||
- Four tabs: **Images**, **Videos**, **Audio**, **Files** (each with a live count)
|
||||
- **Images/Videos** — a month-grouped grid; tiles decrypt on demand (lazy, near-viewport), open a keyboard-navigable **lightbox** (←/→/Esc, prev/next). Each grid tile has a hover/focus **download** button, and the lightbox header has a **Download** action — both reuse the shared `FileDownloadButton` (full-resolution source, decrypts E2EE media client-side, spinner/✓/retry states), so images and videos can be saved without jumping to the message. On touch (no-hover) devices the tile download button stays visible. In the lightbox, **images support zoom & pan** (scroll wheel or −/+ header buttons, `+`/`-`/`0` keys, double-click or the % chip to toggle 1×↔2×; drag to pan when zoomed) via the shared `useZoom`/`usePan` hooks; zoom resets when navigating to another item.
|
||||
- **Audio** — voice messages + audio files (`m.audio`) with an inline player (reuses `AudioContent`: **waveform scrubbing** for voice messages, play/seek/**speed control**; decrypts on play)
|
||||
- **Files** — name/size/sender rows with download
|
||||
- **Jump to message** — a "Go to message" action on file rows, audio rows, and in the lightbox navigates the timeline to the source event (`useRoomNavigate`) and closes the drawer
|
||||
- Encrypted media is decrypted client-side on demand (no lock placeholder); download works for all types
|
||||
- **Auto-pagination** — an `IntersectionObserver` sentinel calls `mx.paginateEventTimeline()` to pull older media as you scroll (manual retry on error)
|
||||
|
||||
### Knock-to-Join
|
||||
|
||||
@@ -1187,9 +1289,9 @@ Features:
|
||||
|
||||
Accessible via **Room/Space Settings → Policy Lists** (admin only).
|
||||
|
||||
- Displays the room's subscribed policy lists in read-only format
|
||||
- Subscribe (join) and unsubscribe (leave) controls for each list
|
||||
- Enforcement is delegated to Draupnir or equivalent tooling; Lotus only manages list membership
|
||||
- Enter a policy-list room's **ID or alias** (one you have already joined) to view its `m.policy.rule.user` / `.room` / `.server` rules in read-only format
|
||||
- Viewer only — there are **no** subscribe/unsubscribe controls and no listing of "subscribed" lists; join or leave the policy-list room itself the normal way
|
||||
- Enforcement is delegated to Draupnir or equivalent tooling
|
||||
|
||||
---
|
||||
|
||||
@@ -1209,6 +1311,13 @@ Accessible via **Room/Space Settings → Policy Lists** (admin only).
|
||||
- Gates both `notify()` (visual/OS notifications) and `playSound()` (audio alerts)
|
||||
- When active, notifications are silently dropped rather than queued
|
||||
|
||||
### Pause Notifications (snooze)
|
||||
|
||||
- A **cross-platform** "Pause Notifications" control in **Settings → Notifications** (the desktop-tray Do Not Disturb only worked on the desktop app; web/mobile had no manual pause).
|
||||
- Quick presets: 30 minutes / 1 hour / 4 hours / Until 8 AM / **Until I resume** (indefinite), plus a **Resume** button; the tile shows the live "Paused until …" status (via `formatFriendlyDateTime`) and flips back to "on" the moment the snooze lapses.
|
||||
- Persisted (`cinny_notification_snooze_until_v1`) as the epoch-ms instant to pause until (`0` = off), so a snooze survives a reload. Feeds the same notification gate as Focus Assist / Quiet Hours (`ClientNonUIFeatures`), suppressing both `notify()` and `playSound()`.
|
||||
- Pure, unit-tested helpers `isSnoozeActive` / `nextTimeAtHour` / `SNOOZE_INDEFINITE` in `src/app/utils/snooze.ts` (`snooze.test.ts`); persisted atom in `src/app/state/notificationSnooze.ts`.
|
||||
|
||||
### Full Push Rule Editor
|
||||
|
||||
A complete UI for managing Matrix push notification rules:
|
||||
|
||||
+25
-1
@@ -1,6 +1,6 @@
|
||||
# Lotus Chat — Manual Testing Guide
|
||||
|
||||
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP)
|
||||
**Generated:** June 2026 · **Updated:** July 2026 (added §O — threads, per-thread notifications, math, search cache, session hardening, audit wave, desktop CSP; added the **Automated coverage map** below — logic now pinned by unit tests, so manual QA can focus on the human-only surface)
|
||||
**Scope:** Everything landed on the `lotus` branch since the v4.12.3 merge that I (Claude) could **not** verify statically and that needs a human in a real environment to confirm. Work through it top-to-bottom; the highest-risk / hardest-to-reproduce items are first.
|
||||
|
||||
> **How to report back:** For each numbered check, tell me **PASS** / **FAIL** (or **partial**). On any FAIL, include: what you saw vs. expected, the browser/OS (and whether web LXC 106 or the desktop/Tauri build), the theme you were on, and any **browser console** errors (F12 → Console). Screenshots help for anything visual.
|
||||
@@ -28,6 +28,30 @@
|
||||
|
||||
---
|
||||
|
||||
## Automated coverage map — what the unit tests already pin (2026-07)
|
||||
|
||||
**Read this before working the guide.** Much of the _logic_ these manual checks were written to catch is now locked by deterministic unit tests (`npm test`, 920+ cases, green in CI). Unit tests do **not** prove visual rendering, real-call behavior, the desktop build, E2EE, or cross-device sync — those still need a human. But where a decision is pure logic, you can **trust the test and spend your manual time on the human-only part**. For each row below, the middle column is "don't bother re-deriving this by hand"; the right column is "this is what your manual pass is actually for."
|
||||
|
||||
| Guide item | Logic **pinned by a unit test** (trust it) | What still needs **you** (manual) |
|
||||
| :----------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **A1** ringtone previews | `callSounds.test.ts` — each style's synthesized melody (chime/soft/retro), click-free gain ramps, context unlock/reuse, unknown = no-op | that it's actually **audible** + the WebAudio first-gesture caveat |
|
||||
| **A2** ringtone persist/fallback | `settings.test.ts` — unknown `ringtoneId` → default, malformed JSON → defaults, merge-over-defaults (this **is** A2 step 3) | the dropdown shows the persisted value after reload (trivial glance) |
|
||||
| **B2/B3** poll voting | `poll.test.ts` (18) — vote tally, latest-per-sender, multi-select, cleared/re-vote, winners, results-visible, single-vs-multi validation | **visual** only: borders, radio-vs-checkbox, progress-bar fill, on each theme |
|
||||
| **O2/P4-1** thread notifications | `threadNotifications.test.ts` (32) — the **entire** notify decision (participating default, All/Mentions/Mute, @mention+highlight override, room-mute trumps), mode-map + muted-badge hygiene | live **2-person** delivery + sound + **cross-device** account-data sync |
|
||||
| **O3** math / LaTeX | `mathParse.test.ts` (14) — inline `$…$`, block `$$…$$`, **currency guard** (`$5 and $10`), escaped/unbalanced stay text, adjacency rules | KaTeX **renders** visually + lazy-chunk load; code-block-literal is the markdown pipeline |
|
||||
| **O4/P4-8** encrypted search cache | `searchCache.test.ts` — the pure helpers `mergeSearchResults` (merge/dedupe/sort) + `computeCoverage` (window widening) + resilient-when-IDB-absent. **The IDB round-trip test is `skip`ped under `npm test`** (node has no IndexedDB), so it runs only in a browser-like env, not CI | the actual IndexedDB persist-across-**reload**, Clear button, **logout wipe** (integration — and the round-trip itself) |
|
||||
| **M1** `has:image/file/video` | `useMessageSearch.test.ts` — `filterGroupsByMsgType` union filter, drops empty groups, ignores non-string msgtype | the chips render + compose with room/sender/date filters |
|
||||
| **M4** pinned-only filter | `useMessageSearch.test.ts` — `filterGroupsByPinned` keeps pinned, drops empty | chip renders; needs a room with actual pins |
|
||||
| **M2** recent searches | `recentSearches.test.ts` (6) — prepend, dedupe+move-to-front, trim, ignore-empty, cap-at-10 | chips render/click-re-run; persistence across refresh |
|
||||
| **Retention** (disappearing msgs) | `retention.test.ts` — `isExpired` window math (strict boundary), disabled = never, preset monotonicity | the timeline **hide** + self-**redact** integration; Synapse-side purge |
|
||||
| **O5/N97a** session hardening | `sessions.test.ts` (22) — blob migration, legacy-key coercion, dual-write blob↔legacy sync, corrupt/partial-blob fallback, token-refresh, AND the `subscribeSessionChanges` storage-event logic (fires on session/null, ignores unrelated keys) | the real **cross-tab** logout _behavior_ end-to-end (two live tabs) |
|
||||
| **Q1/Q2** embeds (URL→player) | `videoEmbed.test.ts` (26) — every provider's URL→`{provider, kind, embedUrl, height}` parse (incl. Mixcloud/Deezer, TikTok, reserved-path guards) | the click-to-play **facade**, no-network-until-Play, the **CSP** (esp. desktop), visuals |
|
||||
| **Seasonal theme resolution** (part of F2) | `seasonSchedule.test.ts` — `resolveSeasonTheme` (off→none, auto→active season, pinned→that) + `getActiveSeason` priority/boundary days. **NB: this pins _which_ theme shows for a date, NOT F2's background↔seasonal mutual exclusion** — that write-side logic is untested | all of **F2**: the picker actually clearing the _other_ setting live, and the overlay suppression when a background is set |
|
||||
|
||||
Everything else in the guide (calls, screen readers, desktop/Tauri, chat backgrounds, animated visuals, PWA install, real E2EE) is genuinely manual — no unit test substitutes for it. Items already **verified live** are listed at the very bottom ("Verified working in live testing").
|
||||
|
||||
---
|
||||
|
||||
## A. Calls — new ringtone + notification work (highest priority)
|
||||
|
||||
### A1. Ringtone selection — preview in Settings
|
||||
|
||||
+148
-35
@@ -32,10 +32,9 @@ A three-wave feature bug-hunt (~15 parallel agents, each batch independently rev
|
||||
|
||||
**Still open (low tail — all 🟡 minor):**
|
||||
|
||||
- **Calls host:** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles no-op if EC renames — **both retire via EC-fork P6-2**. C-L1 AFK mic not released if EC elides the echo; C-L2 ringtone-preview global cross-cancel; C-L3 first ring after cold load can be silent (ctx not unlocked); C-L5 speaker-observer churn on membership change; C-L7 all-muted DOM miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size.
|
||||
- **Threads:** T5 `participating` detection is server-bundle-only (`thread.hasCurrentUserParticipated`) → can under-notify a thread you just replied to; T6 room "Mentions & Keywords" not honored for participated/Default thread replies (over-notify); T7 account-data thread-mute write is a lost-update race.
|
||||
- **Crypto/session:** F5 OIDC refresh drops `expiresAt` on persist (`persistTokens` can't reach the expiry without SDK-internal plumbing; refresh is reactive on 401).
|
||||
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage. H10 room-name setter fire-and-forget/silent length reject (trivial). N6 per-message read-receipt avatars may not refresh on membership change (emitter uncertain, low impact).
|
||||
- ✅ **Low-tail batch FIXED** (`a267e9e9`, 2-agent-reviewed, gate-green): **T5** (`participated` now also scans the local thread timeline, not just the server bundle → no under-notify), **T6** (room "Mentions & Keywords" honored for Default thread replies via a new `roomMentionsOnly` gate → no over-notify; +4 tests), **T7** (thread-mode account-data writes serialized with content carried forward → no lost update), **C-L2** (a real incoming ring cancels a lingering Settings preview), **C-L3** (ringtone AudioContext primed on first page gesture → first ring after cold load not silent), **C-L5** (`useCallSpeakers` depends on a stable boolean → no observer churn on membership change), **F5** (OIDC refresher forwards the refreshed token `expiry` as `expiresInMs` → `expiresAt` no longer stale across reloads). **Verified already-handled, no change:** **N6** (`useMemberAvatar` already subscribes via `useRoomMemberChange`), **H10** (`RoomProfile` already has `maxLength={255}` + surfaces the submit error).
|
||||
- **Calls host (still open):** C-M1 deafen DOM-fallback leaks late-added `<audio>` tracks; C-M2 `.click()`-by-testid toggles no-op if EC renames — **both retire via EC-fork P6-2**. C-L1 AFK mic not released if EC elides the echo; C-L7 all-muted DOM miscount if EC label format differs; C-L8 PiP sw/nw resize anchor jitter at min size. **All four are EC-DOM/echo-behavior or visual-jitter items — need a real call + the EC iframe to verify; deferred.**
|
||||
- **Native/desktop:** D7 Unity badge `application://cinny.desktop` id may not match the installed `.desktop` basename — **runtime-verify** on the `.deb`/AppImage.
|
||||
- **EC fork (EC1–EC6 fixed on `element-call:lotus`, needs a republish):** re-apply `setTimeout` cleanup, remote-gated subscription → `allConnections$`, per-call decoration state leak, re-subscribe-every-render, focus-clear on missing `userId`. Rides with **P6-2 phase 2**.
|
||||
|
||||
---
|
||||
@@ -67,35 +66,86 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then gr
|
||||
|
||||
Agent-surveyed + TPVR-verified correctness / a11y / tech-debt fixes (DP1–DP18) are implemented, review-fixed, and gate-green (tsc + eslint + 737 tests + build). Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md) §R, then remove this note. Full detail in git history — commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`.
|
||||
|
||||
### 🔍 Discovery pass 2 (2026-07) — perf / security / correctness (agent-surveyed, single-pass — NOT yet TPVR'd)
|
||||
### ✅ Discovery pass 2 (2026-07) — perf / security / correctness — DONE
|
||||
|
||||
Three agents swept fresh lenses (performance, security/privacy, correctness in under-covered subsystems); each verified its own findings against the code + SDK. `Sev`/`Eff` = S/M/L. Confirm before fixing (no independent TPVR yet).
|
||||
Agent-surveyed findings, each **verified against the code before fixing**, then implemented and gate-green (tsc + eslint + prettier + 857 tests + build), with **two review agents on every staged diff before commit**. Verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then remove this note.
|
||||
|
||||
**Performance / rendering:**
|
||||
- [x] **PERF-1 — presence: 3 client listeners PER avatar → one shared presence store** (3 listeners total). `8a154051`.
|
||||
- [x] **PERF-2 — `#`-mention autocomplete mutated + re-sorted the shared `allRoomsAtom` every keystroke** → copy + `useMemo` (also fixed a real shared-array mutation hitting ~27 consumers). `4708a179`.
|
||||
- [x] **PERF-3 — read-receipt rows: ~6 global `Members` listeners per row → one shared member-change store** (`useRoomMemberChange`). `1b8f5545`.
|
||||
- [x] **PERF-4 — message-search room filter re-sorted every render** → `useMemo`. `4708a179`.
|
||||
- [x] **PERF-5 — DM-preview `Decrypted` listener ran for every nav item** → gated on `direct`. `4708a179`.
|
||||
- [x] **SEC-1 / SEC-2 — scheduled-message plaintext + recent searches survived logout** → `clearPlaintextCaches()` on both logout paths, extended to the whole `recent_*` family + nav-paths. `726cefb5`.
|
||||
- [x] **SEC-3 — `window.open(_blank)` without `noopener`** → `noopener,noreferrer` at 5 sites (SSOStage excluded — needs the handle). `3e1106b2`.
|
||||
- [x] **SEC-4 — `/acl` self-lockout footgun** → shared `serverAcl.ts` validation, no-brick allow default, fail-closed self-ban guard. `3e1106b2`.
|
||||
- [x] **COR-1 — space-child UNLINK over-deleted** → targeted `UNLINK` reducer action. `fd3b8b42`.
|
||||
- [x] **COR-2 — `useCallJoined` stuck true on 2nd-call embed swap** → re-seed on `[embed]`. `1f80d1d1`.
|
||||
- [x] **COR-3 — incoming-call lifetime guard only corrected future clock-skew** → `Math.abs(...)` (±20s). `ab01d27a`.
|
||||
- [x] **COR-4 — shared notify-dedupe slot double-notified** → key by `roomId|threadId`. `1f80d1d1`.
|
||||
- [x] **COR-5 — upload cancel ignored during retry back-off** → `AbortSignal` threaded into the retry loop. `ab01d27a`.
|
||||
- [x] **COR-6 — `CallControl.forceState` dropped `screenshareAudioMuted`** → passes it. `ab01d27a`.
|
||||
|
||||
- [ ] **PERF-1 — Presence: 3 global client listeners registered PER avatar (Sev High · Eff M).** `hooks/useUserPresence.ts:43-53` (via `PresenceRingAvatar.tsx:19`, `MembersDrawer.tsx:142`) — every timeline/member avatar adds `mx.on` for `Presence`/`CurrentlyActive`/`LastPresenceTs` → 100–250 global listeners; each presence event fans out across all, and fast scroll churns add/remove per Message mount. Fix: one shared presence store (context + `useSyncExternalStore` or a jotai `atomFamily`) with 3 listeners total; components select by userId.
|
||||
- [ ] **PERF-2 — `#`-mention autocomplete re-sorts AND mutates the room list every keystroke (Sev Med · Eff S).** `components/editor/autocomplete/RoomMentionAutocomplete.tsx:83` — `useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx))` is unmemoized (O(N log N) `mx.getRoom` per compare, N=all joined rooms, per keypress) **and `.sort()` mutates the shared `allRoomsAtom` array in place.** Fix: `useMemo(() => [...rooms].sort(...), [rooms, mx])`.
|
||||
- [ ] **PERF-3 — Read-receipt rows attach per-instance global `RoomStateEvent.Members` listeners (Sev Med · Eff M).** `components/read-receipt-avatars/ReadReceiptAvatars.tsx:74` + `hooks/useMemberAvatar.ts:44` — up to ~6 global `Members` listeners per receipt row; fires on any membership/name/avatar change in any room. Fix: one shared room-member-change subscription fanning out by roomId+userId (shares with PERF-1).
|
||||
- [ ] **PERF-4 — Message-search room filter re-sorts full room list every render (Sev Low–Med · Eff S).** `features/message-search/SearchFilters.tsx:155` — `Array.from(...).sort(factoryRoomIdByAtoZ(mx))` outside `useMemo` (re-sorts as the user types). Fix: `useMemo`.
|
||||
- [ ] **PERF-5 — DM-preview hook subscribes a global `Decrypted` listener for EVERY nav item incl. non-DMs (Sev Low–Med · Eff S).** `hooks/useRoomLatestRenderedEvent.ts:64-67` (called at `RoomNavItem.tsx:681`) — preview is only used for `direct` rooms but the hook + its `MatrixEventEvent.Decrypted` listener run for all; wasteful, and fans out across all during bulk decryption. Fix: gate the hook/subscription on `direct`.
|
||||
- [ ] **PERF-6 — Avatar-decoration profile-fetch on first render of large member/timeline views (Sev Low · Eff M, likely leave).** `hooks/useAvatarDecoration.ts` — one `/profile/{userId}` per new user (well-guarded: module cache + in-flight dedupe + backoff). Network/HS-load note only; batch/skip only if it proves costly.
|
||||
**Deferred / decided (not built):**
|
||||
|
||||
**Security / privacy:** _(no exploitable XSS found — `sanitize.ts` strips `javascript:`, forces mxc `<img>` + `rel=noopener` `<a>`; embeds use host-allowlist + origin-scoped `postMessage`; KaTeX `trust:false`. These are storage-hygiene / URL items.)_
|
||||
- [DEFERRED] **PERF-6 — avatar-decoration `/profile` fetch** — well-guarded (module cache + in-flight dedupe + backoff); a network/HS-load note only. Batch/skip only if it proves costly.
|
||||
- [DEFERRED] **SEC-5 — embeds' `allow-popups-to-escape-sandbox`** — informational; main-app hijack already prevented (no `allow-top-navigation`), and popups are arguably needed for "open in provider." Revisit with per-provider verification if dropped.
|
||||
- **KE-1 preventive (`navigator.storage.persist()`)** is **already implemented** (`initClient` → `requestPersistentStorage()` + `src/index.tsx` boot). The rest of the KE cluster stays under **Encryption / E2EE** below (needs live capture).
|
||||
|
||||
- [ ] **SEC-1 — Scheduled-message plaintext persists in `localStorage`, survives logout (Sev Med · Eff S).** `state/scheduledMessages.ts:12-19` — full `IContent` (incl. E2EE cleartext `body`) under `cinny_scheduled_messages_v1`; normal logout (`removeFallbackSession`, N98) keeps it. Leak on shared/kiosk browser. Fix: clear on logout (add to `removeFallbackSession`) — or encrypt-at-rest with N97.
|
||||
- [ ] **SEC-2 — Recent search terms persist in `localStorage`, survive logout (Sev Low · Eff S).** `state/recentSearches.ts:4-12` — last 10 queries (often PII) under `cinny_recent_searches_v1`, survive normal logout. Fix: clear on logout.
|
||||
- [ ] **SEC-3 — `window.open(url, '_blank')` without `'noopener'` → reverse tab-nabbing (Sev Low · Eff S).** `components/user-profile/UserChips.tsx:117` (+ `SSOStage.tsx:23`, `settings/devices/Verification.tsx:276`, `OtherDevices.tsx:36,49`, `OidcManageAccount.tsx:23`) — `window.open` doesn't null `window.opener`. UserChips opens a user/room-derived server URL. Fix: pass `'noopener,noreferrer'` (or set `w.opener = null`).
|
||||
- [ ] **SEC-4 — `/acl` has no `*`/self-lockout guard or glob validation (Sev Low · Eff S–M).** `hooks/useCommands.ts:489-538` — `/acl -d *` writes `deny:['*']` (can brick the room); empty/whitespace globs pushed unvalidated into `m.room.server_acl`. PL-gated + matches upstream, but a destructive footgun. Fix: reject a `*`/empty deny matching the local server, validate glob syntax, confirm on `*`.
|
||||
- [ ] **SEC-5 — Embeds get `allow-popups-to-escape-sandbox` (Sev Low, informational · Eff M).** `components/url-preview/UrlPreviewCard.tsx:590-591` (`EMBED_SANDBOX`) — a hijacked provider script could spawn an un-sandboxed popup (phishing). Main-app hijack already prevented (no `allow-top-navigation`); popups arguably needed for "open in provider." Fix (if desired): drop the escape flag + verify per-provider.
|
||||
### 🔍 Feature bug hunt (2026-07, 5-agent, LOTUS_FEATURES surface) — open findings
|
||||
|
||||
**Correctness (under-covered subsystems):**
|
||||
Per-slice bug hunt (5 agents: theming · calls · messaging · threads/presence/UX · rooms/mod/notif/infra/desktop), each **verified against current code** (already-fixed items not re-flagged; the heavily-audited hot paths came back clean). Residual findings below. `[live]` / `[desktop]` = needs a real call / the desktop app to confirm.
|
||||
|
||||
- [ ] **COR-1 — Space-child UNLINK nukes a room's OTHER parents + orphans descendants (Sev Med · Eff M).** `state/room/roomToParents.ts:102-103` (DELETE reducer `:53-66`) — removing one `m.space.child` fires the same `DELETE` used for "room left," wiping ALL of the child's parents and stripping it as a parent everywhere. Remove C from space A only → C also loses parent B, and C's own children lose C, until a full resync. Fix: a targeted "remove one parent from one child" action. (Reducer inherited from upstream.)
|
||||
- [ ] **COR-2 — `useCallJoined` stays `true` across a direct embed swap (answering a 2nd call) (Sev Med · Eff S).** `hooks/useCallEmbed.ts:140-158` — the reset effect only clears `joined` when `!embed`; answering call B from the banner swaps embed A→B directly, so `joined` stays true → B renders as live/joined before EC actually joins, skipping the loading/watchdog UI. Fix: `useEffect(() => setJoined(embed?.joined ?? false), [embed])`.
|
||||
- [ ] **COR-3 — Incoming-call lifetime guard only corrects FUTURE clock-skew, not past (Sev Low · Eff S).** `components/CallEmbedProvider.tsx:476-477` — distrusts `sender_ts` only when >20 s ahead of `origin_server_ts`; a caller clock that's behind leaves `sender_ts` in the past → `remaining ≤ 0` → ring auto-dismisses/never shows for a fresh invite. Fix: `Math.abs(...) > 20000` or just use `event.getTs()`.
|
||||
- [ ] **COR-4 — Shared notify-dedupe slot double-notifies a re-fired main message after an interleaving thread reply (Sev Low · Eff S).** `pages/client/ClientNonUIFeatures.tsx:521,530` — `lastNotifiedEventRef` is one `Map<roomId,eventId>` slot shared by the main + thread paths; a thread reply overwrites it, so a re-emitted main message (decrypt/edit re-fire, common in E2EE) mis-dedupes and notifies again. Fix: separate dedupe keys per path (or key by `roomId|eventId`).
|
||||
- [ ] **COR-5 — Upload cancel during retry back-off is silently ignored (upload resurrects) (Sev Low · Eff M).** `utils/matrix.ts:204-238` + `state/upload.ts:125-129` — during the 1–30 s back-off the atom holds the previous (rejected) promise, so `cancelUpload` is a no-op and the loop wakes to start the next attempt. Fix: thread an abort flag into the retry loop.
|
||||
- [ ] **COR-6 — `CallControl.forceState` resets `screenshareAudioMuted` to false (Sev Low · Eff S).** `plugins/call/CallControl.ts:155-162` — `forceState` passes only 5 args so the 6th defaults false; low impact today (runs at join, no screenshare yet) but silently discards the field. Fix: pass `this.screenshareAudioMuted`.
|
||||
**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)
|
||||
|
||||
@@ -155,6 +205,31 @@ Genuine Matrix client-spec / MSC features Lotus does **not** yet implement (audi
|
||||
|
||||
**Server-gated / advanced (capture, don't build yet):** QR sign-in for a new device (**MSC4108** rendezvous — needs an HS-side endpoint); dehydrated devices (**MSC3814** — offline key delivery, also helps the E2EE KE cluster); E2EE history key sharing on invite (**MSC3061** `shared_history`, niche); voice broadcast (Element MSC3888, low value — skip).
|
||||
|
||||
### [PARKED] Matrix 2.0 call membership — MSC4354 Sticky Events (investigated 2026-07, 3 agents + live infra check)
|
||||
|
||||
Move MatrixRTC/Element Call call-membership from state events (MSC3401) to **sticky events** — the "Matrix 2.0" path. **Not a flag flip; a coordinated rollout. Parked deliberately.**
|
||||
|
||||
Findings:
|
||||
|
||||
- **Server (Synapse 1.157.1, LXC 151):** `msc4354_enabled` defaults `false`. Enabling is **low-risk, additive, reversible** — schema (`sticky_events` table) already ships unconditionally, no migration/backfill, all runtime paths flag-gated, residual rows self-expire ≤1h. The one historical `/sync` EDU-filter bug (#19787) was fixed in 1.155.0; SQLite guard N/A (we're Postgres).
|
||||
- **The flag alone is a no-op for behavior.** Our EC fork (upstream **v0.20.1** base, `@lotusguild/element-call-embedded`, bundled into Cinny at build → fleet upgrades atomically) gates sticky mode behind BOTH server support AND a per-device **developer-settings** radio (`matrix-rtc-mode`, defaults `Legacy`). Enabling the flag only un-greys that radio; no client changes what it sends until a human toggles it.
|
||||
- **Matrix-layer mixed-mode = safe:** js-sdk (v41.6.0) reads + merges sticky and state membership, so cross-mode participants see each other.
|
||||
- **Open risk before any real rollout:** media layer. Sticky mode drops `livekit_alias` + uses lk-jwt-service `/get_token` (slot `m.call#ROOM`); legacy uses `/sfu/get` (`room=roomId`). Both endpoints are **live** on our lk-jwt-service, but whether they resolve to the **same LiveKit room** is unverified — must confirm with a **two-account cross-mode test call** (one device `Matrix_2_0`, one `Legacy`) before changing the default, else split-at-media.
|
||||
|
||||
To actually adopt (future): (1) enable `msc4354_enabled: true` + restart; (2) two-account media-interop test; (3) if unified, flip EC default mode `Legacy`→`Compatibility`/`Matrix_2_0` in the fork + redeploy; (4) keep legacy fallback during transition. **No user benefit until step 3.**
|
||||
|
||||
### [ ] Matrix 2.0 call membership — MSC4354 sticky events (INVESTIGATED 2026-07, deliberately NOT enabled)
|
||||
|
||||
3-agent investigation after the 1.157.1 upgrade (EC-fork behavior · Synapse/upstream readiness · client-fleet composition). **Conclusion: leave `msc4354_enabled` OFF for now** — enabling it is safe but delivers **zero user-visible benefit on its own**, and introduces a latent footgun.
|
||||
|
||||
**Why it's a no-op alone:** the EC fork's `doesServerSupportUnstableFeature(MSC4354)` probe feeds **exactly one thing** — whether the "Matrix 2.0" radio in **Developer Settings** is greyed out (`DeveloperSettingsTab.tsx:349-353`). The real switch is the per-device `matrixRTCMode` setting (`settings.ts:149-152`), which **defaults to `Legacy`** and never auto-enables. Sticky sending is gated at `LocalMember.ts:862` (`unstableSendStickyEvents: mode === Matrix_2_0`). So flipping the server flag changes nothing any client sends.
|
||||
|
||||
**Verified safe:** Synapse-side is **additive and cleanly reversible** — the `sticky_events` schema ships unconditionally (no migration/backfill on enable), every write/read/serialize/replication path is flag-gated, disabling stops it instantly and residual rows self-expire ≤1h. The one relevant bug (#19787 `/sync` EDU-filter) was fixed in 1.155.0; the SQLite<3.40 guard doesn't apply (we're on PG 17.10). Matrix-layer **mixed-mode visibility is safe**: js-sdk `collectMembersEvents` reads **both** sticky and state membership and merges them, so sticky-mode and legacy-mode participants see each other. Our `lk-jwt-service` already serves **both** JWT endpoints (legacy `/sfu/get` **and** the sticky-mode `/get_token` — both probed live, 400-with-validation-error = present). EC is bundled into cinny's build (`@lotusguild/element-call-embedded`), so the fleet upgrades **atomically** — the "all EC clients ≥ v0.17.0" precondition is structurally guaranteed for our own users.
|
||||
|
||||
**The one unresolved risk (blocks a real rollout, not the flag):** sticky mode drops `livekit_alias` and uses `/get_token` (slot `m.call#ROOM`) while legacy uses `/sfu/get` (`room=roomId`). **Whether both resolve to the same LiveKit room is a property of lk-jwt-service, not the client** — unverified. If they diverge, cross-mode participants appear in each other's member list but are **split at the media layer** (silent, no error). Requires a **two-account test call** (one device on Legacy, one on Matrix 2.0) to confirm before anyone relies on it.
|
||||
|
||||
**If we ever do this:** (1) run the two-account media-interop test; (2) only then consider enabling `msc4354_enabled: true` in `/etc/matrix-synapse/homeserver.yaml` (LXC 151) + restart; (3) treat a default-mode change as a separate coordinated EC rollout. MSC4354 is still **OPEN upstream** (not in FCP, `needs-implementation`), so this stays experimental regardless.
|
||||
|
||||
### Remaining spec/MSC gaps (2026-07 full-surface survey)
|
||||
|
||||
After Phases A–C the client spec is ~complete. What's left, flagged by **what unblocks it**:
|
||||
@@ -209,13 +284,25 @@ Shipped in the EC fork (DeepFilterNet3 default-capable / DTLN / RNNoise / Speex;
|
||||
|
||||
Phase 1 shipped: `io.lotus.set_deafen` (LiveKit-source deafen/screenshare-audio-mute) replaces the brittle `<audio>.muted` iframe hack; cinny sends it join-gated alongside the transitional DOM fallback. **Phase 2 (blocked on user npm publish):** publish fork `0.20.1-lotus.2` → bump cinny pin `lotus.1`→`lotus.2` → delete the `CallControl.ts` `.muted` fallback + the EC1–EC6 fixes ship. **Deferred pieces (P6-2b):** the `useCallSpeakers` DOM-scrape is a dormant fallback behind `io.lotus.call_state`; `.click()`-by-`data-testid` UI toggles are low-value fork surface. Divergence to confirm: deafen doesn't silence soundboard/`Unknown`-source audio (setVolume type limit).
|
||||
|
||||
### [ ] Mobile audit
|
||||
### [~] Mobile audit — code-level pass DONE (device QA + deferred items open)
|
||||
|
||||
Comprehensive audit of all LOTUS_FEATURES.md features for mobile PWA usability + responsiveness. Method: 44px touch targets, no horizontal overflow, full-screen modals/drawers on mobile, composer not obscured by keyboard.
|
||||
Comprehensive **code-level** responsive audit of the LOTUS_FEATURES surface (12 survey agents — 6 area slices + 6 deep per-feature dives — each finding verified, fixed in reviewed batches, then a 5-agent all-files regression+efficacy gate; gate-green tsc/eslint/857 tests/build). Shipped `lotus` commits `d6159997` `836e4a66` `4c298a36` `09415f95` `36fdbdd3` `154e35ef` `09f37f89` (M1–M6 + N1–N2): message-table/composer/call-bar/url-preview/explore overflow fixes; full-screen media/file/avatar viewers + touch-pan for zoomed images; full-screen scrollable member profile (+close btn); native SettingsSelect + tile-body volume sliders + measured GifPicker; full-screen Report/"Seen by" dialogs + full-width toasts + popover clamps; **image/video aspect-ratio (no crop/letterbox on phones)**; 44px room-row + space-rail touch targets. The app was found **structurally sound** on mobile (thread panel, dialogs, drawers, settings shells, ACL/widgets/search/QR/auth all already responsive).
|
||||
|
||||
Intentional desktop deltas (disclosed, non-regressive): volume sliders below labels; Report dialog 380→480px & "Seen by" modals 460→360px (sibling-modal normalization); translate select → folds SettingsSelect.
|
||||
|
||||
**NOT done — needs a real device / product decisions (open):**
|
||||
|
||||
- [ ] **Runtime mobile QA** — none of the above is validated on an actual phone (static analysis only). Needs device/devtools walk-through per LOTUS_TESTING §E.
|
||||
- [x] **Element Call fork in-call mobile UI** — DONE (`element-call:lotus` `e36aef8a`, 3-agent survey + 2-agent review). Fixed the EC iframe's own phone UI: footer control row wraps so hangup can't clip (320–500px), portrait 1:1 self-PiP safe-area inset, 44px camera-flip + reaction-picker targets, settings-tab horizontal scroll, landscape spotlight filmstrip. All mobile-gated (EC is mobile-first CSS). Rides to users on the next fork republish (P6-2). Runtime on-device QA still pending (needs a phone).
|
||||
- [ ] **M2 — touch discoverability** — message quick-reactions/actions are hover-gated; long-press is the fallback but is **unreliable on iOS Safari** (deep audit). A visible touch affordance is needed but the naive fix hides unread badges / clutters messages (member-profile-style redesign).
|
||||
- [~] **Sub-44px touch-target sweep** — primary controls DONE via a shared `MobileTouchTarget` `@media` class (`P1`, `8a1168bc`): in-call bar ×7, call-status bar ×4, thread "N replies" chip, knock Approve/Deny, ACL remove. Secondary batch DONE (`r2`, `72e7447d`): image-viewer close/zoom±/zoom%/download, embed-player Close/Collapse/Fullscreen/View-post, read-receipt "seen by" pill. **Deferred (rationale, not built):** PiP fullscreen/resize handles — enlarging four 24px corners to 44px would swallow a ~160px mobile PiP and block "Return to call" (needs a design rethink, not a blunt bump); presence dot is a non-interactive status indicator (no target needed).
|
||||
- [x] **Avatar-decoration `prefers-reduced-motion`** — DONE (`P2`, `c3e1fbff`): renders just the avatar (no animated APNG overlay) under the preference; no static-frame asset to freeze to.
|
||||
- [x] **Twitch/Twitter/TikTok preview cards** — DONE (`r2`, `72e7447d`). These fragment cards render header/thumbnail beside content as direct children of the `UrlPreview` flex row; added `StackOnMobile` (mobile-only `@media (max-width:750px){ flex-direction:column }`) scoped to those variants via `cardClass`. folds `Box` has no default `direction` so the override wins uncontested; desktop unchanged (verified by 2 review agents). Pre-existing desktop quirk (header bar beside content on Twitter/TikTok at desktop width) left as-is — the fuller fix is wrapping each card body in a column `Box`; out of scope for a mobile pass.
|
||||
- [ ] **M2 — message action/quick-reaction touch discoverability** — hover-gated + iOS-long-press-unreliable; a visible touch affordance collides with unread-badge placement / per-message clutter → needs a design decision + device look.
|
||||
|
||||
### [ ] Inline media embeds — remaining providers (LOW PRIORITY)
|
||||
|
||||
The inline embed system (`videoEmbed.ts`) covers 16 providers; three more were **deliberately deferred** (verified against 2026 docs by review agents):
|
||||
The inline embed system (`videoEmbed.ts`) covers 18 providers (16 + Mixcloud/Deezer); three more were **deliberately deferred** (verified against 2026 docs by review agents):
|
||||
|
||||
- **Bandcamp** (highest-value audio add) — needs an **oEmbed** round-trip: the player URL requires numeric `album`/`track` item ids that aren't in the page URL (`bandcamp.com/oembed` is the resolver; mirror the `TikTokEmbedCard` on-click oEmbed pattern). CSP `frame-src`: `bandcamp.com`. Classify `kind: 'audio'`.
|
||||
- **SoundCloud `on.soundcloud.com` short links** — the `w.soundcloud` widget resolver does **not** follow the redirect; needs the same on-click oEmbed resolve (`soundcloud.com/oembed`, CORS-enabled) to get the canonical URL. (Canonical `soundcloud.com/{user}/{track}` links already work.)
|
||||
@@ -223,6 +310,19 @@ The inline embed system (`videoEmbed.ts`) covers 16 providers; three more were *
|
||||
|
||||
Also open (from the quality review): a real `onError`/error-state fallback for iframes that fail to load (deleted post / region lock / X login-wall) — cross-origin frames don't fire `onError` reliably, so this needs a load-timeout heuristic; the Close button + badge link are the current escape hatch.
|
||||
|
||||
**✅ Steam detailed embed (2026-07, 2-agent review) — `ef82650c`.** `store.steampowered.com` content URLs get rich cards: **app** pages → OG capsule header + click-to-play facade → Steam's official `/widget/{id}` store iframe (live region-aware price / discount % / Buy on Steam, gated by `inlineMediaEmbeds`); **news/announcement** → banner + headline + body-preview card; **bundle/sub/dlc** → OG store card. `getSteamTarget`/`steamWidgetEmbedUrl` in `videoEmbed.ts` (+tests). Grounded in prod CSP (`frame-src https:` allows the widget with no infra change; images via homeserver `mxc`; NO client-side Steam API — `connect-src` + Steam CORS both block it, which is the honest ceiling: no review scores/genres/screenshots client-side). **Needs on-device QA:** the live widget iframe height/fit (can't render headlessly) — verify the price/Buy stay visible on desktop-wide and phone.
|
||||
|
||||
**✅ GIF previews now animate + Mixcloud/Deezer embeds (2026-07, 2-agent review) — `4154cae5`.** Reported live: a `media.giphy.com` link "shows the gif's image but doesn't play it." Root cause: **Synapse's `/thumbnail` endpoint flattens animated GIFs to a still first frame**, and every preview image went through it. `GifCard` (Giphy/Tenor) + the generic OG card now request the **original** via `/download` (`mxcUrlToHttp` with no width/height) when the preview is a GIF (`og:image:type === 'image/gif'` or a `.gif` pathname). Guarded: `shouldServeGifOriginal()` keeps the frozen thumbnail past a **10 MB** `matrix:image:size` cap, and the generic card's eager `<img>` gained the `loading="lazy"` it was the only preview image missing. Also added **Mixcloud + Deezer** audio embeds, and fixed Deezer podcasts (they live at `/show/<id>`, **not** `/podcast/<id>` — the latter 404s on Deezer's own oEmbed; verified against the live API). **Needs on-device QA:** confirm a large GIF still animates and doesn't stall the timeline.
|
||||
|
||||
**✅ Embed bug hunt (2026-07, 3 survey agents + 2-agent review) — `f2673eff`.** Core posture verified **sound** (iframe sandbox, `useIframeAutoHeight` postMessage origin+source trust, no XSS/`dangerouslySetInnerHTML`, `rel="noreferrer"` on all 21 links, oEmbed no-SSRF, the whole facade→iframe/abort/observer lifecycle). Fixed: Twitch/Kick/SoundCloud/Streamable reserved-path over-match (utility pages rendered as broken players), Vimeo hash over-capture (`[0-9a-f]{6,}`), Spotify/Steam/Discord/IMDb `og:image` now via `mxcUrlToHttp` (was a broken raw `mxc://` `<img>` + a pre-click 3p-request facade bypass), `wide` class follows the og:url-resolved embed, Twitter host alignment (`mobile.twitter.com`/`/statuses/`), URL de-dupe.
|
||||
|
||||
**Deferred / surfaced from the hunt (not fixed — decide before doing):**
|
||||
|
||||
- **Security-vs-functionality tradeoff (needs a call):** drop `allow-popups-to-escape-sandbox` and/or `clipboard-write` from `EMBED_SANDBOX`/`allow=` on embed iframes — real hardening against a _compromised_ provider (phishing popup / clipboard hijack), but risks breaking a legit provider popup/copy on the trusted major providers we embed. Low marginal value; not shipped blindly.
|
||||
- **Defense-in-depth:** `encodeURIComponent` the Bluesky authority + Apple Music path/search interpolated into the embed `src` (not currently exploitable — host is fixed and value comes from `URL.pathname`; React escapes the attribute).
|
||||
- **Out of embed scope (real, low-sev):** `LotusDenoiseFeature` (`ClientNonUIFeatures.tsx`) has a `window` `message` listener with **no origin/source check** → any frame/window can post `{type:'lotus-denoise-status', error}` and pop a forged **"System"** toast (text only, no XSS). Validate `event.source`.
|
||||
- **Lifecycle Lows (cosmetic/latent):** a re-fetch flips a playing embed back to the spinner (latent — url is keyed); auto-height retained across close→reopen; `extractEmbedHeight` generic `.height` fallback accepts any allowed-origin message; `TweetEmbed` theme is a one-time `matchMedia` snapshot (no live theme switch); host-normalization gaps (`vt.tiktok.com` misses `StackOnMobile`, `m.instagram.com`, `www.youtu.be`).
|
||||
|
||||
### Deferred / dropped (decided — kept for context)
|
||||
|
||||
- **[DEFERRED] P5-51** Federated "Identity Contexts" (session isolation) — multi-sprint, touches auth/crypto/storage core; smaller intermediate step = plain multi-account switch. **[DROPPED] P5-52** per-room sync governor — js-sdk can't truly per-room filter `/sync`; only a cosmetic hide. **[DEFERRED] P5-53** local scripting plugin — prefer a declarative automation-rules feature (no arbitrary code). **[DEFERRED] Audit-3** profile banner — MSC4427 open/unmerged; revisit on merge. **[WON'T FIX] P5-50** Windows HW media pipeline (WebRTC decode lives in WebView2; not injectable). **[MOVED] P5-9** LFG → LotusBot `!lfg`.
|
||||
@@ -231,11 +331,11 @@ Also open (from the quality review): a real `onError`/error-state fallback for i
|
||||
|
||||
## 🚫 Blocked Features (server / upstream gated)
|
||||
|
||||
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade.
|
||||
Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgrade. **Re-checked on 1.157.1 (2026-07-23): no change — all four below are still `false`.** The 1.156.0→1.157.1 delta unblocked nothing (it's a bugfix release; the only feature-bearing release in the gap was 1.156.0, which we were already running).
|
||||
|
||||
- **[BLOCKED] Live Location Sharing** (MSC3489 + MSC3672 both `false`) — real-time GPS beacons over the existing static share.
|
||||
- **[BLOCKED] Reaction/Relation Redaction** (MSC3892 `false`) — remove a reaction without redacting the parent; current full-redaction fallback is acceptable.
|
||||
- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate` → `RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the *unstable* `im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card.
|
||||
- **[DONE 2026-07] Room Preview before joining** (MSC3266) — the client was always built (`JoinBeforeNavigate` → `RoomCard` via `mx.getRoomSummary`). The earlier "blocked" flag was a **misdiagnosis**: it tested `/v1/rooms/{id}/summary` (404), but the SDK calls the _unstable_ `im.nheko.summary/summary/{id}` path, which returns **200** with name/topic/members/join_rule. Verified live after the 1.156 upgrade; also added a join-rule/encryption chip + Request-to-join for knock rooms to the preview card.
|
||||
- **[BLOCKED] Thread Subscriptions** (MSC4306 `false`) — "Follow thread" button (depends on the shipped Thread Panel).
|
||||
|
||||
---
|
||||
@@ -244,8 +344,9 @@ Re-run `/_matrix/client/versions` + `unstable_features` after each Synapse upgra
|
||||
|
||||
### Server Capabilities (as of 2026-07)
|
||||
|
||||
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.156.0+trixie1` (upgraded 2026-07-07 from 1.155; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
|
||||
- **MSC ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892`.
|
||||
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.157.1+trixie1` (upgraded 2026-07-23 from **1.156.0** — note the host was found on 1.156.0 while the docs claimed 1.155.0, so **always verify with `dpkg-query -W matrix-synapse-py3`**, don't trust the docs; apt package on Debian 13, LXC 151) · **Matrix spec** up to `v1.12` (Synapse still advertises v1.12; MSC features via `unstable_features`).
|
||||
- **MSC ON** (re-dumped live from `/_matrix/client/versions` on 1.157.1): `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (room summary live at unstable `im.nheko.summary/summary/{id}` — 200; the `/v1/rooms/{id}/summary` path is still 404) · `msc3401_matrix_rtc` · `msc2285.stable` · `msc3827.stable` · `msc3981` · `msc4380.stable` · `msc4445` · `msc2659.stable` · `msc2666` · `msc2432` · `e2e_cross_signing` · `label_based_filtering`. **OFF/blocked:** `msc4306` · `msc3882` · `msc3912` · `msc4155` · `msc3489`/`msc3672` · `msc3892` · `msc4028` · `msc4069` · `msc4108` · `msc3391` · `msc4354` (sticky events — **deliberately off**, see the Matrix 2.0 section above) · `msc4143` (RTC foci — **not a gap**: LiveKit is discovered via `.well-known` `org.matrix.msc4143.rtc_foci`, confirmed live, not this flag).
|
||||
- **Dead client code:** Synapse 1.157.0 **removed** `msc3861` (MAS auth delegation) entirely — the ~6 `msc3861`/`msc2965` references in `src/` can never activate against this homeserver (we auth via Authelia `oidc_providers`). Harmless, but cleanup material.
|
||||
- **Live endpoints:** Report User (MSC4260) **200** ✅ · Report Room (MSC4151) ✅.
|
||||
- **Homeserver access (audits):** Synapse = LXC 151 (`pct exec 151 -- bash`), config `/etc/matrix-synapse/homeserver.yaml`. Web deploy = LXC 106. Voice guard = `voice-limit-guard.py` on LXC 151.
|
||||
- **SDK notes:** no arbitrary profile-field methods (use `mx.http.authedRequest()` for MSC4133); js-sdk can't per-room filter `/sync`; sanitizer strips `<math>`/MathML; SW exists at `src/sw.ts`; `getMatrixToRoom()` builds invite URLs; EC audio-inject unblocked via the fork's `io.lotus.inject_audio`.
|
||||
@@ -292,8 +393,20 @@ Also flag-gated: `lotusTransparent`/`lotusTheme`, `lotusDenoiseSource=1` (in-sou
|
||||
|
||||
```
|
||||
edit → commit → git push origin lotus
|
||||
→ Gitea Actions: tsc --noEmit, eslint, prettier (~3 min)
|
||||
→ lotus_deploy.sh on LXC 106 polls CI → npm ci && npm run build → rsync → live (~11 min)
|
||||
→ Gitea Actions (.gitea/workflows/ci.yml): npm ci → build + npm test + tsc + eslint + prettier (ALL hard gates) → audit + bundle-size (informational)
|
||||
→ lotus_deploy.sh on LXC 106 polls the "Build & Quality Checks" status → npm ci && npm run build → rsync → live (~11 min)
|
||||
```
|
||||
|
||||
Before marking a feature complete: `npx tsc --noEmit` (0 errors) · `npx eslint src/` (0 new) · `npx prettier --check src/` · `npm test` (Node runner via tsx, hard CI gate — colocated `*.test.ts`) · update `README.md`/`landing/index.html` for Lotus-custom features · visually verify on `chat.lotusguild.org`.
|
||||
|
||||
**CI hardening (2026-07, reviewed):**
|
||||
|
||||
- [x] **Concurrency** — `cancel-in-progress` on cinny `ci.yml` and cinny-desktop `release.yml` (`386a2979` / `c5461ce`): a superseded lotus push cancels its in-flight web CI and collapses queued ~30-min Tauri desktop builds to just the newest. Safe for deploys because `lotus_deploy.sh` now **follows origin/lotus HEAD** each poll iteration + resets to the gated SHA (`matrix` `c15a489`) — closes the latched-SHA freeze race.
|
||||
- [x] **Hard quality gates** — typecheck/eslint/prettier promoted from `continue-on-error` to blocking (tree held clean). eslint gates on errors only; `no-explicit-any` warnings stay informational.
|
||||
|
||||
**CI follow-ups (open):**
|
||||
|
||||
- [ ] **Dedicated `desktop-linux` runner** (infra) — concurrency only collapses _burst_ stacking; a single in-flight `build-linux` (Tauri, `ubuntu-latest`) still shares the runner with web CI and can queue a web CI/deploy up to ~30 min. Fix = register a 2nd Linux act_runner labelled `desktop-linux` (root, network, RAM for a Tauri build; do NOT also label it `ubuntu-latest`) and point only `build-linux: runs-on` at it. Relabeling without a matching runner hangs the job forever.
|
||||
- [ ] **Debounce the desktop trigger** — `trigger-desktop` fires a full desktop build on _every_ lotus commit; consider tag/`workflow_dispatch`/schedule-gating to decouple desktop cadence from web commits (biggest remaining runner-load source).
|
||||
- [ ] **Verify Gitea ≥ 1.24** actually honors workflow `concurrency` (older silently ignores it → safe no-op, but the change is then inert — confirm on a test burst).
|
||||
- [ ] **Deferred (chosen-not-now):** build-once/deploy-the-artifact (kill the CI-then-deploy double build); CI-gate the `lotus-build.sh` upstream-merge path (currently builds+deploys+then pushes, bypassing CI).
|
||||
|
||||
@@ -36,6 +36,7 @@ The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the o
|
||||
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
||||
- Search messages with a date range filter
|
||||
- Optional persistent search index for encrypted rooms (off by default — stores decrypted text on your device; clearable, wiped on logout)
|
||||
- On-device message translation — foreign-language messages show a "Translate" action, then an inline "Translated from <language> · Show original" toggle. Runs entirely on your device via the browser's built-in translation models, so message text never leaves your device or touches a cloud service (no Google/DeepL/Microsoft) — preserving end-to-end encryption. Pick your target language and optionally auto-translate incoming messages at Settings → General → Messages. Chromium desktop (Chrome/Edge 138+) and the Lotus desktop app only; hidden where unavailable (Firefox, Safari, mobile)
|
||||
- Write math with LaTeX: `$inline$` and `$$block$$` render via KaTeX (spec `data-mx-maths` supported)
|
||||
- Room topics support rich formatting (bold, links, italics)
|
||||
- Deleted messages show a placeholder instead of disappearing
|
||||
|
||||
@@ -42,7 +42,7 @@ import { CallEmbed, useCallControlState } from '../plugins/call';
|
||||
import { useSelectedRoom } from '../hooks/router/useSelectedRoom';
|
||||
import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { previewRingtone, startRingtone } from '../utils/ringtones';
|
||||
import { previewRingtone, startRingtone, unlockRingtoneAudio } from '../utils/ringtones';
|
||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||
import { useCallQuality } from '../hooks/useCallQuality';
|
||||
@@ -473,8 +473,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
|
||||
const sender = event.getSender();
|
||||
const content = event.getContent<IRTCNotificationContent>();
|
||||
// Trust the caller's sender_ts only when it's within 20s of the server's
|
||||
// timestamp in EITHER direction. A fast caller clock made a fresh invite
|
||||
// look expired-in-the-future; a SLOW one left sender_ts in the past so
|
||||
// `Date.now() >= senderTs + lifetime` hid/dismissed the ring for a
|
||||
// genuinely fresh invite (COR-3). Fall back to the server ts on large skew.
|
||||
const senderTs =
|
||||
content.sender_ts - event.getTs() > 20000 ? event.getTs() : content.sender_ts;
|
||||
typeof content.sender_ts === 'number' &&
|
||||
Math.abs(content.sender_ts - event.getTs()) <= 20000
|
||||
? content.sender_ts
|
||||
: event.getTs();
|
||||
const lifetime = Math.min(content.lifetime, 120000);
|
||||
const notificationType = content.notification_type;
|
||||
const relation =
|
||||
@@ -695,6 +703,23 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
|
||||
const { screenshare: pipScreenshare } = useCallControlState(callEmbed?.control);
|
||||
|
||||
// C-L3 — prime the ringtone AudioContext on the first user gesture of the
|
||||
// session so the first incoming-call ring isn't silent (a fresh context stays
|
||||
// suspended until a gesture, and an incoming ring has none of its own).
|
||||
useEffect(() => {
|
||||
const prime = () => {
|
||||
unlockRingtoneAudio();
|
||||
window.removeEventListener('pointerdown', prime);
|
||||
window.removeEventListener('keydown', prime);
|
||||
};
|
||||
window.addEventListener('pointerdown', prime, { once: true, passive: true });
|
||||
window.addEventListener('keydown', prime, { once: true, passive: true });
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', prime);
|
||||
window.removeEventListener('keydown', prime);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Sync pip mode into CallControl so it can adjust behavior accordingly
|
||||
useEffect(() => {
|
||||
if (!callEmbed) return;
|
||||
@@ -706,8 +731,24 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
||||
// When screenshare ends, release the spotlight we auto-enabled.
|
||||
const pipAutoSpotlightRef = React.useRef(false);
|
||||
useEffect(() => {
|
||||
if (!pipMode || !callEmbed) return;
|
||||
if (pipScreenshare) {
|
||||
if (!callEmbed) {
|
||||
// The embed (and its spotlight) is torn down with the call; drop the latch
|
||||
// so a stale ref can't act on the next call's fresh embed.
|
||||
pipAutoSpotlightRef.current = false;
|
||||
return;
|
||||
}
|
||||
// Spotlight is wanted only while in pip with an active screenshare. Release
|
||||
// it when EITHER ends — including leaving pip (returning to the call room).
|
||||
// The release must not sit behind a `!pipMode` early-return, or a
|
||||
// screenshare→pip→back sequence leaves the auto-enabled spotlight stuck on
|
||||
// with pipAutoSpotlightRef latched true. The ref gates release so we only
|
||||
// ever undo a spotlight we turned on (never one the user set).
|
||||
// NB: `control.spotlight` is read below but deliberately NOT a dependency —
|
||||
// this effect reacts to pip/screenshare *intent*, not to spotlight changes.
|
||||
// Adding it as a dep would re-run on every manual spotlight toggle and fight
|
||||
// the user.
|
||||
const wantSpotlight = pipMode && pipScreenshare;
|
||||
if (wantSpotlight) {
|
||||
if (!callEmbed.control.spotlight) {
|
||||
callEmbed.control.toggleSpotlight();
|
||||
pipAutoSpotlightRef.current = true;
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useRef, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { Grid, SearchBar, SearchContext, SearchContextManager } from '@giphy/react-components';
|
||||
import { IGif } from '@giphy/js-types';
|
||||
import { Box, color, config } from 'folds';
|
||||
import { useElementSizeObserver } from '../hooks/useElementSizeObserver';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { addRecentGif, RecentGif, recentGifsAtom } from '../state/recentGifs';
|
||||
|
||||
const PICKER_WIDTH = 312;
|
||||
const PICKER_WIDTH_CSS = `min(${PICKER_WIDTH}px, calc(100vw - 16px))`;
|
||||
@@ -15,24 +18,147 @@ type GifPickerInnerProps = {
|
||||
lotusTerminal: boolean;
|
||||
};
|
||||
|
||||
// Small monospace section header matching the picker's `// GIF_SEARCH` treatment
|
||||
// (lotusTerminal) / a muted label otherwise.
|
||||
const RECENT_LABEL_ID = 'gif-picker-recent-label';
|
||||
|
||||
function SectionLabel({
|
||||
text,
|
||||
lotusTerminal,
|
||||
id,
|
||||
}: {
|
||||
text: string;
|
||||
lotusTerminal: boolean;
|
||||
id?: string;
|
||||
}) {
|
||||
if (lotusTerminal) {
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
style={{
|
||||
padding: '4px 2px',
|
||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
|
||||
fontSize: '10px',
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.1em',
|
||||
color: 'var(--lt-accent-orange)',
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{`// ${text.toUpperCase()}`}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div
|
||||
id={id}
|
||||
style={{
|
||||
padding: '2px 2px 4px',
|
||||
fontSize: '11px',
|
||||
fontWeight: 600,
|
||||
color: color.Surface.OnContainer,
|
||||
opacity: 0.6,
|
||||
userSelect: 'none',
|
||||
}}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RecentGifs({
|
||||
recents,
|
||||
lotusTerminal,
|
||||
onPick,
|
||||
}: {
|
||||
recents: RecentGif[];
|
||||
lotusTerminal: boolean;
|
||||
onPick: (gif: RecentGif) => void;
|
||||
}) {
|
||||
return (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<SectionLabel text="Recent" lotusTerminal={lotusTerminal} id={RECENT_LABEL_ID} />
|
||||
<div
|
||||
role="group"
|
||||
aria-labelledby={RECENT_LABEL_ID}
|
||||
style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 4 }}
|
||||
>
|
||||
{recents.map((g, i) => (
|
||||
<button
|
||||
key={g.url}
|
||||
type="button"
|
||||
aria-label={`Send recent GIF ${i + 1} of ${recents.length}`}
|
||||
onClick={() => onPick(g)}
|
||||
style={{
|
||||
padding: 0,
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
cursor: 'pointer',
|
||||
height: 72,
|
||||
borderRadius: lotusTerminal ? '4px' : '8px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{/* A still preview (falls back to the animated url for pre-existing
|
||||
recents) so the Recent row doesn't autoplay many GIFs at once. */}
|
||||
<img
|
||||
src={g.previewUrl ?? g.url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover', display: 'block' }}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInnerProps) {
|
||||
const { fetchGifs, searchKey } = React.useContext(SearchContext);
|
||||
const { fetchGifs, searchKey, term } = React.useContext(SearchContext);
|
||||
const [recents, setRecents] = useAtom(recentGifsAtom);
|
||||
|
||||
const sendGif = useCallback(
|
||||
(gif: RecentGif) => {
|
||||
setRecents((prev) => addRecentGif(prev, gif));
|
||||
onSelect(gif.url, gif.width, gif.height);
|
||||
requestClose();
|
||||
},
|
||||
[onSelect, requestClose, setRecents],
|
||||
);
|
||||
|
||||
const handleClick = useCallback(
|
||||
(gif: IGif, e: React.SyntheticEvent) => {
|
||||
e.preventDefault();
|
||||
const r = gif.images.downsized ?? gif.images.original;
|
||||
const { url } = r;
|
||||
const width = Number(r.width) || 200;
|
||||
const height = Number(r.height) || 200;
|
||||
onSelect(url, width, height);
|
||||
requestClose();
|
||||
const previewUrl =
|
||||
gif.images.fixed_width_small_still?.url ??
|
||||
gif.images.downsized_still?.url ??
|
||||
gif.images.original_still?.url;
|
||||
sendGif({
|
||||
url: r.url,
|
||||
width: Number(r.width) || 200,
|
||||
height: Number(r.height) || 200,
|
||||
previewUrl,
|
||||
});
|
||||
},
|
||||
[onSelect, requestClose],
|
||||
[sendGif],
|
||||
);
|
||||
|
||||
const showRecents = recents.length > 0 && !(term ?? '').trim();
|
||||
|
||||
// The container is min(312px, 100vw-16); feed the Grid the live pixel width
|
||||
// (minus the inner 8px padding on each side) so it doesn't overflow a phone
|
||||
// narrower than 312px with a fixed 296px grid.
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [gridWidth, setGridWidth] = useState(PICKER_WIDTH - 16);
|
||||
useElementSizeObserver(
|
||||
useCallback(() => containerRef.current, []),
|
||||
useCallback((w) => setGridWidth(Math.max(1, Math.floor(w) - 16)), []),
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }}>
|
||||
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }} ref={containerRef}>
|
||||
{lotusTerminal && (
|
||||
<div
|
||||
style={{
|
||||
@@ -57,10 +183,13 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
||||
<div
|
||||
style={{ overflowY: 'auto', overflowX: 'hidden', maxHeight: '340px', padding: '0 8px 8px' }}
|
||||
>
|
||||
{showRecents && (
|
||||
<RecentGifs recents={recents} lotusTerminal={lotusTerminal} onPick={sendGif} />
|
||||
)}
|
||||
<Grid
|
||||
key={searchKey}
|
||||
fetchGifs={fetchGifs}
|
||||
width={PICKER_WIDTH - 16}
|
||||
width={gridWidth}
|
||||
columns={2}
|
||||
gutter={4}
|
||||
onGifClick={handleClick}
|
||||
|
||||
@@ -2,7 +2,7 @@ import React from 'react';
|
||||
import { MsgType } from 'matrix-js-sdk';
|
||||
import { HTMLReactParserOptions } from 'html-react-parser';
|
||||
import { Opts } from 'linkifyjs';
|
||||
import { config } from 'folds';
|
||||
import { config, Text } from 'folds';
|
||||
import {
|
||||
AudioContent,
|
||||
DownloadFile,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
MAudio,
|
||||
MBadEncrypted,
|
||||
MEmote,
|
||||
MessageEditedContent,
|
||||
MFile,
|
||||
MImage,
|
||||
MLocation,
|
||||
@@ -87,8 +88,9 @@ export function RenderMessageContent({
|
||||
}: RenderMessageContentProps) {
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
||||
// fetches + iframes at once.
|
||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url)).slice(0, 6);
|
||||
// fetches + iframes at once. De-dupe first: a message linking the same URL
|
||||
// twice would otherwise render sibling cards with identical React keys.
|
||||
const filteredUrls = [...new Set(urls.filter((url) => !testMatrixTo(url)))].slice(0, 6);
|
||||
if (filteredUrls.length === 0) return undefined;
|
||||
return (
|
||||
<UrlPreviewHolder>
|
||||
@@ -119,6 +121,15 @@ export function RenderMessageContent({
|
||||
/>
|
||||
);
|
||||
}
|
||||
// No caption, but the media was edited (e.g. a caption was removed): keep the
|
||||
// "(edited)" affordance so Edit History stays reachable.
|
||||
if (edited) {
|
||||
return (
|
||||
<Text style={{ marginTop: config.space.S200 }} size="T200">
|
||||
<MessageEditedContent onEditHistoryClick={onEditHistoryClick} />
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,5 +1,19 @@
|
||||
import React from 'react';
|
||||
import { Menu, PopOut, toRem } from 'folds';
|
||||
import {
|
||||
Box,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Menu,
|
||||
Modal,
|
||||
Overlay,
|
||||
OverlayBackdrop,
|
||||
OverlayCenter,
|
||||
PopOut,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useCloseUserRoomProfile, useUserRoomProfileState } from '../state/hooks/userRoomProfile';
|
||||
import { UserRoomProfile } from './user-profile';
|
||||
@@ -8,6 +22,20 @@ import { useAllJoinedRoomsSet, useGetRoom } from '../hooks/useGetRoom';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
import { SpaceProvider } from '../hooks/useSpace';
|
||||
import { RoomProvider } from '../hooks/useRoom';
|
||||
import { ScreenSize, useScreenSize } from '../hooks/useScreenSize';
|
||||
|
||||
// Matches useModalStyle's mobile branch: fill the phone screen with internal
|
||||
// scroll so tall profiles (moderation actions, device list, notes) are fully
|
||||
// reachable — the anchored 340px popout below can't scroll and clipped them.
|
||||
const MOBILE_FULLSCREEN = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '100%',
|
||||
borderRadius: 0,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
} as const;
|
||||
|
||||
function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState }) {
|
||||
const { roomId, spaceId, userId, cords, position } = state;
|
||||
@@ -15,32 +43,61 @@ function UserRoomProfileContextMenu({ state }: { state: UserRoomProfileState })
|
||||
const getRoom = useGetRoom(allJoinedRooms);
|
||||
const room = getRoom(roomId);
|
||||
const space = spaceId ? getRoom(spaceId) : undefined;
|
||||
const screenSize = useScreenSize();
|
||||
|
||||
const close = useCloseUserRoomProfile();
|
||||
|
||||
if (!room) return null;
|
||||
|
||||
const profile = (
|
||||
<SpaceProvider value={space ?? null}>
|
||||
<RoomProvider value={room}>
|
||||
<UserRoomProfile userId={userId} />
|
||||
</RoomProvider>
|
||||
</SpaceProvider>
|
||||
);
|
||||
|
||||
const focusTrapOptions = {
|
||||
initialFocus: false,
|
||||
onDeactivate: close,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
};
|
||||
|
||||
// On phones, render as a full-screen scrollable modal instead of an anchored,
|
||||
// fixed-width, unscrollable popout.
|
||||
if (screenSize === ScreenSize.Mobile) {
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
<FocusTrap focusTrapOptions={focusTrapOptions}>
|
||||
<Modal size="500" style={MOBILE_FULLSCREEN}>
|
||||
{/* Full-screen covers the backdrop (no tap-to-dismiss) and the
|
||||
profile has no self-close, so provide an explicit close. */}
|
||||
<Header size="600" style={{ flexShrink: 0, paddingRight: config.space.S200 }}>
|
||||
<Box grow="Yes" />
|
||||
<IconButton size="300" radii="300" onClick={close} aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
<Box grow="Yes" style={{ overflow: 'hidden auto' }}>
|
||||
{profile}
|
||||
</Box>
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position={position ?? 'Top'}
|
||||
align="Start"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: close,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ width: toRem(340) }}>
|
||||
<SpaceProvider value={space ?? null}>
|
||||
<RoomProvider value={room}>
|
||||
<UserRoomProfile userId={userId} />
|
||||
</RoomProvider>
|
||||
</SpaceProvider>
|
||||
</Menu>
|
||||
<FocusTrap focusTrapOptions={focusTrapOptions}>
|
||||
<Menu style={{ width: toRem(340) }}>{profile}</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -2,8 +2,9 @@ import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Text, color, config, toRem } from 'folds';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { MobileTouchTarget } from '../styles/mobile.css';
|
||||
|
||||
type RecorderState = 'idle' | 'recording' | 'preview';
|
||||
type RecorderState = 'idle' | 'recording' | 'paused' | 'preview';
|
||||
|
||||
interface VoiceRecorderProps {
|
||||
onSend: (blob: Blob, mimeType: string, durationMs: number, waveform: number[]) => void;
|
||||
@@ -41,11 +42,15 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const chunksRef = useRef<Blob[]>([]);
|
||||
const analyserRef = useRef<AnalyserNode | null>(null);
|
||||
const audioCtxRef = useRef<AudioContext | null>(null);
|
||||
const rawSamplesRef = useRef<number[]>([]);
|
||||
const startTimeRef = useRef<number>(0);
|
||||
// Active-recording duration excluding paused time: accumulated ms from prior
|
||||
// segments + (now - segmentStart) for the current segment.
|
||||
const accumulatedMsRef = useRef<number>(0);
|
||||
const segmentStartRef = useRef<number>(0);
|
||||
const animFrameRef = useRef<number>(0);
|
||||
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
@@ -54,18 +59,67 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
const previewAudioRef = useRef<HTMLAudioElement | null>(null);
|
||||
const [previewPlaying, setPreviewPlaying] = useState(false);
|
||||
|
||||
const stopAll = useCallback(() => {
|
||||
// Start the waveform (rAF) + duration (interval) meters against the live
|
||||
// analyser. Reused by startRecording and resumeRecording.
|
||||
const startMeters = useCallback(() => {
|
||||
const analyser = analyserRef.current;
|
||||
if (!analyser) return;
|
||||
// Guard against ever running two loops.
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
if (timerRef.current) clearInterval(timerRef.current);
|
||||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||
const tick = () => {
|
||||
if (!analyserRef.current) return;
|
||||
analyserRef.current.getByteFrequencyData(buf);
|
||||
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
|
||||
rawSamplesRef.current.push(avg);
|
||||
setWaveformBars((prev) => [...prev.slice(1), Math.round((avg / 255) * 100)]);
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
timerRef.current = setInterval(() => {
|
||||
setDurationMs(accumulatedMsRef.current + (Date.now() - segmentStartRef.current));
|
||||
}, 100);
|
||||
}, []);
|
||||
|
||||
// Stop the meters (rAF + interval) without tearing down the audio graph, so a
|
||||
// paused recording can resume.
|
||||
const stopMeters = useCallback(() => {
|
||||
if (animFrameRef.current) cancelAnimationFrame(animFrameRef.current);
|
||||
if (timerRef.current) {
|
||||
clearInterval(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Release the microphone. The mic tracks are independent of the MediaRecorder
|
||||
// and the AudioContext — neither mr.stop() nor audioCtx.close() releases them —
|
||||
// so they must be stopped explicitly (else the OS mic indicator stays on).
|
||||
const stopStream = useCallback(() => {
|
||||
streamRef.current?.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}, []);
|
||||
|
||||
const stopAll = useCallback(() => {
|
||||
stopMeters();
|
||||
if (audioCtxRef.current) {
|
||||
audioCtxRef.current.close();
|
||||
audioCtxRef.current = null;
|
||||
}
|
||||
analyserRef.current = null;
|
||||
}, []);
|
||||
}, [stopMeters]);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
// Unmounting mid-recording/pause must release the mic — stopAll() only
|
||||
// closes the AudioContext and cancels the meters, not the mic tracks.
|
||||
const mr = mediaRecorderRef.current;
|
||||
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
||||
mr.ondataavailable = null;
|
||||
mr.onstop = null;
|
||||
mr.stop();
|
||||
}
|
||||
stopStream();
|
||||
stopAll();
|
||||
if (previewUrl) URL.revokeObjectURL(previewUrl);
|
||||
},
|
||||
@@ -83,9 +137,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
|
||||
const mr = new MediaRecorder(stream, { mimeType });
|
||||
mediaRecorderRef.current = mr;
|
||||
streamRef.current = stream;
|
||||
chunksRef.current = [];
|
||||
rawSamplesRef.current = [];
|
||||
startTimeRef.current = Date.now();
|
||||
accumulatedMsRef.current = 0;
|
||||
segmentStartRef.current = Date.now();
|
||||
|
||||
const audioCtx = new AudioContext();
|
||||
audioCtxRef.current = audioCtx;
|
||||
@@ -95,24 +151,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
source.connect(analyser);
|
||||
analyserRef.current = analyser;
|
||||
|
||||
const buf = new Uint8Array(analyser.frequencyBinCount);
|
||||
const tick = () => {
|
||||
if (!analyserRef.current) return;
|
||||
analyserRef.current.getByteFrequencyData(buf);
|
||||
const avg = buf.reduce((a, b) => a + b, 0) / buf.length;
|
||||
rawSamplesRef.current.push(avg);
|
||||
|
||||
setWaveformBars((prev) => {
|
||||
const next = [...prev.slice(1), Math.round((avg / 255) * 100)];
|
||||
return next;
|
||||
});
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
animFrameRef.current = requestAnimationFrame(tick);
|
||||
|
||||
timerRef.current = setInterval(() => {
|
||||
setDurationMs(Date.now() - startTimeRef.current);
|
||||
}, 100);
|
||||
startMeters();
|
||||
|
||||
mr.ondataavailable = (e) => {
|
||||
if (e.data.size > 0) chunksRef.current.push(e.data);
|
||||
@@ -120,8 +159,8 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
|
||||
mr.onstop = () => {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
const blob = new Blob(chunksRef.current, { type: mimeType });
|
||||
previewDurationRef.current = Date.now() - startTimeRef.current;
|
||||
setPreviewBlob(blob);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
@@ -135,33 +174,59 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
} catch {
|
||||
onError?.('Microphone access denied');
|
||||
}
|
||||
}, [onError]);
|
||||
}, [onError, startMeters]);
|
||||
|
||||
const pauseRecording = useCallback(() => {
|
||||
const mr = mediaRecorderRef.current;
|
||||
if (mr?.state !== 'recording') return;
|
||||
accumulatedMsRef.current += Date.now() - segmentStartRef.current;
|
||||
setDurationMs(accumulatedMsRef.current);
|
||||
stopMeters();
|
||||
mr.pause();
|
||||
setState('paused');
|
||||
}, [stopMeters]);
|
||||
|
||||
const resumeRecording = useCallback(() => {
|
||||
const mr = mediaRecorderRef.current;
|
||||
if (mr?.state !== 'paused') return;
|
||||
segmentStartRef.current = Date.now();
|
||||
mr.resume();
|
||||
startMeters();
|
||||
setState('recording');
|
||||
}, [startMeters]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
const mr = mediaRecorderRef.current;
|
||||
const activeMs = mr?.state === 'recording' ? Date.now() - segmentStartRef.current : 0;
|
||||
previewDurationRef.current = accumulatedMsRef.current + activeMs;
|
||||
stopAll();
|
||||
if (mediaRecorderRef.current?.state === 'recording') {
|
||||
mediaRecorderRef.current.stop();
|
||||
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
||||
mr.stop();
|
||||
}
|
||||
}, [stopAll]);
|
||||
|
||||
const cancelRecording = useCallback(() => {
|
||||
stopAll();
|
||||
const mr = mediaRecorderRef.current;
|
||||
if (mr?.state === 'recording') {
|
||||
if (mr && (mr.state === 'recording' || mr.state === 'paused')) {
|
||||
mr.ondataavailable = null;
|
||||
// onstop (which would release the mic) is cleared, so release it here.
|
||||
mr.onstop = null;
|
||||
mr.stop();
|
||||
}
|
||||
stopStream();
|
||||
setPreviewBlob(null);
|
||||
setPreviewUrl((prev) => {
|
||||
if (prev) URL.revokeObjectURL(prev);
|
||||
return null;
|
||||
});
|
||||
rawSamplesRef.current = [];
|
||||
accumulatedMsRef.current = 0;
|
||||
segmentStartRef.current = 0;
|
||||
setWaveformBars(Array(WAVEFORM_BARS).fill(0));
|
||||
setDurationMs(0);
|
||||
setState('idle');
|
||||
}, [stopAll]);
|
||||
}, [stopAll, stopStream]);
|
||||
|
||||
const sendVoice = useCallback(() => {
|
||||
if (!previewBlob) return;
|
||||
@@ -175,6 +240,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
if (state === 'idle') {
|
||||
return (
|
||||
<IconButton
|
||||
className={MobileTouchTarget}
|
||||
onClick={startRecording}
|
||||
aria-label="Record voice message"
|
||||
variant="SurfaceVariant"
|
||||
@@ -187,10 +253,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
);
|
||||
}
|
||||
|
||||
if (state === 'recording') {
|
||||
if (state === 'recording' || state === 'paused') {
|
||||
const paused = state === 'paused';
|
||||
return (
|
||||
<Box
|
||||
data-voice-recorder="recording"
|
||||
data-voice-recorder={paused ? 'paused' : 'recording'}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{
|
||||
@@ -209,13 +276,15 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
borderRadius: '50%',
|
||||
background: lotusTerminal ? 'var(--lt-accent-orange)' : color.Critical.Main,
|
||||
flexShrink: 0,
|
||||
animation: 'pttLivePulse 900ms ease-in-out infinite',
|
||||
// Pulse only while actively recording; hold steady (dimmed) when paused.
|
||||
animation: paused ? 'none' : 'pttLivePulse 900ms ease-in-out infinite',
|
||||
opacity: paused ? 0.5 : 1,
|
||||
}}
|
||||
/>
|
||||
<Text
|
||||
size="T200"
|
||||
role="timer"
|
||||
aria-label={`Recording duration ${formatDuration(durationMs)}`}
|
||||
aria-label={`${paused ? 'Paused' : 'Recording'}, duration ${formatDuration(durationMs)}`}
|
||||
style={{
|
||||
minWidth: toRem(32),
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
@@ -249,16 +318,29 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<IconButton
|
||||
onClick={paused ? resumeRecording : pauseRecording}
|
||||
aria-label={paused ? 'Resume recording' : 'Pause recording'}
|
||||
variant="SurfaceVariant"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
title={paused ? 'Resume' : 'Pause'}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Icon src={paused ? Icons.Play : Icons.Pause} size="100" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={stopRecording}
|
||||
aria-label="Stop recording"
|
||||
aria-label="Finish recording"
|
||||
variant="Primary"
|
||||
fill="Soft"
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Stop recording"
|
||||
title="Finish"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Icon src={Icons.Pause} size="100" />
|
||||
<Icon src={Icons.Check} size="100" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
onClick={cancelRecording}
|
||||
@@ -267,6 +349,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
size="300"
|
||||
radii="300"
|
||||
title="Cancel"
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
|
||||
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||
import { decorationUrl } from '../../features/lotus/avatarDecorations';
|
||||
|
||||
const DEFAULT_INSET = 8;
|
||||
@@ -16,8 +17,14 @@ export function AvatarDecoration({
|
||||
inset = DEFAULT_INSET,
|
||||
}: AvatarDecorationProps) {
|
||||
const slug = useAvatarDecoration(userId);
|
||||
const reducedMotion = useReducedMotion();
|
||||
|
||||
if (!slug) {
|
||||
// Decorations are animated APNGs with no static asset to freeze to, so honor
|
||||
// prefers-reduced-motion by not rendering the animation at all (consistent
|
||||
// with the rest of the theming stack — chat backgrounds / seasonal overlays —
|
||||
// which all suppress motion under this preference; also avoids dozens of live
|
||||
// APNGs animating in scrolling mobile lists).
|
||||
if (!slug || reducedMotion) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,23 @@ export const EditorOptions = style([
|
||||
DefaultReset,
|
||||
{
|
||||
padding: config.space.S200,
|
||||
'@media': {
|
||||
// On phones the toolbar can hold many 44px buttons; let them wrap to a
|
||||
// second line instead of overflowing horizontally.
|
||||
'(max-width: 750px)': { flexWrap: 'wrap' },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
// The composer's before | editable | after row. On phones, allow the toolbar
|
||||
// (`after`) to wrap below the input instead of squeezing the editable to zero
|
||||
// and pushing the Send button off-screen.
|
||||
export const EditorInputRow = style({
|
||||
'@media': {
|
||||
'(max-width: 750px)': { flexWrap: 'wrap' },
|
||||
},
|
||||
});
|
||||
|
||||
export const EditorTextareaScroll = style({});
|
||||
|
||||
export const EditorTextarea = style([
|
||||
|
||||
@@ -124,7 +124,7 @@ export const CustomEditor = forwardRef<HTMLDivElement, CustomEditorProps>(
|
||||
<div className={css.Editor} ref={ref}>
|
||||
<Slate editor={editor} initialValue={initialValue} onChange={onChange}>
|
||||
{top}
|
||||
<Box alignItems="Start">
|
||||
<Box className={css.EditorInputRow} alignItems="Start">
|
||||
{before && (
|
||||
<Box className={css.EditorOptions} alignItems="Center" gap="100" shrink="No">
|
||||
{before}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect } from 'react';
|
||||
import React, { KeyboardEvent as ReactKeyboardEvent, useCallback, useEffect, useMemo } from 'react';
|
||||
import { Editor } from 'slate';
|
||||
import { Avatar, Icon, Icons, MenuItem, Text } from 'folds';
|
||||
import { JoinRule, MatrixClient } from 'matrix-js-sdk';
|
||||
@@ -80,7 +80,14 @@ export function RoomMentionAutocomplete({
|
||||
const mx = useMatrixClient();
|
||||
const mDirects = useAtomValue(mDirectAtom);
|
||||
|
||||
const allRooms = useAtomValue(allRoomsAtom).sort(factoryRoomIdByActivity(mx));
|
||||
// Copy before sorting: `.sort()` mutates in place, and `allRoomsAtom`'s array
|
||||
// is shared app-wide — sorting it here reorders it for every other consumer.
|
||||
// Also memoize so a keystroke doesn't re-run the O(N log N) getRoom compare.
|
||||
const allRoomsList = useAtomValue(allRoomsAtom);
|
||||
const allRooms = useMemo(
|
||||
() => [...allRoomsList].sort(factoryRoomIdByActivity(mx)),
|
||||
[allRoomsList, mx],
|
||||
);
|
||||
|
||||
const [result, search, resetSearch] = useAsyncSearch(
|
||||
allRooms,
|
||||
|
||||
@@ -7,8 +7,7 @@ import { BlockType } from './types';
|
||||
// Loose Slate node builders for the test.
|
||||
const txt = (text: string, marks: Record<string, unknown> = {}) =>
|
||||
({ text, ...marks }) as unknown as Descendant;
|
||||
const el = (type: BlockType, children: unknown[]) =>
|
||||
({ type, children }) as unknown as Descendant;
|
||||
const el = (type: BlockType, children: unknown[]) => ({ type, children }) as unknown as Descendant;
|
||||
|
||||
const OPTS = {
|
||||
allowTextFormatting: true,
|
||||
|
||||
@@ -42,7 +42,7 @@ const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
|
||||
.map((seg) =>
|
||||
seg.type === 'text'
|
||||
? textToCustomHtml({ ...node, text: seg.value }, { ...opts, allowMath: false })
|
||||
: mathToCustomHtml(seg.value, seg.type === 'block')
|
||||
: mathToCustomHtml(seg.value, seg.type === 'block'),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
|
||||
@@ -194,22 +194,43 @@ export const createCommandElement = (command: string): CommandElement => ({
|
||||
});
|
||||
|
||||
export const replaceWithElement = (editor: Editor, selectRange: BaseRange, element: Element) => {
|
||||
Transforms.select(editor, selectRange);
|
||||
Transforms.insertNodes(editor, element);
|
||||
Transforms.collapse(editor, {
|
||||
edge: 'end',
|
||||
});
|
||||
// Wrap the whole sequence: on a stale autocomplete range (the document changed
|
||||
// between the menu opening and the pick) `insertNodes` — not `select`, which is
|
||||
// lazy in this Slate version — can throw. This runs inside the pick's event
|
||||
// handler, so an escape wouldn't hit the error boundary, but keep it contained.
|
||||
try {
|
||||
Transforms.select(editor, selectRange);
|
||||
Transforms.insertNodes(editor, element);
|
||||
Transforms.collapse(editor, { edge: 'end' });
|
||||
} catch {
|
||||
/* stale range — the pick is a no-op rather than an uncaught error */
|
||||
}
|
||||
};
|
||||
|
||||
export const moveCursor = (editor: Editor, withSpace?: boolean) => {
|
||||
// Defer to the next tick so React can flush any pending void-element DOM
|
||||
// updates (e.g. after inserting a mention) before Slate resolves cursor
|
||||
// positions via ReactEditor.toDOMNode — otherwise Slate throws
|
||||
// "Cannot resolve a DOM node from slate node".
|
||||
// Move the caret out of the just-inserted inline void and land it in a real
|
||||
// trailing text node — SYNCHRONOUSLY, in the same commit as the insert.
|
||||
// `Transforms.move` escapes the void (after insertNodes+collapse the caret is
|
||||
// INSIDE the void's inner text node; insertText there is a no-op, blocked by
|
||||
// Slate's void guard). The space then lands in a real text node.
|
||||
// Doing this in the same commit (vs the old deferred setTimeout) means the
|
||||
// caret never sits on the void's zero-width edge on a racy tick — that edge's
|
||||
// DOM (a U+FEFF node) isn't populated yet, so slate-react's commit-phase
|
||||
// selection sync (setBaseAndExtent) threw IndexSizeError mid-render and tripped
|
||||
// the composer error boundary. Both ops are pure model transforms (no DOM
|
||||
// resolution), so running them synchronously is safe.
|
||||
Transforms.move(editor);
|
||||
if (withSpace) editor.insertText(' ');
|
||||
// Re-assert focus next tick (a pick usually keeps the editor focused). Guarded
|
||||
// because ReactEditor.focus resolves the DOM; with the caret now in a real text
|
||||
// node this is safe, but stay defensive against a mid-flight editor.
|
||||
setTimeout(() => {
|
||||
ReactEditor.focus(editor);
|
||||
Transforms.move(editor);
|
||||
if (withSpace) editor.insertText(' ');
|
||||
try {
|
||||
ReactEditor.focus(editor);
|
||||
} catch {
|
||||
// The editor DOM can be mid-flight (autocomplete just closed / re-render
|
||||
// landed). The element is already inserted, so skip the focus nudge.
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import { Box, config, Icons, Scroll } from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { atom, PrimitiveAtom, useAtom, useSetAtom } from 'jotai';
|
||||
import { atom, PrimitiveAtom, useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import { EmojiData, IEmoji, emojiGroups, emojis, loadEmojiData } from '../../plugins/emoji';
|
||||
import { useEmojiGroupLabels } from './useEmojiGroupLabels';
|
||||
@@ -29,6 +29,7 @@ import { useAsyncSearch, UseAsyncSearchOptions } from '../../hooks/useAsyncSearc
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { useThrottle } from '../../hooks/useThrottle';
|
||||
import { addRecentEmoji } from '../../plugins/recent-emoji';
|
||||
import { addRecentSticker, recentStickersAtom } from '../../state/recentStickers';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { ImagePack, ImageUsage, PackImageReader } from '../../plugins/custom-emoji';
|
||||
import { getEmoticonSearchStr } from '../../plugins/utils';
|
||||
@@ -56,6 +57,9 @@ import { VirtualTile } from '../virtualizer';
|
||||
|
||||
const RECENT_GROUP_ID = 'recent_group';
|
||||
const SEARCH_GROUP_ID = 'search_group';
|
||||
// Stable empty pack list for hideCustomEmojis (unicode-only) mode — a fresh []
|
||||
// each render would needlessly re-run the memoized group/search builders.
|
||||
const NO_IMAGE_PACKS: ImagePack[] = [];
|
||||
|
||||
/**
|
||||
* Lazily pull in the emojibase data (see plugins/emoji `loadEmojiData`). The
|
||||
@@ -98,10 +102,12 @@ type StickerGroupItem = {
|
||||
const useGroups = (
|
||||
tab: EmojiBoardTab,
|
||||
imagePacks: ImagePack[],
|
||||
hideCustomEmojis?: boolean,
|
||||
): [EmojiGroupItem[], StickerGroupItem[]] => {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const recentEmojis = useRecentEmoji(mx, 21);
|
||||
const recentStickers = useAtomValue(recentStickersAtom);
|
||||
const labels = useEmojiGroupLabels();
|
||||
const { emojiGroups: loadedEmojiGroups } = useEmojiData();
|
||||
|
||||
@@ -112,7 +118,9 @@ const useGroups = (
|
||||
g.push({
|
||||
id: RECENT_GROUP_ID,
|
||||
name: 'Recent',
|
||||
items: recentEmojis,
|
||||
// In unicode-only mode drop recently-used CUSTOM emojis (PackImageReader);
|
||||
// keep only unicode ones (IEmoji has `unicode`).
|
||||
items: hideCustomEmojis ? recentEmojis.filter((e) => 'unicode' in e) : recentEmojis,
|
||||
});
|
||||
|
||||
imagePacks.forEach((pack) => {
|
||||
@@ -137,12 +145,22 @@ const useGroups = (
|
||||
});
|
||||
|
||||
return g;
|
||||
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups]);
|
||||
}, [mx, recentEmojis, labels, imagePacks, tab, loadedEmojiGroups, hideCustomEmojis]);
|
||||
|
||||
const stickerGroupItems = useMemo(() => {
|
||||
const g: StickerGroupItem[] = [];
|
||||
if (tab !== EmojiBoardTab.Sticker) return g;
|
||||
|
||||
if (recentStickers.length > 0) {
|
||||
g.push({
|
||||
id: RECENT_GROUP_ID,
|
||||
name: 'Recent',
|
||||
// StickerItem only reads url/shortcode/body, so a minimal PackImageReader
|
||||
// reconstructed from the stored data renders and re-sends correctly.
|
||||
items: recentStickers.map((s) => new PackImageReader(s.shortcode, s.url, { body: s.body })),
|
||||
});
|
||||
}
|
||||
|
||||
imagePacks.forEach((pack) => {
|
||||
let label = pack.meta.name;
|
||||
if (!label) label = isUserId(pack.id) ? 'Personal Pack' : mx.getRoom(pack.id)?.name;
|
||||
@@ -157,7 +175,7 @@ const useGroups = (
|
||||
});
|
||||
|
||||
return g;
|
||||
}, [mx, imagePacks, tab]);
|
||||
}, [mx, imagePacks, tab, recentStickers]);
|
||||
|
||||
return [emojiGroupItems, stickerGroupItems];
|
||||
};
|
||||
@@ -289,6 +307,7 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
||||
const [activeGroupId, setActiveGroupId] = useAtom(activeGroupAtom);
|
||||
const recentStickers = useAtomValue(recentStickersAtom);
|
||||
const usage = ImageUsage.Sticker;
|
||||
|
||||
const packLabels = useMemo(() => {
|
||||
@@ -308,6 +327,17 @@ function StickerSidebar({ activeGroupAtom, packs, onScrollToGroup }: StickerSide
|
||||
|
||||
return (
|
||||
<Sidebar>
|
||||
{recentStickers.length > 0 && (
|
||||
<SidebarStack>
|
||||
<GroupIcon
|
||||
active={activeGroupId === RECENT_GROUP_ID}
|
||||
id={RECENT_GROUP_ID}
|
||||
label="Recent"
|
||||
icon={Icons.RecentClock}
|
||||
onClick={handleScrollToGroup}
|
||||
/>
|
||||
</SidebarStack>
|
||||
)}
|
||||
<SidebarStack>
|
||||
{packs.map((pack) => {
|
||||
const label = packLabels.get(pack.id);
|
||||
@@ -410,6 +440,11 @@ type EmojiBoardProps = {
|
||||
onStickerSelect?: (mxc: string, shortcode: string, label: string) => void;
|
||||
allowTextCustomEmoji?: boolean;
|
||||
addToRecentEmoji?: boolean;
|
||||
// Unicode-only mode: hide custom/image-pack emojis (packs, sidebar icons,
|
||||
// search, and custom entries in Recent). For targets that can only hold plain
|
||||
// text — e.g. the presence status message, which can't render an mxc image —
|
||||
// so users only see emojis that actually insert.
|
||||
hideCustomEmojis?: boolean;
|
||||
};
|
||||
|
||||
export function EmojiBoard({
|
||||
@@ -423,6 +458,7 @@ export function EmojiBoard({
|
||||
onStickerSelect,
|
||||
allowTextCustomEmoji,
|
||||
addToRecentEmoji = true,
|
||||
hideCustomEmojis,
|
||||
}: EmojiBoardProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
@@ -435,8 +471,12 @@ export function EmojiBoard({
|
||||
);
|
||||
const activeGroupIdAtom = useMemo(() => atom<string | undefined>(undefined), []);
|
||||
const setActiveGroupId = useSetAtom(activeGroupIdAtom);
|
||||
const imagePacks = useRelevantImagePacks(usage, imagePackRooms);
|
||||
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks);
|
||||
const setRecentStickers = useSetAtom(recentStickersAtom);
|
||||
const relevantImagePacks = useRelevantImagePacks(usage, imagePackRooms);
|
||||
// Unicode-only mode: drop all custom/image packs so the sidebar, group list,
|
||||
// and search show only insertable unicode emojis.
|
||||
const imagePacks = hideCustomEmojis ? NO_IMAGE_PACKS : relevantImagePacks;
|
||||
const [emojiGroupItems, stickerGroupItems] = useGroups(tab, imagePacks, hideCustomEmojis);
|
||||
const groups = emojiTab ? emojiGroupItems : stickerGroupItems;
|
||||
const renderItem = useItemRenderer(tab);
|
||||
const { emojis: loadedEmojis } = useEmojiData();
|
||||
@@ -494,6 +534,13 @@ export function EmojiBoard({
|
||||
}
|
||||
if (emojiInfo.type === EmojiType.Sticker) {
|
||||
onStickerSelect?.(emojiInfo.data, emojiInfo.shortcode, emojiInfo.label);
|
||||
setRecentStickers((prev) =>
|
||||
addRecentSticker(prev, {
|
||||
url: emojiInfo.data,
|
||||
shortcode: emojiInfo.shortcode,
|
||||
body: emojiInfo.label,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (!evt.altKey && !evt.shiftKey) requestClose();
|
||||
};
|
||||
|
||||
@@ -46,7 +46,13 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
|
||||
const { address } = imagePack;
|
||||
if (!address) return;
|
||||
|
||||
await sendStateEvent(mx, address.roomId, StateEvent.PoniesRoomEmotes, packContent, address.stateKey);
|
||||
await sendStateEvent(
|
||||
mx,
|
||||
address.roomId,
|
||||
StateEvent.PoniesRoomEmotes,
|
||||
packContent,
|
||||
address.stateKey,
|
||||
);
|
||||
},
|
||||
[mx, imagePack],
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import * as css from './ImageViewer.css';
|
||||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { usePan } from '../../hooks/usePan';
|
||||
import { downloadMedia } from '../../utils/matrix';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
|
||||
export type ImageViewerProps = {
|
||||
alt: string;
|
||||
@@ -19,7 +20,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
const { t } = useTranslation();
|
||||
const saveFile = useSaveFile();
|
||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
|
||||
const { pan, cursor, onMouseDown, onTouchStart } = usePan(zoom !== 1);
|
||||
|
||||
const handleDownload = async () => {
|
||||
const fileContent = await downloadMedia(src);
|
||||
@@ -35,7 +36,13 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
>
|
||||
<Header className={css.ImageViewerHeader} size="400">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<IconButton size="300" radii="300" onClick={requestClose} aria-label="Close">
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
className={MobileTouchTarget}
|
||||
onClick={requestClose}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Icon size="50" src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
<Text size="T300" truncate>
|
||||
@@ -48,12 +55,18 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
outlined={zoom < 1}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
className={MobileTouchTarget}
|
||||
onClick={zoomOut}
|
||||
aria-label="Zoom Out"
|
||||
>
|
||||
<Icon size="50" src={Icons.Minus} />
|
||||
</IconButton>
|
||||
<Chip variant="SurfaceVariant" radii="Pill" onClick={() => setZoom(zoom === 1 ? 2 : 1)}>
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => setZoom(zoom === 1 ? 2 : 1)}
|
||||
>
|
||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||
</Chip>
|
||||
<IconButton
|
||||
@@ -61,6 +74,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
outlined={zoom > 1}
|
||||
size="300"
|
||||
radii="Pill"
|
||||
className={MobileTouchTarget}
|
||||
onClick={zoomIn}
|
||||
aria-label="Zoom In"
|
||||
>
|
||||
@@ -70,6 +84,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
variant="Primary"
|
||||
onClick={handleDownload}
|
||||
radii="300"
|
||||
className={MobileTouchTarget}
|
||||
before={<Icon size="50" src={Icons.Download} />}
|
||||
>
|
||||
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
|
||||
@@ -81,16 +96,26 @@ export const ImageViewer = as<'div', ImageViewerProps>(
|
||||
className={css.ImageViewerContent}
|
||||
justifyContent="Center"
|
||||
alignItems="Center"
|
||||
onWheel={(evt) => {
|
||||
if (evt.deltaY < 0) zoomIn();
|
||||
else if (evt.deltaY > 0) zoomOut();
|
||||
}}
|
||||
>
|
||||
<img
|
||||
className={css.ImageViewerImg}
|
||||
style={{
|
||||
cursor,
|
||||
transform: `scale(${zoom}) translate(${pan.translateX}px, ${pan.translateY}px)`,
|
||||
// translate runs inside scale(), so divide by zoom to make a dragged
|
||||
// pixel move the image one screen pixel (1:1 with the cursor) rather
|
||||
// than `zoom` pixels.
|
||||
transform: `scale(${zoom}) translate(${pan.translateX / zoom}px, ${
|
||||
pan.translateY / zoom
|
||||
}px)`,
|
||||
}}
|
||||
src={src}
|
||||
alt={alt}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
@@ -11,7 +11,7 @@ export const MediaControl = as<'div', MediaControlProps>(
|
||||
({ before, after, leftControl, rightControl, children, ...props }, ref) => (
|
||||
<Box grow="Yes" direction="Column" gap="300" {...props} ref={ref}>
|
||||
{before && <Box direction="Column">{before}</Box>}
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Box alignItems="Center" gap="200" wrap="Wrap">
|
||||
<Box alignItems="Center" grow="Yes" gap="Inherit">
|
||||
{leftControl}
|
||||
</Box>
|
||||
|
||||
@@ -10,9 +10,12 @@ import {
|
||||
MessageBrokenContent,
|
||||
MessageDeletedContent,
|
||||
MessageEditedContent,
|
||||
MessageTranslatedContent,
|
||||
MessageUnsupportedContent,
|
||||
MessageVerificationRequestContent,
|
||||
} from './content';
|
||||
import { useMessageTranslation } from '../../hooks/useMessageTranslation';
|
||||
import { languageName } from '../../utils/translation/langUtils';
|
||||
import {
|
||||
IAudioContent,
|
||||
IAudioInfo,
|
||||
@@ -162,6 +165,76 @@ type RenderBodyProps = {
|
||||
body: string;
|
||||
customBody?: string;
|
||||
};
|
||||
|
||||
// Shared body renderer for m.text / m.emote / m.notice. Handles the on-device
|
||||
// translation swap: when translation is active and done, the translated text is
|
||||
// rendered through the plain-text path (linkify + emoji, no stored HTML) inside
|
||||
// a dir="auto" span for RTL, with a "Translated from … · Show original" chip.
|
||||
type TranslatableBodyProps = {
|
||||
variant: 'text' | 'emote' | 'notice';
|
||||
displayName?: string;
|
||||
eventId: string;
|
||||
trimmedBody: string;
|
||||
customBody?: string;
|
||||
renderBody: (props: RenderBodyProps) => ReactNode;
|
||||
edited?: boolean;
|
||||
onEditHistoryClick?: () => void;
|
||||
style?: CSSProperties;
|
||||
};
|
||||
function TranslatableBody({
|
||||
variant,
|
||||
displayName,
|
||||
eventId,
|
||||
trimmedBody,
|
||||
customBody,
|
||||
renderBody,
|
||||
edited,
|
||||
onEditHistoryClick,
|
||||
style,
|
||||
}: TranslatableBodyProps) {
|
||||
const translation = useMessageTranslation(eventId, trimmedBody);
|
||||
const showTranslated =
|
||||
translation.active && translation.status === 'done' && !!translation.translated;
|
||||
const shownBody = showTranslated ? translation.translated! : trimmedBody;
|
||||
|
||||
const chipStatus = translation.status === 'detecting' ? 'translating' : translation.status;
|
||||
const showChip =
|
||||
translation.active &&
|
||||
(chipStatus === 'downloading' ||
|
||||
chipStatus === 'translating' ||
|
||||
chipStatus === 'done' ||
|
||||
chipStatus === 'error');
|
||||
|
||||
return (
|
||||
<MessageTextBody
|
||||
emote={variant === 'emote'}
|
||||
notice={variant === 'notice'}
|
||||
preWrap={showTranslated || typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(shownBody)}
|
||||
style={style}
|
||||
>
|
||||
{variant === 'emote' && <b>{`${displayName} `}</b>}
|
||||
{showTranslated ? (
|
||||
<span dir="auto">{renderBody({ body: shownBody, customBody: undefined })}</span>
|
||||
) : (
|
||||
renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})
|
||||
)}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
{showChip && (
|
||||
<MessageTranslatedContent
|
||||
fromLangName={languageName(translation.fromLang ?? '')}
|
||||
status={chipStatus as 'downloading' | 'translating' | 'done' | 'error'}
|
||||
downloadProgress={translation.downloadProgress}
|
||||
onToggle={translation.toggle}
|
||||
/>
|
||||
)}
|
||||
</MessageTextBody>
|
||||
);
|
||||
}
|
||||
|
||||
type MTextProps = {
|
||||
edited?: boolean;
|
||||
onEditHistoryClick?: () => void;
|
||||
@@ -190,17 +263,30 @@ export function MText({
|
||||
return (
|
||||
<>
|
||||
<CollapsibleBody eventId={eventId}>
|
||||
<MessageTextBody
|
||||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
style={style}
|
||||
>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
</MessageTextBody>
|
||||
{eventId ? (
|
||||
<TranslatableBody
|
||||
variant="text"
|
||||
eventId={eventId}
|
||||
trimmedBody={trimmedBody}
|
||||
customBody={typeof customBody === 'string' ? customBody : undefined}
|
||||
renderBody={renderBody}
|
||||
edited={edited}
|
||||
onEditHistoryClick={onEditHistoryClick}
|
||||
style={style}
|
||||
/>
|
||||
) : (
|
||||
<MessageTextBody
|
||||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
style={style}
|
||||
>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
</MessageTextBody>
|
||||
)}
|
||||
</CollapsibleBody>
|
||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||
</>
|
||||
@@ -235,18 +321,31 @@ export function MEmote({
|
||||
return (
|
||||
<>
|
||||
<CollapsibleBody eventId={eventId}>
|
||||
<MessageTextBody
|
||||
emote
|
||||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
>
|
||||
<b>{`${displayName} `}</b>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
</MessageTextBody>
|
||||
{eventId ? (
|
||||
<TranslatableBody
|
||||
variant="emote"
|
||||
displayName={displayName}
|
||||
eventId={eventId}
|
||||
trimmedBody={trimmedBody}
|
||||
customBody={typeof customBody === 'string' ? customBody : undefined}
|
||||
renderBody={renderBody}
|
||||
edited={edited}
|
||||
onEditHistoryClick={onEditHistoryClick}
|
||||
/>
|
||||
) : (
|
||||
<MessageTextBody
|
||||
emote
|
||||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
>
|
||||
<b>{`${displayName} `}</b>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
</MessageTextBody>
|
||||
)}
|
||||
</CollapsibleBody>
|
||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||
</>
|
||||
@@ -279,17 +378,29 @@ export function MNotice({
|
||||
return (
|
||||
<>
|
||||
<CollapsibleBody eventId={eventId}>
|
||||
<MessageTextBody
|
||||
notice
|
||||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
</MessageTextBody>
|
||||
{eventId ? (
|
||||
<TranslatableBody
|
||||
variant="notice"
|
||||
eventId={eventId}
|
||||
trimmedBody={trimmedBody}
|
||||
customBody={typeof customBody === 'string' ? customBody : undefined}
|
||||
renderBody={renderBody}
|
||||
edited={edited}
|
||||
onEditHistoryClick={onEditHistoryClick}
|
||||
/>
|
||||
) : (
|
||||
<MessageTextBody
|
||||
notice
|
||||
preWrap={typeof customBody !== 'string'}
|
||||
jumboEmoji={JUMBO_EMOJI_REG.test(trimmedBody)}
|
||||
>
|
||||
{renderBody({
|
||||
body: trimmedBody,
|
||||
customBody: typeof customBody === 'string' ? customBody : undefined,
|
||||
})}
|
||||
{edited && <MessageEditedContent onEditHistoryClick={onEditHistoryClick} />}
|
||||
</MessageTextBody>
|
||||
)}
|
||||
</CollapsibleBody>
|
||||
{renderUrlsPreview && urls && urls.length > 0 && renderUrlsPreview(urls)}
|
||||
</>
|
||||
@@ -306,6 +417,22 @@ type RenderImageContentProps = {
|
||||
markedAsSpoiler?: boolean;
|
||||
spoilerReason?: string;
|
||||
};
|
||||
// Media frame sizing. When intrinsic width/height are known, drive the box by
|
||||
// aspect-ratio so its height tracks the responsive (maxWidth:100%) width — a
|
||||
// fixed pixel height computed for a 400px-wide layout otherwise crops (images,
|
||||
// object-fit:cover) or letterboxes (videos, object-fit:contain) on phones where
|
||||
// the box narrows below 400px. On desktop the box stays 400px wide, so the
|
||||
// aspect-ratio yields the identical height. Falls back to the fixed height when
|
||||
// dimensions are unknown.
|
||||
const attachmentMediaStyle = (
|
||||
w: number | undefined,
|
||||
h: number | undefined,
|
||||
fallbackHeight: number,
|
||||
): CSSProperties =>
|
||||
w && h
|
||||
? { aspectRatio: `${w} / ${h}`, minHeight: toRem(48) }
|
||||
: { height: toRem(fallbackHeight < 48 ? 48 : fallbackHeight) };
|
||||
|
||||
type MImageProps = {
|
||||
content: IImageContent;
|
||||
renderImageContent: (props: RenderImageContentProps) => ReactNode;
|
||||
@@ -321,11 +448,7 @@ export function MImage({ content, renderImageContent, outlined }: MImageProps) {
|
||||
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
<AttachmentBox style={attachmentMediaStyle(imgInfo?.w, imgInfo?.h, height)}>
|
||||
{renderImageContent({
|
||||
body: content.body || 'Image',
|
||||
info: imgInfo,
|
||||
@@ -387,11 +510,7 @@ export function MVideo({ content, renderAsFile, renderVideoContent, outlined }:
|
||||
}
|
||||
/>
|
||||
</AttachmentHeader>
|
||||
<AttachmentBox
|
||||
style={{
|
||||
height: toRem(height < 48 ? 48 : height),
|
||||
}}
|
||||
>
|
||||
<AttachmentBox style={attachmentMediaStyle(videoInfo.w, videoInfo.h, height)}>
|
||||
{renderVideoContent({
|
||||
body: content.body || 'Video',
|
||||
info: videoInfo,
|
||||
@@ -411,6 +530,7 @@ type RenderAudioContentProps = {
|
||||
mimeType: string;
|
||||
url: string;
|
||||
encInfo?: IEncryptedFile;
|
||||
waveform?: number[];
|
||||
};
|
||||
type MAudioProps = {
|
||||
content: IAudioContent;
|
||||
@@ -431,6 +551,9 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
|
||||
}
|
||||
|
||||
const filename = content.filename ?? content.body ?? 'Audio';
|
||||
const waveform = (content as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } })[
|
||||
'org.matrix.msc1767.audio'
|
||||
]?.waveform;
|
||||
return (
|
||||
<Attachment outlined={outlined}>
|
||||
<AttachmentHeader>
|
||||
@@ -454,6 +577,7 @@ export function MAudio({ content, renderAsFile, renderAudioContent, outlined }:
|
||||
mimeType: safeMimeType,
|
||||
url: mxcUrl,
|
||||
encInfo: content.file,
|
||||
waveform,
|
||||
})}
|
||||
</AttachmentContent>
|
||||
</AttachmentBox>
|
||||
@@ -509,7 +633,19 @@ type MLocationProps = {
|
||||
};
|
||||
export function MLocation({ content }: MLocationProps) {
|
||||
const { t } = useTranslation();
|
||||
const geoUri = content.geo_uri;
|
||||
// Prefer the legacy top-level geo_uri, but fall back to the MSC3488 extensible
|
||||
// location block so events from clients that only send the new shape (uri under
|
||||
// org.matrix.msc3488.location / m.location) still render instead of appearing
|
||||
// broken.
|
||||
const msc3488 = (content['org.matrix.msc3488.location'] ?? content['m.location']) as
|
||||
| { uri?: string; description?: string }
|
||||
| undefined;
|
||||
const geoUri =
|
||||
typeof content.geo_uri === 'string'
|
||||
? content.geo_uri
|
||||
: typeof msc3488?.uri === 'string'
|
||||
? msc3488.uri
|
||||
: undefined;
|
||||
if (typeof geoUri !== 'string') return <BrokenContent />;
|
||||
const location = parseGeoUri(geoUri);
|
||||
if (!location) return <BrokenContent />;
|
||||
@@ -517,6 +653,7 @@ export function MLocation({ content }: MLocationProps) {
|
||||
const lat = parseFloat(location.latitude);
|
||||
const lon = parseFloat(location.longitude);
|
||||
if (!isFinite(lat) || !isFinite(lon)) return <BrokenContent />;
|
||||
const description = typeof msc3488?.description === 'string' ? msc3488.description : undefined;
|
||||
const mapSrc = `https://www.openstreetmap.org/export/embed.html?bbox=${lon - 0.007},${
|
||||
lat - 0.004
|
||||
},${lon + 0.007},${lat + 0.004}&layer=mapnik&marker=${lat},${lon}`;
|
||||
@@ -537,13 +674,18 @@ export function MLocation({ content }: MLocationProps) {
|
||||
loading="lazy"
|
||||
sandbox="allow-scripts"
|
||||
/>
|
||||
{description && (
|
||||
<Text size="T300" style={{ wordBreak: 'break-word', maxWidth: '280px' }}>
|
||||
{description}
|
||||
</Text>
|
||||
)}
|
||||
<Text size="T300" priority="300">
|
||||
{`${lat.toFixed(5)}, ${lon.toFixed(5)}`}
|
||||
</Text>
|
||||
<Button
|
||||
as="a"
|
||||
size="400"
|
||||
href={`https://www.openstreetmap.org/?mlat=${location.latitude}&mlon=${location.longitude}#map=16/${location.latitude}/${location.longitude}`}
|
||||
href={`https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=16/${lat}/${lon}`}
|
||||
target="_blank"
|
||||
rel="noreferrer noopener"
|
||||
variant="Secondary"
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds';
|
||||
import React, { ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Chip,
|
||||
color,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
ProgressBar,
|
||||
Spinner,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment';
|
||||
import { Range } from 'react-range';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { IAudioInfo } from '../../../../types/matrix/common';
|
||||
import {
|
||||
@@ -28,6 +41,127 @@ const PLAY_TIME_THROTTLE_OPS = {
|
||||
immediate: true,
|
||||
};
|
||||
|
||||
const WAVEFORM_DISPLAY_BARS = 48;
|
||||
|
||||
/** Reduce a stored MSC1767 waveform (up to ~100 samples) to a fixed display count. */
|
||||
export function downsampleWaveform(waveform: number[], count: number): number[] {
|
||||
if (waveform.length <= count) return waveform;
|
||||
const out: number[] = [];
|
||||
const step = waveform.length / count;
|
||||
for (let i = 0; i < count; i += 1) {
|
||||
out.push(waveform[Math.floor(i * step)] ?? 0);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// Voice-message waveform that doubles as a seek control: bars fill with the accent
|
||||
// as the clip plays; click/drag/keyboard scrubs. Mirrors the recorder's bar styling
|
||||
// (VoiceMessageRecorder). Falls back to the plain seek Range when there's no waveform.
|
||||
function WaveformSeek({
|
||||
waveform,
|
||||
currentTime,
|
||||
duration,
|
||||
onSeek,
|
||||
getCurrentTime,
|
||||
}: {
|
||||
waveform: number[];
|
||||
currentTime: number;
|
||||
duration: number;
|
||||
onSeek: (time: number) => void;
|
||||
/** Reads the live media time (the `currentTime` prop is throttled ~500ms). */
|
||||
getCurrentTime: () => number;
|
||||
}) {
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const bars = useMemo(() => downsampleWaveform(waveform, WAVEFORM_DISPLAY_BARS), [waveform]);
|
||||
const barMax = useMemo(() => Math.max(...bars, 1), [bars]);
|
||||
const progress = duration > 0 ? Math.min(1, Math.max(0, currentTime / duration)) : 0;
|
||||
const accent = lotusTerminal ? 'var(--lt-accent-green)' : color.Primary.Main;
|
||||
const unplayedColor = `color-mix(in srgb, ${accent} 32%, transparent)`;
|
||||
|
||||
const seekFromClientX = useCallback(
|
||||
(clientX: number) => {
|
||||
const el = containerRef.current;
|
||||
if (!el || duration <= 0) return;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0;
|
||||
onSeek(Math.min(duration, Math.max(0, ratio * duration)));
|
||||
},
|
||||
[duration, onSeek],
|
||||
);
|
||||
|
||||
const handlePointerDown = (evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
evt.currentTarget.setPointerCapture(evt.pointerId);
|
||||
seekFromClientX(evt.clientX);
|
||||
};
|
||||
const handlePointerMove = (evt: React.PointerEvent<HTMLDivElement>) => {
|
||||
if (evt.currentTarget.hasPointerCapture(evt.pointerId)) seekFromClientX(evt.clientX);
|
||||
};
|
||||
const handleKeyDown = (evt: React.KeyboardEvent<HTMLDivElement>) => {
|
||||
if (duration <= 0) return;
|
||||
// Base off the LIVE time so rapid presses accumulate (the prop is throttled).
|
||||
const base = getCurrentTime();
|
||||
if (evt.key === 'ArrowRight' || evt.key === 'ArrowUp') {
|
||||
evt.preventDefault();
|
||||
onSeek(Math.min(duration, base + 5));
|
||||
} else if (evt.key === 'ArrowLeft' || evt.key === 'ArrowDown') {
|
||||
evt.preventDefault();
|
||||
onSeek(Math.max(0, base - 5));
|
||||
} else if (evt.key === 'Home') {
|
||||
evt.preventDefault();
|
||||
onSeek(0);
|
||||
} else if (evt.key === 'End') {
|
||||
evt.preventDefault();
|
||||
onSeek(duration);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
role="slider"
|
||||
tabIndex={0}
|
||||
aria-label="Seek"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={Math.round(duration)}
|
||||
aria-valuenow={Math.round(currentTime)}
|
||||
aria-valuetext={`${secondsToMinutesAndSeconds(currentTime)} of ${secondsToMinutesAndSeconds(
|
||||
duration,
|
||||
)}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onKeyDown={handleKeyDown}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: toRem(2),
|
||||
width: '100%',
|
||||
height: toRem(24),
|
||||
cursor: 'pointer',
|
||||
touchAction: 'none',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{bars.map((v, i) => {
|
||||
const played = bars.length > 0 && (i + 1) / bars.length <= progress;
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
flex: 1,
|
||||
minWidth: toRem(2),
|
||||
height: toRem(2 + (v / barMax) * 16),
|
||||
borderRadius: toRem(1),
|
||||
background: played ? accent : unplayedColor,
|
||||
transition: 'background 0.1s',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type RenderMediaControlProps = {
|
||||
after: ReactNode;
|
||||
leftControl: ReactNode;
|
||||
@@ -39,6 +173,8 @@ export type AudioContentProps = {
|
||||
url: string;
|
||||
info: IAudioInfo;
|
||||
encInfo?: EncryptedAttachmentInfo;
|
||||
/** MSC1767 voice waveform (0–1024 ints); when present, the seek bar renders it. */
|
||||
waveform?: number[];
|
||||
renderMediaControl: (props: RenderMediaControlProps) => ReactNode;
|
||||
};
|
||||
export function AudioContent({
|
||||
@@ -46,6 +182,7 @@ export function AudioContent({
|
||||
url,
|
||||
info,
|
||||
encInfo,
|
||||
waveform,
|
||||
renderMediaControl,
|
||||
}: AudioContentProps) {
|
||||
const mx = useMatrixClient();
|
||||
@@ -63,6 +200,8 @@ export function AudioContent({
|
||||
);
|
||||
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
// A seek requested before the media has loaded; applied once metadata arrives.
|
||||
const pendingSeekRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
@@ -103,6 +242,11 @@ export function AudioContent({
|
||||
if (!audio) return undefined;
|
||||
const applyRate = () => {
|
||||
audio.playbackRate = playbackSpeed;
|
||||
// Apply a seek that was requested before the source loaded.
|
||||
if (pendingSeekRef.current != null && audio.readyState >= 1) {
|
||||
audio.currentTime = pendingSeekRef.current;
|
||||
pendingSeekRef.current = null;
|
||||
}
|
||||
};
|
||||
// Apply immediately, and re-apply whenever the media element (re)loads a new
|
||||
// source — e.g. after async decrypt swaps in the blob URL — since the browser
|
||||
@@ -132,14 +276,38 @@ export function AudioContent({
|
||||
}
|
||||
};
|
||||
|
||||
// Seeking before the media has loaded (e.g. clicking the waveform first) loads it
|
||||
// and applies the position once metadata arrives (the <audio> autoPlays).
|
||||
const handleSeek = useCallback(
|
||||
(time: number) => {
|
||||
if (srcState.status === AsyncStatus.Success) {
|
||||
seek(time);
|
||||
} else if (srcState.status !== AsyncStatus.Loading) {
|
||||
pendingSeekRef.current = time;
|
||||
loadSrc();
|
||||
}
|
||||
},
|
||||
[srcState.status, seek, loadSrc],
|
||||
);
|
||||
|
||||
const hasWaveform = !!waveform && waveform.length > 0 && duration > 0;
|
||||
|
||||
return renderMediaControl({
|
||||
after: (
|
||||
after: hasWaveform ? (
|
||||
<WaveformSeek
|
||||
waveform={waveform ?? []}
|
||||
currentTime={currentTime}
|
||||
duration={duration}
|
||||
onSeek={handleSeek}
|
||||
getCurrentTime={() => audioRef.current?.currentTime ?? currentTime}
|
||||
/>
|
||||
) : (
|
||||
<Range
|
||||
step={1}
|
||||
min={0}
|
||||
max={duration || 1}
|
||||
values={[currentTime]}
|
||||
onChange={(values) => seek(values[0])}
|
||||
onChange={(values) => handleSeek(values[0])}
|
||||
renderTrack={(params) => (
|
||||
<div {...params.props}>
|
||||
{params.children}
|
||||
|
||||
@@ -89,3 +89,47 @@ export const MessageEditedContent = as<
|
||||
</Text>
|
||||
),
|
||||
);
|
||||
|
||||
type TranslatedStatus = 'downloading' | 'translating' | 'done' | 'error';
|
||||
|
||||
const translatedLabel = (
|
||||
status: TranslatedStatus,
|
||||
fromLangName: string,
|
||||
downloadProgress?: number,
|
||||
): string => {
|
||||
if (status === 'downloading') {
|
||||
const pct =
|
||||
typeof downloadProgress === 'number' ? ` ${Math.round(downloadProgress * 100)}%` : '';
|
||||
return `Downloading translation model…${pct}`;
|
||||
}
|
||||
if (status === 'translating') return 'Translating…';
|
||||
if (status === 'error') return 'Translation failed — show original';
|
||||
return `Translated from ${fromLangName} · Show original`;
|
||||
};
|
||||
|
||||
// Inline chip shown beside a translated message body. Clicking it toggles back
|
||||
// to the original text (the same per-event toggle the message menu drives).
|
||||
export const MessageTranslatedContent = as<
|
||||
'span',
|
||||
{
|
||||
children?: never;
|
||||
fromLangName: string;
|
||||
status: TranslatedStatus;
|
||||
downloadProgress?: number;
|
||||
onToggle: () => void;
|
||||
}
|
||||
>(({ fromLangName, status, downloadProgress, onToggle, ...props }, ref) => (
|
||||
<span ref={ref} {...(props as React.HTMLAttributes<HTMLSpanElement>)}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onToggle}
|
||||
aria-pressed={status === 'done'}
|
||||
aria-label="Toggle message translation"
|
||||
style={{ cursor: 'pointer', background: 'none', border: 'none', padding: 0 }}
|
||||
>
|
||||
<Text as="span" size="T200" priority="300">
|
||||
{` · ${translatedLabel(status, fromLangName, downloadProgress)}`}
|
||||
</Text>
|
||||
</button>
|
||||
</span>
|
||||
));
|
||||
|
||||
@@ -1,179 +1,194 @@
|
||||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||
import React, { KeyboardEvent, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Box, Chip, color, config, Icon, Icons, Text, toRem } from 'folds';
|
||||
import { RelationsEvent } from 'matrix-js-sdk/lib/models/relations';
|
||||
import { RoomEvent } from 'matrix-js-sdk';
|
||||
import { MatrixEvent, Room, RoomEvent, PollEvent } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getMemberName } from '../../../utils/room';
|
||||
import {
|
||||
ParsedPoll,
|
||||
PollResponse,
|
||||
PollTally,
|
||||
parsePollStart,
|
||||
parseResponseAnswerIds,
|
||||
resultsVisible,
|
||||
tallyResponses,
|
||||
validateSelections,
|
||||
winningAnswerIds,
|
||||
} from '../../../utils/poll';
|
||||
|
||||
type PollTextValue = Array<{ body: string }> | string;
|
||||
const EMPTY_TALLY: PollTally = {
|
||||
counts: new Map(),
|
||||
voters: new Map(),
|
||||
myVotes: new Set(),
|
||||
total: 0,
|
||||
};
|
||||
|
||||
function extractText(val: PollTextValue | undefined): string {
|
||||
if (!val) return '';
|
||||
if (typeof val === 'string') return val;
|
||||
return val[0]?.body ?? '';
|
||||
type PollState = { tally: PollTally; isEnded: boolean };
|
||||
|
||||
function setsEqual(a: Set<string>, b: Set<string>): boolean {
|
||||
if (a.size !== b.size) return false;
|
||||
for (const x of a) if (!b.has(x)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
type PollAnswer = {
|
||||
'm.id'?: string;
|
||||
id?: string;
|
||||
'm.text'?: PollTextValue;
|
||||
'org.matrix.msc3381.poll.answer'?: { body: string };
|
||||
};
|
||||
|
||||
type PollData = {
|
||||
question?: { body?: string; 'm.text'?: PollTextValue };
|
||||
answers?: PollAnswer[];
|
||||
max_selections?: number;
|
||||
};
|
||||
|
||||
type VoteState = {
|
||||
counts: Map<string, number>;
|
||||
myVotes: Set<string>;
|
||||
total: number;
|
||||
};
|
||||
|
||||
function computeVotes(
|
||||
function computePollState(
|
||||
mx: ReturnType<typeof useMatrixClient>,
|
||||
roomId: string,
|
||||
room: Room,
|
||||
eventId: string,
|
||||
_isStable: boolean,
|
||||
): VoteState {
|
||||
const empty: VoteState = { counts: new Map(), myVotes: new Set(), total: 0 };
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return empty;
|
||||
|
||||
const timelineSet = room.getUnfilteredTimelineSet();
|
||||
const stableRels = timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'm.reference',
|
||||
'm.poll.response',
|
||||
);
|
||||
const unstableRels = timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'org.matrix.msc3381.poll.response' as any,
|
||||
'org.matrix.msc3381.poll.response',
|
||||
);
|
||||
|
||||
// Per-sender keep only the latest response (which may include multiple selections)
|
||||
const latestBySender = new Map<string, { ts: number; answerIds: string[] }>();
|
||||
parsed: ParsedPoll,
|
||||
): PollState {
|
||||
const relations = room.getUnfilteredTimelineSet().relations;
|
||||
const myUserId = mx.getSafeUserId();
|
||||
const validIds = new Set(parsed.answers.map((a) => a.id));
|
||||
|
||||
const processRelations = (rels: typeof stableRels, stable: boolean) => {
|
||||
const events = rels?.getRelations() ?? [];
|
||||
for (const ev of events) {
|
||||
const getRels = (type: string): MatrixEvent[] =>
|
||||
relations.getChildEventsForEvent(eventId, 'm.reference', type as any)?.getRelations() ?? [];
|
||||
|
||||
const endEvents = [...getRels('m.poll.end'), ...getRels('org.matrix.msc3381.poll.end')];
|
||||
const respEvents = [
|
||||
...getRels('m.poll.response'),
|
||||
...getRels('org.matrix.msc3381.poll.response'),
|
||||
];
|
||||
|
||||
// End state: prefer the SDK Poll model (validates ender = creator or redact PL,
|
||||
// refilters responses). Fall back to an end from the poll's own creator, which is
|
||||
// always valid, in case the model hasn't processed it yet.
|
||||
const poll = room.polls.get(eventId);
|
||||
let isEnded = poll?.isEnded ?? false;
|
||||
let endTs = poll?.endEventId ? room.findEventById(poll.endEventId)?.getTs() : undefined;
|
||||
if (!isEnded) {
|
||||
const creator = room.findEventById(eventId)?.getSender();
|
||||
for (const ev of endEvents) {
|
||||
if (ev.isRedacted()) continue;
|
||||
const sender = ev.getSender();
|
||||
if (!sender) continue;
|
||||
const content = ev.getContent();
|
||||
let answerIds: string[] = [];
|
||||
if (stable) {
|
||||
answerIds = (content['m.selections'] as string[] | undefined) ?? [];
|
||||
} else {
|
||||
answerIds =
|
||||
((content['org.matrix.msc3381.poll.response'] as any)?.answers as string[] | undefined) ??
|
||||
[];
|
||||
if (creator && ev.getSender() === creator) {
|
||||
isEnded = true;
|
||||
const t = ev.getTs();
|
||||
if (endTs === undefined || t < endTs) endTs = t;
|
||||
}
|
||||
if (answerIds.length === 0) continue;
|
||||
const ts = ev.getTs();
|
||||
const existing = latestBySender.get(sender);
|
||||
if (!existing || ts > existing.ts) {
|
||||
latestBySender.set(sender, { ts, answerIds });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
processRelations(stableRels, true);
|
||||
processRelations(unstableRels, false);
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
const myVotes = new Set<string>();
|
||||
for (const [sender, { answerIds }] of latestBySender) {
|
||||
for (const id of answerIds) {
|
||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
||||
if (sender === myUserId) myVotes.add(id);
|
||||
}
|
||||
}
|
||||
|
||||
return { counts, myVotes, total: latestBySender.size };
|
||||
const responses: PollResponse[] = [];
|
||||
for (const ev of respEvents) {
|
||||
if (ev.isRedacted()) continue;
|
||||
const sender = ev.getSender();
|
||||
if (!sender) continue;
|
||||
const ts = ev.getTs();
|
||||
// Only responses cast on or before the end event are valid.
|
||||
if (isEnded && endTs !== undefined && ts > endTs) continue;
|
||||
const answerIds = validateSelections(
|
||||
parseResponseAnswerIds(ev.getContent()),
|
||||
validIds,
|
||||
parsed.maxSelections,
|
||||
);
|
||||
responses.push({ sender, ts, answerIds });
|
||||
}
|
||||
|
||||
return { tally: tallyResponses(responses, myUserId), isEnded };
|
||||
}
|
||||
|
||||
export function PollContent({
|
||||
content,
|
||||
roomId,
|
||||
eventId,
|
||||
mEvent,
|
||||
room,
|
||||
canRedact,
|
||||
}: {
|
||||
content: Record<string, unknown>;
|
||||
roomId?: string;
|
||||
eventId?: string;
|
||||
mEvent: MatrixEvent;
|
||||
room: Room;
|
||||
/** Whether the current user may redact in this room (gates the End-poll action). */
|
||||
canRedact: boolean;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const _isStable = !!content['m.poll'];
|
||||
const roomId = room.roomId;
|
||||
const eventId = mEvent.getId();
|
||||
const senderId = mEvent.getSender();
|
||||
|
||||
const poll = (content['m.poll'] ?? content['org.matrix.msc3381.poll.start']) as
|
||||
| PollData
|
||||
| undefined;
|
||||
const parsed = useMemo(() => parsePollStart(mEvent.getContent()), [mEvent]);
|
||||
|
||||
const [votes, setVotes] = useState<VoteState>(() => {
|
||||
if (!roomId || !eventId) return { counts: new Map(), myVotes: new Set(), total: 0 };
|
||||
return computeVotes(mx, roomId, eventId, _isStable);
|
||||
const [state, setState] = useState<PollState>(() => {
|
||||
if (!eventId || !parsed) return { tally: EMPTY_TALLY, isEnded: false };
|
||||
return computePollState(mx, room, eventId, parsed);
|
||||
});
|
||||
const [pending, setPending] = useState<Set<string> | null>(null);
|
||||
const [confirmEnd, setConfirmEnd] = useState(false);
|
||||
const [ending, setEnding] = useState(false);
|
||||
const [showVoters, setShowVoters] = useState(false);
|
||||
|
||||
// Refresh votes whenever Relations events fire
|
||||
const refresh = useCallback(() => {
|
||||
if (!roomId || !eventId) return;
|
||||
setVotes(computeVotes(mx, roomId, eventId, _isStable));
|
||||
}, [mx, roomId, eventId, _isStable]);
|
||||
if (!eventId || !parsed) return;
|
||||
const next = computePollState(mx, room, eventId, parsed);
|
||||
setState(next);
|
||||
// Drop the optimistic selection only once our own vote is actually reflected,
|
||||
// so an unrelated refresh (another user voting) can't revert our pending click.
|
||||
setPending((prev) => (prev !== null && setsEqual(prev, next.tally.myVotes) ? null : prev));
|
||||
}, [mx, room, eventId, parsed]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!roomId || !eventId) return;
|
||||
const room = mx.getRoom(roomId);
|
||||
if (!room) return;
|
||||
const timelineSet = room.getUnfilteredTimelineSet();
|
||||
if (!eventId || !parsed) return undefined;
|
||||
const relations = room.getUnfilteredTimelineSet().relations;
|
||||
const relObjs = [
|
||||
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.response' as any),
|
||||
relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'm.reference',
|
||||
'org.matrix.msc3381.poll.response' as any,
|
||||
),
|
||||
relations.getChildEventsForEvent(eventId, 'm.reference', 'm.poll.end' as any),
|
||||
relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'm.reference',
|
||||
'org.matrix.msc3381.poll.end' as any,
|
||||
),
|
||||
];
|
||||
relObjs.forEach((r) => {
|
||||
r?.on(RelationsEvent.Add, refresh);
|
||||
r?.on(RelationsEvent.Remove, refresh);
|
||||
r?.on(RelationsEvent.Redaction, refresh);
|
||||
});
|
||||
|
||||
const stableRels = timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'm.reference',
|
||||
'm.poll.response',
|
||||
);
|
||||
const unstableRels = timelineSet.relations.getChildEventsForEvent(
|
||||
eventId,
|
||||
'org.matrix.msc3381.poll.response' as any,
|
||||
'org.matrix.msc3381.poll.response',
|
||||
);
|
||||
|
||||
stableRels?.on(RelationsEvent.Add, refresh);
|
||||
stableRels?.on(RelationsEvent.Remove, refresh);
|
||||
stableRels?.on(RelationsEvent.Redaction, refresh);
|
||||
unstableRels?.on(RelationsEvent.Add, refresh);
|
||||
unstableRels?.on(RelationsEvent.Remove, refresh);
|
||||
unstableRels?.on(RelationsEvent.Redaction, refresh);
|
||||
// Also listen at room level: if no votes exist yet, the Relations object is null
|
||||
// and the listeners above are no-ops. The room timeline event catches the first vote.
|
||||
const onTimeline = (ev: any) => {
|
||||
const type = ev.getType?.();
|
||||
const relatesTo = ev.getContent?.()?.['m.relates_to'];
|
||||
// The relations object for the first response/end may not exist yet at mount, and
|
||||
// redactions (m.room.redaction) don't flow through the relations listeners for a
|
||||
// post-mount response — the room timeline catches those.
|
||||
const onTimeline = (ev: MatrixEvent) => {
|
||||
const type = ev.getType();
|
||||
if (type === 'm.room.redaction') {
|
||||
refresh();
|
||||
return;
|
||||
}
|
||||
const relatesTo = ev.getContent()['m.relates_to'] as { event_id?: string } | undefined;
|
||||
if (
|
||||
(type === 'm.poll.response' || type === 'org.matrix.msc3381.poll.response') &&
|
||||
(type === 'm.poll.response' ||
|
||||
type === 'org.matrix.msc3381.poll.response' ||
|
||||
type === 'm.poll.end' ||
|
||||
type === 'org.matrix.msc3381.poll.end') &&
|
||||
relatesTo?.event_id === eventId
|
||||
) {
|
||||
refresh();
|
||||
}
|
||||
};
|
||||
const room2 = mx.getRoom(roomId);
|
||||
room2?.on(RoomEvent.Timeline, onTimeline);
|
||||
room.on(RoomEvent.Timeline, onTimeline);
|
||||
|
||||
const poll = room.polls.get(eventId);
|
||||
poll?.on(PollEvent.Update, refresh);
|
||||
poll?.on(PollEvent.Responses, refresh);
|
||||
poll?.on(PollEvent.End, refresh);
|
||||
room.on(PollEvent.New, refresh);
|
||||
|
||||
return () => {
|
||||
stableRels?.off(RelationsEvent.Add, refresh);
|
||||
stableRels?.off(RelationsEvent.Remove, refresh);
|
||||
stableRels?.off(RelationsEvent.Redaction, refresh);
|
||||
unstableRels?.off(RelationsEvent.Add, refresh);
|
||||
unstableRels?.off(RelationsEvent.Remove, refresh);
|
||||
unstableRels?.off(RelationsEvent.Redaction, refresh);
|
||||
room2?.off(RoomEvent.Timeline, onTimeline);
|
||||
relObjs.forEach((r) => {
|
||||
r?.off(RelationsEvent.Add, refresh);
|
||||
r?.off(RelationsEvent.Remove, refresh);
|
||||
r?.off(RelationsEvent.Redaction, refresh);
|
||||
});
|
||||
room.off(RoomEvent.Timeline, onTimeline);
|
||||
poll?.off(PollEvent.Update, refresh);
|
||||
poll?.off(PollEvent.Responses, refresh);
|
||||
poll?.off(PollEvent.End, refresh);
|
||||
room.off(PollEvent.New, refresh);
|
||||
};
|
||||
}, [mx, roomId, eventId, refresh]);
|
||||
}, [room, eventId, parsed, refresh]);
|
||||
|
||||
if (!poll) {
|
||||
if (!parsed) {
|
||||
return (
|
||||
<Text priority="300">
|
||||
<i>Poll (unreadable format)</i>
|
||||
@@ -181,61 +196,96 @@ export function PollContent({
|
||||
);
|
||||
}
|
||||
|
||||
const questionText =
|
||||
extractText((poll.question as any)?.['m.text']) ||
|
||||
(poll.question as any)?.body ||
|
||||
'Untitled poll';
|
||||
|
||||
const canVote = !!roomId && !!eventId;
|
||||
const maxSelections = (poll as any).max_selections ?? 1;
|
||||
const { tally, isEnded } = state;
|
||||
const { isUndisclosed, maxSelections, question, answers } = parsed;
|
||||
const isMultiple = maxSelections > 1;
|
||||
const { counts, myVotes, total } = votes;
|
||||
|
||||
const showResults = resultsVisible(isUndisclosed, isEnded);
|
||||
const canVote = !!roomId && !!eventId && !isEnded;
|
||||
const canEnd = !!eventId && !isEnded && (senderId === mx.getUserId() || canRedact);
|
||||
|
||||
const myVotes = pending ?? tally.myVotes;
|
||||
const { counts, total, voters } = tally;
|
||||
const winners = isEnded && showResults ? new Set(winningAnswerIds(counts)) : new Set<string>();
|
||||
// Voter identities are shown under the same rule as the counts: disclosed polls
|
||||
// reveal them live, undisclosed polls only once ended.
|
||||
const canShowVoters = showResults && total > 0;
|
||||
|
||||
const handleVote = (answerId: string) => {
|
||||
if (!roomId || !eventId) return;
|
||||
if (!roomId || !eventId || !canVote) return;
|
||||
|
||||
const newVotes = new Set(myVotes);
|
||||
if (newVotes.has(answerId)) {
|
||||
newVotes.delete(answerId);
|
||||
const next = new Set(myVotes);
|
||||
if (next.has(answerId)) {
|
||||
next.delete(answerId);
|
||||
} else if (isMultiple) {
|
||||
if (next.size >= maxSelections) return; // enforce max_selections
|
||||
next.add(answerId);
|
||||
} else {
|
||||
if (!isMultiple) newVotes.clear();
|
||||
newVotes.add(answerId);
|
||||
next.clear();
|
||||
next.add(answerId);
|
||||
}
|
||||
|
||||
// Optimistic local update
|
||||
setVotes((prev) => {
|
||||
const next = new Map(prev.counts);
|
||||
// Remove all old vote counts for this user
|
||||
for (const id of prev.myVotes) {
|
||||
const c = next.get(id) ?? 1;
|
||||
if (c <= 1) next.delete(id);
|
||||
else next.set(id, c - 1);
|
||||
}
|
||||
// Add new vote counts
|
||||
for (const id of newVotes) {
|
||||
next.set(id, (next.get(id) ?? 0) + 1);
|
||||
}
|
||||
const hadVotes = prev.myVotes.size > 0;
|
||||
const hasVotes = newVotes.size > 0;
|
||||
const newTotal = prev.total + (hasVotes && !hadVotes ? 1 : !hasVotes && hadVotes ? -1 : 0);
|
||||
return { counts: next, myVotes: newVotes, total: newTotal };
|
||||
});
|
||||
|
||||
const selectionsArr = Array.from(newVotes);
|
||||
if (_isStable) {
|
||||
mx.sendEvent(roomId, 'm.poll.response' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': selectionsArr,
|
||||
}).catch(() => undefined);
|
||||
} else {
|
||||
mx.sendEvent(roomId, 'org.matrix.msc3381.poll.response' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'org.matrix.msc3381.poll.response': { answers: selectionsArr },
|
||||
}).catch(() => undefined);
|
||||
}
|
||||
setPending(next);
|
||||
// Send the STABLE m.poll.response (matches Lotus's stable m.poll.start; the reader
|
||||
// accepts both namespaces).
|
||||
mx.sendEvent(roomId, 'm.poll.response' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.selections': Array.from(next),
|
||||
}).catch(() => setPending(null));
|
||||
};
|
||||
|
||||
const answers = poll.answers ?? [];
|
||||
const handleEndPoll = () => {
|
||||
if (!roomId || !eventId || ending) return;
|
||||
setEnding(true);
|
||||
mx.sendEvent(roomId, 'm.poll.end' as any, {
|
||||
'm.relates_to': { rel_type: 'm.reference', event_id: eventId },
|
||||
'm.poll.end': {},
|
||||
'm.text': 'The poll has ended.',
|
||||
})
|
||||
.then(() => {
|
||||
setConfirmEnd(false);
|
||||
setEnding(false);
|
||||
})
|
||||
.catch(() => setEnding(false));
|
||||
};
|
||||
|
||||
// Radiogroup keyboard model (single-choice only): arrows move focus + selection.
|
||||
const handleRadioKeyDown = (evt: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (isMultiple || !canVote) return;
|
||||
const nav = ['ArrowDown', 'ArrowRight', 'ArrowUp', 'ArrowLeft', 'Home', 'End'];
|
||||
if (!nav.includes(evt.key)) return;
|
||||
evt.preventDefault();
|
||||
const buttons = Array.from(
|
||||
evt.currentTarget.querySelectorAll<HTMLButtonElement>('[data-poll-answer]'),
|
||||
);
|
||||
if (buttons.length === 0) return;
|
||||
const current = buttons.findIndex((b) => b === document.activeElement);
|
||||
let idx = current < 0 ? 0 : current;
|
||||
if (evt.key === 'ArrowDown' || evt.key === 'ArrowRight') idx = (idx + 1) % buttons.length;
|
||||
else if (evt.key === 'ArrowUp' || evt.key === 'ArrowLeft')
|
||||
idx = (idx - 1 + buttons.length) % buttons.length;
|
||||
else if (evt.key === 'Home') idx = 0;
|
||||
else if (evt.key === 'End') idx = buttons.length - 1;
|
||||
buttons[idx]?.focus();
|
||||
handleVote(answers[idx].id);
|
||||
};
|
||||
|
||||
const headerLabel = isEnded
|
||||
? 'Poll closed · Final results'
|
||||
: `Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`;
|
||||
|
||||
const footerNote = (() => {
|
||||
if (isUndisclosed && !isEnded) {
|
||||
return canVote
|
||||
? 'Voting open · Results hidden until the poll ends'
|
||||
: 'Results hidden until the poll ends';
|
||||
}
|
||||
const votesPart = total > 0 ? `${total} vote${total === 1 ? '' : 's'}` : 'No votes yet';
|
||||
if (isEnded) return `${votesPart} · Poll closed`;
|
||||
if (!canVote) return votesPart;
|
||||
if (isMultiple) return `${votesPart} · Select up to ${maxSelections}`;
|
||||
return `${votesPart} · ${myVotes.size > 0 ? 'Click to change' : 'Click to vote'}`;
|
||||
})();
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -256,116 +306,188 @@ export function PollContent({
|
||||
marginBottom: config.space.S100,
|
||||
}}
|
||||
>
|
||||
{`◉ Poll · ${isMultiple ? 'Multiple choice' : 'Single choice'}`}
|
||||
<span aria-hidden>◉ </span>
|
||||
{headerLabel}
|
||||
</Text>
|
||||
<Text size="T400" style={{ fontWeight: 600 }}>
|
||||
{questionText}
|
||||
{question}
|
||||
</Text>
|
||||
<Box direction="Column" gap="100" style={{ marginTop: '2px' }}>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ marginTop: '2px' }}
|
||||
role={isMultiple ? 'group' : 'radiogroup'}
|
||||
aria-label={question}
|
||||
onKeyDown={handleRadioKeyDown}
|
||||
>
|
||||
{answers.map((answer, i) => {
|
||||
const text =
|
||||
extractText((answer as any)['m.text']) ||
|
||||
(answer as any)['org.matrix.msc3381.poll.answer']?.body ||
|
||||
`Option ${i + 1}`;
|
||||
const id = answer['m.id'] ?? answer.id ?? String(i);
|
||||
const id = answer.id;
|
||||
const text = answer.text;
|
||||
const selected = myVotes.has(id);
|
||||
const voteCount = counts.get(id) ?? 0;
|
||||
const pct = total > 0 ? Math.round((voteCount / total) * 100) : 0;
|
||||
const pct = showResults && total > 0 ? Math.round((voteCount / total) * 100) : 0;
|
||||
const isWinner = winners.has(id);
|
||||
// Roving tabindex for the single-choice radiogroup; checkboxes stay tabbable.
|
||||
const tabIndex = isMultiple ? 0 : selected || (myVotes.size === 0 && i === 0) ? 0 : -1;
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
data-poll-answer
|
||||
data-selected={selected}
|
||||
onClick={canVote ? () => handleVote(id) : undefined}
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R300,
|
||||
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
|
||||
border: `${config.borderWidth.B300} solid ${
|
||||
selected ? color.Primary.Main : color.SurfaceVariant.ContainerLine
|
||||
}`,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'left',
|
||||
cursor: canVote ? 'pointer' : 'default',
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S100,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
{total > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
right: 'auto',
|
||||
width: `${pct}%`,
|
||||
background: selected
|
||||
? color.Primary.ContainerActive
|
||||
: color.SurfaceVariant.ContainerActive,
|
||||
pointerEvents: 'none',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
<React.Fragment key={id}>
|
||||
<button
|
||||
type="button"
|
||||
data-poll-answer
|
||||
data-selected={selected}
|
||||
role={isMultiple ? 'checkbox' : 'radio'}
|
||||
aria-checked={selected}
|
||||
aria-disabled={!canVote}
|
||||
aria-label={isWinner ? `${text}, winning answer` : undefined}
|
||||
aria-describedby={
|
||||
showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0
|
||||
? `poll-voters-${eventId}-${id}`
|
||||
: undefined
|
||||
}
|
||||
tabIndex={tabIndex}
|
||||
onClick={canVote ? () => handleVote(id) : undefined}
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R300,
|
||||
background: selected ? color.Primary.Container : color.SurfaceVariant.Container,
|
||||
border: `${config.borderWidth.B300} solid ${
|
||||
isWinner
|
||||
? color.Success.Main
|
||||
: selected
|
||||
? color.Primary.Main
|
||||
: color.SurfaceVariant.ContainerLine
|
||||
}`,
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'left',
|
||||
cursor: canVote ? 'pointer' : 'default',
|
||||
color: 'inherit',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: config.space.S200,
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S100,
|
||||
width: '100%',
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
transition: 'border-color 0.15s, background 0.15s',
|
||||
}}
|
||||
>
|
||||
{showResults && total > 0 && (
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
right: 'auto',
|
||||
width: `${pct}%`,
|
||||
background: selected
|
||||
? color.Primary.ContainerActive
|
||||
: color.SurfaceVariant.ContainerActive,
|
||||
pointerEvents: 'none',
|
||||
transition: 'width 0.3s ease',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: toRem(14),
|
||||
height: toRem(14),
|
||||
border: `${config.borderWidth.B300} solid ${
|
||||
selected ? color.Primary.Main : color.Primary.ContainerLine
|
||||
}`,
|
||||
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
|
||||
background: selected ? color.Primary.Main : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: color.Primary.OnMain,
|
||||
transition: 'all 0.15s',
|
||||
gap: config.space.S200,
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{selected && isMultiple ? <Icon size="50" src={Icons.Check} /> : null}
|
||||
</span>
|
||||
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
||||
{text}
|
||||
</Text>
|
||||
{total > 0 && (
|
||||
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||
{pct}%
|
||||
<span
|
||||
aria-hidden
|
||||
style={{
|
||||
flexShrink: 0,
|
||||
width: toRem(14),
|
||||
height: toRem(14),
|
||||
border: `${config.borderWidth.B300} solid ${
|
||||
selected ? color.Primary.Main : color.Primary.ContainerLine
|
||||
}`,
|
||||
borderRadius: isMultiple ? config.radii.R300 : config.radii.Pill,
|
||||
background: selected ? color.Primary.Main : 'transparent',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
color: color.Primary.OnMain,
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
>
|
||||
{selected ? <Icon size="50" src={Icons.Check} /> : null}
|
||||
</span>
|
||||
<Text as="span" size="T300" style={{ flexGrow: 1 }}>
|
||||
{text}
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{isWinner && (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Check}
|
||||
style={{ flexShrink: 0, color: color.Success.Main }}
|
||||
/>
|
||||
)}
|
||||
{showResults && total > 0 && (
|
||||
<Text as="span" size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||
{pct}%
|
||||
</Text>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
{showVoters && canShowVoters && (voters.get(id)?.length ?? 0) > 0 && (
|
||||
<Text
|
||||
id={`poll-voters-${eventId}-${id}`}
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{ padding: `0 ${config.space.S300} ${config.space.S100}` }}
|
||||
>
|
||||
{`Voted by ${(voters.get(id) ?? []).map((s) => getMemberName(room, s)).join(', ')}`}
|
||||
</Text>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Text size="T200" priority="300" style={{ marginTop: '2px' }}>
|
||||
<i>
|
||||
{total > 0 ? `${total} vote${total === 1 ? '' : 's'} · ` : ''}
|
||||
{canVote
|
||||
? isMultiple
|
||||
? 'Select all that apply'
|
||||
: myVotes.size > 0
|
||||
? 'Click to change'
|
||||
: 'Click to vote'
|
||||
: 'Voting not available'}
|
||||
</i>
|
||||
</Text>
|
||||
{canShowVoters && (
|
||||
<Box>
|
||||
<Chip
|
||||
variant={showVoters ? 'Primary' : 'SurfaceVariant'}
|
||||
radii="Pill"
|
||||
aria-pressed={showVoters}
|
||||
onClick={() => setShowVoters((v) => !v)}
|
||||
before={<Icon size="50" src={Icons.User} />}
|
||||
>
|
||||
<Text size="T200">{showVoters ? 'Hide voters' : 'Show who voted'}</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
)}
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Text size="T200" priority="300" style={{ minWidth: 0 }}>
|
||||
<i>{footerNote}</i>
|
||||
</Text>
|
||||
{canEnd &&
|
||||
(confirmEnd ? (
|
||||
<Box gap="100" shrink="No" alignItems="Center">
|
||||
<Chip
|
||||
variant="Critical"
|
||||
radii="Pill"
|
||||
aria-disabled={ending}
|
||||
onClick={ending ? undefined : handleEndPoll}
|
||||
>
|
||||
<Text size="T200">{ending ? 'Ending…' : 'End poll'}</Text>
|
||||
</Chip>
|
||||
<Chip variant="Secondary" radii="Pill" onClick={() => setConfirmEnd(false)}>
|
||||
<Text size="T200">Cancel</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
) : (
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
onClick={() => setConfirmEnd(true)}
|
||||
before={<Icon size="50" src={Icons.Cross} />}
|
||||
>
|
||||
<Text size="T200">End poll</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,11 @@ const NavItemBase = style({
|
||||
color: OnContainer,
|
||||
outline: 'none',
|
||||
minHeight: toRem(36),
|
||||
'@media': {
|
||||
// The room/nav row is the app's primary tap target; give it a 44px touch
|
||||
// area on phones (desktop stays the denser 36px).
|
||||
'(max-width: 750px)': { minHeight: toRem(44) },
|
||||
},
|
||||
|
||||
selectors: {
|
||||
'&:hover, &:focus-visible': {
|
||||
|
||||
@@ -27,7 +27,14 @@ type PresenceBadgeProps = {
|
||||
};
|
||||
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||
const label = usePresenceLabel();
|
||||
const ariaLabel = status ? `${label[presence]} — ${status}` : label[presence];
|
||||
// DND is encoded as unavailable + status_msg 'dnd'; render it red/"Do Not
|
||||
// Disturb" to match PresenceRingAvatar and the settings picker (which both
|
||||
// special-case 'dnd' → Critical) — the badge was the lone outlier showing a
|
||||
// yellow "Idle". The 'dnd' sentinel isn't surfaced as a status line.
|
||||
const isDnd = presence === Presence.Unavailable && status === 'dnd';
|
||||
const displayLabel = isDnd ? 'Do Not Disturb' : label[presence];
|
||||
const displayStatus = isDnd ? undefined : status;
|
||||
const ariaLabel = displayStatus ? `${displayLabel} — ${displayStatus}` : displayLabel;
|
||||
|
||||
return (
|
||||
<TooltipProvider
|
||||
@@ -38,9 +45,9 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
||||
<Text size="L400">{label[presence]}</Text>
|
||||
{status && <Text size="T200">•</Text>}
|
||||
{status && <Text size="T200">{status}</Text>}
|
||||
<Text size="L400">{displayLabel}</Text>
|
||||
{displayStatus && <Text size="T200">•</Text>}
|
||||
{displayStatus && <Text size="T200">{displayStatus}</Text>}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
}
|
||||
@@ -50,7 +57,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||
aria-label={ariaLabel}
|
||||
ref={triggerRef}
|
||||
size={size}
|
||||
variant={PresenceToColor[presence]}
|
||||
variant={isDnd ? 'Critical' : PresenceToColor[presence]}
|
||||
fill={presence === Presence.Offline ? 'Soft' : 'Solid'}
|
||||
radii="Pill"
|
||||
/>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Room, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
|
||||
import React, { useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Icon,
|
||||
Icons,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
config,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { getMemberName } from '../../utils/room';
|
||||
@@ -21,8 +20,9 @@ import { StackedAvatar } from '../stacked-avatar';
|
||||
import { EventReaders } from '../event-readers';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { useForceUpdate } from '../../hooks/useForceUpdate';
|
||||
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
|
||||
import { useRoomMembersChange } from '../../hooks/useRoomMemberChange';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import * as css from './ReadReceiptAvatars.css';
|
||||
|
||||
const MAX_DISPLAY = 5;
|
||||
@@ -50,32 +50,15 @@ export function ReadReceiptAvatars({
|
||||
eventId: string;
|
||||
userIds: string[];
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const modalStyle = useModalStyle(360);
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
|
||||
// The avatars + names below are read from room member state at render time, so
|
||||
// they only refresh when this component re-renders (which the timeline does on a
|
||||
// receipt change). Re-render on a member-state change of any displayed reader so
|
||||
// an avatar/display-name update shows live. RoomStateEvent.Members fires for name,
|
||||
// avatar AND membership changes (RoomMemberEvent has no Avatar signal).
|
||||
useEffect(() => {
|
||||
const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
|
||||
event,
|
||||
_state,
|
||||
member,
|
||||
) => {
|
||||
if (event.getRoomId() === room.roomId && userIds.includes(member.userId)) {
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
mx.on(RoomStateEvent.Members, handleMembers);
|
||||
return () => {
|
||||
mx.removeListener(RoomStateEvent.Members, handleMembers);
|
||||
};
|
||||
}, [mx, room.roomId, userIds, forceUpdate]);
|
||||
// The tooltip names below are read from room member state at render time.
|
||||
// Re-render on a member-state change of any displayed reader so a display-name
|
||||
// update shows live (each avatar handles its own via useMemberAvatar). Uses the
|
||||
// shared member-change store — one global listener for the whole app (PERF-3).
|
||||
useRoomMembersChange(room.roomId, userIds);
|
||||
|
||||
if (userIds.length === 0) return null;
|
||||
|
||||
@@ -110,7 +93,7 @@ export function ReadReceiptAvatars({
|
||||
onClick={() => setOpen(true)}
|
||||
title={tooltipNames}
|
||||
aria-label={tooltipNames}
|
||||
className={css.ReceiptTrigger}
|
||||
className={`${css.ReceiptTrigger} ${MobileTouchTarget}`}
|
||||
>
|
||||
{/* Pill wrapper ensures visibility on any wallpaper/background */}
|
||||
<span
|
||||
|
||||
@@ -6,6 +6,11 @@ export const CardGrid = style({
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(3, 1fr)',
|
||||
gap: config.space.S400,
|
||||
'@media': {
|
||||
// Cards squish/overflow below ~360px each; drop to a single column on phones
|
||||
// (mirrors the 750px breakpoint the nav uses).
|
||||
'(max-width: 750px)': { gridTemplateColumns: '1fr' },
|
||||
},
|
||||
});
|
||||
|
||||
export const RoomCardBase = style([
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { useReducedMotion } from '../../hooks/useReducedMotion';
|
||||
import { zIndices } from '../../styles/zIndex';
|
||||
import { SeasonTheme } from './types';
|
||||
import { getActiveSeason } from './seasonSchedule';
|
||||
import { resolveSeasonTheme } from './seasonSchedule';
|
||||
import { HalloweenOverlay } from './themes/Halloween';
|
||||
import { ChristmasOverlay } from './themes/Christmas';
|
||||
import { NewYearOverlay } from './themes/NewYear';
|
||||
@@ -96,13 +96,25 @@ export function SeasonalPreview({ theme }: { theme: SeasonTheme }) {
|
||||
export function SeasonalEffect() {
|
||||
const settings = useAtomValue(settingsAtom);
|
||||
const reduced = useReducedMotion();
|
||||
const override = settings.seasonalThemeOverride ?? 'auto';
|
||||
|
||||
const theme = useMemo<SeasonTheme | null>(() => {
|
||||
const override = settings.seasonalThemeOverride ?? 'auto';
|
||||
if (override === 'off') return null;
|
||||
if (override === 'auto') return getActiveSeason(new Date());
|
||||
return override as SeasonTheme;
|
||||
}, [settings.seasonalThemeOverride]);
|
||||
// In auto mode, re-evaluate hourly so a long-lived session crosses a
|
||||
// season/holiday-window boundary (e.g. into a new day) without a reload —
|
||||
// otherwise the active season is frozen at the value it had on mount.
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
useEffect(() => {
|
||||
if (override !== 'auto') return undefined;
|
||||
// Refresh on entering auto too: `now` may be a stale mount-time value if we
|
||||
// were previously in a pinned/off mode (the interval only runs while auto).
|
||||
setNow(Date.now());
|
||||
const id = window.setInterval(() => setNow(Date.now()), 60 * 60 * 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [override]);
|
||||
|
||||
const theme = useMemo<SeasonTheme | null>(
|
||||
() => resolveSeasonTheme(override, now),
|
||||
[override, now],
|
||||
);
|
||||
|
||||
if (!theme) return null;
|
||||
// Suppress seasonal overlay when a chat background is active — both running simultaneously
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import { getActiveSeason, SEASON_SCHEDULE, SEASON_DATE_RANGES } from './seasonSchedule';
|
||||
import {
|
||||
getActiveSeason,
|
||||
resolveSeasonTheme,
|
||||
SEASON_SCHEDULE,
|
||||
SEASON_DATE_RANGES,
|
||||
} from './seasonSchedule';
|
||||
import { SeasonTheme } from './types';
|
||||
|
||||
// Date(year, monthIndex0, day)
|
||||
@@ -52,6 +57,27 @@ test('window boundaries are inclusive at both ends', () => {
|
||||
assert.equal(getActiveSeason(on(1, 16)), null); // Feb 16 just after
|
||||
});
|
||||
|
||||
test('resolveSeasonTheme: off → null, pinned → that theme, auto → active season', () => {
|
||||
const halloweenTs = on(9, 20).getTime(); // Oct 20 → halloween season
|
||||
const offSeasonTs = on(5, 15).getTime(); // Jun 15 → no season
|
||||
// 'off' never renders, regardless of date.
|
||||
assert.equal(resolveSeasonTheme('off', halloweenTs), null);
|
||||
// A pinned theme renders regardless of date (even off-season).
|
||||
assert.equal(resolveSeasonTheme('christmas', offSeasonTs), 'christmas');
|
||||
// 'auto' tracks the active season for the given instant.
|
||||
assert.equal(resolveSeasonTheme('auto', halloweenTs), 'halloween');
|
||||
assert.equal(resolveSeasonTheme('auto', offSeasonTs), null);
|
||||
});
|
||||
|
||||
test('resolveSeasonTheme: auto re-evaluates as `now` advances across a boundary', () => {
|
||||
// The same 'auto' override yields different themes at different instants — this
|
||||
// is what the SeasonalEffect ticker relies on (incl. the switch-into-auto case
|
||||
// where `now` must be current, not a stale mount value).
|
||||
assert.equal(resolveSeasonTheme('auto', on(9, 20).getTime()), 'halloween'); // Oct 20
|
||||
assert.equal(resolveSeasonTheme('auto', on(11, 15).getTime()), 'christmas'); // Dec 15
|
||||
assert.equal(resolveSeasonTheme('auto', on(6, 4).getTime()), null); // Jul 4
|
||||
});
|
||||
|
||||
test('SEASON_DATE_RANGES has a label for every scheduled theme', () => {
|
||||
assert.equal(SEASON_SCHEDULE.length, 11);
|
||||
const themes = SEASON_SCHEDULE.map((e) => e.theme);
|
||||
|
||||
@@ -93,3 +93,18 @@ export function getActiveSeason(now: Date): SeasonTheme | null {
|
||||
const day = now.getDate();
|
||||
return SEASON_SCHEDULE.find((entry) => entry.matches(month, day))?.theme ?? null;
|
||||
}
|
||||
|
||||
/** A seasonal-theme setting value: the active season, a pinned theme, or off. */
|
||||
export type SeasonalOverride = SeasonTheme | 'auto' | 'off';
|
||||
|
||||
/**
|
||||
* The theme to render for a `seasonalThemeOverride` at time `now` (epoch ms):
|
||||
* 'off' → none, 'auto' → the active season for that instant, else the pinned
|
||||
* theme. Kept pure (and unit-tested) so the decision is verifiable without
|
||||
* mounting the React overlay.
|
||||
*/
|
||||
export function resolveSeasonTheme(override: SeasonalOverride, now: number): SeasonTheme | null {
|
||||
if (override === 'off') return null;
|
||||
if (override === 'auto') return getActiveSeason(new Date(now));
|
||||
return override;
|
||||
}
|
||||
|
||||
@@ -81,6 +81,10 @@ export const SidebarItem = recipe({
|
||||
justifyContent: 'center',
|
||||
position: 'relative',
|
||||
transition: 'transform 200ms cubic-bezier(0, 0.8, 0.67, 0.97)',
|
||||
'@media': {
|
||||
// Space-rail buttons to a 44px touch target on phones (2px larger).
|
||||
'(max-width: 750px)': { minWidth: toRem(44), minHeight: toRem(44) },
|
||||
},
|
||||
|
||||
selectors: {
|
||||
'&:hover': {
|
||||
|
||||
@@ -275,6 +275,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
||||
key={key}
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
wrap="Wrap"
|
||||
style={{
|
||||
padding: config.space.S200,
|
||||
borderRadius: config.radii.R400,
|
||||
@@ -317,6 +318,7 @@ export function SoundboardPackEditor({ pack, canEdit, onUpdate }: SoundboardPack
|
||||
readOnly={!canEdit || markedDeleted}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => commit({ body: e.target.value })}
|
||||
aria-label="Clip name"
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
<Box
|
||||
|
||||
@@ -4,7 +4,9 @@ import { DefaultReset, color, config, toRem } from 'folds';
|
||||
export const UrlPreview = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: toRem(400),
|
||||
// 25rem (=400px) on desktop, but shrink to fit narrow phones so a single
|
||||
// card doesn't exceed the viewport (mirrors UrlPreviewWide's min()).
|
||||
width: 'min(25rem, 92vw)',
|
||||
minHeight: toRem(102),
|
||||
backgroundColor: color.SurfaceVariant.Container,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
@@ -21,6 +23,19 @@ export const UrlPreviewWide = style({
|
||||
width: 'min(38rem, 94vw)',
|
||||
});
|
||||
|
||||
// The Twitch/Twitter/TikTok-fallback cards lay their header/thumbnail out BESIDE
|
||||
// the content as direct children of the UrlPreview flex row; on a phone that
|
||||
// squeezes both. Stack them vertically on narrow viewports only. `UrlPreview`'s
|
||||
// Box has no explicit direction (browser default row), so this override wins
|
||||
// with nothing to compete against, and desktop (>750px) is unchanged.
|
||||
export const StackOnMobile = style({
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
flexDirection: 'column',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const UrlPreviewImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
@@ -404,6 +419,56 @@ export const BadgeSteam = style({
|
||||
color: '#c7d5e0',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Steam card — full-width header/banner image + official store widget iframe
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const SteamBannerWrapper = style([
|
||||
DefaultReset,
|
||||
{
|
||||
position: 'relative',
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
// Steam header capsules are 460×215 (~2.14:1); news banners vary but crop
|
||||
// fine to the same ratio.
|
||||
aspectRatio: '460 / 215',
|
||||
overflow: 'hidden',
|
||||
flexShrink: 0,
|
||||
backgroundColor: '#0e1520',
|
||||
cursor: 'pointer',
|
||||
|
||||
':hover': {
|
||||
filter: 'brightness(0.9)',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
export const SteamBannerImg = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
objectPosition: 'center',
|
||||
display: 'block',
|
||||
},
|
||||
]);
|
||||
|
||||
// The official Steam store widget is a compact banner (~646×190). Full-width,
|
||||
// fixed height so the iframe doesn't collapse to its intrinsic size.
|
||||
export const SteamWidget = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: '100%',
|
||||
height: toRem(190),
|
||||
border: 0,
|
||||
display: 'block',
|
||||
borderRadius: config.radii.R300,
|
||||
backgroundColor: '#1b2838',
|
||||
marginTop: config.space.S100,
|
||||
},
|
||||
]);
|
||||
|
||||
export const BadgeWikipedia = style({
|
||||
backgroundColor: color.SurfaceVariant.ContainerLine,
|
||||
color: color.SurfaceVariant.OnContainer,
|
||||
@@ -484,6 +549,16 @@ export const BadgeTidal = style({
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
export const BadgeMixcloud = style({
|
||||
backgroundColor: '#52aad8',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
export const BadgeDeezer = style({
|
||||
backgroundColor: '#a238ff',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Twitch LIVE badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IPreviewUrlResponse } from 'matrix-js-sdk';
|
||||
import { Box, Chip, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
|
||||
import {
|
||||
Box,
|
||||
Chip,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
as,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { ImageOverlay } from '../ImageOverlay';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { UrlPreview, UrlPreviewContent, UrlPreviewDescription, UrlPreviewImg } from './UrlPreview';
|
||||
@@ -21,11 +34,13 @@ import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import {
|
||||
extractEmbedHeight,
|
||||
getSteamTarget,
|
||||
getTikTokVideoId,
|
||||
getTweetId,
|
||||
isTikTokLink,
|
||||
MediaEmbed,
|
||||
parseMediaEmbed,
|
||||
steamWidgetEmbedUrl,
|
||||
tiktokIdFromOembed,
|
||||
tiktokOembedUrl,
|
||||
tiktokPlayerEmbedUrl,
|
||||
@@ -103,11 +118,15 @@ function isGitHubRepo(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Keep these hosts + the /status(es) pattern in sync with getTweetId
|
||||
// (videoEmbed.ts): otherwise a mobile.twitter.com / legacy /statuses/ tweet has
|
||||
// an extractable id but never routes to the Twitter card or "View post" embed.
|
||||
const TWITTER_HOSTS = new Set(['twitter.com', 'x.com', 'mobile.twitter.com']);
|
||||
|
||||
function isTwitter(url: string): boolean {
|
||||
try {
|
||||
const { hostname } = new URL(url);
|
||||
const h = hostname.replace(/^www\./, '');
|
||||
return h === 'twitter.com' || h === 'x.com';
|
||||
return TWITTER_HOSTS.has(hostname.replace(/^www\./, ''));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -116,9 +135,8 @@ function isTwitter(url: string): boolean {
|
||||
function isTwitterTweet(url: string): boolean {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
const h = hostname.replace(/^www\./, '');
|
||||
if (h !== 'twitter.com' && h !== 'x.com') return false;
|
||||
return /\/status\/\d+/.test(pathname);
|
||||
if (!TWITTER_HOSTS.has(hostname.replace(/^www\./, ''))) return false;
|
||||
return /\/status(?:es)?\/\d+/.test(pathname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
@@ -207,17 +225,6 @@ function isTwitch(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function isSteamApp(url: string): boolean {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
const h = hostname.replace(/^www\./, '');
|
||||
if (h !== 'store.steampowered.com') return false;
|
||||
return pathname.startsWith('/app/');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isWikipedia(url: string): boolean {
|
||||
try {
|
||||
const { hostname, pathname } = new URL(url);
|
||||
@@ -298,6 +305,32 @@ function isTenor(url: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// Synapse's thumbnailer flattens animated images to a single still frame, so a
|
||||
// GIF served from /thumbnail renders but never plays. Detect GIF previews so the
|
||||
// card can point at /download (the original) instead.
|
||||
function isGifPreview(url: string, prev: IPreviewUrlResponse): boolean {
|
||||
if (prev['og:image:type'] === 'image/gif') return true;
|
||||
try {
|
||||
return new URL(url).pathname.toLowerCase().endsWith('.gif');
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Ceiling on the /download upgrade below: a self-hosted GIF can be hundreds of
|
||||
// MB, and unlike a thumbnail it is served unscaled. Past the cap we keep the
|
||||
// (frozen) thumbnail — the card still links out, so the GIF is one click away.
|
||||
const GIF_ORIGINAL_MAX_BYTES = 10 * 1024 * 1024;
|
||||
|
||||
// Should this preview's image be fetched whole (so it animates) rather than
|
||||
// thumbnailed? Size is advisory: Synapse usually reports it, and when it's
|
||||
// absent we prefer a working animation over a hypothetical huge file.
|
||||
function shouldServeGifOriginal(url: string, prev: IPreviewUrlResponse): boolean {
|
||||
if (!isGifPreview(url, prev)) return false;
|
||||
const size = prev['matrix:image:size'];
|
||||
return typeof size !== 'number' || size <= GIF_ORIGINAL_MAX_BYTES;
|
||||
}
|
||||
|
||||
function getCardVariant(url: string): CardVariant {
|
||||
// NOTE: embeddable providers (YouTube/Vimeo/TikTok/Spotify/Twitch/…) are handled
|
||||
// upstream by parseMediaEmbed + MediaEmbedCard; getCardVariant only routes the
|
||||
@@ -308,7 +341,7 @@ function getCardVariant(url: string): CardVariant {
|
||||
if (getRedditSubreddit(url) !== null) return 'reddit';
|
||||
if (getSpotifyType(url) !== null) return 'spotify';
|
||||
if (isTwitch(url)) return 'twitch';
|
||||
if (isSteamApp(url)) return 'steam';
|
||||
if (getSteamTarget(url)) return 'steam';
|
||||
if (isWikipedia(url)) return 'wikipedia';
|
||||
if (isDiscordInvite(url)) return 'discord';
|
||||
if (isNpm(url)) return 'npm';
|
||||
@@ -714,6 +747,7 @@ function TwitterCard({
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => setExpanded(false)}
|
||||
aria-label="Collapse post"
|
||||
>
|
||||
@@ -768,6 +802,7 @@ function TwitterCard({
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => setExpanded(true)}
|
||||
before={<Icon size="50" src={Icons.Play} />}
|
||||
>
|
||||
@@ -1068,6 +1103,8 @@ const EMBED_BADGE: Record<string, { label: string; class: string }> = {
|
||||
bluesky: { label: 'Bluesky', class: previewCss.BadgeBluesky },
|
||||
loom: { label: 'Loom', class: previewCss.BadgeLoom },
|
||||
kick: { label: 'Kick', class: previewCss.BadgeKick },
|
||||
mixcloud: { label: 'Mixcloud', class: previewCss.BadgeMixcloud },
|
||||
deezer: { label: 'Deezer', class: previewCss.BadgeDeezer },
|
||||
};
|
||||
|
||||
// The homeserver preview for some sites (notably Reddit) comes back as a bot-check
|
||||
@@ -1237,6 +1274,7 @@ function MediaEmbedCard({
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
className={MobileTouchTarget}
|
||||
onClick={enterFullscreen}
|
||||
aria-label="Fullscreen"
|
||||
>
|
||||
@@ -1247,6 +1285,7 @@ function MediaEmbedCard({
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => setPlaying(false)}
|
||||
aria-label="Close player"
|
||||
>
|
||||
@@ -1360,9 +1399,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
onClick={start}
|
||||
disabled={resolving}
|
||||
aria-busy={resolving}
|
||||
aria-label={
|
||||
resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`
|
||||
}
|
||||
aria-label={resolving ? 'Loading TikTok…' : `Play TikTok${title ? `: ${title}` : ''}`}
|
||||
>
|
||||
{facadeInner}
|
||||
</button>
|
||||
@@ -1397,6 +1434,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
className={MobileTouchTarget}
|
||||
onClick={enterFullscreen}
|
||||
aria-label="Fullscreen"
|
||||
>
|
||||
@@ -1406,6 +1444,7 @@ function TikTokEmbedCard({ url, prev }: { url: string; prev: IPreviewUrlResponse
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => setPlaying(false)}
|
||||
aria-label="Close player"
|
||||
>
|
||||
@@ -1482,9 +1521,17 @@ function GitHubCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
}
|
||||
|
||||
function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const artworkUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
// Route through the homeserver like every other card — a raw og:image would
|
||||
// be an mxc:// URI (broken <img>) on a standard HS, or an off-HS request that
|
||||
// defeats the click-to-play facade on a nonstandard one.
|
||||
const artworkUrl = mxcImage
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
|
||||
: null;
|
||||
const spotifyType = getSpotifyType(url) ?? 'track';
|
||||
const typeLabel = spotifyType.charAt(0).toUpperCase() + spotifyType.slice(1);
|
||||
|
||||
@@ -1534,10 +1581,16 @@ function SpotifyCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
|
||||
);
|
||||
}
|
||||
|
||||
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
// Steam bundle/sub/dlc or other store page — OG card (no per-app widget).
|
||||
function SteamStoreCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const thumbnailUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
const thumbnailUrl = mxcImage
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 480, 270, 'scale', false)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1598,6 +1651,167 @@ function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
);
|
||||
}
|
||||
|
||||
// Steam news / announcement post — rich OG card (Steam has no official embed
|
||||
// widget for announcements): banner + headline + body preview.
|
||||
function SteamNewsCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
const bannerUrl = mxcImage
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 460, 215, 'scale', false)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box direction="Column" style={{ width: '100%' }}>
|
||||
{bannerUrl && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={previewCss.SteamBannerWrapper}
|
||||
aria-label={`View on Steam: ${title}`}
|
||||
>
|
||||
<img className={previewCss.SteamBannerImg} src={bannerUrl} alt={title} loading="lazy" />
|
||||
</a>
|
||||
)}
|
||||
<UrlPreviewContent>
|
||||
<Box alignItems="Center" gap="100" wrap="Wrap">
|
||||
<SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
|
||||
<Text size="T200" priority="300" style={{ opacity: 0.7 }}>
|
||||
Announcement
|
||||
</Text>
|
||||
</Box>
|
||||
{title && (
|
||||
<Text
|
||||
priority="400"
|
||||
style={{
|
||||
fontWeight: 700,
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{title}
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text size="T200" priority="300">
|
||||
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||
</Text>
|
||||
)}
|
||||
<Text
|
||||
style={linkStyles}
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
View on Steam
|
||||
</Text>
|
||||
</UrlPreviewContent>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// Steam store app page — OG header + a click-to-play facade that loads Steam's
|
||||
// official store-widget iframe (live, region-aware price / discount / Buy on
|
||||
// Steam). Nothing loads from Steam until the user presses "Show price & store".
|
||||
function SteamAppCard({
|
||||
url,
|
||||
prev,
|
||||
appId,
|
||||
}: {
|
||||
url: string;
|
||||
prev: IPreviewUrlResponse;
|
||||
appId: string;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [inlineMediaEmbeds] = useSetting(settingsAtom, 'inlineMediaEmbeds');
|
||||
const [showWidget, setShowWidget] = useState(false);
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
const capsuleUrl = mxcImage
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 460, 215, 'scale', false)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Box direction="Column" style={{ width: '100%' }}>
|
||||
{capsuleUrl && (
|
||||
<a
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className={previewCss.SteamBannerWrapper}
|
||||
aria-label={`View on Steam: ${title}`}
|
||||
>
|
||||
<img className={previewCss.SteamBannerImg} src={capsuleUrl} alt={title} loading="lazy" />
|
||||
</a>
|
||||
)}
|
||||
<UrlPreviewContent>
|
||||
<SiteBadge label="Steam" colorClass={previewCss.BadgeSteam} />
|
||||
{title && (
|
||||
<Text truncate priority="400">
|
||||
<b>{title}</b>
|
||||
</Text>
|
||||
)}
|
||||
{description && (
|
||||
<Text size="T200" priority="300">
|
||||
<UrlPreviewDescription>{description}</UrlPreviewDescription>
|
||||
</Text>
|
||||
)}
|
||||
{showWidget ? (
|
||||
<iframe
|
||||
className={previewCss.SteamWidget}
|
||||
src={steamWidgetEmbedUrl(appId)}
|
||||
title={title ? `Steam store: ${title}` : 'Steam store widget'}
|
||||
sandbox={EMBED_SANDBOX}
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<Box alignItems="Center" gap="200" justifyContent="SpaceBetween">
|
||||
<Text
|
||||
style={linkStyles}
|
||||
truncate
|
||||
as="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
store.steampowered.com
|
||||
</Text>
|
||||
{inlineMediaEmbeds && (
|
||||
<Chip
|
||||
variant="Secondary"
|
||||
radii="Pill"
|
||||
onClick={() => setShowWidget(true)}
|
||||
before={<Icon size="50" src={Icons.Setting} />}
|
||||
>
|
||||
<Text size="T200">Show price & store</Text>
|
||||
</Chip>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</UrlPreviewContent>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
function SteamCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const target = getSteamTarget(url);
|
||||
if (target?.kind === 'news') return <SteamNewsCard url={url} prev={prev} />;
|
||||
if (target?.kind === 'app') return <SteamAppCard url={url} prev={prev} appId={target.appId} />;
|
||||
return <SteamStoreCard url={url} prev={prev} />;
|
||||
}
|
||||
|
||||
function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const title = prev['og:title'] ?? '';
|
||||
const rawDescription = prev['og:description'] ?? '';
|
||||
@@ -1654,9 +1868,14 @@ function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }
|
||||
|
||||
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const iconUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
const iconUrl = mxcImage
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 96, 96, 'scale', false)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1817,9 +2036,14 @@ function StackOverflowCard({ url, prev }: { url: string; prev: IPreviewUrlRespon
|
||||
}
|
||||
|
||||
function ImdbCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const title = prev['og:title'] ?? '';
|
||||
const description = prev['og:description'] ?? '';
|
||||
const posterUrl = (prev['og:image'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
const posterUrl = mxcImage
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 120, 180, 'scale', false)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -1885,8 +2109,13 @@ function GifCard({
|
||||
const title = (prev['og:title'] as string | undefined) ?? '';
|
||||
const mxcImage = prev['og:image'] as string | undefined;
|
||||
|
||||
// A GIF card exists to show a moving GIF, so request the original rather than
|
||||
// a thumbnail — the thumbnail endpoint would return a frozen first frame.
|
||||
// `loading="lazy"` below keeps it off the wire until it's near the viewport.
|
||||
const thumbSrc = mxcImage
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
||||
? shouldServeGifOriginal(url, prev)
|
||||
? mxcUrlToHttp(mx, mxcImage, useAuthentication)
|
||||
: mxcUrlToHttp(mx, mxcImage, useAuthentication, 400, 200, 'scale', false)
|
||||
: null;
|
||||
|
||||
// If there's no image, fall back to a generic-style layout
|
||||
@@ -1965,16 +2194,30 @@ function GenericCard({
|
||||
const description = prev['og:description'] ?? '';
|
||||
const siteName = typeof prev['og:site_name'] === 'string' ? prev['og:site_name'] : undefined;
|
||||
|
||||
// Synapse returns 400 from the thumbnail endpoint when it can't thumbnail a
|
||||
// cached preview image (e.g. SVG/animated). Fall back to the full image, then
|
||||
// hide entirely if that also fails — otherwise the card shows a broken image
|
||||
// and the browser keeps re-requesting the failing thumbnail (console spam).
|
||||
const [useFullImg, setUseFullImg] = useState(false);
|
||||
const [imgFailed, setImgFailed] = useState(false);
|
||||
const displayThumb = useFullImg ? imgUrl : thumbUrl;
|
||||
const handleImgError = () => {
|
||||
if (!useFullImg && imgUrl && imgUrl !== thumbUrl) setUseFullImg(true);
|
||||
else setImgFailed(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{thumbUrl && (
|
||||
{!imgFailed && displayThumb && (
|
||||
<UrlPreviewImg
|
||||
src={thumbUrl}
|
||||
src={displayThumb}
|
||||
alt={prev['og:title']}
|
||||
title={prev['og:title']}
|
||||
loading="lazy"
|
||||
tabIndex={0}
|
||||
onKeyDown={(evt) => onEnterOrSpace(() => onOpenViewer())(evt)}
|
||||
onClick={onOpenViewer}
|
||||
onError={handleImgError}
|
||||
/>
|
||||
)}
|
||||
{imgUrl && (
|
||||
@@ -1997,7 +2240,7 @@ function GenericCard({
|
||||
size="T200"
|
||||
priority="300"
|
||||
>
|
||||
{!thumbUrl && (
|
||||
{(!displayThumb || imgFailed) && (
|
||||
<Icon
|
||||
src={Icons.Link}
|
||||
size="50"
|
||||
@@ -2045,21 +2288,37 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
// Interactive embeds (players, tweets) render in a wider, responsive card so
|
||||
// player chrome / tweet content isn't cramped or clipped.
|
||||
const embed = parseMediaEmbed(url, window.location.hostname);
|
||||
const wide = !!embed || isTwitterTweet(url);
|
||||
const cardClass = wide ? previewCss.UrlPreviewWide : undefined;
|
||||
|
||||
const renderContent = (prev: IPreviewUrlResponse): React.ReactNode => {
|
||||
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
||||
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play tile.
|
||||
// Short "copy-link" share URLs (e.g. vm.tiktok.com, tiktok.com/t/…, youtu.be
|
||||
// redirects) don't carry the id, so fall back to the canonical og:url that
|
||||
// the homeserver already resolved when fetching the preview.
|
||||
// Short "copy-link" links carry no id, so the embed is only resolvable from
|
||||
// the homeserver's canonical og:url. Resolve it here so `wide` reflects the
|
||||
// ACTUALLY rendered card — an og:url-resolved MediaEmbedCard must still get
|
||||
// the wide layout, not the cramped narrow one.
|
||||
const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => {
|
||||
if (embed) return embed;
|
||||
const ogUrl = prev['og:url'];
|
||||
const resolvedEmbed =
|
||||
embed ??
|
||||
(typeof ogUrl === 'string' && ogUrl !== url
|
||||
? parseMediaEmbed(ogUrl, window.location.hostname)
|
||||
: null);
|
||||
return typeof ogUrl === 'string' && ogUrl !== url
|
||||
? parseMediaEmbed(ogUrl, window.location.hostname)
|
||||
: null;
|
||||
};
|
||||
// A Steam app page renders the official ~646px store-widget iframe, so it
|
||||
// needs the wide card too.
|
||||
const steamAppWide = getSteamTarget(url)?.kind === 'app';
|
||||
// Twitter/Twitch/TikTok(fallback) cards render header/thumbnail beside content
|
||||
// in the card flex row; stack them on phones (no-op for the single-column
|
||||
// embed cards). Desktop keeps the row layout.
|
||||
const stackOnMobile = isTwitter(url) || isTwitch(url) || isTikTok(url);
|
||||
const buildCardClass = (wide: boolean): string | undefined =>
|
||||
[wide && previewCss.UrlPreviewWide, stackOnMobile && previewCss.StackOnMobile]
|
||||
.filter(Boolean)
|
||||
.join(' ') || undefined;
|
||||
|
||||
const renderContent = (
|
||||
prev: IPreviewUrlResponse,
|
||||
resolvedEmbed: MediaEmbed | null,
|
||||
): React.ReactNode => {
|
||||
// Embeddable media (YouTube/Vimeo/TikTok/Dailymotion/Streamable/Twitch/
|
||||
// Spotify/SoundCloud/Apple Music/Tidal/Instagram/Reddit) → click-to-play
|
||||
// tile. `resolvedEmbed` (computed by the caller via resolveEmbed) already
|
||||
// folds in the og:url fallback for short "copy-link" share URLs.
|
||||
if (resolvedEmbed) {
|
||||
return <MediaEmbedCard url={url} prev={prev} embed={resolvedEmbed} />;
|
||||
}
|
||||
@@ -2124,16 +2383,11 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
// Generic fallback — skip empty cards
|
||||
if (!prev['og:title'] && !prev['og:description']) return null;
|
||||
|
||||
const thumbUrl = mxcUrlToHttp(
|
||||
mx,
|
||||
prev['og:image'] || '',
|
||||
useAuthentication,
|
||||
256,
|
||||
256,
|
||||
'scale',
|
||||
false,
|
||||
);
|
||||
const imgUrl = mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication);
|
||||
// Show the original for GIFs so they animate; thumbnailing freezes them.
|
||||
const thumbUrl = shouldServeGifOriginal(url, prev)
|
||||
? imgUrl
|
||||
: mxcUrlToHttp(mx, prev['og:image'] || '', useAuthentication, 256, 256, 'scale', false);
|
||||
|
||||
return (
|
||||
<GenericCard
|
||||
@@ -2152,17 +2406,27 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>(
|
||||
|
||||
// Don't render the card wrapper when content is empty (loaded but nothing to show)
|
||||
if (previewStatus.status === AsyncStatus.Success) {
|
||||
const content = renderContent(previewStatus.data);
|
||||
const prev = previewStatus.data;
|
||||
const resolvedEmbed = resolveEmbed(prev);
|
||||
const content = renderContent(prev, resolvedEmbed);
|
||||
if (content === null) return null;
|
||||
// `wide` follows the resolved embed (incl. the og:url fallback), so a short
|
||||
// link that resolves to a player still gets the wide layout.
|
||||
const wide = !!resolvedEmbed || isTwitterTweet(url) || steamAppWide;
|
||||
return (
|
||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
||||
<UrlPreview {...props} ref={ref} className={buildCardClass(wide)}>
|
||||
{content}
|
||||
</UrlPreview>
|
||||
);
|
||||
}
|
||||
|
||||
// Loading/idle: no preview data yet, so base `wide` on the url-only embed.
|
||||
return (
|
||||
<UrlPreview {...props} ref={ref} className={cardClass}>
|
||||
<UrlPreview
|
||||
{...props}
|
||||
ref={ref}
|
||||
className={buildCardClass(!!embed || isTwitterTweet(url) || steamAppWide)}
|
||||
>
|
||||
<Box grow="Yes" alignItems="Center" justifyContent="Center">
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
|
||||
@@ -114,7 +114,7 @@ export function ServerChip({ server }: { server: string }) {
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={() => {
|
||||
window.open(`https://${server}`, '_blank');
|
||||
window.open(`https://${server}`, '_blank', 'noopener,noreferrer');
|
||||
close();
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { UserAvatar } from '../user-avatar';
|
||||
import colorMXID from '../../../util/colorMXID';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
|
||||
import { ModalMobileFull } from '../../styles/Modal.css';
|
||||
import { UserPresence } from '../../hooks/useUserPresence';
|
||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
|
||||
@@ -83,7 +84,11 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal size="500" onContextMenu={(evt: any) => evt.stopPropagation()}>
|
||||
<Modal
|
||||
size="500"
|
||||
className={ModalMobileFull}
|
||||
onContextMenu={(evt: any) => evt.stopPropagation()}
|
||||
>
|
||||
<ImageViewer
|
||||
src={viewAvatar}
|
||||
alt={userId}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import React, { ChangeEvent, ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useAtom } from 'jotai';
|
||||
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
||||
import {
|
||||
Avatar,
|
||||
Box,
|
||||
@@ -16,13 +18,19 @@ import {
|
||||
} from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { useBookmarks, Bookmark } from '../../hooks/useBookmarks';
|
||||
import {
|
||||
BookmarkSort,
|
||||
isBookmarkSort,
|
||||
sortBookmarks,
|
||||
groupBookmarksByRoom,
|
||||
} from '../../utils/bookmarks';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomEvent } from '../../hooks/useRoomEvent';
|
||||
import { MessageDeletedContent } from '../../components/message/content/FallbackContent';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { RoomAvatar } from '../../components/room-avatar';
|
||||
import { getRoomAvatarUrl } from '../../utils/room';
|
||||
import { getRoomAvatarUrl, getMemberName } from '../../utils/room';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
@@ -41,15 +49,57 @@ function formatTimeAgo(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Remember the last-chosen sort across panel opens (the panel unmounts on close).
|
||||
// getOnInit reads localStorage synchronously at init so the persisted sort is
|
||||
// applied on the very first render (no flash from the 'newest' default).
|
||||
const bookmarkSortAtom = atomWithStorage<BookmarkSort>(
|
||||
'cinny_bookmarks_sort_v1',
|
||||
'newest',
|
||||
createJSONStorage(() => localStorage),
|
||||
{ getOnInit: true },
|
||||
);
|
||||
|
||||
const SORT_OPTIONS: { value: BookmarkSort; label: string }[] = [
|
||||
{ value: 'newest', label: 'Newest' },
|
||||
{ value: 'oldest', label: 'Oldest' },
|
||||
{ value: 'room', label: 'By room' },
|
||||
];
|
||||
|
||||
// Segmented sort button — mirrors MediaGallery's tab styling for house consistency.
|
||||
function SortButton({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
size="300"
|
||||
variant={active ? 'Primary' : 'Secondary'}
|
||||
fill={active ? 'Solid' : 'Soft'}
|
||||
radii="300"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Text size="B300">{label}</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type BookmarkItemProps = {
|
||||
bookmark: Bookmark;
|
||||
onJump: (roomId: string, eventId: string) => void;
|
||||
onRemove: (eventId: string) => void;
|
||||
// Optional live-rendered preview node; falls back to the stored snapshot when absent.
|
||||
preview?: ReactNode;
|
||||
// Live-resolved author name; falls back to the stored snapshot when absent.
|
||||
senderName?: string;
|
||||
};
|
||||
|
||||
function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps) {
|
||||
function BookmarkItem({ bookmark, onJump, onRemove, preview, senderName }: BookmarkItemProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const room = mx.getRoom(bookmark.roomId) ?? undefined;
|
||||
@@ -57,6 +107,9 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps
|
||||
const avatarUrl = room
|
||||
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
|
||||
: undefined;
|
||||
// Prefer a live-resolved author name, then the stored snapshot.
|
||||
const author = senderName ?? bookmark.senderName;
|
||||
const timeAgo = formatTimeAgo(bookmark.savedAt);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -82,8 +135,8 @@ function BookmarkItem({ bookmark, onJump, onRemove, preview }: BookmarkItemProps
|
||||
<Text size="T200" truncate style={{ fontWeight: config.fontWeight.W600 }}>
|
||||
{displayRoomName}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{formatTimeAgo(bookmark.savedAt)}
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{author ? `${author} · ${timeAgo}` : timeAgo}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
@@ -144,7 +197,81 @@ function LiveBookmarkItem({ room, bookmark, onJump, onRemove }: LiveBookmarkItem
|
||||
}
|
||||
}
|
||||
|
||||
return <BookmarkItem bookmark={bookmark} onJump={onJump} onRemove={onRemove} preview={preview} />;
|
||||
// Resolve the author's current display name from the live event when available.
|
||||
const liveSender = liveEvent?.getSender();
|
||||
const senderName = liveSender ? getMemberName(room, liveSender) : undefined;
|
||||
|
||||
return (
|
||||
<BookmarkItem
|
||||
bookmark={bookmark}
|
||||
onJump={onJump}
|
||||
onRemove={onRemove}
|
||||
preview={preview}
|
||||
senderName={senderName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type RoomGroupHeaderProps = {
|
||||
roomId: string;
|
||||
roomName: string;
|
||||
count: number;
|
||||
collapsed: boolean;
|
||||
contentId: string;
|
||||
onToggle: () => void;
|
||||
};
|
||||
|
||||
// Collapsible section header for the "By room" grouping. Uses the live room name
|
||||
// / avatar when the room is joined, falling back to the stored snapshot name.
|
||||
function RoomGroupHeader({
|
||||
roomId,
|
||||
roomName,
|
||||
count,
|
||||
collapsed,
|
||||
contentId,
|
||||
onToggle,
|
||||
}: RoomGroupHeaderProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const room = mx.getRoom(roomId) ?? undefined;
|
||||
const displayRoomName = room?.name ?? roomName;
|
||||
const avatarUrl = room
|
||||
? (getRoomAvatarUrl(mx, room, 96, useAuthentication) ?? undefined)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
size="300"
|
||||
radii="300"
|
||||
onClick={onToggle}
|
||||
aria-expanded={!collapsed}
|
||||
aria-controls={contentId}
|
||||
// Explicit name avoids the avatar alt + visible name being announced twice,
|
||||
// and gives the bare count meaning for screen readers.
|
||||
aria-label={`${displayRoomName}, ${count} saved message${count !== 1 ? 's' : ''}`}
|
||||
style={{ justifyContent: 'flex-start', padding: config.space.S200 }}
|
||||
>
|
||||
<Box grow="Yes" alignItems="Center" gap="200" style={{ minWidth: 0 }}>
|
||||
<Icon size="100" src={collapsed ? Icons.ChevronRight : Icons.ChevronBottom} />
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={roomId}
|
||||
src={avatarUrl}
|
||||
alt=""
|
||||
renderFallback={() => <Text size="H6">{nameInitials(displayRoomName)}</Text>}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="T200" truncate style={{ flexGrow: 1, fontWeight: config.fontWeight.W600 }}>
|
||||
{displayRoomName}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" style={{ flexShrink: 0 }}>
|
||||
{count}
|
||||
</Text>
|
||||
</Box>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
type BookmarksPanelProps = {
|
||||
@@ -156,6 +283,20 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||
const { bookmarks, removeBookmark } = useBookmarks();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const [filter, setFilter] = useState('');
|
||||
const [storedSort, setSort] = useAtom(bookmarkSortAtom);
|
||||
// Normalize a stale/corrupt persisted value so exactly one sort is always active.
|
||||
const sort: BookmarkSort = isBookmarkSort(storedSort) ? storedSort : 'newest';
|
||||
// roomIds whose group section is collapsed (only relevant in "By room" mode).
|
||||
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
|
||||
|
||||
const toggleGroup = useCallback((roomId: string) => {
|
||||
setCollapsed((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(roomId)) next.delete(roomId);
|
||||
else next.add(roomId);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Escape closes the panel (parity with the app's other overlays/drawers).
|
||||
useEffect(() => {
|
||||
@@ -182,14 +323,69 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||
};
|
||||
|
||||
const query = filter.trim().toLowerCase();
|
||||
const filtered: Bookmark[] =
|
||||
query.length === 0
|
||||
? bookmarks
|
||||
: bookmarks.filter(
|
||||
(bk) =>
|
||||
bk.previewText.toLowerCase().includes(query) ||
|
||||
bk.roomName.toLowerCase().includes(query),
|
||||
);
|
||||
const filtered: Bookmark[] = useMemo(
|
||||
() =>
|
||||
query.length === 0
|
||||
? bookmarks
|
||||
: bookmarks.filter(
|
||||
(bk) =>
|
||||
bk.previewText.toLowerCase().includes(query) ||
|
||||
bk.roomName.toLowerCase().includes(query) ||
|
||||
(bk.senderName?.toLowerCase().includes(query) ?? false),
|
||||
),
|
||||
[bookmarks, query],
|
||||
);
|
||||
|
||||
// Prune collapsed roomIds that no longer have any bookmark, so a room re-saved
|
||||
// later doesn't reappear pre-collapsed and the Set can't grow unbounded.
|
||||
useEffect(() => {
|
||||
setCollapsed((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
const live = new Set(bookmarks.map((bk) => bk.roomId));
|
||||
let changed = false;
|
||||
const next = new Set<string>();
|
||||
prev.forEach((roomId) => {
|
||||
if (live.has(roomId)) next.add(roomId);
|
||||
else changed = true;
|
||||
});
|
||||
return changed ? next : prev;
|
||||
});
|
||||
}, [bookmarks]);
|
||||
|
||||
// Live render when the room is joined (useRoomEvent needs a non-null Room);
|
||||
// otherwise fall back to the stored snapshot for rooms we've left. Shared by
|
||||
// both the flat (newest/oldest) and grouped (by room) render paths.
|
||||
const renderItem = useCallback(
|
||||
(bk: Bookmark) => {
|
||||
const room = mx.getRoom(bk.roomId);
|
||||
return room ? (
|
||||
<LiveBookmarkItem
|
||||
key={bk.eventId}
|
||||
room={room}
|
||||
bookmark={bk}
|
||||
onJump={handleJump}
|
||||
onRemove={removeBookmark}
|
||||
/>
|
||||
) : (
|
||||
<BookmarkItem
|
||||
key={bk.eventId}
|
||||
bookmark={bk}
|
||||
onJump={handleJump}
|
||||
onRemove={removeBookmark}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[mx, handleJump, removeBookmark],
|
||||
);
|
||||
|
||||
const sortedItems = useMemo(
|
||||
() => (sort === 'room' ? filtered : sortBookmarks(filtered, sort)),
|
||||
[filtered, sort],
|
||||
);
|
||||
const groups = useMemo(
|
||||
() => (sort === 'room' ? groupBookmarksByRoom(filtered) : []),
|
||||
[filtered, sort],
|
||||
);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -237,11 +433,23 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||
}
|
||||
/>
|
||||
{bookmarks.length > 0 && (
|
||||
<Text size="T200" priority="300">
|
||||
{filtered.length === bookmarks.length
|
||||
? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
|
||||
: `${filtered.length} of ${bookmarks.length} messages`}
|
||||
</Text>
|
||||
<>
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{filtered.length === bookmarks.length
|
||||
? `${bookmarks.length} saved message${bookmarks.length !== 1 ? 's' : ''}`
|
||||
: `${filtered.length} of ${bookmarks.length} messages`}
|
||||
</Text>
|
||||
<Box as="div" role="group" aria-label="Sort saved messages" gap="100">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SortButton
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
active={sort === opt.value}
|
||||
onClick={() => setSort(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
@@ -265,27 +473,29 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) {
|
||||
</Box>
|
||||
) : (
|
||||
<Box className={css.BookmarksContent} direction="Column" gap="200">
|
||||
{filtered.map((bk) => {
|
||||
// Live render when the room is joined (useRoomEvent needs a non-null Room);
|
||||
// otherwise fall back to the stored snapshot for rooms we've left.
|
||||
const room = mx.getRoom(bk.roomId);
|
||||
return room ? (
|
||||
<LiveBookmarkItem
|
||||
key={bk.eventId}
|
||||
room={room}
|
||||
bookmark={bk}
|
||||
onJump={handleJump}
|
||||
onRemove={removeBookmark}
|
||||
/>
|
||||
) : (
|
||||
<BookmarkItem
|
||||
key={bk.eventId}
|
||||
bookmark={bk}
|
||||
onJump={handleJump}
|
||||
onRemove={removeBookmark}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{sort === 'room'
|
||||
? groups.map((group) => {
|
||||
const isCollapsed = collapsed.has(group.roomId);
|
||||
const contentId = `bookmark-group-${group.roomId}`;
|
||||
return (
|
||||
<Box key={group.roomId} direction="Column" gap="200">
|
||||
<RoomGroupHeader
|
||||
roomId={group.roomId}
|
||||
roomName={group.roomName}
|
||||
count={group.items.length}
|
||||
collapsed={isCollapsed}
|
||||
contentId={contentId}
|
||||
onToggle={() => toggleGroup(group.roomId)}
|
||||
/>
|
||||
{!isCollapsed && (
|
||||
<Box id={contentId} direction="Column" gap="200">
|
||||
{group.items.map((bk) => renderItem(bk))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})
|
||||
: sortedItems.map((bk) => renderItem(bk))}
|
||||
</Box>
|
||||
)}
|
||||
</Scroll>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StatusDivider } from './components';
|
||||
import { CallEmbed, useCallControlState } from '../../plugins/call';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
|
||||
type MicrophoneButtonProps = {
|
||||
enabled: boolean;
|
||||
@@ -31,6 +32,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
className={MobileTouchTarget}
|
||||
onClick={toggleMic}
|
||||
outlined
|
||||
disabled={disabled || loading}
|
||||
@@ -66,6 +68,7 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => onToggle()}
|
||||
aria-label={enabled ? 'Deafen' : 'Undeafen'}
|
||||
aria-pressed={enabled}
|
||||
@@ -108,6 +111,7 @@ function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
className={MobileTouchTarget}
|
||||
onClick={toggleVideo}
|
||||
aria-label={enabled ? 'Stop Video' : 'Start Video'}
|
||||
aria-pressed={enabled}
|
||||
@@ -147,6 +151,7 @@ function ScreenShareButton({ enabled, onToggle, disabled }: ScreenShareButtonPro
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
className={MobileTouchTarget}
|
||||
onClick={onToggle}
|
||||
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
|
||||
aria-pressed={enabled}
|
||||
|
||||
@@ -35,6 +35,7 @@ import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { useResizeObserver } from '../../hooks/useResizeObserver';
|
||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||
@@ -51,18 +52,25 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
const controlRef = useRef<HTMLDivElement>(null);
|
||||
const callEmbedRef = useCallEmbedRef();
|
||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||
const [compact, setCompact] = useState(document.body.clientWidth < 500);
|
||||
const screenSize = useScreenSize();
|
||||
const [narrowContainer, setNarrowContainer] = useState(document.body.clientWidth < 500);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useResizeObserver(
|
||||
useCallback(() => {
|
||||
const element = controlRef.current;
|
||||
if (!element) return;
|
||||
setCompact(element.clientWidth < 500);
|
||||
setNarrowContainer(element.clientWidth < 500);
|
||||
}, []),
|
||||
useCallback(() => controlRef.current, []),
|
||||
);
|
||||
|
||||
// Collapse to the stacked/compact layout whenever the bar's own container is
|
||||
// narrow (a small desktop call window) OR the viewport is a phone. The old
|
||||
// element-only `< 500` check left the ~11-control row overflowing off-screen
|
||||
// in the 500–750px band (landscape phones / small tablets).
|
||||
const compact = narrowContainer || screenSize === ScreenSize.Mobile;
|
||||
|
||||
useEffect(() => {
|
||||
const onFullscreenChange = () => setIsFullscreen(!!document.fullscreenElement);
|
||||
document.addEventListener('fullscreenchange', onFullscreenChange);
|
||||
@@ -330,6 +338,9 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
padding: '1rem 1.25rem',
|
||||
zIndex: 100,
|
||||
minWidth: '260px',
|
||||
// Don't run past the screen edges on a narrow phone (centered via
|
||||
// translateX(-50%)); clamp to the viewport minus a small margin.
|
||||
maxWidth: `calc(100vw - 2 * ${config.space.S400})`,
|
||||
boxShadow: '0 8px 32px rgba(0,0,0,0.35)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
@@ -376,6 +387,7 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
||||
radii="500"
|
||||
alignItems="Center"
|
||||
justifyContent="SpaceBetween"
|
||||
wrap="Wrap"
|
||||
>
|
||||
<Box alignItems="Center" gap="Inherit" grow="Yes" direction={compact ? 'Column' : 'Row'}>
|
||||
<Box shrink="No" alignItems="Inherit" justifyContent="Inherit" gap="200">
|
||||
|
||||
@@ -67,9 +67,11 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||
// C-L6: the play() flow schedules a 30s safety timeout that clears playingKey;
|
||||
// guard those setState calls against the component unmounting first.
|
||||
const mountedRef = useRef(true);
|
||||
const safetyTimerRef = useRef<number | undefined>(undefined);
|
||||
useEffect(
|
||||
() => () => {
|
||||
mountedRef.current = false;
|
||||
if (safetyTimerRef.current !== undefined) window.clearTimeout(safetyTimerRef.current);
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -96,7 +98,17 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||
if (playingKey) return; // one at a time (fork also enforces this)
|
||||
setPlayingKey(flat.key);
|
||||
setError(undefined);
|
||||
// Per-play timer token: `done` clears its OWN timer by identity, so a
|
||||
// stale done() from a prior clip can't disarm a newer clip's safety timer
|
||||
// (which — since a rejected audio.play() fires neither ended nor error —
|
||||
// is sometimes the only thing that unsticks the playingKey guard).
|
||||
let myTimer: number | undefined;
|
||||
const done = () => {
|
||||
if (myTimer !== undefined) {
|
||||
window.clearTimeout(myTimer);
|
||||
if (safetyTimerRef.current === myTimer) safetyTimerRef.current = undefined;
|
||||
myTimer = undefined;
|
||||
}
|
||||
if (!mountedRef.current) return;
|
||||
setPlayingKey((k) => (k === flat.key ? undefined : k));
|
||||
};
|
||||
@@ -108,11 +120,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||
if (audio) {
|
||||
audio.addEventListener('ended', done, { once: true });
|
||||
audio.addEventListener('error', done, { once: true });
|
||||
// Safety: clear the guard even if the audio never signals end.
|
||||
myTimer = window.setTimeout(done, 30_000);
|
||||
safetyTimerRef.current = myTimer;
|
||||
} else {
|
||||
done();
|
||||
}
|
||||
// Safety: clear the guard even if the audio never signals end.
|
||||
window.setTimeout(done, 30_000);
|
||||
} catch {
|
||||
setError('Could not play that clip.');
|
||||
done();
|
||||
@@ -135,7 +148,12 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu style={{ maxWidth: manage ? toRem(420) : toRem(340), maxHeight: '70vh' }}>
|
||||
<Menu
|
||||
style={{
|
||||
maxWidth: `min(${manage ? toRem(420) : toRem(340)}, calc(100vw - 2 * ${config.space.S400}))`,
|
||||
maxHeight: '70vh',
|
||||
}}
|
||||
>
|
||||
<Box direction="Column" style={{ maxHeight: '70vh' }}>
|
||||
<Box
|
||||
shrink="No"
|
||||
|
||||
@@ -2,6 +2,7 @@ import React from 'react';
|
||||
import { Icon, IconButton, Icons, Line, Text, Tooltip, TooltipProvider } from 'folds';
|
||||
import { useAtom } from 'jotai';
|
||||
import * as css from './styles.css';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
|
||||
@@ -36,6 +37,7 @@ export function MicrophoneButton({ enabled, onToggle }: MicrophoneButtonProps) {
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={toggleMic}
|
||||
aria-label={enabled ? 'Turn Off Microphone' : 'Turn On Microphone'}
|
||||
outlined
|
||||
@@ -70,6 +72,7 @@ export function SoundButton({ enabled, onToggle }: SoundButtonProps) {
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => onToggle()}
|
||||
aria-label={enabled ? 'Undeafen' : 'Deafen'}
|
||||
outlined
|
||||
@@ -113,6 +116,7 @@ export function VideoButton({ enabled, onToggle, disabled }: VideoButtonProps) {
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={toggleVideo}
|
||||
outlined
|
||||
disabled={disabled || loading}
|
||||
@@ -155,6 +159,7 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => onToggle()}
|
||||
aria-label={enabled ? 'Stop Screenshare' : 'Start Screenshare'}
|
||||
outlined
|
||||
@@ -200,6 +205,7 @@ export function FullscreenButton({ isFullscreen, onToggle }: FullscreenButtonPro
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={onToggle}
|
||||
aria-label={isFullscreen ? 'Exit Fullscreen' : 'Fullscreen'}
|
||||
aria-pressed={isFullscreen}
|
||||
@@ -234,6 +240,7 @@ export function ScreenshareAudioButton({ muted, onToggle }: ScreenshareAudioButt
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={onToggle}
|
||||
aria-label={muted ? 'Unmute Screenshare Audio' : 'Mute Screenshare Audio'}
|
||||
aria-pressed={muted}
|
||||
@@ -266,6 +273,7 @@ export function ChatButton() {
|
||||
fill="Soft"
|
||||
radii="400"
|
||||
size="400"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => setChat(!chat)}
|
||||
aria-label={chat ? 'Close Chat' : 'Open Chat'}
|
||||
aria-pressed={chat}
|
||||
|
||||
@@ -17,15 +17,29 @@ function useMediaPermissions(): MediaPermState {
|
||||
useEffect(() => {
|
||||
if (!navigator.permissions) {
|
||||
setState('unknown');
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
let cancelled = false;
|
||||
let permStatus: PermissionStatus | undefined;
|
||||
navigator.permissions
|
||||
.query({ name: 'microphone' as unknown as PermissionDescriptor['name'] })
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
permStatus = result;
|
||||
setState(result.state as MediaPermState);
|
||||
result.onchange = () => setState(result.state as MediaPermState);
|
||||
result.onchange = () => {
|
||||
if (!cancelled) setState(result.state as MediaPermState);
|
||||
};
|
||||
})
|
||||
.catch(() => setState('unknown'));
|
||||
.catch(() => {
|
||||
if (!cancelled) setState('unknown');
|
||||
});
|
||||
// Detach the onchange handler on unmount so it can't setState afterward (and
|
||||
// so the PermissionStatus doesn't retain the callback).
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (permStatus) permStatus.onchange = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
return state;
|
||||
|
||||
Binary file not shown.
@@ -119,7 +119,7 @@ function EditPower({ maxPower, power, tag, onSave, onClose }: EditPowerProps) {
|
||||
return (
|
||||
<Box onSubmit={handleSubmit} as="form" direction="Column" gap="400">
|
||||
<Box direction="Column" gap="300">
|
||||
<Box gap="200">
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<Box shrink="No" direction="Column" gap="100">
|
||||
<Text size="L400">Color</Text>
|
||||
<Box gap="200">
|
||||
|
||||
@@ -152,7 +152,10 @@ function SelectRoomButton({ roomList, selectedRooms, onChange }: SelectRoomButto
|
||||
getRoomNameStr,
|
||||
SEARCH_OPTS,
|
||||
);
|
||||
const rooms = Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(mx));
|
||||
const rooms = useMemo(
|
||||
() => Array.from(searchResult?.items ?? roomList).sort(factoryRoomIdByAtoZ(mx)),
|
||||
[searchResult, roomList, mx],
|
||||
);
|
||||
|
||||
const virtualizer = useVirtualizer({
|
||||
count: rooms.length,
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import React, { MouseEventHandler, forwardRef, useCallback, useRef, useState } from 'react';
|
||||
import React, {
|
||||
MouseEventHandler,
|
||||
forwardRef,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { MatrixClient, Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
@@ -27,6 +34,7 @@ import {
|
||||
import { useFocusWithin, useHover } from 'react-aria';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { selectAtom } from 'jotai/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import isToday from 'dayjs/plugin/isToday';
|
||||
import isYesterday from 'dayjs/plugin/isYesterday';
|
||||
@@ -40,6 +48,9 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { markedUnreadAtom, setMarkedUnread } from '../../state/room/markedUnread';
|
||||
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { hasMsgDraft } from '../../utils/draft';
|
||||
import { DraftDot } from '../room/DraftIndicator.css';
|
||||
import { getPowersLevelFromMatrixEvent, usePowerLevels } from '../../hooks/usePowerLevels';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
@@ -678,7 +689,18 @@ function RoomNavItem_({
|
||||
const roomName = useLocalRoomName(room);
|
||||
const hasLocalName = useHasLocalRoomName(room.roomId);
|
||||
|
||||
const latestEvent = useRoomLatestRenderedEvent(room);
|
||||
// Whether this room has an unsent message draft. selectAtom maps to a boolean
|
||||
// so the row only re-renders when that flips (the draft atom itself is written
|
||||
// on room-leave, not per keystroke).
|
||||
const hasDraftAtom = useMemo(
|
||||
() => selectAtom(roomIdToMsgDraftAtomFamily(room.roomId), hasMsgDraft),
|
||||
[room.roomId],
|
||||
);
|
||||
const hasDraft = useAtomValue(hasDraftAtom);
|
||||
|
||||
// Only DM rows render this preview — pass `direct` so non-DM nav items don't
|
||||
// register the global decryption listener (PERF-5).
|
||||
const latestEvent = useRoomLatestRenderedEvent(room, !!direct);
|
||||
const dmPreview = (() => {
|
||||
if (!direct || !latestEvent) return null;
|
||||
const type = latestEvent.getType();
|
||||
@@ -813,6 +835,9 @@ function RoomNavItem_({
|
||||
style={{ opacity: config.opacity.P300, flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
{hasDraft && !selected && (
|
||||
<span className={DraftDot} role="img" aria-label="Unsent draft" />
|
||||
)}
|
||||
</Box>
|
||||
{dmPreview && (
|
||||
<Box as="span" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
|
||||
|
||||
@@ -92,6 +92,16 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
const evId = ev.getId();
|
||||
if (!evId || seen.has(evId)) continue;
|
||||
seen.add(evId);
|
||||
// Advance the raw pagination boundary for EVERY event (any type,
|
||||
// decrypted or not) — getTs() is unencrypted metadata. Gating this on
|
||||
// a decrypted m.room.message let undecryptable/non-message old events
|
||||
// stall oldestRawTs, so the fromTs break never fired → over-paginate
|
||||
// and a false "truncated".
|
||||
const ts = ev.getTs();
|
||||
// Require a positive ts: an event with a bogus 0/negative
|
||||
// origin_server_ts must not collapse the boundary and trigger an early
|
||||
// break (silent under-pagination in the export).
|
||||
if (ts > 0 && ts < oldestRawTs) oldestRawTs = ts;
|
||||
// Attempt decryption for events that haven't been decrypted yet
|
||||
// (paginateEventTimeline may fetch events before the SDK decrypts them)
|
||||
if (ev.isEncrypted() && !ev.getClearContent()) {
|
||||
@@ -100,8 +110,6 @@ export function ExportRoomHistory({ requestClose }: ExportRoomHistoryProps) {
|
||||
}
|
||||
if (ev.getType() !== EventType.RoomMessage) continue;
|
||||
if (ev.isDecryptionFailure()) continue;
|
||||
const ts = ev.getTs();
|
||||
if (ts < oldestRawTs) oldestRawTs = ts;
|
||||
if (fromTs !== null && ts < fromTs) continue;
|
||||
if (toTs !== null && ts > toTs) continue;
|
||||
const content = ev.getContent();
|
||||
|
||||
@@ -307,7 +307,7 @@ export function PolicyListViewer({ requestClose }: PolicyListViewerProps) {
|
||||
gap="300"
|
||||
>
|
||||
{/* Tabs */}
|
||||
<Box gap="200">
|
||||
<Box gap="200" wrap="Wrap">
|
||||
<TabButton
|
||||
label="Users"
|
||||
count={userEntries.length}
|
||||
|
||||
@@ -32,6 +32,8 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../common-settings/styles.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { isValidServerPattern, matchesAnyGlob } from '../../utils/serverAcl';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
@@ -48,57 +50,6 @@ const DEFAULT_ACL: ServerAclContent = {
|
||||
allow_ip_literals: false,
|
||||
};
|
||||
|
||||
// ── Validation ────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Validate a server-name glob for an ACL entry.
|
||||
*
|
||||
* Matrix ACL `allow`/`deny` entries are globs where `*` (any run of chars) and
|
||||
* `?` (single char) may appear ANYWHERE — e.g. `*`, `*.example.com`,
|
||||
* `1.2.3.*`, `10.0.0.?`, `*.evil.*`, `*bad*`. We therefore validate the *glob*
|
||||
* rather than a concrete hostname:
|
||||
* - reject empty / whitespace-only
|
||||
* - allow only hostname/IP chars plus the wildcards `*` and `?`
|
||||
* (letters, digits, dots, hyphens, colons for ports/IPv6 — NO underscore)
|
||||
* - reject consecutive/leading/trailing dots (`...`, `.foo`, `foo.`)
|
||||
* - reject entries with no alphanumeric or wildcard char (bare `-`, lone `:`)
|
||||
*/
|
||||
function isValidServerPattern(value: string): boolean {
|
||||
const v = value.trim();
|
||||
if (!v) return false;
|
||||
// Only hostname/IP glob chars — wildcards may appear at any position.
|
||||
if (!/^[A-Za-z0-9.:*?-]+$/.test(v)) return false;
|
||||
// Structural rules for the dotted parts.
|
||||
if (v.startsWith('.') || v.endsWith('.') || v.includes('..')) return false;
|
||||
// Must carry actual signal — reject pure punctuation like `-`, `:` or `-.-`.
|
||||
if (!/[A-Za-z0-9*?]/.test(v)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an ACL glob (`*` = any run, `?` = single char) to an anchored RegExp,
|
||||
* escaping every other regex metacharacter. Used only for local self-ban
|
||||
* detection — never sent to the server.
|
||||
*/
|
||||
function globToRegExp(glob: string): RegExp {
|
||||
const escaped = glob.replace(/[.+^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = escaped.replace(/\*/g, '.*').replace(/\?/g, '.');
|
||||
// Case-INsensitive: Synapse's glob_to_regex uses IGNORECASE and hostnames are
|
||||
// case-insensitive, so a deny like `MATRIX.foo.org` must still be detected as
|
||||
// self-banning `matrix.foo.org` (otherwise the warning is a false negative).
|
||||
return new RegExp(`^${pattern}$`, 'i');
|
||||
}
|
||||
|
||||
function matchesAnyGlob(domain: string, globs: string[]): boolean {
|
||||
return globs.some((glob) => {
|
||||
try {
|
||||
return globToRegExp(glob).test(domain);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Server list sub-component ─────────────────────────────────────────────────
|
||||
|
||||
type ServerListProps = {
|
||||
@@ -198,6 +149,7 @@ function ServerList({ label, entries, canEdit, onAdd, onRemove }: ServerListProp
|
||||
size="300"
|
||||
variant="Background"
|
||||
radii="300"
|
||||
className={MobileTouchTarget}
|
||||
aria-label={`Remove ${entry}`}
|
||||
onClick={() => onRemove(i)}
|
||||
style={{ flexShrink: 0 }}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useAtomValue } from 'jotai';
|
||||
import { Box, Text, config } from 'folds';
|
||||
|
||||
import { roomIdToMsgDraftAtomFamily } from '../../state/room/roomInputDrafts';
|
||||
import { toPlainText } from '../../components/editor';
|
||||
import { hasMsgDraft } from '../../utils/draft';
|
||||
import { DraftDot, DraftDotPulse, DraftIndicatorBase } from './DraftIndicator.css';
|
||||
|
||||
const PULSE_DURATION = 600;
|
||||
@@ -27,7 +27,7 @@ type DraftIndicatorProps = {
|
||||
export function DraftIndicator({ roomId }: DraftIndicatorProps) {
|
||||
const draft = useAtomValue(roomIdToMsgDraftAtomFamily(roomId));
|
||||
// Real content, not just an empty paragraph.
|
||||
const hasDraft = toPlainText(draft, false).trim().length > 0;
|
||||
const hasDraft = hasMsgDraft(draft);
|
||||
|
||||
const [pulse, setPulse] = useState(false);
|
||||
const hadDraft = useRef(false);
|
||||
|
||||
@@ -52,8 +52,38 @@ export const MediaGalleryGrid = style({
|
||||
gap: config.space.S100,
|
||||
});
|
||||
|
||||
// Wraps a tile + its floating download button so the download control is a
|
||||
// sibling of the tile <button> (never nested inside it — that would be invalid
|
||||
// interactive-in-interactive markup). The wrapper is the grid cell; the tile
|
||||
// button fills it via aspect-ratio.
|
||||
export const GalleryTileWrap = style({
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
});
|
||||
|
||||
export const GalleryTileDownload = style({
|
||||
position: 'absolute',
|
||||
top: config.space.S100,
|
||||
right: config.space.S100,
|
||||
zIndex: 1,
|
||||
opacity: 0,
|
||||
transition: 'opacity 100ms ease-in-out',
|
||||
selectors: {
|
||||
[`${GalleryTileWrap}:hover &, ${GalleryTileWrap}:focus-within &`]: {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
// Touch devices have no hover; keep the control reachable there.
|
||||
'@media': {
|
||||
'(hover: none)': {
|
||||
opacity: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const GalleryTile = style({
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '1',
|
||||
overflow: 'hidden',
|
||||
borderRadius: config.radii.R300,
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
Header,
|
||||
Icon,
|
||||
IconButton,
|
||||
@@ -20,25 +21,33 @@ import { EventType, MatrixClient, MatrixEvent, MsgType, Room } from 'matrix-js-s
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import classNames from 'classnames';
|
||||
import { useNearViewport } from '../../hooks/useNearViewport';
|
||||
import { useZoom } from '../../hooks/useZoom';
|
||||
import { usePan, Pan } from '../../hooks/usePan';
|
||||
import { IEncryptedFile, IImageInfo, IThumbnailContent } from '../../../types/matrix/common';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { decryptFile, downloadEncryptedMedia, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { AudioContent, FileDownloadButton } from '../../components/message';
|
||||
import { MediaControl } from '../../components/media';
|
||||
import { getBlobSafeMimeType, mimeTypeToExt } from '../../utils/mimeTypes';
|
||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||
import { ContainerColor } from '../../styles/ContainerColor.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import * as css from './MediaGallery.css';
|
||||
|
||||
type GalleryTab = 'image' | 'video' | 'file';
|
||||
type GalleryTab = 'image' | 'video' | 'file' | 'audio';
|
||||
|
||||
const TAB_LABELS: Record<GalleryTab, string> = {
|
||||
image: 'Images',
|
||||
video: 'Videos',
|
||||
audio: 'Audio',
|
||||
file: 'Files',
|
||||
};
|
||||
|
||||
const TAB_MSGTYPES: Record<GalleryTab, MsgType> = {
|
||||
image: MsgType.Image,
|
||||
video: MsgType.Video,
|
||||
audio: MsgType.Audio,
|
||||
file: MsgType.File,
|
||||
};
|
||||
|
||||
@@ -125,6 +134,22 @@ function formatBytes(bytes: number): string {
|
||||
return `${(bytes / 1048576).toFixed(1)} MB`;
|
||||
}
|
||||
|
||||
// A sensible download filename: prefer the event body/filename; if it has no
|
||||
// plausible extension already, append one derived from the mimetype so the
|
||||
// saved file opens. "Plausible" = a short alphanumeric tail after the last dot,
|
||||
// so "Screenshot 2024.01.05" still gets a real extension appended.
|
||||
function hasFileExtension(name: string): boolean {
|
||||
const dot = name.lastIndexOf('.');
|
||||
if (dot <= 0 || dot === name.length - 1) return false;
|
||||
return /^[a-z0-9]{1,5}$/i.test(name.slice(dot + 1));
|
||||
}
|
||||
function mediaFilename(body: string, mimeType?: string): string {
|
||||
const name = body.trim() || 'media';
|
||||
if (hasFileExtension(name)) return name;
|
||||
const ext = mimeType ? mimeTypeToExt(mimeType) : '';
|
||||
return ext ? `${name}.${ext}` : name;
|
||||
}
|
||||
|
||||
function monthLabel(ts: number): string {
|
||||
return new Date(ts).toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
|
||||
}
|
||||
@@ -155,14 +180,27 @@ type LightboxItem = {
|
||||
body: string;
|
||||
sender: string;
|
||||
ts: number;
|
||||
eventId: string;
|
||||
};
|
||||
|
||||
function LightboxMedia({
|
||||
item,
|
||||
useAuthentication,
|
||||
zoom,
|
||||
pan,
|
||||
cursor,
|
||||
onMouseDown,
|
||||
onTouchStart,
|
||||
onImageDoubleClick,
|
||||
}: {
|
||||
item: LightboxItem;
|
||||
useAuthentication: boolean;
|
||||
zoom: number;
|
||||
pan: Pan;
|
||||
cursor: string;
|
||||
onMouseDown: React.MouseEventHandler<HTMLElement>;
|
||||
onTouchStart: React.TouchEventHandler<HTMLElement>;
|
||||
onImageDoubleClick: () => void;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const media = useDecryptedMediaUrl(
|
||||
@@ -215,12 +253,25 @@ function LightboxMedia({
|
||||
<img
|
||||
src={media.url}
|
||||
alt={item.body}
|
||||
draggable={false}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
onDoubleClick={onImageDoubleClick}
|
||||
style={{
|
||||
maxWidth: '100%',
|
||||
maxHeight: 'calc(100vh - 120px)',
|
||||
objectFit: 'contain',
|
||||
borderRadius: config.radii.R300,
|
||||
display: 'block',
|
||||
cursor,
|
||||
// translate is nested inside scale(), so it runs in scaled space —
|
||||
// divide by zoom so a dragged pixel moves the image one screen pixel
|
||||
// (1:1 with the cursor) rather than `zoom` pixels.
|
||||
transform: `scale(${zoom}) translate(${pan.translateX / zoom}px, ${
|
||||
pan.translateY / zoom
|
||||
}px)`,
|
||||
transition: cursor === 'grabbing' ? 'none' : 'transform 120ms ease-out',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
@@ -233,14 +284,30 @@ function Lightbox({
|
||||
initialIndex,
|
||||
useAuthentication,
|
||||
onClose,
|
||||
onJump,
|
||||
}: {
|
||||
items: LightboxItem[];
|
||||
initialIndex: number;
|
||||
useAuthentication: boolean;
|
||||
onClose: () => void;
|
||||
onJump: (eventId: string) => void;
|
||||
}) {
|
||||
const [index, setIndex] = useState(initialIndex);
|
||||
|
||||
const item = items[index];
|
||||
const isImage = item?.msgtype === MsgType.Image;
|
||||
|
||||
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
|
||||
// Pan is only active for a zoomed-in image; usePan resets its offset when this
|
||||
// flips false (i.e. back to 1x, on navigation, or on a video).
|
||||
const { pan, cursor, onMouseDown, onTouchStart } = usePan(isImage && zoom !== 1);
|
||||
const toggleZoom = useCallback(() => setZoom((z) => (z === 1 ? 2 : 1)), [setZoom]);
|
||||
|
||||
// Reset zoom when navigating to another item (and thus pan, via usePan).
|
||||
useEffect(() => {
|
||||
setZoom(1);
|
||||
}, [index, setZoom]);
|
||||
|
||||
const prev = useCallback(() => setIndex((i) => Math.max(0, i - 1)), []);
|
||||
const next = useCallback(
|
||||
() => setIndex((i) => Math.min(items.length - 1, i + 1)),
|
||||
@@ -251,11 +318,21 @@ function Lightbox({
|
||||
if (e.key === 'ArrowLeft') prev();
|
||||
else if (e.key === 'ArrowRight') next();
|
||||
else if (e.key === 'Escape') onClose();
|
||||
else if (isImage && (e.key === '+' || e.key === '=')) zoomIn();
|
||||
else if (isImage && e.key === '-') zoomOut();
|
||||
else if (isImage && e.key === '0') setZoom(1);
|
||||
},
|
||||
[prev, next, onClose],
|
||||
[prev, next, onClose, isImage, zoomIn, zoomOut, setZoom],
|
||||
);
|
||||
const handleWheel = useCallback(
|
||||
(e: React.WheelEvent) => {
|
||||
if (!isImage) return;
|
||||
if (e.deltaY < 0) zoomIn();
|
||||
else if (e.deltaY > 0) zoomOut();
|
||||
},
|
||||
[isImage, zoomIn, zoomOut],
|
||||
);
|
||||
|
||||
const item = items[index];
|
||||
if (!item) return null;
|
||||
|
||||
const dateStr = new Date(item.ts).toLocaleDateString(undefined, {
|
||||
@@ -309,6 +386,85 @@ function Lightbox({
|
||||
<Text size="T200" style={{ color: 'rgba(255,255,255,0.4)', flexShrink: 0 }}>
|
||||
{index + 1} / {items.length}
|
||||
</Text>
|
||||
{isImage && (
|
||||
<Box
|
||||
shrink="No"
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
role="group"
|
||||
aria-label="Zoom controls"
|
||||
>
|
||||
<IconButton
|
||||
variant="Surface"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Zoom out"
|
||||
onClick={zoomOut}
|
||||
disabled={zoom <= 0.2}
|
||||
>
|
||||
<Icon size="50" src={Icons.Minus} />
|
||||
</IconButton>
|
||||
<Chip variant="Surface" radii="Pill" onClick={toggleZoom} aria-label="Reset zoom">
|
||||
<Text size="B300">{Math.round(zoom * 100)}%</Text>
|
||||
</Chip>
|
||||
<IconButton
|
||||
variant="Surface"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Zoom in"
|
||||
onClick={zoomIn}
|
||||
disabled={zoom >= 5}
|
||||
>
|
||||
<Icon size="50" src={Icons.Plus} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
{item.mxcUrl && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Download</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(ref) => (
|
||||
<span ref={ref}>
|
||||
<FileDownloadButton
|
||||
filename={mediaFilename(item.body, item.mimeType)}
|
||||
url={item.mxcUrl}
|
||||
mimeType={item.mimeType ?? 'application/octet-stream'}
|
||||
encInfo={item.encInfo}
|
||||
/>
|
||||
</span>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{item.eventId && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>Go to message</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(ref) => (
|
||||
<IconButton
|
||||
ref={ref}
|
||||
variant="Surface"
|
||||
aria-label="Go to message"
|
||||
onClick={() => onJump(item.eventId)}
|
||||
>
|
||||
<Icon src={Icons.Message} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
align="End"
|
||||
@@ -332,6 +488,7 @@ function Lightbox({
|
||||
grow="Yes"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
onWheel={handleWheel}
|
||||
style={{ overflow: 'hidden', padding: config.space.S400 }}
|
||||
>
|
||||
{index > 0 && (
|
||||
@@ -354,6 +511,12 @@ function Lightbox({
|
||||
key={`${item.mxcUrl}-${item.ts}`}
|
||||
item={item}
|
||||
useAuthentication={useAuthentication}
|
||||
zoom={zoom}
|
||||
pan={pan}
|
||||
cursor={cursor}
|
||||
onMouseDown={onMouseDown}
|
||||
onTouchStart={onTouchStart}
|
||||
onImageDoubleClick={toggleZoom}
|
||||
/>
|
||||
</Box>
|
||||
{index < items.length - 1 && (
|
||||
@@ -385,6 +548,10 @@ function GalleryTile({
|
||||
ts,
|
||||
useAuthentication,
|
||||
onClick,
|
||||
downloadUrl,
|
||||
downloadEncInfo,
|
||||
downloadMimeType,
|
||||
downloadFilename,
|
||||
}: {
|
||||
mxcUrl: string;
|
||||
encInfo?: IEncryptedFile;
|
||||
@@ -395,6 +562,10 @@ function GalleryTile({
|
||||
ts: number;
|
||||
useAuthentication: boolean;
|
||||
onClick: () => void;
|
||||
downloadUrl?: string;
|
||||
downloadEncInfo?: IEncryptedFile;
|
||||
downloadMimeType?: string;
|
||||
downloadFilename: string;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const tileRef = useRef<HTMLButtonElement>(null);
|
||||
@@ -410,55 +581,71 @@ function GalleryTile({
|
||||
const relDate = formatRelativeDate(ts);
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={tileRef}
|
||||
type="button"
|
||||
aria-label={body || (isVideo ? 'Video' : 'Image')}
|
||||
onClick={onClick}
|
||||
className={css.GalleryTile}
|
||||
>
|
||||
{media.status === 'loading' && <Spinner size="200" />}
|
||||
{media.status === 'error' && (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
style={{ padding: config.space.S100 }}
|
||||
>
|
||||
<Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />
|
||||
<Text
|
||||
size="T200"
|
||||
truncate
|
||||
priority="300"
|
||||
style={{ maxWidth: '100%', textAlign: 'center' }}
|
||||
<div className={css.GalleryTileWrap}>
|
||||
<button
|
||||
ref={tileRef}
|
||||
type="button"
|
||||
aria-label={body || (isVideo ? 'Video' : 'Image')}
|
||||
onClick={onClick}
|
||||
className={css.GalleryTile}
|
||||
>
|
||||
{media.status === 'loading' && <Spinner size="200" />}
|
||||
{media.status === 'error' && (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
style={{ padding: config.space.S100 }}
|
||||
>
|
||||
{body}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{media.status === 'ok' && <img src={media.url} alt={body} className={css.GalleryTileImg} />}
|
||||
|
||||
{/* Video play badge */}
|
||||
{isVideo && media.status === 'ok' && (
|
||||
<div className={css.GalleryVideoBadge}>
|
||||
<Icon src={Icons.Play} size="200" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover/focus caption overlay (CSS-driven) */}
|
||||
{media.status === 'ok' && (
|
||||
<div className={css.GalleryTileOverlay}>
|
||||
<div className={css.GalleryTileCaption}>
|
||||
<Text size="T200" truncate style={{ color: '#fff', display: 'block', lineHeight: 1.3 }}>
|
||||
{sender}
|
||||
</Text>
|
||||
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)' }}>
|
||||
{relDate}
|
||||
<Icon src={isVideo ? Icons.Play : Icons.Photo} size="300" />
|
||||
<Text
|
||||
size="T200"
|
||||
truncate
|
||||
priority="300"
|
||||
style={{ maxWidth: '100%', textAlign: 'center' }}
|
||||
>
|
||||
{body}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
{media.status === 'ok' && <img src={media.url} alt={body} className={css.GalleryTileImg} />}
|
||||
|
||||
{/* Video play badge */}
|
||||
{isVideo && media.status === 'ok' && (
|
||||
<div className={css.GalleryVideoBadge}>
|
||||
<Icon src={Icons.Play} size="200" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover/focus caption overlay (CSS-driven) */}
|
||||
{media.status === 'ok' && (
|
||||
<div className={css.GalleryTileOverlay}>
|
||||
<div className={css.GalleryTileCaption}>
|
||||
<Text
|
||||
size="T200"
|
||||
truncate
|
||||
style={{ color: '#fff', display: 'block', lineHeight: 1.3 }}
|
||||
>
|
||||
{sender}
|
||||
</Text>
|
||||
<Text size="T200" style={{ color: 'rgba(255,255,255,0.65)' }}>
|
||||
{relDate}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
{downloadUrl && (
|
||||
<div className={css.GalleryTileDownload}>
|
||||
<FileDownloadButton
|
||||
filename={downloadFilename}
|
||||
url={downloadUrl}
|
||||
mimeType={downloadMimeType ?? 'application/octet-stream'}
|
||||
encInfo={downloadEncInfo}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -503,6 +690,16 @@ type MediaGalleryProps = {
|
||||
export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
|
||||
// Close the drawer and land the timeline on the source message.
|
||||
const jumpToMessage = useCallback(
|
||||
(eventId: string) => {
|
||||
onClose();
|
||||
navigateRoom(room.roomId, eventId);
|
||||
},
|
||||
[onClose, navigateRoom, room.roomId],
|
||||
);
|
||||
|
||||
const [tab, setTab] = useState<GalleryTab>('image');
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -613,12 +810,13 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
body: c.body ?? '',
|
||||
sender: getSenderName(room, ev.getSender() ?? ''),
|
||||
ts: ev.getTs(),
|
||||
eventId: ev.getId() ?? '',
|
||||
};
|
||||
});
|
||||
|
||||
// Per-tab counts for the tab labels (single pass over loaded timeline)
|
||||
const tabCounts = useMemo(() => {
|
||||
const counts: Record<GalleryTab, number> = { image: 0, video: 0, file: 0 };
|
||||
const counts: Record<GalleryTab, number> = { image: 0, video: 0, audio: 0, file: 0 };
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getEvents()
|
||||
@@ -627,6 +825,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
const mt = ev.getContent().msgtype;
|
||||
if (mt === MsgType.Image) counts.image += 1;
|
||||
else if (mt === MsgType.Video) counts.video += 1;
|
||||
else if (mt === MsgType.Audio) counts.audio += 1;
|
||||
else if (mt === MsgType.File) counts.file += 1;
|
||||
});
|
||||
return counts;
|
||||
@@ -739,6 +938,9 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
// Guard before incrementing: skipped tiles must not consume a slot
|
||||
if (!thumbMxc) return null;
|
||||
const idx = flatIdx++;
|
||||
// Full-resolution source for download (not the thumb).
|
||||
const fullMxc: string | undefined = c.file?.url ?? c.url;
|
||||
const bodyStr: string = c.body ?? '';
|
||||
return (
|
||||
<GalleryTile
|
||||
key={mEvent.getId()}
|
||||
@@ -746,11 +948,15 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
encInfo={thumbEnc}
|
||||
mimeType={thumbMime}
|
||||
isVideo={isVideo}
|
||||
body={c.body ?? ''}
|
||||
body={bodyStr}
|
||||
sender={getSenderName(room, mEvent.getSender() ?? '')}
|
||||
ts={mEvent.getTs()}
|
||||
useAuthentication={useAuthentication}
|
||||
onClick={() => setLightboxIndex(idx)}
|
||||
downloadUrl={fullMxc}
|
||||
downloadEncInfo={isEnc ? c.file : undefined}
|
||||
downloadMimeType={info?.mimetype}
|
||||
downloadFilename={mediaFilename(bodyStr, info?.mimetype)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
@@ -784,9 +990,6 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
const body: string = c.body ?? 'Unnamed file';
|
||||
const size: number | undefined = c.info?.size;
|
||||
const sender = getSenderName(room, mEvent.getSender() ?? '');
|
||||
const downloadUrl = mxcUrl
|
||||
? (mxcUrlToHttp(mx, mxcUrl, useAuthentication) ?? '#')
|
||||
: '#';
|
||||
return (
|
||||
<Box
|
||||
key={mEvent.getId()}
|
||||
@@ -816,18 +1019,113 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label={`Download ${body}`}
|
||||
aria-label="Go to message"
|
||||
onClick={() => {
|
||||
const a = document.createElement('a');
|
||||
a.href = downloadUrl;
|
||||
a.download = body;
|
||||
a.target = '_blank';
|
||||
a.rel = 'noreferrer';
|
||||
a.click();
|
||||
const id = mEvent.getId();
|
||||
if (id) jumpToMessage(id);
|
||||
}}
|
||||
>
|
||||
<Icon size="200" src={Icons.Download} />
|
||||
<Icon size="200" src={Icons.Message} />
|
||||
</IconButton>
|
||||
{mxcUrl && (
|
||||
<FileDownloadButton
|
||||
filename={body}
|
||||
url={mxcUrl}
|
||||
mimeType={c.info?.mimetype ?? 'application/octet-stream'}
|
||||
encInfo={c.file}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Audio / voice list ── */}
|
||||
{tab === 'audio' && (
|
||||
<>
|
||||
{events.length === 0 && !loading && (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: config.space.S400 }}
|
||||
>
|
||||
<Icon src={Icons.VolumeHigh} size="600" />
|
||||
<Text size="T300" priority="300" align="Center">
|
||||
{hasLoadedOnce ? 'No audio found.' : 'No audio in recent history.'}
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
<Box direction="Column" gap="200">
|
||||
{events.map((mEvent) => {
|
||||
const c = mEvent.getContent();
|
||||
const url: string | undefined = c.file?.url ?? c.url;
|
||||
if (!url) return null;
|
||||
const body: string = c.body || 'Voice message';
|
||||
const sender = getSenderName(room, mEvent.getSender() ?? '');
|
||||
const relDate = formatRelativeDate(mEvent.getTs());
|
||||
// Sanitize the mimetype the way MAudio does (e.g. application/ogg →
|
||||
// audio/ogg) so the decrypted blob actually plays.
|
||||
const mimeType = getBlobSafeMimeType(c.info?.mimetype ?? 'audio/ogg');
|
||||
const filename = body.includes('.')
|
||||
? body
|
||||
: `${body}.${mimeTypeToExt(mimeType)}`;
|
||||
const waveform = (
|
||||
c as unknown as { 'org.matrix.msc1767.audio'?: { waveform?: number[] } }
|
||||
)['org.matrix.msc1767.audio']?.waveform;
|
||||
return (
|
||||
<Box
|
||||
key={mEvent.getId()}
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{
|
||||
padding: `${config.space.S200} ${config.space.S300}`,
|
||||
borderRadius: config.radii.R300,
|
||||
background: color.SurfaceVariant.Container,
|
||||
}}
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Box
|
||||
grow="Yes"
|
||||
direction="Column"
|
||||
style={{ overflow: 'hidden', gap: '2px' }}
|
||||
>
|
||||
<Text size="T300" truncate title={body}>
|
||||
{body}
|
||||
</Text>
|
||||
<Text size="T200" priority="300">
|
||||
{sender} · {relDate}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Go to message"
|
||||
onClick={() => {
|
||||
const id = mEvent.getId();
|
||||
if (id) jumpToMessage(id);
|
||||
}}
|
||||
>
|
||||
<Icon size="200" src={Icons.Message} />
|
||||
</IconButton>
|
||||
<FileDownloadButton
|
||||
filename={filename}
|
||||
url={url}
|
||||
mimeType={mimeType}
|
||||
encInfo={c.file}
|
||||
/>
|
||||
</Box>
|
||||
<AudioContent
|
||||
mimeType={mimeType}
|
||||
url={url}
|
||||
info={c.info ?? {}}
|
||||
encInfo={c.file}
|
||||
waveform={waveform}
|
||||
renderMediaControl={(p) => <MediaControl {...p} />}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
@@ -888,6 +1186,7 @@ export function MediaGallery({ room, onClose }: MediaGalleryProps) {
|
||||
initialIndex={lightboxIndex}
|
||||
useAuthentication={useAuthentication}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onJump={jumpToMessage}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import classNames from 'classnames';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import { Membership } from '../../../types/matrix/room';
|
||||
|
||||
import * as css from './MembersDrawer.css';
|
||||
@@ -460,6 +461,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
variant="Success"
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => mx.invite(room.roomId, knockMember.userId)}
|
||||
>
|
||||
<Text size="B300">Approve</Text>
|
||||
@@ -469,6 +471,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
variant="Critical"
|
||||
radii="300"
|
||||
fill="Soft"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => mx.kick(room.roomId, knockMember.userId)}
|
||||
>
|
||||
<Text size="B300">Deny</Text>
|
||||
|
||||
@@ -34,6 +34,13 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
const [question, setQuestion] = useState('');
|
||||
const [options, setOptions] = useState<string[]>(['', '']);
|
||||
const [isMultiple, setIsMultiple] = useState(false);
|
||||
// For multiple-choice polls: the most options a voter may pick. Defaults high
|
||||
// so an untouched multiple poll means "select all that apply" (the previous
|
||||
// behavior); the effective value is clamped to the current option count.
|
||||
const [maxSelections, setMaxSelections] = useState(10);
|
||||
// Results visibility: disclosed (live results, default) vs undisclosed (hidden
|
||||
// until the poll is ended).
|
||||
const [disclosed, setDisclosed] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -73,14 +80,21 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
setError(null);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
// Text fallback for clients that don't understand polls: the question + a
|
||||
// numbered list of the options.
|
||||
const fallbackBody = [trimmedQuestion, ...filledOptions.map((o, i) => `${i + 1}. ${o}`)].join(
|
||||
'\n',
|
||||
);
|
||||
await mx.sendEvent(roomId, 'm.poll.start' as any, {
|
||||
'm.poll': {
|
||||
question: { 'm.text': trimmedQuestion },
|
||||
answers: filledOptions.map((o, i) => ({ 'm.id': `${i}`, 'm.text': o })),
|
||||
max_selections: isMultiple ? filledOptions.length : 1,
|
||||
kind: 'm.poll.undisclosed',
|
||||
max_selections: isMultiple
|
||||
? Math.min(Math.max(2, maxSelections), filledOptions.length)
|
||||
: 1,
|
||||
kind: disclosed ? 'm.poll.disclosed' : 'm.poll.undisclosed',
|
||||
},
|
||||
body: trimmedQuestion,
|
||||
body: fallbackBody,
|
||||
msgtype: 'm.text',
|
||||
});
|
||||
onClose();
|
||||
@@ -214,6 +228,56 @@ export function PollCreator({ roomId, onClose }: PollCreatorProps) {
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
{isMultiple && (
|
||||
<Box alignItems="Center" gap="200" style={{ marginTop: config.space.S200 }}>
|
||||
<Text as="label" htmlFor="poll-max-select" size="T200" priority="400">
|
||||
Voters can pick up to
|
||||
</Text>
|
||||
<Input
|
||||
id="poll-max-select"
|
||||
variant="Background"
|
||||
size="300"
|
||||
type="number"
|
||||
min={2}
|
||||
max={options.length}
|
||||
value={Math.min(maxSelections, options.length)}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setMaxSelections(
|
||||
Math.min(options.length, Math.max(2, parseInt(e.target.value, 10) || 2)),
|
||||
)
|
||||
}
|
||||
style={{ width: '4rem' }}
|
||||
/>
|
||||
<Text size="T200" priority="300">
|
||||
of {options.length} options
|
||||
</Text>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* Results visibility */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="L400">Results</Text>
|
||||
<Box gap="200">
|
||||
{(['live', 'hidden'] as const).map((mode) => {
|
||||
const active = mode === 'live' ? disclosed : !disclosed;
|
||||
return (
|
||||
<Button
|
||||
key={mode}
|
||||
type="button"
|
||||
size="300"
|
||||
variant="Primary"
|
||||
fill={active ? 'Solid' : 'None'}
|
||||
radii="300"
|
||||
onClick={() => setDisclosed(mode === 'live')}
|
||||
>
|
||||
<Text size="B300">
|
||||
{mode === 'live' ? 'Show live results' : 'Hidden until ended'}
|
||||
</Text>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Error */}
|
||||
|
||||
@@ -25,7 +25,9 @@ import { CallChatView } from './CallChatView';
|
||||
import { useCallEmbed } from '../../hooks/useCallEmbed';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { roomIdToActiveThreadIdAtomFamily } from '../../state/room/thread';
|
||||
import { threadsListAtom } from '../../state/threadsList';
|
||||
import { ThreadPanel } from './thread';
|
||||
import { ThreadsListPanel } from './thread/ThreadsListPanel';
|
||||
|
||||
export function Room() {
|
||||
const { eventId } = useParams();
|
||||
@@ -43,6 +45,8 @@ export function Room() {
|
||||
const setGalleryOpen = useSetAtom(mediaGalleryAtom);
|
||||
const widgetsOpen = useAtomValue(widgetsPanelAtom);
|
||||
const setWidgetsOpen = useSetAtom(widgetsPanelAtom);
|
||||
const threadsListOpen = useAtomValue(threadsListAtom);
|
||||
const setThreadsListOpen = useSetAtom(threadsListAtom);
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const screenSize = useScreenSizeContext();
|
||||
const powerLevels = usePowerLevels(room);
|
||||
@@ -66,6 +70,17 @@ export function Room() {
|
||||
),
|
||||
);
|
||||
|
||||
// Stable handlers for the threads-list panel so its document-level Escape
|
||||
// listener isn't torn down and re-added on every Room re-render.
|
||||
const closeThreadsList = useCallback(() => setThreadsListOpen(false), [setThreadsListOpen]);
|
||||
const openThreadFromList = useCallback(
|
||||
(threadId: string) => {
|
||||
setActiveThreadId(threadId);
|
||||
setThreadsListOpen(false);
|
||||
},
|
||||
[setActiveThreadId, setThreadsListOpen],
|
||||
);
|
||||
|
||||
const callView = callEmbed?.roomId === room.roomId || room.isCallRoom() || callMembers.length > 0;
|
||||
|
||||
// The content panels (thread / media gallery / widgets) are mutually exclusive
|
||||
@@ -74,24 +89,43 @@ export function Room() {
|
||||
const prevThreadRef = useRef(activeThreadId);
|
||||
const prevGalleryRef = useRef(galleryOpen);
|
||||
const prevWidgetsRef = useRef(widgetsOpen);
|
||||
const prevThreadsListRef = useRef(threadsListOpen);
|
||||
useEffect(() => {
|
||||
const threadJustOpened = Boolean(activeThreadId) && !prevThreadRef.current;
|
||||
const galleryJustOpened = galleryOpen && !prevGalleryRef.current;
|
||||
const widgetsJustOpened = widgetsOpen && !prevWidgetsRef.current;
|
||||
const threadsListJustOpened = threadsListOpen && !prevThreadsListRef.current;
|
||||
if (threadJustOpened) {
|
||||
if (galleryOpen) setGalleryOpen(false);
|
||||
if (widgetsOpen) setWidgetsOpen(false);
|
||||
if (threadsListOpen) setThreadsListOpen(false);
|
||||
} else if (galleryJustOpened) {
|
||||
if (activeThreadId) setActiveThreadId(null);
|
||||
if (widgetsOpen) setWidgetsOpen(false);
|
||||
if (threadsListOpen) setThreadsListOpen(false);
|
||||
} else if (widgetsJustOpened) {
|
||||
if (activeThreadId) setActiveThreadId(null);
|
||||
if (galleryOpen) setGalleryOpen(false);
|
||||
if (threadsListOpen) setThreadsListOpen(false);
|
||||
} else if (threadsListJustOpened) {
|
||||
if (activeThreadId) setActiveThreadId(null);
|
||||
if (galleryOpen) setGalleryOpen(false);
|
||||
if (widgetsOpen) setWidgetsOpen(false);
|
||||
}
|
||||
prevThreadRef.current = activeThreadId;
|
||||
prevGalleryRef.current = galleryOpen;
|
||||
prevWidgetsRef.current = widgetsOpen;
|
||||
}, [activeThreadId, galleryOpen, widgetsOpen, setGalleryOpen, setActiveThreadId, setWidgetsOpen]);
|
||||
prevThreadsListRef.current = threadsListOpen;
|
||||
}, [
|
||||
activeThreadId,
|
||||
galleryOpen,
|
||||
widgetsOpen,
|
||||
threadsListOpen,
|
||||
setGalleryOpen,
|
||||
setActiveThreadId,
|
||||
setWidgetsOpen,
|
||||
setThreadsListOpen,
|
||||
]);
|
||||
|
||||
// On non-desktop screens at most one right-side panel may show, priority
|
||||
// thread > gallery > widgets > members. On desktop thread + members may coexist
|
||||
@@ -100,8 +134,16 @@ export function Room() {
|
||||
const showThreadPanel = !callView && Boolean(activeThreadId);
|
||||
const showGallery = !callView && galleryOpen && (isDesktop || !activeThreadId);
|
||||
const showWidgets = !callView && widgetsOpen && (isDesktop || (!activeThreadId && !galleryOpen));
|
||||
// The single-thread panel always replaces the list (they share the content slot).
|
||||
const showThreadsList =
|
||||
!callView &&
|
||||
threadsListOpen &&
|
||||
!activeThreadId &&
|
||||
(isDesktop || (!galleryOpen && !widgetsOpen));
|
||||
const showMembers =
|
||||
!callView && isDrawer && (isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen));
|
||||
!callView &&
|
||||
isDrawer &&
|
||||
(isDesktop || (!activeThreadId && !galleryOpen && !widgetsOpen && !threadsListOpen));
|
||||
|
||||
return (
|
||||
<PowerLevelsContextProvider value={powerLevels}>
|
||||
@@ -151,6 +193,19 @@ export function Room() {
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showThreadsList && (
|
||||
<>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<Line variant="Background" direction="Vertical" size="300" />
|
||||
)}
|
||||
<ThreadsListPanel
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
onClose={closeThreadsList}
|
||||
onOpenThread={openThreadFromList}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{showThreadPanel && activeThreadId && (
|
||||
<>
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
|
||||
@@ -151,9 +151,16 @@ interface RoomInputProps {
|
||||
roomId: string;
|
||||
room: Room;
|
||||
threadRootId?: string;
|
||||
// Identifies this composer to global key handlers (e.g. the up-arrow "edit last
|
||||
// message" handler). Threads pass a distinct name so the main timeline's handler
|
||||
// doesn't fire for the thread composer, and vice-versa.
|
||||
editableName?: string;
|
||||
}
|
||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
({ editor, fileDropContainerRef, roomId, room, threadRootId }, ref) => {
|
||||
(
|
||||
{ editor, fileDropContainerRef, roomId, room, threadRootId, editableName = 'RoomInput' },
|
||||
ref,
|
||||
) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
@@ -223,7 +230,13 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
|
||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
|
||||
const touchTarget = mobileOrTablet() ? { minWidth: '44px', minHeight: '44px' } : undefined;
|
||||
const isMobile = mobileOrTablet();
|
||||
// On phones the composer's secondary action buttons (attach, GIF, poll,
|
||||
// location, voice, formatting, schedule) collapse behind a "+" toggle so the
|
||||
// input stays one compact row instead of wrapping into a tall stack. Emoji +
|
||||
// Send remain inline. Desktop keeps everything inline (isMobile === false).
|
||||
const [mobileToolsOpen, setMobileToolsOpen] = useState(false);
|
||||
const touchTarget = isMobile ? { minWidth: '44px', minHeight: '44px' } : undefined;
|
||||
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
||||
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
||||
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
||||
@@ -251,10 +264,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
setLocating(false);
|
||||
const { latitude, longitude } = pos.coords;
|
||||
const geoUri = `geo:${latitude.toFixed(6)},${longitude.toFixed(6)}`;
|
||||
const ts = Date.now();
|
||||
// MSC3488 extensible location: send the geo_uri (legacy) alongside the
|
||||
// m.location/m.asset/m.ts blocks so Element and other clients render a
|
||||
// proper "shared location" pin instead of falling back to plain text.
|
||||
mx.sendMessage(roomId, threadRootId ?? null, {
|
||||
msgtype: 'm.location',
|
||||
body: `Location: ${geoUri}`,
|
||||
body: `Shared a location: ${geoUri}`,
|
||||
geo_uri: geoUri,
|
||||
'org.matrix.msc3488.location': { uri: geoUri },
|
||||
'org.matrix.msc3488.asset': { type: 'm.self' },
|
||||
'org.matrix.msc3488.ts': ts,
|
||||
'm.ts': ts,
|
||||
} as any);
|
||||
},
|
||||
(err) => {
|
||||
@@ -506,9 +527,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const submit = useCallback(() => {
|
||||
uploadBoardHandlers.current?.handleSend();
|
||||
|
||||
// Slash-command interpretation is disabled in thread mode (v1): "/foo"
|
||||
// sends literally rather than being parsed as a command.
|
||||
const commandName = threadRootId ? undefined : getBeginCommand(editor);
|
||||
// Slash commands work in threads too: content-transform commands (/me,
|
||||
// /notice, /shrug, /tableflip, /unflip) flow into the normal send below,
|
||||
// which routes to the thread via `threadRootId`; the rest (/invite, /kick,
|
||||
// …) are room-level actions. This also matches the command autocomplete,
|
||||
// which is already shown in the thread composer.
|
||||
const commandName = getBeginCommand(editor);
|
||||
let plainText = toPlainText(editor.children, isMarkdown).trim();
|
||||
let customHtml = trimCustomHtml(
|
||||
toMatrixCustomHTML(editor.children, {
|
||||
@@ -602,6 +626,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setCharCount(0);
|
||||
setMsgDraft([]);
|
||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||
setReplyDraft(undefined);
|
||||
sendTypingStatus(false);
|
||||
@@ -614,6 +639,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
replyDraft,
|
||||
sendTypingStatus,
|
||||
setReplyDraft,
|
||||
setMsgDraft,
|
||||
isMarkdown,
|
||||
commands,
|
||||
setToast,
|
||||
@@ -687,11 +713,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
});
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
setMsgDraft([]);
|
||||
localStorage.removeItem(`draft-msg-${draftKey}`);
|
||||
setReplyDraft(undefined);
|
||||
sendTypingStatus(false);
|
||||
},
|
||||
[setScheduledMessages, roomId, draftKey, editor, setReplyDraft, sendTypingStatus],
|
||||
[
|
||||
setScheduledMessages,
|
||||
roomId,
|
||||
draftKey,
|
||||
editor,
|
||||
setReplyDraft,
|
||||
setMsgDraft,
|
||||
sendTypingStatus,
|
||||
],
|
||||
);
|
||||
|
||||
const handleKeyDown: KeyboardEventHandler = useCallback(
|
||||
@@ -847,6 +882,12 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
);
|
||||
}
|
||||
|
||||
// Mobile "+" overflow: the `after` builder stashes the collapsed secondary
|
||||
// buttons here and the `bottom` slot renders them when the toggle is open.
|
||||
// React evaluates JSX props in source order (before → after → bottom), so
|
||||
// `after` assigns this before `bottom` reads it within the same render.
|
||||
let composerOverflow: ReactNode = null;
|
||||
|
||||
return (
|
||||
<div ref={ref}>
|
||||
{selectedFiles.length > 0 && (
|
||||
@@ -962,7 +1003,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
)}
|
||||
<ScheduledMessagesTray roomId={roomId} />
|
||||
<CustomEditor
|
||||
editableName="RoomInput"
|
||||
editableName={editableName}
|
||||
editor={editor}
|
||||
placeholder="Send a message..."
|
||||
onKeyDown={handleKeyDown}
|
||||
@@ -1006,16 +1047,31 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
)
|
||||
}
|
||||
before={
|
||||
<IconButton
|
||||
onClick={() => pickFile('*')}
|
||||
aria-label="Attach file"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
>
|
||||
<Icon src={Icons.PlusCircle} />
|
||||
</IconButton>
|
||||
isMobile ? (
|
||||
<IconButton
|
||||
onClick={() => setMobileToolsOpen((open) => !open)}
|
||||
aria-label="More actions"
|
||||
aria-expanded={mobileToolsOpen}
|
||||
aria-controls="composer-more-actions"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
>
|
||||
<Icon src={mobileToolsOpen ? Icons.Cross : Icons.Plus} />
|
||||
</IconButton>
|
||||
) : (
|
||||
<IconButton
|
||||
onClick={() => pickFile('*')}
|
||||
aria-label="Attach file"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
>
|
||||
<Icon src={Icons.PlusCircle} />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
after={(() => {
|
||||
const formatButton = showFormat ? (
|
||||
@@ -1277,9 +1333,37 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
}
|
||||
});
|
||||
|
||||
// Mobile: keep only emoji/sticker inline beside Send; the rest move
|
||||
// into the "+" overflow row (rendered via `bottom`), led by the attach
|
||||
// button that `before` gives up on mobile. Desktop renders all inline.
|
||||
const emojiInline = orderedButtons.filter(
|
||||
(node) => React.isValidElement(node) && node.key === 'showEmojiSticker',
|
||||
);
|
||||
const overflowButtons = orderedButtons.filter(
|
||||
(node) => !(React.isValidElement(node) && node.key === 'showEmojiSticker'),
|
||||
);
|
||||
if (isMobile) {
|
||||
composerOverflow = (
|
||||
<>
|
||||
<IconButton
|
||||
key="showAttach"
|
||||
onClick={() => pickFile('*')}
|
||||
aria-label="Attach file"
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
style={touchTarget}
|
||||
>
|
||||
<Icon src={Icons.PlusCircle} />
|
||||
</IconButton>
|
||||
{overflowButtons}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{orderedButtons}
|
||||
{isMobile ? emojiInline : orderedButtons}
|
||||
{gifError && (
|
||||
<Text
|
||||
size="T200"
|
||||
@@ -1336,12 +1420,30 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
);
|
||||
})()}
|
||||
bottom={
|
||||
toolbar && (
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<Toolbar />
|
||||
</div>
|
||||
)
|
||||
<>
|
||||
{isMobile && mobileToolsOpen && composerOverflow && (
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<Box
|
||||
id="composer-more-actions"
|
||||
role="group"
|
||||
aria-label="More actions"
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
wrap="Wrap"
|
||||
style={{ padding: config.space.S200 }}
|
||||
>
|
||||
{composerOverflow}
|
||||
</Box>
|
||||
</div>
|
||||
)}
|
||||
{toolbar && (
|
||||
<div>
|
||||
<Line variant="SurfaceVariant" size="300" />
|
||||
<Toolbar />
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
{pollOpen && <PollCreator room={room} roomId={roomId} onClose={() => setPollOpen(false)} />}
|
||||
|
||||
@@ -1314,13 +1314,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
mEvent.getType() === 'm.poll.start' ||
|
||||
mEvent.getType() === 'org.matrix.msc3381.poll.start'
|
||||
)
|
||||
return (
|
||||
<PollContent
|
||||
content={mEvent.getContent()}
|
||||
roomId={room.roomId}
|
||||
eventId={mEvent.getId() ?? undefined}
|
||||
/>
|
||||
);
|
||||
return <PollContent mEvent={mEvent} room={room} canRedact={canRedact} />;
|
||||
if (mEvent.getType() === MessageEvent.RoomMessageEncrypted)
|
||||
return (
|
||||
<Text>
|
||||
@@ -1449,11 +1443,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
) : (
|
||||
<PollContent
|
||||
content={mEvent.getContent()}
|
||||
roomId={room.roomId}
|
||||
eventId={mEvent.getId() ?? undefined}
|
||||
/>
|
||||
<PollContent mEvent={mEvent} room={room} canRedact={canRedact} />
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
@@ -1506,11 +1496,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
{mEvent.isRedacted() ? (
|
||||
<RedactedContent reason={mEvent.getUnsigned().redacted_because?.content.reason} />
|
||||
) : (
|
||||
<PollContent
|
||||
content={mEvent.getContent()}
|
||||
roomId={room.roomId}
|
||||
eventId={mEvent.getId() ?? undefined}
|
||||
/>
|
||||
<PollContent mEvent={mEvent} room={room} canRedact={canRedact} />
|
||||
)}
|
||||
</Message>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { Box, Text, config } from 'folds';
|
||||
import { Box, Button, Text, config } from 'folds';
|
||||
import { EventType } from 'matrix-js-sdk';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { Transforms } from 'slate';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
@@ -152,17 +153,38 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
||||
<>
|
||||
{canMessage && (
|
||||
<ErrorBoundary
|
||||
fallback={
|
||||
onReset={() => {
|
||||
// The composer crash is a transient bad-selection render
|
||||
// (e.g. after an autocomplete insert); the draft content is
|
||||
// intact. Clear the selection so the remounted composer can
|
||||
// render — the user clicks in to continue, no page refresh.
|
||||
try {
|
||||
Transforms.deselect(editor);
|
||||
} catch {
|
||||
/* editor already in a safe state */
|
||||
}
|
||||
}}
|
||||
fallbackRender={({ resetErrorBoundary }) => (
|
||||
<RoomInputPlaceholder
|
||||
role="alert"
|
||||
style={{ padding: config.space.S200 }}
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="200"
|
||||
>
|
||||
<Text align="Center">
|
||||
Message composer encountered an error. Try refreshing.
|
||||
</Text>
|
||||
<Text align="Center">The message composer hit a snag.</Text>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={resetErrorBoundary}
|
||||
>
|
||||
<Text size="B300">Reload composer</Text>
|
||||
</Button>
|
||||
</RoomInputPlaceholder>
|
||||
}
|
||||
)}
|
||||
>
|
||||
<RoomInput
|
||||
room={room}
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoomLatestRenderedEvent } from '../../hooks/useRoomLatestRenderedEvent';
|
||||
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
||||
import { EventReaders } from '../../components/event-readers';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
|
||||
export function RoomViewFollowingPlaceholder() {
|
||||
@@ -34,6 +35,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
||||
({ className, room, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const modalStyle = useModalStyle(360);
|
||||
const latestEvent = useRoomLatestRenderedEvent(room);
|
||||
const latestEventReaders = useRoomEventReaders(room, latestEvent?.getId());
|
||||
const names = latestEventReaders
|
||||
@@ -55,7 +57,7 @@ export const RoomViewFollowing = as<'div', RoomViewFollowingProps>(
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="300">
|
||||
<Modal variant="Surface" size="300" style={modalStyle}>
|
||||
<EventReaders room={room} eventId={eventId} requestClose={() => setOpen(false)} />
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
|
||||
@@ -75,6 +75,7 @@ import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { webRTCSupported } from '../../utils/rtc';
|
||||
import { mediaGalleryAtom } from '../../state/mediaGallery';
|
||||
import { widgetsPanelAtom } from '../../state/widgetsPanel';
|
||||
import { threadsListAtom } from '../../state/threadsList';
|
||||
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
|
||||
import { bookmarksPanelAtom } from '../../state/bookmarksPanel';
|
||||
|
||||
@@ -491,6 +492,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||
const [galleryOpen, setGalleryOpen] = useAtom(mediaGalleryAtom);
|
||||
const [widgetsOpen, setWidgetsOpen] = useAtom(widgetsPanelAtom);
|
||||
const [threadsListOpen, setThreadsListOpen] = useAtom(threadsListAtom);
|
||||
const pendingKnocks = usePendingKnocks(room);
|
||||
|
||||
const handleSearchClick = () => {
|
||||
@@ -704,6 +706,29 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
||||
(direct ||
|
||||
(room.getJoinRule() === 'invite' &&
|
||||
getStateEvents(room, StateEvent.SpaceParent).length === 0)) && <CallButton />}
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
offset={4}
|
||||
tooltip={
|
||||
<Tooltip>
|
||||
<Text>{threadsListOpen ? 'Hide Threads' : 'Threads'}</Text>
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
{(triggerRef) => (
|
||||
<IconButton
|
||||
fill="None"
|
||||
ref={triggerRef}
|
||||
onClick={() => setThreadsListOpen(!threadsListOpen)}
|
||||
aria-label="Toggle threads"
|
||||
aria-pressed={threadsListOpen}
|
||||
>
|
||||
<Icon size="400" src={Icons.Thread} filled={threadsListOpen} />
|
||||
</IconButton>
|
||||
)}
|
||||
</TooltipProvider>
|
||||
)}
|
||||
{screenSize === ScreenSize.Desktop && (
|
||||
<TooltipProvider
|
||||
position="Bottom"
|
||||
|
||||
@@ -21,11 +21,24 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { scheduleMessage } from '../../utils/scheduledMessages';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import {
|
||||
toLocalDate,
|
||||
toLocalTime,
|
||||
parseLocalDateTime,
|
||||
pickerInputStyle,
|
||||
formatFriendlyDateTime,
|
||||
} from '../../utils/datetimeInput';
|
||||
|
||||
interface ScheduleMessageModalProps {
|
||||
roomId: string;
|
||||
/** Pre-fill the message body from the composer. Pass null/undefined to open blank. */
|
||||
initialBody?: string;
|
||||
/** Pre-fill the date/time pickers (Unix ms) — used when editing/rescheduling. */
|
||||
initialSendAt?: number;
|
||||
/** Header title; defaults to "Schedule Message". */
|
||||
title?: string;
|
||||
/** Primary-button label; defaults to "Schedule" (e.g. "Reschedule" when editing). */
|
||||
submitLabel?: string;
|
||||
onScheduled: (delayId: string, sendAt: number, content: IContent) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -40,54 +53,12 @@ function formatRelativeTime(ms: number): string {
|
||||
return 'in less than a minute';
|
||||
}
|
||||
|
||||
function formatSendAt(sendAt: Date): string {
|
||||
const now = new Date();
|
||||
const isToday =
|
||||
sendAt.getFullYear() === now.getFullYear() &&
|
||||
sendAt.getMonth() === now.getMonth() &&
|
||||
sendAt.getDate() === now.getDate();
|
||||
const tomorrow = new Date(now);
|
||||
tomorrow.setDate(tomorrow.getDate() + 1);
|
||||
const isTomorrow =
|
||||
sendAt.getFullYear() === tomorrow.getFullYear() &&
|
||||
sendAt.getMonth() === tomorrow.getMonth() &&
|
||||
sendAt.getDate() === tomorrow.getDate();
|
||||
|
||||
const timeStr = sendAt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
if (isToday) return `Today at ${timeStr}`;
|
||||
if (isTomorrow) return `Tomorrow at ${timeStr}`;
|
||||
return `${sendAt.toLocaleDateString()} at ${timeStr}`;
|
||||
}
|
||||
|
||||
function toLocalDate(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function toLocalTime(date: Date): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||
}
|
||||
|
||||
// Shared style for date/time inputs — dark-mode calendar/clock popup via colorScheme.
|
||||
const pickerInputStyle = (c: typeof color, cfg: typeof config): React.CSSProperties => ({
|
||||
background: c.SurfaceVariant.Container,
|
||||
color: c.SurfaceVariant.OnContainer,
|
||||
border: `${cfg.borderWidth.B300} solid ${c.SurfaceVariant.ContainerLine}`,
|
||||
borderRadius: cfg.radii.R300,
|
||||
padding: `${cfg.space.S200} ${cfg.space.S300}`,
|
||||
fontSize: '0.875rem',
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
fontFamily: 'inherit',
|
||||
// Hint browser to render the calendar/clock popup in dark mode
|
||||
colorScheme: 'dark',
|
||||
});
|
||||
|
||||
export function ScheduleMessageModal({
|
||||
roomId,
|
||||
initialBody,
|
||||
initialSendAt,
|
||||
title = 'Schedule Message',
|
||||
submitLabel = 'Schedule',
|
||||
onScheduled,
|
||||
onClose,
|
||||
}: ScheduleMessageModalProps) {
|
||||
@@ -105,15 +76,15 @@ export function ScheduleMessageModal({
|
||||
return d;
|
||||
};
|
||||
|
||||
const def = defaultDate();
|
||||
// When editing, seed the pickers from the existing send-time; else default to +1h.
|
||||
const def = initialSendAt ? new Date(initialSendAt) : defaultDate();
|
||||
const [dateValue, setDateValue] = useState<string>(() => toLocalDate(def));
|
||||
const [timeValue, setTimeValue] = useState<string>(() => toLocalTime(def));
|
||||
|
||||
const getSendAt = useCallback((): Date | null => {
|
||||
if (!dateValue || !timeValue) return null;
|
||||
const dt = new Date(`${dateValue}T${timeValue}:00`);
|
||||
return Number.isNaN(dt.getTime()) ? null : dt;
|
||||
}, [dateValue, timeValue]);
|
||||
const getSendAt = useCallback(
|
||||
(): Date | null => parseLocalDateTime(dateValue, timeValue),
|
||||
[dateValue, timeValue],
|
||||
);
|
||||
|
||||
const [preview, setPreview] = useState<{ label: string; relative: string } | null>(null);
|
||||
|
||||
@@ -128,7 +99,10 @@ export function ScheduleMessageModal({
|
||||
setPreview(null);
|
||||
return;
|
||||
}
|
||||
setPreview({ label: formatSendAt(sendAt), relative: formatRelativeTime(diffMs) });
|
||||
setPreview({
|
||||
label: formatFriendlyDateTime(sendAt.getTime()),
|
||||
relative: formatRelativeTime(diffMs),
|
||||
});
|
||||
}, [getSendAt]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -172,7 +146,7 @@ export function ScheduleMessageModal({
|
||||
<OverlayCenter>
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
initialFocus: '#schedule-message-body',
|
||||
onDeactivate: onClose,
|
||||
clickOutsideDeactivates: true,
|
||||
escapeDeactivates: stopPropagation,
|
||||
@@ -199,7 +173,7 @@ export function ScheduleMessageModal({
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Clock} size="100" />
|
||||
<Text id="schedule-message-title" size="H4">
|
||||
Schedule Message
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" onClick={onClose} aria-label="Close">
|
||||
@@ -334,7 +308,7 @@ export function ScheduleMessageModal({
|
||||
disabled={submitting || !preview}
|
||||
before={submitting ? <Spinner variant="Primary" size="100" /> : undefined}
|
||||
>
|
||||
<Text size="B400">Schedule</Text>
|
||||
<Text size="B400">{submitLabel}</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { IContent } from 'matrix-js-sdk';
|
||||
import { Box, Button, Icon, IconButton, Icons, Text, color, config } from 'folds';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { scheduledMessagesAtom, ScheduledMessage } from '../../state/scheduledMessages';
|
||||
import { cancelScheduledMessage } from '../../utils/scheduledMessages';
|
||||
import { cancelScheduledMessage, sendScheduledMessageNow } from '../../utils/scheduledMessages';
|
||||
import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
|
||||
interface ScheduledMessagesTrayProps {
|
||||
roomId: string;
|
||||
@@ -34,6 +36,8 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const [cancelling, setCancelling] = useState<Set<string>>(new Set());
|
||||
const [cancelErrors, setCancelErrors] = useState<Set<string>>(new Set());
|
||||
const [sendErrors, setSendErrors] = useState<Set<string>>(new Set());
|
||||
const [editing, setEditing] = useState<ScheduledMessage | null>(null);
|
||||
|
||||
const messages = useMemo(() => scheduledMessages.get(roomId) ?? [], [scheduledMessages, roomId]);
|
||||
|
||||
@@ -106,92 +110,256 @@ export function ScheduledMessagesTray({ roomId }: ScheduledMessagesTrayProps) {
|
||||
[mx, roomId, cancelling, setScheduledMessages],
|
||||
);
|
||||
|
||||
if (messages.length === 0) return null;
|
||||
const handleSendNow = useCallback(
|
||||
async (msg: ScheduledMessage) => {
|
||||
if (cancelling.has(msg.delayId)) return;
|
||||
setCancelling((prev) => new Set(prev).add(msg.delayId));
|
||||
setSendErrors((prev) => {
|
||||
if (!prev.has(msg.delayId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.delayId);
|
||||
return next;
|
||||
});
|
||||
try {
|
||||
await sendScheduledMessageNow(mx, msg.delayId);
|
||||
// Only prune once the server confirms the send. The delayed event is now
|
||||
// consumed and appears in the timeline; dropping it before confirmation
|
||||
// could hide a still-live event that never actually sent.
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(roomId) ?? [];
|
||||
const remaining = current.filter((m) => m.delayId !== msg.delayId);
|
||||
if (remaining.length === 0) {
|
||||
next.delete(roomId);
|
||||
} else {
|
||||
next.set(roomId, remaining);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch {
|
||||
// Keep the item (still sendable/editable/cancellable) and surface an
|
||||
// inline error; the delayed event is still scheduled on the server.
|
||||
setSendErrors((prev) => new Set(prev).add(msg.delayId));
|
||||
} finally {
|
||||
setCancelling((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(msg.delayId);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
},
|
||||
[mx, roomId, cancelling, setScheduledMessages],
|
||||
);
|
||||
|
||||
// Editing = cancel-old + schedule-new (MSC4140 has no in-place edit). The modal has
|
||||
// already scheduled the NEW message by the time this fires; add it, then cancel the
|
||||
// old one — removing the old from state only once the server confirms, so a failed
|
||||
// cancel leaves it visible (and retriable) instead of letting it silently fire.
|
||||
const handleEdit = useCallback(
|
||||
(oldMsg: ScheduledMessage, newDelayId: string, sendAt: number, content: IContent) => {
|
||||
// Add the newly-scheduled message up front (nothing lost yet).
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = (next.get(roomId) ?? []).filter((m) => m.delayId !== newDelayId);
|
||||
next.set(roomId, [{ delayId: newDelayId, roomId, content, sendAt }, ...current]);
|
||||
return next;
|
||||
});
|
||||
// Mark the old message as cancelling so its row's Edit/Cancel buttons are
|
||||
// disabled while we tear it down. Without this the old row stays live during
|
||||
// the in-flight cancel and a second edit could orphan a still-scheduled event
|
||||
// (both would fire). Also clear any stale error from a prior failed cancel.
|
||||
setCancelling((prev) => new Set(prev).add(oldMsg.delayId));
|
||||
setCancelErrors((prev) => {
|
||||
if (!prev.has(oldMsg.delayId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(oldMsg.delayId);
|
||||
return next;
|
||||
});
|
||||
setSendErrors((prev) => {
|
||||
if (!prev.has(oldMsg.delayId)) return prev;
|
||||
const next = new Set(prev);
|
||||
next.delete(oldMsg.delayId);
|
||||
return next;
|
||||
});
|
||||
setEditing(null);
|
||||
cancelScheduledMessage(mx, oldMsg.delayId)
|
||||
.then(() => {
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const remaining = (next.get(roomId) ?? []).filter((m) => m.delayId !== oldMsg.delayId);
|
||||
if (remaining.length === 0) next.delete(roomId);
|
||||
else next.set(roomId, remaining);
|
||||
return next;
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
// Cancel failed — the old delayed event is still live server-side, so it
|
||||
// must stay visible and retriable. Re-insert it if auto-prune removed the
|
||||
// row while the modal was open, otherwise the failure (and the duplicate
|
||||
// it will send) would be invisible.
|
||||
setScheduledMessages((prev) => {
|
||||
const next = new Map(prev);
|
||||
const current = next.get(roomId) ?? [];
|
||||
if (!current.some((m) => m.delayId === oldMsg.delayId)) {
|
||||
next.set(roomId, [...current, oldMsg]);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setCancelErrors((prev) => new Set(prev).add(oldMsg.delayId));
|
||||
})
|
||||
.finally(() => {
|
||||
setCancelling((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(oldMsg.delayId);
|
||||
return next;
|
||||
});
|
||||
});
|
||||
},
|
||||
[mx, roomId, setScheduledMessages],
|
||||
);
|
||||
|
||||
if (messages.length === 0 && !editing) return null;
|
||||
|
||||
return (
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
background: color.SurfaceVariant.Container,
|
||||
}}
|
||||
>
|
||||
{/* Tray header */}
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
radii="0"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`}
|
||||
before={<Icon src={Icons.Clock} size="50" />}
|
||||
after={<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
|
||||
<>
|
||||
{editing && (
|
||||
<ScheduleMessageModal
|
||||
roomId={roomId}
|
||||
initialBody={typeof editing.content.body === 'string' ? editing.content.body : ''}
|
||||
initialSendAt={editing.sendAt}
|
||||
title="Edit scheduled message"
|
||||
submitLabel="Reschedule"
|
||||
onScheduled={(newDelayId, sendAt, content) =>
|
||||
handleEdit(editing, newDelayId, sendAt, content)
|
||||
}
|
||||
onClose={() => setEditing(null)}
|
||||
/>
|
||||
)}
|
||||
<Box
|
||||
direction="Column"
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
justifyContent: 'flex-start',
|
||||
borderBottom: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
background: color.SurfaceVariant.Container,
|
||||
}}
|
||||
>
|
||||
<Text size="T200" style={{ flex: 1, fontWeight: 600, textAlign: 'left' }}>
|
||||
{messages.length} scheduled message{messages.length !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</Button>
|
||||
{/* Tray header */}
|
||||
<Button
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
radii="0"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
aria-expanded={expanded}
|
||||
aria-label={`${messages.length} scheduled message${messages.length !== 1 ? 's' : ''}`}
|
||||
before={<Icon src={Icons.Clock} size="50" />}
|
||||
after={<Icon src={expanded ? Icons.ChevronTop : Icons.ChevronBottom} size="50" />}
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
justifyContent: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<Text size="T200" style={{ flex: 1, fontWeight: 600, textAlign: 'left' }}>
|
||||
{messages.length} scheduled message{messages.length !== 1 ? 's' : ''}
|
||||
</Text>
|
||||
</Button>
|
||||
|
||||
{/* Tray items */}
|
||||
{expanded && (
|
||||
<Box direction="Column">
|
||||
{messages.map((msg) => (
|
||||
<Box
|
||||
key={msg.delayId}
|
||||
direction="Column"
|
||||
style={{
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text
|
||||
size="T200"
|
||||
priority="400"
|
||||
{/* Tray items */}
|
||||
{expanded && (
|
||||
<Box direction="Column">
|
||||
{messages.map((msg) => {
|
||||
const bodyPreview =
|
||||
typeof msg.content.body === 'string' ? (msg.content.body as string) : '(message)';
|
||||
const rowDesc = `${bodyPreview} at ${formatSendAt(msg.sendAt)}`;
|
||||
return (
|
||||
<Box
|
||||
key={msg.delayId}
|
||||
direction="Column"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
padding: `${config.space.S100} ${config.space.S300}`,
|
||||
borderTop: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
|
||||
}}
|
||||
>
|
||||
{typeof msg.content.body === 'string'
|
||||
? (msg.content.body as string)
|
||||
: '(message)'}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" style={{ whiteSpace: 'nowrap', flexShrink: 0 }}>
|
||||
{formatSendAt(msg.sendAt)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label="Cancel scheduled message"
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancel(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{cancelErrors.has(msg.delayId) && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||
>
|
||||
Could not cancel this message. Try again.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Text
|
||||
size="T200"
|
||||
priority="400"
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{bodyPreview}
|
||||
</Text>
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={{ whiteSpace: 'nowrap', flexShrink: 0 }}
|
||||
>
|
||||
{formatSendAt(msg.sendAt)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label={`Send scheduled message now: ${rowDesc}`}
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleSendNow(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Send} size="50" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label={`Edit scheduled message: ${rowDesc}`}
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setEditing(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Pencil} size="50" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
aria-label={`Cancel scheduled message: ${rowDesc}`}
|
||||
disabled={cancelling.has(msg.delayId)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleCancel(msg);
|
||||
}}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="50" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
{cancelErrors.has(msg.delayId) && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||
>
|
||||
Could not cancel this message. Try again.
|
||||
</Text>
|
||||
)}
|
||||
{sendErrors.has(msg.delayId) && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||
>
|
||||
Could not send now. Try again.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -234,7 +234,9 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) {
|
||||
</Box>
|
||||
{timestampState.status === AsyncStatus.Error && (
|
||||
<Text style={{ color: color.Critical.Main }} size="T300">
|
||||
{timestampState.error.message}
|
||||
{timestampState.error.errcode === 'M_UNRECOGNIZED'
|
||||
? "Your homeserver doesn't support jumping to a date or time (MSC3030)."
|
||||
: timestampState.error.message}
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { ReactNode, useCallback, useEffect, useState } from 'react';
|
||||
import React, { ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import parse from 'html-react-parser';
|
||||
import Linkify from 'linkify-react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
OverlayCenter,
|
||||
Scroll,
|
||||
Spinner,
|
||||
Switch,
|
||||
Text,
|
||||
color,
|
||||
config,
|
||||
} from 'folds';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
@@ -28,6 +30,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { timeDayMonYear, timeHourMinute } from '../../../utils/time';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import { diffWords } from '../../../utils/textDiff';
|
||||
|
||||
type RawEditEvent = {
|
||||
type: string;
|
||||
@@ -87,11 +90,76 @@ function getVersionContent(evt: MatrixEvent): ReactNode {
|
||||
return renderContent(newContent ?? content);
|
||||
}
|
||||
|
||||
const asText = (source: Record<string, unknown> | null | undefined): string => {
|
||||
const body = source?.body;
|
||||
return typeof body === 'string' ? body : '';
|
||||
};
|
||||
|
||||
// Plain-text (body) of the pre-edit message — mirrors getOriginalContent, but
|
||||
// returns the raw string for diffing rather than a rendered node.
|
||||
function getOriginalText(evt: MatrixEvent): string {
|
||||
const raw =
|
||||
(evt.getClearContent() as Record<string, unknown> | null) ??
|
||||
(evt.event as { content?: Record<string, unknown> }).content ??
|
||||
{};
|
||||
return asText(raw);
|
||||
}
|
||||
|
||||
// Plain-text (body) of an edit's new content.
|
||||
function getVersionText(evt: MatrixEvent): string {
|
||||
const content = evt.getContent();
|
||||
const newContent = content['m.new_content'] as Record<string, unknown> | undefined;
|
||||
return asText(newContent ?? content);
|
||||
}
|
||||
|
||||
// Renders a word-level diff of prev -> next: added words highlighted, removed
|
||||
// words struck-through, using semantic <ins>/<del> elements. Plain-text only
|
||||
// (formatting isn't diffed — see the "Highlight changes" toggle).
|
||||
function DiffText({ prev, next }: { prev: string; next: string }) {
|
||||
const segments = useMemo(() => diffWords(prev, next), [prev, next]);
|
||||
if (segments.length === 0) return <>(no text)</>;
|
||||
return (
|
||||
<>
|
||||
{segments.map((seg, i) => {
|
||||
const key = `${i}-${seg.type}`;
|
||||
if (seg.type === 'added') {
|
||||
return (
|
||||
<ins
|
||||
key={key}
|
||||
style={{
|
||||
background: color.Success.Container,
|
||||
color: color.Success.OnContainer,
|
||||
border: `${config.borderWidth.B300} solid ${color.Success.ContainerLine}`,
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `0 ${config.space.S100}`,
|
||||
textDecoration: 'none',
|
||||
boxDecorationBreak: 'clone',
|
||||
WebkitBoxDecorationBreak: 'clone',
|
||||
}}
|
||||
>
|
||||
{seg.text}
|
||||
</ins>
|
||||
);
|
||||
}
|
||||
if (seg.type === 'removed') {
|
||||
return (
|
||||
<del key={key} style={{ color: color.Critical.Main }}>
|
||||
{seg.text}
|
||||
</del>
|
||||
);
|
||||
}
|
||||
return <span key={key}>{seg.text}</span>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProps) {
|
||||
const mx = useMatrixClient();
|
||||
const modalStyle = useModalStyle(560);
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [dateFormatString] = useSetting(settingsAtom, 'dateFormatString');
|
||||
const [showDiff, setShowDiff] = useState(true);
|
||||
|
||||
const eventId = mEvent.getId();
|
||||
const roomId = room.roomId;
|
||||
@@ -171,6 +239,10 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
|
||||
const originalTs = mEvent.getTs();
|
||||
|
||||
// Ordered plain-text of every version: [original, ...each edit]. Edit i diffs
|
||||
// against versionTexts[i] (the version immediately before it).
|
||||
const versionTexts = [getOriginalText(mEvent), ...edits.map(getVersionText)];
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -200,6 +272,12 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
Edit History
|
||||
</Text>
|
||||
</Box>
|
||||
<Box as="label" alignItems="Center" gap="200" style={{ cursor: 'pointer' }}>
|
||||
<Text size="T200" priority="300">
|
||||
Highlight changes
|
||||
</Text>
|
||||
<Switch variant="Primary" value={showDiff} onChange={setShowDiff} />
|
||||
</Box>
|
||||
<IconButton size="300" onClick={onClose} radii="300" aria-label="Close">
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
@@ -258,7 +336,11 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
||||
size="T300"
|
||||
style={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}
|
||||
>
|
||||
{getVersionContent(editEvt)}
|
||||
{showDiff ? (
|
||||
<DiffText prev={versionTexts[index]} next={versionTexts[index + 1]} />
|
||||
) : (
|
||||
getVersionContent(editEvt)
|
||||
)}
|
||||
</Text>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
Box,
|
||||
Button,
|
||||
Checkbox,
|
||||
Chip,
|
||||
color,
|
||||
config,
|
||||
Header,
|
||||
@@ -21,9 +22,11 @@ import {
|
||||
Scroll,
|
||||
Spinner,
|
||||
Text,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { MatrixEvent, MsgType, Room } from 'matrix-js-sdk';
|
||||
import { useAtomValue, useAtom } from 'jotai';
|
||||
import { IThumbnailContent } from '../../../../types/matrix/common';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
@@ -31,6 +34,14 @@ import { mDirectAtom } from '../../../state/mDirectList';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { ThumbnailContent } from '../../../components/message/content/ThumbnailContent';
|
||||
import { getMemberAvatarMxc, getMemberName, trimReplyFromBody } from '../../../utils/room';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
import {
|
||||
recentForwardTargetsAtom,
|
||||
addRecentForwardTarget,
|
||||
} from '../../../state/recentForwardTargets';
|
||||
import { buildForwardContent } from './forwardContent';
|
||||
|
||||
type RoomRowProps = {
|
||||
@@ -93,6 +104,143 @@ function RoomRow({ room, dm, useAuthentication, selected, onToggle, sending }: R
|
||||
);
|
||||
}
|
||||
|
||||
// Compact, read-only preview of the message being forwarded — sender + body,
|
||||
// plus a small thumbnail for image/video. We already hold mEvent, so no fetch.
|
||||
function ForwardPreview({
|
||||
mEvent,
|
||||
useAuthentication,
|
||||
}: {
|
||||
mEvent: MatrixEvent;
|
||||
useAuthentication: boolean;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const room = mx.getRoom(mEvent.getRoomId() ?? '') ?? undefined;
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderName = room
|
||||
? getMemberName(room, senderId)
|
||||
: senderId.split(':')[0]?.slice(1) || senderId;
|
||||
const senderMxc = room ? getMemberAvatarMxc(room, senderId) : undefined;
|
||||
const senderAvatarUrl = senderMxc
|
||||
? (mxcUrlToHttp(mx, senderMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
|
||||
: undefined;
|
||||
|
||||
const content = mEvent.getContent();
|
||||
const msgtype = content.msgtype;
|
||||
const isMedia =
|
||||
msgtype === MsgType.Image ||
|
||||
msgtype === MsgType.Video ||
|
||||
msgtype === MsgType.File ||
|
||||
msgtype === MsgType.Audio;
|
||||
const bodyStr = typeof content.body === 'string' ? content.body : '';
|
||||
const label = isMedia
|
||||
? ((content.filename as string | undefined) ?? bodyStr) || '(media)'
|
||||
: trimReplyFromBody(bodyStr) || '(message)';
|
||||
const info = content.info as IThumbnailContent | undefined;
|
||||
const showThumb =
|
||||
(msgtype === MsgType.Image || msgtype === MsgType.Video) &&
|
||||
!!info &&
|
||||
(!!info.thumbnail_url || !!info.thumbnail_file);
|
||||
|
||||
return (
|
||||
<Box
|
||||
shrink="No"
|
||||
gap="200"
|
||||
alignItems="Center"
|
||||
role="group"
|
||||
aria-label="Message to forward"
|
||||
style={{
|
||||
margin: `${config.space.S200} ${config.space.S400} 0`,
|
||||
padding: config.space.S200,
|
||||
borderRadius: config.radii.R300,
|
||||
background: color.SurfaceVariant.Container,
|
||||
}}
|
||||
>
|
||||
<Avatar size="200" radii="300">
|
||||
<UserAvatar
|
||||
userId={senderId}
|
||||
src={senderAvatarUrl}
|
||||
alt={senderName}
|
||||
renderFallback={() => <Text size="H6">{nameInitials(senderName)}</Text>}
|
||||
/>
|
||||
</Avatar>
|
||||
{showThumb && info && (
|
||||
<ThumbnailContent
|
||||
info={info}
|
||||
renderImage={(src) => (
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
style={{
|
||||
width: toRem(40),
|
||||
height: toRem(40),
|
||||
borderRadius: config.radii.R300,
|
||||
objectFit: 'cover',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<Box direction="Column" grow="Yes" style={{ minWidth: 0 }}>
|
||||
<Text size="T200" truncate style={{ fontWeight: config.fontWeight.W600 }}>
|
||||
{senderName}
|
||||
</Text>
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{label}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// A compact selectable chip for a recently-forwarded-to room.
|
||||
function RecentChip({
|
||||
room,
|
||||
useAuthentication,
|
||||
selected,
|
||||
onToggle,
|
||||
sending,
|
||||
}: {
|
||||
room: Room;
|
||||
useAuthentication: boolean;
|
||||
selected: boolean;
|
||||
onToggle: () => void;
|
||||
sending: boolean;
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const avatarMxc = room.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 48, 48, 'crop') ?? undefined)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<Chip
|
||||
variant={selected ? 'Primary' : 'SurfaceVariant'}
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
disabled={sending}
|
||||
onClick={onToggle}
|
||||
aria-pressed={selected}
|
||||
before={
|
||||
<Avatar size="200" radii="300">
|
||||
<RoomAvatar
|
||||
roomId={room.roomId}
|
||||
src={avatarUrl}
|
||||
alt={room.name}
|
||||
renderFallback={() => (
|
||||
<RoomIcon roomType={room.getType()} size="100" joinRule={room.getJoinRule()} filled />
|
||||
)}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Text size="B300" truncate style={{ maxWidth: toRem(120) }}>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
mEvent: MatrixEvent;
|
||||
onClose: () => void;
|
||||
@@ -105,9 +253,16 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const [query, setQuery] = useState('');
|
||||
const [comment, setComment] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [sentTo, setSentTo] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [recents, setRecents] = useAtom(recentForwardTargetsAtom);
|
||||
// Rooms whose comment message already delivered this session — so a retry after
|
||||
// a forward failure doesn't re-post the comment (only the missing forward). Kept
|
||||
// for the dialog's lifetime: a room that already got the comment won't get it
|
||||
// again even if the text is later edited, which is the safe (no-duplicate) choice.
|
||||
const commentSentRef = useRef<Set<string>>(new Set());
|
||||
// Selection persists across query changes: a room selected then filtered out
|
||||
// of the rendered slice stays selected.
|
||||
const [selectedRoomIds, setSelectedRoomIds] = useState<Set<string>>(new Set());
|
||||
@@ -139,6 +294,15 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
return allRooms.filter((r) => r.name.toLowerCase().includes(q));
|
||||
}, [allRooms, query]);
|
||||
|
||||
// Resolve recent target ids to still-joined rooms (drop ones left/gone).
|
||||
const recentRooms = useMemo(
|
||||
() =>
|
||||
recents
|
||||
.map((id) => mx.getRoom(id))
|
||||
.filter((r): r is Room => !!r && r.getMyMembership() === 'join' && !r.isSpaceRoom()),
|
||||
[recents, mx],
|
||||
);
|
||||
|
||||
const sendToSelected = useCallback(async () => {
|
||||
if (sending || selectedRoomIds.size === 0) return;
|
||||
const fwdContent = buildForwardContent(mx, mEvent);
|
||||
@@ -150,20 +314,42 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
setError(null);
|
||||
|
||||
const ids = [...selectedRoomIds];
|
||||
const commentBody = comment.trim();
|
||||
const results = await Promise.allSettled(
|
||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
ids.map((id) => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent)),
|
||||
ids.map((id) => {
|
||||
// threadId-aware overload (P3-8): explicit null = send to the main timeline.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const sendForward = () => mx.sendEvent(id, null, mEvent.getType() as any, fwdContent);
|
||||
// Send the optional comment first so it reads as a note above the
|
||||
// forwarded content. The room counts as failed if either send rejects.
|
||||
// Track rooms whose comment already landed so a retry (after the FORWARD
|
||||
// failed) doesn't post the comment twice — only the missing forward.
|
||||
const needsComment = commentBody && !commentSentRef.current.has(id);
|
||||
const step = needsComment
|
||||
? mx
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
.sendMessage(id, null, { msgtype: MsgType.Text, body: commentBody } as any)
|
||||
.then(() => {
|
||||
commentSentRef.current.add(id);
|
||||
})
|
||||
: Promise.resolve();
|
||||
return step.then(sendForward);
|
||||
}),
|
||||
);
|
||||
|
||||
const failedIds: string[] = [];
|
||||
const failedNames: string[] = [];
|
||||
const succeededIds: string[] = [];
|
||||
results.forEach((result, i) => {
|
||||
if (result.status === 'rejected') {
|
||||
failedIds.push(ids[i]);
|
||||
failedNames.push(mx.getRoom(ids[i])?.name ?? ids[i]);
|
||||
} else {
|
||||
succeededIds.push(ids[i]);
|
||||
}
|
||||
});
|
||||
// Remember successful targets (most-recent first) for the Recent row.
|
||||
succeededIds.forEach((id) => setRecents((prev) => addRecentForwardTarget(prev, id)));
|
||||
|
||||
const total = ids.length;
|
||||
const failed = failedNames.length;
|
||||
@@ -184,7 +370,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
return;
|
||||
}
|
||||
setError(`Forwarded to ${succeeded}/${total}. Failed: ${failedNames.join(', ')}.`);
|
||||
}, [mx, mEvent, onClose, sending, selectedRoomIds]);
|
||||
}, [mx, mEvent, onClose, sending, selectedRoomIds, comment, setRecents]);
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
@@ -221,10 +407,12 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Header>
|
||||
{!sentTo && <ForwardPreview mEvent={mEvent} useAuthentication={useAuthentication} />}
|
||||
{!sentTo && (
|
||||
<Box
|
||||
shrink="No"
|
||||
direction="Column"
|
||||
gap="200"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S400}` }}
|
||||
>
|
||||
<Input
|
||||
@@ -233,15 +421,24 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
size="400"
|
||||
radii="400"
|
||||
outlined
|
||||
aria-label="Search rooms"
|
||||
placeholder="Search rooms…"
|
||||
value={query}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setQuery(e.target.value)}
|
||||
/>
|
||||
<Input
|
||||
variant="Background"
|
||||
size="400"
|
||||
radii="400"
|
||||
outlined
|
||||
aria-label="Add a comment"
|
||||
placeholder="Add a comment (optional)…"
|
||||
value={comment}
|
||||
disabled={sending}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setComment(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<Text
|
||||
size="T200"
|
||||
style={{ color: color.Critical.Main, paddingTop: config.space.S100 }}
|
||||
>
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
{error}
|
||||
</Text>
|
||||
)}
|
||||
@@ -262,7 +459,46 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
<>
|
||||
<Box grow="Yes" style={{ minHeight: 0, position: 'relative' }}>
|
||||
<Scroll size="300" hideTrack visibility="Hover">
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ padding: config.space.S200, opacity: sending ? 0.5 : 1 }}
|
||||
>
|
||||
{/* Recent targets — quick access, hidden while searching */}
|
||||
{!query && recentRooms.length > 0 && (
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ paddingBottom: config.space.S100 }}
|
||||
>
|
||||
<Text
|
||||
size="L400"
|
||||
priority="300"
|
||||
style={{ padding: `0 ${config.space.S200}` }}
|
||||
>
|
||||
Recent
|
||||
</Text>
|
||||
<Box
|
||||
gap="100"
|
||||
wrap="Wrap"
|
||||
role="group"
|
||||
aria-label="Recent forward targets"
|
||||
style={{ padding: `0 ${config.space.S200}` }}
|
||||
>
|
||||
{recentRooms.map((room) => (
|
||||
<RecentChip
|
||||
key={room.roomId}
|
||||
room={room}
|
||||
useAuthentication={useAuthentication}
|
||||
selected={selectedRoomIds.has(room.roomId)}
|
||||
onToggle={() => toggleRoom(room.roomId)}
|
||||
sending={sending}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Line size="300" style={{ marginTop: config.space.S100 }} />
|
||||
</Box>
|
||||
)}
|
||||
{filtered.slice(0, 60).map((room) => (
|
||||
<RoomRow
|
||||
key={room.roomId}
|
||||
@@ -291,12 +527,7 @@ export function ForwardMessageDialog({ mEvent, onClose }: Props) {
|
||||
<Box
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.35)',
|
||||
borderRadius: config.radii.R500,
|
||||
}}
|
||||
style={{ position: 'absolute', inset: 0 }}
|
||||
>
|
||||
<Spinner variant="Secondary" size="400" />
|
||||
</Box>
|
||||
|
||||
@@ -38,6 +38,7 @@ import { useHover, useFocusWithin } from 'react-aria';
|
||||
import { MatrixEvent, Room, EventStatus } from 'matrix-js-sdk';
|
||||
import { Relations } from 'matrix-js-sdk/lib/models/relations';
|
||||
import classNames from 'classnames';
|
||||
import { useAtom } from 'jotai';
|
||||
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import {
|
||||
AvatarBase,
|
||||
@@ -50,16 +51,21 @@ import {
|
||||
UsernameBold,
|
||||
} from '../../../components/message';
|
||||
import {
|
||||
canEditEvent,
|
||||
canEditCaption,
|
||||
canEditEventOrCaption,
|
||||
getEventEdits,
|
||||
getMemberAvatarMxc,
|
||||
getMemberName,
|
||||
sendStateEvent,
|
||||
trimReplyFromBody,
|
||||
} from '../../../utils/room';
|
||||
import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { messageAriaLabel } from '../../../utils/a11y';
|
||||
import { MessageLayout, MessageSpacing } from '../../../state/settings';
|
||||
import { msgTranslationActiveAtomFamily } from '../../../state/translation';
|
||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import { useRecentEmoji } from '../../../hooks/useRecentEmoji';
|
||||
import * as css from './styles.css';
|
||||
import { MsgAppearClass, SendingSpinClass } from '../../../styles/Animations.css';
|
||||
@@ -255,6 +261,7 @@ export const MessageReadReceiptItem = as<
|
||||
}
|
||||
>(({ room, eventId, onClose, ...props }, ref) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const modalStyle = useModalStyle(360);
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
@@ -273,7 +280,7 @@ export const MessageReadReceiptItem = as<
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Modal variant="Surface" size="300">
|
||||
<Modal variant="Surface" size="300" style={modalStyle}>
|
||||
<EventReaders room={room} eventId={eventId} requestClose={handleClose} />
|
||||
</Modal>
|
||||
</FocusTrap>
|
||||
@@ -410,6 +417,88 @@ export const MessageCopyLinkItem = as<
|
||||
);
|
||||
});
|
||||
|
||||
// Copies the message's plain-text body (reply fallback stripped) to the
|
||||
// clipboard. Renders nothing for events without a usable text body (e.g. media
|
||||
// without a caption), so the caller can list it unconditionally.
|
||||
export const MessageCopyTextItem = as<
|
||||
'button',
|
||||
{
|
||||
mEvent: MatrixEvent;
|
||||
onClose?: () => void;
|
||||
}
|
||||
>(({ mEvent, onClose, ...props }, ref) => {
|
||||
const content = mEvent.getContent();
|
||||
// Text-only: for media the `body` is the filename (or caption). We don't want
|
||||
// "Copy Text" to copy a filename, so gate to textual message types.
|
||||
const msgtype = content.msgtype;
|
||||
const isTextual = msgtype === 'm.text' || msgtype === 'm.emote' || msgtype === 'm.notice';
|
||||
const rawBody = typeof content.body === 'string' ? content.body : '';
|
||||
const body = trimReplyFromBody(rawBody).trim();
|
||||
|
||||
if (!isTextual || !body) return null;
|
||||
|
||||
const handleCopy = () => {
|
||||
copyToClipboard(body);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Text} />}
|
||||
radii="300"
|
||||
onClick={handleCopy}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
Copy Text
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const MessageTranslateItem = as<
|
||||
'button',
|
||||
{
|
||||
mEvent: MatrixEvent;
|
||||
onClose?: () => void;
|
||||
}
|
||||
>(({ mEvent, onClose, ...props }, ref) => {
|
||||
const content = mEvent.getContent();
|
||||
const msgtype = content.msgtype;
|
||||
const isTextual = msgtype === 'm.text' || msgtype === 'm.emote' || msgtype === 'm.notice';
|
||||
const rawBody = typeof content.body === 'string' ? content.body : '';
|
||||
const body = trimReplyFromBody(rawBody).trim();
|
||||
const eventId = mEvent.getId() ?? '';
|
||||
|
||||
const [active, setActive] = useAtom(msgTranslationActiveAtomFamily(eventId));
|
||||
|
||||
// On-device translation is Chromium-desktop only; hide the action entirely
|
||||
// where the engine can't run, and for non-textual/empty messages.
|
||||
if (!chromeTranslationEngine.isSupported() || !isTextual || !body || !eventId) return null;
|
||||
|
||||
const handleToggle = () => {
|
||||
setActive((a) => !a);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Globe} />}
|
||||
radii="300"
|
||||
onClick={handleToggle}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<Text className={css.MessageMenuItemText} as="span" size="T300" truncate>
|
||||
{active ? 'Show Original' : 'Translate'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
});
|
||||
|
||||
export const MessagePinItem = as<
|
||||
'button',
|
||||
{
|
||||
@@ -587,6 +676,7 @@ export const MessageReportItem = as<
|
||||
>(({ room, mEvent, onClose, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const modalStyle = useModalStyle(480);
|
||||
const [reportState, reportMessage] = useAsyncCallback(
|
||||
useCallback(
|
||||
(eventId: string, score: number, reason: string) =>
|
||||
@@ -628,7 +718,7 @@ export const MessageReportItem = as<
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Dialog variant="Surface">
|
||||
<Dialog variant="Surface" style={modalStyle}>
|
||||
<Header
|
||||
style={{
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S400}`,
|
||||
@@ -1072,13 +1162,13 @@ export const Message = React.memo(
|
||||
<Icon src={Icons.ThreadPlus} size="100" />
|
||||
</IconButton>
|
||||
)}
|
||||
{canEditEvent(mx, mEvent) && onEditId && (
|
||||
{canEditEventOrCaption(mx, mEvent) && onEditId && (
|
||||
<IconButton
|
||||
onClick={() => onEditId(mEvent.getId())}
|
||||
variant="SurfaceVariant"
|
||||
size="300"
|
||||
radii="300"
|
||||
aria-label="Edit message"
|
||||
aria-label={canEditCaption(mx, mEvent) ? 'Edit caption' : 'Edit message'}
|
||||
>
|
||||
<Icon src={Icons.Pencil} size="100" />
|
||||
</IconButton>
|
||||
@@ -1189,6 +1279,7 @@ export const Message = React.memo(
|
||||
savedAt: Date.now(),
|
||||
previewText: body.slice(0, 120),
|
||||
roomName: room.name,
|
||||
senderName: senderDisplayName,
|
||||
});
|
||||
}
|
||||
closeMenu();
|
||||
@@ -1247,7 +1338,7 @@ export const Message = React.memo(
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
{canEditEvent(mx, mEvent) && onEditId && (
|
||||
{canEditEventOrCaption(mx, mEvent) && onEditId && (
|
||||
<MenuItem
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Pencil} />}
|
||||
@@ -1264,7 +1355,7 @@ export const Message = React.memo(
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
Edit Message
|
||||
{canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
@@ -1282,6 +1373,8 @@ export const Message = React.memo(
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
)}
|
||||
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
{canPinEvent && (
|
||||
<MessagePinItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
@@ -1511,6 +1604,8 @@ export const Event = React.memo(
|
||||
onClose={closeMenu}
|
||||
/>
|
||||
)}
|
||||
<MessageCopyTextItem mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageTranslateItem mEvent={mEvent} onClose={closeMenu} />
|
||||
<MessageCopyLinkItem room={room} mEvent={mEvent} onClose={closeMenu} />
|
||||
</Box>
|
||||
{((!mEvent.isRedacted() && canDelete && !stateEvent) ||
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from 'folds';
|
||||
import { Editor, Transforms } from 'slate';
|
||||
import { ReactEditor } from 'slate-react';
|
||||
import { IContent, IMentions, MatrixEvent, RelationType, Room } from 'matrix-js-sdk';
|
||||
import { IContent, IMentions, MatrixEvent, MsgType, RelationType, Room } from 'matrix-js-sdk';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import {
|
||||
AUTOCOMPLETE_PREFIXES,
|
||||
@@ -75,6 +75,15 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
// message is being edited (a11y, P3-4).
|
||||
const editSenderId = mEvent.getSender();
|
||||
const editSenderName = editSenderId ? getMemberName(room, editSenderId) : '';
|
||||
// Image/video messages carry an optional caption in `body` (a caption exists
|
||||
// when body !== filename). Editing such a message edits the caption while
|
||||
// preserving the media, rather than replacing the content with text.
|
||||
const editMsgType = mEvent.getContent().msgtype;
|
||||
const isMediaCaption = editMsgType === MsgType.Image || editMsgType === MsgType.Video;
|
||||
const editFilename =
|
||||
typeof mEvent.getContent().filename === 'string'
|
||||
? (mEvent.getContent().filename as string)
|
||||
: undefined;
|
||||
const [enterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||
const [globalToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||
const [isMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||
@@ -118,6 +127,70 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
}),
|
||||
);
|
||||
|
||||
// Media caption edit: preserve the media, change only body/formatted_body.
|
||||
// An empty caption is valid (it removes the caption → body falls back to
|
||||
// the filename).
|
||||
if (isMediaCaption) {
|
||||
const evtId = mEvent.getId()!;
|
||||
const evtTimeline = room.getTimelineForEvent(evtId);
|
||||
const editedEvent =
|
||||
evtTimeline && getEditedEvent(evtId, mEvent, evtTimeline.getTimelineSet());
|
||||
const orig: IContent = {
|
||||
...(editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()),
|
||||
};
|
||||
delete orig['m.relates_to'];
|
||||
delete orig['m.new_content'];
|
||||
|
||||
const filename = typeof orig.filename === 'string' ? orig.filename : (editFilename ?? '');
|
||||
const hasFormatting = !customHtmlEqualsPlainText(customHtml, plainText);
|
||||
|
||||
const mediaContent: IContent = { ...orig };
|
||||
if (plainText) {
|
||||
mediaContent.body = plainText;
|
||||
if (hasFormatting) {
|
||||
mediaContent.format = 'org.matrix.custom.html';
|
||||
mediaContent.formatted_body = customHtml;
|
||||
} else {
|
||||
delete mediaContent.format;
|
||||
delete mediaContent.formatted_body;
|
||||
}
|
||||
} else {
|
||||
// Caption removed.
|
||||
mediaContent.body = filename;
|
||||
delete mediaContent.format;
|
||||
delete mediaContent.formatted_body;
|
||||
}
|
||||
|
||||
// No-op guard: caption text/markup unchanged.
|
||||
if (
|
||||
mediaContent.body === orig.body &&
|
||||
mediaContent.formatted_body === orig.formatted_body
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Carry mentions typed into the caption (union with prior mentions), so
|
||||
// an @-mention in a caption edit notifies — mirrors the text path.
|
||||
const [, , prevMentions] = getPrevBodyAndFormattedBody();
|
||||
const mentionData = getMentions(mx, roomId, editor);
|
||||
prevMentions?.user_ids?.forEach((id) => mentionData.users.add(id));
|
||||
mediaContent['m.mentions'] = getMentionContent(
|
||||
Array.from(mentionData.users),
|
||||
mentionData.room,
|
||||
);
|
||||
|
||||
const content: IContent = {
|
||||
...mediaContent,
|
||||
'm.new_content': mediaContent,
|
||||
'm.relates_to': {
|
||||
event_id: evtId,
|
||||
rel_type: RelationType.Replace,
|
||||
},
|
||||
};
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return mx.sendMessage(roomId, content as any);
|
||||
}
|
||||
|
||||
const [prevBody, prevCustomHtml, prevMentions] = getPrevBodyAndFormattedBody();
|
||||
|
||||
if (plainText === '') return undefined;
|
||||
@@ -165,7 +238,17 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return mx.sendMessage(roomId, content as any);
|
||||
}, [mx, editor, roomId, mEvent, isMarkdown, getPrevBodyAndFormattedBody]),
|
||||
}, [
|
||||
mx,
|
||||
editor,
|
||||
roomId,
|
||||
room,
|
||||
mEvent,
|
||||
isMarkdown,
|
||||
getPrevBodyAndFormattedBody,
|
||||
isMediaCaption,
|
||||
editFilename,
|
||||
]),
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -220,10 +303,19 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
useEffect(() => {
|
||||
const [body, customHtml] = getPrevBodyAndFormattedBody();
|
||||
|
||||
// For media, seed from the caption only: an empty caption is `body ===
|
||||
// filename`, so don't prefill the filename into the editor.
|
||||
let seedText = typeof body === 'string' ? body : '';
|
||||
let seedHtml = typeof customHtml === 'string' ? customHtml : undefined;
|
||||
if (isMediaCaption && seedText === editFilename) {
|
||||
seedText = '';
|
||||
seedHtml = undefined;
|
||||
}
|
||||
|
||||
const initialValue =
|
||||
typeof customHtml === 'string'
|
||||
? htmlToEditorInput(customHtml, isMarkdown)
|
||||
: plainToEditorInput(typeof body === 'string' ? body : '', isMarkdown);
|
||||
seedHtml !== undefined
|
||||
? htmlToEditorInput(seedHtml, isMarkdown)
|
||||
: plainToEditorInput(seedText, isMarkdown);
|
||||
|
||||
Transforms.select(editor, {
|
||||
anchor: Editor.start(editor, []),
|
||||
@@ -232,7 +324,7 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
|
||||
editor.insertFragment(initialValue);
|
||||
if (!mobileOrTablet()) ReactEditor.focus(editor);
|
||||
}, [editor, getPrevBodyAndFormattedBody, isMarkdown]);
|
||||
}, [editor, getPrevBodyAndFormattedBody, isMarkdown, isMediaCaption, editFilename]);
|
||||
|
||||
useEffect(() => {
|
||||
if (saveState.status === AsyncStatus.Success) {
|
||||
@@ -268,8 +360,14 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
)}
|
||||
<CustomEditor
|
||||
editor={editor}
|
||||
placeholder="Edit message..."
|
||||
ariaLabel={editSenderId ? `Editing message from ${editSenderName}` : 'Edit message'}
|
||||
placeholder={isMediaCaption ? 'Add a caption…' : 'Edit message...'}
|
||||
ariaLabel={
|
||||
isMediaCaption
|
||||
? `Editing caption for ${editSenderName}'s attachment`
|
||||
: editSenderId
|
||||
? `Editing message from ${editSenderName}`
|
||||
: 'Edit message'
|
||||
}
|
||||
onKeyDown={handleKeyDown}
|
||||
onKeyUp={handleKeyUp}
|
||||
bottom={
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import {
|
||||
Box,
|
||||
@@ -19,6 +19,13 @@ import {
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { useReminders } from '../../../hooks/useReminders';
|
||||
import { useModalStyle } from '../../../hooks/useModalStyle';
|
||||
import {
|
||||
toLocalDate,
|
||||
toLocalTime,
|
||||
parseLocalDateTime,
|
||||
pickerInputStyle,
|
||||
formatFriendlyDateTime,
|
||||
} from '../../../utils/datetimeInput';
|
||||
|
||||
type RemindMeDialogProps = {
|
||||
roomId: string;
|
||||
@@ -40,14 +47,48 @@ function getPresets(): Array<{ label: string; ms: number }> {
|
||||
];
|
||||
}
|
||||
|
||||
// Default custom pick: 1 hour from now, rounded up to the nearest 5 minutes.
|
||||
function defaultCustomDate(): Date {
|
||||
const d = new Date(Date.now() + 60 * 60 * 1000);
|
||||
d.setSeconds(0, 0);
|
||||
d.setMinutes(Math.ceil(d.getMinutes() / 5) * 5);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) {
|
||||
const modalStyle = useModalStyle(320);
|
||||
const { addReminder } = useReminders();
|
||||
const { addReminder, removeReminder, reminders } = useReminders();
|
||||
const presets = useMemo(() => getPresets(), []);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [customOpen, setCustomOpen] = useState(false);
|
||||
const def = useMemo(() => defaultCustomDate(), []);
|
||||
const [dateValue, setDateValue] = useState(() => toLocalDate(def));
|
||||
const [timeValue, setTimeValue] = useState(() => toLocalTime(def));
|
||||
const dateInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handlePick = async (ms: number) => {
|
||||
// Reminders already set on this message (soonest first) — so the user can see
|
||||
// and cancel them instead of silently stacking duplicates.
|
||||
const existing = useMemo(
|
||||
() => reminders.filter((r) => r.eventId === eventId).sort((a, b) => a.timestamp - b.timestamp),
|
||||
[reminders, eventId],
|
||||
);
|
||||
|
||||
// Move focus into the revealed date input for keyboard/SR users.
|
||||
useEffect(() => {
|
||||
if (customOpen) dateInputRef.current?.focus();
|
||||
}, [customOpen]);
|
||||
|
||||
const handleCancelExisting = (timestamp: number) => {
|
||||
// Optimistic, matching removeBookmark: the shared account-data store drops
|
||||
// the reminder locally at once (no rollback) and re-syncs from the server.
|
||||
// We deliberately show no inline error — the store has no rollback path, so a
|
||||
// failed write simply reappears on the next sync rather than leaving a stale
|
||||
// "couldn't cancel" message beside an already-vanished row.
|
||||
removeReminder(eventId, timestamp).catch(() => undefined);
|
||||
};
|
||||
|
||||
const commit = async (timestamp: number) => {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
@@ -55,7 +96,7 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
await addReminder({
|
||||
roomId,
|
||||
eventId,
|
||||
timestamp: Date.now() + ms,
|
||||
timestamp,
|
||||
message: previewText || 'Reminder',
|
||||
});
|
||||
onClose();
|
||||
@@ -65,6 +106,23 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
}
|
||||
};
|
||||
|
||||
const handlePick = (ms: number) => commit(Date.now() + ms);
|
||||
|
||||
const customDate = parseLocalDateTime(dateValue, timeValue);
|
||||
const customValid = !!customDate && customDate.getTime() - Date.now() >= 60_000;
|
||||
|
||||
const handleCustom = () => {
|
||||
if (!customDate) {
|
||||
setError('Please select a valid date and time.');
|
||||
return;
|
||||
}
|
||||
if (customDate.getTime() - Date.now() < 60_000) {
|
||||
setError('Reminder time must be at least 1 minute in the future.');
|
||||
return;
|
||||
}
|
||||
commit(customDate.getTime());
|
||||
};
|
||||
|
||||
return (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -111,6 +169,40 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
<Line size="300" />
|
||||
</>
|
||||
)}
|
||||
{existing.length > 0 && (
|
||||
<>
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
style={{ padding: `${config.space.S200} ${config.space.S200} 0` }}
|
||||
>
|
||||
<Text size="L400" priority="300" style={{ paddingLeft: config.space.S200 }}>
|
||||
{existing.length === 1 ? 'Reminder set' : 'Reminders set'}
|
||||
</Text>
|
||||
{existing.map((r, idx) => (
|
||||
// Composite key: two custom reminders on one message can share
|
||||
// a minute-precision timestamp; index keeps React keys unique.
|
||||
<Box key={`${r.timestamp}-${idx}`} alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Clock} size="100" style={{ flexShrink: 0 }} />
|
||||
<Text size="T200" style={{ flexGrow: 1, minWidth: 0 }} truncate>
|
||||
{formatFriendlyDateTime(r.timestamp)}
|
||||
</Text>
|
||||
<IconButton
|
||||
size="300"
|
||||
radii="300"
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
onClick={() => handleCancelExisting(r.timestamp)}
|
||||
aria-label={`Cancel reminder for ${formatFriendlyDateTime(r.timestamp)}`}
|
||||
>
|
||||
<Icon src={Icons.Cross} size="100" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
<Line size="300" style={{ marginTop: config.space.S200 }} />
|
||||
</>
|
||||
)}
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S200 }}>
|
||||
{presets.map((p) => (
|
||||
<Button
|
||||
@@ -127,6 +219,77 @@ export function RemindMeDialog({ roomId, eventId, previewText, onClose }: Remind
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
|
||||
{customOpen ? (
|
||||
<Box direction="Column" gap="200" style={{ paddingTop: config.space.S100 }}>
|
||||
<Box gap="200">
|
||||
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
||||
<Text as="label" htmlFor="remind-date" size="T200" priority="400">
|
||||
Date
|
||||
</Text>
|
||||
<input
|
||||
ref={dateInputRef}
|
||||
id="remind-date"
|
||||
type="date"
|
||||
value={dateValue}
|
||||
min={toLocalDate(new Date())}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
setDateValue(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
style={pickerInputStyle(color, config)}
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
||||
<Text as="label" htmlFor="remind-time" size="T200" priority="400">
|
||||
Time
|
||||
</Text>
|
||||
<input
|
||||
id="remind-time"
|
||||
type="time"
|
||||
value={timeValue}
|
||||
disabled={busy}
|
||||
onChange={(e) => {
|
||||
setTimeValue(e.target.value);
|
||||
setError(null);
|
||||
}}
|
||||
style={pickerInputStyle(color, config)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
{!customValid && (dateValue || timeValue) && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Must be at least 1 minute in the future
|
||||
</Text>
|
||||
)}
|
||||
<Button
|
||||
size="300"
|
||||
variant="Primary"
|
||||
radii="300"
|
||||
disabled={busy || !customValid}
|
||||
onClick={handleCustom}
|
||||
>
|
||||
<Text size="B300">Set reminder</Text>
|
||||
</Button>
|
||||
</Box>
|
||||
) : (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
radii="300"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setError(null);
|
||||
setCustomOpen(true);
|
||||
}}
|
||||
before={<Icon src={Icons.Clock} size="100" />}
|
||||
>
|
||||
<Text size="B300">Custom time…</Text>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<Text
|
||||
size="T200"
|
||||
|
||||
@@ -196,6 +196,7 @@ export function ThreadPanel({ room, threadId, requestClose }: ThreadPanelProps)
|
||||
roomId={room.roomId}
|
||||
threadRootId={threadId}
|
||||
editor={editor}
|
||||
editableName="ThreadInput"
|
||||
fileDropContainerRef={fileDropContainerRef}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { Badge, Box, Chip, Icon, Icons, Text, config } from 'folds';
|
||||
import { MatrixEvent, Room } from 'matrix-js-sdk';
|
||||
import { MobileTouchTarget } from '../../../styles/mobile.css';
|
||||
import { useThreadSummary } from '../../../hooks/useThreadSummary';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
@@ -31,6 +32,7 @@ export function ThreadSummary({ rootEvent, room, onOpen }: ThreadSummaryProps) {
|
||||
<Chip
|
||||
variant="SurfaceVariant"
|
||||
radii="300"
|
||||
className={MobileTouchTarget}
|
||||
before={<Icon size="50" src={Icons.Thread} />}
|
||||
after={
|
||||
unread > 0 ? <Badge variant="Success" fill="Solid" radii="Pill" size="200" /> : undefined
|
||||
|
||||
@@ -32,11 +32,12 @@ import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { Badge, Box, Chip, Icon, Icons, Line, Scroll, Spinner, Text, color, config } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { Opts as LinkifyOpts } from 'linkifyjs';
|
||||
import { isKeyHotkey } from 'is-hotkey';
|
||||
import { eventWithShortcode, factoryEventSentBy } from '../../../utils/matrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useVirtualPaginator, ItemRange } from '../../../hooks/useVirtualPaginator';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { scrollToBottom } from '../../../utils/dom';
|
||||
import { editableActiveElement, scrollToBottom } from '../../../utils/dom';
|
||||
import {
|
||||
DefaultPlaceholder,
|
||||
MessageBase,
|
||||
@@ -55,9 +56,11 @@ import {
|
||||
renderMatrixMention,
|
||||
} from '../../../plugins/react-custom-html-parser';
|
||||
import {
|
||||
canEditEvent,
|
||||
decryptAllTimelineEvent,
|
||||
getEditedEvent,
|
||||
getEventReactions,
|
||||
getLatestEditableEvt,
|
||||
getMemberName,
|
||||
getReactionContent,
|
||||
reactionOrEditEvent,
|
||||
@@ -76,7 +79,8 @@ import {
|
||||
today,
|
||||
yesterday,
|
||||
} from '../../../utils/time';
|
||||
import { createMentionElement, moveCursor } from '../../../components/editor';
|
||||
import { createMentionElement, isEmptyEditor, moveCursor } from '../../../components/editor';
|
||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||
import { roomIdToReplyDraftAtomFamily } from '../../../state/room/roomInputDrafts';
|
||||
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../../types/matrix/room';
|
||||
@@ -580,6 +584,32 @@ export function ThreadTimeline({ room, thread, editor }: ThreadTimelineProps) {
|
||||
[editor],
|
||||
);
|
||||
|
||||
// Up-arrow in the (empty) thread composer edits your last editable reply in
|
||||
// this thread — parity with the main timeline. Gated on the thread composer's
|
||||
// distinct editableName so it and the main handler never cross-fire.
|
||||
useKeyDown(
|
||||
window,
|
||||
useCallback(
|
||||
(evt) => {
|
||||
if (
|
||||
isKeyHotkey('arrowup', evt) &&
|
||||
editableActiveElement() &&
|
||||
document.activeElement?.getAttribute('data-editable-name') === 'ThreadInput' &&
|
||||
isEmptyEditor(editor)
|
||||
) {
|
||||
const editableEvt = getLatestEditableEvt(thread.liveTimeline, (mEvt) =>
|
||||
canEditEvent(mx, mEvt),
|
||||
);
|
||||
const editableEvtId = editableEvt?.getId();
|
||||
if (!editableEvtId) return;
|
||||
setEditId(editableEvtId);
|
||||
evt.preventDefault();
|
||||
}
|
||||
},
|
||||
[mx, thread, editor],
|
||||
),
|
||||
);
|
||||
|
||||
const handleOpenReply: MouseEventHandler = useCallback(
|
||||
(evt) => {
|
||||
const targetId = evt.currentTarget.getAttribute('data-event-id');
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
|
||||
export const ThreadsListPanel = style({
|
||||
width: toRem(340),
|
||||
'@media': {
|
||||
'(max-width: 750px)': {
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
zIndex: 500,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const ThreadsListHeader = style({
|
||||
flexShrink: 0,
|
||||
padding: `0 ${config.space.S200} 0 ${config.space.S300}`,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
});
|
||||
|
||||
export const ThreadsListToolbar = style({
|
||||
flexShrink: 0,
|
||||
padding: config.space.S200,
|
||||
borderBottomWidth: config.borderWidth.B300,
|
||||
});
|
||||
|
||||
export const ThreadsListContent = style({
|
||||
padding: config.space.S200,
|
||||
});
|
||||
|
||||
export const ThreadRow = style({
|
||||
width: '100%',
|
||||
textAlign: 'left',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
borderRadius: config.radii.R300,
|
||||
padding: config.space.S300,
|
||||
background: color.SurfaceVariant.Container,
|
||||
selectors: {
|
||||
'&:hover': { background: color.SurfaceVariant.ContainerHover },
|
||||
'&:active': { background: color.SurfaceVariant.ContainerActive },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,382 @@
|
||||
import React, { useEffect, useMemo, useRef } from 'react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
|
||||
import { NotificationCountType, Room, Thread } from 'matrix-js-sdk';
|
||||
import { Avatar, Box, Button, Header, Icon, IconButton, Icons, Scroll, Text, config } from 'folds';
|
||||
import classNames from 'classnames';
|
||||
import { useVirtualizer } from '@tanstack/react-virtual';
|
||||
import * as css from './ThreadsListPanel.css';
|
||||
import { ContainerColor } from '../../../styles/ContainerColor.css';
|
||||
import { VirtualTile } from '../../../components/virtualizer';
|
||||
import { UserAvatar } from '../../../components/user-avatar';
|
||||
import { StackedAvatar } from '../../../components/stacked-avatar';
|
||||
import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge';
|
||||
import { useMemberAvatar } from '../../../hooks/useMemberAvatar';
|
||||
import { trimReplyFromBody } from '../../../utils/room';
|
||||
import { scaleSystemEmoji } from '../../../plugins/react-custom-html-parser';
|
||||
import { threadNotificationsAtom } from '../../../state/threadNotifications';
|
||||
import { getMutedThreads } from '../../../utils/threadNotifications';
|
||||
import { nameInitials } from '../../../utils/common';
|
||||
import {
|
||||
ThreadFilter,
|
||||
ThreadSort,
|
||||
ThreadSnapshot,
|
||||
filterThreads,
|
||||
sortThreads,
|
||||
isThreadFilter,
|
||||
isThreadSort,
|
||||
} from '../../../utils/threadList';
|
||||
import { useRoomThreads } from '../../../hooks/useRoomThreads';
|
||||
|
||||
// Persisted across panel opens (the panel unmounts on close). getOnInit reads
|
||||
// localStorage synchronously so the chosen filter/sort apply on first render.
|
||||
const threadFilterAtom = atomWithStorage<ThreadFilter>(
|
||||
'cinny_threads_filter_v1',
|
||||
'all',
|
||||
createJSONStorage(() => localStorage),
|
||||
{ getOnInit: true },
|
||||
);
|
||||
const threadSortAtom = atomWithStorage<ThreadSort>(
|
||||
'cinny_threads_sort_v1',
|
||||
'recent',
|
||||
createJSONStorage(() => localStorage),
|
||||
{ getOnInit: true },
|
||||
);
|
||||
|
||||
const FILTER_OPTIONS: { value: ThreadFilter; label: string }[] = [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'unread', label: 'Unread' },
|
||||
{ value: 'participating', label: 'Participating' },
|
||||
];
|
||||
const SORT_OPTIONS: { value: ThreadSort; label: string }[] = [
|
||||
{ value: 'recent', label: 'Recent' },
|
||||
{ value: 'oldest', label: 'Oldest' },
|
||||
];
|
||||
|
||||
const MAX_PARTICIPANTS = 5;
|
||||
|
||||
function formatTimeAgo(ts: number): string {
|
||||
const diff = Date.now() - ts;
|
||||
const minutes = Math.floor(diff / 60_000);
|
||||
if (minutes < 1) return 'just now';
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
if (days === 1) return 'yesterday';
|
||||
if (days < 7) return `${days}d ago`;
|
||||
return new Date(ts).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Segmented button, mirroring the Bookmarks panel sort control for consistency.
|
||||
function SegButton({
|
||||
label,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Button
|
||||
size="300"
|
||||
variant={active ? 'Primary' : 'Secondary'}
|
||||
fill={active ? 'Solid' : 'Soft'}
|
||||
radii="300"
|
||||
aria-pressed={active}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Text size="B300">{label}</Text>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantAvatar({ room, userId }: { room: Room; userId: string }) {
|
||||
const { name, avatarUrl } = useMemberAvatar(room, userId);
|
||||
return (
|
||||
<StackedAvatar title={name} variant="SurfaceVariant" size="200" radii="Pill">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</StackedAvatar>
|
||||
);
|
||||
}
|
||||
|
||||
type ThreadRowProps = {
|
||||
room: Room;
|
||||
thread: Thread;
|
||||
unread: number;
|
||||
highlight: number;
|
||||
participants: string[];
|
||||
onOpen: (threadId: string) => void;
|
||||
};
|
||||
function ThreadRow({ room, thread, unread, highlight, participants, onOpen }: ThreadRowProps) {
|
||||
const rootEvent = thread.rootEvent;
|
||||
const rootSender = rootEvent?.getSender() ?? '';
|
||||
const { name: rootName, avatarUrl } = useMemberAvatar(room, rootSender);
|
||||
const bodyRaw =
|
||||
typeof rootEvent?.getContent().body === 'string' ? (rootEvent.getContent().body as string) : '';
|
||||
const snippet = bodyRaw ? scaleSystemEmoji(trimReplyFromBody(bodyRaw)) : '(no preview)';
|
||||
const count = thread.length;
|
||||
const lastTs = thread.replyToEvent?.getTs() ?? rootEvent?.getTs();
|
||||
const extra = participants.length - MAX_PARTICIPANTS;
|
||||
const replyLabel = `${count} ${count === 1 ? 'reply' : 'replies'}`;
|
||||
const ariaLabel = `Open thread by ${rootName}${unread > 0 ? ', unread' : ''}, ${replyLabel}${
|
||||
typeof lastTs === 'number' ? `, last reply ${formatTimeAgo(lastTs)}` : ''
|
||||
}`;
|
||||
|
||||
return (
|
||||
<Box
|
||||
as="button"
|
||||
direction="Column"
|
||||
gap="100"
|
||||
className={css.ThreadRow}
|
||||
onClick={() => onOpen(thread.id)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Avatar size="200" radii="300">
|
||||
<UserAvatar
|
||||
userId={rootSender}
|
||||
src={avatarUrl}
|
||||
alt={rootName}
|
||||
renderFallback={() => <Text size="H6">{nameInitials(rootName)}</Text>}
|
||||
/>
|
||||
</Avatar>
|
||||
<Text size="T200" truncate style={{ flexGrow: 1, fontWeight: config.fontWeight.W600 }}>
|
||||
{rootName}
|
||||
</Text>
|
||||
{unread > 0 && (
|
||||
<UnreadBadgeCenter>
|
||||
<UnreadBadge highlight={highlight > 0} count={unread} />
|
||||
</UnreadBadgeCenter>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
<Text
|
||||
size="T200"
|
||||
priority="400"
|
||||
style={{
|
||||
display: '-webkit-box',
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: 'vertical',
|
||||
overflow: 'hidden',
|
||||
wordBreak: 'break-word',
|
||||
}}
|
||||
>
|
||||
{snippet}
|
||||
</Text>
|
||||
|
||||
<Box alignItems="Center" gap="200">
|
||||
<Icon size="50" src={Icons.Thread} />
|
||||
<Text size="T200" priority="300" truncate style={{ flexGrow: 1 }}>
|
||||
{count} {count === 1 ? 'reply' : 'replies'}
|
||||
{typeof lastTs === 'number' ? ` · ${formatTimeAgo(lastTs)}` : ''}
|
||||
</Text>
|
||||
<Box shrink="No" alignItems="Center">
|
||||
{participants.slice(0, MAX_PARTICIPANTS).map((userId) => (
|
||||
<ParticipantAvatar key={userId} room={room} userId={userId} />
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<Text size="T200" priority="300" style={{ marginLeft: config.space.S100 }}>
|
||||
+{extra}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
export type ThreadsListPanelProps = {
|
||||
room: Room;
|
||||
onClose: () => void;
|
||||
onOpenThread: (threadId: string) => void;
|
||||
};
|
||||
export function ThreadsListPanel({ room, onClose, onOpenThread }: ThreadsListPanelProps) {
|
||||
const threads = useRoomThreads(room);
|
||||
const threadNotifications = useAtomValue(threadNotificationsAtom);
|
||||
const [storedFilter, setFilter] = useAtom(threadFilterAtom);
|
||||
const [storedSort, setSort] = useAtom(threadSortAtom);
|
||||
const filter: ThreadFilter = isThreadFilter(storedFilter) ? storedFilter : 'all';
|
||||
const sort: ThreadSort = isThreadSort(storedSort) ? storedSort : 'recent';
|
||||
|
||||
// Escape closes the panel (parity with the app's other drawers).
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (evt: KeyboardEvent) => {
|
||||
if (evt.key === 'Escape' && !evt.defaultPrevented) {
|
||||
evt.preventDefault();
|
||||
evt.stopPropagation();
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
const { visible, unreadById, highlightById, participantsById, threadById } = useMemo(() => {
|
||||
const mutedSet = getMutedThreads(threadNotifications, room.roomId);
|
||||
const unreadMap = new Map<string, number>();
|
||||
const highlightMap = new Map<string, number>();
|
||||
const partsMap = new Map<string, string[]>();
|
||||
const byId = new Map<string, Thread>();
|
||||
const snaps: ThreadSnapshot[] = threads.map((t) => {
|
||||
byId.set(t.id, t);
|
||||
const muted = mutedSet.has(t.id);
|
||||
const unread = muted
|
||||
? 0
|
||||
: (room.getThreadUnreadNotificationCount(t.id, NotificationCountType.Total) ?? 0);
|
||||
const highlight = muted
|
||||
? 0
|
||||
: (room.getThreadUnreadNotificationCount(t.id, NotificationCountType.Highlight) ?? 0);
|
||||
unreadMap.set(t.id, unread);
|
||||
highlightMap.set(t.id, highlight);
|
||||
|
||||
// Participant senders: root first, then timeline order, plus the last
|
||||
// replier (from the server bundle) so it's present even if not paginated
|
||||
// into the loaded timeline yet. Deduped, order preserved.
|
||||
const seen = new Set<string>();
|
||||
const parts: string[] = [];
|
||||
const push = (s?: string | null) => {
|
||||
if (s && !seen.has(s)) {
|
||||
seen.add(s);
|
||||
parts.push(s);
|
||||
}
|
||||
};
|
||||
push(t.rootEvent?.getSender());
|
||||
t.timeline.forEach((e) => push(e.getSender()));
|
||||
push(t.replyToEvent?.getSender());
|
||||
partsMap.set(t.id, parts);
|
||||
|
||||
return {
|
||||
id: t.id,
|
||||
latestTs: t.replyToEvent?.getTs() ?? t.rootEvent?.getTs() ?? 0,
|
||||
unread,
|
||||
participated: t.hasCurrentUserParticipated,
|
||||
};
|
||||
});
|
||||
const visibleSnaps = sortThreads(filterThreads(snaps, filter), sort);
|
||||
return {
|
||||
visible: visibleSnaps,
|
||||
unreadById: unreadMap,
|
||||
highlightById: highlightMap,
|
||||
participantsById: partsMap,
|
||||
threadById: byId,
|
||||
};
|
||||
}, [threads, room, filter, sort, threadNotifications]);
|
||||
|
||||
const scrollRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
|
||||
const virtualizer = useVirtualizer({
|
||||
count: visible.length,
|
||||
getScrollElement: () => scrollRef.current,
|
||||
estimateSize: () => 96,
|
||||
overscan: 8,
|
||||
getItemKey: (index) => visible[index].id,
|
||||
});
|
||||
|
||||
const totalThreads = threads.length;
|
||||
const emptyMessage =
|
||||
totalThreads === 0
|
||||
? 'No threads in this room yet. Start one from a message’s “Reply in Thread”.'
|
||||
: 'No threads match this filter.';
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.ThreadsListPanel, ContainerColor({ variant: 'Background' }))}
|
||||
shrink="No"
|
||||
direction="Column"
|
||||
>
|
||||
<Header className={css.ThreadsListHeader} variant="Background" size="600">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Icon src={Icons.Thread} size="200" />
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="H4" truncate>
|
||||
Threads
|
||||
</Text>
|
||||
<Text size="T200" truncate style={{ opacity: 0.65 }}>
|
||||
{room.name}
|
||||
</Text>
|
||||
</Box>
|
||||
<IconButton size="300" radii="300" aria-label="Close threads" onClick={onClose}>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Header>
|
||||
|
||||
<Box className={css.ThreadsListToolbar} direction="Column" gap="200">
|
||||
<Box role="group" aria-label="Filter threads" gap="100" wrap="Wrap">
|
||||
{FILTER_OPTIONS.map((opt) => (
|
||||
<SegButton
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
active={filter === opt.value}
|
||||
onClick={() => setFilter(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
<Box role="group" aria-label="Sort threads" gap="100">
|
||||
{SORT_OPTIONS.map((opt) => (
|
||||
<SegButton
|
||||
key={opt.value}
|
||||
label={opt.label}
|
||||
active={sort === opt.value}
|
||||
onClick={() => setSort(opt.value)}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box grow="Yes" style={{ minHeight: 0 }}>
|
||||
<Scroll ref={scrollRef} variant="Background" size="300" visibility="Hover" hideTrack>
|
||||
{visible.length === 0 ? (
|
||||
<Box
|
||||
direction="Column"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
gap="300"
|
||||
style={{ padding: config.space.S700, textAlign: 'center' }}
|
||||
>
|
||||
<Icon size="600" src={Icons.Thread} style={{ opacity: config.opacity.Disabled }} />
|
||||
<Text size="T300" priority="300" align="Center">
|
||||
{emptyMessage}
|
||||
</Text>
|
||||
</Box>
|
||||
) : (
|
||||
<Box className={css.ThreadsListContent} direction="Column">
|
||||
<div
|
||||
style={{ position: 'relative', height: virtualizer.getTotalSize(), width: '100%' }}
|
||||
>
|
||||
{virtualizer.getVirtualItems().map((vItem) => {
|
||||
const snap = visible[vItem.index];
|
||||
const thread = threadById.get(snap.id);
|
||||
if (!thread) return null;
|
||||
return (
|
||||
<VirtualTile
|
||||
key={snap.id}
|
||||
virtualItem={vItem}
|
||||
ref={virtualizer.measureElement}
|
||||
style={{ width: '100%', paddingBottom: config.space.S200 }}
|
||||
>
|
||||
<ThreadRow
|
||||
room={room}
|
||||
thread={thread}
|
||||
unread={unreadById.get(snap.id) ?? 0}
|
||||
highlight={highlightById.get(snap.id) ?? 0}
|
||||
participants={participantsById.get(snap.id) ?? []}
|
||||
onOpen={onOpenThread}
|
||||
/>
|
||||
</VirtualTile>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</Box>
|
||||
)}
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -4,10 +4,12 @@ import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import LotusLogo from '../../../../../public/res/Lotus.png';
|
||||
import pkg from '../../../../../package.json';
|
||||
import { clearCacheAndReload } from '../../../../client/initMatrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { getOriginBaseUrl, withOriginBaseUrl } from '../../../pages/pathUtils';
|
||||
|
||||
const LotusLogo = withOriginBaseUrl(getOriginBaseUrl(), '/Lotus.png');
|
||||
|
||||
type MSC1929Contact = {
|
||||
matrix_id?: string;
|
||||
@@ -42,9 +44,16 @@ function useServerSupport(): { support: MSC1929Support | null; loading: boolean
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
const baseUrl = mx.getHomeserverUrl();
|
||||
// MSC1929 support info is served from the MXID server-name host (like
|
||||
// /.well-known/matrix/client), which on delegated/split-domain servers is
|
||||
// NOT the client-API URL. Derive it from the user's domain.
|
||||
const serverName = mx.getDomain();
|
||||
if (!serverName) {
|
||||
setLoading(false);
|
||||
return undefined;
|
||||
}
|
||||
setLoading(true);
|
||||
fetch(`${baseUrl}/.well-known/matrix/support`, { signal: controller.signal })
|
||||
fetch(`https://${serverName}/.well-known/matrix/support`, { signal: controller.signal })
|
||||
.then((res) => {
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
|
||||
@@ -20,7 +20,11 @@ export function OidcManageAccount() {
|
||||
const open = useCallback(() => {
|
||||
const authUrl = authMetadata?.account_management_uri ?? authMetadata?.issuer;
|
||||
if (!authUrl) return;
|
||||
window.open(withSearchParam(authUrl, { action: accountManagementActions.profile }), '_blank');
|
||||
window.open(
|
||||
withSearchParam(authUrl, { action: accountManagementActions.profile }),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}, [authMetadata, accountManagementActions]);
|
||||
|
||||
if (!authMetadata) return null;
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
Spinner,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Chip,
|
||||
} from 'folds';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
@@ -56,6 +57,13 @@ import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||
import { Presence, useUserPresence } from '../../../hooks/useUserPresence';
|
||||
import { ProfileDecoration } from './ProfileDecoration';
|
||||
import { EmojiBoard } from '../../../components/emoji-board';
|
||||
import { useStatusPresets } from '../../../hooks/useStatusPresets';
|
||||
import {
|
||||
BUILT_IN_STATUS_PRESETS,
|
||||
StatusPreset,
|
||||
makePresetId,
|
||||
normalizeLabel,
|
||||
} from '../../../utils/statusPresets';
|
||||
|
||||
type ProfileProps = {
|
||||
profile: UserProfile;
|
||||
@@ -355,14 +363,26 @@ function ProfileStatus() {
|
||||
const [presenceStatus] = useSetting(settingsAtom, 'presenceStatus');
|
||||
const [hidePresence] = useSetting(settingsAtom, 'hidePresence');
|
||||
|
||||
const [statusMsg, setStatusMsg] = useState<string>(
|
||||
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
|
||||
);
|
||||
const initialStatus = presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '';
|
||||
const [statusMsg, setStatusMsg] = useState<string>(initialStatus);
|
||||
// True while the user has unsaved local edits — prevents a server presence
|
||||
// echo from overwriting what the user is currently typing/inserting.
|
||||
const statusDirtyRef = useRef(false);
|
||||
// The last remote status we synced into the input. Presence heartbeats fire
|
||||
// every few seconds carrying the SAME status; the sync effect must only react
|
||||
// when the remote value actually changes, otherwise a repeated heartbeat can
|
||||
// overwrite an unsaved local edit (e.g. an emoji just inserted) the instant
|
||||
// the dirty flag is out of sync. Seeded with the value the input started on.
|
||||
const lastSyncedRemoteRef = useRef<string>(initialStatus);
|
||||
// The value we most recently applied (with the apply time). A presence
|
||||
// heartbeat can echo the PREVIOUS status just after we save the new one; that
|
||||
// stale echo would otherwise revert the input. We ignore non-matching echoes
|
||||
// until our own echo lands or a short window elapses (bounded so a genuinely
|
||||
// dropped echo can't block real cross-device updates forever).
|
||||
const pendingAppliedRef = useRef<{ value: string; ts: number } | null>(null);
|
||||
const [clearAfter, setClearAfter] = useState('0');
|
||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||
const { presets, addPreset, removePreset } = useStatusPresets();
|
||||
|
||||
// Sync input when another device changes the status.
|
||||
// Skipped while the user has unsaved local edits to avoid clobbering
|
||||
@@ -375,6 +395,20 @@ function ProfileStatus() {
|
||||
// wipe the saved status on every invisible toggle.
|
||||
if (presence.presence === Presence.Offline) return;
|
||||
const remoteStatus = presence.status ?? '';
|
||||
// Only act on an actual remote change. Repeated heartbeats carrying the same
|
||||
// status are ignored so they can never clobber an unsaved local edit.
|
||||
if (remoteStatus === lastSyncedRemoteRef.current) return;
|
||||
lastSyncedRemoteRef.current = remoteStatus;
|
||||
const pending = pendingAppliedRef.current;
|
||||
if (pending) {
|
||||
if (remoteStatus === pending.value) {
|
||||
pendingAppliedRef.current = null; // our own echo landed — accept it
|
||||
} else if (Date.now() - pending.ts < 15_000) {
|
||||
return; // stale echo of the previous status; ignore within the window
|
||||
} else {
|
||||
pendingAppliedRef.current = null; // window elapsed — accept external changes
|
||||
}
|
||||
}
|
||||
if (remoteStatus) {
|
||||
setStatusMsg(remoteStatus);
|
||||
localStorage.setItem(STATUS_MSG_KEY(userId), remoteStatus);
|
||||
@@ -412,32 +446,68 @@ function ProfileStatus() {
|
||||
setStatusMsg(evt.currentTarget.value);
|
||||
};
|
||||
|
||||
// Save a status message + auto-clear timer. Shared by the Save button and the
|
||||
// one-click presets so all three go through exactly the same server write and
|
||||
// localStorage bookkeeping.
|
||||
const applyStatus = useCallback(
|
||||
(rawMsg: string, clearAfterValue: string) => {
|
||||
statusDirtyRef.current = false;
|
||||
const msg = rawMsg.trim();
|
||||
// Guard against a stale presence echo reverting this value (see the sync effect).
|
||||
pendingAppliedRef.current = { value: msg, ts: Date.now() };
|
||||
saveStatus(msg).catch(() => undefined);
|
||||
|
||||
if (msg) {
|
||||
localStorage.setItem(STATUS_MSG_KEY(userId), msg);
|
||||
} else {
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
}
|
||||
|
||||
const delayMs = getMsFromOption(clearAfterValue);
|
||||
if (msg && delayMs > 0) {
|
||||
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
||||
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
|
||||
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(Date.now() + delayMs));
|
||||
} else {
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
}
|
||||
},
|
||||
[saveStatus, userId],
|
||||
);
|
||||
|
||||
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||
evt.preventDefault();
|
||||
if (saving) return;
|
||||
statusDirtyRef.current = false;
|
||||
const msg = statusMsg.trim();
|
||||
saveStatus(msg).catch(() => undefined);
|
||||
|
||||
if (msg) {
|
||||
localStorage.setItem(STATUS_MSG_KEY(userId), msg);
|
||||
} else {
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
}
|
||||
|
||||
const delayMs = getMsFromOption(clearAfter);
|
||||
if (msg && delayMs > 0) {
|
||||
// Persist the expiry timestamp; the always-mounted StatusExpiryMonitor
|
||||
// (ClientNonUIFeatures) fires the auto-clear even when Settings is closed.
|
||||
const ts = Date.now() + delayMs;
|
||||
localStorage.setItem(STATUS_EXPIRY_KEY(userId), String(ts));
|
||||
} else {
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
}
|
||||
applyStatus(statusMsg, clearAfter);
|
||||
};
|
||||
|
||||
// Preset click = one-click apply: reflect it in the inputs and save immediately.
|
||||
const applyPreset = useCallback(
|
||||
(preset: StatusPreset) => {
|
||||
if (saving) return;
|
||||
setStatusMsg(preset.label);
|
||||
setClearAfter(preset.clearAfter);
|
||||
applyStatus(preset.label, preset.clearAfter);
|
||||
},
|
||||
[saving, applyStatus],
|
||||
);
|
||||
|
||||
const handleSaveCurrent = useCallback(() => {
|
||||
const label = statusMsg.trim();
|
||||
if (!label) return;
|
||||
addPreset({ id: makePresetId(), label, clearAfter }).catch(() => undefined);
|
||||
}, [statusMsg, clearAfter, addPreset]);
|
||||
|
||||
// Hide any saved preset that duplicates a built-in (it already shows under
|
||||
// "Quick statuses"), so the same status can't appear as two chips.
|
||||
const customPresets = useMemo(() => {
|
||||
const builtInLabels = new Set(BUILT_IN_STATUS_PRESETS.map((p) => normalizeLabel(p.label)));
|
||||
return presets.filter((p) => !builtInLabels.has(normalizeLabel(p.label)));
|
||||
}, [presets]);
|
||||
|
||||
const handleClear = () => {
|
||||
statusDirtyRef.current = false;
|
||||
pendingAppliedRef.current = { value: '', ts: Date.now() };
|
||||
setStatusMsg('');
|
||||
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||
@@ -463,7 +533,83 @@ function ProfileStatus() {
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box direction="Column" grow="Yes" gap="100">
|
||||
<Box direction="Column" grow="Yes" gap="200">
|
||||
{/* Quick statuses — built-in presets, one click applies message + timer */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text id="status-quick-heading" size="T200" priority="300">
|
||||
Quick statuses
|
||||
</Text>
|
||||
<Box gap="100" wrap="Wrap" role="group" aria-labelledby="status-quick-heading">
|
||||
{BUILT_IN_STATUS_PRESETS.map((preset) => (
|
||||
<Chip
|
||||
key={preset.id}
|
||||
type="button"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
disabled={saving}
|
||||
onClick={() => applyPreset(preset)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
{preset.label}
|
||||
</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Your presets — saved from the current status; synced via account data */}
|
||||
<Box direction="Column" gap="100">
|
||||
<Text id="status-custom-heading" size="T200" priority="300">
|
||||
Your presets
|
||||
</Text>
|
||||
<Box gap="200" wrap="Wrap" role="group" aria-labelledby="status-custom-heading">
|
||||
{customPresets.map((preset) => (
|
||||
// gap="0" keeps the chip and its delete X reading as one unit; the
|
||||
// parent row's larger gap="200" separates one preset from the next.
|
||||
<Box key={preset.id} alignItems="Center" gap="0">
|
||||
<Chip
|
||||
type="button"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
disabled={saving}
|
||||
onClick={() => applyPreset(preset)}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
{preset.label}
|
||||
</Text>
|
||||
</Chip>
|
||||
<IconButton
|
||||
type="button"
|
||||
size="300"
|
||||
radii="Pill"
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
aria-label={`Delete preset ${preset.label}`}
|
||||
onClick={() => removePreset(preset.id).catch(() => undefined)}
|
||||
>
|
||||
<Icon size="100" src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
))}
|
||||
<Chip
|
||||
type="button"
|
||||
variant="Success"
|
||||
fill="Soft"
|
||||
radii="Pill"
|
||||
outlined
|
||||
disabled={saving || !statusMsg.trim()}
|
||||
onClick={handleSaveCurrent}
|
||||
before={<Icon size="100" src={Icons.Plus} />}
|
||||
>
|
||||
<Text as="span" size="B300">
|
||||
Save current
|
||||
</Text>
|
||||
</Chip>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center" aria-disabled={saving}>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Input
|
||||
@@ -502,6 +648,11 @@ function ProfileStatus() {
|
||||
imagePackRooms={[]}
|
||||
returnFocusOnDeactivate={false}
|
||||
onEmojiSelect={handleEmojiSelect}
|
||||
// A status message is plain-text presence (`status_msg`), so it
|
||||
// can't hold a custom mxc-image emoji — show unicode only, so every
|
||||
// emoji in the picker actually inserts (custom ones silently no-op'd
|
||||
// because there's no onCustomEmojiSelect here).
|
||||
hideCustomEmojis
|
||||
requestClose={() => setEmojiAnchor(undefined)}
|
||||
/>
|
||||
}
|
||||
|
||||
@@ -4,11 +4,7 @@ import { Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import {
|
||||
DECORATION_CATEGORIES,
|
||||
DECORATION_CDN,
|
||||
decorationUrl,
|
||||
} from '../../lotus/avatarDecorations';
|
||||
import { DECORATION_CATEGORIES, decorationUrl } from '../../lotus/avatarDecorations';
|
||||
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||
|
||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||
@@ -48,7 +44,7 @@ function DecorationPreviewCell({
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`${DECORATION_CDN}/${slug}.png`}
|
||||
src={decorationUrl(slug)}
|
||||
alt={name}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
@@ -73,11 +69,11 @@ export function ProfileDecoration() {
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Fetch the whole profile, not the `/{field}` sub-resource: an unset field
|
||||
// 404s (a console error for anyone without a decoration). The full profile
|
||||
// returns 200 with all fields incl. custom MSC4133 ones — read it out.
|
||||
mx.http
|
||||
.authedRequest<Record<string, string>>(
|
||||
Method.Get,
|
||||
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
|
||||
)
|
||||
.authedRequest<Record<string, string>>(Method.Get, `/profile/${encodeURIComponent(userId)}`)
|
||||
.then((res) => {
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
setCurrent(val);
|
||||
|
||||
@@ -36,9 +36,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) {
|
||||
return (
|
||||
<AccountDataEditor
|
||||
type={accountDataType ?? undefined}
|
||||
content={
|
||||
accountDataType ? getAccountData<object>(mx, accountDataType) : undefined
|
||||
}
|
||||
content={accountDataType ? getAccountData<object>(mx, accountDataType) : undefined}
|
||||
submitChange={submitAccountData}
|
||||
requestClose={() => setAccountDataType(undefined)}
|
||||
/>
|
||||
|
||||
@@ -38,6 +38,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
action: accountManagementActions.sessionsList,
|
||||
}),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
}, [authMetadata, accountManagementActions]);
|
||||
|
||||
@@ -52,6 +53,7 @@ export function OtherDevices({ devices, refreshDeviceList, showVerification }: O
|
||||
device_id: deviceId,
|
||||
}),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
},
|
||||
[authMetadata, accountManagementActions],
|
||||
|
||||
@@ -278,6 +278,7 @@ export function DeviceVerificationOptions() {
|
||||
action: accountManagementActions.crossSigningReset,
|
||||
}),
|
||||
'_blank',
|
||||
'noopener,noreferrer',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -129,6 +129,11 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
try {
|
||||
const ctx = new AudioContext({ sampleRate: sampleRateFor(model) });
|
||||
const stream = await navigator.mediaDevices.getUserMedia(MIC_CONSTRAINTS(nativeNS));
|
||||
if (!mountedRef.current) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
ctx.close().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const inAnalyser = ctx.createAnalyser();
|
||||
inAnalyser.fftSize = 1024;
|
||||
@@ -182,7 +187,12 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
timer: number;
|
||||
} | null>(null);
|
||||
const clipRef = useRef<AudioBuffer | null>(null);
|
||||
const playRef = useRef<{ ctx: AudioContext; source: AudioBufferSourceNode } | null>(null);
|
||||
const playRef = useRef<{
|
||||
ctx: AudioContext;
|
||||
source: AudioBufferSourceNode;
|
||||
model: DenoiseNode | null;
|
||||
gate: AudioWorkletNode | null;
|
||||
} | null>(null);
|
||||
const [recording, setRecording] = useState(false);
|
||||
const [recDb, setRecDb] = useState(-100);
|
||||
const [hasClip, setHasClip] = useState(false);
|
||||
@@ -208,6 +218,10 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
const startRecord = async () => {
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia(RAW_CONSTRAINTS);
|
||||
if (!mountedRef.current) {
|
||||
stream.getTracks().forEach((t) => t.stop());
|
||||
return;
|
||||
}
|
||||
const ctx = new AudioContext();
|
||||
const source = ctx.createMediaStreamSource(stream);
|
||||
const analyser = ctx.createAnalyser();
|
||||
@@ -249,7 +263,15 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
}
|
||||
};
|
||||
|
||||
// Bumped whenever a playback starts or stops. An in-flight `play()` compares
|
||||
// the generation it claimed against this after its awaits; if it no longer
|
||||
// matches (a newer play, a Stop, or unmount happened during model load) it
|
||||
// discards what it built instead of orphaning it — closes the rapid-click /
|
||||
// unmount-during-load leak.
|
||||
const playGenRef = useRef(0);
|
||||
|
||||
const stopPlayback = useCallback(() => {
|
||||
playGenRef.current += 1;
|
||||
const p = playRef.current;
|
||||
playRef.current = null;
|
||||
if (p) {
|
||||
@@ -259,6 +281,15 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
try {
|
||||
// Mirror stopLive: dispose the model node (worker/WASM) + gate, else each
|
||||
// A/B playback through a model leaks a DeepFilterNet/DTLN worker.
|
||||
p.gate?.disconnect();
|
||||
p.model?.dispose();
|
||||
p.model?.node.disconnect();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
p.ctx.close().catch(() => undefined);
|
||||
}
|
||||
setPlaying(null);
|
||||
@@ -268,37 +299,73 @@ export function DenoiseTester({ model, useGate, gateThreshold, nativeNS }: Denoi
|
||||
stopPlayback();
|
||||
const clip = clipRef.current;
|
||||
if (!clip) return;
|
||||
// Claim this generation AFTER stopPlayback's bump; a later play/stop/unmount
|
||||
// moves it past `gen`, signalling us to discard what we built below.
|
||||
const gen = playGenRef.current;
|
||||
try {
|
||||
// bufferSource auto-resamples the 48 kHz clip to the context rate, so DTLN
|
||||
// gets the 16 kHz it needs while raw/RNNoise/Speex stay at 48 kHz.
|
||||
const ctx = new AudioContext({ sampleRate: sampleRateFor(playModel ?? 'rnnoise') });
|
||||
const source = ctx.createBufferSource();
|
||||
source.buffer = clip;
|
||||
let playGate: AudioWorkletNode | null = null;
|
||||
let playModelNode: DenoiseNode | null = null;
|
||||
if (playModel) {
|
||||
let head: AudioNode = source;
|
||||
if (useGate) {
|
||||
const gate = await buildGateNode(ctx, gateThreshold);
|
||||
head.connect(gate);
|
||||
head = gate;
|
||||
playGate = await buildGateNode(ctx, gateThreshold);
|
||||
head.connect(playGate);
|
||||
head = playGate;
|
||||
}
|
||||
const denoise = await buildModelNode(ctx, playModel);
|
||||
head.connect(denoise.node);
|
||||
denoise.node.connect(ctx.destination);
|
||||
playModelNode = await buildModelNode(ctx, playModel);
|
||||
head.connect(playModelNode.node);
|
||||
playModelNode.node.connect(ctx.destination);
|
||||
} else {
|
||||
source.connect(ctx.destination);
|
||||
}
|
||||
// Superseded while the WASM/worklet loaded (another Play, a Stop, or the
|
||||
// panel unmounted)? Tear down this now-orphaned graph instead of storing
|
||||
// it — otherwise its worker/WASM + ctx would leak and its audio would play
|
||||
// over the winner.
|
||||
if (playGenRef.current !== gen || !mountedRef.current) {
|
||||
try {
|
||||
playGate?.disconnect();
|
||||
playModelNode?.dispose();
|
||||
playModelNode?.node.disconnect();
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
ctx.close().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
source.onended = () => {
|
||||
if (playRef.current?.ctx === ctx) stopPlayback();
|
||||
};
|
||||
playRef.current = { ctx, source };
|
||||
playRef.current = { ctx, source, model: playModelNode, gate: playGate };
|
||||
source.start();
|
||||
setPlaying(label);
|
||||
} catch (e) {
|
||||
console.error('[denoise-tester] playback failed', e);
|
||||
stopPlayback();
|
||||
// Only tear down if we're still the current playback — a superseded
|
||||
// invocation must not stop the winner that replaced it.
|
||||
if (playGenRef.current === gen) stopPlayback();
|
||||
}
|
||||
};
|
||||
|
||||
// Guards the async getUserMedia paths: if Settings closes while the mic
|
||||
// permission prompt is open, the resolved stream/ctx would otherwise be
|
||||
// created after the unmount cleanup already ran, leaking + setState-after-
|
||||
// unmount. Own [] effect so it only flips on real unmount.
|
||||
const mountedRef = useRef(true);
|
||||
useEffect(() => {
|
||||
// Set on mount (not just cleared on unmount) so a setup→cleanup→setup
|
||||
// remount of the same fiber (StrictMode/Activity) leaves it true.
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
stopLive();
|
||||
|
||||
@@ -96,10 +96,17 @@ import {
|
||||
useThemes,
|
||||
} from '../../../hooks/useTheme';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
import { pickerInputStyle } from '../../../utils/datetimeInput';
|
||||
import { BG_OPTIONS, getChatBg } from '../../lotus/chatBackground';
|
||||
import { resetBootSequence, runLotusBootSequence } from '../../../../lotus-boot';
|
||||
import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||
import {
|
||||
TRANSLATE_TARGET_LANGUAGES,
|
||||
normalizeLang,
|
||||
isSupportedTargetLang,
|
||||
} from '../../../utils/translation/langUtils';
|
||||
import { chromeTranslationEngine } from '../../../utils/translation/chromeEngine';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||
import { isTauri as isTauriEnv, invokeTauri, tauriInvoke } from '../../../hooks/useTauri';
|
||||
@@ -435,6 +442,12 @@ function Appearance() {
|
||||
const [lotusTerminal, setLotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const [nightLightEnabled, setNightLightEnabled] = useSetting(settingsAtom, 'nightLightEnabled');
|
||||
const [nightLightOpacity, setNightLightOpacity] = useSetting(settingsAtom, 'nightLightOpacity');
|
||||
const [nightLightSchedule, setNightLightSchedule] = useSetting(
|
||||
settingsAtom,
|
||||
'nightLightSchedule',
|
||||
);
|
||||
const [nightLightStart, setNightLightStart] = useSetting(settingsAtom, 'nightLightStart');
|
||||
const [nightLightEnd, setNightLightEnd] = useSetting(settingsAtom, 'nightLightEnd');
|
||||
const [glassmorphismSidebar, setGlassmorphismSidebar] = useSetting(
|
||||
settingsAtom,
|
||||
'glassmorphismSidebar',
|
||||
@@ -536,7 +549,11 @@ function Appearance() {
|
||||
value={seasonalThemeOverride ?? 'auto'}
|
||||
onChange={(v) => {
|
||||
setSeasonalThemeOverride(v);
|
||||
if (v !== 'auto' && v !== 'off') setChatBackground('none');
|
||||
// Any active seasonal mode (incl. "auto") is mutually exclusive
|
||||
// with a chat background — else picking it is a silent no-op, since
|
||||
// SeasonalEffect suppresses the overlay while a background is set.
|
||||
// Only "off" leaves the background alone.
|
||||
if (v !== 'off') setChatBackground('none');
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
@@ -567,23 +584,71 @@ function Appearance() {
|
||||
{nightLightEnabled && (
|
||||
<Box
|
||||
direction="Column"
|
||||
gap="100"
|
||||
gap="300"
|
||||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||
>
|
||||
<Text size="T200" priority="300">
|
||||
Intensity: {nightLightOpacity}%
|
||||
</Text>
|
||||
<input
|
||||
aria-label="Night light intensity"
|
||||
type="range"
|
||||
min={5}
|
||||
max={80}
|
||||
value={nightLightOpacity}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setNightLightOpacity(parseInt(e.target.value, 10))
|
||||
}
|
||||
style={{ width: '100%', accentColor: color.Primary.Main }}
|
||||
/>
|
||||
<Box direction="Column" gap="100">
|
||||
<Text size="T200" priority="300">
|
||||
Intensity: {nightLightOpacity}%
|
||||
</Text>
|
||||
<input
|
||||
aria-label="Night light intensity"
|
||||
type="range"
|
||||
min={5}
|
||||
max={80}
|
||||
value={nightLightOpacity}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
|
||||
setNightLightOpacity(parseInt(e.target.value, 10))
|
||||
}
|
||||
style={{ width: '100%', accentColor: color.Primary.Main }}
|
||||
/>
|
||||
</Box>
|
||||
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
|
||||
<Box direction="Column">
|
||||
<Text size="T300">Schedule</Text>
|
||||
<Text size="T200" priority="300">
|
||||
Only tint during the hours below
|
||||
</Text>
|
||||
</Box>
|
||||
<Switch
|
||||
variant="Primary"
|
||||
value={nightLightSchedule}
|
||||
onChange={setNightLightSchedule}
|
||||
/>
|
||||
</Box>
|
||||
{nightLightSchedule && (
|
||||
<Box gap="200">
|
||||
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
||||
<Text as="label" htmlFor="night-light-start" size="T200" priority="400">
|
||||
From
|
||||
</Text>
|
||||
<input
|
||||
id="night-light-start"
|
||||
type="time"
|
||||
value={nightLightStart}
|
||||
onChange={(e) => setNightLightStart(e.target.value)}
|
||||
style={pickerInputStyle(color, config)}
|
||||
/>
|
||||
</Box>
|
||||
<Box direction="Column" gap="100" style={{ flex: 1 }}>
|
||||
<Text as="label" htmlFor="night-light-end" size="T200" priority="400">
|
||||
To
|
||||
</Text>
|
||||
<input
|
||||
id="night-light-end"
|
||||
type="time"
|
||||
value={nightLightEnd}
|
||||
onChange={(e) => setNightLightEnd(e.target.value)}
|
||||
style={pickerInputStyle(color, config)}
|
||||
/>
|
||||
</Box>
|
||||
</Box>
|
||||
)}
|
||||
{nightLightSchedule && nightLightStart === nightLightEnd && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Start and end times are the same, so the tint never turns on. Set different times.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</SequenceCard>
|
||||
@@ -1837,27 +1902,27 @@ function Calls() {
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Ringtone Volume"
|
||||
description="Volume of the incoming call ringtone."
|
||||
after={
|
||||
<Box direction="Row" alignItems="Center" gap="200" style={{ minWidth: '160px' }}>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={ringtoneVolume}
|
||||
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
|
||||
aria-label="Ringtone volume"
|
||||
style={{ flex: 1, accentColor: color.Primary.Main }}
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
|
||||
{ringtoneVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<SettingTile title="Ringtone Volume" description="Volume of the incoming call ringtone." />
|
||||
<Box
|
||||
direction="Row"
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
step="5"
|
||||
value={ringtoneVolume}
|
||||
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
|
||||
aria-label="Ringtone volume"
|
||||
style={{ flex: 1, accentColor: color.Primary.Main }}
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
|
||||
{ringtoneVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
@@ -1929,26 +1994,28 @@ function Calls() {
|
||||
}
|
||||
/>
|
||||
{soundboardEnabled && (
|
||||
<SettingTile
|
||||
title="Soundboard Volume"
|
||||
after={
|
||||
<Box alignItems="Center" gap="200" style={{ minWidth: toRem(180) }}>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={soundboardVolume}
|
||||
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
|
||||
style={{ flexGrow: 1 }}
|
||||
aria-label="Soundboard volume"
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
|
||||
{soundboardVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<>
|
||||
<SettingTile title="Soundboard Volume" />
|
||||
<Box
|
||||
alignItems="Center"
|
||||
gap="200"
|
||||
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||
>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={100}
|
||||
step={5}
|
||||
value={soundboardVolume}
|
||||
onChange={(e) => setSoundboardVolume(parseInt(e.target.value, 10))}
|
||||
style={{ flexGrow: 1 }}
|
||||
aria-label="Soundboard volume"
|
||||
/>
|
||||
<Text size="T200" style={{ minWidth: toRem(36), textAlign: 'right' }}>
|
||||
{soundboardVolume}%
|
||||
</Text>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
@@ -2256,6 +2323,15 @@ function Messages() {
|
||||
settingsAtom,
|
||||
'enforceRetentionLocally',
|
||||
);
|
||||
const [translateTargetLang, setTranslateTargetLang] = useSetting(
|
||||
settingsAtom,
|
||||
'translateTargetLang',
|
||||
);
|
||||
const [autoTranslate, setAutoTranslate] = useSetting(settingsAtom, 'autoTranslate');
|
||||
const translationSupported = chromeTranslationEngine.isSupported();
|
||||
const selectedTargetLang = isSupportedTargetLang(translateTargetLang)
|
||||
? normalizeLang(translateTargetLang)
|
||||
: 'en';
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -2375,6 +2451,32 @@ function Messages() {
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Translate Messages Into"
|
||||
description={
|
||||
translationSupported
|
||||
? 'Translate messages written in other languages into this one. Runs entirely on your device — message text never leaves your computer or touches a cloud service.'
|
||||
: 'On-device translation isn’t available in this browser. Use a Chromium desktop browser (Chrome/Edge 138+) or the Lotus desktop app.'
|
||||
}
|
||||
after={
|
||||
<SettingsSelect
|
||||
value={selectedTargetLang}
|
||||
onChange={(v) => setTranslateTargetLang(v)}
|
||||
disabled={!translationSupported}
|
||||
aria-label="Translate messages into"
|
||||
options={TRANSLATE_TARGET_LANGUAGES.map((l) => ({ value: l.code, label: l.name }))}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
{translationSupported && (
|
||||
<SettingTile
|
||||
title="Auto-translate Incoming Messages"
|
||||
description="Automatically translate messages that aren’t already in your language. When off, translate individual messages from their menu. The first time a language is used, its model downloads once (a few MB)."
|
||||
after={<Switch variant="Primary" value={autoTranslate} onChange={setAutoTranslate} />}
|
||||
/>
|
||||
)}
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
|
||||
import React, {
|
||||
ChangeEventHandler,
|
||||
FormEventHandler,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
|
||||
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
|
||||
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
|
||||
@@ -56,6 +63,13 @@ function RuleEnableToggle({ kind, pushRule }: RuleEnableToggleProps) {
|
||||
const mx = useMatrixClient();
|
||||
const [enabled, setEnabled] = useState(pushRule.enabled !== false);
|
||||
|
||||
// Re-sync when the rule changes externally (e.g. toggled on another device →
|
||||
// account-data sync). The useState initializer only runs once, so without
|
||||
// this the Switch would show a stale value.
|
||||
useEffect(() => {
|
||||
setEnabled(pushRule.enabled !== false);
|
||||
}, [pushRule.enabled]);
|
||||
|
||||
const [toggleState, toggle] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (value: boolean) => {
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { Box, Text, Switch, Button, color, config, Spinner } from 'folds';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text, Switch, Button, Chip, Icon, Icons, color, config, Spinner } from 'folds';
|
||||
import { IPusherRequest } from 'matrix-js-sdk';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { NOTIFICATION_SOUND_MAP } from '../../../utils/notificationSounds';
|
||||
import { SequenceCard } from '../../../components/sequence-card';
|
||||
import { SequenceCardStyle } from '../styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useSetting } from '../../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../../state/settings';
|
||||
import {
|
||||
isSnoozeActive,
|
||||
nextTimeAtHour,
|
||||
notificationSnoozeUntilAtom,
|
||||
SNOOZE_INDEFINITE,
|
||||
} from '../../../state/notificationSnooze';
|
||||
import { formatFriendlyDateTime } from '../../../utils/datetimeInput';
|
||||
import { getNotificationState, usePermissionState } from '../../../hooks/usePermission';
|
||||
import { useEmailNotifications } from '../../../hooks/useEmailNotifications';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
@@ -120,6 +128,70 @@ const selectStyle: React.CSSProperties = {
|
||||
outline: 'none',
|
||||
};
|
||||
|
||||
const SNOOZE_PRESETS: Array<{ label: string; resolve: (now: number) => number }> = [
|
||||
{ label: '30 minutes', resolve: (now) => now + 30 * 60_000 },
|
||||
{ label: '1 hour', resolve: (now) => now + 60 * 60_000 },
|
||||
{ label: '4 hours', resolve: (now) => now + 4 * 60 * 60_000 },
|
||||
{ label: 'Until 8 AM', resolve: (now) => nextTimeAtHour(8, now) },
|
||||
{ label: 'Until I resume', resolve: () => SNOOZE_INDEFINITE },
|
||||
];
|
||||
|
||||
// Cross-platform "pause notifications" — sets a snooze instant that the
|
||||
// notification gate (ClientNonUIFeatures) reads to suppress alerts + sounds.
|
||||
function PauseNotifications() {
|
||||
const snoozeUntil = useAtomValue(notificationSnoozeUntilAtom);
|
||||
const setSnoozeUntil = useSetAtom(notificationSnoozeUntilAtom);
|
||||
// While paused, tick so the status flips to "on" the moment the snooze lapses.
|
||||
const [, setTick] = useState(0);
|
||||
const active = isSnoozeActive(snoozeUntil);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return undefined;
|
||||
const id = setInterval(() => setTick((n) => n + 1), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [active]);
|
||||
|
||||
const status = !active
|
||||
? 'Notifications are on.'
|
||||
: snoozeUntil >= SNOOZE_INDEFINITE
|
||||
? 'Paused until you resume.'
|
||||
: `Paused until ${formatFriendlyDateTime(snoozeUntil)}.`;
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title="Pause Notifications"
|
||||
description={<span style={active ? { color: color.Warning.Main } : undefined}>{status}</span>}
|
||||
after={
|
||||
active ? (
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
onClick={() => setSnoozeUntil(0)}
|
||||
before={<Icon size="100" src={Icons.BellRing} />}
|
||||
>
|
||||
<Text size="B300">Resume</Text>
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
<Box gap="200" wrap="Wrap" style={{ marginTop: config.space.S200 }}>
|
||||
{SNOOZE_PRESETS.map((preset) => (
|
||||
<Chip
|
||||
key={preset.label}
|
||||
variant="SurfaceVariant"
|
||||
radii="Pill"
|
||||
onClick={() => setSnoozeUntil(preset.resolve(Date.now()))}
|
||||
>
|
||||
<Text size="B300">{preset.label}</Text>
|
||||
</Chip>
|
||||
))}
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
|
||||
export function SystemNotification() {
|
||||
const notifPermission = usePermissionState('notifications', getNotificationState());
|
||||
const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications');
|
||||
@@ -174,6 +246,14 @@ export function SystemNotification() {
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
direction="Column"
|
||||
gap="400"
|
||||
>
|
||||
<PauseNotifications />
|
||||
</SequenceCard>
|
||||
<SequenceCard
|
||||
className={SequenceCardStyle}
|
||||
variant="SurfaceVariant"
|
||||
@@ -301,6 +381,11 @@ export function SystemNotification() {
|
||||
style={selectStyle}
|
||||
/>
|
||||
</Box>
|
||||
{(!quietHoursStart || !quietHoursEnd) && (
|
||||
<Text size="T200" style={{ color: color.Critical.Main }}>
|
||||
Set both a start and end time — quiet hours stay inactive until both are filled in.
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</SequenceCard>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useRef, CSSProperties } from 'react';
|
||||
import { useAtomValue, useSetAtom } from 'jotai';
|
||||
import { color, config, Icon, IconButton, Icons } from 'folds';
|
||||
import { ScreenSize, useScreenSize } from '../../hooks/useScreenSize';
|
||||
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
@@ -37,6 +38,7 @@ function ToastCard({ toast }: ToastCardProps) {
|
||||
// folds tokens so toasts render correctly on stock Cinny themes (the --lt-*
|
||||
// vars only exist while Terminal mode is active).
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -80,8 +82,11 @@ function ToastCard({ toast }: ToastCardProps) {
|
||||
}`,
|
||||
borderRadius: config.radii.R400,
|
||||
padding: `${config.space.S300} ${config.space.S400}`,
|
||||
minWidth: '280px',
|
||||
maxWidth: '340px',
|
||||
// Full-width on phones (the container spans the viewport there); a fixed
|
||||
// 280-340px card would otherwise overflow a narrow screen.
|
||||
minWidth: isMobile ? 0 : '280px',
|
||||
maxWidth: isMobile ? 'none' : '340px',
|
||||
width: isMobile ? '100%' : undefined,
|
||||
boxShadow: lotusTerminal
|
||||
? toast.sticky
|
||||
? 'var(--lt-box-glow-cyan)'
|
||||
@@ -216,22 +221,39 @@ export function LotusToastContainer() {
|
||||
}, []);
|
||||
|
||||
const toasts = useAtomValue(toastQueueAtom);
|
||||
const isMobile = useScreenSize() === ScreenSize.Mobile;
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// The newest toast is the last (bottom) child; if the stack ever overflows its
|
||||
// max-height (many sticky action toasts), keep that newest one in view instead
|
||||
// of leaving it scrolled below the fold.
|
||||
useEffect(() => {
|
||||
const el = listRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [toasts.length]);
|
||||
|
||||
if (toasts.length === 0) return null;
|
||||
|
||||
const containerStyle: CSSProperties = {
|
||||
position: 'fixed',
|
||||
bottom: '1.5rem',
|
||||
right: '1.5rem',
|
||||
// Span the width just inside the screen edges on a phone (so full-width
|
||||
// cards fit); float bottom-right on desktop.
|
||||
bottom: isMobile ? config.space.S200 : '1.5rem',
|
||||
right: isMobile ? config.space.S200 : '1.5rem',
|
||||
left: isMobile ? config.space.S200 : undefined,
|
||||
zIndex: zIndices.toast,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: config.space.S200,
|
||||
pointerEvents: 'auto',
|
||||
// Safety net beyond the queue cap: if many sticky action toasts pile up they
|
||||
// scroll within a bounded height instead of covering the whole screen.
|
||||
maxHeight: isMobile ? '70vh' : '80vh',
|
||||
overflowY: 'auto',
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={containerStyle} aria-live="polite" aria-label="Notifications">
|
||||
<div ref={listRef} style={containerStyle} aria-live="polite" aria-label="Notifications">
|
||||
{toasts.map((toast) => (
|
||||
<ToastCard key={toast.id} toast={toast} />
|
||||
))}
|
||||
|
||||
@@ -6,9 +6,7 @@ import { getAccountData } from '../utils/room';
|
||||
|
||||
export function useAccountData(eventType: string): MatrixEvent | undefined {
|
||||
const mx = useMatrixClient();
|
||||
const [event, setEvent] = useState<MatrixEvent | undefined>(() =>
|
||||
getAccountData(mx, eventType),
|
||||
);
|
||||
const [event, setEvent] = useState<MatrixEvent | undefined>(() => getAccountData(mx, eventType));
|
||||
|
||||
useAccountDataCallback(
|
||||
mx,
|
||||
|
||||
@@ -12,6 +12,22 @@ const pending = new Map<string, Array<(val: string | null) => void>>();
|
||||
// Transient-failure attempt counts (userId → n) so a flaky federated lookup
|
||||
// can retry a couple of times, then gives up for the session.
|
||||
const failures = new Map<string, number>();
|
||||
// Mounted hooks per userId, so an invalidation (e.g. you change your own
|
||||
// decoration) re-fetches live instead of waiting for a remount.
|
||||
const listeners = new Map<string, Set<() => void>>();
|
||||
|
||||
function subscribeDecoration(userId: string, cb: () => void): () => void {
|
||||
let set = listeners.get(userId);
|
||||
if (!set) {
|
||||
set = new Set();
|
||||
listeners.set(userId, set);
|
||||
}
|
||||
set.add(cb);
|
||||
return () => {
|
||||
set.delete(cb);
|
||||
if (set.size === 0) listeners.delete(userId);
|
||||
};
|
||||
}
|
||||
|
||||
function fetchDecoration(
|
||||
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
||||
@@ -66,6 +82,10 @@ function fetchDecoration(
|
||||
|
||||
export function invalidateDecorationCache(userId: string): void {
|
||||
cache.delete(userId);
|
||||
// Also clear the give-up counter so the next fetch starts fresh.
|
||||
failures.delete(userId);
|
||||
// Notify mounted avatars for this user so they re-fetch immediately.
|
||||
listeners.get(userId)?.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
export function useAvatarDecoration(userId: string): string | null {
|
||||
@@ -74,14 +94,22 @@ export function useAvatarDecoration(userId: string): string | null {
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchDecoration(
|
||||
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
|
||||
userId,
|
||||
).then((val) => {
|
||||
if (!cancelled) setSlug(val);
|
||||
});
|
||||
const load = () => {
|
||||
fetchDecoration(
|
||||
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
|
||||
userId,
|
||||
).then((val) => {
|
||||
if (!cancelled) setSlug(val);
|
||||
});
|
||||
};
|
||||
load();
|
||||
// Re-run on invalidation (fetchDecoration re-fetches since the cache entry
|
||||
// was cleared; concurrent mounts for the same user still de-dupe via
|
||||
// `pending`). Keeps live avatars in sync when the decoration changes.
|
||||
const unsubscribe = subscribeDecoration(userId, load);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unsubscribe();
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
|
||||
@@ -8,6 +8,12 @@ export type Bookmark = {
|
||||
savedAt: number;
|
||||
previewText: string;
|
||||
roomName: string;
|
||||
// Author display name, snapshotted at save time. Optional for backward
|
||||
// compatibility with bookmarks stored before attribution was added; the panel
|
||||
// re-resolves a live name when the room is joined and falls back to this
|
||||
// snapshot otherwise. (We deliberately don't store the sender MXID — it's never
|
||||
// read back and would only grow the 500-entry account-data blob.)
|
||||
senderName?: string;
|
||||
};
|
||||
|
||||
const BOOKMARKS_KEY = 'io.lotus.bookmarks';
|
||||
|
||||
@@ -149,9 +149,11 @@ export const useCallJoined = (embed?: CallEmbed): boolean => {
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!embed) {
|
||||
setJoined(false);
|
||||
}
|
||||
// Re-seed from the (new) embed whenever it changes identity, not only when
|
||||
// it becomes undefined. Answering a 2nd call swaps embed A→B directly (embed
|
||||
// stays truthy), so a plain `if (!embed)` reset left `joined` stuck true and
|
||||
// B rendered as already-joined, skipping the loading/watchdog UI.
|
||||
setJoined(embed?.joined ?? false);
|
||||
}, [embed]);
|
||||
|
||||
return joined;
|
||||
|
||||
@@ -25,8 +25,14 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||
const callMembers = useCallMembers(callSession);
|
||||
const joined = useCallJoined(callEmbed);
|
||||
|
||||
// C-L5 — depend on a STABLE boolean, not the callMembers array (whose identity
|
||||
// changes on every membership change). The MutationObserver + io.lotus.call_state
|
||||
// subscription below already track tiles joining/leaving live, so rebuilding
|
||||
// them on each membership change is pure churn.
|
||||
const hasCallMembers = callMembers.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!callMembers || !joined) {
|
||||
if (!hasCallMembers || !joined) {
|
||||
setSpeakers(new Set<string>());
|
||||
return undefined;
|
||||
}
|
||||
@@ -126,7 +132,7 @@ export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
|
||||
bodyWatcher?.disconnect();
|
||||
unsubLotus();
|
||||
};
|
||||
}, [callEmbed, callMembers, joined]);
|
||||
}, [callEmbed, hasCallMembers, joined]);
|
||||
|
||||
return speakers;
|
||||
};
|
||||
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
import { useRoomNavigate } from './useRoomNavigate';
|
||||
import { Membership, StateEvent } from '../../types/matrix/room';
|
||||
import { getStateEvent, sendStateEvent } from '../utils/room';
|
||||
import { isValidServerPattern, matchesAnyGlob } from '../utils/serverAcl';
|
||||
import { splitWithSpace } from '../utils/common';
|
||||
import { createRoomEncryptionState } from '../components/create-room';
|
||||
|
||||
@@ -509,8 +510,15 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||
StateEvent.RoomServerAcl,
|
||||
)?.getContent<RoomServerAclEventContent>();
|
||||
|
||||
// When there is NO existing ACL, default allow to `*` (allow all) so a
|
||||
// first `/acl -d evil.com` doesn't send `allow: []` — which per spec
|
||||
// allows NO server and bricks the room. But do NOT synthesize `*` for a
|
||||
// room that already HAS an ACL whose allow key is absent/empty: that
|
||||
// would silently widen a deny-all room to allow-all. Such a state is
|
||||
// preserved as-is and then rejected by the guard below (fail closed,
|
||||
// rather than silently opening federation).
|
||||
const aclContent: RoomServerAclEventContent = {
|
||||
allow: serverAcl?.allow ? [...serverAcl.allow] : [],
|
||||
allow: serverAcl ? (serverAcl.allow ? [...serverAcl.allow] : []) : ['*'],
|
||||
allow_ip_literals: serverAcl?.allow_ip_literals,
|
||||
deny: serverAcl?.deny ? [...serverAcl.deny] : [],
|
||||
};
|
||||
@@ -534,6 +542,40 @@ export const useCommands = (mx: MatrixClient, room: Room): CommandRecord => {
|
||||
aclContent.allow?.sort();
|
||||
aclContent.deny?.sort();
|
||||
|
||||
// Validate every added glob (empty/whitespace/malformed patterns).
|
||||
const invalidPattern = [...allowList, ...denyList].find(
|
||||
(servername) => !isValidServerPattern(servername),
|
||||
);
|
||||
if (invalidPattern) {
|
||||
throw new Error(`/acl: invalid server pattern "${invalidPattern}"`);
|
||||
}
|
||||
|
||||
const resultAllow = aclContent.allow ?? [];
|
||||
const resultDeny = aclContent.deny ?? [];
|
||||
|
||||
// Fail CLOSED on the universally-catastrophic cases regardless of
|
||||
// whether we can resolve our own server: an empty allow list bans every
|
||||
// server, and a `*` deny bans everyone.
|
||||
if (resultAllow.length === 0 || resultDeny.includes('*')) {
|
||||
throw new Error(
|
||||
'/acl: refused — this would ban all servers from the room. Use Room Settings → Server ACL to edit safely.',
|
||||
);
|
||||
}
|
||||
|
||||
// Refuse a change that would lock THIS homeserver out of the room — a
|
||||
// room-bricking footgun (denying the local server, or an allow list that
|
||||
// no longer matches it). Mirrors the Room Settings ACL editor's self-ban
|
||||
// detection.
|
||||
const localDomain = mx.getDomain() ?? '';
|
||||
if (
|
||||
localDomain &&
|
||||
(!matchesAnyGlob(localDomain, resultAllow) || matchesAnyGlob(localDomain, resultDeny))
|
||||
) {
|
||||
throw new Error(
|
||||
`/acl: refused — this would ban this homeserver (${localDomain}) from the room`,
|
||||
);
|
||||
}
|
||||
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.RoomServerAcl, aclContent);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useStore } from 'jotai';
|
||||
import { Descendant } from 'slate';
|
||||
import { roomIdToMsgDraftAtomFamily } from '../state/room/roomInputDrafts';
|
||||
import { DRAFT_MSG_KEY_PREFIX, hasMsgDraft } from '../utils/draft';
|
||||
|
||||
/**
|
||||
* On startup, pre-fill the per-room message-draft atoms from their localStorage
|
||||
* persistence (`draft-msg-<roomId>`). RoomInput only restores a draft into its
|
||||
* atom when that room's composer mounts, so without this a background room's
|
||||
* persisted draft wouldn't reflect in the room-nav draft indicator until the
|
||||
* room is opened. Runs once; only touches the same atoms RoomInput restores from
|
||||
* (identical content), so composer restore is unaffected.
|
||||
*
|
||||
* Thread drafts (key contains `::`) are skipped — the nav indicator is room-level.
|
||||
*/
|
||||
export function useHydrateMsgDrafts(): void {
|
||||
const store = useStore();
|
||||
|
||||
useEffect(() => {
|
||||
let keys: string[];
|
||||
try {
|
||||
keys = Object.keys(localStorage);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
keys.forEach((key) => {
|
||||
if (!key.startsWith(DRAFT_MSG_KEY_PREFIX)) return;
|
||||
const draftKey = key.slice(DRAFT_MSG_KEY_PREFIX.length);
|
||||
// Skip thread drafts (`<roomId>::<$threadRootEventId>`) — room-level only.
|
||||
// Match `::$` specifically so an IPv6-literal server name (e.g. `[::1]`)
|
||||
// in a roomId isn't mistaken for a thread key.
|
||||
if (!draftKey || draftKey.includes('::$')) return;
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (!stored) return;
|
||||
const nodes = JSON.parse(stored) as Descendant[];
|
||||
if (Array.isArray(nodes) && hasMsgDraft(nodes)) {
|
||||
store.set(roomIdToMsgDraftAtomFamily(draftKey), nodes);
|
||||
}
|
||||
} catch {
|
||||
// Ignore a malformed stored draft.
|
||||
}
|
||||
});
|
||||
}, [store]);
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Room, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useMediaAuthentication } from './useMediaAuthentication';
|
||||
import { useForceUpdate } from './useForceUpdate';
|
||||
import { useRoomMemberChange } from './useRoomMemberChange';
|
||||
import { getMemberName } from '../utils/room';
|
||||
import { mxcUrlToHttp } from '../utils/matrix';
|
||||
|
||||
@@ -15,9 +14,8 @@ export type MemberAvatar = {
|
||||
* Resolve a room member's display name and avatar http url, staying reactive to
|
||||
* that member's profile (name/avatar/membership) changes.
|
||||
*
|
||||
* Mirrors the reactivity pattern in ReadReceiptAvatars (N6): subscribe to
|
||||
* `RoomStateEvent.Members` filtered to this room + userId and force a re-render,
|
||||
* since `RoomMemberEvent` has no dedicated Avatar signal.
|
||||
* Stays reactive to this member's profile (name/avatar/membership) changes via
|
||||
* the shared member-change store (one global listener for the whole app).
|
||||
*/
|
||||
export const useMemberAvatar = (
|
||||
room: Room,
|
||||
@@ -28,23 +26,8 @@ export const useMemberAvatar = (
|
||||
): MemberAvatar => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
|
||||
useEffect(() => {
|
||||
const handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
|
||||
event,
|
||||
_state,
|
||||
member,
|
||||
) => {
|
||||
if (event.getRoomId() === room.roomId && member.userId === userId) {
|
||||
forceUpdate();
|
||||
}
|
||||
};
|
||||
mx.on(RoomStateEvent.Members, handleMembers);
|
||||
return () => {
|
||||
mx.removeListener(RoomStateEvent.Members, handleMembers);
|
||||
};
|
||||
}, [mx, room.roomId, userId, forceUpdate]);
|
||||
useRoomMemberChange(room.roomId, userId);
|
||||
|
||||
const name = getMemberName(room, userId);
|
||||
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useAtom } from 'jotai';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import {
|
||||
translationCacheAtom,
|
||||
msgTranslationActiveAtomFamily,
|
||||
makeCacheKey,
|
||||
findTranslation,
|
||||
addTranslation,
|
||||
} from '../state/translation';
|
||||
import { chromeTranslationEngine as engine } from '../utils/translation/chromeEngine';
|
||||
import { sameLanguage } from '../utils/translation/langUtils';
|
||||
|
||||
export type MsgTranslationStatus =
|
||||
| 'idle' // not translating (original shown)
|
||||
| 'detecting' // detecting source language
|
||||
| 'downloading' // downloading the on-device model (first use of a pair)
|
||||
| 'translating' // running the translation
|
||||
| 'done' // translation available
|
||||
| 'skipped' // already in the target language (nothing to do)
|
||||
| 'error'; // detection/translation failed
|
||||
|
||||
export type MsgTranslation = {
|
||||
/** Whether the on-device engine is usable at all in this browser. */
|
||||
supported: boolean;
|
||||
/** Whether the translated body should be shown in place of the original. */
|
||||
active: boolean;
|
||||
status: MsgTranslationStatus;
|
||||
translated?: string;
|
||||
/** Detected source language (normalized base code). */
|
||||
fromLang?: string;
|
||||
/** 0..1 while `status === 'downloading'`. */
|
||||
downloadProgress?: number;
|
||||
/** Toggle translation on/off for this message (satisfies the user gesture). */
|
||||
toggle: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Per-message on-device translation. The message menu action and the inline
|
||||
* "Show original" chip both drive the same per-event `active` atom; whichever
|
||||
* component mounts the body runs the actual detect→translate flow (once) and
|
||||
* writes the result to the local cache. Nothing ever leaves the device.
|
||||
*/
|
||||
export const useMessageTranslation = (eventId: string, text: string): MsgTranslation => {
|
||||
const supported = engine.isSupported();
|
||||
const [targetLang] = useSetting(settingsAtom, 'translateTargetLang');
|
||||
const [autoTranslate] = useSetting(settingsAtom, 'autoTranslate');
|
||||
const [active, setActive] = useAtom(msgTranslationActiveAtomFamily(eventId));
|
||||
const [cache, setCache] = useAtom(translationCacheAtom);
|
||||
|
||||
// The key includes a fingerprint of `text`, so an edit (same event id, new
|
||||
// body) misses the old entry and re-translates instead of showing stale text.
|
||||
const key = makeCacheKey(eventId, targetLang, text);
|
||||
const cached = findTranslation(cache, key);
|
||||
|
||||
const [status, setStatus] = useState<MsgTranslationStatus>('idle');
|
||||
const [translated, setTranslated] = useState<string | undefined>(undefined);
|
||||
const [fromLang, setFromLang] = useState<string | undefined>(undefined);
|
||||
const [downloadProgress, setDownloadProgress] = useState<number | undefined>(undefined);
|
||||
|
||||
const toggle = useCallback(() => setActive((a) => !a), [setActive]);
|
||||
|
||||
// Adopt a cached translation immediately (scrollback / re-render never
|
||||
// re-translates).
|
||||
useEffect(() => {
|
||||
if (cached) {
|
||||
setTranslated(cached.translated);
|
||||
setFromLang(cached.fromLang);
|
||||
if (active) setStatus('done');
|
||||
}
|
||||
}, [cached, active]);
|
||||
|
||||
// Auto-translate: when enabled, flip `active` on for foreign messages whose
|
||||
// model is already downloaded (no gesture/download needed). Messages needing
|
||||
// a first-time download keep the manual chip (which provides the gesture).
|
||||
// Store the text we last auto-tried so an edit (new text) re-triggers auto
|
||||
// detection instead of being suppressed by a one-shot flag.
|
||||
const autoTriedText = useRef<string | undefined>(undefined);
|
||||
useEffect(() => {
|
||||
if (!supported || !autoTranslate || active || cached || !eventId) return;
|
||||
if (!text.trim() || autoTriedText.current === text) return;
|
||||
autoTriedText.current = text;
|
||||
let alive = true;
|
||||
(async () => {
|
||||
const from = await engine.detect(text);
|
||||
if (!alive || !from || sameLanguage(from, targetLang)) return;
|
||||
const avail = await engine.availability(from, targetLang);
|
||||
if (!alive || avail !== 'available') return;
|
||||
setActive(true);
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [supported, autoTranslate, active, cached, text, targetLang, eventId, setActive]);
|
||||
|
||||
// The detect→translate flow. Runs when translation is switched on and we have
|
||||
// no cached result. Guarded against overlapping runs / stale writes.
|
||||
const runIdRef = useRef(0);
|
||||
useEffect(() => {
|
||||
if (!active || cached || !supported) return undefined;
|
||||
if (!text.trim()) {
|
||||
setStatus('skipped');
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const runId = runIdRef.current + 1;
|
||||
runIdRef.current = runId;
|
||||
const stale = () => runId !== runIdRef.current;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setStatus('detecting');
|
||||
setDownloadProgress(undefined);
|
||||
const from = await engine.detect(text);
|
||||
if (stale()) return;
|
||||
if (!from || sameLanguage(from, targetLang)) {
|
||||
setStatus('skipped');
|
||||
setActive(false);
|
||||
return;
|
||||
}
|
||||
setFromLang(from);
|
||||
|
||||
const avail = await engine.availability(from, targetLang);
|
||||
if (stale()) return;
|
||||
if (avail === 'unavailable') {
|
||||
setStatus('error');
|
||||
return;
|
||||
}
|
||||
setStatus(avail === 'available' ? 'translating' : 'downloading');
|
||||
|
||||
const result = await engine.translate(text, from, targetLang, (p) => {
|
||||
if (!stale()) setDownloadProgress(p);
|
||||
});
|
||||
if (stale()) return;
|
||||
|
||||
setTranslated(result);
|
||||
setDownloadProgress(undefined);
|
||||
setStatus('done');
|
||||
setCache((prev) => addTranslation(prev, { key, fromLang: from, translated: result }));
|
||||
} catch {
|
||||
if (!stale()) setStatus('error');
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
// Invalidate this run's writes if inputs change / unmount.
|
||||
runIdRef.current += 1;
|
||||
};
|
||||
}, [active, cached, supported, text, targetLang, key, setActive, setCache]);
|
||||
|
||||
// When switched off, drop back to idle (keep the cached translation around so
|
||||
// toggling back on is instant).
|
||||
useEffect(() => {
|
||||
if (!active && status !== 'idle') setStatus('idle');
|
||||
}, [active, status]);
|
||||
|
||||
return {
|
||||
supported,
|
||||
active,
|
||||
status: active ? status : 'idle',
|
||||
translated: cached?.translated ?? translated,
|
||||
fromLang: cached?.fromLang ?? fromLang,
|
||||
downloadProgress,
|
||||
toggle,
|
||||
};
|
||||
};
|
||||
|
||||
/** Lightweight helper for the message menu — is the translate action offerable? */
|
||||
export const isTranslationSupported = (): boolean => engine.isSupported();
|
||||
+46
-1
@@ -1,4 +1,4 @@
|
||||
import { MouseEventHandler, useEffect, useRef, useState } from 'react';
|
||||
import { MouseEventHandler, TouchEventHandler, useEffect, useRef, useState } from 'react';
|
||||
|
||||
export type Pan = {
|
||||
translateX: number;
|
||||
@@ -21,6 +21,10 @@ export const usePan = (active: boolean) => {
|
||||
const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>(
|
||||
null,
|
||||
);
|
||||
const touchAttachedRef = useRef<{
|
||||
move: (e: TouchEvent) => void;
|
||||
end: (e: TouchEvent) => void;
|
||||
} | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setCursor(active ? 'grab' : 'initial');
|
||||
@@ -53,6 +57,40 @@ export const usePan = (active: boolean) => {
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
// Touch equivalent so a zoomed image can be dragged on a phone. Single-finger
|
||||
// only (ignore multi-touch / pinch); touch events carry no movementX/Y, so we
|
||||
// derive the delta from the previous touch position.
|
||||
const handleTouchStart: TouchEventHandler<HTMLElement> = (evt) => {
|
||||
if (!active || evt.touches.length !== 1) return;
|
||||
setCursor('grabbing');
|
||||
let lastX = evt.touches[0].clientX;
|
||||
let lastY = evt.touches[0].clientY;
|
||||
|
||||
const handleTouchMove = (e: TouchEvent) => {
|
||||
if (e.touches.length !== 1) return;
|
||||
e.preventDefault();
|
||||
const t = e.touches[0];
|
||||
const dx = t.clientX - lastX;
|
||||
const dy = t.clientY - lastY;
|
||||
lastX = t.clientX;
|
||||
lastY = t.clientY;
|
||||
setPan((p) => ({ translateX: p.translateX + dx, translateY: p.translateY + dy }));
|
||||
};
|
||||
|
||||
const handleTouchEnd = () => {
|
||||
setCursor('grab');
|
||||
document.removeEventListener('touchmove', handleTouchMove);
|
||||
document.removeEventListener('touchend', handleTouchEnd);
|
||||
document.removeEventListener('touchcancel', handleTouchEnd);
|
||||
touchAttachedRef.current = null;
|
||||
};
|
||||
|
||||
touchAttachedRef.current = { move: handleTouchMove, end: handleTouchEnd };
|
||||
document.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||
document.addEventListener('touchend', handleTouchEnd);
|
||||
document.addEventListener('touchcancel', handleTouchEnd);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) setPan(INITIAL_PAN);
|
||||
}, [active]);
|
||||
@@ -65,6 +103,12 @@ export const usePan = (active: boolean) => {
|
||||
document.removeEventListener('mouseup', attachedRef.current.up);
|
||||
attachedRef.current = null;
|
||||
}
|
||||
if (touchAttachedRef.current) {
|
||||
document.removeEventListener('touchmove', touchAttachedRef.current.move);
|
||||
document.removeEventListener('touchend', touchAttachedRef.current.end);
|
||||
document.removeEventListener('touchcancel', touchAttachedRef.current.end);
|
||||
touchAttachedRef.current = null;
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
@@ -73,5 +117,6 @@ export const usePan = (active: boolean) => {
|
||||
pan,
|
||||
cursor,
|
||||
onMouseDown: handleMouseDown,
|
||||
onTouchStart: handleTouchStart,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -12,13 +12,22 @@ import { useSetting } from '../state/hooks/settings';
|
||||
import { MessageEvent, StateEvent } from '../../types/matrix/room';
|
||||
import { isMembershipChanged, reactionOrEditEvent } from '../utils/room';
|
||||
|
||||
export const useRoomLatestRenderedEvent = (room: Room) => {
|
||||
export const useRoomLatestRenderedEvent = (room: Room, enabled = true) => {
|
||||
const [hideMembershipEvents] = useSetting(settingsAtom, 'hideMembershipEvents');
|
||||
const [hideNickAvatarEvents] = useSetting(settingsAtom, 'hideNickAvatarEvents');
|
||||
const [showHiddenEvents] = useSetting(settingsAtom, 'showHiddenEvents');
|
||||
const [latestEvent, setLatestEvent] = useState<MatrixEvent>();
|
||||
|
||||
useEffect(() => {
|
||||
// The latest-event preview is only rendered for DM rooms. Skip the work —
|
||||
// and especially the GLOBAL client `Decrypted` listener, which every nav
|
||||
// item would otherwise add and which fans out on every decryption app-wide
|
||||
// (PERF-5) — when the caller isn't going to use the result.
|
||||
if (!enabled) {
|
||||
setLatestEvent(undefined);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const getLatestEvent = (): MatrixEvent | undefined => {
|
||||
const liveEvents = room.getLiveTimeline().getEvents();
|
||||
for (let i = liveEvents.length - 1; i >= 0; i -= 1) {
|
||||
@@ -67,7 +76,7 @@ export const useRoomLatestRenderedEvent = (room: Room) => {
|
||||
room.removeListener(RoomEvent.Timeline, handleTimelineEvent);
|
||||
room.client.removeListener(MatrixEventEvent.Decrypted, handleDecrypted);
|
||||
};
|
||||
}, [room, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]);
|
||||
}, [room, enabled, hideMembershipEvents, hideNickAvatarEvents, showHiddenEvents]);
|
||||
|
||||
return latestEvent;
|
||||
};
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import type { MatrixClient } from 'matrix-js-sdk';
|
||||
import { memberChangeStore } from './useRoomMemberChange';
|
||||
|
||||
// Minimal fake client: records registered handlers and can emit a member change.
|
||||
// The store registers exactly one RoomStateEvent.Members handler; we invoke it
|
||||
// with the same (event, state, member) shape the SDK uses.
|
||||
const makeFakeMx = () => {
|
||||
const handlers: Array<(e: unknown, s: unknown, m: unknown) => void> = [];
|
||||
const mx = {
|
||||
on: (_event: unknown, h: (e: unknown, s: unknown, m: unknown) => void) => {
|
||||
handlers.push(h);
|
||||
},
|
||||
removeListener: (_event: unknown, h: (e: unknown, s: unknown, m: unknown) => void) => {
|
||||
const i = handlers.indexOf(h);
|
||||
if (i >= 0) handlers.splice(i, 1);
|
||||
},
|
||||
};
|
||||
return {
|
||||
mx: mx as unknown as MatrixClient,
|
||||
listenerCount: () => handlers.length,
|
||||
emit: (roomId: string, userId: string) =>
|
||||
handlers.forEach((h) => h({ getRoomId: () => roomId }, undefined, { userId })),
|
||||
};
|
||||
};
|
||||
|
||||
test('fans out only to the matching roomId|userId', () => {
|
||||
const { mx, emit } = makeFakeMx();
|
||||
let a = 0;
|
||||
let b = 0;
|
||||
memberChangeStore.subscribe(mx, '!fanout', '@a', () => {
|
||||
a += 1;
|
||||
});
|
||||
memberChangeStore.subscribe(mx, '!fanout', '@b', () => {
|
||||
b += 1;
|
||||
});
|
||||
|
||||
emit('!fanout', '@a');
|
||||
assert.equal(a, 1);
|
||||
assert.equal(b, 0);
|
||||
|
||||
emit('!other', '@a'); // different room, same user -> no fire
|
||||
assert.equal(a, 1);
|
||||
});
|
||||
|
||||
test('registers exactly one client listener regardless of subscriber count', () => {
|
||||
const { mx, listenerCount } = makeFakeMx();
|
||||
memberChangeStore.subscribe(mx, '!one', '@a', () => {});
|
||||
memberChangeStore.subscribe(mx, '!one', '@b', () => {});
|
||||
memberChangeStore.subscribe(mx, '!one2', '@c', () => {});
|
||||
assert.equal(listenerCount(), 1);
|
||||
});
|
||||
|
||||
test('idempotent unsubscribe does not evict a re-subscribed key', () => {
|
||||
const { mx, emit } = makeFakeMx();
|
||||
const unsubA = memberChangeStore.subscribe(mx, '!idem', '@a', () => {});
|
||||
unsubA();
|
||||
|
||||
let count = 0;
|
||||
memberChangeStore.subscribe(mx, '!idem', '@a', () => {
|
||||
count += 1;
|
||||
});
|
||||
unsubA(); // double-invoke of the first unsub must not drop the new subscriber
|
||||
emit('!idem', '@a');
|
||||
assert.equal(count, 1);
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
import { useEffect } from 'react';
|
||||
import { MatrixClient, RoomStateEvent, RoomStateEventHandlerMap } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
import { useForceUpdate } from './useForceUpdate';
|
||||
|
||||
type MemberListener = () => void;
|
||||
|
||||
const memberKey = (roomId: string, userId: string): string => `${roomId}|${userId}`;
|
||||
|
||||
/**
|
||||
* Shared room-member-change store. Previously every ReadReceiptAvatars row and
|
||||
* every useMemberAvatar registered its own global `RoomStateEvent.Members`
|
||||
* listener — ~6 per receipt row — each firing on ANY membership / display-name /
|
||||
* avatar change in ANY room (PERF-3). This registers exactly ONE listener total
|
||||
* and fans out to subscribers keyed by `roomId|userId`.
|
||||
*
|
||||
* (`RoomStateEvent.Members` is used rather than `RoomMemberEvent` because the
|
||||
* latter has no dedicated avatar signal.)
|
||||
*/
|
||||
class MemberChangeStore {
|
||||
private mx: MatrixClient | undefined;
|
||||
|
||||
private started = false;
|
||||
|
||||
private subscribers = new Map<string, Set<MemberListener>>();
|
||||
|
||||
private handleMembers: RoomStateEventHandlerMap[RoomStateEvent.Members] = (
|
||||
event,
|
||||
_state,
|
||||
member,
|
||||
) => {
|
||||
const roomId = event.getRoomId();
|
||||
if (!roomId) return;
|
||||
const subs = this.subscribers.get(memberKey(roomId, member.userId));
|
||||
if (!subs) return;
|
||||
subs.forEach((cb) => cb());
|
||||
};
|
||||
|
||||
private start(mx: MatrixClient): void {
|
||||
if (this.started && this.mx === mx) return;
|
||||
if (this.started && this.mx) {
|
||||
this.mx.removeListener(RoomStateEvent.Members, this.handleMembers);
|
||||
}
|
||||
this.mx = mx;
|
||||
mx.on(RoomStateEvent.Members, this.handleMembers);
|
||||
this.started = true;
|
||||
}
|
||||
|
||||
subscribe(mx: MatrixClient, roomId: string, userId: string, cb: MemberListener): () => void {
|
||||
this.start(mx);
|
||||
const key = memberKey(roomId, userId);
|
||||
let subs = this.subscribers.get(key);
|
||||
if (!subs) {
|
||||
subs = new Set();
|
||||
this.subscribers.set(key, subs);
|
||||
}
|
||||
subs.add(cb);
|
||||
return () => {
|
||||
subs.delete(cb);
|
||||
// Only drop the registry entry if this is still the live set for the key,
|
||||
// keeping unsubscribe idempotent against a double-invoke / re-subscribe.
|
||||
if (subs.size === 0 && this.subscribers.get(key) === subs) {
|
||||
this.subscribers.delete(key);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const memberChangeStore = new MemberChangeStore();
|
||||
|
||||
/**
|
||||
* Re-render the caller whenever the given room member's state (display name,
|
||||
* avatar, or membership) changes, via the shared single-listener store.
|
||||
*/
|
||||
export const useRoomMemberChange = (roomId: string, userId: string): void => {
|
||||
const mx = useMatrixClient();
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
useEffect(
|
||||
() => memberChangeStore.subscribe(mx, roomId, userId, forceUpdate),
|
||||
[mx, roomId, userId, forceUpdate],
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Multi-user variant: re-render when ANY of `userIds` changes. Subscribes to
|
||||
* each id through the shared store from a single effect (no per-id hook).
|
||||
*/
|
||||
export const useRoomMembersChange = (roomId: string, userIds: string[]): void => {
|
||||
const mx = useMatrixClient();
|
||||
const [, forceUpdate] = useForceUpdate();
|
||||
// Stable, order-independent dependency for the set of ids (the array identity
|
||||
// is unstable, and re-ordering the same members shouldn't churn subscriptions).
|
||||
const idsKey = [...userIds].sort().join(',');
|
||||
useEffect(() => {
|
||||
const unsubs = userIds.map((userId) =>
|
||||
memberChangeStore.subscribe(mx, roomId, userId, forceUpdate),
|
||||
);
|
||||
return () => unsubs.forEach((unsub) => unsub());
|
||||
// `userIds` is captured via the stable `idsKey`; re-subscribes when it changes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [mx, roomId, idsKey, forceUpdate]);
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user