Commit Graph
100 Commits
Author SHA1 Message Date
jaredandClaude Opus 4.8 a4660a8163 feat(polls): let creators set max selections for multiple-choice
The poll creator only offered single (max_selections 1) or multiple = pick ALL
options — no way to run a "pick your top 2" poll, even though the display side
already enforces an arbitrary max_selections ("Select up to N"). Add a "Voters
can pick up to N of M options" control shown for multiple-choice polls. Defaults
to the option count (preserving the old select-all behavior) until lowered;
clamped to [2, filled option count] on submit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:06:32 -04:00
jaredandClaude Opus 4.8 85ac8de5d9 style: apply prettier across fork files
check:prettier was not part of my gate routine, so formatting drift accumulated
across the session's touched files (and a few older ones). Run prettier --write
to bring the repo back to 'All matched files use Prettier code style!'.
Formatting only — no logic changes. tsc/tests/build all green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:36 -04:00
jaredandClaude Opus 4.8 d727e7a7ab refactor(schedule): dedup formatSendAt into shared formatFriendlyDateTime
ScheduleMessageModal had a local formatSendAt(Date) byte-equivalent to the
tested formatFriendlyDateTime (utils/datetimeInput). Reuse the shared, unit-
tested helper instead of a second copy — identical output. (Also prettier-clean.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:52:29 -04:00
jaredandClaude Opus 4.8 3a1c626bc8 feat(stickers): "recently used" row in the sticker picker
The emoji and GIF pickers both have a "Recent" row, but the sticker tab of the
shared EmojiBoard did not — you had to hunt through packs to re-send a sticker.
Add recent stickers, mirroring recentGifs:

- New state/recentStickers.ts (localStorage cinny_recent_stickers_v1, deduped by
  url, capped 16) + pure addRecentSticker with 4 unit tests.
- EmojiBoard: a "Recent" group in stickerGroupItems and a RecentClock sidebar
  icon in StickerSidebar, shown only when recents exist. Entries are rebuilt into
  minimal PackImageReaders (StickerItem needs only url/shortcode/body) so they
  render + re-send like pack stickers.
- Recorded on select in the shared delegated click handler, covering both the
  grouped and search paths.

Blast radius is the sticker tab only (reactions/status use the emoji tab).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:43:17 -04:00
jaredandClaude Opus 4.8 fb8e0c6e14 fix(threads): enable slash commands in the thread composer
The thread composer already showed the /command autocomplete (RoomInput.tsx:954
was never gated), but the interpreter was disabled (:523), so /me, /shrug,
/invite, etc. sent literally in threads - a confusing inconsistency and the
other half of the threads "v1" limitation.

Remove the thread gate: content-transform commands (/me, /notice, /shrug,
/tableflip, /unflip) flow into the normal send path, which already routes to the
thread via threadRootId; the rest are room-level actions. No command sends a
mis-routed timeline message (verified against useCommands). Scheduling stays
disabled in threads for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:34:24 -04:00
jaredandClaude Opus 4.8 360e72f73c a11y(polls): associate voter list with its answer for screen readers
CI / Build & Quality Checks (push) Successful in 10m46s
CI / Trigger Desktop Build (push) Successful in 6s
From review: the per-answer voter line sat inside the radiogroup with no
association, so a screen-reader user on the radio didn't hear who voted. Add
aria-describedby from each answer to its voter line (id poll-voters-<eventId>-
<answerId>) and prefix the line with "Voted by" for a clearer announcement.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 22:00:27 -04:00
jaredandClaude Opus 4.8 1aaea09fc9 feat(polls): "See who voted" — per-answer voter list
The poll card showed vote counts but never who voted, even though
computePollState already parses a sender for every response. Surface it:

- tallyResponses now also returns voters: Map<answerId, senderId[]>, built in the
  same latest-response-per-sender loop as the counts, so voters can never disagree
  with the numbers (voters.get(id).length === counts.get(id)). +5 unit tests.
- PollContent adds a "Show who voted" toggle, shown only when results are visible
  (disclosed live, or undisclosed after end — so a secret ballot stays secret).
  When on, each answer lists its voters' display names (getMemberName), rendered
  as a sibling of the answer button so the radiogroup keyboard model is untouched.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 21:56:50 -04:00
jaredandClaude Opus 4.8 5cce94edba refactor(notifications): quiet-hours uses shared tested time-window helper
isInQuietHours was a hand-rolled, untested duplicate of the overnight-window
logic. Replace it with the shared, unit-tested isWithinTimeWindow (utils/
timeWindow.ts) - identical behavior for valid HH:MM inputs, more robust on
malformed ones (returns false rather than doing NaN math), and now covered by
timeWindow.test.ts. One implementation instead of two.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:43:08 -04:00
jaredandClaude Opus 4.8 f155a4dc22 feat(threads): up-arrow edits your last thread reply (+ fix cross-fire)
The thread composer reused RoomInput's hardcoded editableName="RoomInput", so
the main timeline's global up-arrow "edit last message" handler fired while
focused in a thread composer and targeted the MAIN timeline's last message
(wrong), and there was no up-arrow edit for the thread itself.

- Make editableName a RoomInput prop (default "RoomInput"); the thread composer
  passes "ThreadInput", so the two up-arrow handlers never cross-fire.
- Add an up-arrow-edit handler to ThreadTimeline (parity with RoomTimeline):
  empty thread composer + Up -> edit the latest editable reply in that thread,
  using thread.liveTimeline + canEditEvent + setEditId.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 20:28:26 -04:00
jaredandClaude Opus 4.8 61f1733f50 feat(notifications): cross-platform "Pause Notifications" / snooze
Manual DND only existed via the desktop tray (manualDndAtom), so web/mobile
users had no way to pause notifications, and there was no snooze-for-a-duration
anywhere. Add a "Pause Notifications" control in Settings > Notifications:

- Presets: 30 min / 1 hour / 4 hours / Until 8 AM / Until I resume, plus Resume;
  live "Paused until ..." status that flips back on when the snooze lapses.
- Persisted snooze instant (cinny_notification_snooze_until_v1) so it survives a
  reload; 0 = off, SNOOZE_INDEFINITE = until resumed.
- Feeds the existing notification gate (ClientNonUIFeatures, both the message and
  invite monitors) alongside Focus Assist / manual DND / Quiet Hours, suppressing
  notify() and playSound().
- Pure helpers isSnoozeActive/nextTimeAtHour/SNOOZE_INDEFINITE in utils/snooze.ts
  (+5 unit tests); persisted atom in state/notificationSnooze.ts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 19:08:02 -04:00
jaredandClaude Opus 4.8 e7e6d44a31 feat(image-viewer): scroll wheel to zoom
CI / Build & Quality Checks (push) Successful in 10m44s
CI / Trigger Desktop Build (push) Successful in 18s
The timeline image viewer only zoomed via the -/+ buttons and the % toggle. Add
scroll-to-zoom (wheel up = in, down = out) for parity with the media-gallery
lightbox.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:58:21 -04:00
jaredandClaude Opus 4.8 829525506c fix(image-viewer): pan tracks the cursor 1:1 when zoomed
The timeline image viewer's transform applied translate inside scale(), so
dragging a zoomed image moved it by zoom x the cursor distance (panning outran
the pointer). Divide the pan offset by zoom so it tracks 1:1 - matching the
media-gallery lightbox fix.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 17:57:33 -04:00
jaredandClaude Opus 4.8 dcad282749 fix(jump-to-time): friendly message when homeserver lacks MSC3030
Jump to Time uses timestampToEvent (MSC3030). On a homeserver that doesn't
support it, the dialog showed the raw "M_UNRECOGNIZED: Unrecognized request"
error. Show a clear explanation instead when errcode is M_UNRECOGNIZED.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:22:43 -04:00
jaredandClaude Opus 4.8 4d78d427d2 fix(message): gate "Copy Text" to textual message types
Review noted media without a caption has a filename body, so Copy Text showed
and copied the filename. Gate to m.text/m.emote/m.notice so it only appears for
actual text messages (matching the intended behavior).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:20:58 -04:00
jaredandClaude Opus 4.8 12aa49f054 feat(message): add "Copy Text" context-menu action
The message menu had Copy Link (permalink) but no way to copy the message text
itself. Add a Copy Text item that copies the plain-text body with the reply
fallback stripped (trimReplyFromBody). It renders nothing when there is no
usable text body (e.g. media without a caption), so the caller can list it
unconditionally next to Copy Link. Uses Icons.Text.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:17:52 -04:00
jaredandClaude Opus 4.8 df8afe4410 fix(location): wrap long MSC3488 descriptions
Constrain the rendered location description width and break long tokens so an
oversized description from another client can't overflow the message bubble.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:14:09 -04:00
jaredandClaude Opus 4.8 ea6e89ae56 feat(location): render MSC3488-only locations (uri + description)
The m.location renderer only read the legacy top-level geo_uri, so a location
from a client that sends only the MSC3488 shape (uri under
org.matrix.msc3488.location / m.location) showed as broken. Fall back to that
uri, and display an MSC3488 description above the coordinates when present.
Closes the consume-side gap noted in review of the send-side MSC3488 change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:12:01 -04:00
jaredandClaude Opus 4.8 75b94bd39f fix(night-light): warn when schedule start equals end
From review: equal start/end silently disables the tint (zero-length window).
Show an inline hint so the user isn't left wondering why nothing happens.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:09:33 -04:00
jaredandClaude Opus 4.8 2d8bc487e5 feat(location): send MSC3488-compliant shared-location events
Location sharing sent only a legacy geo_uri with a "Location: geo:..." body, so
other clients often rendered it as plain text. Include the MSC3488 blocks
(org.matrix.msc3488.location/asset/ts + m.ts) alongside geo_uri and a readable
body, so Element and others render a proper location pin. Local rendering is
unchanged (still reads geo_uri).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:08:32 -04:00
jaredandClaude Opus 4.8 cf4ee6618c feat(night-light): optional auto-on schedule (time window)
Night Light was all-or-nothing. Add an optional schedule with From/To time
inputs so the warm overlay only shows during set hours and toggles 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
utils/timeWindow.ts. New settings: nightLightSchedule/Start/End (default
21:00-07:00).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:05:56 -04:00
jaredandClaude Opus 4.8 23950a6797 fix(invite): robust QR filename + encode-failure feedback
From review: sanitizeFilename now denylists only path-hostile + control chars
(preserving Unicode room names instead of collapsing CJK/emoji names to "room"),
converts whitespace to dashes, trims separator runs, and caps length. Also
surface an inline error when canvas.toBlob returns null so a failed PNG encode
isn't a silent no-op.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 16:03:04 -04:00
jaredandClaude Opus 4.8 a1107015fd feat(invite): download the room QR code as a PNG
The invite QR was display-only. Add a "Download QR" button that exports it as a
PNG via an offscreen high-resolution (1024px, spec 4-module quiet zone)
QRCodeCanvas + canvas.toBlob, saved through useSaveFile (filename from the room
name) with the standard download toast. The visible code stays an SVG so it
renders crisply at any size/theme.

Also corrects the stale LOTUS_FEATURES note (the QR is generated locally via
qrcode.react, not api.qrserver.com).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:57:19 -04:00
jaredandClaude Opus 4.8 4a3a80df9f fix(reminders): make cancel optimistic, no contradictory error
The shared account-data store removes optimistically with no rollback, so the
reminder row vanished the instant Cancel was clicked. Showing a "could not
cancel" error beside the already-gone row (and it reappearing on next sync) was
self-contradictory. Match the removeBookmark convention: fire-and-forget
optimistic removal, no inline error. Drops the now-moot cancelling busy-guard
and uses a collision-safe React key for same-minute custom reminders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:56:22 -04:00
jaredandClaude Opus 4.8 7d67cdeef0 feat(reminders): view and cancel a message's existing reminders
There was no way to see or cancel a reminder once set (removeReminder was only
called by the fire-and-forget monitor), and addReminder didn't dedupe, so a
message could silently accumulate duplicate reminders. The Remind Me dialog now
lists the reminders already set on that message (soonest first) each with a
cancel button.

- New shared, tested formatFriendlyDateTime(ts, now?) in utils/datetimeInput.ts
  (Today/Tomorrow/date + time).
- Per-row cancel busy-guard; inline "Could not cancel" on failure.

Also applies two nits from the custom-time review: focus the date input when the
custom picker is revealed, and clear the error when editing date/time.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:51:11 -04:00
jaredandClaude Opus 4.8 b6725a6ee3 feat(reminders): custom date/time option in Remind Me
The Remind Me dialog only offered four fixed presets, so you couldn't set a
reminder for an arbitrary time. Add a "Custom time…" option that reveals date +
time pickers (validated >= 1 minute in the future) and sets the reminder at that
absolute timestamp.

Also extract the local date/time <input> helpers (toLocalDate, toLocalTime,
parseLocalDateTime, pickerInputStyle) into a shared, unit-tested
utils/datetimeInput.ts and reuse them in ScheduleMessageModal (deduped from an
inline copy) — identical output, now covered by tests.

Documents the previously-undocumented Message Reminders feature in
LOTUS_FEATURES.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:45:43 -04:00
jaredandClaude Opus 4.8 23e264d179 fix(bookmarks): drop unused senderId from stored bookmark
Review noted the 500-entry io.lotus.bookmarks blob approaches the ~65KB event
limit. The senderId I stored was never read back (the panel re-resolves the
live sender via the event and uses the senderName snapshot otherwise), so it
was dead payload. Keep only senderName.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:40:39 -04:00
jaredandClaude Opus 4.8 e01b87b214 feat(bookmarks): show who wrote each saved message
Saved-message cards showed the room and time but not the author, so in a busy
room you couldn't tell who said it without jumping. Now each card shows
"{sender} - {time ago}":

- Bookmark gains optional senderId/senderName (snapshotted at save time in
  Message.tsx from the already-computed sender display name); optional so
  existing stored bookmarks stay valid.
- The panel re-resolves the author's current display name live from the event
  when the room is joined, falling back to the stored snapshot for left rooms.
- Search now also matches the author name.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:37:12 -04:00
jaredandClaude Opus 4.8 f9af363dd6 fix(media-gallery): harden download filename + zoom/pan from review
- mediaFilename: only skip appending an extension when the body already ends in
  a plausible short alphanumeric extension, so "Screenshot 2024.01.05" still
  gets a real extension appended for the saved file.
- Lightbox pan: divide the translate by zoom (it runs nested inside scale), so
  dragging tracks the cursor 1:1 instead of moving `zoom`x too far.
- Wheel: ignore deltaY === 0 (pure horizontal scroll no longer zooms out).
- Zoom-out button disables at the real reachable minimum (0.2, not dead 0.1).
- Zoom-controls group gets role="group" so its aria-label is announced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:28:27 -04:00
jaredandClaude Opus 4.8 8a92821a48 feat(media-gallery): zoom & pan images in the lightbox
The timeline image viewer supports zoom/pan, but the gallery's own lightbox
rendered a plain object-fit:contain image. Add the same affordances for images
(videos keep their native controls):

- scroll wheel or header -/+ buttons to zoom; +/-/0 keys; double-click or the
  % chip toggles 1x<->2x
- drag to pan when zoomed; zoom/pan reset when navigating to another item

Reuses the shared useZoom/usePan hooks (usePan already cleans up drag listeners
on unmount and resets pan when zoom returns to 1x).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:23:14 -04:00
jaredandClaude Opus 4.8 68a88e84b8 feat(media-gallery): download images/videos from the viewer and grid tiles
The gallery's File and Audio tabs already had download buttons, but images and
videos could only be saved by jumping to the source message. Add:

- a Download button in the lightbox header (full-resolution source), and
- a hover/focus download button on each image/video grid tile

Both reuse the shared FileDownloadButton (decrypts E2EE media client-side, saves
via useSaveFile, spinner/check/retry states). The tile download control is a
sibling of the tile button (not nested — avoids interactive-in-interactive) and
stays visible on touch (hover:none) devices. Download always targets the
full-res file/url, not the thumbnail.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 15:18:55 -04:00
jaredandClaude Opus 4.8 82eb65b822 fix(scheduled): clear stale send-now error when a row is edited
CI / Build & Quality Checks (push) Successful in 10m50s
CI / Trigger Desktop Build (push) Successful in 9s
handleEdit already clears cancelErrors for the old row; also clear sendErrors
so a prior failed "Send now" doesn't leave a stale inline error after editing.
Cosmetic hygiene, matching the existing cancelErrors handling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:51:15 -04:00
jaredandClaude Opus 4.8 708a15d196 fix(soundboard): let the clip-name field fill its available width
The folds Input container is display:flex with no width, so inside the row's
grow="Yes" wrapper the wrapper grew but the input collapsed to content width.
Set width:100% on the Input so the name field uses the space the row gives it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:48:00 -04:00
jaredandClaude Opus 4.8 120ad2d1b5 feat(scheduled): add "Send now" action to the scheduled-messages tray
Fire a pending scheduled message immediately via MSC4140 action:'send'
(the server dispatches the stored delayed event now, as a normal timeline
event) instead of having to cancel and retype.

- sendScheduledMessageNow(mx, delayId) mirrors cancel/restart with action:'send'
- handleSendNow reuses the per-row busy guard; prunes local state only once the
  server confirms; a failed send shows an inline "Could not send now" error with
  the message still sendable/editable/cancellable
- Send-now IconButton (Icons.Send) added before Edit/Cancel in each row

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:47:52 -04:00
jaredandClaude Opus 4.8 b38df58b68 fix(voice): release mic on cancel/unmount + recorder polish
Address findings from 2 review agents (pause/resume duration model +
meter lifecycle were verified correct):

- Mic-stream leak (HIGH, pre-existing but in this change's blast radius):
  the mic tracks were only stopped inside mr.onstop, which cancel() nulls
  and the unmount effect never triggered — so cancelling or unmounting
  mid-recording/pause left the mic live (OS indicator on). Hold the stream
  in a ref and release its tracks explicitly (stopStream) on cancel and on
  unmount, independent of onstop. Normal stop still releases via onstop.

- Defensive: startMeters now cancels any existing rAF/interval before
  starting, so it can never spawn a second loop.

- a11y/UX: the finish button (checkmark, advances to the preview step) is
  relabeled "Finish recording"/"Finish" so the label matches the check
  glyph (was "Stop recording" with the old pause icon). The three
  recording-control buttons get flexShrink:0 so they don't squish the
  waveform at narrow composer widths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 14:04:31 -04:00
jaredandClaude Opus 4.8 13304f1571 feat(voice): pause / resume while recording
The voice recorder was a single continuous take — an interruption meant
stopping early or starting over (and the Stop button confusingly used a
Pause icon). Add real pause/resume.

- MediaRecorder.pause()/resume() with a new 'paused' state.
- Duration now accumulates only active-recording time: an accumulate-on-
  pause model (accumulatedMsRef + segmentStartRef) replaces the wall-clock
  startTime, so paused time is excluded from both the live timer and the
  finalized preview duration.
- Extracted startMeters/stopMeters so the waveform rAF + timer interval are
  reused across start/resume; stopMeters keeps the audio graph alive for
  resume while stopAll tears it down.
- Recording view now also renders the 'paused' state: a Pause/Resume toggle
  (Pause vs Play icon), the record dot stops pulsing (dimmed), and the
  waveform/timer freeze. Stop/Cancel/unmount all handle a paused recorder.
- Fixed the mislabeled finish button: it now shows a checkmark (it advances
  to the preview step) instead of a pause icon.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:58:01 -04:00
jaredandClaude Opus 4.8 87b1c1a702 fix(captions): harden caption editing after review
Address findings from 2 review agents (core edit path was verified
correct — media preservation incl. encrypted, threading, no-op guard):

- Require a filename (utils/room.ts): canEditCaption now also checks the
  MSC2530 `filename` exists. Fixes media from clients that omit filename,
  where the editor prefilled the filename as a caption and clearing it
  wrote an empty body. Such media simply isn't caption-editable (matches
  renderCaption never showing a caption for it).

- Carry m.mentions (MessageEditor): a caption edit now unions typed
  @-mentions with prior mentions like the text-edit path, so mentioning
  someone in a caption edit notifies them.

- Double caption: revert to the editor replacing the content while editing
  (as text edits do) instead of rendering the media + its caption above an
  editor prefilled with the same caption — removes the confusing duplicate.

- Removed-caption "(edited)" marker (RenderMessageContent): when a media
  message is edited but has no caption (e.g. the caption was removed),
  render the standalone "(edited)" affordance so Edit History stays
  reachable (previously it lived only inside the caption and vanished).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 13:01:02 -04:00
jaredandClaude Opus 4.8 125af8446f feat(captions): edit image/video captions after sending
Captions could be attached at upload but never changed — canEditEvent
only allowed m.text/emote/notice, so a typo in an image caption meant
delete + re-upload. Add caption editing for image/video messages.

- utils/room.ts: canEditCaption (own image/video RoomMessage, no
  non-thread relation) + canEditEventOrCaption. canEditEvent unchanged.
- Message.tsx: gate the Edit affordance (quick-actions + menu) on
  canEditEventOrCaption; label it "Edit caption" for media; keep the media
  rendered above the editor while editing.
- MessageEditor.tsx: for a media message, seed the editor from the caption
  (not the filename), allow an empty caption (removes it), and build the
  m.replace so m.new_content spreads the original media content
  (url/info/encrypted file/filename/msgtype) and only sets body +
  format/formatted_body. Outer content is the full media (not a "* text"
  fallback) so non-edit-aware clients still render the media. No-op guard
  when the caption is unchanged. Placeholder "Add a caption…".

Rendering + Edit History need no changes: getEditedEvent's m.new_content
flows to renderCaption, and the word-diff already diffs body (the
caption). Encrypted media keeps its file/key (no re-upload).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:50:42 -04:00
jaredandClaude Opus 4.8 9a4796c167 fix(gif): still previews + a11y for recent GIFs
Address findings from 2 review agents on the recent-GIFs row:

- Motion/perf: the Recent row rendered up to 16 full animated GIFs at
  once (autoplaying). Capture a small still image at pick time
  (fixed_width_small_still / *_still) into RecentGif.previewUrl and render
  that for the thumbnail, so recents no longer autoplay. Pre-existing
  recents without a preview fall back to the animated url. Re-send still
  uses the animated url, so the sent m.image is unchanged.

- a11y: the recent buttons all had the identical label "Send recent GIF".
  Give them positional labels ("Send recent GIF N of M") and wrap the grid
  in a role="group" labelled by the "Recent" section heading, so the row
  is a distinguishable, announced group.

Correctness review found no bugs (write-before-unmount, term gating,
dedupe, re-send fidelity all verified).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 12:34:51 -04:00
jaredandClaude Opus 4.8 5b58e5fe43 feat(gif): recently-used GIFs in the picker
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>
2026-07-10 12:27:18 -04:00
jaredandClaude Opus 4.8 eaf6853910 fix(nav): harden draft indicator after review
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>
2026-07-10 11:48:35 -04:00
jaredandClaude Opus 4.8 c44ef8d795 feat(nav): draft indicator on room-nav items
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>
2026-07-10 11:37:21 -04:00
jaredandClaude Opus 4.8 90e6901a60 fix(edit-history): harden diff after review
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>
2026-07-10 11:11:16 -04:00
jaredandClaude Opus 4.8 961789fd71 feat(edit-history): word-level diff view
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>
2026-07-10 11:02:59 -04:00
jaredandClaude Opus 4.8 b6413d763d fix(threads): harden threads list after review
CI / Build & Quality Checks (push) Successful in 11m23s
CI / Trigger Desktop Build (push) Successful in 9s
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>
2026-07-10 01:21:01 -04:00
jaredandClaude Opus 4.8 d6d1f5a233 feat(threads): room-level Threads list panel
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>
2026-07-10 01:08:34 -04:00
jaredandClaude Opus 4.8 57f21e5cac fix(forward): harden comment retry + a11y after review
CI / Build & Quality Checks (push) Successful in 10m47s
CI / Trigger Desktop Build (push) Successful in 9s
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>
2026-07-10 00:43:32 -04:00
jaredandClaude Opus 4.8 629db9724f feat(forward): message preview, optional comment, recent targets
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>
2026-07-10 00:37:49 -04:00
jaredandClaude Opus 4.8 a739c25f10 fix(status): stop presence heartbeats from clobbering status edits
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>
2026-07-10 00:13:44 -04:00
jaredandClaude Opus 4.8 53ce2e40a4 fix(url-preview): thumbnail fallback + quiet timeline console spam
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>
2026-07-09 23:47:19 -04:00
jaredandClaude Opus 4.8 398e743cdd fix(status): harden presets after review
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>
2026-07-09 23:37:05 -04:00
jaredandClaude Opus 4.8 d0614710b0 feat(status): built-in and custom status presets
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>
2026-07-09 23:30:08 -04:00
jaredandClaude Opus 4.8 39e75f4eea fix(bookmarks): harden sort/group after review
CI / Build & Quality Checks (push) Successful in 10m44s
CI / Trigger Desktop Build (push) Successful in 7s
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>
2026-07-09 23:09:42 -04:00
jaredandClaude Opus 4.8 cb3cd30ab5 feat(bookmarks): sort & group saved messages by room
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>
2026-07-09 23:03:28 -04:00
jaredandClaude Opus 4.8 cacefb1f30 fix(scheduling): harden edit/reschedule after review
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>
2026-07-09 22:54:57 -04:00
jaredandClaude Opus 4.8 5d5ae0ee70 feat(scheduling): edit / reschedule a scheduled message
CI / Build & Quality Checks (push) Successful in 11m43s
CI / Trigger Desktop Build (push) Successful in 8s
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>
2026-07-09 22:47:43 -04:00
jaredandClaude Opus 4.8 7d02f4e538 fix(voice): apply waveform review findings
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>
2026-07-09 22:01:41 -04:00
jaredandClaude Opus 4.8 56863d2649 feat(voice): render the waveform on playback with click/drag/keyboard scrubbing
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>
2026-07-09 21:50:08 -04:00
jaredandClaude Opus 4.8 2d0c804abc fix(media-gallery): address review — proper decrypt-download + mimetype sanitize
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>
2026-07-09 21:35:35 -04:00
jaredandClaude Opus 4.8 28cb004e80 feat(media-gallery): add Audio/Voice tab + jump-to-message
- 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>
2026-07-09 21:26:02 -04:00
jaredandClaude Opus 4.8 0b06158477 feat(polls): complete the poll lifecycle — voting, undisclosed, end, spec-correct
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>
2026-07-09 19:48:58 -04:00
jaredandClaude Opus 4.8 384a1dd262 fix(embeds): video player iframe collapses to ~200px (use padding-top, not aspect-ratio)
CI / Build & Quality Checks (push) Successful in 10m53s
CI / Trigger Desktop Build (push) Successful in 15s
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>
2026-07-08 02:14:49 -04:00
jaredandClaude Opus 4.8 d7d261a019 docs(todo): add Discovery pass 2 — PERF/SEC/COR findings (agent-surveyed)
CI / Build & Quality Checks (push) Successful in 10m56s
CI / Trigger Desktop Build (push) Successful in 9s
Three agents swept fresh lenses (performance, security/privacy, correctness in
under-covered subsystems). 16 verified items filed: PERF-1..6, SEC-1..5 (no
exploitable XSS found — sanitization surface is hardened), COR-1..6. Single-pass,
not yet TPVR'd.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 01:11:42 -04:00
jaredandClaude Opus 4.8 3045a9f014 docs: add DP1-DP18 test items (LOTUS_TESTING §R), retire DP backlog
CI / Build & Quality Checks (push) Successful in 10m57s
CI / Trigger Desktop Build (push) Successful in 7s
The discovery-pass items DP1-DP18 are implemented + TPVR-reviewed, so move their
manual QA steps into LOTUS_TESTING.md (new section R) and replace the DP backlog
block in LOTUS_TODO.md with a done-breadcrumb pointing at §R + the commits.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-08 00:05:05 -04:00
jaredandClaude Opus 4.8 c2598d21cd fix(dp): address TPVR review findings (DP2 redo, DP4 dedup, DP15 gap)
- 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>
2026-07-08 00:02:04 -04:00
jaredandClaude Opus 4.8 4fa4327a18 DP17: use folds Icons + named brand-color consts in UrlPreviewCard
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>
2026-07-07 23:40:49 -04:00
jaredandClaude Opus 4.8 101e4116e8 refactor(DP15): centralize sendStateEvent as-any cast in typed helper
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>
2026-07-07 23:32:50 -04:00
jaredandClaude Opus 4.8 4fc3f7a35f DP13: extract shared account-data list-store engine
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>
2026-07-07 23:24:08 -04:00
jaredandClaude Opus 4.8 b1ee3ada98 DP16: add typed get/setAccountData helpers, remove ~19 any-casts
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>
2026-07-07 23:14:35 -04:00
jaredandClaude Opus 4.8 e545706c3b DP18: add useMemberAvatar hook and adopt it in reader avatars
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>
2026-07-07 23:01:39 -04:00
jaredandClaude Opus 4.8 8c0e2b4250 DP18: add getMemberName helper and dedup name-fallback sites
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>
2026-07-07 23:01:32 -04:00
jaredandClaude Opus 4.8 165714e133 fix(tds): resolve send-status and read-receipt colors from --lt-* vars
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>
2026-07-07 22:40:54 -04:00
jaredandClaude Opus 4.8 6cf18c3bd9 fix(a11y): correct call-control aria + picker/recorder/live-chip UX (DP7-DP12)
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>
2026-07-07 22:37:22 -04:00
jaredandClaude Opus 4.8 db86432644 fix(state): correct invite re-notify, status clear sync, soundboard resync (DP2/DP3/DP5)
- 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>
2026-07-07 22:29:47 -04:00
jaredandClaude Opus 4.8 8eb961b682 fix(errors): surface previously-silent async failures (DP1/DP4/DP6)
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>
2026-07-07 22:29:39 -04:00
jaredandClaude Opus 4.8 2614e6b92d docs(todo): add DP1-DP18 discovery-pass findings (agent-surveyed + TPVR-verified)
Three discovery agents swept src/ for correctness/a11y/tech-debt issues beyond the
tracked backlog; a separate TPVR pass independently confirmed all 18 (5 refined to
partial with count/scope corrections). Filed as DP1-DP18 under Open - Actionable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 22:13:23 -04:00
jaredandClaude Opus 4.8 f9c03d5e33 fix(inputs): cap room-name / display-name length client-side (H10)
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>
2026-07-07 20:58:10 -04:00
jaredandClaude Opus 4.8 3f9fea1cf2 fix(receipts): refresh read-receipt avatars/names on member changes (N6)
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>
2026-07-07 20:58:10 -04:00
jaredandClaude Opus 4.8 1b2142e6c4 fix(profile): fetch full profile for avatar decoration to avoid 404 spam
CI / Build & Quality Checks (push) Successful in 10m46s
CI / Trigger Desktop Build (push) Successful in 19s
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>
2026-07-07 20:28:40 -04:00
jaredandClaude Opus 4.8 4236621c7a fix(build): serve favicon + all res/ icons at their referenced /public paths
The static-copy step landed the android icons at dist/public/android/ (stripBase
dropped the res/ segment), never copied public/res/apple/ or public/favicon.ico at
all, and the manifest pointed at /res/android/ — so every PWA icon, apple-touch
icon, og:image, and the favicon 404'd on the live server.

Copy all of public/res -> dist/public/res and public/favicon.ico -> dist/public/,
and point manifest.json icons at ./public/res/android/ — matching the /public/res
and /public/favicon.ico paths index.html already uses. Verified in dist/.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 20:28:40 -04:00
jaredandClaude Opus 4.8 45afc9ba7e fix(crypto): request persistent storage on client init (KE-1 mitigation)
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>
2026-07-07 20:18:32 -04:00
jaredandClaude Opus 4.8 fd87a27251 fix(embeds): stop building broken redd.it / i.redd.it Reddit embeds
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>
2026-07-07 20:09:05 -04:00
jaredandClaude Opus 4.8 0c6003fb87 fix(room-preview): make the preview card actually populate + richer
CI / Build & Quality Checks (push) Successful in 10m53s
CI / Trigger Desktop Build (push) Successful in 14s
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>
2026-07-07 14:46:21 -04:00
jaredandClaude Opus 4.8 7fd3164b1f feat(room-preview): preview affordance in space lobby + explore grids
CI / Build & Quality Checks (push) Successful in 10m48s
CI / Trigger Desktop Build (push) Successful in 7s
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>
2026-07-07 14:07:51 -04:00
jaredandClaude Opus 4.8 44420220d4 feat(room-preview): join-rule + encryption chips, Request-to-join for knock rooms
CI / Build & Quality Checks (push) Successful in 10m48s
CI / Trigger Desktop Build (push) Successful in 8s
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>
2026-07-07 13:36:41 -04:00
jaredandClaude Opus 4.8 a3ca951fba docs: mark P4-4 Math/LaTeX done + note outgoing data-mx-maths interop
CI / Build & Quality Checks (push) Successful in 11m55s
CI / Trigger Desktop Build (push) Successful in 9s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:47:06 -04:00
jaredandClaude Opus 4.8 33cb103abb feat(math): emit data-mx-maths on send for cross-client LaTeX
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>
2026-07-07 12:46:25 -04:00
jaredandClaude Opus 4.8 7ad948e26c docs: document inline media embeds feature
- README: new bullet for inline media embeds (video/audio/post players + facade).
- LOTUS_FEATURES: 'Inline Media Embeds' section (kinds table, facade, provider
  notes, sandbox, setting, CSP, the fixed YouTube-thumbnail web bug).
- LOTUS_TODO: awaiting-verification row (16 providers) + deferred providers
  (Bandcamp / on.soundcloud / Vimeo event) under the feature backlog + the iframe
  onError gap.
- LOTUS_TESTING: section Q (facade, TikTok, post self-resize + close, new
  Bluesky/Loom/Kick + toggle + cap).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 12:13:01 -04:00
jaredandClaude Opus 4.8 07b0c410ab fix(notifications): don't badge/notify stale device-verification requests
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>
2026-07-07 12:10:33 -04:00
jaredandClaude Opus 4.8 013f113bc2 feat(embeds): add Bluesky, Loom, Kick
CI / Build & Quality Checks (push) Successful in 10m47s
CI / Trigger Desktop Build (push) Successful in 9s
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>
2026-07-07 00:50:39 -04:00
jaredandClaude Opus 4.8 a52c9e12a4 fix(embeds): quality pass — close button, focus, a11y, perf, SoundCloud revert
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>
2026-07-07 00:45:51 -04:00
jaredandClaude Opus 4.8 a28c305835 feat(embeds): TikTok URL trim, SoundCloud on. + redd.it short links
- 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>
2026-07-07 00:32:09 -04:00
jaredandClaude Opus 4.8 3694a3612d fix(embeds): second-pass review — Reddit auto-height, sandbox, Vimeo/Shorts gaps
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>
2026-07-07 00:08:51 -04:00
jaredandClaude Opus 4.8 4de2d233ef feat(embeds): apply review findings + fix TikTok portrait padding
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>
2026-07-06 23:56:47 -04:00
jaredandClaude Opus 4.8 b247a0447c fix(embeds): resolve TikTok short links via oEmbed + og:url fallback
CI / Build & Quality Checks (push) Successful in 10m51s
CI / Trigger Desktop Build (push) Successful in 9s
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>
2026-07-06 23:03:44 -04:00
jaredandClaude Opus 4.8 6f15f7f56e fix(embeds): wider card for Tidal, declutter caption while video plays
- Widen the interactive-embed card to min(38rem, 94vw) so the Tidal player (and
  other audio/video embeds) has enough width.
- Hide the title/description caption while a video embed is playing, so the text
  below it stops squeezing the player small (Twitch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 22:23:32 -04:00
jaredandClaude Opus 4.8 fbaa921f83 feat(embeds): fullscreen, wider cards, Instagram/Tidal/Reddit; fix TikTok/X/Twitch
CI / Build & Quality Checks (push) Successful in 10m35s
CI / Trigger Desktop Build (push) Successful in 8s
Feedback fixes + new platforms:
- Fullscreen: add a universal 'Fullscreen' button on video embeds (requests
  fullscreen on the media container) so Shorts/TikTok/etc. can go fullscreen
  regardless of each player's own controls. Portrait media enlarged (220->300).
- Drop the iframe sandbox (CSP-allowlisted trusted hosts; sandbox was breaking
  player features and likely TikTok).
- Wider, responsive embed/tweet cards (min(34rem,92vw)) so Twitch player chrome
  isn't cramped and X posts stop getting clipped.
- Instagram (p/reel/tv), Tidal (track/album/playlist/video), and Reddit posts
  now embed. Reddit uses redditmedia.com to bypass the homeserver's blocked
  preview (Reddit serves it a bot-check 'please wait for verification' page);
  a bot-wall title filter keeps that garbage out of any caption.

New pure parsers unit-tested (videoEmbed.test.ts, 17 cases). Desktop + live web
CSP frame-src updated for instagram.com, embed.tidal.com, redditmedia.com.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:55:47 -04:00
jaredandClaude Opus 4.8 791b1cb5ea feat(embeds): interactive X/Twitter post embed + Apple Music player
CI / Build & Quality Checks (push) Successful in 10m39s
CI / Trigger Desktop Build (push) Successful in 7s
X/Twitter: the static OG card was a snapshot — no video, no galleries/threads.
Keep it as the (reliable) facade and add a 'View post' button that loads the
official platform.twitter.com interactive embed on click: playable video/GIF,
image galleries, quote tweets. The embed self-sizes via a scoped postMessage
resize listener (matched to our iframe + the platform.twitter.com origin). Nothing
loads from X until the user clicks, and the link still works if X blocks the frame.

Apple Music: music.apple.com album/playlist/song links now play inline via the
embed.music.apple.com player (compact for a single song, tall for collections),
through the existing MediaEmbedCard audio path.

Pure parsers/builders unit-tested (13 cases). Desktop CSP frame-src adds
platform.twitter.com + embed.music.apple.com (separate commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:14:53 -04:00
jaredandClaude Opus 4.8 6c903fe3e5 feat(embeds): add TikTok, Twitch, Dailymotion, Streamable, Spotify, SoundCloud players
Generalize the inline-embed system: parseMediaEmbed() resolves any supported URL
to an embed spec (provider + kind + embed URL), and a single MediaEmbedCard
renders the media-forward click-to-play facade for all of them — landscape 16:9
(YouTube, Vimeo, Dailymotion, Streamable, Twitch), portrait 9:16 (Shorts, TikTok),
and short fixed-height audio players (Spotify, SoundCloud). Same privacy facade as
before: nothing loads from the third party until the user presses play, gated by
the 'Inline Media Players' setting.

Twitch embeds pass the current page hostname as the required parent param.
Non-embeddable fallbacks (e.g. vm.tiktok.com short links, non-media tweets) keep
their existing rich OG cards; X/Twitter intentionally keeps its rich tweet card.

All parsers/builders are pure + unit-tested (videoEmbed.test.ts, 10 cases).
Desktop Tauri CSP frame-src updated for the new hosts (separate commit).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:01:05 -04:00
jaredandClaude Opus 4.8 93f307cd63 feat(embeds): inline YouTube/Vimeo players + media-forward video tiles
CI / Build & Quality Checks (push) Successful in 11m17s
CI / Trigger Desktop Build (push) Successful in 10s
Video link tiles (YouTube, Shorts, Vimeo) now play in place instead of only
opening a browser tab. Adds a media-forward 16:9 (9:16 for Shorts) tile with a
privacy-friendly click-to-play facade: the homeserver's cached og:image thumbnail
+ a play button, and only on click does it swap in the cookie-less
youtube-nocookie / player.vimeo iframe — so nothing loads from Google/Vimeo until
the user presses play. Gated by a new 'Inline Media Players' setting (default on);
when off it falls back to a link that opens the video in a new tab.

Also sources YouTube thumbnails from the homeserver og:image instead of
img.youtube.com, which fixes the existing broken YouTube thumbnails on the web
build (nginx img-src has no YouTube host) and removes the pre-click Google request.

Pure URL parsing + embed-URL building moved to utils/videoEmbed.ts (unit-tested).

Note: the desktop app's Tauri CSP frame-src must allow the video hosts (separate
commit in cinny-desktop).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:48:12 -04:00
jaredandClaude Opus 4.8 29d74eda8f fix(invites): decline invites robustly (no 500, no ghost, friendly error)
CI / Build & Quality Checks (push) Successful in 27m33s
CI / Trigger Desktop Build (push) Successful in 7s
Declining a remote invite could show a raw 'MatrixError: [500] Internal server
error' and appear to do nothing. Root causes were client-side: decline called
mx.leave unconditionally, so re-clicking after a slow federated leave hit an
already-left remote room that Synapse 500s on; the room was never forgotten so a
'leave' ghost lingered and re-invited a click; and the raw error string was shown.

Add a shared declineInvite(mx, roomId) helper that only leaves when still in the
room (invite/join/knock) and then forgets it (best-effort, first use of forget in
the app). Route the InviteCard decline and both 'Decline All' paths through it,
and replace the raw error with a friendly message (real error kept in console).

Tests: declineInvite covered (6 cases); typecheck + full suite + build clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-05 11:55:35 -04:00