Commit Graph
4140 Commits
Author SHA1 Message Date
Lotus CIandClaude Opus 5.5 c088b4c0e6 feat(media): show the full caption under the media in the viewer (#164)
A captioned image/video (MSC2530: `filename` set and `body` differs) put the
caption in the header, truncated to one line, so a long caption was
unreadable. The header now shows the file name; the caption is shown in full
under the media (wrapped, scrolls past 25vh). The image alt text uses the
caption, and Download now saves under the real file name instead of the
caption.

Verified in Chromium: header "lake-sunset.png", the 150-character caption
fully visible below the image.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:51:33 -04:00
Lotus CIandClaude Opus 5.5 ea07fb5087 fix(media): bigger prev/next arrows in the media viewer on touch screens (#164)
The viewer's arrows were 32 px at the screen edges on a phone, under the
44 px target the rest of the mobile work uses. On a coarse pointer they use
the next IconButton size (48 px measured on a Pixel 7); desktop is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:49:36 -04:00
Lotus CIandClaude Opus 5.5 e39714a6e0 refactor(types): typed send helpers replace 16 as any casts (#210)
- sendRoomMessage (composer's fire-and-forget sends: text, location, voice,
  files, GIFs) and sendRoomEvent (polls, poll responses/ends, forwards,
  reactions, edits) in utils/room.ts carry the one cast each needs
  (`keyof TimelineEvents` / `RoomMessageEventContent`, no `any`).
- sendRoomMessage also swallows the rejected promise: a failed send already
  shows on the local echo (Failed to send + Retry, or the consent prompt), so
  it no longer surfaces as an unhandled error in the console.
- getAccountData narrows to `keyof AccountDataEvents`; ForwardMessageDialog's
  guard now narrows `contentToSend` itself (same behaviour).
- `as any` 39 → 23; eslint warnings 46 → 36, ratchet tightened to 36.

Verified in Chromium on a local Synapse: a text message, a quick reaction and
an edit all reach the server with the right content; a consent-blocked send
no longer logs an unhandled MatrixError. 1219 unit tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:47:44 -04:00
Lotus CIandClaude Opus 5.5 39589b40f3 feat(media): two-finger pinch zoom in the media viewer (#164)
Zoom on a phone was only the +/- buttons; the pinch people try first did
nothing. usePinchZoom tracks a two-finger gesture on the viewer's media area
and scales from the zoom it started at (clamped 1×–5×, snapping back to
exactly 1× when released near it, so one-finger swipe navigation re-arms).
The area gets touch-action: none so the browser doesn't zoom the page
instead. One-finger swipe and pan already ignore multi-touch.

Verified on an emulated Pixel 7 with CDP two-point touch: spread 80→200 px
gives 250 %, closing to 120 px gives 150 %, closing fully returns to 100 %,
and a one-finger swipe afterwards still moves 2/2 → 1/2.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:33:13 -04:00
Lotus CIandClaude Opus 5.5 c1661e48fa feat(widgets): ask before a room widget may read or send in the room (#205)
CI / Build & Quality Checks (push) Successful in 1m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 13s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 9m15s
Room widgets were limited to display-only capabilities because the driver
couldn't serve anything else. Now:

- classifyWidgetCapabilities: display caps are still granted silently; reading
  or sending events/state in the widget's OWN room is offered to the user;
  everything else (other rooms' timelines, to-device, account data, uploads,
  user directory, delayed events) stays denied. Writing protected state
  (power levels, join rules, encryption, membership, ACLs, widgets, …) is
  never offered, and the driver refuses it again at send time.
- WidgetPermissionPrompt names the widget, the host that runs it and who added
  it; each request in plain words ("Send messages of type m.text in this
  room · as you"); reading is pre-ticked, sending is not; Deny / Escape grant
  nothing extra. "Remember my choice" stores it per viewer (localStorage),
  tied to the widget URL, so a changed URL asks again.
- GeneralWidgetDriver implements sendEvent / readRoomTimeline / readRoomState
  / readEventRelations, each refusing any room but the widget's own;
  RoomWidgetView feeds the room's live (decrypted) events and state updates,
  which ClientWidgetApi forwards only if the widget holds the capability.

Verified in Chromium with a cross-origin test widget against a local Synapse:
power-levels and timeline:* requests are never shown; after allowing
send+read the widget's message lands on the server, its power-levels write
is rejected, it receives live messages, and after a reload the remembered
choice skips the prompt.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:17:18 -04:00
Lotus CIandClaude Opus 5.5 c96c47dd0d feat: explain "Failed to send" when the homeserver wants its terms accepted
CI / Build & Quality Checks (push) Successful in 1m45s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Successful in 14s
CI / Playwright smoke (e2e) (push) Successful in 6m58s
A homeserver with a consent requirement (Synapse user_consent) rejects sends
with 403 M_CONSENT_NOT_GIVEN until the user accepts its current terms. The
message just showed "Failed to send" with no reason.

Listen for the SDK's HttpApiEvent.NoConsent and show a dialog naming the
user's own homeserver (the client works with any server, so no Lotus-specific
wording), with "Review and accept" opening the server's consent_uri (http(s)
only; anything else is dropped) and "I've accepted — retry sending" resending
every event that failed for this reason. "Later" snoozes it for 10 s so
background retries don't re-open it immediately.

Verified in Chromium against a local Synapse with the send endpoint answering
M_CONSENT_NOT_GIVEN: dialog shows, link opens, retry delivers the message.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 21:08:15 -04:00
Lotus CIandClaude Opus 5.5 103c6f4624 fix(a11y): closing a header/sidebar menu returns focus to its button
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 5s
CI / Playwright smoke (e2e) (push) Successful in 11m15s
16 menus (room header, Home/Direct/Space sidebar tabs, create tab, lobby,
room-nav item, history visibility, client root) set
returnFocusOnDeactivate: false, so pressing Escape dropped focus to
<body> and a keyboard user had to start over from the top of the page.
That option was there so focus wouldn't be pulled back to the menu button
when an item opens a dialog; since ce8ed89f dialogs move focus into
themselves (their initial focus runs after the menu's return), so it's no
longer needed. The autocomplete menu (focus must stay in the editor) and
the two menus whose opener disappears are left as they were.

Checked: room header menu → Escape → focus on "More options" (was
<body>); menu → Leave Room → focus inside the Leave Room dialog on
Cancel, same as before.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 13:16:12 -04:00
Lotus CIandClaude Opus 5.5 7f2e93d389 fix(a11y): name the headless modals too — Seen by, source, file viewers (#185)
CI / Build & Quality Checks (push) Successful in 1m41s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 13m14s
Follow-up to ce8ed89f for the modals that have no visible heading to
point at: the three "Seen by" reader lists (read-receipt pill, "is
following", message menu), View source, the text/PDF file viewers, the
room-card join error and the user-profile modal get role="dialog",
aria-modal and an aria-label; their traps move focus in (fallbackFocus
on the dialog) where the trap is local.

Verified: the receipt pill opens a "Seen by" dialog with focus on
Close; Escape closes it and focus returns to the pill.

The remaining unnamed Modal/Dialog uses are startup/loading and error
screens (config, feature check, spec versions, client root, password
reset) and wrappers around components that carry their own role (image
viewer).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 13:12:13 -04:00
Lotus CIandClaude Opus 5.5 ce8ed89fdc fix(a11y): 36 modal dialogs announce themselves and take focus (#185)
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
Most modals rendered a folds Dialog/Modal with no role and no name, and
their focus traps used `initialFocus: false`, so focus stayed behind the
modal and a screen reader never announced it.

- 32 dialogs with a visible heading: role="dialog", aria-modal,
  aria-labelledby → the heading (given an id), tabIndex=-1.
- 4 dialogs that already had a name (Leave Room, room topic viewer,
  server ACL, room-nav prompt): role + aria-modal.
- Their focus traps drop `initialFocus: false` for focus-trap's default
  (keep an already-focused autoFocus field, else the first tabbable
  element) with the dialog itself as fallbackFocus, so a dialog without
  a tabbable node can't crash the trap. Traps that live in a parent
  (UIA stages, Logout, Forward, Invite) get the semantics only.
- The file drop overlay is deliberately left alone (not a dialog).

Checked at runtime: Join with Address, Delete Message, Report Message,
Leave Room and Logout open as named dialogs with focus inside and close
with Escape (Tab first when a text field has focus — the shared
stopPropagation keeps Escape from discarding typed text, by design).
The axe e2e spec (6 tests) passes; eslint warnings unchanged (46).
17 modals with no heading (image/file viewers, loading screens) remain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 13:06:10 -04:00
Lotus CIandClaude Opus 5.5 359c79a440 feat(desktop): ask once what closing the window should do (cinny-desktop #5)
CI / Build & Quality Checks (push) Successful in 1m57s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Canceled after 12m7s
The first time the window is closed, a dialog asks "Keep Lotus Chat
running?" — Keep running in the tray (default, focused) / Quit when I
close the window — with an opt-in "Start Lotus Chat when I sign in"
checkbox in the same moment (per the approved design: one dialog, no
wizard). The choice is saved natively; Settings → General → "When I
close the window" changes it later (tray / quit / ask me).

The dialog is role="dialog" aria-modal, labelled and described, with
focus on the default button. Web-side flow verified with a stubbed
native side: event → dialog → checkbox + Quit sends autostart enable +
resolve_close_request("quit"); the Settings select saves "tray".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 12:51:18 -04:00
Lotus CIandClaude Opus 5.5 206a3e933a fix(a11y): thread chip says it opens the thread, and whether it's unread/muted (#179)
CI / Build & Quality Checks (push) Successful in 2m15s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 14s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
The "N replies · <time>" chip under a thread root was named only by its
visible text, so a screen reader never said it opens the thread, and the
unread dot / muted bell were visual-only. Its accessible name is now the
visible text first (WCAG 2.5.3) plus "view thread", "unread replies" and
"muted" as applicable, e.g. "1 reply · 12:16 PM, view thread".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 12:18:35 -04:00
Lotus CIandClaude Opus 5.5 d5aa18e3ab fix(pwa): maskable icons had a visible black box behind the logo (#181 L2)
CI / Build & Quality Checks (push) Successful in 1m33s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Canceled after 12s
The logo sat on a pure-black (#000) square inside the icon's #0A0A0A
background, so the adaptive (circle/squircle) home-screen icon showed a
faint square around the logo — the "floating in an odd box" the
checklist warns about. Recoloured that backing square to the background.

Checked: both icons load in production (200, image/png); the logo's
furthest pixel is 29 % of the width from centre (Android safe zone: 40 %),
corners opaque — so no clipping in any launcher mask.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 12:16:39 -04:00
Lotus CIandClaude Opus 5.5 862a128102 fix(a11y): skip link focuses <main>; ? dialog and reaction viewer announce (#185)
CI / Build & Quality Checks (push) Canceled after 1m33s
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
- Skip link: following #main-content left focus on <body> (the <main>
  target wasn't focusable), so nothing was announced and the URL got a
  stray fragment. <main> is now tabIndex=-1 and the link focuses it.
- Keyboard-shortcuts dialog (?): no role, and focus stayed in the
  timeline, so it opened silently. Now role="dialog" aria-modal, and focus
  moves into it (Escape still returns focus to where you were).
- Reaction viewer (both the reaction context-menu path and "View
  Reactions"): same — role="dialog" aria-modal aria-label="Reactions",
  focus moves in.

Keyboard-only checks (Playwright): Tab → skip link → Enter focuses
<main>; Tab reaches the room list, Enter opens a room, typing lands in the
composer, Enter sends (verified on the server); focus ring visible. "?"
opens the dialog with focus inside, Escape returns to the same element,
"?" in the composer stays text. Topic viewer, Search dialog and reaction
viewer all return focus to their opener.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 12:15:02 -04:00
Lotus CIandClaude Opus 5.5 c0a9b2da6b fix(a11y): call toggles no longer contradict their labels (#187 DP7/8)
CI / Build & Quality Checks (push) Successful in 2m9s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 10m15s
The call-bar and status-bar toggles change their label with the state
("Deafen"/"Undeafen", "Turn off/on microphone", "Start/Stop Video", …)
AND set aria-pressed, which made screen readers say "Deafen, pressed"
while you were not deafened, and gave mic/screenshare a pressed state in
one bar but not the other. Per the ARIA practice, a toggle uses either a
changing action label or aria-pressed with a fixed label; these use the
former, so aria-pressed is removed from all nine.

Checked in a live call: every control now announces only its action, in
both states (deafen → "Undeafen", mic → "Turn on microphone", …).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 11:59:45 -04:00
Lotus CIandClaude Opus 5.5 ed87b700d9 fix(a11y): announce the incoming-call overlay (role=alertdialog + name)
CI / Build & Quality Checks (push) Successful in 2m8s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
The full-screen incoming-call overlay (shown when you're not already in a
call — the common case) had no role or accessible name on any ancestor of
its buttons, so a screen reader landed on an unlabelled button with no
hint that a call was ringing. The in-call banner variant already had
role="alert" + a label. The overlay's Dialog is now role="alertdialog",
aria-modal, labelled "Incoming voice|video call from <caller>".

Found while verifying #187 DP6 (decline still dismisses when the decline
send fails — it does).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 11:56:51 -04:00
Lotus CIandClaude Opus 5.5 f62c5d5778 fix(status): a status cleared on another device stays cleared (#187 DP3)
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
Status messages are saved per device and re-sent with every presence
heartbeat (a presence write without status_msg clears it on Synapse). A
device never receives its own user's presence changes made on other
devices, so the DP3 fix in db864326 — mirroring remote changes from the
Profile page — could never fire: device B kept a status that device A had
cleared and re-published it on its next state change.

Heartbeats now reconcile with the server first: GET our own presence,
send the server's current status_msg and bring the local copy in line.
Falls back to the local copy when the read fails, when the server shows
us offline (invisible mode clears the status by design), and for 15 s
after this device saved/cleared its own status (a server read that
hasn't caught up yet can't override a fresh save).

Verified with two sessions of the same user against local Synapse:
B sets "dp3 old status" → A clears it → B goes hidden→visible → server
stays "" and B's local copy is removed (before: back to "dp3 old status").
A sets "dp3 new from A" → B heartbeat keeps it and adopts it locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 11:50:13 -04:00
Lotus CIandClaude Opus 5.5 cccd78fd43 fix(embeds): decode leftover HTML entities in link-preview text (#187 DP17)
CI / Build & Quality Checks (push) Successful in 4m16s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Steam double-encodes its meta tags (`&amp;quot;`), so after Synapse's
single decode the card showed "The &quot;Perpetual Testing
Initiative&quot; …". The preview's og:title / og:description /
og:site_name are now decoded once where the preview is fetched, so every
card (about 20 read those fields directly) gets clean text. Rendered as
React text only, so decoding can't inject markup; exactly one level is
decoded.

Verified on the Portal 2 store link: "The \"Perpetual Testing
Initiative\" has been expanded…", no literal &quot; left on the page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 11:24:28 -04:00
Lotus CIandClaude Opus 5.5 e06600afe1 fix(a11y): Escape no longer yanks focus into the composer (#187 DP9)
CI / Build & Quality Checks (push) Successful in 6m18s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 10s
CI / Trigger Desktop Build (push) Successful in 10s
CI / Playwright smoke (e2e) (push) Canceled after 14m59s
RoomView focuses the composer on any "typing" key pressed while nothing
editable has focus, and Escape counted as typing. Closing the GIF picker
with Esc (its focus trap returns focus to the GIF button) therefore
landed in the composer instead, and so did Esc on any other room control.
Escape, CapsLock, Insert, ContextMenu, PrintScreen and Pause are now
excluded like the other non-typing keys.

Verified at 1300 px and 320 px: Esc closes the picker and focus is back
on "Insert GIF"; the 320 px picker fits with no page overflow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-24 11:02:46 -04:00
Lotus CIandClaude Opus 5.5 f3722ae859 refactor(types): drop 127 unnecessary as any casts (#210)
Removed every `as any` in src/, then restored only the ones tsc still
needs: 163 → 36. 112 were in lotus-terminal.css.ts (`'… !important' as
any` etc. — vanilla-extract's style types accept these strings as-is),
so its file-wide eslint-disable goes too. The casts were type-only, so
emitted code and generated CSS are unchanged.

eslint warnings 49 → 46; the ratchet is tightened to match.
tsc clean; 1202 unit tests pass.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 23:32:42 -04:00
Lotus CIandClaude Opus 5.5 4a9823890b feat(desktop): "Start minimized" setting under Launch on login (cinny-desktop #3)
CI / Build & Quality Checks (push) Successful in 5m19s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 24s
CI / Trigger Desktop Build (push) Successful in 9s
CI / Playwright smoke (e2e) (push) Successful in 15m37s
Shown only while Launch on login is on, and only when the native side
answers `get_start_minimized` (older desktop builds just don't show it).
Toggling calls `set_start_minimized`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 22:46:10 -04:00
Lotus CIandClaude Opus 5.5 e80b170fd1 fix(sw): only register the service worker on http(s) pages
register() rejects on non-http(s) origins, and the rejection was
unhandled. The desktop app's debug build loads from tauri://localhost,
which surfaced as a Sentry "serviceWorker.register() must be called with
a script URL whose protocol is either HTTP or HTTPS". Skip registration
there, and catch any other failure (e.g. SWs disabled) with a warning.
The app works without a SW.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 22:46:05 -04:00
Lotus CIandClaude Opus 5.5 76929a8763 docs(embeds): SoundCloud short links already embed via og:url (#200)
CI / Build & Quality Checks (push) Successful in 4m47s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 17s
CI / Trigger Desktop Build (push) Successful in 6s
CI / Playwright smoke (e2e) (push) Canceled after 4m39s
The comment said on.soundcloud.com links need an oEmbed round-trip. They
don't: Synapse follows the redirect for the preview and the og:url
fallback re-parses the canonical track URL. Checked with a real short
link (the play facade loads the w.soundcloud player).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 22:09:34 -04:00
Lotus CIandClaude Opus 5.5 146ba4d2ff feat(desktop): upload progress on the taskbar button (cinny-desktop #10)
Upload atoms now also report into an app-wide `uploadProgressAtom`, and
`aggregateUploadProgress` turns every in-flight upload into one taskbar
state: byte-weighted percentage while uploading, indeterminate until a
size is known, red after a failure (held 4 s, then cleared), none when
done. Cancelled uploads just disappear. `useTauriTaskbarProgress` sends it
to the native `set_taskbar_progress`, at most ~4 times a second, always
ending on the latest state.

Verified with a throttled 6 MB upload: 0% → 99% at 250 ms steps, then
cleared; with the upload request aborted: 0% → error → cleared after 4 s.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 22:04:48 -04:00
Lotus CIandClaude Opus 5.5 25fa0f6ef9 feat(desktop): mirror call state onto the tray icon (cinny-desktop #4)
Sends `set_tray_call_state` from the same effect as the taskbar thumbbar,
so tray and thumbbar can't disagree. Verified through a real local call:
idle → active → active+muted → active+muted+deafened → active+muted → idle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 21:53:48 -04:00
Lotus CIandClaude Opus 5.5 b77720c3da fix(calls): no stale "muted + deafened" render when a call starts
useCallControlState kept the previous control's state (the all-off
default before a call) for one render after the control changed, until
its effect caught up. The thumbbar, SMTC and tray hooks pushed that render
to the OS, so every join flashed "muted + deafened" first (recorded:
idle → active+muted+deafened → active). The state now remembers which
control it belongs to and reads the new control directly when they differ
(now: idle → active).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 21:53:43 -04:00
Lotus CIandClaude Opus 5.5 6edfe70020 feat(desktop): mirror a pending update into the tray (cinny-desktop #6)
CI / Secret scan (gitleaks) (push) Successful in 21s
CI / Build & Quality Checks (push) Successful in 4m54s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 4s
CI / Playwright smoke (e2e) (push) Successful in 12m56s
While an update is available the web client asks the native side to show
"Restart to update (vX)" in the tray menu and an "update ready" tooltip;
it's cleared once a check reports we're current, and kept while
installing or after a failed attempt. Clicking the tray item runs the
same install flow as the toast, so progress and failures show in-app.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 21:31:29 -04:00
Lotus CIandClaude Opus 5.5 d9e7f77402 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.13
CI / Build & Quality Checks (push) Successful in 3m54s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 16s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Canceled after 15m41s
Brings the plain-language "Couldn't connect to voice" dialog with a
Try again button (replaces "Failed to connect to Livekit server
(Reason: ServerUnreachable)").

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 21:11:11 -04:00
Lotus CIandClaude Opus 5.5 568f218fe9 feat(desktop): update failures say what happened and what to do
CI / Secret scan (gitleaks) (push) Successful in 15s
CI / Build & Quality Checks (push) Canceled after 53s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Docker image build & smoke test (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
A friend's update failed ten times in a row ("Update check failed: error
sending request for url (…nsis.zip)") before the 11th went through, and
he didn't know what to do. The label was also wrong: the check had
worked; the download failed.

- Progress: "Downloading update… 28% (14.3 MB of 49.9 MB)", "The update
  server didn't respond. Trying again in 3 s (attempt 2 of 4)…", from the
  native `lotus-update-progress` events (cinny-desktop retries itself).
- Failures name the step (check / download / install, from the native
  error prefix) in plain language, with Try again and a Download
  installer button (Windows: the setup .exe; else the release page), and
  the raw error under "Details".
- Installing from the update toast now shows a "Downloading update"
  toast, and on failure a sticky "Update didn't install" toast that
  retries on click and points at Settings → General → App Updates.
  Before, the toast vanished and the failure was only visible in Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 20:52:16 -04:00
Lotus CIandClaude Opus 5.5 23649f1255 fix(media): catch autoplay rejections from play()
CI / Build & Quality Checks (push) Successful in 5m10s
CI / Docker image build & smoke test (push) Skipped
CI / Trigger Desktop Build (push) Successful in 7s
CI / Secret scan (gitleaks) (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Notification sounds called `audio.play()` without handling the promise,
so every message that arrived before the user interacted with the page
(e.g. right after launch) logged an uncaught NotAllowedError — 15 in a
short test run. Same pattern in the video thumbnail loader, the voice
preview (which now also resets its Play button) and useMediaPlay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 19:48:29 -04:00
Lotus CIandClaude Opus 5.5 343de27cad feat(desktop): toast "Mark as read" marks the room read (cinny-desktop #9)
Handles the native `lotus-notification-mark-read` event through the
same `markAsRead` path as the room menu's "Mark as Read", honouring the
private-receipt settings. Verified: 3 unread → 0 on the server.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 19:48:23 -04:00
Lotus CIandClaude Opus 5.5 3b6de2fdac fix(desktop): hydrate Focus Assist on mount; thread-aware toast quick reply
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
- useTauriFocusAssist queries the new `get_focus_assist` command on mount.
  The native poll's first reading is emitted during app setup, before the
  page listens, and the atom resets on every reload — so with Focus Assist
  already on, notifications leaked until the OS state flipped
  (cinny-desktop #15).
- The toast quick reply takes the real `threadId` from the notification
  data and replies inside the thread (cinny-desktop #17).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 19:32:42 -04:00
jaredandClaude Opus 5 f5ee3c0d0e fix(assets): rename Lotus.png so it no longer collides with lotus.png (#229)
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 7s
CI / Trigger Desktop Build (push) Successful in 7s
CI / Playwright smoke (e2e) (push) Successful in 8m41s
public/res/ carried two DIFFERENT files whose names differ only in case —
Lotus.png (19 897 B, the 256px logo used by the welcome page, auth layout,
OIDC config and About) and lotus.png (2 073 B, the notification icon).
Windows and macOS filesystems are case-insensitive, so a checkout there
collapses them into one path: one of the two references then resolves to
the wrong bytes or to nothing, which is why the desktop client's home
logo rendered as alt text from
http://localhost:44548/public/res/Lotus.png.

Renamed the logo to lotus-logo.png and updated its four references;
verified no case-only filename collisions remain anywhere in the repo,
and that both logos still load (256x256 on the welcome page and About).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:39:26 -04:00
jaredandClaude Opus 5 08abf5aec8 fix(embeds): players fill the card and keep their aspect ratio (#228)
Two sizing bugs, both visible the moment a video starts:

1. The video/TikTok embed columns had no width. UrlPreview is a flex ROW,
   so they shrink-to-fit: the facade's <img> supplied the width, but the
   player <iframe> is absolutely positioned and supplies none, so pressing
   play collapsed the whole embed to the iframe's ~200px intrinsic size
   (measured 606x341 -> 204x115 in a 608px card). Both columns are now
   width: 100%.
2. EmbedMediaPortrait's 9:16 came from a 177.78% padding-top, but a
   padding percentage resolves against the CONTAINING BLOCK's width, not
   the element's — so inside a wide card a Short/TikTok rendered
   300x1077 instead of 300x533. Capped with min() so it is exact at
   >= 300px and still correct on narrower phones.

Audited every provider before/after play at 1300 px, 500 px and Pixel 7:
YouTube, Vimeo, Twitch 16:9 (0.56); Shorts, TikTok 9:16 (1.78); Spotify
152 px and SoundCloud 166 px fixed-height — all stable across play.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:37:57 -04:00
jaredandClaude Opus 5 e393c45f50 fix(status): auto-clear survives a rate-limited presence write (#227)
The quick-status auto-clear removed its localStorage record BEFORE the
presence write and swallowed any error, so a single failure dropped the
timer permanently and the status stayed set forever. Synapse rate-limits
presence to ~1 write / 10 s per user (#226) and the heartbeat spends that
budget, so 429s here are routine — especially right after startup, when
the monitor's first check runs.

The clear now goes through setPresenceWithRetry (honours retry_after_ms)
and only forgets the status once the server has taken it; a re-entry guard
stops overlapping attempts and the poll is 15 s so a retry lands promptly.

Reproduced and verified with three injected 429s: before, the status
stayed on the server forever with the local record gone; now it clears.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:31:57 -04:00
jaredandClaude Opus 5 fdec3ed7f2 feat(threads): "Mark all read" in the threads list (#165)
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
A "Mark all read" chip appears in the Threads panel header whenever any
thread in the room has unread replies, and sends one threaded receipt per
unread thread (never the root — a root lives in the main timeline and a
receipt there would drag the MAIN read marker backwards, the P6
regression). Honours the private-receipt settings.

Verified: two threads with unread replies → the chip appears, the rows
say "unread" in their labels; after clicking, no unread rows and the chip
is gone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:25:53 -04:00
jaredandClaude Opus 5 cf80729a5c feat(media): swipe between items in the full-screen viewer (#164)
CI / Build & Quality Checks (push) Successful in 1m29s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 6s
CI / Trigger Desktop Build (push) Canceled after 0s
CI / Playwright smoke (e2e) (push) Canceled after 0s
Touch had no way to move between images except the small arrow buttons.
A one-finger horizontal swipe now goes next/previous, and is inert while
the image is zoomed in (where the same gesture pans). The classifier is
pure and unit-tested: ≥ 60 px horizontal, not mostly vertical, under
800 ms, single finger.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:20:22 -04:00
jaredandClaude Opus 5 738067cfc6 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.12
CI / Build & Quality Checks (push) Successful in 2m52s
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) Canceled after 2m31s
Brings io.lotus.screenshare_notice (EC #39) and the screenshare tile's
audio-state glyph (EC #38).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-23 15:15:25 -04:00
jaredandClaude Opus 5 2bed70d335 feat(calls): toast the fork's screenshare reminders (EC #39)
CI / Build & Quality Checks (push) Successful in 1m39s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 3s
CI / Playwright smoke (e2e) (push) Successful in 10m4s
io.lotus.screenshare_notice → "Screen sharing stopped — the shared window
was closed.", "Your screen share is showing nothing — the shared window
may be minimised or hidden.", and a sticky "Still sharing?" after 30 min
with nobody else in the call (fork ≥ 0.25.0-lotus.12).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:51:59 -04:00
jaredandClaude Opus 5 c7aa4b9b19 ci: debounce the desktop build trigger to one bump per hour (matrix #9)
trigger-desktop checks the age of cinny-desktop's last main commit and
skips the submodule bump when it is under 60 minutes old; cinny-desktop's
new nightly catch-up workflow (or a manual dispatch) moves the submodule
to lotus HEAD for whatever landed inside the window. The desktop cadence
stops tracking every web commit, which was the biggest runner-load source.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:39:55 -04:00
jaredandClaude Opus 5 81777ddfe1 chore(calls): bump @lotusguild/element-call-embedded to 0.25.0-lotus.11
Brings io.lotus.set_audio_output for the call-bar output switch (#119).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:38:14 -04:00
jaredandClaude Opus 5 eb4b88a028 fix(math): $…$ inside a backtick code span stays literal (#194 P4-4)
Verifying the KaTeX checklist: `$x^2$` in inline code was turned into
math and the backticks were left as literal text, because the math split
runs before markdown. The splitter now skips backtick code spans (N ticks
close with N), so markdown's inline code wins: wire is
<code>$y^2$</code>. Fenced blocks were already fine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:35:54 -04:00
jaredandClaude Opus 5 e3883e0fce test(haptics): swap globalThis.navigator via defineProperty (Node 22)
CI / Build & Quality Checks (push) Successful in 1m38s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 11s
CI / Trigger Desktop Build (push) Successful in 8s
CI / Playwright smoke (e2e) (push) Successful in 9m50s
CI's Node exposes a read-only navigator getter; assigning threw
"Cannot set property navigator ... which has only a getter".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:32:32 -04:00
jaredandClaude Opus 5 98c80f36cb feat(calls): audio output quick-switch in the call bar (#119)
Verified first: the embed hides Element Call's footer, so its in-call
settings sheet (which has the output picker) is unreachable from Lotus;
the cinny call bar had no output control.

Now a speaker button next to Deafen (desktop bar only; hidden where
setSinkId is unavailable — Firefox, Safari, Android Chrome) opens a menu
of enumerateDevices() audio outputs with the current one checked; picking
one sends io.lotus.set_audio_output to the fork (≥ 0.25.0-lotus.11),
which selects it, and the choice is re-sent with the rest of the sticky
fork state after an EC remount.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:31:47 -04:00
jaredandClaude Opus 5 ad1cbcf792 feat(room-settings): one-line "who can see this" under the room name (#133)
CI / Build & Quality Checks (push) Failing after 1m43s
CI / Trigger Desktop Build (push) Skipped
CI / Docker image build & smoke test (push) Skipped
CI / Playwright smoke (e2e) (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 9s
Room Settings → General now reads e.g. "Encrypted · Invite only ·
History visible to members since they joined" under the name — derived
from m.room.encryption, m.room.join_rules and m.room.history_visibility
with the same words the preview-card chips and the history setting use.
One Text line, no card, no icons, no controls; spec defaults (invite-only,
shared history) when a state event is absent. Unit-tested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:18:08 -04:00
jaredandClaude Opus 5 492b57c60d feat(mobile): haptic tick on PTT press/release and on reactions (#125)
utils/haptics.ts: tick('ptt-on' | 'ptt-off' | 'reaction') → 10/10/8 ms
navigator.vibrate, a no-op without the API (iOS), when the system prefers
reduced motion, or when the new Settings → Calls "Haptic Feedback" switch
(default on, only rendered where the API exists) is off. PTT is observed
once through pttActiveAtom so the keyboard, global-hotkey and on-screen
paths all tick; reactions tick where the reaction event is sent in the
room and thread timelines (quick bar, hover bar, sheet and emoji board
all funnel there).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:15:04 -04:00
jaredandClaude Opus 5 0c45bde832 feat(composer): a single pasted/dropped image lands in its caption field (#129)
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
Verified first: after a paste the focus stayed in the composer and the
caption needed a click. Now, when exactly one image is pasted or dropped
into an empty composer, its upload card's caption input takes focus;
Enter there sends the board (with any composer text) and Escape returns
focus to the composer. Multi-file drops and non-empty composers are
unchanged. The target card is matched by file name + mtime because the
metadata strip re-wraps the File.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:11:05 -04:00
jaredandClaude Opus 5 082b8fc879 feat(sidebar): custom room sections via u.* tags (#108)
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
Room context menu → "Add to Section" submenu: every u.<name> tag in use
across your rooms as a checkable item, plus a "New section…" field
(validated: non-empty, ≤ 40 chars, no dots, unique). Toggling writes or
deletes the standard u.<name> room tag (order 0.5), so sections sync
across devices and other clients see the same tags. The menu row reads
"Sections: Raids, Off-topic" once a room is in any.

Home renders each section as a collapsible category between Favorites
and Rooms (alphabetical; members by tag order then name; the same
closed-state store and unread-only-when-collapsed behaviour as the built-in
categories). A sectioned room leaves the plain Rooms list but keeps a
Favorite / Low Priority placement. Empty sections don't exist by
construction; rename is retag (v2). Derivation in utils/roomSections.ts
with unit tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 15:05:30 -04:00
jaredandClaude Opus 5 c6c2e88df5 feat(settings): storage usage tile with persistence status (#120)
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
One tile under Settings → General → Storage: total local usage vs the
browser's quota from navigator.storage.estimate(), the Chromium
usageDetails breakdown when available (IndexedDB = sync cache + encryption
keys + search index; Cache Storage = offline app files), and whether the
browser granted persistent storage — green "Protected" or amber "May be
evicted" with a "Keep my data" button that calls storage.persist(). That
last bit is the useful diagnostic for the KE-1 storage-eviction cluster.

No clear button: media lives in the browser's own HTTP cache (not in the
estimate, and not clearable from a page), the crypto store must never be
casually cleared, and the search-index clear already lives in Message
Search. The About page's "Clear Cache & Reload" now says it deletes this
device's encryption keys too. Hidden entirely when estimate() is missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:53:42 -04:00
jaredandClaude Opus 5 dc0d524989 feat(calls): rejoin the voice room after a crash, update or reload (#118)
While joined, a device-local record {roomId, deviceId, joinedAt,
lastSeen, mic, video} is written and refreshed every 30 s; a deliberate
hangup (HangupCall/Close) and logout clear it. On the next start, once
the first sync is in and the room's MatrixRTC session has reported its
members (waited for up to 10 s — it fills a moment after sync), a fresh
record (< 10 min) for this device with someone still in the call yields
either a sticky "Rejoin voice?" toast (tap to rejoin) or an automatic
rejoin, per the new Settings → Calls → After a Restart (Ask / Rejoin
automatically / Do nothing; default Ask). Skipped when our own membership
is already live from another device, or only our stale one is left.
Mic/camera state comes from the record (camera still gated by
cameraOnJoin); PTT is applied by startCall as usual.

Pure decision in utils/callRejoin.ts with tests. Verified headless:
reload mid-call → toast → tap → back in the call; hangup → reload → no
toast; auto mode → back in without a prompt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:50:57 -04:00
jaredandClaude Opus 5 22d46a7922 feat(messages): reply quotes show a media thumbnail (#151)
CI / Build & Quality Checks (push) Successful in 1m46s
CI / Docker image build & smoke test (push) Skipped
CI / Secret scan (gitleaks) (push) Successful in 8s
CI / Trigger Desktop Build (push) Successful in 11s
CI / Playwright smoke (e2e) (push) Successful in 9m44s
A reply to an image, video or sticker used to quote just the filename.
The quote (timeline) and the composer's reply-draft preview now carry a
36 px thumbnail from the event's own thumbnail, decrypted for E2EE media
via the same hook the gallery uses — never the full-size file. Clicking
still jumps to the original.

useDecryptedMediaUrl and getThumbMxc moved out of MediaGallery into
hooks/ and utils/ so components/message can use them without a cycle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
2026-09-20 14:27:23 -04:00