PTT and deafen were DOM key handlers and only fired while Lotus (or the EC
iframe) had focus — alt-tab into a fullscreen game and the voice controls
stopped working.
- useCallHotkeys: while a call is joined and the new device-local
`globalCallHotkeys` setting is on, register {ptt, deafen} bindings with
the desktop (`set_global_hotkeys`, cleared on leave) and act on its
`lotus-global-hotkey` press/release events. Events are ignored while
`document.hasFocus()` so the DOM handlers keep owning the in-focus case
(editable-field and interactive-element checks, no double toggles). PTT
press engages the mic exactly like the DOM path (pttActive set before
unmute), release restores; the existing blur/focus release covers a hold
that spans a focus change. Same modifier rules as the DOM path
(`shouldActOnGlobalHotkey`, tested).
- Settings → Calls: "Hotkeys Work Outside the Window" toggle, Tauri only.
- settingsSync: `globalCallHotkeys` is device-local (never synced).
- LOTUS_FEATURES: desktop section entry.
Native side lands in cinny-desktop (src-tauri/src/native/hotkeys.rs).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The client never handled beforeinstallprompt and showed no install hint, so
phone users only got the PWA if they knew to dig through Share → Add to
Home Screen (iOS never prompts; Chromium's mini-infobar is easy to miss).
- utils/pwaInstall.ts (pure, 4 tests): show from the second visit, never in
Tauri or an installed PWA (display-mode standalone / navigator.standalone),
30-day snooze after a dismissal; kind = real prompt when the browser
handed us a deferred beforeinstallprompt, Share-sheet instructions on iOS
Safari, nothing elsewhere (Firefox desktop has no install path).
- hooks/usePwaInstallPrompt.ts: captures beforeinstallprompt/appinstalled,
counts one visit per browser session, waits 6s for the prompt event before
deciding, then enqueues a sticky toast; tap → prompt(), X → snooze.
- ToastNotif gains onDismiss (fired by the X button only) so the snooze is
recorded however the toast is closed. Mounted from ClientNonUIFeatures for
signed-in users only.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Every Lotus setting was localStorage-only, so a user on web + desktop + phone
configured theme, composer toolbar, quiet hours, call keys… three times.
- utils/settingsSync.ts (pure, 7 tests): DEVICE_LOCAL_KEYS denylist (zoom,
media auto-load, animation pause, glassmorphism, denoise tier/model,
bitrates, volumes, notification permission, developer tools, PTT mode,
camera-on-join, drawer state, and the sync toggle itself), pickSyncable,
mergeRemoteSettings (unknown keys, device-local keys and wrong-shaped
values are skipped), buildSyncedContent, shouldApplyRemote (LWW on
updatedAt; equal stamp = our own echo).
- hooks/useSettingsSync.ts: on start applies a newer remote snapshot or
pushes local if it differs; debounced push on any settingsAtom write,
skipped when the syncable subset equals the last pushed/applied snapshot
so a remote apply never echoes back; AccountData listener for live
updates; stamps forced monotonic per device; per-account lastSyncedAt
marker so another user on the same device can't inherit it; failed pushes
roll the marker back so the next change retries. Remote values are re-read
through getSettings() so enum coercion applies.
- Settings → General → Sync: toggle (device-local), "Push now", "Clear
synced copy". AccountDataEvent.LotusSettings registered.
- ClientNonUIFeatures: the #103 tracking-param subscriber moves out of
PageZoomFeature into its own TrackingParamsFeature next to
SettingsSyncFeature.
- Docs: LOTUS_FEATURES entries for #103/#104; LOTUS_TODO links the new
Features 2026-Q4 milestone and #108.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
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 `&` 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
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
Every io.lotus.call_state push allocated a new Set, re-rendering the
app-wide call bar for the whole call. nextSpeakerSet() returns the
previous reference when membership is unchanged (pure helpers in
utils/speakerSet.ts, unit-tested), and the DOM MutationObserver fallback
in useCallSpeakers/useRemoteAllMuted is attached only while the fork's
participant list is unavailable.
Fixes#32
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The "should we make noise" predicate used for message sounds is extracted
into useNotificationsQuiet() (unit-tested) and applied to the ringtone in
both the full-screen incoming-call overlay and the compact in-call banner.
The overlay/banner still show so the call can be answered; only the audio
is skipped. Join/media paths untouched.
Fixes#28
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
deleteSearchCacheDatabase() resolved after a 3 s "blocked" timeout while
another tab still held the DB, so decrypted rows could survive logout.
It now broadcasts lotus-logout first; every tab closes its handle and
refuses to reopen, then the delete proceeds. A boot with no session
re-runs the wipe once in case a race was still lost. Unit-tested.
Fixes#45
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The settings atom was a load-time snapshot with no storage listener and
wrote the whole blob, so two tabs silently reverted each other. It now
re-reads on storage events and writes only the keys that changed
relative to the previous value. Unit-tested.
Fixes#42
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Every text message subscribed to the whole translation cache array, so
one translation re-rendered the entire timeline. Messages now subscribe
to their own keyed entry, the cache key is memoised, the auto-translate
"already tried" guard is module-scoped (no re-detect on virtualised
remount), and both atom families are FIFO-capped and evicted on logout.
Unit-tested.
Fixes#39
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- 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#38Fixes#40
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
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
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
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
* chore: Update actionable issues note in CONTRIBUTING.md
Clarify that only issues with numbers greater than #2876 are actionable.
* Update pull request template for clarity
Simplified the pull request template by removing unnecessary details.
The status was component state in the settings tab, so closing Settings
threw away "update available" and forced another check. Move it to a
module-level atom shared by the settings panel and the update toast.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
81a6d9c9 wired getRtcTransports() but its .well-known fallback read
mx.getClientWellKnown(), which the SDK only populates when the client
is started with clientWellKnownPollPeriod — cinny never sets it, so on
the live client the fallback returned [] and calls still failed with
MISSING_MATRIX_RTC_TRANSPORT after deploy. (The unit test faked a
populated cache and hid this.)
The driver now fetches the client .well-known itself, once, via
AutoDiscovery.getRawClientConfig(domain) — the same resolver Element
Call used before 0.22 — and only when the SDK has nothing cached. Test
added for the uncached path (asserts a single fetch); verified the
resolver returns the livekit focus for matrix.lotusguild.org.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Since the upstream v0.25.0 merge, Element Call in widget mode no longer
reads .well-known for its LiveKit transport; it asks the HOST via the
widget API (org.matrix.msc4515.get_rtc_transports, capability
org.matrix.msc4515.rtc_transports). cinny never granted the capability
nor implemented WidgetDriver.getRtcTransports(), so discovery returned
nothing and every join failed with "Call is not supported"
(MISSING_MATRIX_RTC_TRANSPORT) — the [LocalMembership] Multiple
Transport Errors line in the browser console.
- matrix-widget-api 1.17.0 -> 1.18.0 (adds MSC4515; also changes the
sendDelayedEvent driver signature, adapted — parent delay ids were
removed from the draft).
- Grant MSC4515RtcTransports in getCallCapabilities.
- CallWidgetDriver.getRtcTransports(): homeserver /rtc/transports
(MSC4143) first, then the .well-known org.matrix.msc4143.rtc_foci list
(what matrix.lotusguild.org advertises today). Unit-tested.
Server side needs no change: the well-known already carries the livekit
focus and the JWT service answers.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- gitleaks flagged `STORAGE_KEY = 'cinny_recent_gifs_v1'` (a localStorage
key name) as a generic API key. Allowlist namespaced cinny_/lotus- key
names on *_KEY assignment lines, and skip dist/, node_modules/ and the
vendored Element Call bundle (minified matrix-js-sdk crypto identifiers
trip the same rule locally after a build). Verified: 0 findings on this
tree with and without dist/ present.
- The runner has no `docker` binary and Gitea ignores job-level
continue-on-error for the run conclusion, so the docker job painted the
run red. It now runs only when the Actions variable CI_HAS_DOCKER is
"true"; the weekly renovate job likewise only when RENOVATE_ENABLED is
"true" (set alongside the RENOVATE_TOKEN secret).
- The Playwright e2e job passed on the runner, so it's a hard gate now.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Second CI-published fork release: focus works in grid/1:1 and clears on
leave, decoration ring sized/hidden correctly and CDN-pinned, denoise
prefetch + fallback + suspend-while-muted, screenshare cap as a real
budget, soundboard refuses while muted, shared clip AudioContext,
call_state at ≤2/s, transparent-theme contrast guard, plus the
request_state / denoise_state fromWidget actions the host now handles.
Verified: bundle in dist/public/element-call carries all eight
io.lotus.* actions; boot check passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- io.lotus.request_state: when the fork's lotus handlers (re)register
(an EC-side remount that doesn't unmount us) we re-send deafen, quality
and the focus pin, and the decoration pusher re-pushes its roster —
decorations and the pin no longer vanish for the rest of the call
(element-call#17).
- focus_participant carries the per-device media id from call_state
(speaking device preferred) so a multi-device user pins the right
device (element-call#30).
- injectAudio returns the fork's reply; when it refuses with
reason:"muted" the soundboard shows "Unmute your microphone…" instead
of playing the clip locally as if it went out (element-call#13).
All backwards compatible with the 0.25.0-lotus.1 bundle (unknown action
is acked; missing reply fields default to "played").
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Boot tier (runs against vite preview of dist/): login screen renders with
no page or console errors, sw.js is served and registers, the bundled
Element Call mounts in a frame with no failed asset requests. E2EE tier
(skipped without E2E_HOMESERVER/E2E_USER/E2E_PASSWORD): password login,
create an encrypted room, send text, attach a compressed JPEG, and assert
at the network level that every send is m.room.encrypted with no
plaintext body/url/file — the regression test #6/#7/#11 lacked.
Secrets and local usage documented in LOTUS_TESTING.md.
Fixes#90
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- "Verify lockfile is in sync" (git diff --exit-code package-lock.json
after npm ci) replaces the deleted GitHub lockfile workflow (#99).
- gitleaks 8.30.1 binary scan on push + PR with a small allowlist for
the public homeserver/registry URLs (#95).
- docker job builds the image, runs it and asserts 200 + the security
headers; continue-on-error until the runner is confirmed to have a
Docker daemon (#93). .dockerignore keeps the context small.
- docker-nginx.conf now sends a CSP (frame-src allowlist matching
videoEmbed.ts), frame-ancestors 'none', Referrer-Policy and nosniff —
shipped config, verify against the live chat.lotusguild.org headers
before adopting in prod nginx (#95, #44 shipped-config half).
- renovate.json + weekly renovate workflow for cinny and element-call;
needs a RENOVATE_TOKEN secret (names starting GITEA_ are reserved) and
stays continue-on-error until it exists (#94).
- e2e job appended for the Playwright smoke test (#90), continue-on-error
until green on the runner.
Fixes#93Fixes#94Fixes#95
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The language detector switched the few upstream-translated strings to
the browser locale while every Lotus surface stayed English, producing a
mixed UI. supportedLngs is now ['en']; the detector, backend and locale
files stay so re-enabling is one line plus routing Lotus strings through
useTranslation.
Fixes#53
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
Home's categorisation memo keyed only on the room list, and nothing
observed m.tag changes. The SDK emits RoomEvent.Tags on the room and
re-emits it on the client (room.js addTags, sync.js reEmit); a small
client-level hook bumps a version that the memo depends on.
Fixes#20
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
The redirect back with ?error=invalid_client is the only place a stale
dynamic client id is ever rejected; the callback now resolves the issuer
from the SDK's stored mx_oidc_<state> entry and invalidates the cache so
the next attempt re-registers. Degrades to a no-op if the state entry is
gone. Unit-tested.
Fixes#102
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- syncDecorations.mjs tracks which slugs its regex actually removed and
exits 1 without writing if that set doesn't match the missing assets,
instead of silently no-op'ing on a reformatted catalog.
- patch-folds.mjs matches a 4-line context block, reports the installed
folds version and expected snippet when the target is missing, and
distinguishes "already patched" (exit 0) from "pattern not found"
(exit 1). Verified against all three states.
Fixes#88Fixes#55
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
- engines.node >=20.0.0 and .nvmrc mirroring .node-version (#54)
- npm audit --audit-level=high is a hard gate (tree is at 0) (#91)
- scripts/boot-check.mjs serves dist/ with vite preview and asserts /,
config.json, the entry chunk and the Element Call bundle all load (#92)
- scripts/check-bundle-size.mjs enforces gzip budgets from
scripts/bundle-budget.json (seeded +10%); fails PRs, warns on push (#96)
- check:eslint runs with --max-warnings 68 so the count can only go down;
7 unused eslint-disable directives removed to get there (#97)
Fixes#54Fixes#91Fixes#92Fixes#96Fixes#97
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
m.replace events no longer appear as their own "* text" line — the
original row gets m.new_content and an (edited) marker. Media rows carry
a [Image]/[Video]/[Audio]/[File] prefix and the JSON export includes
mediaUrl; the UI notes attachments aren't included. Unit-tested.
Fixes#50Fixes#81
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA