- MLocation "Open in OpenStreetMap" permalink built its URL from the raw
parseGeoUri strings (location.latitude/longitude) while the embedded map iframe
used the parseFloat + isFinite-validated lat/lon. Use lat/lon in the permalink
too, so a malformed geo: substring can't reach the URL (they're already proven
finite a few lines above and used identically in mapSrc).
- LOTUS_FEATURES claimed the Policy List Viewer has "Subscribe (join) /
unsubscribe (leave) controls for each list" and lists subscribed lists.
Verified against PolicyListViewer.tsx: it's a room-ID/alias input viewer that
displays a joined policy room's rules read-only — no subscribe controls, no
subscribed-lists listing. Corrected the doc to match.
Two low-tail bug-hunt findings from LOTUS_TODO. Gate-green (tsc, eslint,
prettier, build).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two bug-hunt findings that were doc inaccuracies, not code bugs:
- Tab title "(N)" is the mention/highlight count (+ "·" for other unread),
mirroring the favicon — intentional. LOTUS_FEATURES said "N unread messages";
corrected to describe the actual highlight-count behavior.
- Collapsible long messages use a fixed COLLAPSE_MAX_HEIGHT (320px ≈ 20 lines);
the doc claimed a Settings → Appearance control that never existed. Corrected
to describe the fixed threshold rather than build a marginal per-user setting.
Verified against ClientNonUIFeatures.tsx and MsgTypeRenderers.tsx.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the feature to README (Messaging) and LOTUS_FEATURES (new
On-Device Message Translation section under Messaging Enhancements),
matching the existing style.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The status-message emoji picker listed the guild's custom/image-pack emojis, but
clicking one did nothing — the field only wires onEmojiSelect (unicode), not
onCustomEmojiSelect, so custom picks were silently dropped (the room composer
works because it wires both). A custom emoji is an mxc image and a status is
plain-text presence status_msg, so it can't render there anyway.
Add an EmojiBoard hideCustomEmojis (unicode-only) mode that zeroes the image
packs (removing pack groups, sidebar icons, and search results) and filters
custom entries out of Recent, and enable it on the status field. Now every emoji
shown actually inserts. Additive prop, default off — no change to other pickers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
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>
- index.tsx: request navigator.storage.persist() for logged-in sessions so the
browser can't evict the IndexedDB rust-crypto store (eviction while the
localStorage session survives resurrects the device with a blank store → the
KE-1 "one time key already exists" upload storm). Guarded, checks persisted()
first, best-effort.
- Docs: remove HANDOFF_ELEMENT_CALL_FORK.md, LOTUS_E2EE_INVESTIGATION.md, and
LOTUS_BUGS.md. Port their live content into the three kept docs — verification
backlog → LOTUS_TESTING; open bugs + E2EE (KE-1..4) + an Element Call fork
operational reference (publish steps + io.lotus action catalog) → LOTUS_TODO.
Fix all dangling references (README, code comments, cross-doc links). Full
history of the removed docs remains in git.
Gates: tsc/eslint/prettier clean, build OK, 665 tests.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Forward: checkbox multi-select room picker + "Send to N rooms" batch send
(Promise.allSettled). Full success auto-closes; partial failure keeps the dialog
open with a "Forwarded to X/N — failed: …" summary and prunes the selection to
only the failures (retry won't duplicate to already-sent rooms). Content builder
extracted to a unit-tested forwardContent.ts (edit-forwarding, reply-strip,
undecryptable-refused; 4 tests).
Bookmarks: BookmarksPanel resolves each saved message's live event (useRoomEvent)
so previews reflect edits and show a deleted indicator for redactions; the stored
snapshot stays as the fallback while loading, on fetch failure, or after leaving
the room. Stored bookmark shape unchanged.
Gates: tsc/eslint/prettier clean, build OK, 665 tests. Reviewed (dup-resend on
retry + Checkbox readOnly fixed).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- P5-42 → [~] IMPLEMENTED (pragmatic WebView2 keep-alive) + LOTUS_FEATURES entry.
- P5-51 → [DEFERRED] with a concrete future-work spec (single-session storage map:
sessions.ts localStorage keys + initMatrix IndexedDB stores; the 6 things true
per-context isolation needs; multi-account as the smaller intermediate step).
- P5-52 → [DROPPED] (matrix-js-sdk can't do true per-room sync filtering; only
cosmetic client-side hiding).
- P5-53 → [DEFERRED] with the lighter automation-rules alternative recorded.
Every desktop P5 item is now dispositioned: implemented, won't-fix, or
deferred-with-spec/dropped.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>