Commit Graph
357 Commits
Author SHA1 Message Date
jaredandClaude Opus 5 8b1c9fa610 fix(threads): thread panel no longer squeezes the composers (#218)
CI / Build & Quality Checks (push) Canceled after 0s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Two causes. (1) Three right-hand columns don't fit under ~1500 px: the member
drawer now yields to an open content panel (thread / gallery / widgets /
threads list) on desktops ≤ 1500 px and returns when it closes, like Element.
(2) The thread composer rendered the full ten-button toolbar inside a 360 px
panel, leaving 24 px for the input ('S…') at every width — RoomInput gains a
compactLayout prop (the phone one-row '+ | input | emoji | send' layout) and
ThreadPanel uses it.

Measured headless: 1400 px thread open → drawer hidden, composers 328/168 px
(was 422 → 'Send a mes…' + 24 px); 1600 px → drawer stays, thread composer 168 px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 13:05:10 -04:00
jaredandClaude Opus 5 464951edf4 fix(timeline): your own new message always scrolls into view (#212)
CI / Build & Quality Checks (push) Successful in 1m34s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 3m1s
Auto-scroll on a live event only ran while the at-bottom flag was true, so a
stale flag (in-place growth like a poll gaining a row briefly pushes the anchor
out of view; or simply having scrolled up) left your own just-sent message
below the fold behind 'Jump to Latest'. An own non-relation, non-state,
non-reaction event now always brings the live end into view: advance the
range + smooth scroll when the window is at the live end, otherwise reset to
the live timeline like the Jump to Latest button. Others' messages keep the
current behaviour.

Verified headless: scrolled up 600 px → Send → message visible, no Jump pill
(before: Jump pill, message off-screen).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:37:54 -04:00
jaredandClaude Opus 5 60076a48d0 fix(commands): /kick and /ban failures are no longer silent (#216)
rateLimitedActions now collects non-429 failures (and a 429 that exhausted its
retries) and returns them instead of swallowing them; existing callers ignore
the return. /kick and /ban turn the list into a CommandError whose message
names who and why, using the server's own sentence (MatrixError.data.error),
never the URL-bearing toString(); RoomInput's toast shows it verbatim.

Verified headless as a non-moderator: '/kick @alice' → "Could not kick
@alice:localhost: You cannot kick user @alice:localhost."; '/ban @nobody
@alice' → "Could not ban @nobody:localhost, @alice:localhost: You don't have
permission to ban".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-19 12:27:15 -04:00
jaredandClaude Opus 5 53823f5466 fix(media viewer): lightbox focuses itself on open (keys worked only after a click); timeline viewer gets +/-/0 keys, double-click zoom and dialog semantics (#164)
CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 5s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 2m19s
Gallery lightbox: FocusTrap had initialFocus:false, so focus stayed on the
tile behind the overlay and ←/→/Esc/+/- were dead until the user clicked
inside. Timeline ImageViewer: no keyboard zoom, no role/aria-modal/label,
focus landed on the <img>; now mirrors the lightbox (+ = / - / 0,
double-click toggles 1×↔2×, role=dialog aria-modal labelled by the file
name, focuses on open).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 21:29:24 -04:00
jaredandClaude Opus 5 f2f498425b fix(threads): drop the fallback root quote on every thread reply; add Threads + Widgets to the mobile room menu (#165, #211)
Inside the thread panel each reply rendered a 'Thread ↩ <root author> <root
text…>' quote because the spec's fallback reply relation (is_falling_back
+ m.in_reply_to root) was treated like a real reply — noise on every row
when the root is already pinned at the top. Genuine reply-to-a-reply
quotes are kept. The Threads list and Widgets panel had desktop-only
header buttons and no way to open them on a phone; both are now in the
mobile ⋮ menu next to Members / Media Gallery.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 21:22:02 -04:00
jaredandClaude Opus 5 5fc90feef9 feat(forward): Discord-style provenance — 'Forwarded from <sender> in <room> · <time>' with jump to the original
CI / Build & Quality Checks (push) Successful in 1m31s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 2m7s
Forwarded events carried no origin at all; they read as the forwarder's own
words. buildForwardContent now stamps io.lotus.forwarded (sender, ts,
room_id, event_id; re-forwards keep the original stamp) and the main and
thread timelines render a reply-style header above the message that jumps
to the original when the viewer is in the source room (sender + time only
otherwise — the source room's name is not leaked). Unit-tested; verified
end to end with Playwright (header text, event content, jump, re-forward).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 21:07:33 -04:00
jaredandClaude Opus 5 b38621861b fix(forward): one forbidden target no longer fails the whole multi-room forward (#194 P6-3)
All targets were sent concurrently through matrix-js-sdk's message queue;
when the send to a room you cannot post to failed with 403 the scheduler
clearQueue()'d every send still waiting, so 'Send to 3 rooms' with one
read-only room reported 'Failed to forward' for all three and left a
half-sent comment in the first. Rooms are now sent one at a time.
Verified: 'Forwarded to 2/3. Failed: Read Only Room.' and both good rooms
receive comment + forwarded message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 19:49:33 -04:00
jaredandClaude Opus 5 1a8fa5cd4c a11y(typing): empty typing state announced 'undefined, undefined, undefined and -3 others are typing' (#187)
CI / Build & Quality Checks (push) Successful in 1m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 12s
CI / Playwright smoke (e2e) (push) Successful in 2m37s
RoomViewTyping's live region fell into the >3-names branch when nobody
was typing, so screen readers heard that string on every room load and
whenever typing stopped. Empty list now announces nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 19:27:23 -04:00
jaredandClaude Opus 5 e9d419f84c a11y: give every icon-only sidebar avatar button and the message sender avatar an accessible name (#185)
axe-core flagged 18 'button-name' criticals on the room view: the space
tabs, Home/Direct/Inbox/Search/Saved/Explore/Add Space/User Settings/
Unverified sidebar buttons, and each message's avatar button had no text
for screen readers. Labels mirror the existing tooltips; message avatars
read '<name>, open profile'. Room view now has zero button-name findings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 19:17:32 -04:00
jaredandClaude Opus 5 4c671fbf3a fix(mobile): member list no longer opens on top of every room on phones (#175)
Room.tsx showed the MembersDrawer on phone widths whenever the persisted
desktop preference isPeopleDrawer was true — which is the default — so a
new mobile session opened every room behind a full-screen member list.
Mobile now uses a transient mobileMembersPanelAtom (header ⋮ → Members
toggles it, the drawer's X closes it, leaving the room resets it); the
desktop drawer keeps using the setting. Verified with Playwright at 400px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 17:45:43 -04:00
jaredandClaude Opus 5 f528e5e440 feat(links): Copy Lotus Link permalinks, in-app recognition, /home redirect for joined rooms, via= alias, OIDC deep-link redirect (#130)
CI / Build & Quality Checks (push) Successful in 2m4s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 5s
CI / Playwright smoke (e2e) (push) Successful in 2m11s
matrix.to cannot target this deployment (Cinny adapter hard-codes
app.cinny.in; web-instance[] is Element-only, allowlisted), so add a
"Copy Lotus Link" next to every Copy Link (message menu, space header
menu, sidebar space tab) producing https://<this origin>/home/<room>/<event>
?viaServers=… via plugins/lotus-permalink.ts. Lotus links in messages are
rewritten to their matrix.to form inside the HTML parser so they render as
room/event mentions and navigate in place.

/home/<room> for a joined room that belongs to a space or Direct now
redirects to its own route (was a preview card with a View button; also
the form matrix.to → Cinny links use). ?via= is accepted as an alias of
?viaServers= (what the matrix.to Cinny adapter emits). A deep link
visited while logged out is now honoured after an OIDC login: the OIDC
callback reloads at the app root, which discarded the stored path — the
index loader consumes it via the shared takeAfterLoginPath().

Verified end-to-end with Playwright on a local Synapse: logged-out cold
link → login → lands on the event under the space route; menu copies the
expected link; a pasted Lotus link renders as a mention and jumps in
place; both space menus copy the space link.

Closes #130

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 00:18:45 -04:00
jaredandClaude Opus 5 d929143f7d fix(gallery): paginate media, activity log and export on detached timeline sets — never the live timeline (#163)
RoomTimeline renders a numeric index window into the live timeline's
event arrays; SDK back-pagination prepends, so any side panel calling
paginateEventTimeline(room.getLiveTimeline()) shifted the visible
messages into the past on the next render and broke at-bottom tracking.

New utils/detachedTimeline.ts builds a timeline set that mirrors the
already-loaded history and paginates independently: a room-registered
filtered set (server-side contains_url / types filter) when the filter
is usable, else a private EventTimelineSet seeded from the live timeline.
useRoomMediaTimeline wraps it for the gallery (live events + redactions
handled); RoomActivityLog uses a type filter (safe in encrypted rooms);
ExportRoomHistory pages a private set so a full export no longer parks
thousands of events in the live timeline.

Verified with Playwright against a local Synapse in a 400-message plain
room and a 200-message encrypted room: timeline stays at the bottom
through gallery pages, activity load-more and a full export; live
messages keep auto-scrolling; all media found in both rooms.

Also adds scripts/dev-homeserver.sh + scripts/dev-seed.py (local
throwaway Synapse for driving the real UI) and documents them.

Closes #163

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-18 00:03:54 -04:00
jaredandClaude Opus 5 5b0d355417 feat(privacy): strip tracking parameters from links on paste, send and render (#103)
Shared links routinely carry ad/analytics identifiers (utm_*, fbclid, gclid,
YouTube si=, Amazon ref=/tag=, X s=/t=, TikTok _r/_t, …) that tie every
recipient's click back to the person who shared the link. New
src/app/utils/urlTracking.ts is a pure, local stripper: a global list +
utm_/pk_/matomo_ prefixes, plus host-scoped rules so e.g. `si` is only
removed on youtube/spotify. matrix.to and non-http(s) schemes are never
rewritten; unparseable input is returned unchanged; Amazon's `th`/`psc`
variant selectors are deliberately kept. 13 unit tests.

Wired at three points, all behind a new Settings → Privacy toggle
(`stripTrackingParams`, default on):
- paste: plain-text pastes are cleaned and re-inserted through Slate's own
  insertData so multi-line pastes still split into paragraphs;
- send: RoomInput submit + schedule paths and MessageEditor saves clean both
  `body` and `formatted_body` (the HTML variant unescapes `&amp;` around each
  URL and re-escapes it so the markup is untouched);
- render: linkify `formatHref`/`format` and explicit `<a href>` in
  formatted_body are cleaned, so links sent from other clients are safe to
  click too. LINKIFY_OPTS is spread into memoised per-timeline objects, so
  the toggle is a module flag kept current by ClientNonUIFeatures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-17 01:46:21 -04:00
jaredandClaude Opus 5 470b5217ae fix(composer): one-row toolbar with uniform 32px buttons on every viewport
CI / Build & Quality Checks (push) Successful in 1m53s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Successful in 1m38s
The composer looked off in two ways, both confirmed by rendering CustomEditor
with RoomInput's exact props and measuring the buttons headlessly:

Desktop: the Lotus additions (location, poll, voice, schedule) used
`Icon size="100"` (18px) inside the same `IconButton size="300"` as the
upstream Aa/sticker/emoji/send buttons (24px icons), so one row mixed
32×32, 26×26 and a 28×19 "GIF" text stub. Every button is now 32×32: the
four small icons use the default icon size and the GIF label sits in a
1.5rem box, the same footprint as an icon. The mic's idle button in
VoiceMessageRecorder gets the same treatment since it lives in this row.

Phones: d6159997 let the before|editable|after row flex-wrap at <=750px, but
folds' Scroll (the editable's wrapper) is `width: 100%`, so the row ALWAYS
broke into three stacked lines — "+" alone on top, the input flush against
the left edge on its own line (the :first-child padding selectors no longer
matched), and emoji/draft/send left-aligned underneath. e1bb8301's "+"
overflow menu was meant to produce [ + | input | emoji | send ] but never
could while the row wrapped. The row no longer wraps (upstream behaviour);
instead the collapse into the "+" overflow is keyed on the viewport
(ScreenSize.Mobile) as well as the touch UA, so a phone-width window on a
desktop UA — iPad desktop mode, split-screen PWA, docked window — also
collapses instead of rendering ten controls inline and clipping Send behind
the editor's overflow:hidden. The "Draft saved" label moves into the overflow
row in compact mode so the inline row stays [ + | input | emoji | count |
send ]. The editable's vertical padding grows to 19px at phone width (only
when the row actually has buttons) so the text sits level with the 44px
touch targets instead of hugging the top of the row. Those touch targets now
also apply the shared MobileTouchTarget class, matching the recorder button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-17 01:14:48 -04:00
jaredandClaude Opus 5 4cdd221eff fix(calls): remove the hidden right-click camera-on call shortcut
Fixes #25

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 22:43:17 -04:00
jaredandClaude Opus 5 9e566807b3 fix(timeline): thread panel shows read receipts; receipts recompute incrementally
- ReadPositionsContext is provided once at Room level so the thread
  panel (a sibling of RoomView) gets real positions instead of the empty
  default; own thread messages no longer sit on "Sent" forever (#38).
- Receipt events only recompute the users they name, merged into the
  previous map with reference equality preserved for untouched rows, so
  a receipt no longer re-renders every message (#40). Unit-tested.

Fixes #38
Fixes #40

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 21:32:06 -04:00
jaredandClaude Opus 5 e0861849b7 fix(composer): persist drafts while typing, not only on unmount
Drafts were written solely in the effect cleanup, so F5 in the open room
lost them and the draft indicator never showed for the current room.
Persist on a 500 ms debounce and on pagehide with the same {userId,
nodes} shape; an empty editor clears the stored draft. Also passes the
full content to the schedule modal (#36).

Fixes #37

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 21:32:06 -04:00
jaredandClaude Opus 5 bec248b228 fix(composer): scheduling keeps formatting, mentions and the reply relation
The schedule modal took only a plain body and rebuilt {body, msgtype},
dropping formatted_body, m.mentions and m.relates_to. It now receives
the full IContent; an unedited body is sent verbatim, an edited body
drops the now-stale formatted_body but keeps mentions and the reply/
thread relation. Reschedule from the tray preserves them too.
Unit-tested (mergeScheduledBody).

Fixes #36

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 21:32:06 -04:00
jaredandClaude Opus 5 e9d07513d2 merge: upstream cinny v4.12.7 into lotus
Brings the 4.12.7 fixes: "%" sign wrapping below the composer input and
emoji autocompletion overwriting the preceding element. The release's
security half (sanitize-html, react-router-dom) was already applied in
91def3ad (we are on newer versions than upstream ships).

Resolved: kept our package.json/lockfile (the fork is ahead on every
dependency — React 19, router 7, i18next 26…), version 4.12.7-lotus, our
README/CONTRIBUTING, our "Lotus Chat vN" branding via pkg.version, and
the deleted GitHub-only workflows stay deleted. Note upstream announces
it is replacing matrix-js-sdk with its own SDK and pausing PRs; future
syncs will get harder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-15 13:07:32 -04:00
jaredandClaude Opus 5 34fe223aa4 chore(lint): remove unused eslint-disable directives
CI / Build & Quality Checks (push) Successful in 1m22s
CI / Trigger Desktop Build (push) Successful in 8s
Part of the #97 warning ratchet.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:59 -04:00
jaredandClaude Opus 5 7e64ea398b fix(moderation): knock Deny gated on kick power; approve/deny errors surfaced
Fixes #49

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:42 -04:00
jaredandClaude Opus 5 2e14980de6 fix(settings): validate seasonalThemeOverride; commit page zoom on blur; GIF picker opt-in
- seasonalThemeOverride is coerced to 'auto' when the persisted value is
  outside the union, like every other enum setting (tested).
- PageZoomInput commits on blur as well as Enter.
- New gifPickerEnabled setting (default off) with a disclosure that GIF
  searches go to Giphy; the picker doesn't mount and the composer button
  is hidden while off.

Fixes #74
Fixes #75
Fixes #68

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 bacfef5558 fix(security): forwarding encrypted media to a plaintext room re-uploads it
buildForwardContent copied content.file (AES key/iv/hashes) verbatim, so
forwarding from an E2EE room into an unencrypted one published the key.
For unencrypted destinations the attachment is now downloaded, decrypted
and re-uploaded as plaintext (url instead of file, thumbnail key
stripped); if that fails the forward is refused rather than leaking.
Encrypted destinations unchanged. Needs a manual check on a live
encrypted -> plaintext forward.

Fixes #63

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 20:28:41 -04:00
jaredandClaude Opus 5 32e5fec6f3 fix(widgets): room widgets render again (bypass broken isValidUrl)
matrix-widget-api 1.17.0's WidgetParser rejects every URL (it compares
URL.protocol "https:" to "https"), so the widgets panel was always empty.
Build Widget objects from the raw state events with a correct scheme
check plus the existing origin check. Unit-tested against a real state
event.

Fixes #15

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 19:46:06 -04:00
jaredandClaude Opus 5 9bdf4ff1fd fix(security): bookmarks/reminders stop storing decrypted text server-side
For encrypted rooms, bookmarks persist only {roomId, eventId, savedAt}
and reminders only their non-text fields; the preview, room name and
sender resolve locally at render/fire time from the timeline (with a
"Message unavailable" fallback). A one-time, loop-guarded cleanup strips
text from existing entries in currently-encrypted rooms. Unencrypted
rooms are unchanged. Unit-tested.

Fixes #10

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 19:46:05 -04:00
jaredandClaude Opus 5 e447fdc0f3 fix(threads): polls created from a thread composer land in the thread
PollCreator used the legacy 3-arg sendEvent (threadId null). Thread the
composer's threadRootId through and send like the sticker path.

Also (#41, same file): composer drafts are persisted as { userId, nodes }
and the restore path drops any draft with a different or missing userId.

Fixes #35

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 14:48:34 -04:00
jaredandClaude Opus 5 4dd0e6637d fix(threads): "Hide Typing & Read Receipts" now governs thread receipts
ThreadPanel only honoured the separate Private Read Receipts switch, so
the privacy switch still emitted public m.read for every opened thread.
Match markAsRead: hideActivity || privateReadReceipts.

Fixes #34

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 14:48:34 -04:00
jaredandClaude Opus 5 dfccaec9dc fix(threads): resolve root edits/reactions against the room timeline set
Non-thread relations targeting a thread root live only in the room's main
timeline set (Room.eventShouldLiveIn), so the panel never saw root edits
or reactions and re-reacting duplicated instead of toggling. Use the room
set for the root event, the thread set for replies.

Fixes #12

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 14:48:34 -04:00
jaredandClaude Fable 5.1 dac74f098e fix(composer): stop three E2EE plaintext leaks (compress, schedule, GIF)
- Image compression in an encrypted room re-encoded the *plaintext* original,
  uploaded it unencrypted, and reused the original's encInfo, so the media sat
  on the server in the clear AND the attachment was undecryptable. The
  compressed bytes are now run through encryptFile and the synthetic upload
  item carries the new encInfo (buildCompressedUploadItem, unit-tested; it can
  never inherit the stale encInfo).
- Scheduled messages (MSC4140) are PUT as raw m.room.message, bypassing the
  SDK encryption pipeline. The Schedule button is now hidden in encrypted
  rooms, handleScheduleClick no-ops there, and scheduleMessage() itself
  refuses with a clear error so no caller can regress this. README notes the
  limitation.
- The GIF picker uploaded the Giphy blob unencrypted into E2EE rooms; it now
  mirrors the voice/attachment path (encryptFile -> upload ciphertext ->
  content.file).

Fixes #6
Fixes #7
Fixes #11

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-12 02:08:03 -04:00
wjaaaaaaat c434e0dda3 fix: emoji autocompletion overwriting preceding element (#3064)
* fix getPrevWorldRange to exclude empty text children

this prevents the range from encroaching on the node of elements like emojis and pings to prevent them from being overwritten

* fix typo of "word" as "world"

* trigger pr checks
2026-08-11 14:23:15 +10:00
jaredandClaude Opus 4.8 477df4ae32 fix(composer): stop the intermittent autocomplete-insert crash
Picking an autocomplete item (mention/emoji/command — all inline voids)
occasionally tripped the composer error boundary, forcing a page refresh, even
though the element had already inserted. Root cause (traced through slate-react):
moveCursor deferred its cursor work to setTimeout(0), leaving the caret on the
just-inserted void's zero-width edge whose DOM (a U+FEFF node) isn't populated on
that tick. slate-react's commit-phase selection sync then calls
setBaseAndExtent(voidEdge, 1) and throws IndexSizeError mid-render → boundary.

Prevention: do the cursor work SYNCHRONOUSLY, in the same commit as the insert —
Transforms.move (escapes the void into the real trailing text node) then
insertText(' '). The caret is then always a resolvable text point when the
selection sync runs. (moveCursor's focus stays deferred+guarded, unchanged.)

Recovery (belt-and-suspenders): the composer error boundary is now recoverable —
a "Reload composer" button (resetErrorBoundary) + onReset Transforms.deselect
clears a transient bad selection so it remounts with the draft intact, no page
refresh. + role="alert" for screen readers.

Three review agents: two root-caused the exact slate-react throw and proved the
try/catch-only version merely recovered; a third reproduced the transforms
headlessly and caught that a first "sync insertText WITHOUT move" attempt hit
Slate's void guard (space dropped, caret trapped) — the move is required to
escape the void. Not unit-testable (needs the live DOM + the timing race).
Gate-green (tsc, eslint, prettier, 925 tests, build).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 16:12:52 -04:00
Claude e1bb8301f0 fix(composer): collapse mobile action buttons behind a "+" overflow menu
On phones the composer's 7-8 secondary action buttons wrapped into a tall
multi-row stack ("massive height"). Mobile now shows a single compact row —
[ + | input | emoji | send ] — where "+" toggles a collapsible row (above the
formatting toolbar) holding attach, GIF, location, poll, voice, formatting and
schedule. Desktop is unchanged (isMobile === false; the mobile branches are
never entered and composerOverflow stays null).

The after-builder stashes the collapsed buttons in a render-local `let` that
the bottom slot reads; safe because JSX props evaluate in source order within
one render (verified by review). Emoji/Send stay inline; the emoji and GIF
PopOut anchors still resolve wherever their button renders.

Review fixes folded in: the "+" toggle uses aria-expanded + aria-controls
(dropped the redundant aria-pressed) pointing at the labelled role="group"
overflow row; the voice recorder's idle mic button gets the @media-gated
MobileTouchTarget 44px target so the overflow row is uniformly tappable.

Two review agents (correctness + UX/a11y); gate-green (tsc, eslint, prettier,
914 tests, build). Visual confirmation still wants a real device per
LOTUS_TESTING.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-24 14:44:13 -04:00
jaredandClaude Opus 4.8 8a1168bc5f fix(mobile): 44px touch targets for primary call/thread/mod controls (P1)
Deep-audit follow-up. New shared MobileTouchTarget class (@media <=750px ->
minWidth/minHeight 44px) applied via className to the primary interactive
controls folds renders below 44px:
- in-call control bar (7 buttons) + persistent call-status bar (4 buttons)
- thread "N replies" chip
- knock Approve/Deny buttons
- server-ACL entry remove button

folds size variants set only padding (no width/height/min-*), so the class
raises the hit-area floor to 44px with the icon/label staying centered at its
normal size; desktop is untouched (@media-gated). Verified by two review passes
(no distortion, no layout overflow, counts exact).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:57:55 -04:00
jaredandClaude Opus 4.8 154e35ef9f fix(mobile): deep-audit structural fixes — dialogs, toasts, call bar (N1)
From the 6-agent deep per-feature audit. Mobile-gated / consistency fixes;
desktop unchanged except two intentional dialog-width normalizations noted below.

- In-call control bar: wrap="Wrap" on the SequenceCard so the compact two-group
  row wraps on the narrowest phones (<=390px) instead of pushing End off-screen
  (M1 fixed the 500-750px band; this covers narrower). Desktop stays one row.
- In-call soundboard popout: clamp maxWidth to the viewport (like M5's screenshare
  popover) so it can't overflow a narrow phone.
- Report-Message dialog + "Seen by" (EventReaders) modals (Message.tsx x2 +
  RoomViewFollowing): add useModalStyle so they go full-screen on mobile like
  their sibling report/receipt modals (they floated as fixed cards before).
- In-app toast container: full-width toasts inset from both edges on mobile
  (ScreenSize.Mobile); a fixed 280-340px card previously overflowed a narrow
  phone. Desktop byte-identical (bottom-right floating card).
- Policy-list tabs + audio-controls rows: wrap="Wrap" (inert on desktop).

Intentional desktop deltas (normalizing to existing sibling modals, verified by
two review passes as consistent, not regressions): Report dialog max-width
380->480px; EventReaders modals 460->360px.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 23:22:59 -04:00
jaredandClaude Opus 4.8 836e4a6679 fix(mobile): full-screen media viewers + touch-pan for zoomed images (M3)
Mobile-audit batch 3. All changes mobile-gated (@media <=750px) so desktop is
unchanged.

- ModalWide: fill the phone screen (100vw/100vh, no radius) at <=750px instead
  of floating as an 85vw card. This also full-screens the file/PDF viewer and
  the avatar-crop editor on mobile (they share ModalWide) — intended.
- UserHero avatar viewer: new mobile-only ModalMobileFull class (no desktop
  effect) so it goes edge-to-edge on phones like the timeline lightbox.
- usePan: add touch support (single-finger drag, cleaned up on
  touchend/cancel/unmount) alongside the unchanged mouse path, so a zoomed image
  can be panned on a phone. Wired into ImageViewer and the MediaGallery lightbox.

Two review passes: mouse path byte-for-byte unchanged; desktop provably
unaffected; touch is gated to zoom!=1 so a non-zoomed image never hijacks
swipe/scroll.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 22:24:26 -04:00
jaredandClaude Opus 4.8 ecb7b1a7fb feat(translation): on-device message translation
Add per-message translation that runs entirely on-device via the
Chromium built-in Translator + LanguageDetector APIs. Message text
never leaves the machine and never touches a cloud service, preserving
the E2EE guarantee. When the on-device engine is unavailable
(non-Chromium / mobile) the feature simply hides itself; there is no
network fallback.

- Engine abstraction (utils/translation): TranslationEngine interface
  plus a chromeTranslationEngine implementation (feature-detected,
  caches translator/detector instances, download-progress monitor).
  Pure lang-code helpers (normalize/sameLanguage/curated targets) with
  unit tests.
- Settings: translateTargetLang (default English) + autoTranslate
  (opt-in), with a Messages settings tile — a target-language select
  and an auto-translate switch, disabled with a note where unsupported.
- useMessageTranslation hook + shared per-event toggle atom-family and a
  persisted LRU cache so scrollback never re-translates.
- UI: a Translate / Show Original message-menu action, an inline
  "Translated from <lang> - Show original" chip, and a body swap in
  m.text/m.emote/m.notice that renders the translated text through the
  plain-text path (linkify + emoji) inside a dir=auto span for RTL.
- Auto-translate flips foreign messages whose model is already
  downloaded; first-time downloads keep the manual chip (user gesture).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-18 16:08:40 -04:00
jaredandClaude Opus 4.8 a8c99f2a45 a11y(polls): drop redundant aria-label on max-selections input
CI / Build & Quality Checks (push) Successful in 11m12s
CI / Trigger Desktop Build (push) Successful in 6s
Review noted the number input had both an htmlFor-associated visible label
("Voters can pick up to") and an aria-label, so the aria-label won and the
visible label was not announced. Remove the aria-label so the accessible name
matches the visible label.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 16:09:01 -04:00
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 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 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 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 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 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