Compare commits
169
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 | ||
|
|
384a1dd262 | ||
|
|
d7d261a019 | ||
|
|
3045a9f014 | ||
|
|
c2598d21cd | ||
|
|
4fa4327a18 | ||
|
|
101e4116e8 | ||
|
|
4fc3f7a35f | ||
|
|
b1ee3ada98 | ||
|
|
e545706c3b | ||
|
|
8c0e2b4250 | ||
|
|
165714e133 | ||
|
|
6cf18c3bd9 | ||
|
|
db86432644 | ||
|
|
8eb961b682 | ||
|
|
2614e6b92d | ||
|
|
f9c03d5e33 | ||
|
|
3f9fea1cf2 | ||
|
|
1b2142e6c4 | ||
|
|
4236621c7a | ||
|
|
45afc9ba7e | ||
|
|
fd87a27251 | ||
|
|
0c6003fb87 | ||
|
|
7fd3164b1f | ||
|
|
44420220d4 | ||
|
|
a3ca951fba | ||
|
|
33cb103abb | ||
|
|
7ad948e26c | ||
|
|
07b0c410ab | ||
|
|
013f113bc2 | ||
|
|
a52c9e12a4 | ||
|
|
a28c305835 | ||
|
|
3694a3612d | ||
|
|
4de2d233ef |
+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
|
||||
|
||||
+195
-26
@@ -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
|
||||
|
||||
@@ -706,16 +760,21 @@ KaTeX-rendered math in messages, two paths:
|
||||
|
||||
- **Spec path (CS-API §11.5):** `<span/div data-mx-maths="…">` in `formatted_body` renders the attribute's LaTeX (block for div, inline for span); on render failure the element's child fallback content shows instead
|
||||
- **Plain-text path:** `$…$` (inline) and `$$…$$` (block) with conservative rules — escape-aware (`\$`), currency-guarded (`$5 and $10` stays text), never inside `code`/`pre`
|
||||
- **Outgoing interop:** on send, the composer converts `$…$`/`$$…$$` to spec `<span/div data-mx-maths>` HTML in `formatted_body` (extracted before markdown so LaTeX isn't mangled; off inside code), so math renders on Element and every other client — not just Lotus. The plain `body` keeps literal `$…$` as the fallback
|
||||
- KaTeX + its CSS load lazily on first math encountered — zero cost to the main bundle
|
||||
- Files: `src/app/utils/mathParse.ts` (+14 tests), `components/math/KaTeX.tsx`, `plugins/react-custom-html-parser.tsx`
|
||||
- Files: `src/app/utils/mathParse.ts` (+14 tests), `components/math/KaTeX.tsx`, `plugins/react-custom-html-parser.tsx` (render), `components/editor/output.ts` (+ `output.test.ts`, outgoing)
|
||||
|
||||
### 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
|
||||
|
||||
@@ -726,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`
|
||||
@@ -767,20 +838,92 @@ Redacted events display "This message has been deleted" along with the redaction
|
||||
|
||||
Generic (non-domain-specific) cards display a Google S2 favicon. Empty or unparseable preview responses are suppressed entirely rather than showing a blank card.
|
||||
|
||||
### Inline Media Embeds
|
||||
|
||||
Media links play/render **in place** instead of opening a browser tab. A pure
|
||||
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 |
|
||||
|
||||
**Privacy-friendly facade.** The tile first shows the homeserver's cached
|
||||
`og:image` thumbnail + a play button; the third-party `<iframe>` is only mounted
|
||||
on click, so nothing hits Google/Meta/etc. until the user opts in. A **Close**
|
||||
button collapses a playing embed back to the facade, and video players carry a
|
||||
Fullscreen control. Cookie-less/DNT variants are used where offered
|
||||
(`youtube-nocookie.com`, Vimeo `dnt=1`).
|
||||
|
||||
**Provider notes.**
|
||||
|
||||
- **TikTok** — short "copy-link" URLs (`vm.tiktok.com`, `tiktok.com/t/…`) carry no
|
||||
video id and the homeserver preview is bot-walled, so `TikTokEmbedCard`
|
||||
resolves the id client-side via TikTok's CORS-enabled **oEmbed** API on click
|
||||
(`AbortController`-guarded), then plays the `player/v1` embed.
|
||||
- **Reddit / Instagram / Bluesky / X** — post embeds self-size via `postMessage`;
|
||||
`useIframeAutoHeight` listens scoped to each provider's origin **and** our own
|
||||
iframe, parsing each provider's height shape (Instagram `MEASURE`, Reddit
|
||||
`resize.embed`, Twitter `twttr.private.resize`). `redd.it` short links resolve to
|
||||
the subreddit-less `embed.reddit.com/comments/{id}/` route.
|
||||
- **Vimeo** unlisted-video privacy hashes (`vimeo.com/{id}/{hash}`) and
|
||||
channel/group/album forms are parsed; **YouTube** handles `/watch`, `youtu.be`,
|
||||
`/embed`, `/live`, `/shorts`, and `m.`/`music.youtube.com`.
|
||||
|
||||
**Defense-in-depth.** Every embed iframe carries a `sandbox` that omits
|
||||
`allow-top-navigation` (so a compromised embed can't redirect the whole app —
|
||||
phishing guard) on top of the CSP `frame-src` allowlist. Previews are also capped
|
||||
at 6 per message.
|
||||
|
||||
**Setting.** `inlineMediaEmbeds` (Settings → General → "Inline Media Players",
|
||||
default **on**). Off → media links fall back to plain link tiles.
|
||||
|
||||
**Latent web bug fixed along the way.** YouTube thumbnails now come from the
|
||||
homeserver `og:image` instead of `img.youtube.com` — which was silently broken on
|
||||
the web build (nginx `img-src` has no YouTube host) — removing a pre-click Google
|
||||
request as a bonus.
|
||||
|
||||
**CSP.** Desktop Tauri `frame-src` (`cinny-desktop` `tauri.conf.json`) and the web
|
||||
nginx `frame-src` allowlist enumerate every embed host (youtube-nocookie,
|
||||
player.vimeo, geo.dailymotion, streamable, player/clips.twitch, open.spotify,
|
||||
w.soundcloud, embed.music.apple, embed.tidal, www.tiktok + connect-src for its
|
||||
oEmbed, platform.twitter, www.instagram, embed.reddit, embed.bsky.app, www.loom,
|
||||
player.kick).
|
||||
|
||||
**Files:** `src/app/utils/videoEmbed.ts` (resolver + parsers, unit-tested),
|
||||
`src/app/components/url-preview/{UrlPreviewCard,UrlPreview.css}.tsx`.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -792,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)
|
||||
|
||||
@@ -834,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
|
||||
|
||||
@@ -866,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}`:
|
||||
@@ -909,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**.
|
||||
@@ -937,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
|
||||
|
||||
@@ -1003,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
|
||||
|
||||
@@ -1015,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
|
||||
|
||||
@@ -1127,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
|
||||
|
||||
---
|
||||
|
||||
@@ -1149,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:
|
||||
|
||||
+96
-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
|
||||
@@ -662,6 +686,77 @@ Run the axe DevTools extension (or Lighthouse → Accessibility) on a room view,
|
||||
|
||||
---
|
||||
|
||||
## Q. Inline Media Embeds — video / audio / post players (needs the web deploy live)
|
||||
|
||||
The whole feature is behind **Settings → General → "Inline Media Players"** (default **on**). Everything loads from the homeserver's cached thumbnail first; the third-party player only mounts on **Play**. Test on the **web** build first, then re-check the video ones on **desktop (Tauri)** since the CSP differs. On any failure, grab the **browser console** (F12) — a blocked embed shows as a CSP `frame-src` violation naming the host.
|
||||
|
||||
### Q1. Facade + one of each kind plays in place
|
||||
|
||||
Paste each of these into a room and confirm a media tile (not a plain link) with a thumbnail + play button, and that clicking Play mounts the player **inline**:
|
||||
|
||||
- **16:9 video:** a YouTube `watch` link, a Vimeo link, a Dailymotion link, a Streamable link, a Twitch VOD/clip, a Loom `share` link.
|
||||
- **9:16 portrait:** a YouTube **Shorts** link (renders tall, not letterboxed).
|
||||
- **Audio player:** a Spotify track, a SoundCloud track, an Apple Music album, a Tidal album/track.
|
||||
- **Post embed:** an X/Twitter post, an Instagram post, a Reddit post.
|
||||
|
||||
**Expected:** ✅ tile shows the thumbnail; **no** request to the third party until you press Play (check DevTools → Network); the player then plays inline. ❌ tell me any that stay a plain link, show a blank frame, or hit the network before you click.
|
||||
|
||||
### Q2. TikTok (the tricky one) + portrait fill
|
||||
|
||||
1. Paste a **full** TikTok URL and a **short** copy-link (`vm.tiktok.com/…` or `tiktok.com/t/…`).
|
||||
2. Press Play on each.
|
||||
|
||||
**Expected:** both resolve to a clean **9:16** player that **fills the box** (no big empty band on the right). The short link shows a brief spinner while it resolves via oEmbed, then plays. ❌ tell me if a short link shows only the TikTok logo/♫ and never a play button, or if the player has dead space beside it.
|
||||
|
||||
### Q3. Post self-resize + Close / Fullscreen controls
|
||||
|
||||
1. Play a **Reddit**, **Instagram**, and **X/Twitter** post embed.
|
||||
2. Watch the card height as the embed loads.
|
||||
|
||||
**Expected:** the card **grows to fit** the post (no clipped/scrollbarless content, no giant empty box). A **Close** button (✕) collapses the player back to the thumbnail; video players also show a **⛶ Fullscreen** control that works. Keyboard: Tab to the play button → it shows a visible **focus ring**.
|
||||
|
||||
### Q4. New providers (unverified) + the toggle + the cap
|
||||
|
||||
- **Bluesky / Loom / Kick** — these are freshly added and unverified live. Paste a `bsky.app/profile/…/post/…`, a `loom.com/share/…`, and a live `kick.com/{channel}` link. ✅ good if each plays/renders inline; ❌ if any is a broken frame (for **Bluesky** especially, note whether a **handle** URL resolves or only a DID one does — grab the console).
|
||||
- **Toggle off:** Settings → General → **Inline Media Players** off → every media link reverts to a plain link tile (no player).
|
||||
- **Cap:** paste a message with **8+** media links → at most **6** preview cards render (the rest are suppressed), and the page stays responsive.
|
||||
|
||||
---
|
||||
|
||||
## R. Discovery-pass fixes (DP1–DP18, 2026-07)
|
||||
|
||||
Agent-surveyed + TPVR-verified low/med issues, now fixed (commits `8eb961b6` `db864326` `6cf18c3b` `165714e1` `8c0e2b42` `e545706c` `b1ee3ada` `4fc3f7a3` `101e4116` `4fa4327a` `c2598d21`). Behavioral items have concrete checks; the refactors just need a "still works" regression pass.
|
||||
|
||||
### Correctness
|
||||
|
||||
- [ ] **DP1 — slash-command errors are visible.** Run a slash command that must fail — e.g. `/kick @nobody:server` in a room where you lack permission, or `/join` a bad alias. **Expected:** an error toast appears (not a silent no-op); a successful command still clears the composer normally.
|
||||
- [ ] **DP2 — no invite re-notify on reload.** With ≥1 pending invite, hard-reload (Ctrl+F5). **Expected:** NO "you have N new invitation" toast/sound on load. Then have someone invite you while the app is open → you DO get one notification for the new invite. (👥 2 accounts)
|
||||
- [ ] **DP3 — status clear syncs across devices.** Set a status message on device A, confirm it shows on B; clear it on A. **Expected:** B clears too and does NOT re-publish the old status on its next presence heartbeat. Toggling Invisible must not wipe a real saved status. (👥 2 sessions)
|
||||
- [ ] **DP4 — tag-toggle failure surfaced once.** With the network offline, toggle a room's Favourite/Low-priority. **Expected:** a single error toast (not two) on failure; on success the tag updates as before.
|
||||
- [ ] **DP5 — soundboard packs update on room switch.** Open a soundboard in room A, then switch to room B (different pack) in the same mounted view. **Expected:** B's packs show without needing an unrelated event.
|
||||
- [ ] **DP6 — declining a call still dismisses.** Decline an incoming call. **Expected:** the ringing UI dismisses even if the decline send fails (best-effort). (👥 2 accounts)
|
||||
|
||||
### a11y / UX (needs a screen reader + a narrow viewport)
|
||||
|
||||
- [ ] **DP7 / DP8 — call-control buttons announce correctly.** In a call with a screen reader: the deafen button announces "Deafen" when sound is on (not "Undeafen"); Sound / Video / Screenshare announce a consistent pressed/unpressed state like Mic.
|
||||
- [ ] **DP9 — GIF picker focus + width.** Open the GIF picker, close it (Esc / click-out) → focus returns to the GIF button. On a ~320px viewport the picker doesn't overflow the page.
|
||||
- [ ] **DP10 — search-filter clears by keyboard.** In message search, toggle a filter chip (Has link / msg-type / pinned) off with Enter; the date-range clears via its menu's Clear. No mouse-only clear needed.
|
||||
- [ ] **DP11 — voice recorder fits + announces.** On a ~360px viewport, start a voice message → the recorder row doesn't overflow the composer; a screen reader can query the duration (role="timer") without being spammed.
|
||||
- [ ] **DP12 — live-call count announced.** With a screen reader, when someone joins/leaves an active call, the "{n} Live" change is announced (polite status region).
|
||||
|
||||
### TDS colors (Lotus Terminal theme)
|
||||
|
||||
- [ ] **DP14 — send-status + receipt colors follow the theme.** In **TDS light** mode: the message send-status "failed" icon and the read-receipt pill use the theme's darker red/blue (from `--lt-*` tokens), NOT bright dark-mode cyan/red. TDS dark still looks right; non-TDS themes unchanged.
|
||||
|
||||
### Refactor regression pass (no behavior change intended)
|
||||
|
||||
- [ ] **DP13 — bookmarks / reminders / notes still work** (they now share one store engine). Add/remove a bookmark, set/clear a reminder, write/clear a user note; each persists across reload; rapid consecutive writes don't clobber each other.
|
||||
- [ ] **DP15 / DP16 — state-event + account-data writes still work.** Edit room name/topic/avatar, join-rules, power levels, an emoji/soundboard pack (state events); toggle a setting stored in account data. All save + reflect correctly.
|
||||
- [ ] **DP17 — link previews render.** Open a TikTok / Spotify / Steam / Reddit link preview; provider icons render (folds icons, not raw glyphs) and brand colors look right.
|
||||
- [ ] **DP18 — member avatars/names live-update.** Read receipts + the "seen by" reader list show correct avatars/names and update live when a member changes their avatar or display name (no reload).
|
||||
|
||||
---
|
||||
|
||||
## Priority if you're short on time
|
||||
|
||||
1. **O1 + O2** (threads + per-thread notifications) — the largest new surface; the main-timeline change is user-visible.
|
||||
|
||||
+180
-16
@@ -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**.
|
||||
|
||||
---
|
||||
@@ -57,11 +56,97 @@ Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then gr
|
||||
| Desktop proactive update notifications (P5-40) | J1 |
|
||||
| OIDC/SSO login (P4-6, needs an MSC3861 server — pick mozilla.org on login) | OIDC |
|
||||
| Windows native WinRT toast quick-reply / click-to-open (D6, AUMID) | rich-toast (§backlog) |
|
||||
| Inline media embeds (16 providers: video/audio/post + click-to-play facade) | Q1 / Q2 / Q3 / Q4 |
|
||||
|
||||
---
|
||||
|
||||
## 🔴 Open — Actionable
|
||||
|
||||
### ✅ Discovery pass (2026-07) — DP1–DP18 DONE
|
||||
|
||||
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 — DONE
|
||||
|
||||
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.
|
||||
|
||||
- [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`.
|
||||
|
||||
**Deferred / decided (not built):**
|
||||
|
||||
- [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).
|
||||
|
||||
### 🔍 Feature bug hunt (2026-07, 5-agent, LOTUS_FEATURES surface) — open findings
|
||||
|
||||
Per-slice bug hunt (5 agents: theming · calls · messaging · threads/presence/UX · rooms/mod/notif/infra/desktop), each **verified against current code** (already-fixed items not re-flagged; the heavily-audited hot paths came back clean). Residual findings below. `[live]` / `[desktop]` = needs a real call / the desktop app to confirm.
|
||||
|
||||
**Embeds / URL previews**
|
||||
|
||||
- [x] **[Med] Desktop (Tauri) CSP `frame-src` was missing `store.steampowered.com`, `www.mixcloud.com`, `widget.deezer.com`** → the Steam widget (shipped) + new Mixcloud/Deezer embeds were silently blocked (blank iframe) **in the desktop app**. **FIXED** (`cinny-desktop` `daba59b`): all three added to `frame-src` (no `connect-src` — these don't do a client oEmbed fetch). Web was always fine (`frame-src 'self' https:`). Needs desktop-app QA to confirm the widgets render.
|
||||
- [x] **[Low]** `searchCache.ts` encrypted-search index has no size/count cap — unbounded on-disk growth (mitigated by the manual "Clear cached index" + logout wipe). **FIXED** (`fff811cb`): per-room cap of 5000 rows, oldest-by-ts evicted on write via a self-chaining IDB cursor + pure unit-tested `evictCount`. IDB-spec correctness (cursor delete/continue, tx liveness, range bracketing) confirmed by 2 review agents since CI can't run IndexedDB.
|
||||
- [x] **[Low]** `MsgTypeRenderers.tsx` `MLocation` OSM permalink uses raw `geo:` lat/lon substrings, not the validated floats — harmless (URL context, malformed input only). **FIXED** (`8a461610`): permalink uses the `parseFloat`+`isFinite` validated `lat`/`lon` (as the map iframe already did).
|
||||
|
||||
**Voice / video calls**
|
||||
|
||||
- [x] **[Med]** `DenoiseTester.play()` (Settings → Calls A/B model test) leaks the denoise model node — calls `ctx.close()` but never `denoise.dispose()` (inconsistent with `stopLive`, which disposes) → leaks the DeepFilterNet/DTLN worker/WASM per press. **FIXED** (`c9d9d914`): `stopPlayback` now mirrors `stopLive` (dispose model + gate), and a generation token also closes the rapid-click / stop-during-load / unmount-during-load leak windows (3 review passes, all 6 interleavings traced).
|
||||
- [x] **[Med] [live]** PiP auto-spotlight never released on return to the call room — the release branch sits inside the `if (!pipMode) return` guard, so screenshare→PiP→back leaves spotlight forced on and `pipAutoSpotlightRef` stuck `true`. `CallEmbedProvider.tsx:733-744`. **FIXED** (`08e19100`, code-level; still wants live QA): effect guards only on `!callEmbed`, releases whenever `pipMode && pipScreenshare` is false; + ref-reset on embed teardown + deps comment (2-agent reviewed).
|
||||
- [x] **[Low]** DenoiseTester async paths (`getUserMedia`) have no mounted-guard → ctx/stream leak + setState-after-unmount if Settings closes during the mic prompt. **FIXED** (`c9d9d914`): a `mountedRef` guards `startLive`/`startRecord` after the `getUserMedia` await (and `play()` after its model load); the ref is set on mount, not only cleared on unmount, so it survives a StrictMode/Activity remount.
|
||||
- [x] **[Low]** Soundboard 30s safety timeout never cleared on natural clip end (`CallSoundboard.tsx:115`); `PrescreenControls` `PermissionStatus.onchange` not removed on unmount (`PrescreenControls.tsx:22-28`). **FIXED** (`56561627`): per-play timer token cleared on end/unmount (identity-guarded so a stale clip can't disarm a newer one); permission `onchange` detached + `cancelled`-guarded setState.
|
||||
- [ ] **[Low] [live]** Call-to-call switch disposes the embed without an explicit `HangupCall` → possible transient ghost RTC membership until EC's unload-leave fires.
|
||||
|
||||
**Theming / visuals**
|
||||
|
||||
- [x] **[Med]** `invalidateDecorationCache` clears the module cache but has no pub/sub → changing **your own** avatar decoration doesn't update live in already-mounted avatars (timeline/members) until remount. Add a listener set / bump counter. `useAvatarDecoration.ts:67`. **FIXED** (`29ff1654`): per-user listener set notified on invalidation (+ clears the give-up counter); concurrent re-fetches de-dupe via the existing `pending` map.
|
||||
- [x] **[Med/Low]** Decoration picker grid thumbnails use the raw `DECORATION_CDN` constant instead of `decorationUrl()`, ignoring the `VITE_DECORATION_CDN` override → broken thumbnails if decorations are repointed. `ProfileDecoration.tsx:51`. **FIXED** (`29ff1654`): grid uses `decorationUrl(slug)`.
|
||||
- [x] **[Low]** Seasonal "Auto" is computed once at mount (no ticker, unlike NightLight) → won't flip across a holiday-window boundary in a long-lived session. `SeasonalEffect.tsx:100`. **FIXED** (`d416c62b`): hourly re-eval ticker (auto only) + refresh on entering auto; decision extracted to pure `resolveSeasonTheme` + tested.
|
||||
- [x] **[Low]** Selecting seasonal "Auto" while a chat background is set is a silent no-op (asymmetric mutual exclusion — SeasonalEffect early-returns when `chatBackground !== 'none'`). `General.tsx:550`. **FIXED** (`d416c62b`): any active seasonal mode (incl. auto) now clears the chat background; only "off" leaves it (symmetric with the bg picker).
|
||||
- [x] **[Low]** Decoration settings fetch the `/{field}` sub-resource → console 404 for users with no decoration set. `ProfileDecoration.tsx:79`. **FIXED** (`29ff1654`): reads the full `/profile/{userId}` (matching `useAvatarDecoration`); PUT/save path unchanged.
|
||||
|
||||
**Threads / presence / UX**
|
||||
|
||||
- [x] **[Med]** `PresenceBadge` renders DND (`unavailable` + `status_msg:'dnd'`) as a **yellow "Idle"** badge + label, while `PresenceRingAvatar` correctly shows **red** — inconsistent. Give the badge the same `status === 'dnd' → Critical` + "Do Not Disturb" branch. `Presence.tsx:17-59`. **FIXED** (`29ff1654`): badge now matches the ring + settings picker (Critical / "Do Not Disturb", `'dnd'` sentinel line suppressed).
|
||||
- [x] **[Med]** Collapsible-message threshold is hardcoded (`COLLAPSE_MAX_HEIGHT = 320`), but the docs claim it's "configurable in Settings → Appearance (default 20 lines)" — unimplemented. Add the setting + control, or fix the doc. `MsgTypeRenderers.tsx:38`. **FIXED** (doc): LOTUS_FEATURES now describes the fixed 320px (≈20-line) threshold; the full 320px is sensible and a per-user setting wasn't worth the surface — reconciled the doc rather than build a marginal setting.
|
||||
- [x] **[Med/Low]** In-app toast container has no visible cap / scroll — a burst of messages across rooms while focused stacks toasts unbounded and can cover the viewport. Cap visible N or `overflow-y:auto` + max-height. `LotusToastContainer.tsx:223-247`. **FIXED** (`1963222d`): queue capped at 5 in the atom writer (drops oldest non-sticky, never the newest or a sticky action toast) + container maxHeight/overflow + scroll-to-newest; +4 tests. (3 review passes — the 2nd caught a newest-dropped edge when the cap is full of stickies.)
|
||||
- [x] **[Low]** "Unread First" room sort leaves the (larger) read portion unordered — no activity fallback for the equal-unread case. `Home.tsx:213-222`. **FIXED** (`1963222d`): `factoryRoomIdByUnread` breaks ties by recent activity; relocated to `utils/sort.ts` (pure) + unit-tested.
|
||||
- [x] **[Low]** Tab title "(N)" counts mentions, not unread messages (doc says unread) — reconcile doc vs. code. `ClientNonUIFeatures.tsx:120-123`. **FIXED** (doc): the mention-count + unread-dot behavior is intentional (mirrors the favicon); LOTUS_FEATURES now describes it accurately (N = highlights, `·` = other unread).
|
||||
|
||||
**Rooms / moderation / notifications / infra / desktop**
|
||||
|
||||
- [ ] **[Med] [desktop]** `useTauriFocusAssist` never queries the initial OS Focus-Assist state on mount (unlike `useTauriDnd`, which rehydrates via `get_tray_dnd`) → if Focus Assist is already ON at launch, notifications/sounds leak through until the OS state next flips. Add a `get_focus_assist` mount query (confirm whether the native poll emits an initial reading). `useTauriFocusAssist.ts:18-24`.
|
||||
- [x] **[Low]** Push-rule enable toggle holds stale local `useState` after an external rule change (toggled on another device) — sync from the `pushRule.enabled` prop. `PushRuleEditor.tsx:55-79`. **FIXED** (`2c0cd0d2`): `useEffect` resyncs on `pushRule.enabled` change (prop flows from live `useAccountData(m.push_rules)`; no optimistic conflict).
|
||||
- [x] **[Low]** Server-support `.well-known/matrix/support` is fetched from `mx.getHomeserverUrl()` (client-API host) instead of the MXID **server-name** host → silently missing on delegated/split-domain servers. `About.tsx:45-47`. **FIXED** (`2c0cd0d2`): fetched from `https://{mx.getDomain()}` (MSC1929-correct); identical for non-delegated, graceful catch otherwise.
|
||||
- [x] **[Low]** Cleared/partial quiet-hours `time` input (`''` → window inactive) silently disables the window while the toggle still reads "on" — no feedback. `SystemNotification.tsx:364-382`. **FIXED** (`5175c095`): inline Critical hint when the toggle is on but a time field is empty.
|
||||
- [~] **[Low] [desktop]** Native quick-reply swallows send errors (`.catch(() => undefined)`); the `show_rich_toast` trigger has no verified web-side caller. `useTauriToastActions.ts:35-38`. **ROOT CAUSE FOUND + web fix shipped** (`0ddf86c6`): `show_rich_toast` was dead because `showOsNotification` preferred the service worker (WebView2 has one), shadowing the injected `window.Notification` shim. Now skips the SW path under Tauri → notifications route to the rich toast, whose click navigates to the message.
|
||||
|
||||
### 🖥️ Desktop notification rich-toast — follow-ups (activated by `0ddf86c6`, need a Windows build)
|
||||
|
||||
The web-side nav fix (`0ddf86c6`) makes the native rich-toast path live for the first time. It fixes click→navigate, but exposes latent behaviors in the **cinny-desktop Rust** that need a Windows build to fix + verify:
|
||||
|
||||
- [ ] **[Med] [desktop]** **Tag-coalescing lost.** The web SW notification used `tag` to _replace_ prior notifications for the same room; `show_rich_toast` (`cinny-desktop/src-tauri/src/native/toast.rs`) ignores `tag` and shows a new WinRT toast every time → rapid same-room messages stack instead of collapsing. Fix: dedupe/replace by room in the toast store (`toast.rs:226-230`).
|
||||
- [ ] **[Med] [desktop]** **Thread / invite quick-reply misroutes.** The reply target is the coalescing `tag` — `${roomId}:${threadId}` for thread replies, `'lotus-invites'` for invites (`ClientNonUIFeatures.tsx:471,192`) — not a real room id, so `mx.sendMessage(tag, …)` fails silently (`useTauriToastActions.ts:37`). Body-click navigation is correct (uses `path`). Fix: pass the real `roomId` separately (e.g. `data.roomId`) and have the shim (`lib.rs` `NOTIFICATION_BRIDGE`) + `toast.rs` use it for the reply target; keep `tag` for coalescing. Invite toasts should also drop the reply box (nothing to reply to).
|
||||
- [ ] **[desktop QA] Windows notification checklist** (verify `0ddf86c6` + the above): (1) confirm the pre-fix symptom was focus-without-navigate; (2) message toast → click navigates to the message, quick-reply sends to the room; (3) thread toast → navigates, reply currently misroutes (until fixed above); (4) invite toast → navigates to invites; (5) rapid same-room messages → stacking until coalescing restored; (6) AUMID-missing/dev build → plain-notification fallback still shows; (7) web PWA unaffected.
|
||||
- [x] **[Low]** Export-history date-range early-break can over-paginate + mislabel "truncated" in E2EE rooms (`oldestRawTs` only advances on decrypted `m.room.message`, so undecryptable old events never move it). `ExportRoomHistory.tsx:104,136`. **FIXED** (`3ff8fb8e`): boundary now advances on every event (getTs is envelope metadata), above the type/decryption filters; guarded `ts > 0` so a bogus 0-ts can't cause the opposite (silent under-pagination). 2-agent reviewed.
|
||||
- [x] **[Info/doc]** `PolicyListViewer` is a manual room-ID/alias viewer with **no** subscribe/unsubscribe controls and no subscribed-lists listing — `LOTUS_FEATURES.md:1287` describes both. Docs oversell; not a runtime bug. **FIXED** (`8a461610`, doc): LOTUS_FEATURES corrected to describe the read-only room-ID/alias viewer (no subscribe controls).
|
||||
|
||||
### ✅ Composer autocomplete-insert crash (reported 2026-07) — FIXED (`477df4ae`)
|
||||
|
||||
Picking an autocomplete item (mention/emoji/command) occasionally tripped the composer error boundary ("encountered an error" → forced refresh) even though the element inserted. Root-caused (3 agents, incl. a headless slate simulation) to `moveCursor` deferring its cursor work to `setTimeout`, leaving the caret on the just-inserted inline-void's zero-width edge; slate-react's commit-phase `setBaseAndExtent(voidEdge, 1)` then threw `IndexSizeError` mid-render → boundary. **Fix:** do `Transforms.move` (escape the void) + `insertText(' ')` synchronously in the same commit as the insert, so the caret is a resolvable text point when the selection sync runs. Plus a recoverable boundary ("Reload composer" + `onReset` deselect) so any residual composer crash no longer needs a page refresh. (A first "sync insertText without move" attempt was caught in review — the void guard drops the space + traps the caret; `move` is required.)
|
||||
|
||||
### ✅ Unread/read-receipt flakiness (reported 2026-07) — FIXED (pending prod QA)
|
||||
|
||||
Room unread dots were inconsistent: reading a message sometimes cleared the dot, sometimes left it stuck, sometimes it resurrected. Root cause (confirmed by tracing + diffing upstream cinny `dev`): **our own "N4" change.** `handleReceipt` recomputed via `getUnreadInfo`, which reads `room.getUnreadNotificationCount()` — server-computed and **stale on the synchronous synthetic receipt echo** (SDK only zeroes it immediately when the last event is your own message) → it PUT the stale non-zero count back → stuck/resurrecting. Compounded by `hasUnread = !!unread` lighting the dot on any present map entry, incl. phantom `{0,0}` PUTs from our `UnreadNotifications` listener. Plus a Mark-as-Unread (MSC2867) flag that never cleared on opening an already-read room (no receipt → no auto-clear).
|
||||
@@ -120,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**:
|
||||
@@ -137,7 +247,7 @@ After Phases A–C the client spec is ~complete. What's left, flagged by **what
|
||||
|
||||
- Live Location Sharing (**MSC3489** + **MSC3672** — both `false`)
|
||||
- Reaction / relation redaction (**MSC3892** — `false`)
|
||||
- Room preview before joining (**MSC3266** — summary endpoint 404s on 1.155)
|
||||
- ~~Room preview before joining (MSC3266)~~ — **DONE** (client was always built; unstable `im.nheko.summary` endpoint returns 200 — verified on 1.156)
|
||||
- Thread subscriptions (**MSC4306** — `false`)
|
||||
|
||||
**Niche / low-value (noted, not planned):** E2EE history-key-on-invite (MSC3061), voice broadcast (MSC3888), a native account-deactivation flow (currently delegated to the OIDC provider for OIDC accounts).
|
||||
@@ -152,9 +262,15 @@ After Phases A–C the client spec is ~complete. What's left, flagged by **what
|
||||
|
||||
A minimal audio editor for soundboard clips and voice content. Scope: (1) **trim/clip** an audio file to a chosen start/end (waveform scrubber, in/out handles); (2) **upload a video file → strip and discard the video track, keep only the audio** (extract audio, then the source video is dropped — never uploaded/stored); (3) minimal edits only (trim, maybe gain/normalize, fade in/out) — not a full DAW. Likely Web Audio API (`AudioContext.decodeAudioData` → trim `AudioBuffer` → re-encode) + `MediaRecorder`/an encoder for output; video demux via a `<video>`+`MediaElementSource` capture or ffmpeg.wasm (weigh bundle cost). Feeds the soundboard uploader (`utils/soundboardClips.ts`, `SoundboardPackEditor`) and attachments. Design under TDS + native-cinny law. Big build — plan a dedicated session; evaluate ffmpeg.wasm size/CSP (wasm) before committing.
|
||||
|
||||
### [ ] P4-4 · Math / LaTeX Rendering (LOW PRIORITY)
|
||||
### [x] P4-4 · Math / LaTeX Rendering — DONE
|
||||
|
||||
Render `$…$` / `$$…$$` via KaTeX; graceful fallback to raw text. **Sanitizer must be patched** — `src/app/utils/sanitize.ts` (sanitize-html, `disallowedTagsMode:'discard'`) strips all MathML: add `<math><mi><mo><mn><mrow><mfrac><msqrt><mroot><msub><msup><msubsup><munder><mover><mtable><mtr><mtd>…` + `annotation` to `permittedHtmlTags`, and `xmlns`/`display`/`mathvariant` to `permittedTagToAttributes`. Parser: split text nodes on `/(\$\$.*?\$\$|\$.*?\$)/g` in `react-custom-html-parser.tsx` → `<KaTeX>`. Lazy-import `katex/dist/katex.min.css` only when a math block renders. Verify KaTeX bundle-size impact.
|
||||
Rendering shipped (KaTeX, `$…$`/`$$…$$` + spec `data-mx-maths`, lazy-loaded,
|
||||
`<pre>/<code>`-guarded) — see LOTUS_FEATURES.md. **Outgoing cross-client interop
|
||||
added (2026-07):** the composer now emits spec `data-mx-maths` HTML on send
|
||||
(`editor/output.ts`, reusing `splitMathSegments`), so math a Lotus user types
|
||||
renders on Element and every other client, not just Lotus. Deferred: multi-line
|
||||
block `$$…$$` (spans editor paragraph nodes) still renders on Lotus via the
|
||||
plain-body `$…$` path only.
|
||||
|
||||
### [~] P5-20 · Quick Reply from Browser Notification (partial)
|
||||
|
||||
@@ -168,9 +284,44 @@ 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 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.)
|
||||
- **Vimeo `event/{id}` (live events) + `ondemand/…`** — event embed host is `vimeo.com` (**not** `player.vimeo.com`, so it needs a new CSP `frame-src` host); on-demand is paywalled and doesn't embed for non-purchasers. Low ROI — only do the event case if `vimeo.com` is widened for another reason.
|
||||
|
||||
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)
|
||||
|
||||
@@ -180,21 +331,22 @@ Comprehensive audit of all LOTUS_FEATURES.md features for mobile PWA usability +
|
||||
|
||||
## 🚫 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.
|
||||
- **[BLOCKED] Room Preview before joining** (MSC3266) — `GET /v1/rooms/{id}/summary` returns 404 `M_UNRECOGNIZED` on Synapse 1.155 despite `msc3266_enabled:true`.
|
||||
- **[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).
|
||||
|
||||
---
|
||||
|
||||
## 📖 Reference
|
||||
|
||||
### Server Capabilities (as of 2026-06)
|
||||
### Server Capabilities (as of 2026-07)
|
||||
|
||||
- **Homeserver** `matrix.lotusguild.org` · **Synapse** `1.155.0` · **Matrix spec** up to `v1.12` (+ MSC `unstable_features`).
|
||||
- **MSC ON:** `msc4140` · `msc3771` · `msc3440.stable` · `msc4133.stable` · `simplified_msc3575` · `msc4222` · `msc3266` (flag on but v1 summary 404s) · `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`.
|
||||
@@ -241,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,11 +36,13 @@ 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
|
||||
- Code blocks highlight syntax for JS/TS, Python, and Rust
|
||||
- Rich link preview cards for YouTube, GitHub, Twitter/X, Reddit, Spotify, Twitch, Steam, Wikipedia, Discord, npm, Stack Overflow, and IMDb
|
||||
- Inline media embeds — play videos and posts in place instead of opening a browser tab: YouTube/Shorts, Vimeo, Dailymotion, Streamable, Twitch, Loom, and Kick as video players; TikTok, X/Twitter, Instagram, Reddit, and Bluesky as inline posts; Spotify, SoundCloud, Apple Music, and Tidal as a built-in audio player. A privacy-friendly facade shows the homeserver's cached thumbnail and only loads the third-party player when you press play. Toggle at Settings → General → "Inline Media Players" (on by default)
|
||||
|
||||
### Calls & Voice
|
||||
|
||||
|
||||
+12
-12
@@ -11,58 +11,58 @@
|
||||
"theme_color": "#980000",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./res/android/android-chrome-36x36.png",
|
||||
"src": "./public/res/android/android-chrome-36x36.png",
|
||||
"sizes": "36x36",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./res/android/android-chrome-48x48.png",
|
||||
"src": "./public/res/android/android-chrome-48x48.png",
|
||||
"sizes": "48x48",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./res/android/android-chrome-72x72.png",
|
||||
"src": "./public/res/android/android-chrome-72x72.png",
|
||||
"sizes": "72x72",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./res/android/android-chrome-96x96.png",
|
||||
"src": "./public/res/android/android-chrome-96x96.png",
|
||||
"sizes": "96x96",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./res/android/android-chrome-144x144.png",
|
||||
"src": "./public/res/android/android-chrome-144x144.png",
|
||||
"sizes": "144x144",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./res/android/android-chrome-192x192.png",
|
||||
"src": "./public/res/android/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./res/android/android-chrome-256x256.png",
|
||||
"src": "./public/res/android/android-chrome-256x256.png",
|
||||
"sizes": "256x256",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./res/android/android-chrome-384x384.png",
|
||||
"src": "./public/res/android/android-chrome-384x384.png",
|
||||
"sizes": "384x384",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./res/android/android-chrome-512x512.png",
|
||||
"src": "./public/res/android/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./res/android/maskable-192x192.png",
|
||||
"src": "./public/res/android/maskable-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "./res/android/maskable-512x512.png",
|
||||
"src": "./public/res/android/maskable-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
@@ -77,7 +77,7 @@
|
||||
"url": "/",
|
||||
"icons": [
|
||||
{
|
||||
"src": "res/android/android-chrome-96x96.png",
|
||||
"src": "public/res/android/android-chrome-96x96.png",
|
||||
"sizes": "96x96"
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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';
|
||||
@@ -59,7 +59,7 @@ import { useTheme, ThemeKind } from '../hooks/useTheme';
|
||||
import { useReducedMotion } from '../hooks/useReducedMotion';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
|
||||
import { getStateEvent, getStateEvents, getMemberName } from '../utils/room';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||
@@ -161,9 +161,7 @@ function IncomingCall({ dm, info, onIgnore, onAnswer, onReject }: IncomingCallPr
|
||||
<Dialog style={{ maxWidth: toRem(324) }}>
|
||||
<Box style={{ padding: config.space.S400 }} direction="Column" gap="700">
|
||||
<Text size="T200" align="Center">
|
||||
{getMemberDisplayName(info.room, info.sender) ??
|
||||
getMxIdLocalPart(info.sender) ??
|
||||
info.sender}
|
||||
{getMemberName(info.room, info.sender)}
|
||||
</Text>
|
||||
<Box direction="Column" gap="500" alignItems="Center">
|
||||
<Box shrink="No">
|
||||
@@ -316,8 +314,7 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
|
||||
return () => clearTimeout(id);
|
||||
}, [info.senderTs, info.lifetime, onIgnore]);
|
||||
|
||||
const callerName =
|
||||
getMemberDisplayName(info.room, info.sender) ?? getMxIdLocalPart(info.sender) ?? info.sender;
|
||||
const callerName = getMemberName(info.room, info.sender);
|
||||
|
||||
return (
|
||||
<Box
|
||||
@@ -453,8 +450,7 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
decliner !== mx.getSafeUserId() &&
|
||||
callEmbed?.roomId === room.roomId
|
||||
) {
|
||||
const declinerName =
|
||||
getMemberDisplayName(room, decliner) ?? getMxIdLocalPart(decliner) ?? decliner;
|
||||
const declinerName = getMemberName(room, decliner);
|
||||
setToast({
|
||||
id: `rtc-decline-${event.getId() ?? decliner}`,
|
||||
displayName: declinerName,
|
||||
@@ -477,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 =
|
||||
@@ -544,11 +548,16 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
|
||||
const handleReject = useCallback(
|
||||
(room: Room, eventId: string) => {
|
||||
// Best-effort: the local UI dismisses regardless (below), but a failed
|
||||
// decline used to reject uncaught. Log it instead of surfacing — the caller
|
||||
// will time the call out on their side even if our decline never lands.
|
||||
mx.sendEvent(room.roomId, EventType.RTCDecline, {
|
||||
'm.relates_to': {
|
||||
rel_type: RelationType.Reference,
|
||||
event_id: eventId,
|
||||
},
|
||||
}).catch((err) => {
|
||||
console.error('Failed to send call decline:', err);
|
||||
});
|
||||
setCallInfo(undefined);
|
||||
},
|
||||
@@ -694,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;
|
||||
@@ -705,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,12 +1,16 @@
|
||||
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))`;
|
||||
|
||||
type GifPickerInnerProps = {
|
||||
onSelect: (url: string, width: number, height: number) => void;
|
||||
@@ -14,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}px` }}>
|
||||
<Box direction="Column" style={{ width: PICKER_WIDTH_CSS }} ref={containerRef}>
|
||||
{lotusTerminal && (
|
||||
<div
|
||||
style={{
|
||||
@@ -56,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}
|
||||
@@ -88,7 +218,7 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
||||
overflow: 'hidden',
|
||||
boxShadow:
|
||||
'0 4px 24px color-mix(in srgb, var(--lt-accent-orange) 10%, transparent), 0 0 0 1px color-mix(in srgb, var(--lt-accent-orange) 8%, transparent)',
|
||||
width: `${PICKER_WIDTH}px`,
|
||||
width: PICKER_WIDTH_CSS,
|
||||
}
|
||||
: {
|
||||
background: color.Surface.Container,
|
||||
@@ -96,14 +226,14 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
||||
borderRadius: config.radii.R400,
|
||||
overflow: 'hidden',
|
||||
boxShadow: color.Other.Shadow,
|
||||
width: `${PICKER_WIDTH}px`,
|
||||
width: PICKER_WIDTH_CSS,
|
||||
};
|
||||
|
||||
return (
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
returnFocusOnDeactivate: false,
|
||||
returnFocusOnDeactivate: true,
|
||||
onDeactivate: requestClose,
|
||||
clickOutsideDeactivates: true,
|
||||
allowOutsideClick: true,
|
||||
|
||||
@@ -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,
|
||||
@@ -86,7 +87,10 @@ export function RenderMessageContent({
|
||||
eventId,
|
||||
}: RenderMessageContentProps) {
|
||||
const renderUrlsPreview = (urls: string[]) => {
|
||||
const filteredUrls = urls.filter((url) => !testMatrixTo(url));
|
||||
// Cap previews per message so a link-dump doesn't spawn dozens of preview
|
||||
// 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>
|
||||
@@ -117,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;
|
||||
};
|
||||
|
||||
|
||||
@@ -6,24 +6,42 @@ import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||
import { LocalRoomSummary, useLocalRoomSummary } from '../hooks/useLocalRoomSummary';
|
||||
import { AsyncState, AsyncStatus } from '../hooks/useAsyncCallback';
|
||||
|
||||
export type IRoomSummary = Awaited<ReturnType<MatrixClient['getRoomSummary']>>;
|
||||
// MSC3266 returns canonical_alias at runtime, but the SDK's RoomSummary type
|
||||
// Omit<>s it — add it back so callers can use it.
|
||||
export type IRoomSummary = Awaited<ReturnType<MatrixClient['getRoomSummary']>> & {
|
||||
canonical_alias?: string;
|
||||
};
|
||||
|
||||
export type RoomSummaryState = { loading: boolean; error: unknown };
|
||||
|
||||
type RoomSummaryLoaderProps = {
|
||||
roomIdOrAlias: string;
|
||||
children: (roomSummary?: IRoomSummary) => ReactNode;
|
||||
/**
|
||||
* Resident servers to route the summary request through. REQUIRED for a room
|
||||
* the local homeserver isn't already in (the exact case a preview exists for) —
|
||||
* without it the summary comes back sparse or 404s.
|
||||
*/
|
||||
via?: string[];
|
||||
children: (roomSummary: IRoomSummary | undefined, state: RoomSummaryState) => ReactNode;
|
||||
};
|
||||
|
||||
export function RoomSummaryLoader({ roomIdOrAlias, children }: RoomSummaryLoaderProps) {
|
||||
export function RoomSummaryLoader({ roomIdOrAlias, via, children }: RoomSummaryLoaderProps) {
|
||||
const mx = useMatrixClient();
|
||||
|
||||
const fetchSummary = useCallback(() => mx.getRoomSummary(roomIdOrAlias), [mx, roomIdOrAlias]);
|
||||
const fetchSummary = useCallback(
|
||||
() => mx.getRoomSummary(roomIdOrAlias, via),
|
||||
[mx, roomIdOrAlias, via],
|
||||
);
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: [roomIdOrAlias, `summary`],
|
||||
const { data, isLoading, error } = useQuery({
|
||||
// `via` is part of the key so a no-via failure isn't reused once we have servers.
|
||||
queryKey: [roomIdOrAlias, 'summary', via ?? []],
|
||||
queryFn: fetchSummary,
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
return children(data);
|
||||
return children(data, { loading: isLoading, error });
|
||||
}
|
||||
|
||||
export function LocalRoomSummaryLoader({
|
||||
|
||||
@@ -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,16 +253,19 @@ 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={{
|
||||
background: color.SurfaceVariant.Container,
|
||||
borderRadius: config.radii.R300,
|
||||
padding: `${toRem(4)} ${toRem(8)}`,
|
||||
maxWidth: '100%',
|
||||
minWidth: 0,
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@@ -207,11 +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={`${paused ? 'Paused' : 'Recording'}, duration ${formatDuration(durationMs)}`}
|
||||
style={{
|
||||
minWidth: toRem(32),
|
||||
fontVariantNumeric: 'tabular-nums',
|
||||
@@ -230,7 +303,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
||||
data-voice-waveform
|
||||
alignItems="Center"
|
||||
gap="100"
|
||||
style={{ height: toRem(20), overflow: 'hidden', flexShrink: 0 }}
|
||||
style={{ height: toRem(20), overflow: 'hidden', flexShrink: 1, minWidth: 0 }}
|
||||
>
|
||||
{waveformBars.map((h, i) => (
|
||||
<div
|
||||
@@ -245,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}
|
||||
@@ -263,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}</>;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'matrix-js-sdk';
|
||||
import { RoomJoinRulesEventContent } from 'matrix-js-sdk/lib/types';
|
||||
import { RoomType, StateEvent } from '../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../utils/room';
|
||||
import { getViaServers } from '../../plugins/via-servers';
|
||||
import { getMxIdServer } from '../../utils/matrix';
|
||||
import { CreateRoomAccess } from './types';
|
||||
@@ -150,9 +151,10 @@ export const createRoom = async (mx: MatrixClient, data: CreateRoomData): Promis
|
||||
const result = await mx.createRoom(options);
|
||||
|
||||
if (data.parent) {
|
||||
await mx.sendStateEvent(
|
||||
await sendStateEvent(
|
||||
mx,
|
||||
data.parent.roomId,
|
||||
StateEvent.SpaceChild as any,
|
||||
StateEvent.SpaceChild,
|
||||
{
|
||||
auto_join: false,
|
||||
suggested: false,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -16,7 +16,7 @@ import { onTabPress } from '../../../utils/keyboard';
|
||||
import { createMentionElement, moveCursor, replaceWithElement } from '../utils';
|
||||
import { useKeyDown } from '../../../hooks/useKeyDown';
|
||||
import { getMxIdLocalPart, getMxIdServer, isUserId } from '../../../utils/matrix';
|
||||
import { getMemberDisplayName, getMemberSearchStr } from '../../../utils/room';
|
||||
import { getMemberName, getMemberSearchStr } from '../../../utils/room';
|
||||
import { UserAvatar } from '../../user-avatar';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { Membership } from '../../../../types/matrix/room';
|
||||
@@ -140,8 +140,7 @@ export function UserMentionAutocomplete({
|
||||
});
|
||||
});
|
||||
|
||||
const getName = (member: RoomMember) =>
|
||||
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||
const getName = (member: RoomMember) => getMemberName(room, member.userId);
|
||||
|
||||
return (
|
||||
<AutocompleteMenu headerContent={<Text size="L400">Mentions</Text>} requestClose={requestClose}>
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { Descendant } from 'slate';
|
||||
import { toMatrixCustomHTML } from './output';
|
||||
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 OPTS = {
|
||||
allowTextFormatting: true,
|
||||
allowInlineMarkdown: false,
|
||||
allowBlockMarkdown: false,
|
||||
allowMath: true,
|
||||
};
|
||||
|
||||
test('inline $…$ → data-mx-maths span, surrounding prose preserved', () => {
|
||||
assert.equal(
|
||||
toMatrixCustomHTML(txt('a $x^2$ b'), OPTS),
|
||||
'a <span data-mx-maths="x^2"><code>x^2</code></span> b',
|
||||
);
|
||||
});
|
||||
|
||||
test('single-line block $$…$$ → data-mx-maths div', () => {
|
||||
assert.equal(
|
||||
toMatrixCustomHTML(txt('$$E=mc^2$$'), OPTS),
|
||||
'<div data-mx-maths="E=mc^2"><code>E=mc^2</code></div>',
|
||||
);
|
||||
});
|
||||
|
||||
test('LaTeX special chars are escaped in attribute + fallback', () => {
|
||||
assert.equal(
|
||||
toMatrixCustomHTML(txt('$a<b$'), OPTS),
|
||||
'<span data-mx-maths="a<b"><code>a<b</code></span>',
|
||||
);
|
||||
});
|
||||
|
||||
test('math bypasses markdown — underscores are not italicised', () => {
|
||||
// Without pre-markdown extraction, `_` would become <em>.
|
||||
assert.equal(
|
||||
toMatrixCustomHTML(txt('$a_b$'), { ...OPTS, allowInlineMarkdown: true }),
|
||||
'<span data-mx-maths="a_b"><code>a_b</code></span>',
|
||||
);
|
||||
});
|
||||
|
||||
test('currency ($5 and $10) stays literal', () => {
|
||||
assert.equal(toMatrixCustomHTML(txt('$5 and $10'), OPTS), '$5 and $10');
|
||||
});
|
||||
|
||||
test('no math conversion inside an inline code mark', () => {
|
||||
const out = toMatrixCustomHTML(txt('$x$', { code: true }), OPTS);
|
||||
assert.ok(!out.includes('data-mx-maths'));
|
||||
assert.ok(out.includes('<code>$x$</code>'));
|
||||
});
|
||||
|
||||
test('no math conversion inside a code block', () => {
|
||||
const block = el(BlockType.CodeBlock, [el(BlockType.CodeLine, [txt('$x$')])]);
|
||||
const out = toMatrixCustomHTML(block, OPTS);
|
||||
assert.ok(!out.includes('data-mx-maths'));
|
||||
assert.ok(out.includes('$x$'));
|
||||
});
|
||||
|
||||
test('a message with no math is unchanged', () => {
|
||||
assert.equal(toMatrixCustomHTML(txt('just hello'), OPTS), 'just hello');
|
||||
});
|
||||
@@ -12,14 +12,42 @@ import {
|
||||
import { findAndReplace } from '../../utils/findAndReplace';
|
||||
import { sanitizeForRegex } from '../../utils/regex';
|
||||
import { isUserId } from '../../utils/matrix';
|
||||
import { splitMathSegments } from '../../utils/mathParse';
|
||||
|
||||
export type OutputOptions = {
|
||||
allowTextFormatting?: boolean;
|
||||
allowInlineMarkdown?: boolean;
|
||||
allowBlockMarkdown?: boolean;
|
||||
allowMath?: boolean;
|
||||
};
|
||||
|
||||
// Spec `data-mx-maths` markup (CS-API §11.5): the attribute holds the LaTeX; the
|
||||
// <code> child is the fallback for clients that don't render math. sanitizeText
|
||||
// escapes & < > " ' — correct for both the attribute value and the <code> text.
|
||||
const mathToCustomHtml = (latex: string, block: boolean): string => {
|
||||
const esc = sanitizeText(latex);
|
||||
const tag = block ? 'div' : 'span';
|
||||
return `<${tag} data-mx-maths="${esc}"><code>${esc}</code></${tag}>`;
|
||||
};
|
||||
|
||||
const textToCustomHtml = (node: Text, opts: OutputOptions): string => {
|
||||
// Convert `$…$`/`$$…$$` to `data-mx-maths` so math renders on other clients.
|
||||
// Extracted BEFORE markdown so LaTeX (`_`, `*`, `\`, `{}`) isn't mangled; never
|
||||
// applied inside inline code. Non-math text recurses with allowMath off so it
|
||||
// still gets the normal marks + inline-markdown treatment.
|
||||
if (opts.allowMath && !node.code) {
|
||||
const segments = splitMathSegments(node.text);
|
||||
if (segments.some((seg) => seg.type !== 'text')) {
|
||||
return segments
|
||||
.map((seg) =>
|
||||
seg.type === 'text'
|
||||
? textToCustomHtml({ ...node, text: seg.value }, { ...opts, allowMath: false })
|
||||
: mathToCustomHtml(seg.value, seg.type === 'block'),
|
||||
)
|
||||
.join('');
|
||||
}
|
||||
}
|
||||
|
||||
let string = sanitizeText(node.text);
|
||||
if (opts.allowTextFormatting) {
|
||||
if (node.bold) string = `<strong>${string}</strong>`;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
@@ -15,12 +15,10 @@ import {
|
||||
} from 'folds';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useRoomEventReaders } from '../../hooks/useRoomEventReaders';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import * as css from './EventReaders.css';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { UserAvatar } from '../user-avatar';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { useSpaceOptionally } from '../../hooks/useSpace';
|
||||
import { getMouseEventCords } from '../../utils/dom';
|
||||
@@ -38,6 +36,64 @@ function formatReadTs(ts: number, hour24Clock: boolean): string {
|
||||
: `${timeMon(ts)} ${timeDay(ts)} ${timeYear(ts)} at ${timeStr}`;
|
||||
}
|
||||
|
||||
type EventReaderItemProps = {
|
||||
room: Room;
|
||||
readerId: string;
|
||||
hour24Clock: boolean;
|
||||
lotusTerminal: boolean;
|
||||
onSelect: React.MouseEventHandler<HTMLButtonElement>;
|
||||
};
|
||||
function EventReaderItem({
|
||||
room,
|
||||
readerId,
|
||||
hour24Clock,
|
||||
lotusTerminal,
|
||||
onSelect,
|
||||
}: EventReaderItemProps) {
|
||||
const { name, avatarUrl } = useMemberAvatar(room, readerId, 100, 100);
|
||||
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
style={{ padding: `0 ${config.space.S200}` }}
|
||||
radii="400"
|
||||
onClick={onSelect}
|
||||
before={
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={readerId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Box direction="Column" grow="Yes">
|
||||
<Text size="T400" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
{receiptTs !== undefined && (
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={
|
||||
lotusTerminal
|
||||
? {
|
||||
color: 'var(--lt-accent-amber)',
|
||||
textShadow: 'var(--lt-glow-amber)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatReadTs(receiptTs, hour24Clock)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
export type EventReadersProps = {
|
||||
room: Room;
|
||||
eventId: string;
|
||||
@@ -46,7 +102,6 @@ export type EventReadersProps = {
|
||||
export const EventReaders = as<'div', EventReadersProps>(
|
||||
({ className, room, eventId, requestClose, ...props }, ref) => {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const myUserId = mx.getUserId();
|
||||
const latestEventReaders = useRoomEventReaders(room, eventId).filter((id) => id !== myUserId);
|
||||
const openProfile = useOpenUserRoomProfile();
|
||||
@@ -54,9 +109,6 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||
const [hour24Clock] = useSetting(settingsAtom, 'hour24Clock');
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
|
||||
const getName = (userId: string) =>
|
||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
|
||||
return (
|
||||
<Box
|
||||
className={classNames(css.EventReaders, className)}
|
||||
@@ -100,64 +152,24 @@ export const EventReaders = as<'div', EventReadersProps>(
|
||||
<Box grow="Yes">
|
||||
<Scroll visibility="Hover" hideTrack size="300">
|
||||
<Box className={css.Content} direction="Column">
|
||||
{latestEventReaders.map((readerId) => {
|
||||
const name = getName(readerId);
|
||||
const avatarMxcUrl = room.getMember(readerId)?.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxcUrl
|
||||
? (mxcUrlToHttp(mx, avatarMxcUrl, useAuthentication, 100, 100, 'crop') ??
|
||||
undefined)
|
||||
: undefined;
|
||||
const receiptTs = room.getReadReceiptForUserId(readerId)?.data.ts;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={readerId}
|
||||
style={{ padding: `0 ${config.space.S200}` }}
|
||||
radii="400"
|
||||
onClick={(event) => {
|
||||
openProfile(
|
||||
room.roomId,
|
||||
space?.roomId,
|
||||
readerId,
|
||||
getMouseEventCords(event.nativeEvent),
|
||||
'Bottom',
|
||||
);
|
||||
}}
|
||||
before={
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={readerId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Box direction="Column" grow="Yes">
|
||||
<Text size="T400" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
{receiptTs !== undefined && (
|
||||
<Text
|
||||
size="T200"
|
||||
priority="300"
|
||||
style={
|
||||
lotusTerminal
|
||||
? {
|
||||
color: 'var(--lt-accent-amber)',
|
||||
textShadow: 'var(--lt-glow-amber)',
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{formatReadTs(receiptTs, hour24Clock)}
|
||||
</Text>
|
||||
)}
|
||||
</Box>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
{latestEventReaders.map((readerId) => (
|
||||
<EventReaderItem
|
||||
key={readerId}
|
||||
room={room}
|
||||
readerId={readerId}
|
||||
hour24Clock={hour24Clock}
|
||||
lotusTerminal={lotusTerminal}
|
||||
onSelect={(event) => {
|
||||
openProfile(
|
||||
room.roomId,
|
||||
space?.roomId,
|
||||
readerId,
|
||||
getMouseEventCords(event.nativeEvent),
|
||||
'Bottom',
|
||||
);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { ImagePackContent } from './ImagePackContent';
|
||||
import { ImagePack, PackContent } from '../../plugins/custom-emoji';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../utils/room';
|
||||
import { useRoomImagePack } from '../../hooks/useImagePacks';
|
||||
import { randomStr } from '../../utils/common';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
@@ -45,10 +46,11 @@ export function RoomImagePack({ room, stateKey }: RoomImagePackProps) {
|
||||
const { address } = imagePack;
|
||||
if (!address) return;
|
||||
|
||||
await mx.sendStateEvent(
|
||||
await sendStateEvent(
|
||||
mx,
|
||||
address.roomId,
|
||||
StateEvent.PoniesRoomEmotes as unknown as keyof import('matrix-js-sdk').StateEvents,
|
||||
packContent as any,
|
||||
StateEvent.PoniesRoomEmotes,
|
||||
packContent,
|
||||
address.stateKey,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { ReactNode } from 'react';
|
||||
import { as, Avatar, Box, Icon, Icons, Text } from 'folds';
|
||||
import { MatrixClient, Room, RoomMember } from 'matrix-js-sdk';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMemberName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { UserAvatar } from '../user-avatar';
|
||||
import * as css from './style.css';
|
||||
|
||||
const getName = (room: Room, member: RoomMember) =>
|
||||
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||
const getName = (room: Room, member: RoomMember) => getMemberName(room, member.userId);
|
||||
|
||||
type MemberTileProps = {
|
||||
mx: MatrixClient;
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -12,21 +12,35 @@ 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 { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { getMemberName } from '../../utils/room';
|
||||
import { UserAvatar } from '../user-avatar';
|
||||
import { StackedAvatar } from '../stacked-avatar';
|
||||
import { EventReaders } from '../event-readers';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useModalStyle } from '../../hooks/useModalStyle';
|
||||
import { useMemberAvatar } from '../../hooks/useMemberAvatar';
|
||||
import { useRoomMembersChange } from '../../hooks/useRoomMemberChange';
|
||||
import { MobileTouchTarget } from '../../styles/mobile.css';
|
||||
import * as css from './ReadReceiptAvatars.css';
|
||||
|
||||
const MAX_DISPLAY = 5;
|
||||
|
||||
function ReceiptStackedAvatar({ 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>
|
||||
);
|
||||
}
|
||||
|
||||
export function ReadReceiptAvatars({
|
||||
room,
|
||||
eventId,
|
||||
@@ -36,12 +50,16 @@ export function ReadReceiptAvatars({
|
||||
eventId: string;
|
||||
userIds: string[];
|
||||
}) {
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||
const modalStyle = useModalStyle(360);
|
||||
|
||||
// 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;
|
||||
|
||||
const displayed = userIds.slice(0, MAX_DISPLAY);
|
||||
@@ -49,7 +67,7 @@ export function ReadReceiptAvatars({
|
||||
const tooltipNames =
|
||||
userIds
|
||||
.slice(0, 5)
|
||||
.map((id) => getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id)
|
||||
.map((id) => getMemberName(room, id))
|
||||
.join(', ') + (extra > 0 ? ` +${extra} more` : '');
|
||||
|
||||
return (
|
||||
@@ -75,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
|
||||
@@ -83,40 +101,20 @@ export function ReadReceiptAvatars({
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
backgroundColor: lotusTerminal
|
||||
? 'rgba(0,212,255,0.07)'
|
||||
? 'color-mix(in srgb, var(--lt-accent-cyan) 7%, transparent)'
|
||||
: color.SurfaceVariant.Container,
|
||||
border: lotusTerminal
|
||||
? `${config.borderWidth.B300} solid rgba(0,212,255,0.30)`
|
||||
? `${config.borderWidth.B300} solid color-mix(in srgb, var(--lt-accent-cyan) 30%, transparent)`
|
||||
: `${config.borderWidth.B300} solid transparent`,
|
||||
boxShadow: lotusTerminal ? '0 0 10px rgba(0,212,255,0.12)' : 'none',
|
||||
boxShadow: lotusTerminal ? 'var(--lt-box-glow-cyan)' : 'none',
|
||||
borderRadius: config.radii.Pill,
|
||||
padding: `${config.space.S100} ${config.space.S200}`,
|
||||
gap: '0px',
|
||||
}}
|
||||
>
|
||||
{displayed.map((userId) => {
|
||||
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const avatarMxc = room.getMember(userId)?.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxc
|
||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ?? undefined)
|
||||
: undefined;
|
||||
return (
|
||||
<StackedAvatar
|
||||
key={userId}
|
||||
title={name}
|
||||
variant="SurfaceVariant"
|
||||
size="200"
|
||||
radii="Pill"
|
||||
>
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</StackedAvatar>
|
||||
);
|
||||
})}
|
||||
{displayed.map((userId) => (
|
||||
<ReceiptStackedAvatar key={userId} room={room} userId={userId} />
|
||||
))}
|
||||
{extra > 0 && (
|
||||
<Text
|
||||
size="T200"
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { ReactNode, useCallback, useRef, useState } from 'react';
|
||||
import { MatrixError, Room } from 'matrix-js-sdk';
|
||||
import { JoinRule, MatrixError, Room } from 'matrix-js-sdk';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
@@ -139,11 +139,29 @@ type RoomCardProps = {
|
||||
topic?: string;
|
||||
memberCount?: number;
|
||||
roomType?: string;
|
||||
joinRule?: string;
|
||||
encrypted?: boolean;
|
||||
canonicalAlias?: string;
|
||||
worldReadable?: boolean;
|
||||
/** The viewer's own membership over federation (leave/invite/knock/ban). */
|
||||
membership?: string;
|
||||
/** Larger, centered treatment for the single-room preview page. */
|
||||
hero?: boolean;
|
||||
viaServers?: string[];
|
||||
onView?: (roomId: string) => void;
|
||||
renderTopicViewer: (name: string, topic: string, requestClose: () => void) => ReactNode;
|
||||
};
|
||||
|
||||
// Map a room's join rule to a short preview chip. Public is the common case and
|
||||
// gets no chip (avoids noise); the rest tell the user how entry works.
|
||||
const joinRuleLabel = (joinRule?: string): string | undefined => {
|
||||
if (joinRule === JoinRule.Restricted || joinRule === 'knock_restricted') return 'Restricted';
|
||||
if (joinRule === JoinRule.Knock) return 'Ask to join';
|
||||
if (joinRule === JoinRule.Invite) return 'Invite only';
|
||||
if (joinRule === JoinRule.Private) return 'Private';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const RoomCard = as<'div', RoomCardProps>(
|
||||
(
|
||||
{
|
||||
@@ -154,6 +172,12 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||
topic,
|
||||
memberCount,
|
||||
roomType,
|
||||
joinRule,
|
||||
encrypted,
|
||||
canonicalAlias,
|
||||
worldReadable,
|
||||
membership,
|
||||
hero,
|
||||
viaServers,
|
||||
onView,
|
||||
renderTopicViewer,
|
||||
@@ -169,16 +193,19 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||
joinedRoom ? getStateEvent(joinedRoom, StateEvent.RoomTopic) : undefined,
|
||||
);
|
||||
|
||||
const fallbackName = getMxIdLocalPart(roomIdOrAlias) ?? roomIdOrAlias;
|
||||
const fallbackTopic = roomIdOrAlias;
|
||||
// Name falls back to the alias (readable), then the alias localpart, then the
|
||||
// raw id — never the raw `!id` when a nicer form exists.
|
||||
const fallbackName = canonicalAlias || getMxIdLocalPart(roomIdOrAlias) || roomIdOrAlias;
|
||||
|
||||
const avatar = joinedRoom
|
||||
? getRoomAvatarUrl(mx, joinedRoom, 96, useAuthentication)
|
||||
: avatarUrl && mxcUrlToHttp(mx, avatarUrl, useAuthentication, 96, 96, 'crop');
|
||||
|
||||
const roomName = joinedRoom?.name || name || fallbackName;
|
||||
const roomTopic =
|
||||
(topicEvent?.getContent().topic as string) || undefined || topic || fallbackTopic;
|
||||
// No topic → render a muted placeholder, NOT the raw room id.
|
||||
const roomTopic = (topicEvent?.getContent().topic as string) || topic || undefined;
|
||||
// Only show an alias line when it adds info beyond the displayed name.
|
||||
const aliasLine = canonicalAlias && canonicalAlias !== roomName ? canonicalAlias : undefined;
|
||||
const joinedMemberCount = joinedRoom?.getJoinedMemberCount() ?? memberCount;
|
||||
|
||||
useStateEventCallback(
|
||||
@@ -203,8 +230,31 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||
[mx, roomIdOrAlias, viaServers],
|
||||
),
|
||||
);
|
||||
const joining =
|
||||
joinState.status === AsyncStatus.Loading || joinState.status === AsyncStatus.Success;
|
||||
const [knockState, knock] = useAsyncCallback<{ room_id: string }, MatrixError, []>(
|
||||
useCallback(
|
||||
() => mx.knockRoom(roomIdOrAlias, { viaServers }),
|
||||
[mx, roomIdOrAlias, viaServers],
|
||||
),
|
||||
);
|
||||
// A knock-rule room can't be joined directly — request to join instead.
|
||||
const isKnock = joinRule === JoinRule.Knock;
|
||||
// Membership known from the summary survives reloads (unlike local knockState).
|
||||
const invited = membership === 'invite';
|
||||
const alreadyKnocked = membership === 'knock';
|
||||
const banned = membership === 'ban';
|
||||
const action = isKnock ? knock : join;
|
||||
const actionState = isKnock ? knockState : joinState;
|
||||
const acting =
|
||||
actionState.status === AsyncStatus.Loading || actionState.status === AsyncStatus.Success;
|
||||
const requested = alreadyKnocked || knockState.status === AsyncStatus.Success;
|
||||
let actionLabel: string;
|
||||
if (banned) actionLabel = 'Banned';
|
||||
else if (invited) actionLabel = acting ? 'Joining' : 'Accept invite';
|
||||
else if (isKnock)
|
||||
actionLabel = requested ? 'Requested' : acting ? 'Requesting' : 'Request to join';
|
||||
else actionLabel = acting ? 'Joining' : 'Join';
|
||||
const actionDisabled = banned || requested || acting;
|
||||
const chip = joinRuleLabel(joinRule);
|
||||
|
||||
const [viewTopic, setViewTopic] = useState(false);
|
||||
const closeTopic = () => setViewTopic(false);
|
||||
@@ -233,9 +283,25 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||
</Box>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<RoomCardName>{roomName}</RoomCardName>
|
||||
<RoomCardTopic onClick={openTopic} onKeyDown={onEnterOrSpace(openTopic)} tabIndex={0}>
|
||||
{roomTopic}
|
||||
</RoomCardTopic>
|
||||
{aliasLine && (
|
||||
<Text size="T200" priority="300" truncate>
|
||||
{aliasLine}
|
||||
</Text>
|
||||
)}
|
||||
{roomTopic ? (
|
||||
<RoomCardTopic
|
||||
onClick={openTopic}
|
||||
onKeyDown={onEnterOrSpace(openTopic)}
|
||||
tabIndex={0}
|
||||
style={hero ? { WebkitLineClamp: 10 } : undefined}
|
||||
>
|
||||
{roomTopic}
|
||||
</RoomCardTopic>
|
||||
) : (
|
||||
<Text size="T200" priority="300" style={{ opacity: 0.6 }}>
|
||||
No description
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Overlay open={viewTopic} backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
@@ -247,7 +313,7 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
{renderTopicViewer(roomName, roomTopic, closeTopic)}
|
||||
{renderTopicViewer(roomName, roomTopic ?? '', closeTopic)}
|
||||
</FocusTrap>
|
||||
</OverlayCenter>
|
||||
</Overlay>
|
||||
@@ -258,6 +324,25 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||
<Text size="T200">{`${millify(joinedMemberCount)} Members`}</Text>
|
||||
</Box>
|
||||
)}
|
||||
{!joinedRoom && (chip || encrypted || worldReadable) && (
|
||||
<Box gap="100" wrap="Wrap">
|
||||
{chip && (
|
||||
<Badge variant="Secondary" fill="Soft" outlined>
|
||||
<Text size="L400">{chip}</Text>
|
||||
</Badge>
|
||||
)}
|
||||
{encrypted && (
|
||||
<Badge variant="Success" fill="Soft" outlined>
|
||||
<Text size="L400">Encrypted</Text>
|
||||
</Badge>
|
||||
)}
|
||||
{worldReadable && (
|
||||
<Badge variant="Secondary" fill="Soft" outlined>
|
||||
<Text size="L400">Readable</Text>
|
||||
</Badge>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
{typeof joinedRoomId === 'string' && (
|
||||
<Button
|
||||
onClick={onView ? () => onView(joinedRoomId) : undefined}
|
||||
@@ -270,23 +355,24 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
{typeof joinedRoomId !== 'string' && joinState.status !== AsyncStatus.Error && (
|
||||
{typeof joinedRoomId !== 'string' && actionState.status !== AsyncStatus.Error && (
|
||||
<Button
|
||||
onClick={join}
|
||||
variant="Secondary"
|
||||
onClick={action}
|
||||
variant={hero ? 'Primary' : 'Secondary'}
|
||||
fill={hero ? 'Solid' : undefined}
|
||||
size="300"
|
||||
disabled={joining}
|
||||
before={joining && <Spinner size="50" variant="Secondary" fill="Soft" />}
|
||||
disabled={actionDisabled}
|
||||
before={acting && <Spinner size="50" variant="Secondary" fill="Soft" />}
|
||||
>
|
||||
<Text size="B300" truncate>
|
||||
{joining ? 'Joining' : 'Join'}
|
||||
{actionLabel}
|
||||
</Text>
|
||||
</Button>
|
||||
)}
|
||||
{typeof joinedRoomId !== 'string' && joinState.status === AsyncStatus.Error && (
|
||||
{typeof joinedRoomId !== 'string' && actionState.status === AsyncStatus.Error && (
|
||||
<Box gap="200">
|
||||
<Button
|
||||
onClick={join}
|
||||
onClick={action}
|
||||
className={css.ActionButton}
|
||||
variant="Critical"
|
||||
fill="Solid"
|
||||
@@ -297,8 +383,8 @@ export const RoomCard = as<'div', RoomCardProps>(
|
||||
</Text>
|
||||
</Button>
|
||||
<ErrorDialog
|
||||
title="Join Error"
|
||||
message={joinState.error.message || 'Failed to join. Unknown Error.'}
|
||||
title={isKnock ? 'Request Error' : 'Join Error'}
|
||||
message={actionState.error.message || 'Failed to join. Unknown Error.'}
|
||||
>
|
||||
{(openError) => (
|
||||
<Button
|
||||
|
||||
@@ -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': {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { SoundboardPackEditor } from './SoundboardPackEditor';
|
||||
import { SoundboardContent, SoundboardPack } from '../../plugins/soundboard';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../utils/room';
|
||||
import { useRoomSoundboardPack } from '../../hooks/useSoundboardPacks';
|
||||
import { PackAddress } from '../../plugins/custom-emoji/PackAddress';
|
||||
import { randomStr } from '../../utils/common';
|
||||
@@ -35,12 +36,7 @@ export function RoomSoundboardPack({ room, stateKey }: RoomSoundboardPackProps)
|
||||
|
||||
const handleUpdate = useCallback(
|
||||
async (content: SoundboardContent) => {
|
||||
await mx.sendStateEvent(
|
||||
room.roomId,
|
||||
StateEvent.LotusSoundboardRoom as unknown as keyof import('matrix-js-sdk').StateEvents,
|
||||
content as never,
|
||||
stateKey,
|
||||
);
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.LotusSoundboardRoom, content, stateKey);
|
||||
},
|
||||
[mx, room.roomId, stateKey],
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
@@ -213,11 +228,16 @@ export const EmbedMediaLandscape = style([
|
||||
{
|
||||
position: 'relative',
|
||||
width: '100%',
|
||||
aspectRatio: '16 / 9',
|
||||
// 16:9 via the padding-top hack (56.25%), NOT `aspect-ratio`. An absolutely-
|
||||
// positioned *replaced* element (the player <iframe>) collapses to its ~200px
|
||||
// intrinsic size inside an `aspect-ratio` box; padding derives a definite height
|
||||
// from the definite width so the iframe (inset:0) reliably fills it.
|
||||
height: 0,
|
||||
paddingTop: '56.25%',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: color.Surface.Container,
|
||||
selectors: {
|
||||
'&:fullscreen': { width: '100vw', height: '100vh', aspectRatio: 'auto' },
|
||||
'&:fullscreen': { width: '100vw', height: '100vh', paddingTop: 0 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -228,12 +248,14 @@ export const EmbedMediaPortrait = style([
|
||||
position: 'relative',
|
||||
width: toRem(300),
|
||||
maxWidth: '100%',
|
||||
aspectRatio: '9 / 16',
|
||||
// 9:16 via the padding-top hack (177.78%) — see EmbedMediaLandscape.
|
||||
height: 0,
|
||||
paddingTop: '177.78%',
|
||||
margin: '0 auto',
|
||||
overflow: 'hidden',
|
||||
backgroundColor: color.Surface.Container,
|
||||
selectors: {
|
||||
'&:fullscreen': { width: '100vw', height: '100vh', aspectRatio: 'auto', margin: 0 },
|
||||
'&:fullscreen': { width: '100vw', height: '100vh', paddingTop: 0, margin: 0 },
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -255,6 +277,12 @@ export const EmbedFacade = style([
|
||||
':hover': {
|
||||
filter: 'brightness(0.85)',
|
||||
},
|
||||
selectors: {
|
||||
'&:focus-visible': {
|
||||
outline: `2px solid ${color.Primary.Main}`,
|
||||
outlineOffset: '-2px',
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
@@ -270,11 +298,14 @@ export const EmbedIframe = style([
|
||||
},
|
||||
]);
|
||||
|
||||
// Variable-height iframe that sizes itself (X/Twitter post embed — height set inline).
|
||||
// Variable-height iframe that sizes itself (X/Twitter, Instagram, Reddit posts —
|
||||
// height set inline). Capped ~550px (X/IG native max) + centered in the wide card.
|
||||
export const EmbedIframeStatic = style([
|
||||
DefaultReset,
|
||||
{
|
||||
width: '100%',
|
||||
maxWidth: toRem(550),
|
||||
margin: '0 auto',
|
||||
border: 0,
|
||||
display: 'block',
|
||||
},
|
||||
@@ -388,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,
|
||||
@@ -448,11 +529,36 @@ export const BadgeInstagram = style({
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
export const BadgeBluesky = style({
|
||||
backgroundColor: '#0085ff',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
export const BadgeLoom = style({
|
||||
backgroundColor: '#625df5',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
export const BadgeKick = style({
|
||||
backgroundColor: '#53fc18',
|
||||
color: '#000000',
|
||||
});
|
||||
|
||||
export const BadgeTidal = style({
|
||||
backgroundColor: '#000000',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
export const BadgeMixcloud = style({
|
||||
backgroundColor: '#52aad8',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
export const BadgeDeezer = style({
|
||||
backgroundColor: '#a238ff',
|
||||
color: '#ffffff',
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Twitch LIVE badge
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||
|
||||
@@ -38,7 +38,7 @@ import { mDirectAtom } from '../../state/mDirectList';
|
||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
||||
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
|
||||
import { VirtualTile } from '../../components/virtualizer';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl } from '../../utils/room';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, sendStateEvent } from '../../utils/room';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
@@ -136,9 +136,10 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM
|
||||
await rateLimitedActions(selectedRooms, async (room) => {
|
||||
const via = getViaServers(room);
|
||||
|
||||
await mx.sendStateEvent(
|
||||
await sendStateEvent(
|
||||
mx,
|
||||
parentId,
|
||||
StateEvent.SpaceChild as any,
|
||||
StateEvent.SpaceChild,
|
||||
{
|
||||
auto_join: false,
|
||||
suggested: false,
|
||||
|
||||
@@ -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,11 +32,12 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
className={MobileTouchTarget}
|
||||
onClick={toggleMic}
|
||||
outlined
|
||||
disabled={disabled || loading}
|
||||
aria-label={enabled ? 'Turn off microphone' : 'Turn on microphone'}
|
||||
aria-pressed={!enabled}
|
||||
aria-pressed={enabled}
|
||||
>
|
||||
<Icon size="100" src={enabled ? Icons.Mic : Icons.MicMute} filled={!enabled} />
|
||||
</IconButton>
|
||||
@@ -66,8 +68,10 @@ function SoundButton({ enabled, onToggle, disabled }: SoundButtonProps) {
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
size="300"
|
||||
className={MobileTouchTarget}
|
||||
onClick={() => onToggle()}
|
||||
aria-label={enabled ? 'Undeafen' : 'Deafen'}
|
||||
aria-label={enabled ? 'Deafen' : 'Undeafen'}
|
||||
aria-pressed={enabled}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
>
|
||||
@@ -107,8 +111,10 @@ 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}
|
||||
outlined
|
||||
disabled={disabled || loading}
|
||||
>
|
||||
@@ -145,8 +151,10 @@ 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}
|
||||
outlined
|
||||
disabled={disabled}
|
||||
>
|
||||
|
||||
@@ -20,8 +20,8 @@ import FocusTrap from 'focus-trap-react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import * as css from './styles.css';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
@@ -45,93 +45,97 @@ export function LiveChip({ count, room, members }: LiveChipProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Top"
|
||||
align="Start"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu
|
||||
style={{
|
||||
maxHeight: '75vh',
|
||||
maxWidth: toRem(300),
|
||||
display: 'flex',
|
||||
<>
|
||||
<span className={css.SrOnly} role="status" aria-live="polite" aria-atomic="true">
|
||||
{`${count} in call`}
|
||||
</span>
|
||||
<PopOut
|
||||
anchor={cords}
|
||||
position="Top"
|
||||
align="Start"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Scroll size="0" hideTrack visibility="Hover">
|
||||
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
||||
{members.map((callMember) => {
|
||||
const userId = callMember.sender;
|
||||
if (!userId) return null;
|
||||
const name =
|
||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||
const avatarUrl = avatarMxc
|
||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
|
||||
: undefined;
|
||||
<Menu
|
||||
style={{
|
||||
maxHeight: '75vh',
|
||||
maxWidth: toRem(300),
|
||||
display: 'flex',
|
||||
}}
|
||||
>
|
||||
<Box grow="Yes">
|
||||
<Scroll size="0" hideTrack visibility="Hover">
|
||||
<Box direction="Column" style={{ padding: config.space.S100 }}>
|
||||
{members.map((callMember) => {
|
||||
const userId = callMember.sender;
|
||||
if (!userId) return null;
|
||||
const name = getMemberName(room, userId);
|
||||
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||
const avatarUrl = avatarMxc
|
||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<MenuItem
|
||||
key={callMember.memberId}
|
||||
size="400"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
style={{ paddingLeft: config.space.S200 }}
|
||||
onClick={(evt) =>
|
||||
openUserProfile(
|
||||
room.roomId,
|
||||
undefined,
|
||||
userId,
|
||||
getMouseEventCords(evt.nativeEvent),
|
||||
'Right',
|
||||
)
|
||||
}
|
||||
before={
|
||||
<Avatar size="200" radii="400">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Text size="T300" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Chip
|
||||
variant="Surface"
|
||||
fill="Soft"
|
||||
before={<Badge variant="Critical" fill="Solid" size="200" />}
|
||||
after={<Icon size="50" src={cords ? Icons.ChevronBottom : Icons.ChevronTop} />}
|
||||
radii="Pill"
|
||||
onClick={handleOpenMenu}
|
||||
return (
|
||||
<MenuItem
|
||||
key={callMember.memberId}
|
||||
size="400"
|
||||
variant="Surface"
|
||||
radii="300"
|
||||
style={{ paddingLeft: config.space.S200 }}
|
||||
onClick={(evt) =>
|
||||
openUserProfile(
|
||||
room.roomId,
|
||||
undefined,
|
||||
userId,
|
||||
getMouseEventCords(evt.nativeEvent),
|
||||
'Right',
|
||||
)
|
||||
}
|
||||
before={
|
||||
<Avatar size="200" radii="400">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
}
|
||||
>
|
||||
<Text size="T300" truncate>
|
||||
{name}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
<Text className={css.LiveChipText} as="span" size="L400" truncate>
|
||||
{count} Live
|
||||
</Text>
|
||||
</Chip>
|
||||
</PopOut>
|
||||
<Chip
|
||||
variant="Surface"
|
||||
fill="Soft"
|
||||
before={<Badge variant="Critical" fill="Solid" size="200" />}
|
||||
after={<Icon size="50" src={cords ? Icons.ChevronBottom : Icons.ChevronTop} />}
|
||||
radii="Pill"
|
||||
onClick={handleOpenMenu}
|
||||
>
|
||||
<Text className={css.LiveChipText} as="span" size="L400" truncate>
|
||||
{count} Live
|
||||
</Text>
|
||||
</Chip>
|
||||
</PopOut>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,8 +4,8 @@ import React, { useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { StackedAvatar } from '../../components/stacked-avatar';
|
||||
@@ -128,7 +128,7 @@ export function MemberGlance({ room, members, speakers, callEmbed, max = 6 }: Me
|
||||
{visibleMembers.map((callMember) => {
|
||||
const { userId } = callMember;
|
||||
if (!userId) return null;
|
||||
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const name = getMemberName(room, userId);
|
||||
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||
const avatarUrl = avatarMxc
|
||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
|
||||
|
||||
@@ -1,17 +1,14 @@
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import React from 'react';
|
||||
import { Box, Icon, Icons, Text } from 'folds';
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { getMemberName } from '../../utils/room';
|
||||
|
||||
type MemberSpeakingProps = {
|
||||
room: Room;
|
||||
speakers: Set<string>;
|
||||
};
|
||||
export function MemberSpeaking({ room, speakers }: MemberSpeakingProps) {
|
||||
const speakingNames = Array.from(speakers).map(
|
||||
(userId) => getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId,
|
||||
);
|
||||
const speakingNames = Array.from(speakers).map((userId) => getMemberName(room, userId));
|
||||
return (
|
||||
<Box alignItems="Center" gap="100">
|
||||
<Icon size="100" src={Icons.Mic} filled />
|
||||
|
||||
@@ -5,6 +5,18 @@ export const LiveChipText = style({
|
||||
color: color.Critical.Main,
|
||||
});
|
||||
|
||||
export const SrOnly = style({
|
||||
position: 'absolute',
|
||||
width: 1,
|
||||
height: 1,
|
||||
padding: 0,
|
||||
margin: -1,
|
||||
overflow: 'hidden',
|
||||
clip: 'rect(0, 0, 0, 0)',
|
||||
whiteSpace: 'nowrap',
|
||||
border: 0,
|
||||
});
|
||||
|
||||
export const CallStatus = style([
|
||||
{
|
||||
padding: `${toRem(6)} ${config.space.S200}`,
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -5,9 +5,9 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
||||
import { getMouseEventCords } from '../../utils/dom';
|
||||
@@ -26,7 +26,7 @@ export function CallMemberCard({ member }: CallMemberCardProps) {
|
||||
const { userId } = member;
|
||||
if (!userId) return null;
|
||||
|
||||
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const name = getMemberName(room, userId);
|
||||
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||
const avatarUrl = avatarMxc
|
||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96) ?? undefined)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -35,6 +35,7 @@ import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { usePowerLevels } from '../../../hooks/usePowerLevels';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { suffixRename } from '../../../utils/common';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
@@ -57,7 +58,7 @@ function CreatePackTile({ packs, roomId }: CreatePackTileProps) {
|
||||
display_name: name,
|
||||
},
|
||||
};
|
||||
await mx.sendStateEvent(roomId, StateEvent.PoniesRoomEmotes as any, content, stateKey);
|
||||
await sendStateEvent(mx, roomId, StateEvent.PoniesRoomEmotes, content, stateKey);
|
||||
},
|
||||
[mx, roomId],
|
||||
),
|
||||
@@ -164,7 +165,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) {
|
||||
for (let i = 0; i < removedPacks.length; i += 1) {
|
||||
const addr = removedPacks[i];
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.PoniesRoomEmotes as any, {}, addr.stateKey);
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.PoniesRoomEmotes, {}, addr.stateKey);
|
||||
}
|
||||
}, [mx, room, removedPacks]),
|
||||
);
|
||||
|
||||
@@ -23,6 +23,7 @@ import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
@@ -48,7 +49,7 @@ export function RoomEncryption({ permissions }: RoomEncryptionProps) {
|
||||
|
||||
const [enableState, enable] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomEncryption as any, {
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.RoomEncryption, {
|
||||
algorithm: ROOM_ENC_ALGO,
|
||||
});
|
||||
}, [mx, room.roomId]),
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { stopPropagation } from '../../../utils/keyboard';
|
||||
@@ -76,7 +77,7 @@ export function RoomHistoryVisibility({ permissions }: RoomHistoryVisibilityProp
|
||||
const content: RoomHistoryVisibilityEventContent = {
|
||||
history_visibility: visibility,
|
||||
};
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomHistoryVisibility as any, content);
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.RoomHistoryVisibility, content);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
),
|
||||
|
||||
@@ -18,7 +18,7 @@ import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { useSpaceOptionally } from '../../../hooks/useSpace';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { getStateEvents } from '../../../utils/room';
|
||||
import { getStateEvents, sendStateEvent } from '../../../utils/room';
|
||||
import {
|
||||
useRecursiveChildSpaceScopeFactory,
|
||||
useSpaceChildren,
|
||||
@@ -111,7 +111,7 @@ export function RoomJoinRules({ permissions }: RoomJoinRulesProps) {
|
||||
join_rule: joinRule as JoinRule,
|
||||
};
|
||||
if (allow.length > 0) c.allow = allow;
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomJoinRules as any, c);
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.RoomJoinRules, c);
|
||||
},
|
||||
[mx, room, space, subspaces, roomIdToParents],
|
||||
),
|
||||
|
||||
@@ -39,6 +39,7 @@ import { mxcUrlToHttp } from '../../../utils/matrix';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useObjectURL } from '../../../hooks/useObjectURL';
|
||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
@@ -122,7 +123,9 @@ export function RoomProfileEdit({
|
||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||
|
||||
const handleEmojiSelect = useCallback((unicode: string) => {
|
||||
setNameValue((prev) => unicode + prev);
|
||||
// Clamp to the same cap as the input's maxLength — programmatic setState isn't
|
||||
// constrained by the DOM maxLength, so the picker could otherwise exceed it.
|
||||
setNameValue((prev) => (unicode + prev).slice(0, 255));
|
||||
setEmojiAnchor(undefined);
|
||||
}, []);
|
||||
|
||||
@@ -150,16 +153,16 @@ export function RoomProfileEdit({
|
||||
useCallback(
|
||||
async (roomAvatarMxc?: string | null, roomName?: string, roomTopic?: string) => {
|
||||
if (roomAvatarMxc !== undefined) {
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomAvatar as any, {
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.RoomAvatar, {
|
||||
url: roomAvatarMxc,
|
||||
});
|
||||
}
|
||||
if (roomName !== undefined) {
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomName as any, { name: roomName });
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.RoomName, { name: roomName });
|
||||
}
|
||||
if (roomTopic !== undefined) {
|
||||
const topicContent = buildTopicContent(roomTopic);
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomTopic as any, topicContent);
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.RoomTopic, topicContent);
|
||||
}
|
||||
},
|
||||
[mx, room.roomId],
|
||||
@@ -309,6 +312,7 @@ export function RoomProfileEdit({
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameValue(e.target.value)}
|
||||
variant="Secondary"
|
||||
radii="300"
|
||||
maxLength={255}
|
||||
readOnly={!canEditName || submitting}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SettingsSelect } from '../../../components/settings-select/SettingsSele
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
@@ -55,7 +56,7 @@ export function RoomQuality({ permissions }: RoomQualityProps) {
|
||||
useCallback(
|
||||
async (next: RoomQualityContent) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.LotusRoomQuality as any, next);
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.LotusRoomQuality, next);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
),
|
||||
|
||||
@@ -7,6 +7,7 @@ import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
@@ -31,7 +32,7 @@ export function RoomRetention({ permissions }: RoomRetentionProps) {
|
||||
// Lotus custom-state convention: cast the type key (RoomRetention isn't a
|
||||
// typed key in the SDK's StateEvents map).
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomRetention as any, content);
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.RoomRetention, content);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
),
|
||||
|
||||
Binary file not shown.
@@ -7,6 +7,7 @@ import { SettingTile } from '../../../components/setting-tile';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||
@@ -31,7 +32,7 @@ export function RoomVoiceLimit({ permissions }: RoomVoiceLimitProps) {
|
||||
useCallback(
|
||||
async (value: number) => {
|
||||
const content: VoiceLimitContent = value > 0 ? { max_users: value } : {};
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.LotusVoiceLimit as any, content);
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.LotusVoiceLimit, content);
|
||||
},
|
||||
[mx, room.roomId],
|
||||
),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { getPowerLevelTag, getPowers, usePowerLevelTags } from '../../../hooks/u
|
||||
import { useRoom } from '../../../hooks/useRoom';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { PowerSwitcher } from '../../../components/power';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
@@ -84,7 +85,7 @@ export function PermissionGroups({
|
||||
|
||||
return draftPowerLevels;
|
||||
});
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomPowerLevels as any, editedPowerLevels);
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.RoomPowerLevels, editedPowerLevels);
|
||||
}, [mx, room, powerLevels, permissionUpdate, permissionGroups]),
|
||||
);
|
||||
|
||||
|
||||
@@ -45,6 +45,7 @@ import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { MemberPowerTag, MemberPowerTagIcon, StateEvent } from '../../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../../utils/room';
|
||||
import { useAlive } from '../../../hooks/useAlive';
|
||||
import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge';
|
||||
import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
@@ -118,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">
|
||||
@@ -335,7 +336,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) {
|
||||
deleted.forEach((power) => {
|
||||
delete content[power];
|
||||
});
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.PowerLevelTags as any, content);
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.PowerLevelTags, content);
|
||||
}, [mx, room, powerLevelTags, editedPowerTags, deleted]),
|
||||
);
|
||||
|
||||
|
||||
@@ -102,7 +102,9 @@ export function CreateRoomForm({
|
||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||
|
||||
const handleEmojiSelect = useCallback((unicode: string) => {
|
||||
setNameValue((prev) => unicode + prev);
|
||||
// Clamp to the same cap as the input's maxLength — programmatic setState isn't
|
||||
// constrained by the DOM maxLength, so the picker could otherwise exceed it.
|
||||
setNameValue((prev) => (unicode + prev).slice(0, 255));
|
||||
setEmojiAnchor(undefined);
|
||||
}, []);
|
||||
|
||||
@@ -240,6 +242,7 @@ export function CreateRoomForm({
|
||||
disabled={disabled}
|
||||
value={nameValue}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setNameValue(e.target.value)}
|
||||
maxLength={255}
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Box>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Text, toRem } from 'folds';
|
||||
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, toRem } from 'folds';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { RoomCard } from '../../components/room-card';
|
||||
import { RoomTopicViewer } from '../../components/room-topic-viewer';
|
||||
@@ -32,51 +32,68 @@ export function JoinBeforeNavigate({
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader balance>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box shrink="No">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton onClick={onBack} aria-label="Back">
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
</BackRouteHandler>
|
||||
)}
|
||||
</Box>
|
||||
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
{roomIdOrAlias}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover" size="0">
|
||||
<Box style={{ height: '100%' }} grow="Yes" alignItems="Center" justifyContent="Center">
|
||||
<RoomSummaryLoader roomIdOrAlias={roomIdOrAlias}>
|
||||
{(summary) => (
|
||||
<RoomCard
|
||||
style={{ maxWidth: toRem(364), width: '100%' }}
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
allRooms={allRooms}
|
||||
avatarUrl={summary?.avatar_url}
|
||||
name={summary?.name}
|
||||
topic={summary?.topic}
|
||||
memberCount={summary?.num_joined_members}
|
||||
roomType={summary?.room_type}
|
||||
viaServers={viaServers}
|
||||
renderTopicViewer={(name, topic, requestClose) => (
|
||||
<RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
|
||||
<RoomSummaryLoader roomIdOrAlias={roomIdOrAlias} via={viaServers}>
|
||||
{(summary, state) => (
|
||||
<>
|
||||
<PageHeader balance>
|
||||
<Box grow="Yes" gap="200">
|
||||
<Box shrink="No">
|
||||
{screenSize === ScreenSize.Mobile && (
|
||||
<BackRouteHandler>
|
||||
{(onBack) => (
|
||||
<IconButton onClick={onBack} aria-label="Back">
|
||||
<Icon src={Icons.ArrowLeft} />
|
||||
</IconButton>
|
||||
)}
|
||||
</BackRouteHandler>
|
||||
)}
|
||||
onView={handleView}
|
||||
/>
|
||||
)}
|
||||
</RoomSummaryLoader>
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</Box>
|
||||
<Box grow="Yes" justifyContent="Center" alignItems="Center" gap="200">
|
||||
<Text size="H3" truncate>
|
||||
{summary?.name || summary?.canonical_alias || roomIdOrAlias}
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</PageHeader>
|
||||
<Box grow="Yes">
|
||||
<Scroll hideTrack visibility="Hover" size="0">
|
||||
<Box
|
||||
style={{ height: '100%' }}
|
||||
grow="Yes"
|
||||
alignItems="Center"
|
||||
justifyContent="Center"
|
||||
>
|
||||
{state.loading && !summary ? (
|
||||
<Spinner size="600" variant="Secondary" />
|
||||
) : (
|
||||
<RoomCard
|
||||
hero
|
||||
style={{ maxWidth: toRem(420), width: '100%' }}
|
||||
roomIdOrAlias={roomIdOrAlias}
|
||||
allRooms={allRooms}
|
||||
avatarUrl={summary?.avatar_url}
|
||||
name={summary?.name}
|
||||
canonicalAlias={summary?.canonical_alias}
|
||||
topic={summary?.topic}
|
||||
memberCount={summary?.num_joined_members}
|
||||
roomType={summary?.room_type}
|
||||
joinRule={summary?.join_rule}
|
||||
encrypted={!!summary?.['im.nheko.summary.encryption']}
|
||||
worldReadable={summary?.world_readable}
|
||||
membership={summary?.membership}
|
||||
viaServers={viaServers}
|
||||
renderTopicViewer={(name, topic, requestClose) => (
|
||||
<RoomTopicViewer name={name} topic={topic} requestClose={requestClose} />
|
||||
)}
|
||||
onView={handleView}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
</Scroll>
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</RoomSummaryLoader>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { HierarchyItem } from '../../hooks/useSpaceHierarchy';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { MSpaceChildContent, StateEvent } from '../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../utils/room';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { LeaveSpacePrompt } from '../../components/leave-space-prompt';
|
||||
@@ -48,7 +49,7 @@ function SuggestMenuItem({
|
||||
const [toggleState, handleToggleSuggested] = useAsyncCallback(
|
||||
useCallback(() => {
|
||||
const newContent: MSpaceChildContent = { ...content, suggested: !content.suggested };
|
||||
return mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, newContent, roomId);
|
||||
return sendStateEvent(mx, parentId, StateEvent.SpaceChild, newContent, roomId);
|
||||
}, [mx, parentId, roomId, content]),
|
||||
);
|
||||
|
||||
@@ -85,7 +86,7 @@ function RemoveMenuItem({
|
||||
|
||||
const [removeState, handleRemove] = useAsyncCallback(
|
||||
useCallback(
|
||||
() => mx.sendStateEvent(parentId, StateEvent.SpaceChild as any, {}, roomId),
|
||||
() => sendStateEvent(mx, parentId, StateEvent.SpaceChild, {}, roomId),
|
||||
[mx, parentId, roomId],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -36,11 +36,12 @@ import { useCategoryHandler } from '../../hooks/useCategoryHandler';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { allRoomsAtom } from '../../state/room-list/roomList';
|
||||
import { getCanonicalAliasOrRoomId, rateLimitedActions } from '../../utils/matrix';
|
||||
import { getSpaceRoomPath } from '../../pages/pathUtils';
|
||||
import { getSpaceRoomPath, withSearchParam } from '../../pages/pathUtils';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { CanDropCallback, useDnDMonitor } from './DnD';
|
||||
import { ASCIILexicalTable, orderKeys } from '../../utils/ASCIILexicalTable';
|
||||
import { getStateEvent } from '../../utils/room';
|
||||
import { getStateEvent, sendStateEvent } from '../../utils/room';
|
||||
import { setAccountData } from '../../utils/accountData';
|
||||
import { useClosedLobbyCategoriesAtom } from '../../state/hooks/closedLobbyCategories';
|
||||
import {
|
||||
makeCinnySpacesContent,
|
||||
@@ -271,9 +272,10 @@ export function Lobby() {
|
||||
if (reorders) {
|
||||
await rateLimitedActions(reorders, async (reorder) => {
|
||||
if (!reorder.item.parentId) return;
|
||||
await mx.sendStateEvent(
|
||||
await sendStateEvent(
|
||||
mx,
|
||||
reorder.item.parentId,
|
||||
StateEvent.SpaceChild as any,
|
||||
StateEvent.SpaceChild,
|
||||
{ ...reorder.item.content, order: reorder.orderKey },
|
||||
reorder.item.roomId,
|
||||
);
|
||||
@@ -298,7 +300,7 @@ export function Lobby() {
|
||||
|
||||
// remove from current space
|
||||
if (item.parentId !== containerParentId) {
|
||||
mx.sendStateEvent(item.parentId, StateEvent.SpaceChild as any, {}, item.roomId);
|
||||
sendStateEvent(mx, item.parentId, StateEvent.SpaceChild, {}, item.roomId);
|
||||
}
|
||||
|
||||
if (
|
||||
@@ -318,7 +320,7 @@ export function Lobby() {
|
||||
joinRuleContent.allow?.filter((allowRule) => allowRule.room_id !== item.parentId) ??
|
||||
[];
|
||||
allow.push({ type: RestrictedAllowType.RoomMembership, room_id: containerParentId });
|
||||
mx.sendStateEvent(itemRoom.roomId, StateEvent.RoomJoinRules as any, {
|
||||
sendStateEvent(mx, itemRoom.roomId, StateEvent.RoomJoinRules, {
|
||||
...joinRuleContent,
|
||||
allow,
|
||||
});
|
||||
@@ -358,9 +360,10 @@ export function Lobby() {
|
||||
|
||||
if (reorders) {
|
||||
await rateLimitedActions(reorders, async (reorder) => {
|
||||
await mx.sendStateEvent(
|
||||
await sendStateEvent(
|
||||
mx,
|
||||
containerParentId,
|
||||
StateEvent.SpaceChild as any,
|
||||
StateEvent.SpaceChild,
|
||||
{ ...reorder.item.content, order: reorder.orderKey },
|
||||
reorder.item.roomId,
|
||||
);
|
||||
@@ -411,8 +414,13 @@ export function Lobby() {
|
||||
const handleOpenRoom: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
const rId = evt.currentTarget.getAttribute('data-room-id');
|
||||
if (!rId) return;
|
||||
// Preview chips carry the room's resident servers so getRoomSummary can
|
||||
// resolve a room the homeserver isn't already in (else the preview is sparse).
|
||||
const via = evt.currentTarget.getAttribute('data-via');
|
||||
const pSpaceIdOrAlias = getCanonicalAliasOrRoomId(mx, space.roomId);
|
||||
navigate(getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId)));
|
||||
let path = getSpaceRoomPath(pSpaceIdOrAlias, getCanonicalAliasOrRoomId(mx, rId));
|
||||
if (via) path = withSearchParam(path, { viaServers: via });
|
||||
navigate(path);
|
||||
};
|
||||
|
||||
const togglePinToSidebar = useCallback(
|
||||
@@ -422,7 +430,7 @@ export function Lobby() {
|
||||
newItems.push(rId);
|
||||
}
|
||||
const newSpacesContent = makeCinnySpacesContent(mx, newItems);
|
||||
mx.setAccountData(AccountDataEvent.CinnySpaces as any, newSpacesContent as any);
|
||||
setAccountData(mx, AccountDataEvent.CinnySpaces, newSpacesContent);
|
||||
},
|
||||
[mx, sidebarItems, sidebarSpaces],
|
||||
);
|
||||
|
||||
@@ -97,6 +97,34 @@ function RoomJoinButton({ roomId, via }: RoomJoinButtonProps) {
|
||||
);
|
||||
}
|
||||
|
||||
// Open the full preview page (JoinBeforeNavigate) for an un-joined room. onOpen
|
||||
// reads data-room-id and navigates to the space→room path, which — because the
|
||||
// room isn't joined — renders the preview card instead of the timeline.
|
||||
function RoomPreviewChip({
|
||||
roomId,
|
||||
via,
|
||||
onOpen,
|
||||
}: {
|
||||
roomId: string;
|
||||
via?: string[];
|
||||
onOpen: MouseEventHandler<HTMLButtonElement>;
|
||||
}) {
|
||||
return (
|
||||
<Chip
|
||||
data-room-id={roomId}
|
||||
data-via={via && via.length > 0 ? via.join(',') : undefined}
|
||||
onClick={onOpen}
|
||||
variant="Secondary"
|
||||
fill="None"
|
||||
size="400"
|
||||
radii="Pill"
|
||||
aria-label="Preview room"
|
||||
>
|
||||
<Icon size="50" src={Icons.Eye} />
|
||||
</Chip>
|
||||
);
|
||||
}
|
||||
|
||||
function RoomProfileLoading() {
|
||||
return (
|
||||
<Box grow="Yes" gap="300">
|
||||
@@ -365,7 +393,10 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
|
||||
</Chip>
|
||||
</Box>
|
||||
) : (
|
||||
<RoomJoinButton roomId={roomId} via={content.via} />
|
||||
<Box shrink="No" gap="100" alignItems="Center">
|
||||
<RoomPreviewChip roomId={roomId} via={content.via} onOpen={onOpen} />
|
||||
<RoomJoinButton roomId={roomId} via={content.via} />
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
/>
|
||||
@@ -409,7 +440,12 @@ export const RoomItemCard = as<'div', RoomItemCardProps>(
|
||||
memberCount={summary.num_joined_members}
|
||||
suggested={content.suggested}
|
||||
joinRule={summary.join_rule}
|
||||
options={<RoomJoinButton roomId={roomId} via={content.via} />}
|
||||
options={
|
||||
<Box shrink="No" gap="100" alignItems="Center">
|
||||
<RoomPreviewChip roomId={roomId} via={content.via} onOpen={onOpen} />
|
||||
<RoomJoinButton roomId={roomId} via={content.via} />
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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,
|
||||
@@ -643,18 +646,7 @@ function DateRangeButton({ fromTs, toTs, onChange }: DateRangeButtonProps) {
|
||||
variant={hasRange ? 'Primary' : 'SurfaceVariant'}
|
||||
radii="Pill"
|
||||
before={<Icon size="100" src={Icons.RecentClock} />}
|
||||
after={
|
||||
hasRange ? (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Cross}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(undefined, undefined);
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
after={hasRange ? <Icon size="50" src={Icons.Cross} /> : undefined}
|
||||
onClick={(e: React.MouseEvent<HTMLButtonElement>) =>
|
||||
setMenuAnchor(e.currentTarget.getBoundingClientRect())
|
||||
}
|
||||
@@ -795,18 +787,7 @@ export function SearchFilters({
|
||||
radii="Pill"
|
||||
aria-pressed={!!containsUrl}
|
||||
before={<Icon size="100" src={Icons.Link} />}
|
||||
after={
|
||||
containsUrl ? (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Cross}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onContainsUrlChange(undefined);
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
after={containsUrl ? <Icon size="50" src={Icons.Cross} /> : undefined}
|
||||
onClick={() => onContainsUrlChange(containsUrl ? undefined : true)}
|
||||
>
|
||||
<Text size="T200">Has link</Text>
|
||||
@@ -821,18 +802,7 @@ export function SearchFilters({
|
||||
radii="Pill"
|
||||
aria-pressed={active}
|
||||
before={<Icon size="100" src={icon} />}
|
||||
after={
|
||||
active ? (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Cross}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onToggleMsgTypeFilter(msgType);
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
after={active ? <Icon size="50" src={Icons.Cross} /> : undefined}
|
||||
onClick={() => onToggleMsgTypeFilter(msgType)}
|
||||
>
|
||||
<Text size="T200">{label}</Text>
|
||||
@@ -845,18 +815,7 @@ export function SearchFilters({
|
||||
radii="Pill"
|
||||
aria-pressed={pinnedOnly}
|
||||
before={<Icon size="100" src={Icons.Pin} />}
|
||||
after={
|
||||
pinnedOnly ? (
|
||||
<Icon
|
||||
size="50"
|
||||
src={Icons.Cross}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onTogglePinnedOnly();
|
||||
}}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
after={pinnedOnly ? <Icon size="50" src={Icons.Cross} /> : undefined}
|
||||
onClick={onTogglePinnedOnly}
|
||||
>
|
||||
<Text size="T200">Pinned</Text>
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
makeMentionCustomProps,
|
||||
renderMatrixMention,
|
||||
} from '../../plugins/react-custom-html-parser';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { useMatrixEventRenderer } from '../../hooks/useMatrixEventRenderer';
|
||||
import { GetContentCallback, MessageEvent, StateEvent } from '../../../types/matrix/room';
|
||||
import {
|
||||
@@ -31,7 +31,7 @@ import { Image } from '../../components/media';
|
||||
import { ImageViewer } from '../../components/image-viewer';
|
||||
import * as customHtmlCss from '../../styles/CustomHtml.css';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName, getRoomAvatarUrl } from '../../utils/room';
|
||||
import { getMemberAvatarMxc, getMemberName, getRoomAvatarUrl } from '../../utils/room';
|
||||
import { ResultItem } from './useMessageSearch';
|
||||
import { SequenceCard } from '../../components/sequence-card';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
@@ -220,10 +220,7 @@ export function SearchResultGroup({
|
||||
{items.map((item) => {
|
||||
const { event } = item;
|
||||
|
||||
const displayName =
|
||||
getMemberDisplayName(room, event.sender) ??
|
||||
getMxIdLocalPart(event.sender) ??
|
||||
event.sender;
|
||||
const displayName = getMemberName(room, event.sender);
|
||||
const senderAvatarMxc = getMemberAvatarMxc(room, event.sender);
|
||||
|
||||
const relation = event.content['m.relates_to'];
|
||||
|
||||
@@ -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,
|
||||
@@ -26,7 +33,8 @@ import {
|
||||
} from 'folds';
|
||||
import { useFocusWithin, useHover } from 'react-aria';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useAtom, useAtomValue } from 'jotai';
|
||||
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';
|
||||
@@ -34,11 +42,15 @@ import { NavItem, NavItemContent, NavItemOptions, NavLink } from '../../componen
|
||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { getDirectRoomAvatarUrl, getRoomAvatarUrl, getStateEvent } from '../../utils/room';
|
||||
import { setAccountData } from '../../utils/accountData';
|
||||
import { nameInitials } from '../../utils/common';
|
||||
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';
|
||||
@@ -68,6 +80,7 @@ import {
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
||||
import { callChatAtom } from '../../state/callEmbed';
|
||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||
import { useCallPreferencesAtom } from '../../state/hooks/callPreferences';
|
||||
import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo';
|
||||
import { livekitSupport } from '../../hooks/useLivekitSupport';
|
||||
@@ -126,11 +139,9 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
|
||||
const existing = getLocalRoomNamesContent(mx);
|
||||
if (newName === '') {
|
||||
const { [room.roomId]: _removed, ...rest } = existing.rooms;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest });
|
||||
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, {
|
||||
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, {
|
||||
rooms: { ...existing.rooms, [room.roomId]: newName },
|
||||
});
|
||||
}
|
||||
@@ -140,8 +151,7 @@ function RenameRoomDialog({ room, onClose }: RenameRoomDialogProps) {
|
||||
const handleClear = useCallback(() => {
|
||||
const existing = getLocalRoomNamesContent(mx);
|
||||
const { [room.roomId]: _removed, ...rest } = existing.rooms;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(mx as any).setAccountData(LOCAL_ROOM_NAMES_KEY, { rooms: rest });
|
||||
setAccountData(mx, LOCAL_ROOM_NAMES_KEY, { rooms: rest });
|
||||
onClose();
|
||||
}, [mx, room.roomId, onClose]);
|
||||
|
||||
@@ -324,6 +334,7 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
||||
const openRoomSettings = useOpenRoomSettings();
|
||||
const space = useSpaceOptionally();
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
|
||||
const [invitePrompt, setInvitePrompt] = useState(false);
|
||||
const [muteMenuAnchor, setMuteMenuAnchor] = useState<RectCords>();
|
||||
@@ -332,23 +343,36 @@ const RoomNavItemMenu = forwardRef<HTMLDivElement, RoomNavItemMenuProps>(
|
||||
const isFavorite = !!room.tags?.['m.favourite'];
|
||||
const isLowPriority = !!room.tags?.['m.lowpriority'];
|
||||
|
||||
// Surface a room-tag write failure instead of letting the toggle fail silently.
|
||||
const notifyTagFailure = (err: unknown) => {
|
||||
console.error('Failed to update room tag:', err);
|
||||
setToast(
|
||||
createErrorToast('Could not update this room. Please try again.', Icons.Warning, 'Failed'),
|
||||
);
|
||||
};
|
||||
|
||||
const handleToggleFavorite = () => {
|
||||
if (isFavorite) {
|
||||
mx.deleteRoomTag(room.roomId, 'm.favourite');
|
||||
mx.deleteRoomTag(room.roomId, 'm.favourite').catch(notifyTagFailure);
|
||||
} else {
|
||||
// Favourite and low-priority are mutually exclusive.
|
||||
if (isLowPriority) mx.deleteRoomTag(room.roomId, 'm.lowpriority');
|
||||
mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 });
|
||||
// Favourite and low-priority are mutually exclusive. Batch both writes so a
|
||||
// failure surfaces a single toast, not one per operation.
|
||||
const ops: Promise<unknown>[] = [mx.setRoomTag(room.roomId, 'm.favourite', { order: 0.5 })];
|
||||
if (isLowPriority) ops.push(mx.deleteRoomTag(room.roomId, 'm.lowpriority'));
|
||||
Promise.all(ops).catch(notifyTagFailure);
|
||||
}
|
||||
requestClose();
|
||||
};
|
||||
|
||||
const handleToggleLowPriority = () => {
|
||||
if (isLowPriority) {
|
||||
mx.deleteRoomTag(room.roomId, 'm.lowpriority');
|
||||
mx.deleteRoomTag(room.roomId, 'm.lowpriority').catch(notifyTagFailure);
|
||||
} else {
|
||||
if (isFavorite) mx.deleteRoomTag(room.roomId, 'm.favourite');
|
||||
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 });
|
||||
const ops: Promise<unknown>[] = [
|
||||
mx.setRoomTag(room.roomId, 'm.lowpriority', { order: 0.5 }),
|
||||
];
|
||||
if (isFavorite) ops.push(mx.deleteRoomTag(room.roomId, 'm.favourite'));
|
||||
Promise.all(ops).catch(notifyTagFailure);
|
||||
}
|
||||
requestClose();
|
||||
};
|
||||
@@ -665,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();
|
||||
@@ -800,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}
|
||||
|
||||
@@ -6,8 +6,8 @@ import { SequenceCard } from '../../components/sequence-card';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||
import { getMemberAvatarMxc, getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { getMemberAvatarMxc, getMemberName } from '../../utils/room';
|
||||
import { mxcUrlToHttp } from '../../utils/matrix';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
@@ -297,8 +297,7 @@ export function RoomInsights({ requestClose }: RoomInsightsProps) {
|
||||
<SectionHeader label="Most Active Members" />
|
||||
<Box direction="Column" gap="200">
|
||||
{stats.top5.map(([userId, count], index) => {
|
||||
const displayName =
|
||||
getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const displayName = getMemberName(room, userId);
|
||||
const avatarMxc = getMemberAvatarMxc(room, userId);
|
||||
const avatarUrl = avatarMxc
|
||||
? (mxcUrlToHttp(mx, avatarMxc, useAuthentication, 32, 32, 'crop') ??
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { useRoom } from '../../hooks/useRoom';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import { sendStateEvent } from '../../utils/room';
|
||||
import { usePowerLevels, readPowerLevel } from '../../hooks/usePowerLevels';
|
||||
import { useRoomCreators } from '../../hooks/useRoomCreators';
|
||||
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
||||
@@ -31,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 ─────────────────────────────────────────────────────────────────────
|
||||
@@ -47,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 = {
|
||||
@@ -197,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 }}
|
||||
@@ -250,7 +203,7 @@ export function RoomServerACL({ requestClose }: RoomServerACLProps) {
|
||||
// Save handler
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
useCallback(async () => {
|
||||
await mx.sendStateEvent(room.roomId, StateEvent.RoomServerAcl as any, {
|
||||
await sendStateEvent(mx, room.roomId, StateEvent.RoomServerAcl, {
|
||||
allow: allowList,
|
||||
deny: denyList,
|
||||
allow_ip_literals: allowIpLiterals,
|
||||
|
||||
@@ -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';
|
||||
@@ -42,7 +43,7 @@ import {
|
||||
} from '../../hooks/useAsyncSearch';
|
||||
import { useDebounce } from '../../hooks/useDebounce';
|
||||
import { TypingIndicator } from '../../components/typing-indicator';
|
||||
import { getMemberDisplayName, getMemberSearchStr } from '../../utils/room';
|
||||
import { getMemberName, getMemberSearchStr } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { useSetSetting, useSetting } from '../../state/hooks/settings';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
@@ -134,8 +135,7 @@ function MemberItem({
|
||||
typing,
|
||||
showEncryption,
|
||||
}: MemberItemProps) {
|
||||
const name =
|
||||
getMemberDisplayName(room, member.userId) ?? getMxIdLocalPart(member.userId) ?? member.userId;
|
||||
const name = getMemberName(room, member.userId);
|
||||
const avatarMxcUrl = member.getMxcAvatarUrl();
|
||||
const avatarUrl = avatarMxcUrl
|
||||
? mx.mxcUrlToHttp(avatarMxcUrl, 100, 100, 'crop', undefined, false, useAuthentication)
|
||||
@@ -418,10 +418,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
Pending Requests
|
||||
</Text>
|
||||
{knockMembers.map((knockMember) => {
|
||||
const knockName =
|
||||
getMemberDisplayName(room, knockMember.userId) ??
|
||||
getMxIdLocalPart(knockMember.userId) ??
|
||||
knockMember.userId;
|
||||
const knockName = getMemberName(room, knockMember.userId);
|
||||
const knockAvatarMxc = knockMember.getMxcAvatarUrl();
|
||||
const knockAvatarUrl = knockAvatarMxc
|
||||
? mx.mxcUrlToHttp(
|
||||
@@ -464,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>
|
||||
@@ -473,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 && (
|
||||
|
||||
@@ -65,7 +65,6 @@ import {
|
||||
TUploadContent,
|
||||
encryptFile,
|
||||
getImageInfo,
|
||||
getMxIdLocalPart,
|
||||
mxcUrlToHttp,
|
||||
tryDeleteMxcContent,
|
||||
} from '../../utils/matrix';
|
||||
@@ -111,7 +110,7 @@ import {
|
||||
getImageMsgContent,
|
||||
getVideoMsgContent,
|
||||
} from './msgContent';
|
||||
import { getMemberDisplayName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
||||
import { getMemberName, getMentionContent, trimReplyFromBody } from '../../utils/room';
|
||||
import { CommandAutocomplete } from './CommandAutocomplete';
|
||||
import { Command, SHRUG, TABLEFLIP, UNFLIP, useCommands } from '../../hooks/useCommands';
|
||||
import { mobileOrTablet } from '../../utils/user-agent';
|
||||
@@ -136,6 +135,7 @@ import { ScheduleMessageModal } from './ScheduleMessageModal';
|
||||
import { ScheduledMessagesTray } from './ScheduledMessagesTray';
|
||||
import { DraftIndicator } from './DraftIndicator';
|
||||
import { scheduledMessagesAtom } from '../../state/scheduledMessages';
|
||||
import { createErrorToast, toastQueueAtom } from '../../state/toast';
|
||||
import { getThreadDraftKey } from '../../state/room/thread';
|
||||
|
||||
const GifPicker = React.lazy(() =>
|
||||
@@ -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');
|
||||
@@ -184,6 +191,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
const [scheduleOpen, setScheduleOpen] = useState(false);
|
||||
const [scheduleContent, setScheduleContent] = useState<IContent | null>(null);
|
||||
const setScheduledMessages = useSetAtom(scheduledMessagesAtom);
|
||||
const setToast = useSetAtom(toastQueueAtom);
|
||||
|
||||
const alive = useAlive();
|
||||
// Scope drafts/replies/uploads by thread so a thread composer stays fully
|
||||
@@ -222,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;
|
||||
@@ -250,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) => {
|
||||
@@ -505,15 +527,19 @@ 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, {
|
||||
allowTextFormatting: true,
|
||||
allowBlockMarkdown: isMarkdown,
|
||||
allowInlineMarkdown: isMarkdown,
|
||||
allowMath: true,
|
||||
}),
|
||||
);
|
||||
let msgType = MsgType.Text;
|
||||
@@ -538,7 +564,22 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
} else if (commandName) {
|
||||
const commandContent = commands[commandName as Command];
|
||||
if (commandContent) {
|
||||
commandContent.exe(plainText);
|
||||
// Fire-and-forget by design (the editor resets immediately for UX), but
|
||||
// surface a rejection instead of failing silently. NOTE: /kick and /ban
|
||||
// route through rateLimitedActions (utils/matrix.ts), whose to() helper
|
||||
// swallows non-429 errors, so those two commands can still resolve even
|
||||
// when the underlying kick/ban failed — this catch only covers errors
|
||||
// that actually reject out of exe().
|
||||
commandContent.exe(plainText).catch((err) => {
|
||||
console.error(`Failed to run /${commandName} command:`, err);
|
||||
setToast(
|
||||
createErrorToast(
|
||||
`The /${commandName} command failed. Please try again.`,
|
||||
Icons.Warning,
|
||||
'Command failed',
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
resetEditor(editor);
|
||||
resetEditorHistory(editor);
|
||||
@@ -585,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);
|
||||
@@ -597,8 +639,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
replyDraft,
|
||||
sendTypingStatus,
|
||||
setReplyDraft,
|
||||
setMsgDraft,
|
||||
isMarkdown,
|
||||
commands,
|
||||
setToast,
|
||||
]);
|
||||
|
||||
/**
|
||||
@@ -616,6 +660,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
allowTextFormatting: true,
|
||||
allowBlockMarkdown: isMarkdown,
|
||||
allowInlineMarkdown: isMarkdown,
|
||||
allowMath: true,
|
||||
}),
|
||||
);
|
||||
if (plainText === '') return null;
|
||||
@@ -668,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(
|
||||
@@ -828,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 && (
|
||||
@@ -943,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}
|
||||
@@ -973,11 +1033,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||
userColor={replyUsernameColor}
|
||||
username={
|
||||
<Text size="T300" truncate>
|
||||
<b>
|
||||
{getMemberDisplayName(room, replyDraft.userId) ??
|
||||
getMxIdLocalPart(replyDraft.userId) ??
|
||||
replyDraft.userId}
|
||||
</b>
|
||||
<b>{getMemberName(room, replyDraft.userId)}</b>
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
@@ -991,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 ? (
|
||||
@@ -1262,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"
|
||||
@@ -1321,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)} />}
|
||||
|
||||
@@ -81,6 +81,7 @@ import {
|
||||
getEventReactions,
|
||||
getLatestEditableEvt,
|
||||
getMemberDisplayName,
|
||||
getMemberName,
|
||||
getReactionContent,
|
||||
isMembershipChanged,
|
||||
reactionOrEditEvent,
|
||||
@@ -1007,7 +1008,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
console.warn('Button should have "data-user-id" attribute!');
|
||||
return;
|
||||
}
|
||||
const name = getMemberDisplayName(room, userId) ?? getMxIdLocalPart(userId) ?? userId;
|
||||
const name = getMemberName(room, userId);
|
||||
editor.insertNode(
|
||||
createMentionElement(
|
||||
userId,
|
||||
@@ -1106,8 +1107,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
|
||||
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderDisplayName =
|
||||
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||
const senderDisplayName = getMemberName(room, senderId);
|
||||
|
||||
return (
|
||||
<Message
|
||||
@@ -1290,8 +1290,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli
|
||||
mEvent.getContent()) as GetContentCallback;
|
||||
|
||||
const senderId = mEvent.getSender() ?? '';
|
||||
const senderDisplayName =
|
||||
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||
const senderDisplayName = getMemberName(room, senderId);
|
||||
return (
|
||||
<RenderMessageContent
|
||||
displayName={senderDisplayName}
|
||||
@@ -1315,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>
|
||||
@@ -1450,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>
|
||||
);
|
||||
@@ -1507,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}
|
||||
|
||||
@@ -15,13 +15,13 @@ import { Room } from 'matrix-js-sdk';
|
||||
import classNames from 'classnames';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
|
||||
import { getMemberDisplayName } from '../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { getMemberName } from '../../utils/room';
|
||||
import * as css from './RoomViewFollowing.css';
|
||||
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() {
|
||||
@@ -35,14 +35,12 @@ 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
|
||||
.filter((readerId) => readerId !== mx.getUserId())
|
||||
.map(
|
||||
(readerId) =>
|
||||
getMemberDisplayName(room, readerId) ?? getMxIdLocalPart(readerId) ?? readerId,
|
||||
);
|
||||
.map((readerId) => getMemberName(room, readerId));
|
||||
|
||||
const eventId = latestEvent?.getId();
|
||||
|
||||
@@ -59,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,15 +51,21 @@ import {
|
||||
UsernameBold,
|
||||
} from '../../../components/message';
|
||||
import {
|
||||
canEditEvent,
|
||||
canEditCaption,
|
||||
canEditEventOrCaption,
|
||||
getEventEdits,
|
||||
getMemberAvatarMxc,
|
||||
getMemberDisplayName,
|
||||
getMemberName,
|
||||
sendStateEvent,
|
||||
trimReplyFromBody,
|
||||
} from '../../../utils/room';
|
||||
import { getMxIdLocalPart, mxcUrlToHttp } from '../../../utils/matrix';
|
||||
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';
|
||||
@@ -104,15 +111,19 @@ function DeliveryStatus({
|
||||
if (status === EventStatus.NOT_SENT || status === EventStatus.CANCELLED) {
|
||||
iconSrc = Icons.Cross;
|
||||
label = 'Failed to send';
|
||||
colorStyle = lotusTerminal ? '#FF3B3B' : color.Critical.Main;
|
||||
colorStyle = lotusTerminal ? 'var(--lt-accent-red)' : color.Critical.Main;
|
||||
} else if (status === EventStatus.QUEUED || isSending) {
|
||||
iconSrc = Icons.Send;
|
||||
label = isSending ? 'Sending...' : 'Queued';
|
||||
colorStyle = lotusTerminal ? 'rgba(0,212,255,0.60)' : color.Secondary.Main;
|
||||
colorStyle = lotusTerminal
|
||||
? 'color-mix(in srgb, var(--lt-accent-cyan) 60%, transparent)'
|
||||
: color.Secondary.Main;
|
||||
} else {
|
||||
iconSrc = Icons.Check;
|
||||
label = 'Sent';
|
||||
colorStyle = lotusTerminal ? 'rgba(0,212,255,0.70)' : color.Secondary.Main;
|
||||
colorStyle = lotusTerminal
|
||||
? 'color-mix(in srgb, var(--lt-accent-cyan) 70%, transparent)'
|
||||
: color.Secondary.Main;
|
||||
}
|
||||
return (
|
||||
<Box
|
||||
@@ -128,7 +139,7 @@ function DeliveryStatus({
|
||||
opacity: 0.85,
|
||||
userSelect: 'none',
|
||||
...(lotusTerminal && status === EventStatus.NOT_SENT
|
||||
? { textShadow: '0 0 6px #FF3B3B' }
|
||||
? { textShadow: 'var(--lt-glow-red)' }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
@@ -250,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);
|
||||
@@ -268,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>
|
||||
@@ -405,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',
|
||||
{
|
||||
@@ -424,7 +518,7 @@ export const MessagePinItem = as<
|
||||
if (!isPinned && eventId) {
|
||||
pinContent.pinned.push(eventId);
|
||||
}
|
||||
mx.sendStateEvent(room.roomId, StateEvent.RoomPinnedEvents as any, pinContent);
|
||||
sendStateEvent(mx, room.roomId, StateEvent.RoomPinnedEvents, pinContent);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
@@ -582,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) =>
|
||||
@@ -623,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}`,
|
||||
@@ -805,8 +900,7 @@ export const Message = React.memo(
|
||||
const [remindOpen, setRemindOpen] = useState(false);
|
||||
const { addBookmark, removeBookmark, isBookmarked } = useBookmarks();
|
||||
|
||||
const senderDisplayName =
|
||||
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||
const senderDisplayName = getMemberName(room, senderId);
|
||||
const senderAvatarMxc = getMemberAvatarMxc(room, senderId);
|
||||
|
||||
const tagColor = memberPowerTag?.color
|
||||
@@ -1068,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>
|
||||
@@ -1185,6 +1279,7 @@ export const Message = React.memo(
|
||||
savedAt: Date.now(),
|
||||
previewText: body.slice(0, 120),
|
||||
roomName: room.name,
|
||||
senderName: senderDisplayName,
|
||||
});
|
||||
}
|
||||
closeMenu();
|
||||
@@ -1243,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} />}
|
||||
@@ -1260,7 +1355,7 @@ export const Message = React.memo(
|
||||
size="T300"
|
||||
truncate
|
||||
>
|
||||
Edit Message
|
||||
{canEditCaption(mx, mEvent) ? 'Edit Caption' : 'Edit Message'}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
)}
|
||||
@@ -1278,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} />
|
||||
@@ -1507,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,
|
||||
@@ -53,11 +53,10 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import {
|
||||
getEditedEvent,
|
||||
getMemberDisplayName,
|
||||
getMemberName,
|
||||
getMentionContent,
|
||||
trimReplyFromFormattedBody,
|
||||
} from '../../../utils/room';
|
||||
import { getMxIdLocalPart } from '../../../utils/matrix';
|
||||
import { mobileOrTablet } from '../../../utils/user-agent';
|
||||
import { useComposingCheck } from '../../../hooks/useComposingCheck';
|
||||
|
||||
@@ -75,9 +74,16 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
// Accessible name for the edit textbox so screen readers announce which
|
||||
// message is being edited (a11y, P3-4).
|
||||
const editSenderId = mEvent.getSender();
|
||||
const editSenderName = editSenderId
|
||||
? (getMemberDisplayName(room, editSenderId) ?? getMxIdLocalPart(editSenderId) ?? editSenderId)
|
||||
: '';
|
||||
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');
|
||||
@@ -117,9 +123,74 @@ export const MessageEditor = as<'div', MessageEditorProps>(
|
||||
allowTextFormatting: true,
|
||||
allowBlockMarkdown: isMarkdown,
|
||||
allowInlineMarkdown: isMarkdown,
|
||||
allowMath: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// 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;
|
||||
@@ -167,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(() => {
|
||||
@@ -222,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, []),
|
||||
@@ -234,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) {
|
||||
@@ -270,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"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user