The GIF picker was a bare Giphy search grid with no memory of what you've
sent, so re-sending a go-to reaction GIF meant re-typing the search every
time. Add a "Recent" row at the top of the picker (default view; hidden
while searching) for one-click re-sending.
- New persisted state state/recentGifs.ts: recentGifsAtom (localStorage,
cinny_recent_gifs_v1, getOnInit) + pure addRecentGif (dedupe-by-url
move-to-front, cap 16, ignore empty url), with 5 unit tests.
- GifPicker records every sent GIF (from search or the Recent row) to the
front, and renders a 3-up thumbnail grid of recents above the search
grid when there are recents and no active search term. Section label
matches the picker's existing `// GIF_SEARCH` treatment (lotusTerminal)
or a muted label otherwise.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the room-nav draft indicator:
- Icon semantics (a11y): Icons.Message read as message activity and
collided with two existing bubble uses in the same row (call-chat
toggle, mark-unread), especially next to the unread badge. Replace it
with the composer's shared DraftDot (a small color.Success.Main dot),
so both draft surfaces share one visual language. Rendered as a
role="img" span with aria-label "Unsent draft" (reliably announced,
unlike a bare aria-labelled svg).
- Precise thread-key filter: hydration skipped any draftKey containing
'::', which would also skip an IPv6-literal server name in a roomId.
Match '::$' (thread root is an event id) so only real thread drafts are
skipped.
- Defensive hasMsgDraft: guard toPlainText so a corrupted/foreign draft
value can't throw during a nav render.
- Clear the draft atom on send: the send / scheduled-send handlers reset
the editor and localStorage but left the jotai draft atom set, so the
composer DraftIndicator could show a stale dot after sending a restored
draft. Add setMsgDraft([]) to both.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Composer drafts persist per room, but nothing in the room list showed
which OTHER rooms had an unsent draft. Add a subtle chat-bubble icon on a
room's nav item when it has a message draft (and isn't the open room), so
half-written messages elsewhere are visible at a glance.
- Shared pure helper hasMsgDraft (utils/draft.ts, unit-tested) replaces
the inline emptiness check; the composer DraftIndicator now reuses it.
- RoomNavItem reads a memoized selectAtom(draftAtom, hasMsgDraft) so a row
re-renders only when its draft flag flips (the draft atom is written on
room-leave, not per keystroke). Uses Icons.Message (pencil is reserved
for the custom-name marker), muted, aria-label "Unsent draft".
- useHydrateMsgDrafts (mounted in ClientNonUIFeatures) pre-fills the
per-room draft atoms from draft-msg-* localStorage on startup, so
indicators are correct after a page reload, not only after revisiting a
room. Thread drafts (key contains ::) are skipped; room-level only.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the edit-history diff:
- Perf: diffWords is O(n*m); cap at 2000 tokens/side and fall back to a
coarse whole-block replaced diff above that, so a very large multi-edit
message can't freeze the main thread. Memoize the per-row diff in
DiffText. (Added a unit test for the coarse fallback.)
- Perceivability (a11y/design): the added-word <ins> highlight was
color-fill only, which is faint against the modal surface in the lotus
themes. Add a Success.ContainerLine border + horizontal padding (so the
rounded corners read as a chip) + box-decoration-break: clone for clean
wrapping, so the "added" cue survives low fill contrast.
- Consistency: a media/no-body edit now renders "(no text)" in diff mode
too (matched the toggle-off view; was blank).
- Softened the code comment's screen-reader claim (bare <ins>/<del> aren't
announced by default).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Edit History modal listed each version's full text with no indication
of what changed. Add a word-level diff: each edit highlights the words
added (green) and removed (struck-through red) relative to the previous
version, so a one-word fix is obvious at a glance.
- New pure, dependency-free diffWords (LCS over word/whitespace tokens) in
utils/textDiff.ts, with 8 unit tests (insert/delete/replace, whitespace
preserved, empty, no-mutation, word-not-char granularity).
- EditHistoryModal renders each edit via a DiffText component using
semantic <ins>/<del> (screen-reader-meaningful) styled with folds
Success/Critical tokens. A "Highlight changes" header toggle (default
on) switches back to full text, which keeps the rich formatted render;
the Original row is always the plain baseline.
- Diff is plain-text (body) only by design; formatted markup isn't diffed
(the toggle restores the rich view), and media/no-body edits diff as
empty strings gracefully.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 3 review agents on the Threads list panel:
- Last-activity accuracy (SDK): sort key and the "last reply <time>"
label now use thread.replyToEvent.getTs() (server bundle latest_event)
instead of lastReply(), which returns the ROOT time until each thread's
replies lazily paginate (or permanently on fetch error). Applied to the
hook signature too.
- Live-refresh completeness (correctness): the useRoomThreads signature
now includes thread.length and the root event's replacingEventId, so a
mid-thread redaction (reply count) and a root-message edit (row snippet)
refresh the row live instead of going stale.
- a11y: the row's aria-label was the button's whole accessible name,
hiding the snippet/count/unread from screen readers. It now describes
the thread ("Open thread by <name>, unread, N replies, last reply ..").
- Unread badge: replaced the bare green dot (Success = the mention color)
with the app-wide UnreadBadge, using the Highlight count so mentions
render red and ordinary unread renders secondary, matching room-nav.
- Hover/focus affordance: the clickable row moved its inline styles to a
css class with token-based :hover / :active backgrounds.
- Participant pile now also includes the last replier from the bundle.
- Stabilized the panel's onClose/onOpenThread with useCallback so its
Escape listener isn't re-subscribed every Room render. Added
filter->sort pipeline + all/participating immutability tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lotus could view one thread at a time but had no overview of a room's
threads. Add a Threads list side panel, opened from a new Threads toggle
in the room header (mirrors the gallery/widgets toggles).
- Lists every thread with a rich row: root sender + snippet, unread dot,
"N replies - last reply <time>", and a participant avatar pile.
- Segmented filter (All / Unread / Participating) and sort (Recent /
Oldest by last-reply time), both persisted in localStorage
(cinny_threads_filter_v1 / cinny_threads_sort_v1) and normalized via
type guards.
- Clicking a row opens the existing single-thread ThreadPanel by reusing
setActiveThreadId; reading it clears the row's unread badge live.
- Stays live via ThreadEvent.New/NewReply/Update/Delete +
RoomEvent.UnreadNotifications, with a signature guard to avoid churn,
and is virtualized (@tanstack/react-virtual) for busy rooms.
Reuses room.getThreads()/fetchRoomThreads(), thread.hasCurrentUser-
Participated / lastReply() / length, getThreadUnreadNotificationCount
(muted threads zeroed), useMemberAvatar/StackedAvatar/UserAvatar,
scaleSystemEmoji/trimReplyFromBody, UnreadBadge, and the Bookmarks-panel
segmented-control + localStorage-atom patterns. Filter/sort logic is pure
in utils/threadList.ts with 8 unit tests. New panel is wired into
Room.tsx's mutually-exclusive content-panel switching.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the forward upgrades:
- Duplicate comment on retry (correctness): if a room's comment message
sent but the forward then failed, retrying re-posted the comment. Track
rooms whose comment already delivered (commentSentRef) and skip it on
retry, sending only the missing forward. An already-commented room won't
get the comment again even if the text is later edited (no-duplicate
choice).
- a11y: give the message-preview box role="group" + aria-label
("Message to forward"), add aria-label to the comment and search inputs
(placeholder is not a label), and match the RecentChip's RoomIcon
fallback size (100) to the room-row convention for a size-200 avatar.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Forward dialog forwarded blind. Add three things (design-system
cleanup included):
- Preview: a compact read-only preview at the top of the dialog shows the
sender + body, with a thumbnail for image/video (reuses ThumbnailContent
and the getMemberName/getMemberAvatarMxc/trimReplyFromBody helpers). We
already hold mEvent, so nothing is fetched.
- Comment: an optional "Add a comment" field sends a short m.text note to
each target room, sequenced BEFORE the forwarded message per room so the
note reads above the quoted content. The existing per-room failure /
retry logic is preserved (a room fails if either send rejects).
- Recent targets: a "Recent" chip row (hidden while searching) offers
one-tap selection of rooms you last forwarded to. Successful targets are
recorded most-recent-first, deduped, capped at 8, in localStorage via the
pure, unit-tested addRecentForwardTarget (state/recentForwardTargets.ts).
Rooms you've since left are filtered out of the row.
Also replaces the hardcoded rgba(0,0,0,0.35) sending scrim with a
token-free opacity dim of the list (design-system rule: no hardcoded
colors).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Selecting an emoji (or any unsaved edit) in the profile Status Message
field could vanish because the presence-sync effect re-applied the remote
status_msg on every presence heartbeat (which fire every few seconds).
The dirty-edit guard alone left a window where a heartbeat carrying the
previous status overwrote the just-inserted emoji, so there was no way to
add emoji to a status.
Track the last remote status we synced and only react when the remote
value actually changes, instead of on every heartbeat. Repeated
heartbeats with an unchanged status are now ignored, so an unsaved local
edit is preserved regardless of the dirty flag's timing. Cross-device
status changes (a genuinely new remote value) and clears still sync, and
the pending-applied stale-echo guard is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two console-noise / glitch fixes surfaced while a room with link
previews was open:
- URL preview og:image thumbnails 400 when Synapse can't thumbnail a
cached preview image (SVG/animated), leaving a broken image that the
browser keeps re-requesting. GenericCard now falls back to the full
image on error, then hides the image (and shows the link icon) if that
also fails, so no broken image and no repeated failing requests.
- Extend the existing console.warn filter to drop matrix-js-sdk's
high-volume, benign timeline bookkeeping warnings ("EventTimelineSet…"
and "Decrypted event … is not in room …"), which fire constantly in
E2EE rooms with threads. Real warnings still log.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the status-presets feature:
- Presence-echo race: a heartbeat can echo the previous status just after
a new one is saved, reverting the input. Track the last-applied value
and ignore non-matching echoes until our own echo lands or a 15s window
elapses (bounded so a dropped echo can't block real cross-device
updates). Applies to Save, preset apply, and Clear.
- Duplicate chips: a saved custom preset that matches a built-in is now
hidden from "Your presets" (it already shows under Quick statuses).
- a11y: the two preset rows use aria-labelledby tied to their visible
headings instead of mismatched hardcoded aria-labels.
- Visual grouping: a custom preset's chip and its delete X now sit with
gap=0 as one unit while the row separates presets with gap=200, so a
chip and its delete no longer read as two separate presets. Delete/Plus
icons bumped to size=100 to match the folds chip-icon convention.
- Parity: addPreset/removePreset promises are now caught like the other
account-data call sites.
Docs updated to the exact 11-preset built-in list.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Status Message field required typing every status from scratch. Add
a one-click preset row:
- Built-in "Quick statuses" spanning gaming, social, life and work
(Gaming, In a party, Ranked grind, AFK, Watching, In a meeting,
Working remotely, Lunch, On vacation, Out sick...), each carrying a
suggested auto-clear so a click sets the message and the timer at once.
- Custom presets: save the current status as a reusable preset, stored
in io.lotus.status_presets account data (synced across devices via the
shared account-data list store), de-duped by normalized label, capped
at 20, deletable inline.
The existing save path is factored into a shared applyStatus() used by
the Save button and by preset apply, so server writes, the status
localStorage keys, and the auto-clear expiry bookkeeping stay identical.
Ordering/de-dupe logic is pure in utils/statusPresets.ts (upsertPreset,
normalizeLabel) with unit tests; no change to the presence wire format,
expiry monitor, or presence-mode selector.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 2 review agents on the bookmark sort/group feature:
- Flash on open: the persisted-sort atom now uses getOnInit so the saved
sort applies on the first render instead of briefly showing Newest and
reordering after mount.
- Stale collapse state: 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 across a session.
- Corrupt persisted value: validate the stored sort with a new
isBookmarkSort type guard, normalizing anything unexpected to Newest so
exactly one sort button is always active.
- a11y: room group headers now expose an explicit aria-label
("<room>, N saved messages") instead of announcing the avatar alt and
the visible name twice with a bare count, plus aria-controls linking the
header to its collapsible content region.
- Layout: move the sort control to its own toolbar row so the three
buttons don't crowd the count text in the narrow (266px) panel.
- Memoize filtered/sortedItems/groups for consistency with renderItem.
Adds unit tests for isBookmarkSort, group-order tie-break, and
groupBookmarksByRoom immutability.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Saved Messages panel showed bookmarks in one fixed order (newest
save first) with no way to reorganize. Add a Newest / Oldest / By-room
segmented sort control to the panel toolbar. In "By room" mode the list
renders collapsible per-room sections, with groups ordered by their most
recently saved message so active rooms float to the top. The chosen sort
persists across panel opens via a localStorage-backed atom.
Ordering and grouping are pure functions in utils/bookmarks.ts
(sortBookmarks, groupBookmarksByRoom) with deterministic eventId
tie-breaks, covered by bookmarks.test.ts (9 tests). No change to the
bookmark data model, account-data schema, useBookmarks, or how bookmarks
are created; search still feeds the sorter/grouper unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address findings from 3 review agents on the edit/reschedule feature:
- Race (correctness): handleEdit now marks the old message as cancelling
while its cancel is in flight, so its Edit/Cancel buttons are disabled.
Previously the old row stayed live during the fire-and-forget cancel, so
a fast second edit could orphan a still-scheduled event and send twice.
- Durability (correctness): on a failed cancel-old, re-insert the old
message if auto-prune removed its row while the modal was open, so the
still-live delayed event stays visible and retriable instead of failing
silently. Also clear any stale cancel error when starting an edit.
- a11y: per-row Edit/Cancel buttons now carry distinct aria-labels that
include the message preview and send-time, so screen-reader users can
tell which of several scheduled messages each button targets.
- UX: modal gains a submitLabel prop; the edit flow shows "Reschedule"
instead of "Schedule". Modal now focuses the message body on open.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The scheduled-messages tray was cancel-only. Add an inline edit button
that re-opens ScheduleMessageModal seeded with the existing body and
send-time, letting the user change the text and/or when it sends.
MSC4140 has no in-place edit, so an edit is schedule-new + cancel-old.
Order matters: the modal schedules the new delayed event first, then we
cancel the old one and only prune it from local state once the server
confirms. A failed cancel therefore leaves a visible, retriable copy in
the tray instead of silently letting the stale message fire or losing
the edit. Edits go through the plain-text composer, so rich content
collapses to m.text (acceptable for v1).
ScheduleMessageModal gains optional initialSendAt (seed the pickers) and
title props so it is reusable for both scheduling and editing.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Three review agents (no regressions found). Applied:
- Keyboard arrow-seek now reads the live media currentTime, not the throttled
~500ms state, so rapid presses accumulate instead of dropping steps.
- Scrubbing the waveform (or the fallback seek bar) BEFORE first play now loads
the media and plays from the clicked position (was a silent no-op).
- Unplayed bars use a dimmed accent (color-mix 32%) instead of a faint surface
token, for consistent contrast across TDS-dark/light + normal themes.
- Fixed first-bar always-lit off-by-one ((i+1)/len), and added overflow:hidden so
the strip clips rather than overflows in a very narrow drawer.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Voice messages carry an MSC1767 waveform (org.matrix.msc1767.audio.waveform) and the
recorder draws a live one, but AudioContent playback only showed a plain seek bar.
Now the player renders the waveform as bars that fill with the accent (TDS green under
Lotus Terminal) as the clip plays, and the waveform itself is the seek control —
click, drag, or keyboard (arrows +/-5s, Home/End) with role=slider + ARIA value text.
- AudioContent: new optional "waveform" prop + a WaveformSeek sub-component
(downsamples to 48 bars, mirrors the recorder's bar styling); falls back to the
plain Range seek bar when there's no waveform.
- Threaded through MAudio (RenderAudioContentProps) so timeline voice messages get it
automatically; the Media Gallery Audio tab passes it directly.
Improves both the timeline and the new gallery Audio tab at once.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Review of 28cb004e (3 agents) surfaced two real issues:
- Downloads used a raw mxc→http anchor, so encrypted media (voice messages are
almost always in E2EE DMs) downloaded ciphertext. Reuse FileDownloadButton for
both the audio AND file rows — it decrypts before saving, adds loading/success
state + a toast, and derives a filename extension.
- Audio playback bypassed MAudio's mimetype sanitization; pass the value through
getBlobSafeMimeType (e.g. application/ogg → audio/ogg) so encrypted/odd-mimetype
audio actually plays. Give voice messages a real filename+extension.
Also corrected the docs: AudioContent renders a seek bar, not an MSC3245 waveform.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- New Audio tab (m.audio) listing voice messages + audio files with an inline player
(reuses AudioContent + MediaControl: play/seek/speed, MSC3245 voice waveform,
decrypt-on-play), sender/date, and download. Added to the tab counts.
- 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; threaded eventId into LightboxItem.
- Update the (stale) LOTUS_FEATURES.md Media Gallery entry.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Upgrade the MSC3381 poll feature from a leaky half-implementation to a complete,
cross-client-correct one:
- End/close a poll (creator or redact-PL mod) via inline confirm → m.poll.end;
locks voting, reveals results, marks winner(s); only pre-end responses count.
- Honor poll kind: undisclosed polls hide counts/percent/bars/total until ended
(creator gets a Show-live-results vs Hidden-until-ended toggle; default live).
Previously every poll was created undisclosed yet the UI leaked live results.
- Enforce max_selections for multi-choice; radiogroup/checkbox a11y with arrow-key
roving and an AT-announced winner.
- Robust, dual-namespace wire handling: parse BOTH stable (m.poll/m.id/m.selections)
and unstable (org.matrix.msc3381.poll.*) by hand — matrix-js-sdk 41.7.0's
PollStart/Response parsers only understand the unstable bodies, so delegating to
them broke every stable poll (caught in agent review). Use the SDK Poll model only
for end validation + before-end filtering.
- Pure tally/visibility/winner/parse logic extracted to utils/poll.ts with 14 tests
incl. a stable/unstable wire-format round-trip.
Reviewed by 3 agents (spec/cross-client, logic, a11y/UI); findings applied.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The inline video embed container (EmbedMediaLandscape/Portrait) used CSS
aspect-ratio for its 16:9 / 9:16 box. An absolutely-positioned *replaced* element
— the player <iframe> (position:absolute; inset:0; width/height:100%) — collapses
to its ~200px intrinsic size inside an aspect-ratio box rather than filling it, so
after pressing play the YouTube/Vimeo player rendered at ~202x114 in the top-left of
the (correct) 600x340 facade box, leaving a wide gray gap. The click-to-play
facade uses an <img>, which doesn't hit this, so the pre-play preview looked fine.
Switch the video containers to the padding-top percentage hack (56.25% / 177.78%),
which derives a definite height from the definite width so the absolutely-positioned
iframe fills it reliably. Fullscreen override sets padding-top:0 + height:100vh.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- DP2: the earlier seed fix was ineffective (allInvitesAtom populates post-mount,
so first render is still empty). Rewrite to track invite room ids and stay
'unarmed' until the initial sync settles (+3s grace), notifying only for ids that
first appear after arming — robust to the async population race.
- DP4: batch the mutually-exclusive tag writes via Promise.all so a failure surfaces
a single toast instead of one per operation.
- DP15: route the two remaining StateEvent writes (RoomSoundboardPack / RoomImagePack,
which used an 'as unknown as keyof StateEvents' idiom the sweep missed) through the
typed sendStateEvent helper.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Replace literal-glyph UI icons in the URL preview cards with folds
Icon/Icons components, and hoist hardcoded provider brand hex values
into a single named BRAND_COLORS map.
Glyph -> folds icon:
- TikTok/Spotify musical note (♫) -> Icons.VolumeHigh
- Portrait play button (▶) -> Icons.Play
- Reddit comment count (💬) -> Icons.Message
- Steam gear (⚙) -> Icons.Setting
All use size="Inherit" so they keep the surrounding font-size/color.
Brand hex now referenced via BRAND_COLORS (tiktok, spotify, steam,
twitch, reddit, discord, npm, stackOverflow) instead of scattered
literals; colors are byte-for-byte identical and remain fixed brand
identities (not converted to TDS theme vars).
The game controller glyph (🎮) is left as-is: folds has no game/
controller icon and no close semantic match.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a typed `sendStateEvent(mx, roomId, eventType, content, stateKey?)`
helper in utils/room.ts that mirrors the DP16 account-data helper pattern.
The SDK's typed `sendStateEvent` overload rejects the fork's custom
`StateEvent` enum values, so every call site cast arg 2 to `any` (which
also collapsed the content type). The single `as any` cast now lives inside
the helper; a generic `content: T extends object` keeps each call site's
content type checked.
Route all 32 `mx.sendStateEvent(..., StateEvent.X as any, ...)` casts across
20 files through the helper. The 2 dynamic-string casts in developer-tools
(SendRoomEvent, StateEventEditor) pass a runtime string, not an enum value,
so they stay as-is.
No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useBookmarks / useReminders / useUserNotes were near-verbatim copies of a
concurrency-critical engine (module-scoped singleton, per-client
subscribe/teardown, a serialized write-queue that prevents lost-update
clobbering, a listener Set, and the account-data subscription).
Extract it into createAccountDataListStore<T, C>({ eventType, read, write }) in
src/app/hooks/createAccountDataListStore.ts. The write-serialization semantics
are preserved identically (still the lost-update fix). The differing payload
shapes are parameterized via read/write: bookmarks/reminders wrap a list
({bookmarks}/{reminders}); notes is a flat Record passed through unchanged.
The three hooks become thin wrappers with their exact public APIs unchanged
(same exported names, signatures, return shapes, and mutators), so no call site
changes. The DP16 setAccountData helper is used inside the queue.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add centralized typed helpers in src/app/utils/accountData.ts:
- getAccountData<T>(mx, eventType): T | undefined (returns content)
- setAccountData<T>(mx, eventType, content): Promise<void>
These wrap the single `as any` cast needed because matrix-js-sdk's typed
overloads reject the fork's custom account-data event names. Every call site
now stays fully typed on its content shape.
Route all account-data reads/writes that previously used
`(mx as any).getAccountData/setAccountData` or `mx.getAccountData(... as any)`
through the helpers (or, where a MatrixEvent is needed, through the existing
utils/room.ts getAccountData whose param is widened to accept string keys).
No behavior change: same event types, same content shapes.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a reactive `useMemberAvatar(room, userId, w?, h?, resize?)` hook
returning { name, avatarUrl }, standardizing the member name + avatar
trio and the RoomStateEvent.Members reactivity pattern (N6). Convert
ReadReceiptAvatars and EventReaders to render per-user avatars via a
small child component using the hook, preserving exact sizes, fallback
rendering, TDS pill/tooltip styling and behavior.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a pure `getMemberName(room, userId): string` helper in utils/room.ts
(= getMemberDisplayName ?? getMxIdLocalPart ?? userId) and replace the
inline `getMemberDisplayName(room, id) ?? getMxIdLocalPart(id) ?? id`
fallback across the codebase. No behavior change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DP14: Message send-status icon and ReadReceiptAvatars pill hardcoded
dark-theme accent hex/rgba, so TDS light mode kept bright cyan/red
instead of the theme-overridden darker values. Route all colors/glows
through the theme-aware --lt-* CSS variables (mirroring EventReaders),
using color-mix for translucent tints and accent-alpha icon colors.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
DP7: fix inverted Deafen/Undeafen aria-label on the sound button so it
matches the action and tooltip.
DP8: add aria-pressed to Sound/Video/ScreenShare toggles and align the
Microphone toggle to the same "pressed = feature on/active" semantic.
DP9: return focus to the trigger when the GifPicker closes and cap its
fixed width to min(312px, calc(100vw - 16px)) so it can't overflow
narrow viewports.
DP10: drop redundant mouse-only clear onClick nested inside the search
filter Chip buttons (parent chip/menu already performs the clear);
the cross icons are now purely decorative.
DP11: constrain the voice recorder widget and let the waveform shrink so
it fits a narrow composer, and expose the live duration as a role="timer"
snapshot instead of spamming a screen reader every 100ms.
DP12: announce the live-call participant count via a visually-hidden
role="status" region.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Invites: seed the notify baseline with the invite count at mount instead
of 0, so a warm reload (allInvitesAtom populates synchronously from cache
while SYNCING) no longer re-fires the toast+sound for pre-existing invites.
Only a genuine increase notifies.
- Status: the cross-device sync effect gated on a truthy presence.status, so
a remote CLEAR never reset the input or localStorage[STATUS_MSG_KEY] and
usePresenceUpdater.readStatus() re-sent the stale status. Now mirror an
empty status as a clear (skipping offline/invisible, which carries an empty
status_msg by design).
- Soundboard: useState initializers ran once and never recomputed when the
room/rooms arg changed. Add a resync effect keyed on the arg while keeping
the live state-event update path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Several fire-and-forget promises failed silently:
- RoomInput slash-command exe() rejections reset the editor as if they
succeeded; now caught and shown via an error toast.
- Favourite / low-priority room-tag toggles (setRoomTag/deleteRoomTag)
swallowed rejections; now surfaced via the same error toast.
- Call-decline sendEvent(RTCDecline) was uncaught; now logged best-effort
while the local UI still dismisses.
Adds a shared createErrorToast builder mirroring createDownloadToast.
/kick and /ban route through rateLimitedActions, whose to() helper
swallows non-429 errors, so those two can still resolve on failure — noted
in code; the top-level exe() catch covers everything that does reject.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The room-name (settings + create-room) and display-name inputs had no client-side
length guard, so an over-long value only failed after a server round-trip. Add
maxLength={255} (matching the existing inline-rename cap in RoomNavItem; under
Synapse's 256 max_displayname_length). Also clamp the emoji-picker prepend in the
two room-name fields, since programmatic setState isn't constrained by the DOM
maxLength and could otherwise push past the cap.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ReadReceiptAvatars read avatar + display-name from room member state at render
time, so they only refreshed when the parent Message re-rendered (on a receipt
change). A reader changing their avatar/display-name left a stale avatar/name
until an unrelated re-render. Subscribe to RoomStateEvent.Members (the only signal
that fires for name, avatar AND membership changes — RoomMemberEvent has no Avatar
event) scoped to the displayed readers, and force a re-render so the pill updates
live. Listener is cleaned up; userIds identity is stable (memoized in the read-
positions map) so no subscribe churn.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
useAvatarDecoration GET /profile/{user}/io.lotus.avatar_decoration returns 404 for
every user without a decoration (most users), which the browser logs as a failed
request — a console 404 per member. Fetch the whole profile (GET /profile/{user},
200 with all MSC4133 fields) and read the decoration field out of it instead. Same
negative-caching behavior; no functional change, just no 404 storm.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The IndexedDB crypto store is evictable while the localStorage session survives,
so the browser can drop it out from under a live login -> the device resurrects
with a blank key store and re-uploads a one-time key at an id Synapse already
holds -> a permanent '400 M_UNKNOWN: One time key ... already exists' upload
storm (and undecryptable to-device/media keys downstream).
initClient now calls navigator.storage.persist() before creating the crypto
store, so the origin's storage is marked persistent and won't be evicted.
Best-effort (granted by engagement/PWA-install, no prompt; denial is non-fatal).
Preventive only -- an already-diverged device still needs a clean re-login.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
embed.reddit.com only renders the /r/<sub>/comments/<id> path (verified against
reddit's embed widgets.js + curl: frame-ancestors * , no X-Frame-Options, so
iframing itself is fine). But getRedditPostEmbed built a bare
embed.reddit.com/comments/<id> for redd.it short links — which serves a 'not
found' page — and the i. host strip routed i.redd.it/*.jpg image links into the
same branch. Returning a (broken) URL also suppressed renderContent's og:url
fallback that would resolve the short link to its canonical /r/<sub>/... form.
Fix: getRedditPostEmbed returns null for any non-reddit.com host, so redd.it /
i.redd.it / v.redd.it fall through to the og:url fallback (working embed or a
normal preview card, never a blank 'not found' iframe). The reddit.com post path
is unchanged. Tests updated (22 pass).
Note: the live 'broken' symptom is mostly the deploy gap — live still runs the
old www.redditmedia.com embed code while the live CSP only allows
embed.reddit.com; deploying the current lotus branch (which emits
embed.reddit.com) resolves it.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two review agents found the preview card degrades to 'id + logo + Join' for real
reasons. Fixes:
- **via was dropped at the summary fetch** (root cause). RoomSummaryLoader now
accepts + forwards `via` to getRoomSummary (and keys the query on it);
JoinBeforeNavigate passes viaServers; the lobby Preview chip carries data-via and
Lobby.handleOpenRoom appends it to the navigated URL. Without this, previews of
rooms the HS isn't already in came back sparse/404.
- **No loading/error state** -> RoomSummaryLoader now surfaces {loading,error};
JoinBeforeNavigate shows a Spinner while loading instead of the degraded card.
- **Room id leaked as name AND topic AND header** -> stop using the raw `!id` as
the topic fallback (show 'No description'); name falls back to canonical_alias
then alias-localpart; the page header shows the summary name.
- **Richer, membership-aware card**: forward canonical_alias (shown under the name),
world_readable ('Readable' badge), and membership -> the button now shows Accept
invite / Requested / Banned correctly instead of a Join that lies; a 'hero' layout
for the single-preview page (primary Join button, unclamped topic). Removed dead
`|| undefined ||`.
IRoomSummary re-adds canonical_alias (the SDK type Omit<>s it though MSC3266
returns it). 738 tests pass, build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Follow-up to the room-preview work so you can actually reach a preview from the
room lists, not just via a matrix.to link:
- Space lobby (RoomItem): un-joined child rooms now show an Eye 'Preview' chip
next to Join. It routes through the existing onOpen (data-room-id -> space/room
path), which renders the full JoinBeforeNavigate preview card because the room
isn't joined.
- Explore grids (Server + Featured): pass join_rule (and encryption, where the
summary provides it) to RoomCard, so the directory/featured cards — which already
are full preview cards — now show the join-rule chip and a Request-to-join button
for knock rooms instead of a Join that fails.
738 tests pass, build clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Room preview (JoinBeforeNavigate -> RoomCard via getRoomSummary) was already
built and, verified after the Synapse 1.156 upgrade, works via the SDK's unstable
im.nheko.summary endpoint (the old 'blocked' flag tested the wrong /v1 path).
Polish the preview card with the summary fields the endpoint returns:
- join-rule chip (Restricted / Ask to join / Invite only / Private; public shows
none) + an Encrypted badge (from im.nheko.summary.encryption).
- knock-rule rooms now show a 'Request to join' button (mx.knockRoom) instead of a
plain Join that would fail — mirrors the RoomIntro knock flow.
Props are optional so other RoomCard usages are unaffected. LOTUS_TODO updated:
Room Preview BLOCKED -> done; Synapse 1.155 -> 1.156.0. 738 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Math rendering already shipped (both the $…$ shorthand and the spec
data-mx-maths form render incoming). But the composer emitted no math, so a
Lotus user's $E=mc^2$ went out as raw text — it rendered on Lotus (both sides
parse $…$) but showed as literal text on Element and other clients.
toMatrixCustomHTML now converts $…$/$$…$$ to
<span|div data-mx-maths="LATEX"><code>LATEX</code></span|div>
(spec CS-API §11.5), reusing the existing splitMathSegments parser. Math is
extracted BEFORE markdown so LaTeX (_, *, \, {}) isn't mangled, and the emitted
span survives the block-markdown pass via the existing ignoreHTMLParseInlineMD
HTML-tag guard. A new allowMath opt threads through the top-level call sites;
code-line/code-block paths use empty opts so math is off inside code. The plain
body keeps literal $…$ as the fallback.
Scope: inline $…$ + single-line $$…$$. Multi-line block $$ (spans editor
paragraph nodes) deferred — still renders on Lotus via the plain-body path.
New output.test.ts (8 cases): span/div emission, escaping, markdown-bypass,
currency non-match, code-mark + code-block exclusion. 738 tests pass.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
In-room verification requests arrive as m.room.message with
msgtype 'm.key.verification.request', so isNotificationEvent() counted them
(type is m.room.message). A stale/old request at the tail of a DM therefore
re-lit the room's unread dot — and could fire a toast/OS notification — on every
fresh sync (app update / CTRL+F5 cache clear); opening the room only cleared it
via the local read-receipt echo, so it returned on the next reload.
Exclude that msgtype from isNotificationEvent so verification control messages
never drive unread/notifications (mirrors the existing member/redaction/edit
exclusions). The rest of the verification flow already uses distinct event types
that aren't notification events.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the coverage-review agent's ranked recommendations (clean iframes, ids in
the URL, one CSP host each):
- Bluesky: bsky.app/profile/{authority}/post/{rkey} → embed.bsky.app (rich, self-
resizing like the other post embeds).
- Loom: loom.com/share|embed/{id} → www.loom.com/embed/{id} (16:9).
- Kick: kick.com/{channel} → player.kick.com/{channel} (live channels only; VODs/
clips have no clean embed and fall back to a link).
Parsers unit-tested. CSP frame-src gains embed.bsky.app / www.loom.com /
player.kick.com (desktop + live web, both updated). Needs live verification once
deployed since the embeds themselves can't be exercised from here.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the quality-review agents:
- Revert on.soundcloud.com support: the w.soundcloud widget doesn't follow the
redirect (needs an oEmbed resolve, deferred).
- Add a Close button to playing video/TikTok embeds and a Collapse button to the
expanded X post — playback was previously one-way (only escapable by scrolling).
- focus-visible ring on the embed facade (folds resets outline:none, leaving
keyboard users with no indicator).
- Only subscribe to resize postMessages while the iframe is mounted (was attaching
a global listener per Instagram/Reddit facade before play).
- TikTok oEmbed fetch now uses AbortController (abort on unmount) + aria-busy /
'Loading…' label on the resolving spinner.
- Decorative facade thumbnails use alt="" (parent already names them); drop the
dangling-colon aria-labels when there's no title.
- Cap URL previews at 6 per message so a link-dump can't spawn dozens of fetches.
Tests 728. No CSP change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- TikTok player URL trimmed to ?autoplay=1&rel=0 (the control params were all
default-on no-ops).
- SoundCloud on.soundcloud.com share short links now embed (widget follows the
redirect via w.soundcloud.com).
- redd.it short links now embed via embed.reddit.com/comments/<id>/ (the redirect
target is reddit.com/comments/<id>, no subreddit needed).
No CSP change (hosts already allowlisted). Vimeo event/ondemand deferred — the
embed format/host couldn't be verified from a fake id and would need a CSP
change; leaving it for the review agents to research. Tests 21.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the second review pass (agents inspected providers' live embed scripts):
- FIX Reddit auto-height (was broken): embed.reddit.com posts
{ type:'resize.embed', data:<height> } — height is under 'data', not 'height',
so it never resized and clipped taller posts (scrolling=no). Add that shape.
- Align TweetEmbed sandbox with EMBED_SANDBOX (adds allow-popups-to-escape-sandbox)
so links/login popups opened from inside a tweet aren't crippled.
- Vimeo: resolve channel/group/album video forms (vimeo.com/channels/{n}/{id} etc.),
not just paths starting with the id.
- Mobile Shorts: m.youtube.com/shorts/{id} now renders portrait 9:16, not landscape.
- Move extractEmbedHeight into videoEmbed.ts and unit-test all three resize shapes
(Instagram MEASURE / Reddit resize.embed / Twitter twttr.private.resize).
Agents confirmed everything else current & robust (Dailymotion geo host, Tidal
gridify, sandbox tokens are a safe superset, X still on platform.twitter.com,
550px cap). Tests 21 in this suite.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
From the review-agent audit:
- Dailymotion: move off the Sept-2024-deprecated /embed/video path to
geo.dailymotion.com/player.html.
- Reddit: point at embed.reddit.com (www.redditmedia.com now 301s there).
- Vimeo: parse the unlisted hash (vimeo.com/{id}/{hash}) and pass &h=…, add dnt=1.
- Tidal: layout=gridify + ~275px height for albums/playlists (fixes narrow player).
- YouTube/Shorts: playsinline=1 (iOS keeps playback inline); parse /live/ +
music.youtube.com.
- Apple Music: /music-video/ renders 16:9 instead of a fixed audio height.
- Re-add a minimal sandbox to all media iframes (omits allow-top-navigation →
blocks phishing redirects) — defense-in-depth atop the CSP frame-src allowlist.
- Self-resize Instagram + Reddit post embeds via a shared useIframeAutoHeight hook
(also now covers the Tweet embed; matches platform.x.com origin too); drop the
fixed 720/480 heights. Cap tweet/post columns at ~550px, centered.
Also from user feedback: TikTok portrait player dropped music_info/description,
which forced TikTok's wide 'video + info panel' layout and left empty space
beside the video — now a clean 9:16 player that fills the box.
Tests 726 pass. CSP frame-src gains embed.reddit.com (separate desktop commit).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
TikTok 'copy-link' share URLs (vm.tiktok.com, tiktok.com/t/…) carry no video id
and the homeserver's link preview is bot-walled (generic 'TikTok - Make Your
Day', no og:url/og:image), so they fell through to the static fallback card with
no play button.
New TikTokEmbedCard resolves the id client-side via TikTok's CORS-enabled oEmbed
API on click (keeps the facade privacy model), then plays the player/v1 embed
(portrait, autoplay + full controls + our fullscreen button). Canonical
/video/<id> links skip the lookup. Also added a general og:url fallback so other
short/redirect links resolve to their canonical form when the raw URL doesn't.
Web CSP connect-src gains www.tiktok.com for the oEmbed fetch (desktop already
allows https:). Tests 19/19.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>