Compare commits

...

49 Commits

Author SHA1 Message Date
jared 9bf56d5748 docs(bugs): track remaining native-cinny polish items
CI / Build & Quality Checks (push) Successful in 10m31s
CI / Trigger Desktop Build (push) Successful in 29s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:44:27 -04:00
jared d5ce56930b refactor(ui): extract shared SettingsSelect; replace raw <select> (native-cinny audit 6/N)
Extracted the folds-native dropdown (Button+PopOut+Menu) from General.tsx into a
shared components/settings-select/SettingsSelect.tsx, and used it to replace raw
native <select> elements (which render OS-styled and broke under non-default
themes via colorScheme:'dark'):
- Profile "auto-clear after" select
- PushRuleEditor add-rule mode select (dropped the now-unused handleModeChange)

The form-tied timezone <select> in Profile is left for a follow-up (it's wired
to native form submission + a disabled state and needs more care).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:43:18 -04:00
jared 349194e7e5 fix(ui): folds primitives for RouteError + PiP fullscreen button (native-cinny audit 5/N)
CI / Build & Quality Checks (push) Successful in 10m33s
CI / Trigger Desktop Build (push) Successful in 21s
- RouteError: raw <div>/<h2>/<p>/<button> (sans-serif, raw px) -> folds
  Box/Text/Button with config tokens.
- CallEmbedProvider PiP fullscreen control: raw <button> with ⊡/⛶ glyphs ->
  folds IconButton reusing the exported FullscreenIcon/ExitFullscreenIcon SVGs
  from Controls (consistent with the main fullscreen button). The intentional
  dark over-video scrim is kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:32:19 -04:00
jared 24d6460e4c chore: remove Sentry.io entirely
We no longer use Sentry. Removed:
- @sentry/react + @sentry/vite-plugin (package.json + lockfile)
- Sentry.init in index.tsx and the VITE_SENTRY_DSN env (.env.production)
- @sentry/vite-plugin + the SENTRY_AUTH_TOKEN sourcemap-upload path in
  vite.config.js (sourcemap now always false) and the CI env var
- Sentry.ErrorBoundary in App.tsx -> react-error-boundary's ErrorBoundary with a
  folds-native fallback (Box/Text/Button + config tokens), which also resolves
  the native-cinny audit's raw-#hex/#5865f2 fallback finding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:21:09 -04:00
jared 127e783f66 fix(ui): toast cards render on stock themes; gate TDS glow (native-cinny audit 4/N)
LotusToastContainer was styled entirely with --lt-* CSS vars but rendered
unconditionally (not gated on lotusTerminal). Those vars only exist inside the
Lotus Terminal theme's scoped block with no global fallback, so in-app toast
notifications rendered with undefined background/border/colors on every stock
Cinny theme. Now the card uses folds tokens (color.Surface.*/Primary.*,
config.radii/space/borderWidth, color.Other.Shadow) by default, keeping the TDS
--lt-* glow/accents only when lotusTerminal is active. The raw <button> dismiss
control is now a folds IconButton.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:06:33 -04:00
jared 198fd12bb2 fix(ui): folds tokens for ML-denoise panel + screenshare popover (native-cinny audit 3/N)
- General ML noise-suppression panel: ungated --border-color/--bg-card/--bg-input/
  --accent-orange -> color.Surface.ContainerLine/Container, SurfaceVariant.Container,
  Primary.Main. (The lotusTerminal-gated Boot button keeps its TDS --accent-orange.)
- CallControls "Share your screen?" popover: --bg-surface/--bg-surface-border ->
  color.Surface.Container / ContainerLine.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 22:01:17 -04:00
jared 34d5209165 fix(ui): folds tokens for settings/profile/glass invented vars (native-cinny audit 2/N)
- DenoiseTester: --bg-card/--border-color/--accent-green/--accent-orange -> color.Surface.*/Success/Primary
- ProfileDecoration: --accent-cyan/--bg-surface-variant -> color.Primary.Main/SurfaceVariant.Container
- Profile: --tc-critical/warning-normal -> color.Critical/Warning.Main
- UserRoomProfile: --tc-positive/warning-normal/--tc-surface-low-contrast/--bg-surface-variant -> color tokens
- Sidebar glass: hardcoded rgba bg/border -> color-mix on color.Surface.Container + SurfaceVariant.ContainerLine
  (also fixes the glass looking wrong on light themes — was always near-black)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:58:57 -04:00
jared 9684ab75bb fix(ui): replace ungated invented CSS vars with folds tokens (native-cinny audit 1/N)
Audit of our delta vs Cinny v4.12.3 found invented CSS vars (--tc-*, --bg-*)
used outside Lotus-Terminal-gated code, which render unstyled/wrong on stock
themes. Batch 1:
- MemberVerificationBadge: --tc-positive/warning-normal -> color.Success/Warning.Main
- RoomInput (gif/location errors): --tc-danger-normal -> color.Critical.Main
- MsgTypeRenderers (location iframe): --bg-surface-border -> color.SurfaceVariant.ContainerLine
- MessageSearch (cached-room row): --bg-surface-variant -> color.SurfaceVariant.Container
- PrescreenControls (mic-denied): --tc-critical-high -> color.Critical.Main

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:54:42 -04:00
jared 0a6b035a67 docs(readme): correct fork-sync version (v4.12.3) and logo path
Two stale facts in README.md: it said "Forked from Cinny v4.12.1" (we've since
synced through v4.12.3) and referenced the logo as lotus_chat.png (the file is
public/res/Lotus.png). CONTRIBUTING.md is intentionally left as upstream
Cinny's and is not modified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:32:49 -04:00
jared cbfd3e5632 docs: N108 -> Needs-Verification; add L2 maskable-icon test
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:27:57 -04:00
jared 3faf0866a0 feat(pwa): add maskable icons for Android adaptive icons (N108)
The manifest had no purpose:"maskable" icon, so Android cropped a non-safe-zoned
icon (corners clipped / inconsistent shape). Added 192px + 512px maskable icons
(logo centered at ~62% on the app background_color #0a0a0a, inside the 80% safe
zone) generated from the existing logo, and registered them with
purpose:"maskable". They sit beside the existing android-chrome icons and use
the same manifest path convention, so they resolve identically to those.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:25:06 -04:00
jared bab3a160c2 docs: move N95 to Needs-Verification (fixed); update L1 test to verify-mode
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:18:08 -04:00
jared 1778cd0009 fix(calls): release AFK-monitor mic capture when muted (N95)
useAfkAutoMute opened its own getUserMedia capture for the whole call and only
stopped it on unmount, so the OS recording indicator stayed lit even when the
user was muted. The capture is now gated on the reactive mic-on state: it runs
only while unmuted (there's nothing to auto-mute when already muted), so muting
tears down the stream and clears the indicator, and unmuting re-acquires it.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:15:31 -04:00
jared 5204766276 docs: clean up LOTUS_BUGS.md and LOTUS_TODO.md
CI / Build & Quality Checks (push) Successful in 10m32s
CI / Trigger Desktop Build (push) Successful in 7s
Per request, removed completed/resolved items (full history is in git) and
reorganized both into actionable form.

LOTUS_BUGS.md (864 -> 77 lines): dropped ~120 fixed-and-verified entries plus
all false-positive / won't-fix records. Now two clear sections: "Needs
Verification" (fixed in code, awaiting live test, cross-referenced to
LOTUS_TESTING.md) and "Open — Actionable" (grouped by theme).

LOTUS_TODO.md (771 -> 694 lines): removed completed [x] blocks (they live in
LOTUS_FEATURES.md) and consolidated the done-but-untested ones into a single
"Done — Awaiting Verification" index pointing at LOTUS_TESTING.md. Pending
[ ] items and nested roadmaps (e.g. DeepFilterNet/FRCRN under P5-30) were
preserved exactly (verified 42 -> 42); empty sections removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:00:56 -04:00
jared 6218012d3f docs: mark P5-2 + pinned filter done; add M4/M5 test steps
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:58:22 -04:00
jared ccb0c1d18e docs+ci: add Native-Cinny design law; harden npm ci against transient ECONNRESET
- LOTUS_TODO.md: add a "Native-Cinny Law" — every feature must feel like stock
  Cinny (folds primitives + tokens, mirror existing patterns), the sole
  exception being opt-in Lotus Terminal (TDS) features. Links the Cinny repo.
- ci.yml: the last build failed on a transient registry ECONNRESET during
  `npm ci`. Raise npm fetch retries/timeouts and retry `npm ci` up to 3x with
  backoff so a flaky network read no longer fails the whole build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:57:47 -04:00
jared 65e24bd446 feat(themes): 5 new dark theme presets — Cyberpunk/Ocean/Blood Red/Matrix/Midnight (P5-2)
Five complete vanilla-extract themes registered in useTheme (useThemes +
useThemeNames), each spreading darkThemeData so Success/Warning/Critical keep
their semantic colors and only Background/Surface/Primary/Secondary are
recolored. A code-review pass computed WCAG contrast for every theme; all body
and accent pairs clear AA except Midnight's Primary.OnMain which was 4.49:1 —
fixed by changing OnMain #0d1320 -> #000000 (5.07:1).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:47:50 -04:00
jared de6cecaffc feat(search): "Pinned only" filter (composes with msgtype + local results)
Adds a "Pinned" toggle chip that narrows results to messages currently in
their room's m.room.pinned_events. Client-side post-filter mirroring the
has:image/file/video pattern: a pure filterGroupsByPinned(groups, enabled,
isPinned) helper consumes a predicate; MessageSearch builds a per-room
Map<roomId, Set<eventId>> from StateEvent.RoomPinnedEvents.

Review fix: the msgtype + pinned filters are now applied to BOTH the server
results AND the encrypted/local-cache results (via a shared applyResultFilters
useCallback), so the chips narrow the whole UI consistently — previously the
local/E2EE section bypassed them.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 16:47:50 -04:00
jared da545ba9b9 docs: mark P5-1 + search filters/recent done; add M1-M3 test steps
CI / Build & Quality Checks (push) Failing after 1h27m15s
CI / Trigger Desktop Build (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:24:18 -04:00
jared 3c4842df1e feat(settings): custom accent color picker for non-TDS themes (P5-1)
Adds a customAccentColor setting + a HexColorPickerPopOut in Settings →
Appearance. When set (and Lotus Terminal/TDS is OFF), it derives a full folds
Primary palette (Main/hover/active/line, contrasting OnMain, alpha-tiered
Container set, OnContainer) from the chosen color and overrides the folds
Primary CSS variables on document.body — resolving each var name from the
imported folds color.Primary.* token strings (e.g. "var(--oq6d07f)"), the
same body-level injection pattern used for mentionHighlightColor. The theme
class is on document.body, so an inline override on body wins over it.
Reverts to theme defaults when unset or when Lotus Terminal is enabled (TDS
keeps its fixed palette); the picker is disabled with a note in TDS mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:22:08 -04:00
jared 1ee0f0b57a feat(search): has:image/file/video filters + recent searches
- Add three msgtype toggle chips (Images/Files/Video) to the search filter
  bar, mirroring the existing "Has link" chip. The Matrix search API can't
  filter by msgtype server-side, so results are post-filtered client-side
  (union match on event.content.msgtype, dropping now-empty groups); the
  server request is unchanged. Visible count may be lower than the server
  total — inherent to client-side filtering.
- Recent searches: last 10 distinct terms persisted via a new
  state/recentSearches.ts (atomWithStorage, error-safe, mirrors
  scheduledMessages). Shown as clickable chips when the search input is
  focused + empty, with a Clear affordance; clicking re-runs the search.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 14:22:08 -04:00
jared 4fbbd9680b docs(bugs): mark Lotus.png asset optimization FIXED
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:20:28 -04:00
jared 259a5a2b3e perf(assets): optimize Lotus.png logo 213KB -> 20KB
The logo was a 1080x1080 RGBA PNG but is only ever displayed at <=70px
(auth 26px, about 60px, welcome 70px). Resized to 256x256 (generous headroom
for high-DPI) with a Lanczos downscale and compressed via pngquant (q85-100).
No code change — Vite still imports the same path. Original preserved off-tree
during optimization; visually identical at display sizes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 13:20:26 -04:00
jared 8d62be9eff docs(bugs): finish hygiene reconciliation (lodash, setMaxListeners statuses)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:56:56 -04:00
jared 63139350e4 docs(bugs): reconcile hygiene findings (lodash, barrels, Lotus.png, setMaxListeners)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:55:56 -04:00
jared 33b33e685a feat(about): credit Cinny logo + upstream project in Settings → About
The current Lotus Chat icon overlaps the Cinny project logo with the Lotus
Guild emblem, but the in-app Credits list gave Cinny no attribution at all.
Add two credit entries (matching the wording already in README.md):
- the logo as a CC-BY 4.0 derivative of the Cinny logo by Ajay Bura and
  contributors (modified logo © Lotus Guild, also CC-BY 4.0);
- Lotus Chat as a fork of Cinny used under AGPL-3.0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:54:30 -04:00
jared a8038bb534 chore(deps): remove unused direct lodash dependency
lodash was a direct dependency pinned to 4.18.1 but is not imported anywhere
in src/. It remains available transitively (slate-react/slate-dom/inquirer/
commitizen depend on it), so removing the direct declaration changes nothing
at runtime — it just drops an unused, oddly-pinned direct dep. (The audit's
"non-existent version" claim was a false positive: 4.18.1 resolves from the
registry with a valid integrity hash and loads correctly.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:49:40 -04:00
jared 4d0e34c4cf docs(bugs): mark N118 acknowledged (inherent EC-DOM fragility, documented)
CI / Build & Quality Checks (push) Successful in 1h1m1s
CI / Trigger Desktop Build (push) Successful in 9s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:37:34 -04:00
jared 70ffd252bd docs(bugs): mark N100/N106/N109/N119 FIXED
CI / Build & Quality Checks (push) Failing after 30m49s
CI / Trigger Desktop Build (push) Has been skipped
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:35:35 -04:00
jared 51d468fbcc fix(security,notifications): pre class allowlist, notification privacy + icon, sync-script safety (N100/N106/N109/N119)
- N100: restrict <pre> classes to language-* in sanitize-html allowedClasses;
  previously `class` was allowed on <pre> with no allowedClasses entry, so a
  remote sender could inject arbitrary class names that activate site CSS.
- N106: OS notifications for E2EE rooms no longer carry decrypted plaintext
  (which persists in the OS notification center / lock screen). Encrypted rooms
  show only the sender; the in-page toast still previews while focused.
- N109: OS notification icon/badge use the static app logo instead of an
  authenticated-media avatar URL the OS can't fetch (was 401 / no icon). The
  in-app toast keeps the real room avatar (it can fetch via the SW).
- N119: syncDecorations.mjs distinguishes a confirmed 404 (remove) from a
  network/5xx failure (abort) so a transient CDN outage can't silently wipe the
  whole decoration catalog from source control.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 12:35:33 -04:00
jared 1c84556600 docs(bugs): mark N98/N99 FIXED
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:27:39 -04:00
jared 34997bcbd1 fix(client): preserve prefs on logout; recover from initial-sync failure (N98/N99)
- N98: logoutClient and handleLogout now call removeFallbackSession() (removes
  only the 4 session credential keys) instead of window.localStorage.clear(),
  so settings, unsent drafts, PiP position, and status are preserved across a
  normal logout. localStorage.clear() stays reserved for clearLoginData() (the
  explicit factory-reset path).
- N99: the useSyncState callback now handles ERROR/STOPPED. A sync failure
  before the first PREPARED (offline at startup, homeserver unreachable) shows
  a dedicated error splash with a Retry button (startMatrix) instead of an
  endless "Heating up" spinner alongside a contradictory "Connection Lost!"
  banner. Guarded by a hasPreparedRef so post-PREPARED transient errors still
  go through <SyncStatus>; PREPARED self-heals the splash on recovery, and the
  redundant banner is suppressed while the splash is shown.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 11:27:36 -04:00
jared 78cb2acd6c docs(bugs): mark N116/N117/N120/N124/N125/N128 FIXED
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:56:10 -04:00
jared ce8a03ab16 fix(build,denoise): gate node leak, postMessage origin, fail-hard patch, CDN dedup (N124/N125/N128/N120)
- N124: denoise shim cleanup() now disconnects the noise gate AudioWorkletNode
  (var-scoped, guarded), releasing the gate processor thread instead of leaking
  it on every getUserMedia within a session.
- N125: denoise-status postMessage now targets the parent origin (derived from
  the parentUrl widget param via new URL(...).origin, falling back to this
  frame's origin) instead of broadcasting with '*'.
- N128: patch-folds.mjs fails hard (process.exit(1)) when the patch target is
  missing, so an unpatched folds can't silently ship. The idempotent
  "already applied" path still exits 0 (verified by re-run).
- N120: the avatar-decoration CDN URL is now single-sourced in
  avatarDecorations.ts (DECORATION_CDN); syncDecorations.mjs extracts it by
  regex (can't import across the build/app boundary) and fails hard if renamed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:55:19 -04:00
jared 19feca4964 fix(calls): make speaker detection scan full DOM via body observer (N116/N117)
useCallSpeakers rebuilt the speaker Set from only the mutated tiles in each
batch (so a still-speaking participant whose tile didn't mutate was dropped),
and observed a static querySelectorAll NodeList (so tiles for participants who
joined mid-call were never watched). Rewritten to mirror useRemoteAllMuted in
the same file: a single body-level MutationObserver (subtree+childList+attrs)
re-scans ALL [data-video-fit] tiles on each relevant mutation. The speaking
criterion (::before background-image !== 'none') and the id (aria-label +
isUserId) are unchanged, so behavior on real EC DOM is a strict superset.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 10:55:19 -04:00
jared adbda094e7 docs(bugs): mark N113/N114/N115/N122/N123/N126 FIXED
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:18:52 -04:00
jared 7013da70bc fix(reminders): RMW race, reliable removal, stable poll interval (N113/N114/N115)
- N113: mutations compute from a local ref kept in sync with server echoes, and
  writes serialize through a promise queue, so rapid add/remove no longer reads
  a stale baseline and clobbers a prior write.
- N114: ReminderMonitor shows each toast once (firedRef) but retries the
  account-data removal on later ticks if it fails (removingRef released on
  error) — a failed removal no longer permanently swallows the reminder.
- N115: the 30s poll interval reads reminders/mDirects via refs and drops them
  from the effect deps, so it's created once instead of resetting its countdown
  on every reminder sync (which could indefinitely defer a near-due reminder).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:17:19 -04:00
jared 49d9410e3a fix(calls): resolve EC mute hang, robust camera focus, PiP NaN guard (N122/N123/N126)
- N122: setMediaState resolves on EC's transport ACK instead of waiting for a
  DeviceMute state-echo that EC may elide or skip during teardown — which
  previously stranded the promise forever and silently skipped the initial
  deafen state + first StateUpdate on join. Dropped the single-slot
  mediaStatePromiseResolver; onMediaState remains the authoritative sync path.
- N123: focusCameraParticipant now waits for a spotlight videoTile to mount via
  a MutationObserver (with a 600ms hard-timeout fallback) instead of a fixed
  2-frame delay that EC's React commit can exceed on slower devices.
- N126: PiP position restored from localStorage is shape+finiteness validated,
  so corrupt data can't feed NaN into the position math (invalid 'NaNpx' CSS).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 09:17:19 -04:00
jared 84a2e7a93e fix(settings): restore background swatch grid layout; verify N4 poll fix
CI / Build & Quality Checks (push) Successful in 10m30s
CI / Trigger Desktop Build (push) Successful in 11s
- Add grow="Yes" to ChatBgGrid and SeasonalBgGrid containers so they
  expand to fill their flex parent — without it the Box shrank to one
  column (~76px wide) because folds Box defaults to display:flex and
  the wrapper is a flex-row with no explicit width.
- Mark N4 (PollContent) FIXED  VERIFIED in LOTUS_BUGS.md after
  confirmed pass on default Cinny themes and Lotus TDS.
- Mark B1 and B4 PASS in LOTUS_TESTING.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 21:30:21 -04:00
jared 950b8a8128 fix(toast): sticky toasts + improve update notification visibility (P5-40)
Add sticky?: boolean to ToastNotif — sticky toasts skip the 4s auto-dismiss
timer entirely, staying until the user clicks or manually dismisses. Sticky
toasts also use cyan accent/glow (vs orange for messages) and allow the body
to wrap rather than truncate, so longer action-oriented copy is fully readable.

Update the Tauri update toast to: sticky: true, ⬆ prefix on the title,
"Click to install and restart" as explicit call to action.

Fixes: auto-dismiss before user noticed it, no visual distinction from
a regular message notification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 21:04:49 -04:00
jared af58f7a32c docs(audit): Wave 2 audit — 28 new findings across 4 domains (N97–N128)
Security & data persistence (N97–N100): plaintext access token storage
detail, normal logout wiping user prefs via localStorage.clear(), sync
ERROR freezing the loading screen, unrestricted CSS classes on <pre>.

PWA/SW/notifications (N105–N109): missing SW notificationclick + push
handlers, decrypted E2EE message body leaked to OS notification center,
missing maskable PWA icon, auth media URLs producing 401 in notification
icon/badge fetches.

Lotus feature internals (N113–N120, N128): reminder read-modify-write
race, fire-and-forget removeReminder silently drops on network failure,
setInterval restart on every reminder state change, useCallSpeakers
rebuilds speaker set from mutation batch only (drops current speakers),
static NodeList misses mid-call tile additions, CDN outage silently wipes
decoration catalog, CDN URL drift between two source files, patch-folds
silent exit-0 when patch target not found.

Call system & noise suppression (N122–N127): setMediaState Promise hangs
forever if EC omits DeviceMute echo, focusCameraParticipant drops tile
click if spotlight isn't ready in 2 rAFs, denoise cleanup() leaks
AudioWorklet gateNode, postMessage wildcard '*' origin, PiP position
NaN on corrupt localStorage, denoise shim inactive in vite dev.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 20:57:45 -04:00
jared 91c6f2f091 fix(calls): remove misleading Retry button from call load error overlay (N96)
Both Retry and Leave called the same dismiss function; Retry implied a
reconnect attempt that never happened. Collapsed to a single Back button
that honestly describes returning to the prescreen.

docs: correct Gemini audit entries — sanitize-html not DOMPurify (Claim A),
retract inaccurate LiveKit replaceTrack soundboard approach (Claim B,
contradicts confirmed cross-origin iframe constraint), expand N95 fix note
to clarify track-stop vs AudioContext-suspend distinction.

docs(testing): add L1 N95 reproduction guide; update A7 to reflect single Back button.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 16:24:33 -04:00
jared 31cf353463 docs(testing): note EC watchdog self-heal in A7
CI / Build & Quality Checks (push) Successful in 10m26s
CI / Trigger Desktop Build (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:16:22 -04:00
jared 8912423aeb i18n: complete DeviceVerification + PasswordStage dialog translation
Review flagged that wrapping only the buttons left the dialog body copy
hardcoded (mixed-language dialogs once a non-en locale ships). Wrap the
remaining body/waiting strings ("Please accept…", "Confirm the emoji…",
"Do not Match", "Your device is verified.", etc.) and the PasswordStage
prompt, adding hooks to the sub-components that lacked one. Keys added to
en.json; all t() keys verified to resolve.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:15:51 -04:00
jared bc85cd4984 fix(calls,matrix): address review findings from agent code review
- CallEmbed watchdog now SELF-HEALS: a genuine ready/joined signal arriving
  after the 25s timeout clears the error and notifies subscribers with
  undefined, so a slow-but-successful EC load no longer strands the user on
  the recovery screen over a live call. Listener dispatch wrapped in try/catch.
- ringtones: synth notes route through a per-session master gain; stop() ramps
  it to 0 so the ring is silenced instantly on answer instead of letting the
  last scheduled phrase ring out over call audio.
- IncomingCallBanner: ping fires exactly once per incoming call (guarded by
  refEventId) instead of re-pinging when ringtone settings change mid-banner.
- focusCameraParticipant: try multiple tile selectors (EC labels vary by
  version), defer the tile click past EC's async spotlight layout switch
  (rAF x2), and dev-warn when no tile matches so testers get signal.
- uploadContent: a cancelled upload (mx.cancelUpload -> AbortError) is no
  longer treated as retryable — previously the retry loop could resurrect an
  upload the user just cancelled. Also retry on 408.
- addRoomIdToMDirect/removeRoomIdFromMDirect: guard against a corrupt m.direct
  whose values aren't arrays.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 18:15:51 -04:00
jared fc8eb70617 docs(bugs): mark 20 localization rows FIXED
CI / Build & Quality Checks (push) Successful in 10m20s
CI / Trigger Desktop Build (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:43:59 -04:00
jared 1a5896ef84 i18n: localize hardcoded UI strings across 10 components
Wraps the hardcoded strings flagged in LOTUS_BUGS.md (Localization rows)
in t() via react-i18next, and adds the keys to public/locales/en.json
under the existing Organisms.* namespace. de.json intentionally left to
fall back to en for now (fallbackLng: 'en') rather than fabricate
translations.

Files: CreateRoomTypeSelector, ImageViewer, MsgTypeRenderers (MLocation),
Reply (ThreadIndicator), ImageContent, DeviceVerification (5 subcomponents),
UrlPreviewCard (DiscordCard), InviteUserPrompt, UploadBoard, PasswordStage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 17:43:36 -04:00
jared 7b94eeaa60 docs: mark N53/N81/N82 fixed; add F3/G3 visual checks to testing guide
CI / Build & Quality Checks (push) Successful in 10m27s
CI / Trigger Desktop Build (push) Successful in 8s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:29:37 -04:00
jared 50076962f6 fix(ui): collapse PTT badge to single folds Chip (N53); responsive bg pickers (N81)
N53: removed the duplicate lotusTerminal PTT-badge branch (raw <Box> with
--lt-* vars + bespoke rem/animation styling). The standard folds <Chip>
path now renders in all modes; TDS theming still flows through the CSS var
layer. Dropped the now-unused lotusTerminal read.

N81: ChatBgGrid / SeasonalBgGrid containers switched from flex-wrap with
fixed-width cells to a responsive CSS grid (repeat(auto-fill, minmax(76px,
1fr))), so swatches fill the row evenly instead of orphaning a lopsided
last row at arbitrary widths.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:21:33 -04:00
69 changed files with 2061 additions and 1679 deletions
-1
View File
@@ -1,2 +1 @@
VITE_SENTRY_DSN=https://264a5e95c5d31fe080a2e92fb008294d@o4511430568378368.ingest.us.sentry.io/4511430571982849
VITE_APP_VERSION=lotus
+18 -2
View File
@@ -21,14 +21,30 @@ jobs:
cache: npm
- name: Install dependencies
run: npm ci
# Harden against transient registry network failures (ECONNRESET etc.):
# raise npm's built-in fetch retries/timeouts and retry `npm ci` up to
# 3 times with backoff before failing the build.
run: |
npm config set fetch-retries 5
npm config set fetch-retry-mintimeout 20000
npm config set fetch-retry-maxtimeout 120000
npm config set fetch-timeout 600000
for attempt in 1 2 3; do
echo "npm ci attempt $attempt…"
npm ci && break
if [ "$attempt" = "3" ]; then
echo "npm ci failed after 3 attempts" >&2
exit 1
fi
echo "npm ci failed; retrying in $((attempt * 15))s…" >&2
sleep $((attempt * 15))
done
# ── Critical gate — if this fails, nothing deploys ──────────────────
- name: Build
run: npm run build
env:
NODE_OPTIONS: '--max_old_space_size=4096'
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
VITE_APP_VERSION: ${{ github.sha }}
# ── Quality checks (informational — pre-existing issues exist) ───────
+62 -465
View File
@@ -1,490 +1,87 @@
# Lotus Chat — Bug Report & Technical Audit
# Lotus Chat — Open Bugs & Technical Debt
**Date:** June 2026
**Only OPEN and awaiting-verification items live here.** Resolved findings
(fixed-and-verified, false-positives, won't-fix) have been removed to keep this
actionable — the full history is in git. Items fixed in code but not yet
verified in a real environment are in **Needs Verification** below and have
step-by-step checks in [`LOTUS_TESTING.md`](./LOTUS_TESTING.md).
This document tracks identified bugs, edge cases, and architectural discrepancies found during the audit of the Lotus Chat codebase. Recommended fixes are provided for each item.
> Design rules for any fix here: follow the **Native-Cinny Law** and **TDS
> Design Law** in [`LOTUS_TODO.md`](./LOTUS_TODO.md).
---
## 🚩 Critical & UI Bugs
## ⚠️ Needs Verification — fixed in code, awaiting live testing
### 12. PiP Mute Icon Misidentifies Whose Mic Is Muted
Implemented and gate-green; confirm each per `LOTUS_TESTING.md`, then delete the row.
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with at least one other participant who mutes/unmutes
- **Issue:** The muted-mic badge in the Picture-in-Picture window used `useRemoteAllMuted` (fires when ANY remote participant is muted) and rendered in the bottom-left corner — the conventional position for "YOUR" mic status. Users read it as their own mic being muted.
- **Root Cause:** `PipMuteOverlay` was triggering on remote-mute events while displaying in a position that implies local-user status.
- **Fix Applied:**
- **Bottom-left badge** now shows only when the LOCAL user's mic is muted (checked via `!controlState.microphone` from `useCallControlState`). Includes "You" label to make it unambiguous. Uses `color.Critical.Main`.
- **Top-right badge** (new) shows "All muted" in `color.Warning.Main` when all remote participants are muted — positioned and labeled so it's clearly about other people, not the local user.
- Both badges use `aria-label` / `title` for accessibility.
| ID | Item | File / area | Test |
| :--- | :------------------------------------------------------- | :--------------------------------------------------- | :---- |
| #1 | Camera focus during screenshare ("Focus camera" menu) | `CallControl.ts`, `MemberGlance.tsx` | A5 |
| #2 | Chat-background animation flicker (`contain:paint`) | `lotus/chatBackground.ts` | F1 |
| #3 | Avatar decorations on call tiles | `call/CallMemberCard.tsx` | A6 |
| #4 | DM/group ringtone selection + in-call banner | `CallEmbedProvider.tsx`, `ringtones.ts` | A1A4 |
| #6 | Background vs. seasonal theme mutual exclusion | `state/settings.ts`, `General.tsx` | F2 |
| #7 | Composer toolbar touch targets (≥44px) | `room/RoomInput.tsx` | E1 |
| #8 | Room Settings horizontal overflow (mobile) | `components/page/style.css.ts` | E2 |
| #9 | Modal fullscreen on mobile (`useModalStyle`) | 22+ modal files | E3 |
| #10 | Composer not hidden by keyboard (`100dvh`) | `src/index.css` | E4 |
| #12 | PiP mute badge attribution (you vs. all-muted) | `CallEmbedProvider.tsx` | G1 |
| N96 | Call-recovery overlay single "Back" button | `call/CallView.tsx` | A7 |
| N95 | AFK-monitor mic released on mute (OS indicator clears) | `hooks/useAfkAutoMute.ts` | L1 |
| N108 | Maskable PWA icons (Android adaptive) | `public/manifest.json` + `res/android/maskable-*` | L2 |
| EC | EC iframe load watchdog + self-heal + recovery UI | `plugins/call/CallEmbed.ts`, `CallView.tsx` | A7 |
| Gal | MediaGallery lazy-decrypt (true virtualization deferred) | `room/MediaGallery.tsx` | H1 |
| a11y | aria-labels: edit-history / reaction / thread / reply | `message/*` (`FallbackContent`, `Reaction`, `Reply`) | I |
---
### 1. No Camera Focus During Screenshare
## 🔴 Open — Actionable
- **File:** `cinny/src/app/plugins/call/CallControl.ts`, `cinny/src/app/features/call-status/MemberGlance.tsx`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with an active screenshare + a participant on camera
- **Issue:** Automatic screenshare spotlighting forces primary display override, preventing users from manually focusing on camera feeds.
- **Root Cause:** Before this feature there was no UI path to manually pick a camera to focus, so EC's auto-spotlight (which prioritizes an active screenshare) always won.
- **Fix Applied:** `CallControl.focusCameraParticipant(userId)` switches EC to spotlight mode and clicks that participant's `[data-testid="videoTile"]` inside the EC iframe — in Element Call, clicking a tile in spotlight **pins** it, so the user's explicit selection takes precedence over the auto-pinned screenshare. Exposed via a "Focus camera" item in the `MemberGlance` participant menu (avatar → menu). Falls back to a plain spotlight toggle if the tile isn't rendered (e.g. camera off).
- **Architectural note:** EC owns the grid/spotlight renderer inside its iframe; our control is DOM-level tile clicks. The pin persists until changed, so a one-shot focus is sufficient. A continuously-enforced "sticky" focus that re-pins on every EC spotlight change was deliberately **not** built — it would require fighting EC's internal state on each mutation and risks flicker.
### Calls / Audio
### 2. Chat Background Animation Flickering
- **N127 — ML denoise shim is never injected in `vite dev`.** The `lotusDenoise` plugin injects only on `closeBundle` (build), so ML noise suppression is silently inactive during local dev. Add a dev-mode injection (`configureServer` / `transformIndexHtml`). Dev-only impact.
- **File:** `cinny/src/app/features/lotus/chatBackground.ts`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on a real device with an animated background active
- **Issue:** Animated background properties cause visible flickering on message text and the composer area, particularly on browsers/GPUs susceptible to repaint-induced artifacts.
- **Root Cause:** Animation triggers excessive repaints or layout recalculations on descendant elements, likely due to animating non-GPU accelerated properties on parent containers without proper rendering context isolation.
- **Fix Applied:** `getChatBg()` now injects `willChange: 'background-position'` and `contain: 'paint'` for any animated variant. This promotes the element to its own compositor layer and isolates repaints from descendants. Background-position animation is already GPU-hinted on modern browsers; `contain: paint` prevents descendant elements from being invalidated during each frame.
### Security & Privacy
### 3. Avatar Decorations in Element Call
- **N97 — Access token stored in plaintext `localStorage`** (`state/sessions.ts`), vulnerable to XSS; device ID likewise. Architectural — needs a token-protection / session-storage redesign.
- **Session writes are non-atomic and not cross-tab synced** (`state/sessions.ts`) — risks inconsistent state / races across tabs.
- **Persisted PII without encryption:** user status message + expiry (`settings/account/Profile.tsx`), unsent composer drafts (`room/RoomInput.tsx`). Leak risk on shared devices.
- **File:** `cinny/src/app/features/call/CallMemberCard.tsx`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification in a live call with a participant who has a decoration set
- **Issue:** Avatar decorations are failing to render within the call/room interface member lists.
- **Root Cause:** Member lists and the people drawer already wrapped avatars in `<AvatarDecoration userId={...}>`, but the call participant tile (`CallMemberCard`) rendered a bare `<UserAvatar>` with no decoration wrapper — so decorations were absent specifically on call tiles. (Note: avatars rendered _inside_ the Element Call iframe are EC-rendered and out of our control; this fix covers our own participant roster / prescreen.)
- **Fix Applied:** Wrapped the call-tile avatar in `<AvatarDecoration userId={userId}>` (commit `0394fce9`), matching the member-list pattern.
### PWA / Offline / Notifications
### 4. DM and Group Message Calls
- **N105 — Service worker has no `notificationclick` handler** — notification clicks are broken when the tab is closed. Needs `showNotification()` via the SW + a `notificationclick` listener.
- **N107 — SW has no `push` handler** — Web Push delivery is entirely non-functional. Needs a `push` listener + a Matrix push-gateway integration.
- **No app-asset caching strategy** (`src/sw.ts`) — no offline capability.
- **`manifest: false`** in `vite.config.js` — may block correct PWA install if not handled externally.
- **File:** `cinny/src/app/components/CallEmbedProvider.tsx`
- **Status:** **FIXED ⚠️ UNTESTED** — needs live-call verification: (a) ring/preview per selected ringtone & volume; (b) the corner banner appearing (with a single ping, not a loop) when a second call arrives while already in a call.
- **Issue:** Incoming call ringtone is hardcoded, lacks volume control, and is suppressed if the user is already in an active call.
- **Root Cause:** Ringing logic is tightly coupled to `RTCNotification` events in `CallEmbedProvider.tsx`, using a hardcoded audio file path. It lacks an abstraction for sound management or user-configurable settings for ringtones/volumes.
- **Fix Applied:**
- `ringtoneVolume` setting (0100, default 70); applied to the ring. Slider in Settings → General → Calls.
- **(a) Ringtone selection** (`4a875884`): `ringtoneId` setting (`classic | chime | soft | retro | none`). New `utils/ringtones.ts` synthesizes the three styles in-browser (WebAudio, mirroring `callSounds.ts`) — no new binary assets; `classic` keeps `call.ogg`; `none` is silent/visual-only. `startRingtone()` loops until stopped; `previewRingtone()` powers the on-select preview in Settings. Persisted id is whitelisted in `getSettings`.
- **(b) Active-call notification** (`c67aed01`): when already joined to a _different_ call, a compact, non-intrusive `IncomingCallBanner` (caller avatar + name + Answer/Reject, top-right) replaces the full-screen `IncomingCall` overlay and plays a **single soft ping** (one-shot ringtone) instead of the looping ring — so it never takes over the screen or talks over the active call. Full overlay still shows when in no call; being in the ringing room's own call still shows nothing.
### Dependencies & Build
### 5. Seasonal Themes and Chat Backgrounds Design
- **`matrix-js-sdk` pinned to a Release Candidate** (`41.6.0-rc.0`); `@atlaskit` and build tools (`vite`, `typescript`, `eslint`) on unstable/experimental pins — review for stable versions; RC SDK is a tree-shaking/bundle-size risk.
- **Build-time overhead:** `lotusDenoise` does heavy sequential `fs` work in `closeBundle`; `viteStaticCopy` config is complex with redundant renames — could be streamlined.
- **File:** `cinny/src/app/hooks/useTheme.ts`, `cinny/src/app/features/lotus/chatBackground.ts`
- **Status:** **OPEN**
- **Issue:** Basic CSS or random moving lines are insufficient for high-fidelity wallpaper/theming. They lack professional design theory, coherence, and aesthetic depth.
- **Root Cause:** Current implementation relies on basic CSS, lacks advanced design theory, and does not leverage modern, performant CSS wallpaper techniques.
- **Proposed Fix (Extreme Depth Redesign):**
- **Research-Backed Implementation:** Implement advanced design techniques (layered `oklch` gradients, `backdrop-filter` for refractive "liquid glass" effects, GPU-accelerated `transform` animations) to create living, breathing backgrounds.
- **Performance Optimization:** Ensure all animations strictly use compositor-thread properties (`transform`, `opacity`) and apply `contain: paint` / `will-change: transform` to prevent layout thrashing/flickering.
- **Design Resources (Examples/Inspiration):**
- [Uiverse.io Patterns](https://uiverse.io/patterns)
- [MagicPattern CSS Backgrounds](https://www.magicpattern.design/tools/css-backgrounds)
- [Prismic Blog: CSS Background Effects](https://prismic.io/blog/css-background-effects)
- [CSS-Pattern.com](https://css-pattern.com) (Pure CSS pattern library)
- [BGJar](https://bgjar.com) (Performance-focused generators)
- **Goal:** Treat each theme/background as a week-long development sprint to ensure professional polish, WCAG AA contrast compliance for overlaying UI, and seamless integration with the Lotus TDS.
### Code Hygiene / DevEx
### 6. Exclusive Background vs. Seasonal Choice
- **No automated test suite** (`src/`) — no unit/integration tests configured.
- **Extensive `as any` casts** across `src/` — gradual typing cleanup.
- **`types/matrix/` mirrors SDK types** instead of importing them — drift risk.
- **Hardcoded CDN URL** should move to an env var (the decoration CDN is now single-sourced in `avatarDecorations.ts`, but the literal is still in-repo).
- **`patch-folds.mjs` edits `node_modules` directly** — consider `patch-package`.
- **Infra docs:** `contrib/nginx` lacks security headers (HSTS/CSP) + uses rewrites over `try_files`; `contrib/caddy` has a placeholder path. CI/CD (`prod-deploy.yml`): sequential deploy, aggressive 1-min Netlify timeout, `package-manager-cache: false`.
- **README:** keep the fork-sync version + logo path current. (`CONTRIBUTING.md` is intentionally left as upstream Cinny's — not a Lotus concern.)
- **Architecture notes (low priority):** deep `features/` + `hooks/` nesting, many small coupled hooks, possible dead CSS/components, `SpacingVariant` / `DropTarget` recipe simplification.
- **Git workflow (forward-looking):** keep commits scoped — past monolithic "fix all bugs" commits and inconsistent prefixes hurt `git bisect`.
- **File:** `cinny/src/app/state/settings.ts`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification: (a) pick a background, confirm seasonal theme auto-clears; (b) pick a seasonal theme, confirm background auto-clears; (c) set both via old localStorage data and reload, confirm SeasonalEffect guard suppresses the overlay
- **Issue:** Concurrent application of both Chat Backgrounds and Seasonal Themes causes visual clutter and high GPU usage.
- **Root Cause:** These are currently handled as independent settings in the `settingsAtom` and applied simultaneously without mutual exclusion.
- **Fix Applied:** Mutual exclusion enforced at two layers: (1) `General.tsx` — ChatBgGrid clears seasonalThemeOverride→'off' when any non-'none' background is picked; SeasonalBgGrid clears chatBackground→'none' when any real seasonal theme is selected. (2) `SeasonalEffect.tsx` — runtime guard returns null if `chatBackground !== 'none'`, protecting against legacy persisted state.
### Native-Cinny polish (remaining from the design-law audit)
### 7. Tiny Touch Targets in Composer Toolbar
The "renders-broken-on-stock-themes" cluster (ungated invented CSS vars across
~13 files + the toast rebuild) is fixed; Sentry was removed. Lower-priority
pattern items left:
- **File:** `cinny/src/app/features/room/RoomInput.tsx`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on a real mobile device: open composer, confirm all toolbar buttons are tappable without mis-taps
- **Issue:** Toolbar buttons have hit areas smaller than the WCAG-recommended 44x44px for touch, hindering mobile accessibility.
- **Fix Applied:** Added `touchTarget = { minWidth: '44px', minHeight: '44px' }` computed from `mobileOrTablet()` and applied as `style={touchTarget}` to all 8 composer toolbar `IconButton` elements (attach, format, sticker, emoji, GIF, location, poll, schedule, send).
- **Profile timezone `<select>`** (`settings/account/Profile.tsx`) — still a raw native select (`colorScheme:'dark'`); it's wired to native form submission + a disabled state, so converting to `SettingsSelect` needs care.
- **MediaGallery lightbox** (`room/MediaGallery.tsx`) — raw `<div role="dialog">` + `#fff`/rgba chrome over forced-black media. Should be folds `Overlay`/`Modal`; the over-media light-on-dark scheme is a borderline-justified scrim.
- **Nits:** scattered `opacity:``priority`, the poll `✓` Unicode glyph → folds `Icon`, a few `zIndex` magic numbers.
### 8. Horizontal Overflow in Room Settings
### Big Projects
- **File:** `cinny/src/app/components/page/style.css.ts`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification: open Room Settings on a narrow mobile screen, confirm nav panel fills full width and no horizontal scrollbar appears
- **Issue:** Wide tables and input elements in room settings cause horizontal overflow on mobile viewports.
- **Fix Applied:** Added `@media (max-width: 750px) { width: '100%' }` to both `'400'` and `'300'` size variants of the `PageNav` vanilla-extract recipe in `style.css.ts`.
### 9. Modal Float-Style Responsiveness
- **File:** Multiple modal files
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification by opening each modal on a real mobile device
- **Issue:** Modals appear as floating boxes on mobile, creating navigation and readability challenges.
- **Fix Applied:** Created `useModalStyle(desktopMaxWidth)` hook (`src/app/hooks/useModalStyle.ts`) that returns fullscreen styles on mobile (no border-radius, no max-width, `height: 100%`) and desktop box styles otherwise. Applied to all 22+ modal files: `LeaveRoomPrompt`, `LeaveSpacePrompt`, `ReportRoomModal`, `ReportUserModal`, `DeviceVerification`, `InviteUserPrompt`, `LogoutDialog`, `DeviceVerificationSetup`, `DeviceVerificationReset`, `JoinAddressPrompt`, `JumpToTime`, `EditHistoryModal`, `ForwardMessageDialog`, `RemindMeDialog`, `CreateRoomModal`, `CreateSpaceModal`, `ScheduleMessageModal`, `PollCreator`, `AddExistingModal`, `RoomEncryption`, `RoomUpgrade`, `Modal500`, `ReadReceiptAvatars`, `RoomTopicViewer`.
- **Note:** `UIAFlowOverlay` already fullscreen via `<Overlay>` — no change needed. `JoinRulesSwitcher`/`RoomNotificationSwitcher` are dropdowns, not modals.
### 10. Composer Keyboard Obscurity
- **File:** `src/index.css`
- **Status:** **FIXED ⚠️ UNTESTED** — needs verification on iOS Safari specifically (the worst offender); on Android Chrome `100dvh` has been standard since Chrome 108
- **Issue:** The chat composer is often partially or fully obscured by the virtual keyboard on mobile.
- **Fix Applied:** Added `height: 100dvh` (dynamic viewport height) to `html` alongside the existing `height: 100%` fallback. `dvh` updates when the software keyboard appears, ensuring the layout shrinks correctly and the composer stays visible.
### 11. Inline Jotai atom creation
- **File:** `cinny/src/app/hooks/useSpaceHierarchy.ts`
- **Status:** **FALSE POSITIVE — CLOSED**
- **Issue:** Inline Jotai atom creation in a hook risks re-rendering components unnecessarily.
- **Resolution:** `useState(() => atom(...))` IS the correct Jotai pattern for local stable atom references. The factory function form of `useState` ensures the atom is created only once per component mount. No change warranted.
---
## 📦 Barrel File Audit
| File Path | Note | Status |
| :------------------------------------------ | :------------------------- | :----- |
| `cinny/src/app/plugins/call/index.ts` | Extensive `export *` usage | OPEN |
| `cinny/src/app/plugins/text-area/index.ts` | Extensive `export *` usage | OPEN |
| `cinny/src/app/components/message/index.ts` | Extensive `export *` usage | OPEN |
---
## 🔍 Technical & Performance Refinements
| Category | Issue Description | File Path | Status |
| :---------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------- | :---------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| State Sync | Fire-and-forget network call to set offline presence during `pagehide` event may not complete reliably, potentially causing UI drift in presence status. | `cinny/src/app/hooks/usePresenceUpdater.ts` | FIXED (`d2946c00`) — unload path now uses `fetch({ keepalive: true })` so the request survives page teardown (`sendBeacon` was unusable here: it can't set the auth header). |
| State Sync | Fire-and-forget network call `setPresence().catch(...)` suppresses errors, meaning the app may falsely assume presence update success. | `cinny/src/app/hooks/usePresenceUpdater.ts` | FIXED (`d2946c00`) — errors are now surfaced via `warnPresenceFailure` (redacted logging) instead of being silently swallowed. |
| Memory Leak | Decrypted Media Memory Leak (Gallery & Lightbox) due to missing virtualization and blob revocation. | `cinny/src/app/features/room/MediaGallery.tsx` | PARTIALLY FIXED ⚠️ UNTESTED — Blob revocation was already correct; added `enabled` param to `useDecryptedMediaUrl` and `useNearViewport(300px)` to each `GalleryTile` to gate decryption until near-viewport, reducing burst on pagination. True virtualization (windowing) deferred — requires significant refactor. |
| Data Persistence | Scheduled Messages are ephemeral (lost on refresh) due to fragile `localStorage` parsing. | `cinny/src/app/state/scheduledMessages.ts` | FIXED — now uses `atomWithStorage` + `createJSONStorage` (Jotai's built-in persistence with error-safe JSON parsing) |
| Memory Leak | Potential memory leak due to uncleaned `handleMouseMove` listener in `usePan`. | `cinny/src/app/hooks/usePan.ts` | FALSE POSITIVE — `usePan` already uses `attachedRef` to track listeners and cleans them up in an unmount `useEffect`. No change needed. |
| Asset Optimization | Large unoptimized media asset (213KB) found in `public/res`. | `public/res/Lotus.png` | OPEN |
| Data Persistence | Non-atomic `localStorage` updates in session management can lead to inconsistent state. | `cinny/src/app/state/sessions.ts` | OPEN |
| Data Persistence | Lack of cross-tab synchronization for `localStorage` updates in session management risks race conditions. | `cinny/src/app/state/sessions.ts` | OPEN |
| Network Resilience | `uploadContent` lacks retry logic, failing immediately upon network error. | `cinny/src/app/utils/matrix.ts` | FIXED (`d2946c00`) — bounded retry (`UPLOAD_MAX_RETRY_COUNT=3`) gated by `isRetryableUploadError` (transient/network/5xx/429 only, not 4xx), reusing the `rateLimitedActions` capped-exponential backoff. |
| Network Resilience | `rateLimitedActions` uses basic retry logic without exponential backoff, which may exacerbate 429 issues. | `cinny/src/app/utils/matrix.ts` | FIXED — fallback delay now uses capped exponential backoff (`min(1000 * 2^retryCount, 30_000)ms`) when server doesn't send `Retry-After`; server header still takes precedence via `getRetryAfterMs()`. |
| Matrix Event Robustness | `useMatrixEventRenderer` handles unknown events gracefully by returning `null`, which may hide potentially important unrendered data. | `cinny/src/app/hooks/useMatrixEventRenderer.ts` | FALSE POSITIVE — returning `null` for unrendered types is the intended contract. Callers opt into rendering unknowns via the `renderStateEvent` / `renderEvent` fallback params; `null` only results when the caller deliberately supplies no fallback. No change warranted. |
| Data Contract | `MatrixError` instantiation with `UploadResponse` might be brittle. | `cinny/src/app/utils/matrix.ts` | FIXED (`d2946c00`) — replaced the brittle direct construction with `matrixErrorFromUploadResponse` / `matrixErrorFromUnknown` guards that validate shape before building a `MatrixError`. |
| Type Safety | `addRoomIdToMDirect` uses `as any` cast for `AccountDataEvent.Direct`, bypassing type contract validation. | `cinny/src/app/utils/matrix.ts` | FIXED (`d2946c00`) — `addRoomIdToMDirect` / `removeRoomIdFromMDirect` now use `EventType.Direct` + a typed `MDirectContent`, dropping the `as any` cast. |
| Robustness | `rateLimitedActions` relies on `MatrixError.httpStatus` which might not exist on all error variants. | `cinny/src/app/utils/matrix.ts` | FALSE POSITIVE — `MatrixError.httpStatus` is defined as `readonly httpStatus?: number` in `matrix-js-sdk/lib/http-api/errors.d.ts`. It is optional (not on all instances) but the `?.` optional chain already guards against undefined. No change needed. |
| Type Contract | Custom types in `cinny/src/types/matrix` mirror SDK types instead of using them, risking drift and contract mismatches. | `cinny/src/types/matrix/` | OPEN |
## 🏗️ Architectural & Hygiene Audit
| Category | Issue Description | File Path | Status |
| :------- | :--------------------------------------------------------------- | :-------- | :----- |
| Hygiene | No stale development notes or TypeScript strictness issues found | N/A | OPEN |
---
## 🏗️ TDS Compliance & Styling Issues
| Issue Description | File Path |
| :------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Hardcoded inline style `cursor: 'pointer'` | `cinny/src/app/plugins/react-custom-html-parser.tsx` |
| Hardcoded color `#00D4FF`, `#FFB300`**VERIFIED COMPLIANT** | `cinny/src/app/components/event-readers/EventReaders.tsx` |
| Hardcoded color `#EE1D52`, `#9146ff`, `#ff4500`, `#cb3837`, `#f48024` ⚠️ **BRAND EXCEPTION** | `cinny/src/app/components/url-preview/UrlPreviewCard.tsx` + `UrlPreview.css.tsx` — official third-party brand colors in SVG logos and site badge backgrounds; cannot convert to CSS variables without inventing new tokens (violates TDS rule 3) |
| Massive number of hardcoded `backgroundColor` values ⚠️ **PATTERN CONTENT EXCEPTION** | `cinny/src/app/features/lotus/chatBackground.ts` — each background's base color is aesthetic content that defines the pattern identity; converting requires inventing 40+ CSS variables (violates TDS rule 3) or using CSS4 `relative-color-syntax` in inline styles (insufficient browser support); these are visual content, not UI chrome |
| Hardcoded colors `#00FF88`, `#FF6B00`**VERIFIED COMPLIANT** | `cinny/src/app/features/call/CallControls.tsx` |
| Hardcoded fallback hexes in toast colors ✅ **FIXED** | `cinny/src/app/features/toast/LotusToastContainer.tsx` |
---
## 🌐 Localization, Accessibility & Performance
| Category | Issue Description | File Path | Status |
| :------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :------------------------------------------------------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Localization | Hardcoded UI string: "Chat Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
| Localization | Hardcoded UI string: "Messages, photos, and videos." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
| Localization | Hardcoded UI string: "Voice Room" | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
| Localization | Hardcoded UI string: "Live audio and video conversations." | `src/app/components/create-room/CreateRoomTypeSelector.tsx` | OPEN |
| Localization | Hardcoded UI string: "Download" | `src/app/components/image-viewer/ImageViewer.tsx` | OPEN |
| Localization | Hardcoded UI string: "Open Location" | `src/app/components/message/MsgTypeRenderers.tsx` | OPEN |
| Localization | Hardcoded UI string: "Thread" | `src/app/components/message/Reply.tsx` | OPEN |
| Localization | Hardcoded UI string: "View" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
| Localization | Hardcoded UI string: "Spoiler" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
| Localization | Hardcoded UI string: "Retry" | `src/app/components/message/content/ImageContent.tsx` | OPEN |
| Localization | Hardcoded UI string: "Close" | `src/app/components/DeviceVerification.tsx` | OPEN |
| Localization | Hardcoded UI string: "Accept" | `src/app/components/DeviceVerification.tsx` | OPEN |
| Localization | Hardcoded UI string: "They Match" | `src/app/components/DeviceVerification.tsx` | OPEN |
| Localization | Hardcoded UI string: "Okay" | `src/app/components/DeviceVerification.tsx` | OPEN |
| Localization | Hardcoded UI string: "Join Server" | `src/app/components/url-preview/UrlPreviewCard.tsx` | OPEN |
| Localization | Hardcoded UI string: "Invite" | `src/app/components/invite-user-prompt/InviteUserPrompt.tsx` | OPEN |
| Localization | Hardcoded UI string: "Files" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
| Localization | Hardcoded UI string: "Send" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
| Localization | Hardcoded UI string: "Upload Failed" | `src/app/components/upload-board/UploadBoard.tsx` | OPEN |
| Localization | Hardcoded UI string: "Password" | `src/app/components/uia-stages/PasswordStage.tsx` | OPEN |
| Bundle Size | Large unoptimized media asset (213KB) | `public/res/Lotus.png` | OPEN |
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/features/lobby/Lobby.tsx` | FALSE POSITIVE — `Lobby` already routes its render loop through the memoized `useGetRoom(allJoinedRooms)`. The two remaining `mx.getRoom()` calls are inside drag/drop event handlers (not render loops) and are O(1) SDK map lookups. No change warranted. |
| Matrix Logic | Inefficient repeated `mx.getRoom()` calls in component render loops | `src/app/components/emoji-board/EmojiBoard.tsx` | FIXED (`b7e1f89c`) — pack-label `mx.getRoom()` lookups in `EmojiSidebar`/`StickerSidebar` hoisted into a `useMemo`'d `Map` built once per pack list. |
| Performance | Numerous event handlers (e.g., handleUserClick, handleReplyClick) lack `useCallback`, leading to unnecessary re-renders of message components. | `cinny/src/app/features/room/RoomTimeline.tsx` | FIXED (`b7e1f89c`) — `handleJumpToLatest`/`handleJumpToUnread`/`handleMarkAsRead` wrapped in `useCallback`. |
| Performance | The `submit` function and file handling callbacks (e.g., handleSendUpload) are re-created on every render, causing re-renders of the editor and toolbar components. | `cinny/src/app/features/room/RoomInput.tsx` | FIXED (`b7e1f89c`) — `handleCancelUpload`/`handleSendUpload`/`handleShareLocation`/`handleEmoticonSelect`/`handleStickerSelect` wrapped in `useCallback`. |
| Accessibility | `button` for edit history lacks `aria-label` | `cinny/src/app/components/message/content/FallbackContent.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View edit history"` |
| Accessibility | `button` for reaction lacks `aria-label` | `cinny/src/app/components/message/Reaction.tsx` | **FIXED ⚠️ UNTESTED**`Reaction` component now computes `aria-label="{shortcode} reaction, N people"` internally using `getShortcodeFor`; custom (mxc://) emoji falls back to "custom emoji reaction". |
| Accessibility | `button` for ThreadIndicator lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="View thread"` |
| Accessibility | `button` for ReplyLayout lacks `aria-label` | `cinny/src/app/components/message/Reply.tsx` | FIXED ⚠️ UNTESTED — added `aria-label="Jump to original message"` |
---
## 🔧 Infrastructure, DevEx & Type Safety
| Category | Issue Description | File Path | Status |
| :------------- | :----------------------------------------------------------------------------------------------------------- | :--------------------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Dependencies | `lodash` pinned to non-existent version `4.18.1` | `cinny/package.json` | OPEN |
| Dependencies | Various pinned versions of `@atlaskit`, `matrix-js-sdk` | `cinny/package.json` | OPEN |
| Dependencies | `matrix-js-sdk` pinned to Release Candidate (`41.6.0-rc.0`) | `cinny/package.json` | OPEN |
| Dependencies | Unstable/experimental versions for build tools (`vite` 8.0.14, `typescript` 6.0.3, `eslint` 9.39.4) | `cinny/package.json` | OPEN |
| CI/CD | `package-manager-cache` set to `false` | `cinny/.github/workflows/build-pull-request.yml` | OPEN |
| CI/CD | Inefficient sequential execution in deployment | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
| CI/CD | Aggressive 1-minute timeout for Netlify deploy | `cinny/.github/workflows/prod-deploy.yml` | OPEN |
| DevEx | Stale upstream bug tracker link/donations/CLA | `cinny/CONTRIBUTING.md` | OPEN |
| DevEx | Alignment issue between README and CONTRIBUTING | `cinny/README.md` | OPEN |
| Testing | No evident automated testing configuration/files | `cinny/src/` | OPEN |
| Type Safety | Extensive use of `as any` type assertions | `cinny/src/` | OPEN |
| Security | Hardcoded public CDN URL; consider moving to environment variable | /root/code/cinny/scripts/syncDecorations.mjs | OPEN |
| Architecture | Modifying node_modules directly is brittle; use patch-package instead | /root/code/cinny/scripts/patch-folds.mjs | OPEN |
| Robustness | Missing security headers (HSTS, CSP, etc.) and inefficient asset serving using rewrites instead of try_files | /root/code/cinny/contrib/nginx/cinny.domain.tld.conf | OPEN |
| Robustness | Incomplete documentation/placeholder path in Caddyfile | /root/code/cinny/contrib/caddy/caddyfile | OPEN |
| Matrix SDK | Inefficient listener management (`setMaxListeners: 150`) and incomplete SDK state transition handling. | `src/client/initMatrix.ts` | OPEN |
| PWA Robustness | Service worker lacks caching strategy for application assets, resulting in no offline capability. | `cinny/src/sw.ts` | OPEN |
| PWA Integrity | `manifest: false` in `vite.config.js` might prevent correct PWA installation if not handled externally. | `cinny/vite.config.js` | OPEN |
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/plugins/call/CallEmbed.ts` | VERIFIED COMPLIANT — reviewed during the logging pass (`203568c9`); the existing log path already records only `e.message`, not raw event payloads. No change needed. |
| PII Leakage | Potential PII exposure via console.warn (parameter imgError/videoError/thumbError object). | `cinny/src/app/features/room/msgContent.ts` | FIXED (`203568c9`) — media-error warnings now log only `error.name` + `error.message`, never the raw error/event object. |
| PII Leakage | Potential PII exposure via console.error (parameter e likely contains event data). | `cinny/src/app/features/room/RoomInput.tsx` | VERIFIED COMPLIANT — reviewed during the logging pass (`203568c9`); the existing log path already records only `e.message`. No change needed. |
## 🏗️ Architectural & Resilience Audit
| Category | Issue Description | File Path | Status |
| :----------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Element Call Integration | Lacks robust iframe failure monitoring beyond initial 'preparing' event; can result in a permanently hung 'Loading...' state with no user-visible error or recovery path. | `src/app/plugins/call/CallEmbed.ts` | FIXED (`0394fce9`) — added a `CALL_LOAD_WATCHDOG_MS` (25s) timeout that settles on ready/capabilities/joined and fails on iframe error/timeout, exposing a `loadFailed` getter + `onLoadError(cb)`. `CallView` renders a `CallLoadErrorMessage` overlay (Retry/Leave) instead of a permanent spinner. ⚠️ UNTESTED — needs a live call. |
| Component Resilience | `RoomTimeline` has no `ErrorBoundary` wrapper — a single malformed event crashing the renderer takes down the entire timeline with no fallback UI. | `src/app/features/room/RoomTimeline.tsx` | FALSE POSITIVE — `RoomView.tsx` (lines 113137) already wraps `<RoomTimeline>` in a react-error-boundary `ErrorBoundary` with a "Timeline unavailable" fallback. A wave-1 agent's redundant nested boundary was reverted. No change needed. |
| Component Resilience | `RoomInput` has no `ErrorBoundary` wrapper — a crash in the composer leaves users unable to send messages. | `src/app/features/room/RoomInput.tsx` | FALSE POSITIVE — `RoomView.tsx` (lines 151171) already wraps `<RoomInput>` in an `ErrorBoundary` with a "Message composer encountered an error" `RoomInputPlaceholder` fallback. No change needed. |
| Fallback Logic | No explicit empty/error fallback for Matrix SDK data calls in `RoomTimeline`; relies purely on SDK internal error propagation, meaning silent failures show a blank timeline. | `src/app/features/room/RoomTimeline.tsx` | ADDRESSED — the `RoomView` `ErrorBoundary` (above) provides the explicit render-error fallback; a thrown SDK/render error now surfaces "Timeline unavailable" rather than a blank timeline. |
| Dependency | Potential for complex dependency chains due to deep nesting in `src/app/features/` and `src/app/hooks/`. | `src/app/` | OPEN |
| Hydration/Race Condition | The SyncState listener registered by useSyncState may miss the initial 'PREPARED' event if the client initializes synchronously from IndexedDB before the effect runs, leading to an infinite loading state. | `cinny/src/app/pages/client/ClientRoot.tsx` | OPEN |
| Structure | High number of small, highly coupled utility hooks (`src/app/hooks/`) may obscure dependency graphs. | `src/app/hooks/` | OPEN |
| Dead Code | Potential for unused CSS modules or UI components in `src/app/features/`. | `src/app/` | OPEN |
| Security | Sensitive session data (access tokens, device ID) stored in `localStorage` is vulnerable to XSS. | `src/app/state/sessions.ts` | OPEN |
| Privacy | Sensitive user status messages and expiry timestamps are persisted in `localStorage`. | `src/app/features/settings/account/Profile.tsx` | OPEN |
| Privacy | Unsent composer drafts stored in `localStorage` without encryption could leak info on shared devices. | `src/app/features/room/RoomInput.tsx` | OPEN |
| Persistence | Scheduled messages relying on fragile `localStorage` parsing are prone to data loss on session expiry or error. | `src/app/state/scheduledMessages.ts` | OPEN |
| Bundle Bloat | Inefficient `lodash` import; risks including entire library instead of necessary utilities. | `cinny/package.json` | OPEN |
| Bundle Bloat | Large `matrix-js-sdk` (RC version) dependency; high potential for tree-shaking overhead. | `cinny/package.json` | OPEN |
| Build-Time Overhead | `lotusDenoise` plugin performs heavy, sequential `fs` operations during `closeBundle`, significantly slowing build times. | `cinny/vite.config.js` | OPEN |
| Build-Time Overhead | Complex manual `viteStaticCopy` configuration requiring multiple renames and path manipulations; risks redundant processing. | `cinny/vite.config.js` | OPEN |
| Architectural Debt | Redundant style variant logic in `SpacingVariant` could be simplified. | `cinny/src/app/components/message/layout/layout.css.ts` | OPEN |
| Overhead Analysis | Potential CSS bloat from `DropTarget` composition across multiple recipes (`SidebarItem`, `SidebarFolder`). | `cinny/src/app/components/sidebar/Sidebar.css.ts` | OPEN |
## 🏗️ Git Workflow & History Audit
| Category | Issue Description | File Path | Status |
| :------- | :------------------------------------------------------------------------------------------------------ | :---------- | :----- |
| Workflow | Monolithic "Fix all bugs" commits (e.g., `10f6544e`, `aa48c9ef`) make `git bisect` difficult. | Git History | OPEN |
| Workflow | Inconsistent commit message prefixes (e.g., `fix`, `feat`, `docs`, `assets`). | Git History | OPEN |
| Workflow | Use of `fix` or `feat` for large-scale changes affecting multiple disparate systems (e.g., `938ead79`). | Git History | OPEN |
---
## 🎨 Native UI/UX Consistency — Lotus vs. Cinny Baseline
> Audit of every Lotus-custom UI feature against Cinny's native folds design-system conventions. "Native pattern" means the `folds` component library, vanilla-extract tokens (`color.*`, `config.radii.*`, `config.space.*`), and established Cinny component patterns. 52 findings, organized by severity.
---
### 🔴 Major — Broken Styling / Functional Regressions
#### N1. `ProfileDecoration` Save Button — Undefined `--accent-cyan` Variable (border invisible on all non-TDS themes)
- **File:** `src/app/features/settings/account/ProfileDecoration.tsx`, lines 191213
- **Status:** **FIXED** — replaced raw `<button>` with `<Button size="400" variant="Success" fill="Solid" radii="300">`, removed undefined `--accent-cyan` reference
- **Issue:** The save button is a raw `<button>` with `border: '1px solid var(--accent-cyan)'` and `color: 'var(--accent-cyan)'`. The variable `--accent-cyan` (without the `--lt-` prefix) is never defined in any theme file — the correct prefixed form is `--lt-accent-cyan`. On all non-TDS themes the border is **invisible** and the text has no color.
- **Root Cause:** Missing `--lt-` prefix. Additionally, the raw `<button>` should be a folds `<Button>` to match every other save button in the same `Profile.tsx` settings panel (e.g., `ProfileDisplayName` save at `Profile.tsx:303`).
- **Fix:** Replace raw `<button>` with `<Button size="400" variant="Success" fill="Solid" radii="300">`. Remove the `--accent-cyan` reference.
#### N2. `UserPrivateNotes` Textarea — Undefined `--border-interactive` Variable (border invisible on all themes)
- **File:** `src/app/components/user-profile/UserRoomProfile.tsx`, lines 246265
- **Status:** **FIXED** — replaced undefined CSS vars with `color.SurfaceVariant.ContainerLine`, `config.radii.R300`, `config.space.S200/S300`
- **Issue:** The notes textarea sets `border: '1px solid var(--border-interactive)'`. This variable is never defined anywhere in the codebase — the correct equivalents are `--bg-surface-border` (`src/index.css`) or `color.SurfaceVariant.ContainerLine` (folds token). The border is **invisible on all themes**.
- **Root Cause:** Invented CSS variable name. Also uses raw pixel sizing (`borderRadius: '6px'`, `padding: '8px 10px'`, `fontSize: '14px'`) instead of folds tokens.
- **Fix:** Replace inline style with `border: \`1px solid ${color.SurfaceVariant.ContainerLine}\``, `borderRadius: config.radii.R300`, `padding: config.space.S200`.
#### N3. `LotusToastContainer` — Z-Index Places Toasts Below Night Light Overlay and All Modals
- **File:** `src/app/features/toast/LotusToastContainer.tsx`, lines 184211; `src/app/pages/App.tsx`
- **Status:** **FIXED** — raised toast `zIndex` from `9997` to `10001` (above Night Light at 9998 and modals at 9999)
- **Issue:** The toast container uses hardcoded `zIndex: 9997`. The Night Light overlay is at `z-index: 9998`. The folds `Overlay`/`Dialog` components used for all modals resolve to `z-index: 9999`. Result: (a) toasts render **under** the Night Light tint and take on the warm orange filter; (b) any open modal covers toasts entirely, making notifications invisible.
- **Root Cause:** The toast container does not use the `folds` `OverlayContainerProvider` portal that manages z-index correctly — it is a plain `position: fixed` div injected directly in `App.tsx`.
- **Fix:** Either route the toast portal through `OverlayContainerProvider` (matching how all other floating UI works), or raise `zIndex` above all overlay layers (10001+). Also audit Night Light's z-index (9998) relative to toasts.
#### N4. `PollContent` Vote Buttons — Entirely Outside the Folds Design System
- **File:** `src/app/components/message/content/PollContent.tsx`
- **Status:** **FIXED ⚠️ UNTESTED** (`caf6318a`) — needs verification: create a poll, then view/vote on it under a **non-TDS theme** (e.g. default Cinny dark/light) and confirm borders, selected state, and progress fill are all visible.
- **Issue:** Each poll answer is a native `<button>` with ~15 hardcoded inline style properties using undefined CSS variables (`--accent-cyan`, `--accent-cyan-dim`, `--accent-cyan-border`, `--border-color`). Checkbox/radio indicators, percentage spans, and the poll label used raw pixel/rem font sizes (`0.68rem`, `0.78rem`, `0.88rem`) and hardcoded `rgba()`/`#fff`. None of those vars exist outside TDS mode — the component rendered unstyled (invisible borders / no selected/progress state) on every non-TDS theme.
- **Root Cause:** Custom implementation that bypassed folds tokens entirely.
- **Fix Applied:** Kept the `<button>` structure (the progress-bar-behind-text affordance has no folds `Button` equivalent) but made every value theme-reactive: `color.Primary.*` for selected/indicator state, `color.SurfaceVariant.*` for the resting surface + progress fill, `config.*` for radii/spacing/border-width, and folds `<Text>` for the option label, percentage, and section label (dropping the raw rem sizes and `opacity` hacks). The duplicate checkbox/radio indicator spans were merged into one.
---
### 🟠 Moderate — Interaction Pattern or Visual Deviations
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :------------------------- | :---------------------------------------- | :---------------- | :---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N5 | Read Receipts | `ReadReceiptAvatars.tsx` | 62137 | Trigger button is raw `<button>` with `onMouseEnter`/`onMouseLeave` JS style mutation for hover state — **FIXED**: hover/focus emphasis moved to co-located `ReadReceiptAvatars.css.ts` (`:hover`/`:focus-visible`), no JS `.style` mutation | All interactive elements use `useHover` from `react-aria` and folds variant system for hover; direct `.style` mutation used nowhere else on buttons |
| N6 | Read Receipts | `ReadReceiptAvatars.tsx` & `Message.tsx` | 3256 / 268283 | Two code paths open `EventReaders`: avatar-pill path uses `useModalStyle(360)` for mobile fullscreen; context-menu path (`MessageReadReceiptItem`) does not — on mobile the context menu path opens a fixed-size non-fullscreen modal for the same content | All modals that share a layout variant use `useModalStyle` consistently; `MessageReadReceiptItem` was not updated when `useModalStyle` was added |
| N7 | Delivery Status | `Message.tsx` | 89148 | `DeliveryStatus` renders Unicode glyphs (`⟳ ✓ ✕`) in a `<span>` with `fontSize: '10px'` instead of folds `<Icon>` components — **FIXED**: replaced with `Icons.Check/Cross/Send` via `<Icon size="100">` | `Icons.Check`, `Icons.Cross`, etc. are used for all other status glyphs; folds `Text` size tokens for all supplementary text |
| N8 | GIF Picker | `GifPicker.tsx` | 83124 | GIF picker container uses fully bespoke inline styles (`borderRadius: '12px'`, `boxShadow: '0 8px 32px rgba(0,0,0,0.4)'`, raw `rgba` border) — two separate style sets for TDS and non-TDS paths — **FIXED**: non-TDS path now uses folds tokens (`color.Surface.Container`, `config.radii.R400`, `color.Surface.ContainerLine`, `color.Other.Shadow`), dropping the undefined `var(--bg-surface)`; the TDS branch keeps its `--lt-*` glow chrome (valid TDS styling) | `EmojiBoard` has no caller-applied container styling; folds components handle their own surface internally via design tokens |
| N9 | GIF Button | `RoomInput.tsx` | 10761087 | GIF toolbar button renders `<Text size="T200">` with hand-rolled `fontWeight`/`fontSize`/`letterSpacing` instead of `<Icon>`**WON'T FIX (deliberate)**: folds has no GIF icon, and "GIF" is a widely-recognized text affordance (Slack/Discord/Element all use a text label). Converting to an arbitrary icon would be less clear, not more. | All 8 other toolbar buttons (`Smile`, `Sticker`, `Location`, `Poll`, etc.) use `<Icon src={...} />` exclusively |
| N10 | Send Animation | `Message.tsx` + `Animations.css.ts` | 979998 / 6071 | `MsgAppearClass` and `MentionHighlightPulse` both animate `transform: scale` on the same `MessageBase` DOM node — on self-sent mention messages both classes apply simultaneously and fight over the `transform` property — **FIXED**: `mentionPulseKeyframes` now animates only `box-shadow` (dropped the imperceptible `scale(1.003)`), so the appear-scale and the mention glow no longer contend for `transform` | Pre-existing `highlightAnime` only animates `backgroundColor`; no prior `transform` animation on `MessageBase` |
| N11 | AvatarDecoration | `AvatarDecoration.tsx` | 5 / 3841 | Fixed 8px inset on all sides regardless of avatar size — at folds size `"200"` (~32px) the decoration bleeds 50% of the avatar diameter, clipping against `overflow: hidden` parent containers in member lists. **Inset issue still OPEN.** _Related regression fixed in `useAvatarDecoration.ts`_: the decoration fetch cached **all** failures (including transient 429/5xx) as "no decoration" permanently for the session, so a single rate-limited burst (member list / timeline mount many avatars at once) would make decorations vanish until a full reload. Now only a genuine 404 is cached; transient errors retry on the next mount. | Folds `Avatar` and `PresenceRingAvatar` do not emit overflow outside their bounding box |
| N12 | MediaGallery Drawer | `MediaGallery.tsx` | 651661 | Drawer uses `position: 'fixed'` with hardcoded `width: '320px'` as inline styles on a `<Box>`**FIXED**: moved positioning/width into co-located `MediaGallery.css.ts` using `toRem(320)` + a `max-width: 750px` full-screen media query (mirrors `MembersDrawer`); border/header now use `config.borderWidth`/`config.space` tokens. Added Escape-to-close on the panel (previously only the lightbox handled Escape). **Full chrome redesign (round 2)** to match native conventions: panel + header switched from `Surface` to `Background` variant (matching `MembersDrawer`/Saved Messages); header now `Text size="H4"` + plain close `IconButton` (dropped the bespoke tooltip-wrapped button); tabs moved to a bordered toolbar strip with the `variant={active?'Primary':'Secondary'} fill={active?'Solid':'Soft'}` pattern from `PolicyListViewer` and now show per-tab counts; the centered "lines + label" month divider replaced with a left-aligned group label (Cinny group-label pattern); thumbnail tiles moved hover/focus styling to CSS `:hover`/`:focus-visible` (no JS hover state) and into `MediaGallery.css.ts`; file rows + grid tokenized. **Docking fix (round 3)** — the core of the finding: the gallery was a `position: fixed` overlay floating over the timeline, mounted from `RoomViewHeader`. It is now a **docked flex sibling** in the room layout row, exactly like `MembersDrawer`: open state lifted to a `mediaGalleryAtom` (mirrors `bookmarksPanelAtom`), rendered in `Room.tsx` with a vertical `Line` separator on desktop and `key={room.roomId}` to reset per room; the CSS is static-width on desktop and only `position: fixed; inset: 0` full-screen on mobile (identical strategy to `MembersDrawer.css`). It now shares the row with the timeline instead of overlapping it. | `MembersDrawer` uses a vanilla-extract class with `width: toRem(266)` and is placed by the layout system, not `position: fixed`. 54px width discrepancy also breaks visual rhythm if both panels could be open |
| N13 | ScheduledMessagesTray | `ScheduledMessagesTray.tsx` | 108126 | Collapsible tray header is `<Box as="button">` with `cursor: 'pointer'` inline style and no folds variant — no hover state, no focus ring — **FIXED**: replaced with folds `<Button variant="Secondary" fill="None" radii="0">` using `before`/`after` icon props (gains design-system hover/focus) | All clickable header/toggle elements in the room view use folds `<Button>` or `<IconButton>` with explicit variants for hover/focus; `<Box as="button">` with no variant is used nowhere else |
| N14 | ForwardMessageDialog | `ForwardMessageDialog.tsx` | 137154 | Dialog uses `<Modal>` but has no `<Header>` component and no close `<IconButton>` — only way to close is clicking outside — **FIXED**: added a folds `<Header variant="Surface" size="500">` with the title + close `<IconButton radii="300">`, matching every other modal | Every other modal using `<Modal>` or `<Box role="dialog">` includes a `<Header>` with a close `<IconButton>` in the top-right (EditHistoryModal, LeaveRoomPrompt, ScheduleMessageModal, RemindMeDialog, etc.) |
| N15 | ScheduleMessageModal | `ScheduleMessageModal.tsx` | 180193 | Modal root is `<Box as="form" role="dialog">` with manually assembled `borderRadius: config.radii.R400`/`boxShadow`**FIXED**: shell is now `<Dialog as="form" variant="Surface">`; removed inline surface styles | `ForwardMessageDialog` uses folds `<Modal size="400">` with `R500` radius; the R400 vs R500 mismatch is visible when both dialogs appear in the same session |
| N16 | Presence Picker | `SettingsTab.tsx` | 118144 | Presence trigger dot is raw `<button>` with `position: absolute; bottom: 2; right: 2` inline and no folds focus ring; no tooltip — **FIXED**: wrapped the trigger in a folds `TooltipProvider` (shows "Status: …"); replaced the undefined `var(--bg-surface)` with `color.Background.Container`. Kept the absolute-positioned `<button>` (it overlays the avatar corner; a full `IconButton` would be too large for the dot). | Every other sidebar icon button uses folds `IconButton` with `SidebarItemTooltip` and `TooltipProvider` |
| N17 | Presence Picker | `SettingsTab.tsx` | 8086 | `PresencePicker` `FocusTrap` missing `escapeDeactivates: stopPropagation` and `isKeyForward`/`isKeyBackward`**FIXED**: added all three options, matching the theme selector / sort menus | Every other `PopOut`+`FocusTrap`+`Menu` combo supplies both (theme selector `General.tsx:143160`, `SettingsSelect`, sort menus) — without it Escape bubbles past the trap and arrow-key navigation is absent |
| N18 | Profile Selects | `Profile.tsx` | 547575 / 816848 | `ProfileStatus` auto-clear and `ProfileTimezone` selectors are native `<select>` elements with hardcoded `colorScheme: 'dark'` — will render in dark mode on light themes | General.tsx uses folds `SettingsSelect<T>` (`Button`+`PopOut`+`Menu`) for all dropdowns; `colorScheme: 'dark'` breaks light/custom theme appearance |
| N19 | Presence Labels | `useUserPresence.ts` vs `SettingsTab.tsx` | 5562 / 3642 | `PresenceBadge` tooltip shows "Active / Busy / Away"; `PresencePicker` options read "Online / Idle / Do Not Disturb / Invisible" — a DND user shows tooltip "Busy", not "Do Not Disturb" — **FIXED**: aligned `usePresenceLabel` reader vocabulary to the setter (online→"Online", unavailable→"Idle", offline→"Offline") | Within the same Lotus feature set the user-facing vocabulary is inconsistent between the setter UI and the reader tooltip |
| N20 | Notification Presets | `Notifications.tsx` | 57107 | Gaming/Work/Sleep preset buttons are bare `<button>` elements with Lotus-specific CSS vars (`--border-interactive-normal`, `--bg-surface-low`) not defined in all themes — **FIXED**: converted to folds `<Button variant="Secondary" fill="Soft" radii="300">` (auto height) wrapping the emoji/label/description column; undefined vars removed | Grouped preset/action buttons elsewhere use folds `Chip variant="Primary/Secondary" outlined radii="Pill"` (e.g., Composer Toolbar toggles in `General.tsx:11001113`) |
| N21 | Notification Sound Selects | `SystemNotification.tsx` | 111305 | Message sound, invite sound, and quiet-hours time pickers are bare `<select>`/`<input type="time">` with `colorScheme: 'dark'` workaround | All other dropdowns in settings use the `Button`+`PopOut`+`Menu`+`MenuItem` folds pattern; the native select renders OS-styled on all platforms |
| N22 | DM Preview Virtualizer | `RoomNavItem.tsx` / `Direct.tsx` | 608627 / 232 | DM preview adds a second text row to each DM item, making it taller than 38px, but `useVirtualizer` in `Direct.tsx` still uses `estimateSize: () => 38` — causes layout jump/overlap on initial render — **FIXED**: bumped `estimateSize` to 52 (the two-line DM-row height) so the initial estimate matches the common case; `measureElement` still corrects each row exactly | Non-DM rooms in Home.tsx also estimate 38px; DM items with a preview are now a different height, creating two visual densities in the same nav column |
| N23 | RoomServerACL | `RoomServerACL.tsx` | 100115 / 298309 | Server-name text input is a raw `<input type="text">` with inline style object; "Allow IP literal addresses" is a raw `<input type="checkbox">` with `style={{ width: 16, height: 16 }}`**FIXED**: text input → folds `<Input variant={error?'Critical':'Secondary'}>`; checkbox → folds `<Checkbox variant="Primary">` | All other text/boolean controls in room settings use folds `Input` and `Checkbox` components (`RoomAddress.tsx:163`, `RoomAddress.tsx:330`) |
| N24 | PolicyListViewer | `PolicyListViewer.tsx` | 245264 | Room-ID add input is a raw `<input type="text">` with manually replicated folds token values — **FIXED**: replaced with folds `<Input variant={error?'Critical':'Secondary'} size="400" radii="300">` | Native pattern: folds `<Input variant="Secondary" size="300" radii="300">` — no inline style needed |
| N25 | ExportRoomHistory Inputs | `ExportRoomHistory.tsx` | 258292 | Both date range pickers are raw `<input type="date">` with inline styles — **FIXED**: replaced with folds `<Input type="date" variant="Secondary" size="400" radii="300">` | Native pattern: folds `Input` component; `<input type="date">` renders OS-native date picker, unstyled relative to the rest of the settings panel |
| N26 | RoomShareInvite QR | `RoomShareInvite.tsx` | 6673 | QR code `<img>` has no `onError` handler and no loading state — broken-image placeholder shown when the external API is unreachable — **FIXED**: added `loading="lazy"` + `onError` that swaps to a folds "QR code unavailable" placeholder card | Cinny avatar components and MediaGallery use `onError` handlers; this is the only settings element making a request to a third-party server with no graceful degradation |
---
### 🟡 Minor — Cosmetic / Token Discipline
| # | Area | File | Lines | Issue | Native Pattern |
| :------ | :--------------------------------- | :------------------------------------- | :---------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N27 | GIF Picker | `GifPicker.tsx` | 103110 | `FocusTrap` omits `returnFocusOnDeactivate: false` — focus returns to GIF button on dismiss instead of staying in the editor — **FIXED**: added `returnFocusOnDeactivate: false` (matches EmojiBoard) | `EmojiBoard` in `RoomInput.tsx:978` explicitly sets `returnFocusOnDeactivate={false}`; GIF picker dismiss behaviour is inconsistent with emoji picker |
| N28 | Character Counter | `RoomInput.tsx` | 11591174 | Composer character counter rendered with `color: 'var(--tc-surface-low)'` and raw pixel padding — a CSS variable not used anywhere else in the codebase — **FIXED**: removed undefined var and raw opacity; now `<Text priority="300">` with `config.space.S100` padding | Use `color.*` folds tokens or `priority="300"` on a `Text` component |
| N29 | PollCreator Modal | `PollCreator.tsx` | 103116 | Modal root is `<Box as="form" role="dialog" aria-modal="true">` with manually assembled surface styles instead of folds `<Dialog variant="Surface">`**FIXED**: shell is now `<Dialog as="form" variant="Surface">`; removed inline surface styles | `MessageDeleteItem` and `MessageReportItem` in `Message.tsx:506,635` use `<Dialog variant="Surface">` inside `OverlayCenter > FocusTrap` |
| N30 | Playback Speed Chip | `AudioContent.tsx` | 163189 | Speed chip uses `variant="SurfaceVariant" radii="Pill"` while adjacent Play/Pause chip uses `variant="Secondary" radii="300"` — mismatched shape and variant within the same `leftControl` row — **FIXED**: changed speed chip to `variant="Secondary" radii="300"` | Controls grouped in the same row should share variant and radii |
| N31 | Collapsible Message Toggle | `MsgTypeRenderers.tsx` | 97105 | "Read more ↓" / "Show less ↑" uses `<Button size="300" variant="Secondary" fill="None">` — visually a padded form button — **FIXED**: replaced with the native flush inline-button pattern (`background:none;border:none;padding:0`) + `<Text size="T200">` tinted `color.Primary.Main`, matching `(edited)` in FallbackContent | Inline text toggles in message content (e.g. `(edited)` in `FallbackContent.tsx:74`) use bare `<button>` with `background: none; border: none; padding: 0` to stay flush with text |
| N32 | ReadReceiptAvatars Pill | `ReadReceiptAvatars.tsx` | 95103 | Pill border is `'1px solid rgba(0,212,255,0.30)'` hardcoded raw rgba string; `borderRadius: '999px'` not a folds radii token; padding in raw pixels — **FIXED**: replaced with `config.borderWidth.B300`, `config.radii.Pill`, `config.space.S100/S200` | Use `color.*` folds tokens and `config.radii.Pill` / `config.space.S*` |
| ~~N33~~ | ~~ReadReceiptAvatars Class~~ | ~~`ReadReceiptAvatars.tsx`~~ | ~~67~~ | ~~`className="receipt-pill-btn"` references a class never defined~~**FIXED**: removed dead className | All custom CSS goes through co-located vanilla-extract `*.css.ts` files |
| N34 | EventReaders Header Size | `EventReaders.tsx` | 70 | `Header size="600"` (56px tall) while all peer message-action modals use `size="500"` (48px) — **FIXED**: changed to `size="500"` | `EditHistoryModal`, `LeaveRoomPrompt`, `MessageDeleteItem`, `MessageReportItem` all use `size="500"`; `size="600"` is reserved for full-page panel headers |
| N35 | EventReaders Close Button | `EventReaders.tsx` | 96 | Close `IconButton` missing explicit `radii="300"` prop — **FIXED**: added `radii="300"` | Every peer modal close button explicitly sets `radii="300"` (EditHistoryModal:184, LeaveRoomPrompt:75, MessageDeleteItem:517) |
| N36 | EventReaders Header Border | `EventReaders.tsx` | 7277 | Lotus-mode header sets `borderBottom: '1px solid var(--lt-border-color)'` as a CSS shorthand string — **FIXED**: changed to `borderBottomWidth: config.borderWidth.B300` | Native modals use `borderBottomWidth: config.borderWidth.B300` to avoid overriding the border-color set by the folds variant system |
| N37 | EventReaders Timestamp | `EventReaders.tsx` | 143151 | Lotus path sets `fontSize: '0.72rem'` inline — a raw relative unit between folds `T200` and `T100` scale steps — **FIXED**: removed raw `fontSize`, added `priority="300"` | Use folds `Text size="T200" priority="300"` for subdued secondary text |
| N38 | BookmarksPanel Header | `BookmarksPanel.tsx` | 155196 | Header uses `variant="Surface"` and close button uses `size="300" radii="300"`; also has a SurfaceVariant search bar strip with no equivalent in any native drawer — **FIXED (full redesign)**: rebuilt the whole "Saved Messages" panel to match the canonical `MembersDrawer` — co-located `BookmarksPanel.css.ts` (`toRem(266)` + `max-width:750px` full-screen media query, replacing the old `position:absolute; zIndex:100` mobile "modal" that had no backdrop/escape), `variant="Background"` header, room **avatars** on each item (was a generic hash icon), `priority` tokens replacing all raw `opacity` hacks, the `borderLeft:3px` accent removed, and Escape-to-close added. | `MembersDrawer` header uses `variant="Background"` and default-size close button; the extra search+count strip creates a structurally different component family |
| N39 | Forward Menu Icon | `Message.tsx` | 1150 | Forward context menu item's `after` icon has no `size="100"` prop — **FIXED**: added `size="100"` to the `ArrowRight` icon | Every other after-icon in the same menu block explicitly uses `size="100"` (Reply, Reaction, Edit, Remind Me, Bookmark); missing size causes the Forward icon to render larger |
| N40 | ProfileDecoration Remove Button | `ProfileDecoration.tsx` | 185 | "Remove" link is a raw `<button>` with `background: 'none'; color: 'var(--tc-surface-low-contrast)'` — an undefined CSS variable — **FIXED**: replaced with `<Button variant="Critical" fill="None" size="300" radii="300">` | Use folds `<Button variant="Critical" fill="None">` or a `Text`-styled inline link |
| N41 | PresenceBadge / UserNotes Saving | `UserRoomProfile.tsx` | 240244 | "Saving…" indicator is `<Text opacity={0.5}>` without a spinner — **FIXED**: now shows a folds `<Spinner variant="Success" fill="Solid" size="100">` beside the "Saving…" text | Every other save operation in `Profile.tsx` shows a folds `<Spinner variant="Success" fill="Solid" size="300">` alongside the save button |
| N42 | Character Counter Convention | `UserRoomProfile.tsx` vs `Profile.tsx` | 243 / 479490 | `UserPrivateNotes` shows remaining count `"N left"`, appears only under 100; `ProfileStatus` shows `"current / 64"` always with color progression | Two Lotus features in the same settings flow use different counter conventions; neither matches a pre-existing Cinny pattern |
| N43 | Night Light Slider | `General.tsx` | 554565 | Night Light intensity slider is a raw `<input type="range">` with no `accentColor` token — renders in browser-default blue on all themes — **FIXED**: added `accentColor: color.Primary.Main`; the intensity label `opacity` hack also replaced with `priority="300"` | The Gate Threshold slider at `General.tsx:1456` at minimum sets `accentColor: 'var(--accent-orange)'`; the Night Light slider does neither |
| N44 | Mention Highlight & Boot Button | `General.tsx` | 597677 | `<input type="color">` for mention highlight uses raw pixel dimensions (`width: '36px'`, `height: '28px'`, `borderRadius: '4px'`); Reset and Boot buttons are bare `<button>` with Lotus CSS vars — **PARTIALLY FIXED**: the mention-highlight Reset button (renders on all themes) is now a folds `<Button variant="Secondary" fill="Soft">`, removing the undefined `--border-interactive-normal` var. The Boot button is **deliberately kept** as-is: it only renders when `lotusTerminal` is active, i.e. exactly when the `--accent-orange*` TDS vars are defined. The `<input type="color">` itself is tracked separately as N69. | Adjacent settings controls use folds `IconButton`/`Button`; there is no other `<input type="color">` in the Cinny settings UI |
| N45 | SettingsSelect vs SelectTheme | `General.tsx` | 126 vs 197 | `SettingsSelect` trigger uses `variant="Secondary"` while `SelectTheme` uses `variant="Primary" outlined fill="Soft"` for the same `Button`+`PopOut` dropdown pattern — adjacent rows in the same Appearance section have different visual weight — **FIXED**: `SelectTheme` trigger changed to `variant="Secondary"` to match `SettingsSelect` | Dropdown triggers should share the same variant within the same settings section |
| N46 | RoomInsights SectionHeader | `RoomInsights.tsx` | 2437 | `SectionHeader` adds `textTransform: 'uppercase'`, `letterSpacing: '0.06em'`, `opacity: 0.6` to `Text size="L400"`**FIXED**: simplified to `<Text size="L400" priority="300">` | Every other settings panel uses bare `<Text size="L400">Label</Text>` with no transforms (`General.tsx:5272`, `ExportRoomHistory.tsx:220,246`) |
| N47 | RoomInsights Chart Radii | `RoomInsights.tsx` | 350356 / 415436 | Bar chart uses `borderRadius: 3` and histogram bars use `borderRadius: '2px 2px 0 0'` as raw pixel integers — **FIXED**: replaced with `config.radii.R300` | All other rounded corners use `config.radii.*` tokens |
| N48 | RoomInsights Font Size | `RoomInsights.tsx` | 448 | Hour-axis labels set `style={{ fontSize: 9 }}` as a raw pixel integer — overrides the folds `Text size="T200"` applied on the same element — **FIXED**: removed raw `style={{ fontSize: 9 }}` | Use only folds `Text` size props; never override with raw `fontSize` |
| N49 | RoomInsights Emoji Icons | `RoomInsights.tsx` | 4165 / 292295 | `StatTile` uses literal Unicode emoji (`🖼️ 🎬 🎵 📎`) in `<Text size="H4">` as icons — **FIXED**: `StatTile` now takes an `icon: IconSrc` and renders `<Icon>` using `Icons.Photo/VideoCamera/Headphone/File` | All other iconographic elements use `<Icon src={Icons.*} />` from folds — emoji rendering varies between Windows/macOS/Linux and cannot be tinted by the theme |
| N50 | RoomInsights Warning Banner | `RoomInsights.tsx` | 168192 | Disclaimer banner uses raw `<Box style={{ border: color.Warning.Main, background: color.Warning.Container }}>`**FIXED**: replaced with `<SequenceCard variant="SurfaceVariant">` with `<Icon>` colored via `color.Warning.Main` | Settings panel informational cards use `<SequenceCard variant="SurfaceVariant">` throughout RoomServerACL, ExportRoomHistory, PolicyListViewer |
| N51 | ExportRoomHistory Progress | `ExportRoomHistory.tsx` | 311314 | Export progress shows as a plain `Text` string ("Exporting… N messages") — **WON'T FIX (deliberate)**: unlike `BackupRestore` (which has a known total to drive a determinate `ProgressBar`), export has no known total — it counts messages as they stream. The operation already shows a folds `Spinner` in the button plus a live count, which is the correct affordance for an indeterminate task. | `BackupRestore.tsx:72,90` uses a folds `<ProgressBar variant="Secondary" size="300">` for the same kind of long async operation |
| N52 | MessageQuickReactions Empty Return | `Message.tsx` | 160 | `if (recentEmojis.length === 0) return <span />;` — injects an invisible DOM node into the hover action bar flex container — **FIXED**: changed to `return null` | Universal convention for empty renders in Cinny is `return null`; 144+ instances across the codebase; the empty `<span>` can affect flex spacing |
---
### Round 2 — Additional Feature Areas
#### 🔴 Additional Major Findings
**N53 — PTT Badge (Lotus Terminal path): Raw `<div>` tree with `--lt-*` CSS vars instead of folds `<Chip>`**
- **File:** `src/app/features/call/CallControls.tsx`, lines 242282
- **Status:** **OPEN**
- **Issue:** When `lotusTerminal` is true the PTT badge renders as a bare `<Box>` with inline styles referencing `--lt-accent-green-dim`, `--lt-accent-green-border`, `--lt-accent-green` — variables absent outside TDS mode — hardcoded rem padding, `borderRadius: '99px'` (non-token), a raw monospace `fontFamily` string, non-token `letterSpacing`, and a raw `animation:` CSS string for the live-pulse dot. The live `●` dot is a raw `<span>` with inline style.
- **Root Cause:** Two entirely separate component trees for the same badge depending on a theme boolean. The non-terminal path (lines 284301) uses the correct `<Chip variant="Success"|"Warning" fill="Soft" radii="400" outlined>`.
- **Fix:** Remove the terminal branch. The standard `<Chip>` path already exists and TDS theming can be applied via the CSS variable layer without a separate component tree.
**N54 — PiP Mute Overlay Badges: Raw `<div>` instead of folds `<Badge>`/`<Chip>`**
- **File:** `src/app/components/CallEmbedProvider.tsx`, lines 438477
- **Status:** **FIXED** — replaced hardcoded `borderRadius`/`padding`/`fontSize` with `config.radii.R300`, `config.space.S100/S200` tokens; replaced raw `<span>` text with folds `<Text size="T200">`; color now applied to the `Icon`/`Text` via `color.Critical/Warning.Main`. The dark translucent scrim (`rgba(0,0,0,0.65)`) is **deliberately retained**: these badges overlay arbitrary video, where a theme `Chip`/`Badge` surface token would not guarantee legibility. They are also non-interactive (`pointerEvents: 'none'`), so an interactive `Chip` (a `<button>`) is semantically wrong.
- **Issue:** Both the "You muted" (bottom-left) and "All muted" (top-right) PiP badges are raw `<div>` elements with hardcoded `background: 'rgba(0,0,0,0.65)'`, `backdropFilter: 'blur(4px)'`, `borderRadius: '6px'`, `padding: '3px 7px'`, `fontSize: '12px'`. Color is set as `color: color.Critical.Main` directly on the wrapper `<div>`, not via a folds `variant` prop. Text is `<span style={{ fontSize: '11px', fontWeight: 600 }}>`.
- **Root Cause:** `CallView.tsx` line 127 uses `<Badge variant="Critical" fill="Solid" size="400">` in the same file for the "N Live" indicator — the native pattern exists and is unused here.
**N55 — Chat Background / Seasonal Theme Selected State Uses `color.Critical.Main` (Error Red)**
- **File:** `src/app/features/settings/general/General.tsx`, lines 16601661 and 17261728
- **Status:** **FIXED** — replaced all 4 instances of `color.Critical.Main` with `color.Primary.Main` in `General.tsx`
- **Issue:** The selected-state border for both `ChatBgGrid` and `SeasonalBgGrid` is `border: \`2px solid ${color.Critical.Main}\``and the label color is also`color.Critical.Main`. `color.Critical.Main` is the semantic token for **destructive/error states** — it is used for "Leave Room", "Delete Message", "Report Room" in the same file. A normal selection indicator rendered in error red is semantically wrong and visually alarming.
- **Root Cause:** Wrong semantic token for an active/selected state.
- **Fix:** Replace `color.Critical.Main` with `color.Primary.Main` (or `color.Success.Main` to match how other settings selections are styled) for both the border and label color.
**N56 — Report Modal Category Dropdown: Native `<select>` Instead of folds `Chip`+`PopOut`+`Menu`**
- **File:** `src/app/features/room/ReportRoomModal.tsx` lines 138163; `src/app/features/room/ReportUserModal.tsx` lines 144169
- **Status:** **FIXED** — extracted a shared `ReportCategorySelect` component (`src/app/features/room/ReportCategorySelect.tsx`) using the folds `Button` trigger + `PopOut` + `FocusTrap` + `Menu` + `MenuItem` pattern (with `escapeDeactivates`/arrow-key nav, matching `OrderButton`); both modals now use it instead of the native `<select>`.
- **Issue:** Both report modals render the "Category" field as `<Box as="select">` with hand-rolled inline styles (padding, border, background, color, fontSize, fontFamily). No other selector in the message-action modal context uses `<select>` — the established pattern for all dropdowns in both message modals and search filters is `Chip onClick → setMenuAnchor → PopOut → FocusTrap → Menu → MenuItem` (reference: `OrderButton` in `SearchFilters.tsx` lines 63114).
---
#### 🟠 Additional Moderate Findings
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :--------------------------------------------------------------------------- | :-------------------------------------------- | :-------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| N57 | PiP Fullscreen Button | `CallEmbedProvider.tsx` | 929951 | PiP fullscreen toggle is a raw `<button>` with `background: 'rgba(0,0,0,0.65)'`, `color: '#fff'`, `fontSize: '13px'`, Unicode ⛶/⊡ glyph — no focus ring, no tooltip — **FIXED (token discipline)**: `borderRadius`/`padding`/gap replaced with `config.radii.R300` + `config.space.*` tokens (also on the "Return to call" label). The dark scrim and `#fff` text are **deliberately kept** for legibility over arbitrary video; the glyph stays because folds has no fullscreen icon. `aria-label`/`title` tooltip already present. | `Controls.tsx` fullscreen button uses `<IconButton variant="Surface" fill="Soft" radii="400" size="400" outlined>` with `<TooltipProvider>`; hardcoded `#fff` fails on light themes |
| N58 | Screenshare Confirm Popup | `CallControls.tsx` | 303360 | "Share your screen?" popup is a raw `<Box>` with `--bg-surface`/`--bg-surface-border` vars (undefined outside TDS), `borderRadius: '0.75rem'`, `boxShadow: '0 8px 32px rgba(...)'`, no `FocusTrap` | Cinny's confirmation dialogs use folds `<Menu>` + `<FocusTrap>` + `<PopOut>`; the non-FocusTrap popup is not keyboard-accessible |
| N59 | ML Noise Suppression Panel | `General.tsx` | 13031487 | Sub-panel uses `var(--border-color)`, `var(--bg-card)`, `var(--bg-input)` (undefined in folds default theme), raw `<details>`/`<summary>` (UA-styled), `accentColor: 'var(--accent-orange)'` (TDS-only) | All other settings sub-sections use `<SettingTile>` rows inside `<SequenceCard>`; no other settings component uses `<details>` |
| N60 | Knock Badge on Members Button | `RoomViewHeader.tsx` | 744782 | Knock count badge wrapped in extra `<div style={{ position: 'relative' }}>` with hardcoded `fontSize: '9px'`, `minWidth: '14px'`, `height: '14px'`, `padding: '0 3px'` overriding folds `size="200"`**FIXED**: removed wrapper div, put `position: 'relative'` directly on the `IconButton`, `<Badge size="400">` with `toRem(3)` insets and `<Text size="L400">` — now matches the Pinned Messages badge pattern exactly | Pinned Messages badge (same header, lines 651677) uses `position: 'relative'` directly on `<IconButton>` + `toRem()` for inset; no extra wrapper div |
| N61 | Knock Member Rows | `MembersDrawer.tsx` | 441487 | Knock requester rows use raw `<Box>` with manually duplicated padding; no `<MenuItem>` wrapper → no hover/focus/active states — **WON'T FIX (deliberate)**: unlike a `MemberItem` (a clickable navigation row), a knock row contains two action buttons (Approve / Deny) and is **not itself clickable**. Wrapping it in `<MenuItem>` (a `<button>`) would nest interactive controls inside a button — invalid HTML/ARIA. The row has no interactive state to express. | Every joined/invited member uses `<MemberItem>` which wraps `<MenuItem variant="Background" radii="400">` with baked-in spacing and all interactive states |
| N62 | Unverified Device Banner | `RoomInput.tsx` | 860883 | Warning callout above composer uses inline `background: color.Warning.Container`, `borderLeft: '3px solid color.Warning.Main'` — a custom left-border accent pattern not present anywhere else in the folds system — **FIXED**: replaced the `borderLeft: '3px'` accent with a standard full `border` using `color.Warning.ContainerLine` + `config.borderWidth.B300`; removed the `opacity` hacks (folds `OnContainer` already meets contrast) | Warning indicators in the same codebase use `<Chip variant="Warning">` or `<Badge variant="Warning">`; the 3px left-border card pattern has no folds equivalent |
| N63 | Report Modals — Box Instead of Dialog | `ReportRoomModal.tsx` / `ReportUserModal.tsx` | 97110 / 103116 | Both modals render as `<Box as="form" role="dialog">` with inline `background`/`borderRadius`/`boxShadow`; use `config.radii.R400` (rounder) vs native `Dialog` which uses `R300`**FIXED**: both shells are now `<Dialog as="form" variant="Surface">`; removed inline surface styles (Dialog provides background/radius/shadow) | Native `MessageReportItem` at `Message.tsx:634` and all other Cinny message-action modals use `<Dialog variant="Surface">` |
| N64 | EditHistoryModal — `<Modal>` vs `<Dialog>` | `EditHistoryModal.tsx` | 166 | Uses `<Modal variant="Surface" size="500">` while sibling message-action modals (`DeleteMessageItem:505`, `MessageReportItem:634`) all use `<Dialog variant="Surface">` — different widths and internal padding | `<Dialog variant="Surface">` is the established modal shell for all message-triggered dialogs |
| N65 | EditHistoryModal — No "Load More" | `EditHistoryModal.tsx` | 253259 | When `hasMore` is true the modal shows passive `<Text>"Showing the 50 most recent edits"</Text>` with no action; older edits are inaccessible — **FIXED**: implemented real pagination — edits accumulate across `next_batch` fetches (de-duped by event id, re-sorted by ts), with a folds `<Button>Load more</Button>` (spinner while loading) replacing the passive text | `RoomActivityLog.tsx:425` and `MessageSearch.tsx:129` both render a folds `<Button size="300" variant="Secondary">Load more</Button>` to fetch the next page |
| N66 | DateRangeButton — Native `<input type="date">` | `SearchFilters.tsx` | 558589 | "From" and "To" date fields are raw `<input type="date">` with inline style overrides including `fontSize: '0.82rem'`**FIXED**: replaced both with folds `<Input type="date" variant="SurfaceVariant" size="300" radii="300">`; removed now-unused `color` import | `SelectRoomButton` (same file, line 224) and `SelectSenderButton` (line 424) both use folds `<Input size="300" radii="300">`; the date inputs are the only native browser inputs in the search filter row |
| N67 | SeasonalEffect / NightLight Z-Index Order | `SeasonalEffect.tsx` / `App.tsx` | 759 / 6277 | `SeasonalEffect` mounts at `zIndex: 9999`; `NightLightOverlay` at `zIndex: 9998`. Seasonal particles render **above** Night Light so they are never tinted. `SeasonalEffect` also shares `z-index: 9999` with the skip-to-content link in `ClientLayout.tsx`**FIXED**: lowered `SeasonalEffect` overlay to `zIndex: 9997` (below Night Light at 9998 and modals at 9999), so Night Light now tints the particles and dialogs are never obscured | Expected UX: Night Light tints all visible content including effects; requires either a higher Night Light z-index or a lower SeasonalEffect z-index |
| N68 | Syntax Highlighting — `--lt-accent-*` Vars in Non-TDS Themes | `syntaxHighlight.ts` | 313323 | `tokenStyle()` returns `var(--lt-accent-cyan/green/orange/purple, hardcoded-fallback)``--lt-*` vars only exist in TDS mode; fallbacks are Monokai dark colors that have poor contrast on light themes and no relationship to the existing `--prism-*` variables in `ReactPrism.css`**FIXED**: `tokenStyle()` now maps to the `--prism-*` family (keyword/selector/boolean/atrule/comment) which has proper light/dark/TDS palettes; comment uses `--prism-comment` instead of an opacity hack | `ReactPrism.css` uses `--prism-keyword`, `--prism-selector` etc. which switch correctly between light and dark palettes; syntax highlighting should use the same variable family |
| N69 | Mention Highlight — `<input type="color">` Instead of `HexColorPickerPopOut` | `General.tsx` | 644675 | Raw `<input type="color">` with hardcoded pixel dimensions; OS-native color picker chrome renders completely differently from the rest of settings UI — **FIXED**: replaced with `<HexColorPickerPopOut>` + `<HexColorPicker>` (react-colorful) behind a folds `<Button>` trigger showing a color swatch; the picker's built-in `onRemove` replaces the separate Reset button | `PowersEditor.tsx:125143` establishes `<HexColorPickerPopOut picker={<HexColorPicker ...>}>` as the codebase's color-picking pattern; Reset button should be `<Button size="300" variant="Secondary" radii="300">` |
| N70 | ChatBgGrid / SeasonalBgGrid — Raw `<button>` Elements | `General.tsx` | 16481689 / 17111742 | Both pickers use raw HTML `<button>` elements with hardcoded `width: toRem(76)`, `height: toRem(50/56)`, `borderRadius: toRem(8)`, `border: 2px solid rgba(...)` — no focus ring via folds, no `variant` prop, no hover state from the design system — **FIXED**: chrome (radius, border, hover, **keyboard `:focus-visible` ring**, selected state via `data-selected`) moved to a shared `BgSwatch.css.ts` using `config`/`color` tokens; only the per-swatch size + live preview background remain inline (these are inherently custom preview tiles, not folds `MenuItem`/`Chip` candidates) | Native Cinny theme pickers use folds `<MenuItem>` or `<Chip>` which respond to theme and provide focus/hover states automatically |
---
#### 🟡 Additional Minor Findings
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :-------------------------------------------- | :-------------------------------------------- | :---------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N71 | Call Prescreen Text | `CallView.tsx` | 6385 | `ChannelFullMessage` and `AlreadyInCallMessage` use `<Text style={{ color: color.Critical/Warning.Main }}>` inline instead of folds `<Badge variant="Critical/Warning">`**WON'T FIX (deliberate)**: these are full, centered explanatory **sentences** ("Channel Full (N/M) — Wait for someone to leave…"), not short labels. A `Badge` is for compact chips like "N Live"; wrapping a sentence in one is visually wrong. They already use folds `color.*` tokens. The sibling `LivekitServerMissingMessage`/`NoPermissionMessage` use the same (un-flagged) pattern. | The "N Live" badge directly above (line 127) correctly uses `<Badge variant="Critical" fill="Solid" size="400">` |
| N72 | Mute MenuItem Icon | `RoomNavItem.tsx` | 454466 | "Mute" `<MenuItem>` places bell-mute icon as a raw child node instead of using the `before` prop — **FIXED**: moved `Icons.BellMute` to `before` prop | Every other `<MenuItem>` in both `RoomNavItemMenu` and `RoomMenu` places its leading icon in the `before` prop |
| N73 | Pending Requests Header | `MembersDrawer.tsx` | 415422 | "Pending Requests" section header is bare `<Text>` with inline padding instead of `className={css.MembersGroupLabel}`**FIXED**: now uses `className={css.MembersGroupLabel}` like every other section header | Power-level group labels at lines 506519 use `className={css.MembersGroupLabel}` for all other section headers in the same virtualizer list |
| N74 | Emoji Prefix Span | `RoomNavItem.tsx` | 730736 | Emoji prefix rendered as raw `<span style={{ fontSize: '1.15em', lineHeight: 1 }}>` inside a `<Text>` node — **FIXED**: removed the emoji-splitting span; the room name (including any leading emoji) now renders directly inside `<Text>` | All other nav item text uses folds `<Text size="Inherit">` or similar — no raw `<span>` with em-based font-size override exists elsewhere in the sidebar |
| N75 | Room Name Override / Star Indicators | `RoomNavItem.tsx` | 741757 | Pencil and star indicator icons are embedded inside the name `<Box as="span">`, giving them the same visual baseline as the room name text — **WON'T FIX (deliberate)**: an inline favorite-star / local-name marker adjacent to the name is a deliberate, common design (cf. Element/Slack pinned-name markers). Moving them to the far right would collide with the unread/notification indicators already there and risks layout regressions. Low value, real regression risk. | Native sidebar status indicators (unread count, notification mode icon) are placed to the far right of the item, never inside the name text span group |
| N76 | Report Modals — Extra Cancel Button | `ReportRoomModal.tsx` / `ReportUserModal.tsx` | 189191 / 195197 | Both custom report modals include a "Cancel" `<Button>` in the footer row — **FIXED**: removed the Cancel button; dismissal is via the header `×` / click-outside, matching `MessageReportItem` | Native `MessageReportItem` (`Message.tsx:675691`) has no Cancel button — dismissal is via `×` header button or click-outside only |
| N77 | Search Filter Inline Lambdas | `SearchFilters.tsx` | 480, 625 | `SelectSenderButton` and `DateRangeButton` trigger chips use inline `onClick` arrow functions — **WON'T FIX (deliberate)**: purely a code-style nit with zero user-facing or behavioural impact. Inline arrow handlers are idiomatic React and used throughout this very file; extracting them yields no functional benefit. | `OrderButton` (line 58) and `SelectRoomButton` (line 195) both extract a named `const handleOpenMenu: MouseEventHandler<HTMLButtonElement>` handler — bypassing the type annotation in the inline form |
| N78 | HasLink Chip Active Color | `SearchFilters.tsx` | 755 | `HasLink` active state uses `variant="Primary"` (blue); all boolean scope-toggle chips in the same bar use `variant="Success"` (green) with `outlined`**FIXED**: changed to `variant={containsUrl ? 'Success' : 'SurfaceVariant'} outlined={!!containsUrl}` | `variant="Success" outlined` is the established active-state pattern for boolean toggles in the filter bar |
| N79 | Server Notice Chip Radii | `RoomViewHeader.tsx` | 570 | `<Chip size="400" radii="Pill">``Pill` radii on a room-type label — **FIXED**: changed to `radii="300"` | Room/space type labels in lobby (`RoomItem.tsx:83`, `SpaceItem.tsx:63`) use `radii="300"`; `radii="Pill"` is for filter/tag chips only |
| N80 | Server Support Contact Layout | `About.tsx` | 172239 | Homeserver support contacts rendered as raw `<Box direction="Column">` with `<Text as="a">` pairs — custom label/link layout — **WON'T FIX (deliberate)**: a contact is `role → {matrix_id?, email?, …}` (one-to-many links per role), which doesn't map onto `SettingTile`'s single `title`/`description`/`after` slots without contortion. The current layout already uses folds `Box`/`Text`/`SequenceCard` + tokens and `Text as="a"` (a valid folds pattern); no undefined vars or raw HTML chrome. | All other `<SequenceCard>` content in `About.tsx` and `General.tsx` uses `<SettingTile title="..." description="..." after={...}>` as the content unit |
| N81 | Background Picker Grid — No Responsive Layout | `General.tsx` | 17071742 | Fixed `width: toRem(76)` flex-wrap cells with no `minWidth` floor or CSS grid `auto-fill` — SeasonalBgGrid's 13 items produce a visually lopsided orphan last row at any viewport width | Cinny's native grids use `grid-template-columns: repeat(auto-fill, minmax(N, 1fr))` or equivalent for responsive fill |
| N82 | Join/Leave Sounds Auto-Preview | `General.tsx` | 15921609 | Selecting a sound in the dropdown immediately plays a preview, but no UI affordance (button label, description text, or "▶ Preview" button) communicates this to the user | Settings tiles with side effects on selection (theme picker, chat background) show a live visual preview or a dedicated control explaining the side effect |
---
### Round 3 — Rich Topic Editor, RemindMe Dialog, Composer Toolbar, Voice Recorder, Uploads, Location, Mention Highlight
#### 🔴 Additional Major Findings
**N83 — Rich Topic Formatting Toolbar: Raw `<button>` Elements with Fully Inline Styles**
- **File:** `src/app/features/common-settings/general/RoomProfile.tsx`, lines 335358
- **Status:** **FIXED** — replaced raw `<button>` elements with `<Button size="300" radii="300" variant="Secondary" fill="Soft">` with styled `<Text>` children for B/I/S/code labels
- **Issue:** The four formatting buttons (B, I, S, `` ` ``) in the room topic editor are plain HTML `<button>` elements with entirely inline styles: manual `border`, `borderRadius`, `background`, `color`, `cursor`, `fontSize`, `fontWeight`, `fontStyle`, `fontFamily`, `lineHeight`. They bypass the folds design token system completely — no `variant`, `size`, or `radii` props, no theme-reactive hover/focus states.
- **Root Cause:** Custom addition without referencing folds primitives.
- **Fix:** Replace with `<IconButton type="button" size="300" radii="300" variant="Surface" fill="Soft">` matching the emoji-picker trigger immediately above them at line 285, which already uses the correct pattern.
**N84 — Topic Preview in Room Settings Renders Plain Text Instead of `formatted_body`**
- **File:** `src/app/features/common-settings/general/RoomProfile.tsx`, lines 457461
- **Status:** **FIXED** — read-mode topic now checks `topic.format === 'org.matrix.custom.html'` and renders `parse(sanitizeCustomHtml(topic.formatted_body))`, matching `RoomTopicViewer` and all other display sites
- **Issue:** The read-mode topic display wraps `topic.topic` (the plain-text field) in `<Linkify>` and never reads `formatted_body`. However `buildTopicContent()` (lines 8289) intentionally stores both `topic` and `formatted_body` under `org.matrix.custom.html`. After the user saves a formatted topic, the preview panel immediately shows the stripped plain-text version — the formatting appears to disappear within the same settings panel.
- **Root Cause:** The existing `RoomTopicViewer` component (`src/app/components/room-topic-viewer/RoomTopicViewer.tsx:2451`) already checks `topic.format === 'org.matrix.custom.html'` and pipes `formatted_body` through `sanitizeCustomHtml`. This component is used everywhere else (`RoomIntro`, `LobbyHero`, `RoomItem`, `Invites`, etc.) but not in Room Settings.
- **Fix:** Replace the inline plain-text render with `<RoomTopicViewer topic={roomTopic}>` to match all other display sites.
---
#### 🟠 Additional Moderate Findings
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :--------------------------------- | :------------------------- | :----------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N85 | RemindMe Dialog Shell | `RemindMeDialog.tsx` | 6981 | Dialog shell is `<Box role="dialog">` with `background`, `borderRadius`, `boxShadow`, `overflow` all set as inline styles using token lookups. Corner radius is `config.radii.R400` which differs from the `R300` embedded in `<Dialog variant="Surface">`**FIXED**: shell replaced with `<Dialog variant="Surface" style={modalStyle}>`; removed the inline `background`/`borderRadius`/`boxShadow`/`overflow` and the now-unused `color` import | All small message-action dialogs (`LeaveRoomPrompt`, `LogoutDialog`, `JoinAddressPrompt`, `PowerChip`, `DeleteMessageItem`) use `<Dialog variant="Surface" style={modalStyle}>` as the shell |
| N86 | RemindMe Preset Buttons | `RemindMeDialog.tsx` | 111117 | The four preset time choices (20 min, 1 hr, 3 hr, tomorrow) use `<MenuItem size="300" radii="300">``MenuItem` is a navigation primitive tied to `menu`/`menubar` ARIA roles; placing it inside `role="dialog"` is an invalid ARIA combination — **FIXED**: each preset is now a folds `<Button variant="Secondary" fill="Soft" radii="300">`, resolving the invalid `menuitem`-in-`dialog` ARIA | Dialog action choices use `<Button>` (delete/leave/logout dialogs) or `<Chip>` (selection choices). No other dialog in the codebase uses `MenuItem` for action items |
| N87 | Composer Toolbar Toggle Pattern | `General.tsx` | 11001114 | Per-button toolbar toggles (Format, Emoji, Sticker, GIF, Location, Poll, Voice, Schedule) use `<Chip variant="Primary"/"Secondary" radii="Pill">` in a wrap grid — a compact chip-toggle grid inside a `SettingTile`, different from every adjacent row | The three sibling tiles in the same `Editor()` function (ENTER for Newline, Markdown, Formatting Toolbar) all use `<SettingTile after={<Switch variant="Primary">}>`. 15+ other binary settings in the file use the Switch pattern |
| N88 | Voice Recorder Recording State | `VoiceMessageRecorder.tsx` | 195, 206, 240, 276 | Recording container background is `var(--bg-surface-variant)`, the live pulse dot is `var(--tc-danger-normal)`, waveform bars are `var(--tc-primary-normal)` — custom Lotus CSS vars that may not exist in folds themes, falling back to transparent/black — **FIXED**: replaced with `color.SurfaceVariant.Container`, `color.Critical.Main`, `color.Primary.Main` | Native message components use JS-accessible `color.*` tokens that are always populated regardless of theme class |
| N89 | Voice Recorder Preview Audio | `VoiceMessageRecorder.tsx` | 282283 | Preview state renders bare `<audio src={previewUrl} controls>` — native browser element with inconsistent cross-browser chrome — **FIXED**: replaced with `<audio ref>` + folds `<IconButton>` play/pause toggle; `onEnded` resets playing state | Native audio messages use folds `Attachment`/`AttachmentContent` layout wrappers; pre-send preview should use `<IconButton>` play/pause controls |
| N90 | Mention Highlight Contrast Formula | `App.tsx` | 3640 | Auto-computed text color (black/white) uses simplified luma `(0.299r + 0.587g + 0.114b)/255 > 0.5` — not WCAG 2.1 relative luminance (which requires gamma linearization) — **FIXED**: replaced with WCAG 2.1 relative luminance formula using `((c+0.055)/1.055)^2.4` gamma linearization; threshold moved from 0.5 to 0.179 | Folds `color.*.OnContainer` tokens are manually curated to pass WCAG AA 4.5:1 contrast ratios; custom computation must match this guarantee |
---
#### 🟡 Additional Minor Findings
| # | Area | File | Lines | Issue | Native Pattern |
| :-- | :--------------------------------- | :----------------------- | :------ | :------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| N91 | Upload Card Caption Input | `UploadCardRenderer.tsx` | 356376 | Caption input is raw `<input type="text">` with hardcoded inline CSS using Lotus-specific vars not in folds — **FIXED**: replaced with folds `<Input variant="Secondary" size="300" radii="300">` | Other text inputs in the UI use folds `<Input size="300" radii="300">` with folds-token props for all sizing and color |
| N92 | Location "Open Location" Button | `MsgTypeRenderers.tsx` | 534547 | "Open Location" action link uses `<Chip as="a">` — compact badge-sized element — **FIXED**: replaced with `<Button as="a" variant="Secondary" fill="Solid" radii="300" size="400">` matching FileContent pattern | `FileContent.tsx` uses `<Button variant="Secondary" fill="Solid" radii="300" size="400">` for "Open File"/"Open PDF" |
| N93 | Location Coordinates Text | `MsgTypeRenderers.tsx` | 532 | `<Text size="T300" style={{ opacity: 0.65 }}>` — hardcoded non-standard opacity — **FIXED**: replaced with `<Text size="T300" priority="300">` | Secondary text uses folds `priority` prop; `0.65` is outside the token scale |
| N94 | Mention Highlight Border Invisible | `App.tsx` | 41 | `--mention-highlight-border` is set to the same value as `--mention-highlight-bg` — the border is invisible — **FIXED**: border is now `rgba(r,g,b,0.5)` — same hue as the background at 50% opacity, always visible | In folds, `color.*.ContainerLine` is always a lighter/muted sibling of `color.*.Container`, providing the 1px outline that gives mention chips visual definition |
- **#5 — Seasonal themes & chat-background redesign.** Current backgrounds are basic CSS; goal is high-fidelity, research-backed, GPU-accelerated designs (layered `oklch`, `backdrop-filter`, `contain:paint`) with WCAG-AA overlay contrast. Treat each as its own design sprint.
+85 -6
View File
@@ -106,18 +106,19 @@
**Expected:** the decoration ring/overlay renders around that participant's avatar on the call tile, the same way it does in member lists.
### A7. EC iframe load watchdog + recovery UI (#EC)
### A7. EC iframe load watchdog + recovery UI (#EC, N96)
This guards against a permanently-stuck "Loading…" call.
This guards against a permanently-stuck "Loading…" call. Also covers the N96 button-label fix (the old "Retry" and "Leave" buttons were identical — now there is a single **"Back"** button).
1. Normal case: **join a call** → it should connect within a few seconds as usual (the watchdog stays invisible).
2. Failure case (best-effort to reproduce): throttle your network hard (devtools → Network → Offline) **right as** you click join, or block the Element Call origin, so the iframe can't finish loading.
**Expected**
- On a genuine failure/timeout (~25s), instead of an endless spinner you get a **visible error overlay with Retry / Leave** buttons.
- **Retry** attempts to reload the call; **Leave** exits cleanly.
- On a genuine failure/timeout (~25s), instead of an endless spinner you get a **visible error overlay with a single "Back" button** (the old "Retry" + "Leave" pair is gone — they did the same thing and "Retry" was misleading).
- Clicking **Back** returns you to the call prescreen, where you can manually click Join to try again.
- Normal joins must **not** trigger the error overlay (no false positives) — this is the important part to confirm.
- **Self-heal:** if the error overlay appears on a slow network but EC then finishes loading anyway, the overlay should **dismiss itself** and drop you into the live call. Worth confirming on a deliberately throttled-but-not-blocked connection.
---
@@ -125,7 +126,7 @@ This guards against a permanently-stuck "Loading…" call.
This was the actual bug: poll buttons used undefined CSS variables, so on the **default (non-Lotus-Terminal) themes** they rendered with invisible borders / no selected state.
### B1. Poll renders on a default theme
### B1. Poll renders on a default theme — ✅ PASS
1. Switch to a **default Cinny theme** (Settings → Appearance — **not** Lotus Terminal / TDS). Test both a **dark** and a **light** theme.
2. In any room, create a poll (composer → poll button): a **single-choice** poll with 3 options.
@@ -153,7 +154,7 @@ This was the actual bug: poll buttons used undefined CSS variables, so on the **
- Indicators are **square checkboxes** (not circles); selected ones show a **✓** that's legible against the filled box.
- You can select **several** options; each shows its own progress fill.
### B4. Lotus Terminal theme regression
### B4. Lotus Terminal theme regression — ✅ PASS
1. Switch to **Lotus Terminal / TDS** theme and re-open a poll.
**Expected:** still looks correct (the fix uses theme tokens, so the TDS accent should now drive it) — no worse than before.
@@ -254,6 +255,11 @@ In Settings → Appearance:
2. Pick a **seasonal theme** → confirm the **chat background** auto-clears to none.
3. (Edge) If you have old data with both set, after reload only one should visibly apply (no double-overlay clutter).
### F3. Background / seasonal picker grid layout (N81)
In Settings → Appearance, look at the **Chat Background** and **Seasonal Theme** swatch grids; resize the window narrow→wide.
**Expected:** swatches reflow to fill each row evenly (responsive grid), with no lopsided/orphaned last row at any width.
---
## G. Calls — additional unverified (👥 2 people)
@@ -271,6 +277,11 @@ In a call with at least one other person, pop out the **Picture-in-Picture** min
1. In a **camera-only** call (no screenshare), confirm the **Fullscreen** button is available (previously only showed during screenshare).
2. Use **MemberGlance → Focus camera** to full-screen/spotlight a specific person's camera. (Overlaps **A5**; if you've done A5 you can skip.)
### G3. PTT badge renders on all themes (N53)
Enable **Push-to-talk** (Settings → Calls) and join a call. Hold the PTT key.
**Expected:** the floating PTT badge above the controls shows "PTT — Hold KEY" when idle and "● Live" (green) while held — on **both** a default theme and Lotus Terminal (it's now a single folds Chip; the old terminal-only variant was removed).
---
## H. Media / performance (needs a room with many images)
@@ -331,6 +342,74 @@ Trigger a desktop/browser notification for a new message.
---
## L. Fixed — verify
### L1. AFK auto-mute releases the OS microphone indicator on mute (N95) — 👥 live call
**Context (now FIXED):** `useAfkAutoMute.ts` opened its own `getUserMedia` level-monitor capture for the whole call, so the OS recording indicator (green dot on macOS, mic icon on Windows/Linux) stayed lit even when muted. The capture is now gated on the reactive mic-on state — it runs only while unmuted, so muting releases the stream.
**To verify:**
1. Enable **AFK auto-mute** in Settings → Calls and **join a call**.
2. Manually **mute your mic** using the call controls → the **OS recording indicator should clear** within ~a second.
3. **Unmute** → the indicator should re-appear (capture re-acquired).
4. Also confirm AFK still works end-to-end: stay unmuted and silent past the configured timeout → mic auto-mutes with the "muted after inactivity" toast, and the indicator clears.
### L2. Maskable PWA icon (N108) — Android install
1. On **Android Chrome**, install Lotus Chat as a PWA (Add to Home Screen).
2. Look at the **home-screen icon**.
**Expected:** the icon fills the adaptive-icon shape cleanly (the logo centered with safe-zone padding on the dark background), **not** clipped at the corners or floating in an odd box. Also worth a quick check in Chrome DevTools → Application → Manifest that the two `purpose: maskable` icons load without a 404 (this also validates the manifest's icon paths resolve in production — a pre-existing path convention I couldn't verify statically).
---
## M. New features (this round)
### M1. Search: `has:image` / `has:file` / `has:video` filters
1. Open message search (in a room with shared images/files/videos in history).
2. Run a broad search, then toggle the **Images**, **Files**, **Video** chips (in the filter bar, next to "Has link").
**Expected:**
- Each chip narrows the visible results to that message type; multiple active chips = union (any of them).
- Toggling them off restores the full results. The existing room/sender/date/has-link filters still work alongside.
- **Known limitation (by design):** filtering is client-side over already-fetched results, so the visible count can be lower than the server's total for that query — paginating/loading more pulls in more to filter. Confirm this reads acceptably.
### M2. Search: recent searches
1. Run a few different searches, then **clear the search box** and focus it.
**Expected:** your last (up to 10) distinct searches appear as clickable chips; clicking one re-runs it. A **Clear** affordance wipes the list. The list **persists across a page refresh** (localStorage).
### M3. Custom accent color (non-TDS themes) — ⚠️ needs your visual judgment
1. Make sure **Lotus Terminal (TDS)** is **off**. Settings → Appearance → **Custom Accent Color** → pick a color.
**Expected:**
- The app's accent (buttons, selected/active states, links, primary chips) recolors to your choice **live**.
- **Look critically at quality** (this is the part I can't verify): button **text legibility** (OnMain contrast) on the accent buttons; **hover/active** shades; and **selected-row / chip** backgrounds (the translucent "Container" tints). Try a **light** color and a **dark** color and a **saturated** one.
- If a dark accent makes selected-row text (OnContainer) hard to read, tell me — that's the one spot in the auto-derived palette most likely to need tuning.
- **Reset** clears it back to the theme default.
- Turn **Lotus Terminal ON** → the custom accent should be **ignored** (TDS fixed palette wins) and the picker shows a "non-TDS only" note; turn it back off → custom accent returns.
- Reload → the chosen accent **persists**.
---
### M4. Search: "Pinned only" filter
In message search, toggle the **Pinned** chip.
**Expected:** results narrow to messages currently pinned in their room; composes with the Images/Files/Video chips and room/sender/date filters; toggling off restores results. It also narrows the **encrypted/local-cache** results section (not just server results). Needs a room with actually pinned messages.
### M5. New theme presets (Cyberpunk / Ocean / Blood Red / Classic Matrix / Midnight) — ⚠️ visual judgment
Settings → Appearance → theme picker → try each of the 5 new themes.
**Expected:** each applies a complete, legible dark palette. Code review computed WCAG contrast and all pass AA, but **eyeball these specifically**: **Midnight** (lowest-contrast accent `#6b7ca8` — selected/focus states), **Classic Matrix** (green accents, light-green body text on near-black), **Blood Red** (white-ish text on bright-red buttons). Confirm Success/Warning/Critical (save/leave/delete) still look correctly green/amber/red, not recolored. Switching back to a stock theme should fully revert.
---
## Priority if you're short on time
1. **A4** (in-call banner) + **A3** (ringtone) — newest, most logic, hardest to reproduce.
+37 -95
View File
@@ -5,28 +5,6 @@
---
## 🏗️ Infrastructure & Maintenance
- [x] **Upgrade Synapse to v1.155.0** ✅ Done 2026-06-18
- **Context:** 1.155.0 is the last version supporting Debian 12 Bookworm. LXC 151 is already on Debian 13 Trixie — OS migration was completed prior to this upgrade.
- **What changed (1.154→1.155):** No breaking changes, no config changes, no DB migrations. Bugfixes: to-device EDU size limiting, restricted room joins, sliding sync subscription response timing. Rust port of more internal classes (perf only).
- **MSC4452** (Preview URL capabilities) shipped in 1.154 — opt-in via `msc4452_enabled`, not enabled.
---
## 📱 Quick Feature Additions
- [x] **Full-Screen Camera Broadcasts** ⚠️ UNTESTED — verify in a real call
- **Context:** Element Call currently supports full-screening screenshares. We need to parity this functionality for camera broadcasts.
- **Goal:** Users should be able to toggle any camera feed to full-screen mode, similar to the existing screenshare full-screen implementation.
- **Implemented 2026-06-18:**
1. **Fullscreen button always shows** — removed `screenshare &&` gate in `CallControls.tsx`. The fullscreen button is now available in camera-only calls, not just during screenshares.
2. **Per-participant camera focus**`CallControl.focusCameraParticipant(userId)` added. Finds the participant's video tile via `[data-testid="videoTile"]` / `[data-video-fit]` + `[aria-label="${userId}"]`, enables spotlight mode, then clicks the tile to focus them.
3. **MemberGlance "Focus camera" action** — clicking a participant avatar in the call status bar now opens a mini popup with "Focus camera" (triggers focusCameraParticipant) and "View profile" options, rather than immediately opening the profile.
4. **PiP fullscreen button** — a small fullscreen toggle button (⛶/⊡) is shown in the PiP overlay top-right, allowing users to go fullscreen directly from PiP mode without navigating back to the call room.
---
## ⚠️ TDS DESIGN LAW — READ BEFORE TOUCHING ANY UI
> **ALL Lotus Terminal Design System (TDS) styling — colors, animations, glows, borders, fonts, spacing — MUST come exclusively from `/root/code/web_template/base.css` CSS variables.**
@@ -37,10 +15,42 @@
---
## 🧩 NATIVE-CINNY LAW — EVERY FEATURE MUST FEEL LIKE STOCK CINNY
> **Every feature we implement must feel native to the upstream Cinny app — indistinguishable from something the Cinny team would have shipped.** Reference: <https://github.com/cinnyapp/cinny>.
>
> Concretely this means:
>
> - **Use the `folds` design system, not bespoke UI.** Build with folds primitives (`Button`, `Chip`, `IconButton`, `Menu`, `MenuItem`, `Dialog`, `Modal`, `Input`, `Switch`, `Badge`, `SettingTile`, `SequenceCard`, etc.) and folds tokens (`color.*`, `config.space.*`, `config.radii.*`, `config.borderWidth.*`). No hardcoded hex/`rgba()` for UI chrome, no invented/undefined CSS variables.
> - **Match Cinny's existing patterns.** Before adding UI, find the closest existing Cinny component/flow and mirror it (e.g. a new dropdown uses `Button`+`PopOut`+`Menu`+`MenuItem` like the rest; a new modal has a `Header` with a close `IconButton`; a new setting is a `SettingTile` inside a `SequenceCard`). Consistency with stock Cinny beats personal style.
> - **Lotus-custom additions should be unobtrusive** and fit Cinny's visual language, spacing, and interaction conventions — a stranger using Cinny should not be able to tell which features are ours.
>
> **The ONE exception:** explicit **Lotus Terminal Design System (TDS)** features, which intentionally have their own distinct look and follow the **TDS Design Law** above. TDS styling is opt-in (only active in Lotus Terminal mode); everything else must look and feel like native Cinny.
---
Completed features are documented in [LOTUS_FEATURES.md](./LOTUS_FEATURES.md).
---
## ✅ Done — Awaiting Verification
Built and gate-green; verify per [LOTUS_TESTING.md](./LOTUS_TESTING.md), then they graduate to LOTUS_FEATURES.md. (Bug-side fixes awaiting verification live in LOTUS_BUGS.md.)
| Feature | Test guide |
| :-------------------------------------------------------------------------------- | :---------------- |
| Full-Screen Camera Broadcasts (per-participant focus) | A5 / G2 |
| Advanced search filters (sender/date/has-link/has:image·file·video/pinned/recent) | K2 / M1 / M2 / M4 |
| Custom Accent Color Picker (non-TDS themes) | M3 |
| 5 Color Theme Presets (Cyberpunk/Ocean/Blood Red/Matrix/Midnight) | M5 |
| Intersection-based lazy media loading | H1 |
| Context-aware thumbnail previews | H2 |
| Desktop — proactive update notifications (Tauri) | J1 |
| Remind Me Later | K1 |
| Mobile Bookmarks access | E5 |
---
Legend:
- `[AUDIT REQUIRED]` — at least one assumption needs code/server verification before implementing
@@ -193,24 +203,6 @@ Features:
**What:** Implement a persistent local cache for search results, optimized for encrypted rooms.
**Approach:** Use `IndexedDB` to store search metadata (event IDs, timestamps) to prevent redundant server-side decryption/fetching.
### [x] P4-9 · Advanced Search Filter UI — PARTIALLY DONE (UNTESTED)
**What:** Improve search filter UX in `SearchFilters.tsx`.
**Completed 2026-06-18:**
-`SelectSenderButton` — picker UI for sender filter (previously required typing `from:@user` by hand)
-`DateRangeButton` — quick-pick presets: Today / Last week / Last month / Last year
-`Has link` chip — `contains_url: true` filter, wired to Matrix API and URL param
**UNTESTED** — needs verification at chat.lotusguild.org.
**Remaining for parity with Discord/Slack:**
- [ ] `has:image` / `has:file` / `has:video` — msgtype filters (require client-side post-filtering, no server API)
- [ ] Pinned messages filter
- [ ] Saved searches / search history
---
### [ ] P4-1 · Thread Notification Mode Per-Thread (MSC3771)
**Spec:** MSC3771 (stable). Depends on Thread Panel (#P3-8).
@@ -262,51 +254,12 @@ Features:
## Priority 5 — Gamer / Aesthetic / Customization
### [ ] P5-1 · Custom Accent Color Picker (non-TDS mode only)
**What:** A hex/HSL color picker in Settings → Appearance. Chosen color replaces the primary accent throughout the UI: buttons, badges, active states, highlights, presence dot, links. Applied via a CSS custom property override injected into `<head>`.
**IMPORTANT:** This feature is completely inactive when TDS is enabled — TDS has its own fixed palette. Add this setting under a "Non-TDS Themes" section that is hidden when TDS is active.
**[AUDIT REQUIRED]** Identify all CSS custom properties that constitute the "accent color" in non-TDS mode. Map them to the folds/vanilla-extract token names. (Confirmed: folds uses vanilla-extract, NOT CSS custom properties — must create a new vanilla-extract theme variant dynamically.)
**Complexity:** Medium.
---
### [ ] P5-2 · Additional Color Theme Presets
**What:** 5 new one-click theme presets alongside TDS. Each must be a complete, polished system with proper contrast ratios (WCAG AA). All implemented as vanilla-extract themes matching the existing TDS pattern.
Themes:
1. **Cyberpunk** — deep navy bg (`#0a0015`), electric purple (`#bf5fff`) + hot pink (`#ff2d9b`) accents, neon glow
2. **Ocean** — deep sea blue bg (`#020b18`), teal (`#00c9b1`) + aqua (`#0096d6`) accents, soft feel
3. **Blood Red** — near-black bg (`#0d0203`), deep crimson (`#7a0010`) + bright red (`#ff2233`) accents
4. **Classic Matrix** — pure black bg (`#000000`), phosphor green (`#00ff41`) text + accents
5. **Midnight** — dark charcoal (`#111827`), cool blue-grey (`#6b7ca8`) accents, clean minimal
**[AUDIT REQUIRED]** Study `src/lotus-terminal.css.ts` for the full token list before designing themes. All tokens must be covered (~50 CSS custom properties each).
**Complexity:** Medium (design effort is the main cost).
---
### [MOVED] P5-9 · LFG (Looking for Group) Command → LotusBot
**Decision:** Implemented as `!lfg` in LotusBot rather than a client slash command. Bot-side rendering works consistently across all Matrix clients; client-side enhanced cards would only be visible to Lotus Chat users and require sanitizer auditing. The bot can also support richer flows (list active LFGs, DM interested players, auto-expire posts).
---
### [x] P5-5 · Intersection-Based Lazy Loading ⚠️ UNTESTED — needs verification in timeline with many images
**What:** Use `IntersectionObserver` to trigger media decryption and loading only when components approach the viewport.
**Approach:** Reduce initial memory footprint and improve timeline load times by deferring decryption of images/videos until they are visible.
### [x] P5-6 · Context-Aware Thumbnail Previews ⚠️ UNTESTED
**What:** Enhance thumbnail rendering in the timeline for consistent, polished aesthetics.
**Approach:** Use CSS `object-fit: cover` with improved focal-point centering within `ThumbnailContent` to prevent media stretching or awkward aspect-ratio cropping.
**Fix Applied:** Added `objectPosition: 'center top'` to: (1) `media.css.ts``Image` component (timeline images), (2) video thumbnail inline style in `RenderMessageContent.tsx`, (3) `GalleryTile` `<img>` in `MediaGallery.tsx`. Full-size viewers retain `objectFit: 'contain'` — no change. `objectPosition: 'center top'` prevents face/subject cropping on tall portrait images capped at 600px by `AttachmentBox`.
---
### [ ] P5-15 · In-Call Soundboard
**What:** Grid of short audio clips playable into the call audio stream via Web Audio API (AudioBufferSourceNode → MediaStreamDestinationNode → mixed with mic). Built-in clips + user-uploadable custom clips (stored as mxc://). Accessible from call controls bar.
@@ -367,15 +320,6 @@ Themes:
---
### [x] P5-40 · Desktop — Proactive Update Notifications (Tauri) ⚠️ UNTESTED (requires Tauri build)
**What:** Automatically check for app updates on launch and periodically during long sessions. If an update is available, show an in-app toast or badge (e.g., on the Settings icon) to alert the user without requiring a manual check in settings.
**Mechanism:** Use the `useTauriUpdater` hook in a global component like `ClientNonUIFeatures.tsx`.
**Note:** Ensure the check is throttled (e.g., once every 12 hours) to avoid redundant Tauri commands.
**Complexity:** Low-Medium.
---
### [ ] P5-41 · Desktop — Native WinRT Toast Notifications
**What:** Replace emulated notifications with native WinRT Toast notifications.
@@ -454,13 +398,6 @@ Themes:
## 🚀 Features to Add
- [ ] **Mobile Audit:** Comprehensive audit of all features in LOTUS_FEATURES.md for mobile PWA usability and layout responsiveness.
- [x] **Remind Me Later:** Slack-style reminders for messages — fully implemented ⚠️ UNTESTED end-to-end
- **Storage:** `useReminders.ts` — persists to `io.lotus.reminders` account data with `addReminder` / `removeReminder` / `getReminders`
- **UI:** `RemindMeDialog.tsx` — 4 presets (20 min, 1 hr, 3 hr, tomorrow 9am); wired into `Message.tsx` context menu via `remindOpen` state; `useModalStyle(320)` for mobile fullscreen
- **Monitor:** `ReminderMonitor` in `ClientNonUIFeatures.tsx` — polls every 30s + on tab visibility; fires Lotus toast when due and calls `removeReminder`
- [x] **Mobile Bookmarks:** Fixed ⚠️ UNTESTED — bookmarks now accessible from within any room on mobile
- **Root Cause:** `BookmarksPanel` renders correctly on mobile but `BookmarksTab` lives in `SidebarNav`, which is hidden when inside a room on mobile (`MobileFriendlyClientNav` returns `null`). No trigger existed.
- **Fix:** Added "Saved Messages" `MenuItem` to the `RoomMenu` (···More Options) in `RoomViewHeader.tsx`. Toggles `bookmarksPanelAtom` and closes the menu. Works on all screen sizes — desktop users see it as a duplicate of the sidebar star, mobile users now have their only in-room access point.
---
@@ -543,6 +480,7 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
**Mechanism:** KaTeX injection into the HTML parser.
- **Sanitizer (`src/app/utils/sanitize.ts`):** Allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
> [Gemini_Found] `sanitize.ts` uses **`sanitize-html`** (not DOMPurify) with an explicit allowlist (`allowedTags`) and `disallowedTagsMode: 'discard'`. All MathML tags are currently absent from the allowlist and are silently stripped. Update `permittedHtmlTags` to include: `<math>`, `<mi>`, `<mo>`, `<mn>`, `<ms>`, `<mtext>`, `<mspace>`, `<mrow>`, `<mfrac>`, `<msqrt>`, `<mroot>`, `<mstyle>`, `<merror>`, `<mpadded>`, `<mphantom>`, `<mfenced>`, `<menclose>`, `<msub>`, `<msup>`, `<msubsup>`, `<munder>`, `<mover>`, `<munderover>`, `<mmultiscripts>`, `<mtable>`, `<mtr>`, `<mtd>`, `<maligngroup>`, `<malignmark>`, and `annotation`. Also add the required MathML attributes (e.g. `xmlns`, `display`, `mathvariant`) to `permittedTagToAttributes`.
- **Parser (`src/app/plugins/react-custom-html-parser.tsx`):** Detect `$ ... $` and `$$ ... $$` patterns in text nodes:
```tsx
if (node.type === 'text') {
@@ -592,12 +530,16 @@ Exhaustive, low-level implementation details for backlog items. Follow these pat
- Route the mic `MediaStream` and the clip source to the destination node.
- Pass the destination's `.stream` to the call bridge.
> ⚠️ **[Gemini_Found — CORRECTED]** Gemini originally suggested using LiveKit's `LocalAudioTrack.replaceTrack()` to mix audio into the call stream. This is **not possible** from Lotus Chat's realm: Element Call runs in a **cross-origin iframe** controlled via `matrix-widget-api` (postMessage). LiveKit's JS SDK and its `LocalAudioTrack` live inside EC's sandboxed context — inaccessible from our code. This directly contradicts the confirmed constraint already listed in the Server Capabilities table: _"Cindy CANNOT inject audio into EC call stream — In-call soundboard must be redesigned as local-only."_ The soundboard must be a local-playback-only feature (output through the user's speakers, not mixed into the call audio stream).
---
### P5-20 · Quick Reply from Browser Notification
**Mechanism:** Service Worker `notificationclick` Action.
> [Gemini_Found] Implementation detail: `serviceWorkerRegistration.showNotification()` should be used instead of `new Notification()` so that the service worker can listen to the `notificationclick` event. `new Notification()` creates notifications that are bound to the client page, not the SW.
```typescript
// src/sw.ts
self.addEventListener('notificationclick', (event) => {
+2 -2
View File
@@ -2,7 +2,7 @@
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** &nbsp;|&nbsp; Forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** &nbsp;|&nbsp; Forked from [Cinny](https://github.com/cinnyapp/cinny), synced through v4.12.3
---
@@ -10,7 +10,7 @@ A Matrix chat client built for Lotus Guild — fast, private, and packed with th
The source code is licensed under [AGPLv3](LICENSE), the same license as the upstream Cinny project. The source for this fork is public at [code.lotusguild.org/LotusGuild/cinny](https://code.lotusguild.org/LotusGuild/cinny).
The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
The Lotus Chat logo (`public/res/Lotus.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
---
+19 -2
View File
@@ -30,6 +30,17 @@
return;
}
// Derive the parent origin for postMessage targetOrigin from the parentUrl
// widget param (a full URL) so denoise-status messages aren't broadcast with
// '*'. Fall back to this frame's own origin if parentUrl is missing/malformed.
var targetOrigin;
try {
var parentUrl = params.get('parentUrl');
targetOrigin = parentUrl ? new URL(parentUrl).origin : window.location.origin;
} catch (e) {
targetOrigin = window.location.origin;
}
var md = navigator.mediaDevices;
if (!md || typeof md.getUserMedia !== 'function') return;
if (typeof AudioWorkletNode === 'undefined' || typeof AudioContext === 'undefined') return;
@@ -274,6 +285,9 @@
source.disconnect();
mlNode.disconnect();
} catch (e) {}
try {
if (gateNode) gateNode.disconnect();
} catch (e) {}
try {
origTrack.stop();
} catch (e) {}
@@ -301,7 +315,7 @@
nativeNS: USE_NATIVE_NS,
gate: USE_GATE,
},
'*',
targetOrigin,
);
}
@@ -316,7 +330,10 @@
.catch(function (e) {
var msg = e instanceof Error ? e.message : String(e);
console.error('[lotus-denoise] Setup failed:', msg);
window.parent.postMessage({ type: 'lotus-denoise-status', active: false, error: msg }, '*');
window.parent.postMessage(
{ type: 'lotus-denoise-status', active: false, error: msg },
targetOrigin,
);
return stream;
});
}
-497
View File
@@ -21,7 +21,6 @@
"@giphy/js-util": "5.2.0",
"@giphy/react-components": "10.1.2",
"@sapphi-red/web-noise-suppressor": "0.3.5",
"@sentry/react": "10.53.1",
"@tanstack/react-query": "5.100.13",
"@tanstack/react-query-devtools": "5.100.13",
"@tanstack/react-virtual": "3.13.25",
@@ -54,7 +53,6 @@
"jotai": "2.20.0",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"lodash": "4.18.1",
"matrix-js-sdk": "41.6.0-rc.0",
"matrix-widget-api": "1.17.0",
"millify": "6.1.0",
@@ -82,7 +80,6 @@
"@element-hq/element-call-embedded": "0.20.1",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@sentry/vite-plugin": "5.3.0",
"@types/chroma-js": "3.1.2",
"@types/file-saver": "2.0.7",
"@types/is-hotkey": "0.1.10",
@@ -3783,403 +3780,6 @@
"integrity": "sha512-jh3+V9yM+zxLriQexoGm0GatoPaJWjs6ypFIbFYwQp+AoUb55eUXrjKtKQyuC5zShzzeAQUl0M5JzqB7SSrsRA==",
"license": "MIT"
},
"node_modules/@sentry-internal/browser-utils": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.53.1.tgz",
"integrity": "sha512-X4d6y8sBMjmNhcDW4eMBU3ASsNIMz8dqaFkhyIMN/dkYr/yZKnbRZPaVuVUGvHKjnlficPpIH0/HK9KBjrYxPw==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/feedback": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/feedback/-/feedback-10.53.1.tgz",
"integrity": "sha512-vVpTI/aEYN5d9IgZeYJWMqVaN0+iFgidSrYNAsZTh1US5sJUzF/wrl+68KdpmCtFROrN3jiAn1oPSwL5CKvEJA==",
"license": "MIT",
"dependencies": {
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay/-/replay-10.53.1.tgz",
"integrity": "sha512-wZNzTBYkgGUPWMuUQv7L64+OJmoCnz7GQNiTrTFK6EVAjJXFBCSsPp/nhif0bLhbk8+0g4xz633uOhpXuQbFdw==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry-internal/replay-canvas": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry-internal/replay-canvas/-/replay-canvas-10.53.1.tgz",
"integrity": "sha512-aueLaf/2prExwA76BGU5/bOXCKWqtt6jQXWA6WJQNrmKpPEtZJB4ypnpsou0McXQCF8tur2Y8U0TEkwQP13yJQ==",
"license": "MIT",
"dependencies": {
"@sentry-internal/replay": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/babel-plugin-component-annotate": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sentry/babel-plugin-component-annotate/-/babel-plugin-component-annotate-5.3.0.tgz",
"integrity": "sha512-p4q8gn8wcFqZGP/s2MnJCAAd8fTikaU6A0mM97RDHQgStcrYiaS0Sc5zUNfb1V+UOLPuvdEdL6MwyxfzjYJQTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 18"
}
},
"node_modules/@sentry/browser": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.53.1.tgz",
"integrity": "sha512-zXF373hzUOGzUOrqd8xb1U3LQi5uYC3mwv+z5OMKUUinQlu30tTWBs7ypy6YTchtix9QlYaHWlayUF8vBZ5UjA==",
"license": "MIT",
"dependencies": {
"@sentry-internal/browser-utils": "10.53.1",
"@sentry-internal/feedback": "10.53.1",
"@sentry-internal/replay": "10.53.1",
"@sentry-internal/replay-canvas": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/bundler-plugin-core": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sentry/bundler-plugin-core/-/bundler-plugin-core-5.3.0.tgz",
"integrity": "sha512-L5T60sWdAI3qWwdg3Ptwek/0TY59PERrxyqp4XMUkroayQvGd9r5dIW9Q1kSeXX9iJ442nXbFZKAOyCKV4Z13Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/core": "^7.18.5",
"@sentry/babel-plugin-component-annotate": "5.3.0",
"@sentry/cli": "^2.58.5",
"dotenv": "^16.3.1",
"find-up": "^5.0.0",
"glob": "^13.0.6",
"magic-string": "~0.30.8"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/balanced-match": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz",
"integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/brace-expansion": {
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz",
"integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/minimatch": {
"version": "10.2.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz",
"integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.5"
},
"engines": {
"node": "18 || 20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/@sentry/bundler-plugin-core/node_modules/minipass": {
"version": "7.1.3",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/@sentry/cli": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli/-/cli-2.58.6.tgz",
"integrity": "sha512-baBcNPLLfUi9WuL+Tpri9BFaAdvugZIKelC5X0tt0Zdy+K0K+PCVSrnNmwMWU/HyaF/SEv6b6UHnXIdqanBlcg==",
"dev": true,
"hasInstallScript": true,
"license": "FSL-1.1-MIT",
"dependencies": {
"https-proxy-agent": "^5.0.0",
"node-fetch": "^2.6.7",
"progress": "^2.0.3",
"proxy-from-env": "^1.1.0",
"which": "^2.0.2"
},
"bin": {
"sentry-cli": "bin/sentry-cli"
},
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@sentry/cli-darwin": "2.58.6",
"@sentry/cli-linux-arm": "2.58.6",
"@sentry/cli-linux-arm64": "2.58.6",
"@sentry/cli-linux-i686": "2.58.6",
"@sentry/cli-linux-x64": "2.58.6",
"@sentry/cli-win32-arm64": "2.58.6",
"@sentry/cli-win32-i686": "2.58.6",
"@sentry/cli-win32-x64": "2.58.6"
}
},
"node_modules/@sentry/cli-darwin": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-2.58.6.tgz",
"integrity": "sha512-udAVvcyfNa0R+95GvPz/+43/N3TC0TYKdkQ7D7jhPSzbcMc7l2fxRNN5yB3UpCA5fWFnW4toeaqwDBhb/Wh3LA==",
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-arm": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm/-/cli-linux-arm-2.58.6.tgz",
"integrity": "sha512-pD0LAt5PcUzAinBwvDqc66x9+2CabHEv486yP0gRjWO7SakbaxmfVq/EXd8VLq/Tzi39LAu422UYK1lpW3MILw==",
"cpu": [
"arm"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-arm64": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-arm64/-/cli-linux-arm64-2.58.6.tgz",
"integrity": "sha512-q8mEcNNmeXMy5i+jWT30TVpH7LcP4HD21CD5XRSPAd/a912HF6EpK0ybf/1USO14WOhoXbAGi9txwaWabSe33g==",
"cpu": [
"arm64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-i686": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-i686/-/cli-linux-i686-2.58.6.tgz",
"integrity": "sha512-q8vNJi1eOV/4vxAFWBsEwLHoSYapaZHIf4j76KJGJXFKTkEbsjCOOsKbwUIBTQQhRgV4DFWh3ryfsPS/que4Kg==",
"cpu": [
"x86",
"ia32"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-linux-x64": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-linux-x64/-/cli-linux-x64-2.58.6.tgz",
"integrity": "sha512-DZu956Mhi3ZRjTBe1WdbGV46ldVbA8d2rgp/fh51GsI25zjBHah4wZnPTSzpc+YqxU6pJpg579B/r3jrIK530Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"linux",
"freebsd",
"android"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-win32-arm64": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-arm64/-/cli-win32-arm64-2.58.6.tgz",
"integrity": "sha512-nj0Ff/kmAB73EPDhR8B4O9r+NUHK5GkPCkGWC+kXVemqAJWL5jcJ5KdxG0l/S0z6RoEoltID8/43/B+TaMlT7A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-win32-i686": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-i686/-/cli-win32-i686-2.58.6.tgz",
"integrity": "sha512-WNZiDzPbgsEMQWq4avsQ391v/xWKJDIWWWo9GYl+N/w5qcYKkoDW7wQG7T9FasI6ENn68phChTOAPXXxbfAdOg==",
"cpu": [
"x86",
"ia32"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/cli-win32-x64": {
"version": "2.58.6",
"resolved": "https://registry.npmjs.org/@sentry/cli-win32-x64/-/cli-win32-x64-2.58.6.tgz",
"integrity": "sha512-R35WJ17oF4D2eqI1DR2sQQqr0fjRTt5xoP16WrTu91XM2lndRMFsnjh+/GttbxapLCBNlrjzia99MJ0PZHZpgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "FSL-1.1-MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=10"
}
},
"node_modules/@sentry/core": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.53.1.tgz",
"integrity": "sha512-XG4ezlkyuAPjBC5+9kXC94rXXuqYTw9NRhfaDHssbTFaGnqBR8vQX2UUgZfY7ucbeelRDGfBu1sywoU+mB04uA==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@sentry/react": {
"version": "10.53.1",
"resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.53.1.tgz",
"integrity": "sha512-lrwNq5T/zW84l60894TpKHPcvFuc1I/Hnohecc0TfYVpIcYYuw2orCHoU4v4wgkFaJUpegVetbgdOphViyLVjA==",
"license": "MIT",
"dependencies": {
"@sentry/browser": "10.53.1",
"@sentry/core": "10.53.1"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"react": "^16.14.0 || 17.x || 18.x || 19.x"
}
},
"node_modules/@sentry/rollup-plugin": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sentry/rollup-plugin/-/rollup-plugin-5.3.0.tgz",
"integrity": "sha512-hgPGPYdQJ/G1cGYOxAb7d4z3V+/k/E5/P/5TFPEEBLuIbFFk+JG0CISUDJdzXJjO382Lb99PBJuXGbueBmO79w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sentry/bundler-plugin-core": "5.3.0",
"magic-string": "~0.30.8"
},
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"rollup": ">=3.2.0"
},
"peerDependenciesMeta": {
"rollup": {
"optional": true
}
}
},
"node_modules/@sentry/vite-plugin": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@sentry/vite-plugin/-/vite-plugin-5.3.0.tgz",
"integrity": "sha512-qcoSzo4n2MulVQ70UUPLq6dTleb2a2HwL2wuwvAgWhPChrYTuk6A6mDg6aQb9fairPAwFPiU9PzOANpoDJcz1A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@sentry/bundler-plugin-core": "5.3.0",
"@sentry/rollup-plugin": "5.3.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@simple-libs/stream-utils": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@simple-libs/stream-utils/-/stream-utils-1.2.0.tgz",
@@ -4894,18 +4494,6 @@
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
}
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"dev": true,
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ajv": {
"version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -6635,19 +6223,6 @@
"url": "https://github.com/fb55/domhandler?sponsor=1"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
@@ -8474,19 +8049,6 @@
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"dev": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/husky": {
"version": "9.1.7",
"resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz",
@@ -10600,26 +10162,6 @@
"integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==",
"dev": true
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"dev": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": {
"version": "2.0.19",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz",
@@ -11179,16 +10721,6 @@
"node": ">=6"
}
},
"node_modules/progress": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
"integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -11199,13 +10731,6 @@
"react-is": "^16.13.1"
}
},
"node_modules/proxy-from-env": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
"dev": true,
"license": "MIT"
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -12784,12 +12309,6 @@
"integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==",
"license": "MIT"
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"dev": true
},
"node_modules/ts-api-utils": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@@ -13337,22 +12856,6 @@
"defaults": "^1.0.3"
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"dev": true
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"dev": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
-3
View File
@@ -45,7 +45,6 @@
"@giphy/js-util": "5.2.0",
"@giphy/react-components": "10.1.2",
"@sapphi-red/web-noise-suppressor": "0.3.5",
"@sentry/react": "10.53.1",
"@tanstack/react-query": "5.100.13",
"@tanstack/react-query-devtools": "5.100.13",
"@tanstack/react-virtual": "3.13.25",
@@ -78,7 +77,6 @@
"jotai": "2.20.0",
"linkify-react": "4.3.3",
"linkifyjs": "4.3.3",
"lodash": "4.18.1",
"matrix-js-sdk": "41.6.0-rc.0",
"matrix-widget-api": "1.17.0",
"millify": "6.1.0",
@@ -106,7 +104,6 @@
"@element-hq/element-call-embedded": "0.20.1",
"@rollup/plugin-inject": "5.0.5",
"@rollup/plugin-wasm": "6.2.2",
"@sentry/vite-plugin": "5.3.0",
"@types/chroma-js": "3.1.2",
"@types/file-saver": "2.0.7",
"@types/is-hotkey": "0.1.10",
+51
View File
@@ -2,6 +2,57 @@
"Organisms": {
"RoomCommon": {
"changed_room_name": " changed room name"
},
"CreateRoom": {
"chat_room": "Chat Room",
"chat_room_desc": "Messages, photos, and videos.",
"voice_room": "Voice Room",
"voice_room_desc": "Live audio and video conversations."
},
"ImageViewer": {
"download": "Download"
},
"Message": {
"open_location": "Open Location",
"thread": "Thread"
},
"ImageContent": {
"view": "View",
"spoiler": "Spoiler",
"retry": "Retry"
},
"DeviceVerification": {
"close": "Close",
"accept": "Accept",
"they_match": "They Match",
"okay": "Okay",
"do_not_match": "Do not Match",
"please_accept": "Please accept the request from other device.",
"waiting_accept": "Waiting for request to be accepted...",
"click_accept": "Click accept to start the verification process.",
"request_accepted": "Verification request has been accepted.",
"waiting_response": "Waiting for the response from other device...",
"starting_emoji": "Starting verification using emoji comparison...",
"confirm_emoji": "Confirm the emoji below are displayed on both devices, in the same order:",
"device_verified": "Your device is verified.",
"verification_canceled": "Verification has been canceled."
},
"UrlPreview": {
"join_server": "Join Server"
},
"InviteUser": {
"invite": "Invite"
},
"UploadBoard": {
"files": "Files",
"send": "Send",
"upload_failed": "Upload Failed"
},
"PasswordStage": {
"account_password": "Account Password",
"password": "Password",
"invalid_password": "Invalid Password!",
"authenticate_prompt": "To perform this action you need to authenticate yourself by entering you account password."
}
}
}
+12
View File
@@ -54,6 +54,18 @@
"src": "./res/android/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "./res/android/maskable-192x192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "maskable"
},
{
"src": "./res/android/maskable-512x512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
],
"categories": ["social", "communication", "productivity"],
Binary file not shown.

Before

Width:  |  Height:  |  Size: 208 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

+11 -2
View File
@@ -19,8 +19,17 @@ try {
writeFileSync(foldsPath, content, 'utf8');
console.log('Applied defensive Icon src guard to folds.');
} else {
console.warn('Warning: folds Icon patch target not found - may need updating.');
// Genuine "patch could not be applied" case: the target string is gone
// (folds renamed/restructured it) AND it isn't already patched. Fail hard
// so the postinstall hook / CI breaks loudly instead of silently shipping
// an unpatched folds (which crashes at render with "src is not a function").
console.error(
'ERROR: folds Icon patch target not found - folds may have updated. ' +
'Update the patch target string in scripts/patch-folds.mjs before building.',
);
process.exit(1);
}
} catch (e) {
console.warn('Warning: Could not patch folds:', e.message);
console.error('ERROR: Could not patch folds:', e.message);
process.exit(1);
}
+40 -4
View File
@@ -21,10 +21,25 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
const root = join(__dirname, '..');
const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts');
const CDN = 'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
// Single source of truth: the CDN base URL lives in avatarDecorations.ts as
// `export const DECORATION_CDN`. We extract it from there at runtime rather than
// re-declaring it here, so the build script and the app can never drift. This
// .mjs script can't cleanly import the browser-side .ts module (it's outside the
// Vite/TS app graph), so we parse the constant out of the file text instead.
// If you migrate the CDN, change it ONLY in avatarDecorations.ts.
const catalog = readFileSync(catalogPath, 'utf8');
const cdnMatch = catalog.match(/export const DECORATION_CDN\s*=\s*['"]([^'"]+)['"]/);
if (!cdnMatch) {
console.error(
'Could not find `export const DECORATION_CDN` in avatarDecorations.ts — ' +
'the constant may have been renamed. Update scripts/syncDecorations.mjs.',
);
process.exit(1);
}
const CDN = cdnMatch[1];
// Extract all slugs from the catalog file
const catalog = readFileSync(catalogPath, 'utf8');
const slugMatches = [...catalog.matchAll(/slug: '([^']+)'/g)].map((m) => m[1]);
if (slugMatches.length === 0) {
@@ -41,7 +56,8 @@ async function headCheck(slug) {
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
return { slug, ok: res.ok, status: res.status };
} catch {
return { slug, ok: false, status: 0 };
// Network/DNS/TLS failure — NOT a confirmation the file is gone.
return { slug, ok: false, status: 0, networkError: true };
}
}
@@ -53,7 +69,27 @@ for (let i = 0; i < slugMatches.length; i += BATCH) {
results.push(...batchResults);
}
const missing = results.filter((r) => !r.ok);
// Only a CONFIRMED HTTP 404 means the file is genuinely gone and safe to
// remove. A network error or any other non-ok status (5xx, 403, timeout) is
// ambiguous — the CDN may be unreachable — so refuse to remove anything and
// abort, otherwise a transient outage would wipe the whole catalog from source
// control (N119).
const transient = results.filter((r) => !r.ok && r.status !== 404);
if (transient.length > 0) {
console.error(
`Aborting: ${transient.length} decoration(s) returned a non-404 failure ` +
`(network error / server error). The CDN may be unreachable — refusing to ` +
`remove entries to avoid wiping the catalog.`,
);
transient
.slice(0, 8)
.forEach((r) =>
console.error(` ${r.slug}: ${r.networkError ? 'network error' : `HTTP ${r.status}`}`),
);
process.exit(1);
}
const missing = results.filter((r) => r.status === 404);
const found = results.filter((r) => r.ok);
if (missing.length === 0) {
+35 -15
View File
@@ -9,6 +9,7 @@ import {
config,
Dialog,
Icon,
IconButton,
Icons,
Overlay,
OverlayBackdrop,
@@ -51,6 +52,7 @@ import { mxcUrlToHttp, getMxIdLocalPart } from '../utils/matrix';
import { RoomAvatar, RoomIcon } from './room-avatar';
import { useRoomNavigate } from '../hooks/useRoomNavigate';
import { getChatBg } from '../features/lotus/chatBackground';
import { ExitFullscreenIcon, FullscreenIcon } from '../features/call/Controls';
import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
@@ -289,11 +291,16 @@ function IncomingCallBanner({ dm, info, onIgnore, onAnswer, onReject }: Incoming
);
// Single soft ping (non-looping) on arrival, respecting the chosen ringtone
// + volume. We intentionally do NOT loop here — the user is mid-call.
// + volume. We intentionally do NOT loop here — the user is mid-call — and we
// ping exactly once per incoming call, not again if the user happens to tweak
// ringtone settings while the banner is showing.
const pingedRef = useRef<string | undefined>(undefined);
useEffect(() => {
if (info.notificationType !== 'ring') return;
if (pingedRef.current === info.refEventId) return;
pingedRef.current = info.refEventId;
previewRingtone(ringtoneId, Math.max(0, Math.min(1, ringtoneVolume / 100)));
}, [info.notificationType, ringtoneId, ringtoneVolume]);
}, [info.notificationType, info.refEventId, ringtoneId, ringtoneVolume]);
useEffect(() => {
const remaining = info.senderTs + info.lifetime - Date.now();
@@ -715,7 +722,25 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
if (pipMode) {
if (!wasInPip) {
const saved = localStorage.getItem('pip-position');
const savedPos = saved ? (JSON.parse(saved) as { left: number; top: number }) : null;
let savedPos: { left: number; top: number } | null = null;
if (saved) {
try {
const raw = JSON.parse(saved) as { left?: unknown; top?: unknown };
// Validate shape + finiteness: a corrupt value would otherwise feed
// NaN into Math.min and produce an invalid `NaNpx` CSS value.
if (
raw &&
typeof raw.left === 'number' &&
Number.isFinite(raw.left) &&
typeof raw.top === 'number' &&
Number.isFinite(raw.top)
) {
savedPos = { left: raw.left, top: raw.top };
}
} catch {
savedPos = null;
}
}
el.style.right = 'auto';
el.style.bottom = 'auto';
if (savedPos) {
@@ -1072,10 +1097,13 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
>
<div style={{ display: 'flex', gap: config.space.S100, alignItems: 'center' }}>
{document.fullscreenEnabled && (
<button
<IconButton
type="button"
size="300"
radii="300"
variant="Surface"
fill="None"
aria-label={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
title={pipIsFullscreen ? 'Exit fullscreen' : 'Fullscreen camera'}
onClick={(e) => {
e.stopPropagation();
handlePipFullscreen();
@@ -1084,19 +1112,11 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
// Dark scrim is intentional for legibility over arbitrary video.
background: 'rgba(0,0,0,0.65)',
backdropFilter: 'blur(4px)',
border: 'none',
borderRadius: config.radii.R300,
padding: `${config.space.S100} ${config.space.S200}`,
color: '#fff',
fontSize: '13px',
cursor: 'pointer',
lineHeight: 1,
display: 'flex',
alignItems: 'center',
}}
>
{pipIsFullscreen ? '⊡' : '⛶'}
</button>
{pipIsFullscreen ? <ExitFullscreenIcon /> : <FullscreenIcon />}
</IconButton>
)}
<div
style={{
+26 -16
View File
@@ -5,6 +5,7 @@ import {
Verifier,
} from 'matrix-js-sdk/lib/crypto-api';
import React, { CSSProperties, useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { VerificationMethod } from 'matrix-js-sdk/lib/types';
import {
Box,
@@ -51,21 +52,23 @@ function WaitingMessage({ message }: WaitingMessageProps) {
type VerificationUnexpectedProps = { message: string; onClose: () => void };
function VerificationUnexpected({ message, onClose }: VerificationUnexpectedProps) {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>{message}</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Close</Text>
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
</Button>
</Box>
);
}
function VerificationWaitAccept() {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>Please accept the request from other device.</Text>
<WaitingMessage message="Waiting for request to be accepted..." />
<Text>{t('Organisms.DeviceVerification.please_accept')}</Text>
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_accept')} />
</Box>
);
}
@@ -74,12 +77,13 @@ type VerificationAcceptProps = {
onAccept: () => Promise<void>;
};
function VerificationAccept({ onAccept }: VerificationAcceptProps) {
const { t } = useTranslation();
const [acceptState, accept] = useAsyncCallback(onAccept);
const accepting = acceptState.status === AsyncStatus.Loading;
return (
<Box direction="Column" gap="400">
<Text>Click accept to start the verification process.</Text>
<Text>{t('Organisms.DeviceVerification.click_accept')}</Text>
<Button
variant="Primary"
fill="Solid"
@@ -87,17 +91,18 @@ function VerificationAccept({ onAccept }: VerificationAcceptProps) {
before={accepting && <Spinner size="100" variant="Primary" fill="Solid" />}
disabled={accepting}
>
<Text size="B400">Accept</Text>
<Text size="B400">{t('Organisms.DeviceVerification.accept')}</Text>
</Button>
</Box>
);
}
function VerificationWaitStart() {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>Verification request has been accepted.</Text>
<WaitingMessage message="Waiting for the response from other device..." />
<Text>{t('Organisms.DeviceVerification.request_accepted')}</Text>
<WaitingMessage message={t('Organisms.DeviceVerification.waiting_response')} />
</Box>
);
}
@@ -106,18 +111,20 @@ type VerificationStartProps = {
onStart: () => Promise<void>;
};
function AutoVerificationStart({ onStart }: VerificationStartProps) {
const { t } = useTranslation();
useEffect(() => {
onStart();
}, [onStart]);
return (
<Box direction="Column" gap="400">
<WaitingMessage message="Starting verification using emoji comparison..." />
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
</Box>
);
}
function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
const { t } = useTranslation();
const [confirmState, confirm] = useAsyncCallback(useCallback(() => sasData.confirm(), [sasData]));
const confirming =
@@ -125,7 +132,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
return (
<Box direction="Column" gap="400">
<Text>Confirm the emoji below are displayed on both devices, in the same order:</Text>
<Text>{t('Organisms.DeviceVerification.confirm_emoji')}</Text>
<Box
className={ContainerColor({ variant: 'SurfaceVariant' })}
style={{
@@ -157,7 +164,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
disabled={confirming}
before={confirming && <Spinner size="100" variant="Primary" />}
>
<Text size="B400">They Match</Text>
<Text size="B400">{t('Organisms.DeviceVerification.they_match')}</Text>
</Button>
<Button
variant="Primary"
@@ -165,7 +172,7 @@ function CompareEmoji({ sasData }: { sasData: ShowSasCallbacks }) {
onClick={() => sasData.mismatch()}
disabled={confirming}
>
<Text size="B400">Do not Match</Text>
<Text size="B400">{t('Organisms.DeviceVerification.do_not_match')}</Text>
</Button>
</Box>
</Box>
@@ -177,6 +184,7 @@ type SasVerificationProps = {
onCancel: () => void;
};
function SasVerification({ verifier, onCancel }: SasVerificationProps) {
const { t } = useTranslation();
const [sasData, setSasData] = useState<ShowSasCallbacks>();
useVerifierShowSas(verifier, setSasData);
@@ -192,7 +200,7 @@ function SasVerification({ verifier, onCancel }: SasVerificationProps) {
return (
<Box direction="Column" gap="400">
<WaitingMessage message="Starting verification using emoji comparison..." />
<WaitingMessage message={t('Organisms.DeviceVerification.starting_emoji')} />
</Box>
);
}
@@ -201,13 +209,14 @@ type VerificationDoneProps = {
onExit: () => void;
};
function VerificationDone({ onExit }: VerificationDoneProps) {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<div>
<Text>Your device is verified.</Text>
<Text>{t('Organisms.DeviceVerification.device_verified')}</Text>
</div>
<Button variant="Primary" fill="Solid" onClick={onExit}>
<Text size="B400">Okay</Text>
<Text size="B400">{t('Organisms.DeviceVerification.okay')}</Text>
</Button>
</Box>
);
@@ -217,11 +226,12 @@ type VerificationCanceledProps = {
onClose: () => void;
};
function VerificationCanceled({ onClose }: VerificationCanceledProps) {
const { t } = useTranslation();
return (
<Box direction="Column" gap="400">
<Text>Verification has been canceled.</Text>
<Text>{t('Organisms.DeviceVerification.verification_canceled')}</Text>
<Button variant="Secondary" fill="Soft" onClick={onClose}>
<Text size="B400">Close</Text>
<Text size="B400">{t('Organisms.DeviceVerification.close')}</Text>
</Button>
</Box>
);
@@ -1,5 +1,5 @@
import React from 'react';
import { Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds';
import { color, Icon, Icons, Text, Tooltip, TooltipProvider } from 'folds';
import { useUserVerifiedStatus } from '../hooks/useUserVerifiedStatus';
type MemberVerificationBadgeProps = {
@@ -9,8 +9,7 @@ type MemberVerificationBadgeProps = {
export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps) {
const vs = useUserVerifiedStatus(userId);
if (vs === 'unknown') return null;
const color =
vs === 'verified' ? 'var(--tc-positive-normal, #5effc4)' : 'var(--tc-warning-normal, #ffcc55)';
const iconColor = vs === 'verified' ? color.Success.Main : color.Warning.Main;
const label = vs === 'verified' ? 'Identity verified' : 'Not verified';
return (
<TooltipProvider
@@ -27,7 +26,7 @@ export function MemberVerificationBadge({ userId }: MemberVerificationBadgeProps
title={label}
style={{ display: 'inline-flex', alignItems: 'center', flexShrink: 0 }}
>
<Icon size="100" src={Icons.ShieldUser} style={{ color }} />
<Icon size="100" src={Icons.ShieldUser} style={{ color: iconColor }} />
</span>
)}
</TooltipProvider>
@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, Icon, Icons, config, IconSrc } from 'folds';
import { SequenceCard } from '../sequence-card';
import { SettingTile } from '../setting-tile';
@@ -17,6 +18,7 @@ export function CreateRoomTypeSelector({
disabled,
getIcon,
}: CreateRoomTypeSelectorProps) {
const { t } = useTranslation();
return (
<Box shrink="No" direction="Column" gap="100">
<SequenceCard
@@ -36,10 +38,10 @@ export function CreateRoomTypeSelector({
>
<Box gap="200" alignItems="Baseline">
<Text size="H6" style={{ flexShrink: 0 }}>
Chat Room
{t('Organisms.CreateRoom.chat_room')}
</Text>
<Text size="T300" priority="300" truncate>
- Messages, photos, and videos.
- {t('Organisms.CreateRoom.chat_room_desc')}
</Text>
</Box>
</SettingTile>
@@ -61,10 +63,10 @@ export function CreateRoomTypeSelector({
>
<Box gap="200" alignItems="Baseline">
<Text size="H6" style={{ flexShrink: 0 }}>
Voice Room
{t('Organisms.CreateRoom.voice_room')}
</Text>
<Text size="T300" priority="300" truncate>
- Live audio and video conversations.
- {t('Organisms.CreateRoom.voice_room_desc')}
</Text>
<BetaNoticeBadge />
</Box>
@@ -1,4 +1,5 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import FileSaver from 'file-saver';
import classNames from 'classnames';
import { Box, Chip, Header, Icon, IconButton, Icons, Text, as } from 'folds';
@@ -15,6 +16,7 @@ export type ImageViewerProps = {
export const ImageViewer = as<'div', ImageViewerProps>(
({ className, alt, src, requestClose, ...props }, ref) => {
const { t } = useTranslation();
const { zoom, zoomIn, zoomOut, setZoom } = useZoom(0.2);
const { pan, cursor, onMouseDown } = usePan(zoom !== 1);
@@ -69,7 +71,7 @@ export const ImageViewer = as<'div', ImageViewerProps>(
radii="300"
before={<Icon size="50" src={Icons.Download} />}
>
<Text size="B300">Download</Text>
<Text size="B300">{t('Organisms.ImageViewer.download')}</Text>
</Chip>
</Box>
</Header>
@@ -7,6 +7,7 @@ import React, {
useRef,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import {
Overlay,
OverlayBackdrop,
@@ -66,6 +67,7 @@ type InviteUserProps = {
requestClose: () => void;
};
export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const modalStyle = useModalStyle(560);
const alive = useAlive();
@@ -194,7 +196,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
>
<Box grow="Yes">
<Text size="H4" truncate>
Invite
{t('Organisms.InviteUser.invite')}
</Text>
</Box>
<Box shrink="No" gap="100" alignItems="Center">
@@ -351,7 +353,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) {
disabled={!validUserId || inviting}
before={inviting && <Spinner size="200" variant="Primary" fill="Solid" />}
>
<Text size="B400">Invite</Text>
<Text size="B400">{t('Organisms.InviteUser.invite')}</Text>
</Button>
</Box>
</Box>
@@ -1,4 +1,5 @@
import React, { CSSProperties, ReactNode, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Button, config, Icon, Icons, Text, color, toRem } from 'folds';
import { IContent } from 'matrix-js-sdk';
import { JUMBO_EMOJI_REG, URL_REG } from '../../utils/regex';
@@ -507,6 +508,7 @@ type MLocationProps = {
content: IContent;
};
export function MLocation({ content }: MLocationProps) {
const { t } = useTranslation();
const geoUri = content.geo_uri;
if (typeof geoUri !== 'string') return <BrokenContent />;
const location = parseGeoUri(geoUri);
@@ -527,7 +529,7 @@ export function MLocation({ content }: MLocationProps) {
style={{
width: '280px',
height: '160px',
border: '1px solid var(--bg-surface-border)',
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: '8px',
display: 'block',
}}
@@ -549,7 +551,7 @@ export function MLocation({ content }: MLocationProps) {
radii="300"
before={<Icon src={Icons.External} size="50" />}
>
<Text size="B300">Open Location</Text>
<Text size="B300">{t('Organisms.Message.open_location')}</Text>
</Button>
</Box>
);
+17 -13
View File
@@ -1,6 +1,7 @@
import { Box, Icon, Icons, Text, as, color, toRem } from 'folds';
import { EventTimelineSet, Room } from 'matrix-js-sdk';
import React, { MouseEventHandler, ReactNode, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import classNames from 'classnames';
import { getMemberDisplayName, trimReplyFromBody } from '../../utils/room';
import { getMxIdLocalPart } from '../../utils/matrix';
@@ -37,19 +38,22 @@ export const ReplyLayout = as<'div', ReplyLayoutProps>(
),
);
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => (
<Box
shrink="No"
className={css.ThreadIndicator}
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
<Icon size="50" src={Icons.Thread} />
<Text size="L400">Thread</Text>
</Box>
));
export const ThreadIndicator = as<'div'>(({ ...props }, ref) => {
const { t } = useTranslation();
return (
<Box
shrink="No"
className={css.ThreadIndicator}
alignItems="Center"
gap="100"
{...props}
ref={ref}
>
<Icon size="50" src={Icons.Thread} />
<Text size="L400">{t('Organisms.Message.thread')}</Text>
</Box>
);
});
type ReplyProps = {
room: Room;
@@ -1,4 +1,5 @@
import React, { ReactNode, useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Box,
@@ -81,6 +82,7 @@ export const ImageContent = as<'div', ImageContentProps>(
const useAuthentication = useMediaAuthentication();
const blurHash = validBlurHash(info?.[MATRIX_BLUR_HASH_PROPERTY_NAME]);
const { t } = useTranslation();
const [load, setLoad] = useState(false);
const [error, setError] = useState(false);
const [viewer, setViewer] = useState(false);
@@ -168,7 +170,7 @@ export const ImageContent = as<'div', ImageContentProps>(
onClick={loadSrc}
before={<Icon size="Inherit" src={Icons.Photo} filled />}
>
<Text size="B300">View</Text>
<Text size="B300">{t('Organisms.ImageContent.view')}</Text>
</Button>
</Box>
)}
@@ -212,7 +214,7 @@ export const ImageContent = as<'div', ImageContentProps>(
}
}}
>
<Text size="B300">Spoiler</Text>
<Text size="B300">{t('Organisms.ImageContent.spoiler')}</Text>
</Chip>
)}
</TooltipProvider>
@@ -247,7 +249,7 @@ export const ImageContent = as<'div', ImageContentProps>(
onClick={handleRetry}
before={<Icon size="Inherit" src={Icons.Warning} filled />}
>
<Text size="B300">Retry</Text>
<Text size="B300">{t('Organisms.ImageContent.retry')}</Text>
</Button>
)}
</TooltipProvider>
@@ -0,0 +1,95 @@
import React, { MouseEventHandler, useState } from 'react';
import { Box, Button, config, Icon, Icons, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { stopPropagation } from '../../utils/keyboard';
export type SettingsSelectOption<T extends string> = {
value: T;
label: string;
disabled?: boolean;
};
/**
* A folds-native dropdown (Button + PopOut + Menu) matching Cinny's select
* pattern used instead of a raw `<select>`, which renders OS-styled and
* breaks under non-default themes.
*/
export function SettingsSelect<T extends string>({
value,
options,
onChange,
'aria-label': ariaLabel,
}: {
value: T;
options: SettingsSelectOption<T>[];
onChange: (v: T) => void;
'aria-label'?: string;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (v: T) => {
onChange(v);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
aria-label={ariaLabel}
aria-haspopup="menu"
aria-expanded={!!menuCords}
>
<Text size="T300">{selectedLabel}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{options.map((opt) => (
<MenuItem
key={opt.value}
size="300"
variant={opt.value === value ? 'Primary' : 'Surface'}
radii="300"
disabled={opt.disabled}
onClick={() => !opt.disabled && handleSelect(opt.value)}
>
<Text size="T300">{opt.label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
+2 -2
View File
@@ -17,10 +17,10 @@ export const Sidebar = style([
]);
export const SidebarGlass = style({
backgroundColor: 'rgba(3, 5, 8, 0.55)',
backgroundColor: `color-mix(in srgb, ${color.Surface.Container} 55%, transparent)`,
backdropFilter: 'blur(12px)',
WebkitBackdropFilter: 'blur(12px)',
borderRight: '1px solid rgba(255,255,255,0.06)',
borderRight: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
});
export const SidebarStack = style([
@@ -1,5 +1,6 @@
import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds';
import React, { FormEventHandler } from 'react';
import { useTranslation } from 'react-i18next';
import { AuthType } from 'matrix-js-sdk';
import { StageComponentProps } from './types';
import { ErrorCode } from '../../cs-errorcode';
@@ -13,6 +14,7 @@ export function PasswordStage({
}: StageComponentProps & {
userId: string;
}) {
const { t } = useTranslation();
const { errorCode, error, session } = stageData;
const handleFormSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@@ -44,7 +46,7 @@ export function PasswordStage({
>
<Box grow="Yes">
<Text as="h2" size="H4">
Account Password
{t('Organisms.PasswordStage.account_password')}
</Text>
</Box>
<IconButton size="300" onClick={onCancel} radii="300" aria-label="Cancel">
@@ -59,12 +61,9 @@ export function PasswordStage({
gap="400"
>
<Box direction="Column" gap="400">
<Text size="T200">
To perform this action you need to authenticate yourself by entering you account
password.
</Text>
<Text size="T200">{t('Organisms.PasswordStage.authenticate_prompt')}</Text>
<Box direction="Column" gap="100">
<Text size="L400">Password</Text>
<Text size="L400">{t('Organisms.PasswordStage.password')}</Text>
<PasswordInput size="400" name="passwordInput" outlined autoFocus required />
{errorCode && (
<Box alignItems="Center" gap="100" style={{ color: color.Critical.Main }}>
@@ -72,7 +71,7 @@ export function PasswordStage({
<Text size="T200">
<b>
{errorCode === ErrorCode.M_FORBIDDEN
? 'Invalid Password!'
? t('Organisms.PasswordStage.invalid_password')
: `${errorCode}: ${error}`}
</b>
</Text>
@@ -1,4 +1,5 @@
import React, { MutableRefObject, ReactNode, useImperativeHandle, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Badge, Box, Chip, Header, Icon, Icons, Spinner, Text, as, percent } from 'folds';
import classNames from 'classnames';
import { useAtomValue } from 'jotai';
@@ -43,6 +44,7 @@ export function UploadBoardHeader({
onSend,
imperativeHandlerRef,
}: UploadBoardHeaderProps) {
const { t } = useTranslation();
const sendingRef = useRef(false);
const uploads = useAtomValue(uploadFamilyObserverAtom);
@@ -88,7 +90,7 @@ export function UploadBoardHeader({
gap="100"
>
<Icon src={open ? Icons.ChevronTop : Icons.ChevronRight} size="50" />
<Text size="H6">Files</Text>
<Text size="H6">{t('Organisms.UploadBoard.files')}</Text>
</Box>
<Box className={css.UploadBoardHeaderContent} alignItems="Center" gap="100">
{isSuccess && (
@@ -100,12 +102,12 @@ export function UploadBoardHeader({
outlined
after={<Icon src={Icons.Send} size="50" filled />}
>
<Text size="B300">Send</Text>
<Text size="B300">{t('Organisms.UploadBoard.send')}</Text>
</Chip>
)}
{isError && !open && (
<Badge variant="Critical" fill="Solid" radii="300">
<Text size="L400">Upload Failed</Text>
<Text size="L400">{t('Organisms.UploadBoard.upload_failed')}</Text>
</Badge>
)}
{!isSuccess && !isError && !open && (
@@ -1,4 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { IPreviewUrlResponse } from 'matrix-js-sdk';
import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds';
import { ImageOverlay } from '../ImageOverlay';
@@ -1343,6 +1344,7 @@ function WikipediaCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }
}
function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse }) {
const { t } = useTranslation();
const title = prev['og:title'] ?? '';
const description = prev['og:description'] ?? '';
const iconUrl = (prev['og:image'] as string | undefined) ?? '';
@@ -1383,7 +1385,9 @@ function DiscordCard({ url, prev }: { url: string; prev: IPreviewUrlResponse })
priority="300"
>
<SiteBadge label="Discord" colorClass={previewCss.BadgeDiscord} />
<span style={{ marginLeft: '6px', opacity: 0.7, fontSize: '0.85em' }}>Join Server</span>
<span style={{ marginLeft: '6px', opacity: 0.7, fontSize: '0.85em' }}>
{t('Organisms.UrlPreview.join_server')}
</span>
</Text>
{title && (
<Text truncate priority="400">
@@ -91,10 +91,10 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
{(status) => {
const deviceColor =
status === VerificationStatus.Verified
? 'var(--tc-positive-normal, #5effc4)'
? color.Success.Main
: status === VerificationStatus.Unverified
? 'var(--tc-warning-normal, #ffcc55)'
: 'var(--tc-surface-low-contrast)';
? color.Warning.Main
: color.SurfaceVariant.OnContainer;
return (
<Box alignItems="Center" gap="200">
<Icon size="100" src={Icons.ShieldUser} style={{ color: deviceColor, flexShrink: 0 }} />
@@ -106,7 +106,7 @@ function UserDeviceRow({ userId, device }: UserDeviceRowProps) {
<Text
size="T200"
truncate
style={{ color: 'var(--tc-surface-low-contrast)', fontFamily: 'monospace' }}
style={{ color: color.SurfaceVariant.OnContainer, fontFamily: 'monospace' }}
>
{device.deviceId}
</Text>
@@ -160,7 +160,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
direction="Column"
gap="100"
style={{
background: 'var(--bg-surface-variant)',
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
padding: config.space.S300,
}}
@@ -171,7 +171,7 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
<Text size="T300">
<b>Sessions</b>
</Text>
<Text size="T200" style={{ color: 'var(--tc-surface-low-contrast)' }}>
<Text size="T200" style={{ color: color.SurfaceVariant.OnContainer }}>
{devices.length}
</Text>
</Box>
+23 -65
View File
@@ -3,6 +3,7 @@ import {
Box,
Button,
Chip,
color,
config,
Icon,
IconButton,
@@ -87,7 +88,6 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const [pttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey] = useSetting(settingsAtom, 'deafenKey');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [pttActive, setPttActive] = useState(false);
// Track microphone via ref so the PTT effect doesn't need it as a dep (avoids listener churn)
@@ -244,68 +244,26 @@ export function CallControls({ callEmbed }: CallControlsProps) {
justifyContent="Center"
alignItems="Center"
>
{pttMode &&
(lotusTerminal ? (
<Box
style={{
position: 'absolute',
top: '-2.5rem',
left: '50%',
transform: 'translateX(-50%)',
background: pttActive ? 'var(--lt-accent-green-dim)' : 'var(--lt-accent-orange-dim)',
border: `1px solid ${pttActive ? 'var(--lt-accent-green-border)' : 'var(--lt-accent-orange-border)'}`,
borderRadius: '99px',
padding: '0.2rem 0.9rem',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
>
<Text
size="T200"
style={{
color: pttActive ? 'var(--lt-accent-green)' : 'var(--lt-accent-orange)',
fontWeight: 700,
letterSpacing: '0.08em',
fontFamily: 'JetBrains Mono, monospace',
}}
>
{pttActive ? (
<>
<span
style={{
display: 'inline-block',
animation: 'pttLivePulse 900ms ease-in-out infinite',
}}
>
</span>
{' LIVE'}
</>
) : (
`PTT — Hold ${pttKeyLabel}`
)}
</Text>
</Box>
) : (
<Chip
variant={pttActive ? 'Success' : 'Warning'}
fill="Soft"
radii="400"
style={{
position: 'absolute',
top: '-2.2rem',
left: '50%',
transform: 'translateX(-50%)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
outlined
>
<Text size="T200" style={{ fontWeight: 700 }}>
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
</Text>
</Chip>
))}
{pttMode && (
<Chip
variant={pttActive ? 'Success' : 'Warning'}
fill="Soft"
radii="400"
style={{
position: 'absolute',
top: '-2.2rem',
left: '50%',
transform: 'translateX(-50%)',
pointerEvents: 'none',
whiteSpace: 'nowrap',
}}
outlined
>
<Text size="T200" style={{ fontWeight: 700 }}>
{pttActive ? '● Live' : `PTT — Hold ${pttKeyLabel}`}
</Text>
</Chip>
)}
{shareConfirm && (
<>
<div
@@ -319,8 +277,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
bottom: '110%',
left: '50%',
transform: 'translateX(-50%)',
background: 'var(--bg-surface)',
border: '1px solid var(--bg-surface-border)',
background: color.Surface.Container,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: '0.75rem',
padding: '1rem 1.25rem',
zIndex: 100,
+2 -5
View File
@@ -164,7 +164,7 @@ function CallLoadErrorMessage() {
const setCallEmbed = useSetAtom(callEmbedAtom);
// Disposing the embed tears down the hung iframe and returns the user to the
// prescreen, from which they can join again ("Retry") or simply walk away.
// prescreen, where they can choose to join again.
const dismiss = () => setCallEmbed(undefined);
return (
@@ -180,11 +180,8 @@ function CallLoadErrorMessage() {
The call failed to load. Check your connection and try again.
</Text>
<Box gap="200" alignItems="Center">
<Button variant="Primary" size="300" radii="400" onClick={dismiss}>
<Text size="B400">Retry</Text>
</Button>
<Button variant="Secondary" fill="Soft" size="300" radii="400" onClick={dismiss}>
<Text size="B400">Leave</Text>
<Text size="B400">Back</Text>
</Button>
</Box>
</Box>
+2 -2
View File
@@ -166,13 +166,13 @@ export function ScreenShareButton({ enabled, onToggle }: ScreenShareButtonProps)
);
}
const FullscreenIcon = () => (
export const FullscreenIcon = () => (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M3 3h6v2H5v4H3V3zm12 0h6v6h-2V5h-4V3zM3 15h2v4h4v2H3v-6zm16 4h-4v2h6v-6h-2v4z" />
</svg>
);
const ExitFullscreenIcon = () => (
export const ExitFullscreenIcon = () => (
<svg width="1em" height="1em" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 3H7v4H3v2h6V3zm6 0v6h6V7h-4V3h-2zM3 13v2h4v4h2v-6H3zm14 4v-4h2v6h-6v-2h4z" />
</svg>
+2 -5
View File
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Button, Icon, Icons, Spinner, Text } from 'folds';
import { Box, Button, color, Icon, Icons, Spinner, Text } from 'folds';
import { SequenceCard } from '../../components/sequence-card';
import * as css from './styles.css';
import { ChatButton, ControlDivider, MicrophoneButton, SoundButton, VideoButton } from './Controls';
@@ -78,10 +78,7 @@ export function PrescreenControls({ canJoin }: PrescreenControlsProps) {
</Box>
<Box grow="Yes" direction="Column" gap="200">
{micDenied && (
<Text
size="T200"
style={{ color: 'var(--tc-critical-high, #e53e3e)', textAlign: 'center' }}
>
<Text size="T200" style={{ color: color.Critical.Main, textAlign: 'center' }}>
Microphone access is blocked. Enable it in your browser settings to join.
</Text>
)}
@@ -1,3 +1,8 @@
// Single source of truth for the avatar-decoration CDN base URL.
// scripts/syncDecorations.mjs reads this exact `DECORATION_CDN` declaration out
// of this file at runtime (by regex) instead of re-declaring it, so the two can
// never drift. If you migrate the CDN, change it here ONLY — keep the
// `export const DECORATION_CDN = '...'` shape so the sync script can still parse it.
export const DECORATION_CDN =
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
@@ -1,10 +1,23 @@
import React, { RefObject, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Text, Box, Icon, Icons, config, Spinner, IconButton, Line, toRem, Button } from 'folds';
import { useAtomValue } from 'jotai';
import {
Text,
Box,
Icon,
Icons,
color,
config,
Spinner,
IconButton,
Line,
toRem,
Button,
} from 'folds';
import { useAtom, useAtomValue } from 'jotai';
import { useVirtualizer } from '@tanstack/react-virtual';
import { useInfiniteQuery } from '@tanstack/react-query';
import { useSearchParams } from 'react-router-dom';
import { EventTimeline, EventType, Room, SearchOrderBy } from 'matrix-js-sdk';
import { RoomPinnedEventsEventContent } from 'matrix-js-sdk/lib/types';
import { PageHero, PageHeroEmpty, PageHeroSection } from '../../components/page';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { _SearchPathSearchParams } from '../../pages/paths';
@@ -18,8 +31,18 @@ import { decodeSearchParamValueArray, encodeSearchParamValueArray } from '../../
import { useRooms } from '../../state/hooks/roomList';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { mDirectAtom } from '../../state/mDirectList';
import { MessageSearchParams, useMessageSearch } from './useMessageSearch';
import { getStateEvent } from '../../utils/room';
import { StateEvent } from '../../../types/matrix/room';
import {
filterGroupsByMsgType,
filterGroupsByPinned,
MessageSearchParams,
MsgTypeFilter,
ResultGroup,
useMessageSearch,
} from './useMessageSearch';
import { useLocalMessageSearch } from './useLocalMessageSearch';
import { addRecentSearch, recentSearchesAtom } from '../../state/recentSearches';
import { SearchResultGroup } from './SearchResultGroup';
import { SearchInput } from './SearchInput';
import { SearchFilters } from './SearchFilters';
@@ -101,7 +124,7 @@ function EncryptedRoomCachePanel({ roomIds, onLoaded }: EncryptedRoomCachePanelP
gap="200"
style={{
padding: config.space.S200,
background: 'var(--bg-surface-variant)',
background: color.SurfaceVariant.Container,
borderRadius: config.radii.R300,
}}
>
@@ -167,6 +190,15 @@ export function MessageSearch({
const searchInputRef = useRef<HTMLInputElement>(null) as React.RefObject<HTMLInputElement>;
const scrollTopAnchorRef = useRef<HTMLDivElement>(null) as React.RefObject<HTMLDivElement>;
// Client-side msgtype post-filter. Kept local — the Matrix search API cannot
// filter by msgtype server-side, so the server request is unaffected.
const [msgTypeFilters, setMsgTypeFilters] = useState<MsgTypeFilter[]>([]);
// Client-side "pinned only" post-filter. Narrows displayed results to events
// currently pinned in their room (`m.room.pinned_events`). Server-unaffected.
const [pinnedOnly, setPinnedOnly] = useState(false);
const [recentSearches, setRecentSearches] = useAtom(recentSearchesAtom);
const [searchParams, setSearchParams] = useSearchParams();
const searchPathSearchParams = useSearchPathSearchParams(searchParams);
const { navigateRoom } = useRoomNavigate();
@@ -257,7 +289,45 @@ export function MessageSearch({
getNextPageParam: (lastPage) => lastPage.nextToken,
});
const groups = useMemo(() => data?.pages.flatMap((result) => result.groups) ?? [], [data]);
// Shared client-side post-filter (msgtype + pinned) applied to BOTH the
// server results and the local/encrypted-cache results, so the filter chips
// narrow the whole UI consistently rather than only the server section.
const applyResultFilters = useCallback(
(allGroups: ResultGroup[]): ResultGroup[] => {
const byMsgType = filterGroupsByMsgType(allGroups, msgTypeFilters);
if (!pinnedOnly) return byMsgType;
// Build a per-room pinned-event lookup. Heavy Matrix reads stay here
// (where `mx` is available); the pure helper only consumes the predicate.
const pinnedByRoom = new Map<string, Set<string>>();
const isPinned = (roomId: string, eventId: string): boolean => {
let pinned = pinnedByRoom.get(roomId);
if (!pinned) {
const room = mx.getRoom(roomId);
const content = room
? getStateEvent(
room,
StateEvent.RoomPinnedEvents,
)?.getContent<RoomPinnedEventsEventContent>()
: undefined;
pinned = new Set(content?.pinned ?? []);
pinnedByRoom.set(roomId, pinned);
}
return pinned.has(eventId);
};
return filterGroupsByPinned(byMsgType, pinnedOnly, isPinned);
},
[msgTypeFilters, pinnedOnly, mx],
);
const groups = useMemo(() => {
const allGroups = data?.pages.flatMap((result) => result.groups) ?? [];
return applyResultFilters(allGroups);
}, [data, applyResultFilters]);
const localGroups = useMemo(
() => (localResult ? applyResultFilters(localResult.groups) : []),
[localResult, applyResultFilters],
);
const highlights = useMemo(() => {
const mixed = data?.pages.flatMap((result) => result.highlights);
return Array.from(new Set(mixed));
@@ -278,7 +348,29 @@ export function MessageSearch({
newParams.append('term', term);
return newParams;
});
setRecentSearches((prev) => addRecentSearch(prev, term));
};
const handleRecentSearch = (term: string) => {
if (searchInputRef.current) {
searchInputRef.current.value = term;
}
handleSearch(term);
};
const handleToggleMsgTypeFilter = useCallback((msgType: MsgTypeFilter) => {
setMsgTypeFilters((prev) =>
prev.includes(msgType) ? prev.filter((t) => t !== msgType) : [...prev, msgType],
);
}, []);
const handleTogglePinnedOnly = useCallback(() => {
setPinnedOnly((prev) => !prev);
}, []);
const handleClearRecentSearches = useCallback(() => {
setRecentSearches([]);
}, [setRecentSearches]);
const handleSearchClear = () => {
if (searchInputRef.current) {
searchInputRef.current.value = '';
@@ -407,6 +499,9 @@ export function MessageSearch({
onSearch={handleSearch}
onReset={handleSearchClear}
onSenderAdd={handleSenderAdd}
recentSearches={recentSearches}
onRecentSearch={handleRecentSearch}
onClearRecentSearches={handleClearRecentSearches}
/>
<SearchFilters
defaultRoomsFilterName={defaultRoomsFilterName}
@@ -425,6 +520,10 @@ export function MessageSearch({
onDateRangeChange={handleDateRangeChange}
containsUrl={msgSearchParams.containsUrl}
onContainsUrlChange={handleContainsUrlChange}
msgTypeFilters={msgTypeFilters}
onToggleMsgTypeFilter={handleToggleMsgTypeFilter}
pinnedOnly={pinnedOnly}
onTogglePinnedOnly={handleTogglePinnedOnly}
/>
</Box>
@@ -550,7 +649,7 @@ export function MessageSearch({
)}
{localResult &&
(senderOnlyMode ? localResult.groups.length > 0 : localResult.encryptedRoomsCount > 0) && (
(senderOnlyMode ? localGroups.length > 0 : localResult.encryptedRoomsCount > 0) && (
<Box direction="Column" gap="300">
<Box direction="Column" gap="200">
<Box alignItems="Center" gap="200">
@@ -565,15 +664,15 @@ export function MessageSearch({
<Text size="T300" priority="300">
{senderOnlyMode
? `Showing locally cached messages from this user across all rooms. Open more rooms or load history below to extend coverage.`
: localResult.groups.length > 0
: localGroups.length > 0
? `Showing locally cached messages from ${localResult.searchedRoomsCount} encrypted room${localResult.searchedRoomsCount !== 1 ? 's' : ''}. Load more history below to extend coverage.`
: `No matches in your local cache. Load messages below to search further back.`}
</Text>
<Line size="300" variant="Surface" />
</Box>
{localResult.groups.length > 0 && (
{localGroups.length > 0 && (
<Box direction="Column" gap="300">
{localResult.groups.map((group) => {
{localGroups.map((group) => {
const groupRoom = mx.getRoom(group.roomId);
if (!groupRoom) return null;
return (
@@ -25,6 +25,7 @@ import {
Input,
Badge,
RectCords,
IconSrc,
} from 'folds';
import { SearchOrderBy } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
@@ -41,6 +42,13 @@ import {
import { DebounceOptions, useDebounce } from '../../hooks/useDebounce';
import { VirtualTile } from '../../components/virtualizer';
import { stopPropagation } from '../../utils/keyboard';
import { MsgTypeFilter } from './useMessageSearch';
const MSG_TYPE_FILTER_OPTIONS: { msgType: MsgTypeFilter; label: string; icon: IconSrc }[] = [
{ msgType: 'm.image', label: 'Images', icon: Icons.Photo },
{ msgType: 'm.file', label: 'Files', icon: Icons.File },
{ msgType: 'm.video', label: 'Video', icon: Icons.VideoCamera },
];
type OrderButtonProps = {
order?: string;
@@ -674,6 +682,10 @@ type SearchFiltersProps = {
onDateRangeChange: (fromTs?: number, toTs?: number) => void;
containsUrl?: boolean;
onContainsUrlChange: (value?: boolean) => void;
msgTypeFilters: MsgTypeFilter[];
onToggleMsgTypeFilter: (msgType: MsgTypeFilter) => void;
pinnedOnly: boolean;
onTogglePinnedOnly: () => void;
};
export function SearchFilters({
defaultRoomsFilterName,
@@ -692,6 +704,10 @@ export function SearchFilters({
onDateRangeChange,
containsUrl,
onContainsUrlChange,
msgTypeFilters,
onToggleMsgTypeFilter,
pinnedOnly,
onTogglePinnedOnly,
}: SearchFiltersProps) {
const mx = useMatrixClient();
@@ -795,6 +811,56 @@ export function SearchFilters({
>
<Text size="T200">Has link</Text>
</Chip>
{MSG_TYPE_FILTER_OPTIONS.map(({ msgType, label, icon }) => {
const active = msgTypeFilters.includes(msgType);
return (
<Chip
key={msgType}
variant={active ? 'Success' : 'SurfaceVariant'}
outlined={active}
radii="Pill"
aria-pressed={active}
before={<Icon size="100" src={icon} />}
after={
active ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onToggleMsgTypeFilter(msgType);
}}
/>
) : undefined
}
onClick={() => onToggleMsgTypeFilter(msgType)}
>
<Text size="T200">{label}</Text>
</Chip>
);
})}
<Chip
variant={pinnedOnly ? 'Success' : 'SurfaceVariant'}
outlined={pinnedOnly}
radii="Pill"
aria-pressed={pinnedOnly}
before={<Icon size="100" src={Icons.Pin} />}
after={
pinnedOnly ? (
<Icon
size="50"
src={Icons.Cross}
onClick={(e) => {
e.stopPropagation();
onTogglePinnedOnly();
}}
/>
) : undefined
}
onClick={onTogglePinnedOnly}
>
<Text size="T200">Pinned</Text>
</Chip>
<DateRangeButton fromTs={fromTs} toTs={toTs} onChange={onDateRangeChange} />
<OrderButton order={order} onChange={onOrderChange} />
</Box>
@@ -43,6 +43,9 @@ type SearchInputProps = {
onSearch: (term: string) => void;
onReset: () => void;
onSenderAdd?: (userId: string) => void;
recentSearches?: string[];
onRecentSearch?: (term: string) => void;
onClearRecentSearches?: () => void;
};
export function SearchInput({
active,
@@ -51,6 +54,9 @@ export function SearchInput({
onSearch,
onReset,
onSenderAdd,
recentSearches,
onRecentSearch,
onClearRecentSearches,
}: SearchInputProps) {
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
@@ -58,6 +64,8 @@ export function SearchInput({
const [fromQuery, setFromQuery] = useState('');
const [showSuggestions, setShowSuggestions] = useState(false);
const [focused, setFocused] = useState(false);
const [inputEmpty, setInputEmpty] = useState(true);
// Collect users from room member lists, scored for relevance.
// Score: same homeserver → +1000, each shared room → +1.
@@ -121,6 +129,7 @@ export function SearchInput({
const handleChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
const value = evt.target.value;
setInputEmpty(value.trim() === '');
const match = FROM_TYPING_REGEX.exec(value);
if (match) {
setFromQuery(match[1]);
@@ -130,9 +139,24 @@ export function SearchInput({
}
};
const handleFocus = () => {
setFocused(true);
};
// Close autocomplete when input loses focus — delay so item clicks fire first
const handleBlur = () => {
setTimeout(closeAutocomplete, 150);
setTimeout(() => {
closeAutocomplete();
setFocused(false);
}, 150);
};
const handleRecentClick = (term: string) => {
if (searchInputRef.current) {
searchInputRef.current.value = term;
}
setInputEmpty(false);
onRecentSearch?.(term);
};
const handleSearchSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
@@ -181,6 +205,7 @@ export function SearchInput({
placeholder="Search messages or type from:@user to filter by sender"
autoComplete="off"
onChange={handleChange}
onFocus={handleFocus}
onBlur={handleBlur}
before={
active && loading ? (
@@ -267,6 +292,52 @@ export function SearchInput({
</Menu>
</div>
)}
{focused &&
inputEmpty &&
suggestedUsers.length === 0 &&
recentSearches &&
recentSearches.length > 0 && (
<div
style={{
position: 'absolute',
top: '100%',
left: 0,
right: 0,
zIndex: 999,
marginTop: config.space.S100,
}}
>
<Menu variant="Surface" style={{ width: '100%' }}>
<Box direction="Column" gap="200" style={{ padding: config.space.S300 }}>
<Box alignItems="Center" justifyContent="SpaceBetween" gap="200">
<Text size="L400">Recent searches</Text>
<Chip
variant="SurfaceVariant"
radii="Pill"
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onClick={() => onClearRecentSearches?.()}
>
<Text size="T200">Clear</Text>
</Chip>
</Box>
<Box gap="200" wrap="Wrap">
{recentSearches.map((term) => (
<Chip
key={term}
variant="SurfaceVariant"
radii="Pill"
before={<Icon size="50" src={Icons.RecentClock} />}
onMouseDown={(e: React.MouseEvent) => e.preventDefault()}
onClick={() => handleRecentClick(term)}
>
<Text size="T200">{term}</Text>
</Chip>
))}
</Box>
</Box>
</Menu>
</div>
)}
</Box>
</Box>
);
@@ -26,6 +26,51 @@ export type SearchResult = {
groups: ResultGroup[];
};
// Client-side msgtype post-filter. The Matrix search API cannot filter by
// msgtype server-side, so this is applied to already-returned results.
export type MsgTypeFilter = 'm.image' | 'm.file' | 'm.video';
/**
* Filter result groups to items whose event msgtype is in `msgTypes` (OR/union).
* Empty/absent filter returns groups unchanged. Now-empty groups are dropped.
*/
export const filterGroupsByMsgType = (
groups: ResultGroup[],
msgTypes: MsgTypeFilter[],
): ResultGroup[] => {
if (msgTypes.length === 0) return groups;
const allowed = new Set<string>(msgTypes);
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => {
const msgtype = item.event.content?.msgtype;
return typeof msgtype === 'string' && allowed.has(msgtype);
}),
}))
.filter((group) => group.items.length > 0);
};
/**
* Filter result groups to items whose event is currently pinned in its room.
* `isPinned(roomId, eventId)` returns whether the event is in the room's
* `m.room.pinned_events` set. When `enabled` is false, groups are returned
* unchanged. Now-empty groups are dropped.
*/
export const filterGroupsByPinned = (
groups: ResultGroup[],
enabled: boolean,
isPinned: (roomId: string, eventId: string) => boolean,
): ResultGroup[] => {
if (!enabled) return groups;
return groups
.map((group) => ({
...group,
items: group.items.filter((item) => isPinned(group.roomId, item.event.event_id)),
}))
.filter((group) => group.items.length > 0);
};
const groupSearchResult = (results: ISearchResult[]): ResultGroup[] => {
const groups: ResultGroup[] = [];
+2 -2
View File
@@ -1106,7 +1106,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Text
size="T200"
style={{
color: 'var(--tc-danger-normal)',
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
@@ -1119,7 +1119,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Text
size="T200"
style={{
color: 'var(--tc-danger-normal)',
color: color.Critical.Main,
padding: '2px 6px',
alignSelf: 'center',
whiteSpace: 'nowrap',
+50
View File
@@ -255,6 +255,56 @@ export function About({ requestClose }: AboutProps) {
paddingLeft: config.space.S400,
}}
>
<li>
<Text size="T300">
The Lotus Chat logo is a derivative work based on the original{' '}
<a
href="https://github.com/cinnyapp/cinny"
rel="noreferrer noopener"
target="_blank"
>
Cinny
</a>{' '}
logo by{' '}
<a
href="https://github.com/ajbura"
rel="noreferrer noopener"
target="_blank"
>
Ajay Bura
</a>{' '}
and contributors, used under the terms of{' '}
<a
href="https://creativecommons.org/licenses/by/4.0/"
rel="noreferrer noopener"
target="_blank"
>
CC-BY 4.0
</a>
. The modified logo is © Lotus Guild, also under CC-BY 4.0.
</Text>
</li>
<li>
<Text size="T300">
Lotus Chat is a fork of{' '}
<a
href="https://github.com/cinnyapp/cinny"
rel="noreferrer noopener"
target="_blank"
>
Cinny
</a>{' '}
by Ajay Bura and contributors, used under the terms of{' '}
<a
href="https://www.gnu.org/licenses/agpl-3.0.html"
rel="noreferrer noopener"
target="_blank"
>
AGPL-3.0
</a>
.
</Text>
</li>
<li>
<Text size="T300">
The{' '}
+10 -32
View File
@@ -30,6 +30,7 @@ import {
} from 'folds';
import { Method } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
@@ -482,9 +483,9 @@ function ProfileStatus() {
opacity: statusMsg.length >= 56 ? 1 : 0.45,
color:
statusMsg.length >= 64
? 'var(--tc-critical-normal)'
? color.Critical.Main
: statusMsg.length >= 56
? 'var(--tc-warning-normal)'
? color.Warning.Main
: undefined,
}}
>
@@ -536,7 +537,7 @@ function ProfileStatus() {
</Button>
</Box>
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save status server may be rate limiting. Try again.
</Text>
)}
@@ -544,35 +545,12 @@ function ProfileStatus() {
<Text size="T200" style={{ opacity: 0.6, whiteSpace: 'nowrap', flexShrink: 0 }}>
Auto-clear after:
</Text>
<select
<SettingsSelect
value={clearAfter}
onChange={(e) => setClearAfter(e.target.value)}
options={CLEAR_AFTER_OPTIONS}
onChange={setClearAfter}
aria-label="Auto-clear status after"
style={{
background: color.SurfaceVariant.Container,
border: `1px solid ${color.SurfaceVariant.ContainerLine}`,
borderRadius: config.radii.R300,
color: color.SurfaceVariant.OnContainer,
colorScheme: 'dark',
fontSize: '0.82rem',
padding: `${config.space.S100} ${config.space.S200}`,
cursor: 'pointer',
outline: 'none',
}}
>
{CLEAR_AFTER_OPTIONS.map((opt) => (
<option
key={opt.value}
value={opt.value}
style={{
background: color.SurfaceVariant.Container,
color: color.SurfaceVariant.OnContainer,
}}
>
{opt.label}
</option>
))}
</select>
/>
</Box>
{(presence?.status || statusMsg) && (
<Button
@@ -730,7 +708,7 @@ function ProfilePronouns() {
</Button>
</Box>
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save pronouns. Try again.
</Text>
)}
@@ -873,7 +851,7 @@ function ProfileTimezone() {
</Button>
</Box>
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
<Text size="T200" style={{ color: color.Critical.Main }}>
Failed to save timezone. Try again.
</Text>
)}
@@ -37,12 +37,12 @@ function DecorationPreviewCell({
width: CELL_SIZE,
height: CELL_SIZE,
flexShrink: 0,
border: `2px solid ${selected ? 'var(--accent-cyan)' : 'transparent'}`,
border: `2px solid ${selected ? color.Primary.Main : 'transparent'}`,
borderRadius: '50%',
background: 'var(--bg-surface-variant)',
background: color.SurfaceVariant.Container,
cursor: 'pointer',
padding: 0,
boxShadow: selected ? '0 0 0 1px var(--accent-cyan)' : 'none',
boxShadow: selected ? `0 0 0 1px ${color.Primary.Main}` : 'none',
overflow: 'hidden',
outline: 'none',
}}
@@ -142,7 +142,7 @@ export function ProfileDecoration() {
height: CELL_SIZE,
flexShrink: 0,
borderRadius: '50%',
background: 'var(--bg-surface-variant)',
background: color.SurfaceVariant.Container,
overflow: 'hidden',
}}
>
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { Box, Button, Text } from 'folds';
import { Box, Button, color, config, Text } from 'folds';
import { DenoiseModelId } from '../../../state/settings';
import { DENOISE_MODELS } from '../../../utils/lotusDenoiseUtils';
import {
@@ -49,8 +49,8 @@ function DbMeter({ label, db, threshold }: { label: string; db: number; threshol
style={{
position: 'relative',
height: '12px',
background: 'var(--bg-card)',
border: '1px solid var(--border-color)',
background: color.Surface.Container,
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
borderRadius: '6px',
overflow: 'hidden',
}}
@@ -62,7 +62,7 @@ function DbMeter({ label, db, threshold }: { label: string; db: number; threshol
left: 0,
bottom: 0,
width: `${pct}%`,
background: 'var(--accent-green)',
background: color.Success.Main,
transition: 'width 0.05s linear',
}}
/>
@@ -74,7 +74,7 @@ function DbMeter({ label, db, threshold }: { label: string; db: number; threshol
bottom: 0,
left: `${markerPct}%`,
width: '2px',
background: 'var(--accent-orange)',
background: color.Primary.Main,
}}
/>
)}
+82 -88
View File
@@ -81,6 +81,7 @@ import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { playCallJoinSound } from '../../../utils/callSounds';
import { previewRingtone, RINGTONE_OPTIONS } from '../../../utils/ringtones';
import { DenoiseTester } from './DenoiseTester';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
type ThemeSelectorProps = {
themeNames: Record<string, string>;
@@ -169,83 +170,6 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
);
}
type SettingsSelectOption<T extends string> = { value: T; label: string; disabled?: boolean };
function SettingsSelect<T extends string>({
value,
options,
onChange,
}: {
value: T;
options: SettingsSelectOption<T>[];
onChange: (v: T) => void;
}) {
const [menuCords, setMenuCords] = useState<RectCords>();
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuCords(evt.currentTarget.getBoundingClientRect());
};
const handleSelect = (v: T) => {
onChange(v);
setMenuCords(undefined);
};
return (
<>
<Button
size="300"
variant="Secondary"
outlined
fill="Soft"
radii="300"
after={<Icon size="300" src={Icons.ChevronBottom} />}
onClick={handleMenu}
>
<Text size="T300">{selectedLabel}</Text>
</Button>
<PopOut
anchor={menuCords}
offset={5}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
onDeactivate: () => setMenuCords(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) =>
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
isKeyBackward: (evt: KeyboardEvent) =>
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
escapeDeactivates: stopPropagation,
}}
>
<Menu>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
{options.map((opt) => (
<MenuItem
key={opt.value}
size="300"
variant={opt.value === value ? 'Primary' : 'Surface'}
radii="300"
disabled={opt.disabled}
onClick={() => !opt.disabled && handleSelect(opt.value)}
>
<Text size="T300">{opt.label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function SystemThemePreferences() {
const themeKind = useSystemThemeKind();
const themeNames = useThemeNames();
@@ -432,6 +356,7 @@ function Appearance() {
settingsAtom,
'mentionHighlightColor',
);
const [customAccentColor, setCustomAccentColor] = useSetting(settingsAtom, 'customAccentColor');
const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily');
const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting(
settingsAtom,
@@ -684,6 +609,55 @@ function Appearance() {
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Custom Accent Color"
description={
lotusTerminal
? 'Only applies to non-TDS themes. Disable Lotus Terminal Mode to use a custom accent.'
: 'Recolors the app accent (buttons, active states, links, selected states). Leave empty to use the theme default.'
}
after={
<HexColorPickerPopOut
picker={
<HexColorPicker
color={customAccentColor || color.Primary.Main}
onChange={setCustomAccentColor}
/>
}
onRemove={customAccentColor ? () => setCustomAccentColor('') : undefined}
>
{(openPicker, opened) => (
<Button
type="button"
aria-pressed={opened}
onClick={openPicker}
disabled={lotusTerminal}
size="300"
variant="Secondary"
fill="Soft"
radii="300"
before={
<span
style={{
width: toRem(16),
height: toRem(16),
borderRadius: config.radii.R300,
background: customAccentColor || color.Primary.Main,
border: `${config.borderWidth.B300} solid ${color.SurfaceVariant.ContainerLine}`,
display: 'inline-block',
flexShrink: 0,
}}
/>
}
>
<Text size="B300">{customAccentColor ? 'Change' : 'Pick'}</Text>
</Button>
)}
</HexColorPickerPopOut>
}
/>
</SequenceCard>
</Box>
);
}
@@ -1322,8 +1296,8 @@ function Calls() {
style={{
padding: '16px',
marginTop: '8px',
borderTop: '1px solid var(--border-color)',
background: 'var(--bg-card)',
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
background: color.Surface.Container,
}}
>
{/* ── Model selection ───────────────────────────────────────── */}
@@ -1347,8 +1321,8 @@ function Calls() {
style={{
padding: '12px',
borderRadius: '8px',
border: '1px solid var(--border-color)',
background: 'var(--bg-input)',
border: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
background: color.SurfaceVariant.Container,
}}
>
<Text size="T300">{selectedDenoiseModel.name}</Text>
@@ -1386,7 +1360,7 @@ function Calls() {
direction="Row"
gap="100"
style={{
borderBottom: '1px solid var(--border-color)',
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
paddingBottom: '4px',
}}
>
@@ -1439,7 +1413,10 @@ function Calls() {
<Box
direction="Column"
gap="300"
style={{ paddingTop: '12px', borderTop: '1px solid var(--border-color)' }}
style={{
paddingTop: '12px',
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
<Text size="L400">Enhancements</Text>
<SettingTile
@@ -1475,7 +1452,7 @@ function Calls() {
step="1"
value={callDenoiseGateThreshold}
onChange={(e) => setCallDenoiseGateThreshold(parseInt(e.target.value, 10))}
style={{ width: '100%', accentColor: 'var(--accent-orange)' }}
style={{ width: '100%', accentColor: color.Primary.Main }}
/>
</Box>
)}
@@ -1485,7 +1462,10 @@ function Calls() {
<Box
direction="Column"
gap="200"
style={{ paddingTop: '12px', borderTop: '1px solid var(--border-color)' }}
style={{
paddingTop: '12px',
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
<Text size="L400">Test &amp; calibrate</Text>
<Text size="T200" priority="300">
@@ -1608,7 +1588,7 @@ function Calls() {
value={ringtoneVolume}
onChange={(e) => setRingtoneVolume(parseInt(e.target.value, 10))}
aria-label="Ringtone volume"
style={{ flex: 1, accentColor: 'var(--accent-orange)' }}
style={{ flex: 1, accentColor: color.Primary.Main }}
/>
<Text size="T200" style={{ minWidth: '32px', textAlign: 'right' }}>
{ringtoneVolume}%
@@ -1667,7 +1647,14 @@ function SeasonalBgGrid({
onChange: (v: Settings['seasonalThemeOverride']) => void;
}) {
return (
<Box wrap="Wrap" gap="200">
<Box
grow="Yes"
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${toRem(76)}, 1fr))`,
gap: config.space.S200,
}}
>
{SEASONAL_OPTIONS.map((opt) => {
const selected = value === opt.value;
const isSpecial = opt.value === 'auto' || opt.value === 'off';
@@ -1727,7 +1714,14 @@ function ChatBgGrid() {
const isDark = theme.kind === ThemeKind.Dark;
return (
<Box wrap="Wrap" gap="200">
<Box
grow="Yes"
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, minmax(${toRem(76)}, 1fr))`,
gap: config.space.S200,
}}
>
{BG_OPTIONS.map((opt) => (
<Box key={opt.value} direction="Column" gap="100" style={{ alignItems: 'center' }}>
<button
@@ -1,6 +1,7 @@
import React, { ChangeEventHandler, FormEventHandler, useCallback, useMemo, useState } from 'react';
import { IPushRule, IPushRules, PushRuleKind } from 'matrix-js-sdk';
import { Box, Text, Button, Input, config, IconButton, Icons, Icon, Spinner, Switch } from 'folds';
import { SettingsSelect } from '../../../components/settings-select/SettingsSelect';
import { useAccountData } from '../../../hooks/useAccountData';
import { AccountDataEvent } from '../../../../types/matrix/accountData';
import { SequenceCard } from '../../../components/sequence-card';
@@ -193,10 +194,6 @@ function AddRuleForm({ kind, placeholder, label }: AddRuleFormProps) {
setRuleId(evt.currentTarget.value);
};
const handleModeChange: ChangeEventHandler<HTMLSelectElement> = (evt) => {
setMode(evt.target.value as NotificationMode);
};
return (
<Box as="form" onSubmit={handleSubmit} direction="Column" gap="200">
<Text size="T200" priority="300">
@@ -217,24 +214,12 @@ function AddRuleForm({ kind, placeholder, label }: AddRuleFormProps) {
/>
</Box>
<Box shrink="No">
<select
<SettingsSelect
value={mode}
onChange={handleModeChange}
style={{
background: 'transparent',
border: '1px solid currentColor',
borderRadius: config.radii.R300,
padding: `${config.space.S100} ${config.space.S200}`,
color: 'inherit',
fontSize: 'inherit',
}}
>
{ADD_MODES.map((m) => (
<option key={m} value={m}>
{MODE_LABELS[m]}
</option>
))}
</select>
options={ADD_MODES.map((m) => ({ value: m, label: MODE_LABELS[m] }))}
onChange={setMode}
aria-label="Notification mode"
/>
</Box>
<Button
size="400"
+59 -39
View File
@@ -1,6 +1,9 @@
import React, { useEffect, useRef, CSSProperties } from 'react';
import { useAtomValue, useSetAtom } from 'jotai';
import { color, config, Icon, IconButton, Icons } from 'folds';
import { toastQueueAtom, dismissToastAtom, ToastNotif } from '../../state/toast';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
// Inject the keyframe animation once
const STYLE_ID = 'lotus-toast-keyframes';
@@ -29,16 +32,21 @@ type ToastCardProps = {
function ToastCard({ toast }: ToastCardProps) {
const dismiss = useSetAtom(dismissToastAtom);
// Lotus Terminal (TDS) gets its bespoke glow/accents; every other theme uses
// folds tokens so toasts render correctly on stock Cinny themes (the --lt-*
// vars only exist while Terminal mode is active).
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (toast.sticky) return;
timerRef.current = setTimeout(() => {
dismiss(toast.id);
}, 4000);
return () => {
if (timerRef.current !== null) clearTimeout(timerRef.current);
};
}, [dismiss, toast.id]);
}, [dismiss, toast.id, toast.sticky]);
const handleCardClick = () => {
if (toast.onClick) {
@@ -55,15 +63,29 @@ function ToastCard({ toast }: ToastCardProps) {
dismiss(toast.id);
};
const accent = toast.sticky ? color.Primary.Main : color.Surface.OnContainer;
const cardStyle: CSSProperties = {
position: 'relative',
background: 'var(--lt-bg-card)',
border: '1px solid var(--lt-border-color)',
borderRadius: '12px',
padding: '12px 14px',
background: lotusTerminal ? 'var(--lt-bg-card)' : color.Surface.Container,
border: `${config.borderWidth.B300} solid ${
lotusTerminal
? toast.sticky
? 'var(--lt-accent-cyan-border)'
: 'var(--lt-border-color)'
: toast.sticky
? color.Primary.Main
: color.Surface.ContainerLine
}`,
borderRadius: config.radii.R400,
padding: `${config.space.S300} ${config.space.S400}`,
minWidth: '280px',
maxWidth: '340px',
boxShadow: 'var(--lt-box-glow-orange)',
boxShadow: lotusTerminal
? toast.sticky
? 'var(--lt-box-glow-cyan)'
: 'var(--lt-box-glow-orange)'
: `0 8px 24px ${color.Other.Shadow}`,
cursor: 'pointer',
animation: 'lotusToastIn 0.2s ease-out both',
userSelect: 'none',
@@ -72,8 +94,8 @@ function ToastCard({ toast }: ToastCardProps) {
const rowStyle: CSSProperties = {
display: 'flex',
alignItems: 'center',
gap: '8px',
marginRight: '20px',
gap: config.space.S200,
marginRight: config.space.S500,
};
const avatarStyle: CSSProperties = {
@@ -88,19 +110,25 @@ function ToastCard({ toast }: ToastCardProps) {
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'var(--lt-accent-orange-dim)',
border: '1px solid var(--lt-accent-orange-border)',
background: lotusTerminal ? 'var(--lt-accent-orange-dim)' : color.Primary.Container,
border: `${config.borderWidth.B300} solid ${
lotusTerminal ? 'var(--lt-accent-orange-border)' : color.Primary.ContainerLine
}`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
fontSize: '10px',
fontWeight: 700,
color: 'var(--lt-accent-orange)',
color: lotusTerminal ? 'var(--lt-accent-orange)' : color.Primary.OnContainer,
flexShrink: 0,
};
const nameStyle: CSSProperties = {
color: 'var(--lt-accent-orange)',
color: lotusTerminal
? toast.sticky
? 'var(--lt-accent-cyan)'
: 'var(--lt-accent-orange)'
: accent,
fontWeight: 600,
fontSize: '0.85rem',
overflow: 'hidden',
@@ -108,31 +136,18 @@ function ToastCard({ toast }: ToastCardProps) {
whiteSpace: 'nowrap',
};
const dismissBtnStyle: CSSProperties = {
position: 'absolute',
top: '8px',
right: '10px',
background: 'none',
border: 'none',
color: 'var(--lt-text-secondary)',
cursor: 'pointer',
fontSize: '14px',
lineHeight: 1,
padding: '2px 4px',
borderRadius: '4px',
};
const bodyStyle: CSSProperties = {
color: 'var(--lt-text-primary)',
color: lotusTerminal ? 'var(--lt-text-primary)' : color.Surface.OnContainer,
fontSize: '0.82rem',
margin: '4px 0 2px',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
...(toast.sticky
? { whiteSpace: 'normal', lineHeight: 1.4 }
: { textOverflow: 'ellipsis', whiteSpace: 'nowrap' }),
};
const roomNameStyle: CSSProperties = {
color: 'var(--lt-text-secondary)',
color: lotusTerminal ? 'var(--lt-text-secondary)' : color.SurfaceVariant.OnContainer,
fontSize: '0.75rem',
overflow: 'hidden',
textOverflow: 'ellipsis',
@@ -157,14 +172,19 @@ function ToastCard({ toast }: ToastCardProps) {
}}
aria-label={`Notification from ${toast.displayName} in ${toast.roomName}`}
>
<button
type="button"
style={dismissBtnStyle}
onClick={handleDismiss}
aria-label="Dismiss notification"
>
×
</button>
<span style={{ position: 'absolute', top: config.space.S100, right: config.space.S100 }}>
<IconButton
type="button"
size="300"
radii="300"
variant="Surface"
fill="None"
onClick={handleDismiss}
aria-label="Dismiss notification"
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
</span>
<div style={rowStyle}>
{toast.avatarUrl ? (
<img src={toast.avatarUrl} alt="" style={avatarStyle} aria-hidden="true" />
@@ -197,7 +217,7 @@ export function LotusToastContainer() {
zIndex: 10001,
display: 'flex',
flexDirection: 'column',
gap: '8px',
gap: config.space.S200,
pointerEvents: 'auto',
};
+27 -23
View File
@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { useSetAtom } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { CallEmbed, useCallControlState } from '../plugins/call';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { toastQueueAtom } from '../state/toast';
@@ -9,17 +9,25 @@ const SILENCE_RMS_THRESHOLD = 0.008;
const CHECK_INTERVAL_MS = 500;
/**
* Monitors microphone audio while in a call. If the mic stays active but
* silent for longer than the configured timeout, the mic is muted and a
* toast is shown. Cleans up its own AudioContext and stream on unmount.
* Monitors microphone audio while in a call. If the mic stays unmuted but
* silent for longer than the configured timeout, the mic is muted and a toast
* is shown.
*
* The level-monitoring capture (`getUserMedia`) is opened ONLY while the mic is
* unmuted there is nothing to auto-mute once you are already muted, so
* holding the capture would keep the OS recording indicator lit even though the
* UI shows you as muted (N95). Muting therefore releases our stream; unmuting
* re-acquires it. The AudioContext + stream are also torn down on unmount.
*/
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
const setToast = useSetAtom(toastQueueAtom);
const { microphone } = useCallControlState(callEmbed?.control);
useEffect(() => {
if (!callEmbed || !enabled) return;
// Only capture while in a call, enabled, AND unmuted (see N95 note above).
if (!callEmbed || !enabled || !microphone) return undefined;
let stream: MediaStream | undefined;
let audioCtx: AudioContext | undefined;
@@ -49,24 +57,20 @@ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
if (rms > SILENCE_RMS_THRESHOLD) {
// Audio detected — reset the silence timer
// Audio detected — reset the silence timer.
silenceStart = null;
} else if (callEmbed.control.microphone) {
// Mic is on but silent — start or advance the timer
if (silenceStart === null) silenceStart = Date.now();
else if (Date.now() - silenceStart >= timeoutMs) {
callEmbed.control.setMicrophone(false);
setToast({
id: `afk-mute-${Date.now()}`,
displayName: 'Lotus Chat',
body: 'Your microphone was muted after inactivity.',
roomName: 'Voice call',
roomId: callEmbed.roomId,
});
silenceStart = null;
}
} else {
// Mic is already muted — don't count silence
} else if (silenceStart === null) {
// Mic is unmuted (effect only runs while unmuted) but silent — start the timer.
silenceStart = Date.now();
} else if (Date.now() - silenceStart >= timeoutMs) {
callEmbed.control.setMicrophone(false);
setToast({
id: `afk-mute-${Date.now()}`,
displayName: 'Lotus Chat',
body: 'Your microphone was muted after inactivity.',
roomName: 'Voice call',
roomId: callEmbed.roomId,
});
silenceStart = null;
}
}, CHECK_INTERVAL_MS);
@@ -79,5 +83,5 @@ export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
stream?.getTracks().forEach((t) => t.stop());
audioCtx?.close().catch(() => undefined);
};
}, [callEmbed, enabled, timeoutMinutes, setToast]);
}, [callEmbed, enabled, timeoutMinutes, setToast, microphone]);
}
+95 -39
View File
@@ -1,60 +1,116 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { CallEmbed } from '../plugins/call';
import { useMutationObserver } from './useMutationObserver';
import { isUserId } from '../utils/matrix';
import { useCallMembers, useCallSession } from './useCall';
import { useCallJoined } from './useCallEmbed';
/**
* Returns the set of Matrix user IDs currently speaking in the Element Call
* iframe.
*
* EC renders each participant's video tile with a `[data-video-fit]` wrapper.
* When a participant is speaking, EC draws a speaking indicator via the tile's
* `::before` pseudo-element `background-image` (anything other than `none`).
* The participant's Matrix user ID is exposed on the first descendant carrying
* an `aria-label`.
*
* We watch the whole iframe document so tiles added/removed mid-call are picked
* up automatically, and on every relevant mutation we re-scan ALL `[data-video-fit]`
* tiles and rebuild the set from the full current DOM state (rather than just the
* tiles in the mutation batch).
*/
export const useCallSpeakers = (callEmbed: CallEmbed): Set<string> => {
const [speakers, setSpeakers] = useState(new Set<string>());
const callSession = useCallSession(callEmbed.room);
const callMembers = useCallMembers(callSession);
const joined = useCallJoined(callEmbed);
const videoContainers = useMemo(() => {
if (callMembers && joined) return callEmbed.document?.querySelectorAll('[data-video-fit]');
return undefined;
}, [callEmbed, callMembers, joined]);
const mutationObserver = useMutationObserver(
useCallback(
(mutations) => {
const s = new Set<string>();
mutations.forEach((mutation) => {
if (mutation.type !== 'attributes') return;
const el = mutation.target as HTMLElement;
const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before');
if (!style) return;
const tileBackgroundImage = style.getPropertyValue('background-image');
const speaking = tileBackgroundImage !== 'none';
if (!speaking) return;
const speakerId = el.querySelector('[aria-label]')?.getAttribute('aria-label');
if (speakerId && isUserId(speakerId)) {
s.add(speakerId);
}
});
setSpeakers(s);
},
[callEmbed],
),
);
useEffect(() => {
videoContainers?.forEach((element) => {
mutationObserver.observe(element, {
if (!callMembers || !joined) {
setSpeakers(new Set<string>());
return undefined;
}
const getDoc = (): Document | undefined =>
callEmbed.iframe.contentDocument ?? callEmbed.iframe.contentWindow?.document ?? undefined;
const syncState = (): void => {
const doc = getDoc();
if (!doc) {
setSpeakers(new Set<string>());
return;
}
const s = new Set<string>();
// Re-scan every tile on each mutation and build the set from the full
// current DOM state, not just the tiles that mutated this batch.
const tiles = doc.querySelectorAll<HTMLElement>('[data-video-fit]');
tiles.forEach((el) => {
const style = callEmbed.iframe.contentWindow?.getComputedStyle(el, '::before');
if (!style) return;
const tileBackgroundImage = style.getPropertyValue('background-image');
const speaking = tileBackgroundImage !== 'none';
if (!speaking) return;
const speakerId = el.querySelector('[aria-label]')?.getAttribute('aria-label');
if (speakerId && isUserId(speakerId)) {
s.add(speakerId);
}
});
setSpeakers(s);
};
let tileObserver: MutationObserver | undefined;
const attachObserver = (): void => {
const doc = getDoc();
if (!doc) return;
tileObserver?.disconnect();
// Watch the whole document for attribute changes on tiles (which carry
// the speaking indicator) and for new tiles being added/removed.
tileObserver = new MutationObserver((mutations) => {
const relevant = mutations.some(
(m) =>
m.type === 'attributes' ||
(m.type === 'childList' &&
(Array.from(m.addedNodes).some(
(n) => n instanceof Element && n.querySelector('[data-video-fit]'),
) ||
Array.from(m.removedNodes).some(
(n) => n instanceof Element && n.querySelector('[data-video-fit]'),
))),
);
if (relevant) syncState();
});
tileObserver.observe(doc.body, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['class', 'style'],
});
});
syncState();
};
attachObserver();
// If iframe isn't ready yet, wait for body to be available.
let bodyWatcher: MutationObserver | undefined;
if (!getDoc()?.body) {
bodyWatcher = new MutationObserver(() => {
if (getDoc()?.body) {
bodyWatcher?.disconnect();
bodyWatcher = undefined;
attachObserver();
}
});
const doc = getDoc();
if (doc) bodyWatcher.observe(doc, { childList: true });
}
return () => {
mutationObserver.disconnect();
tileObserver?.disconnect();
bodyWatcher?.disconnect();
};
}, [videoContainers, mutationObserver]);
}, [callEmbed, callMembers, joined]);
return speakers;
};
+41 -16
View File
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
@@ -32,39 +32,64 @@ export function useReminders(): {
const mx = useMatrixClient();
const [reminders, setReminders] = useState<Reminder[]>(() => readReminders(mx));
// Authoritative local snapshot used to compute mutations. Reading
// mx.getAccountData() per-mutation is racy: two quick add/remove calls both
// read the same stale baseline and the second write clobbers the first
// (N113). We instead mutate from this ref, kept in sync with server echoes.
const latestRef = useRef<Reminder[]>(reminders);
// Serialize writes so overlapping setAccountData calls can't land out of
// order on the server (last-write-wins would otherwise drop data).
const writeQueueRef = useRef<Promise<unknown>>(Promise.resolve());
const applyServerState = useCallback((list: Reminder[]) => {
latestRef.current = list;
setReminders(list);
}, []);
useAccountDataCallback(
mx,
useCallback(
(evt) => {
if (evt.getType() === REMINDERS_KEY) {
setReminders(evt.getContent<RemindersContent>()?.reminders ?? []);
applyServerState(evt.getContent<RemindersContent>()?.reminders ?? []);
}
},
[setReminders],
[applyServerState],
),
);
// Re-read on mx change
useEffect(() => {
setReminders(readReminders(mx));
}, [mx]);
applyServerState(readReminders(mx));
}, [mx, applyServerState]);
const addReminder = useCallback(
async (r: Reminder) => {
const current = readReminders(mx);
const next = [...current, r];
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
const enqueueWrite = useCallback(
(compute: (current: Reminder[]) => Reminder[]): Promise<void> => {
const run = writeQueueRef.current.then(async () => {
const next = compute(latestRef.current);
latestRef.current = next;
setReminders(next);
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
});
// Keep the chain alive even if one write rejects, but propagate the
// rejection to this caller so it can react (e.g. retry).
writeQueueRef.current = run.catch(() => undefined);
return run;
},
[mx],
);
const addReminder = useCallback(
(r: Reminder) => enqueueWrite((current) => [...current, r]),
[enqueueWrite],
);
const removeReminder = useCallback(
async (eventId: string, timestamp: number) => {
const current = readReminders(mx);
const next = current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp));
await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next });
},
[mx],
(eventId: string, timestamp: number) =>
enqueueWrite((current) =>
current.filter((r) => !(r.eventId === eventId && r.timestamp === timestamp)),
),
[enqueueWrite],
);
const getReminders = useCallback(() => reminders, [reminders]);
+49 -1
View File
@@ -2,10 +2,15 @@ import { lightTheme } from 'folds';
import { createContext, useContext, useEffect, useMemo, useState } from 'react';
import { onDarkFontWeight, onLightFontWeight } from '../../config.css';
import {
bloodRedTheme,
butterTheme,
classicMatrixTheme,
cyberpunkTheme,
darkTheme,
lotusTerminalLightTheme,
lotusTerminalTheme,
midnightTheme,
oceanTheme,
silverTheme,
} from '../../colors.css';
import { settingsAtom } from '../state/settings';
@@ -43,6 +48,31 @@ export const ButterTheme: Theme = {
kind: ThemeKind.Dark,
classNames: ['butter-theme', butterTheme, onDarkFontWeight, 'prism-dark'],
};
export const CyberpunkTheme: Theme = {
id: 'cyberpunk-theme',
kind: ThemeKind.Dark,
classNames: ['cyberpunk-theme', cyberpunkTheme, onDarkFontWeight, 'prism-dark'],
};
export const OceanTheme: Theme = {
id: 'ocean-theme',
kind: ThemeKind.Dark,
classNames: ['ocean-theme', oceanTheme, onDarkFontWeight, 'prism-dark'],
};
export const BloodRedTheme: Theme = {
id: 'blood-red-theme',
kind: ThemeKind.Dark,
classNames: ['blood-red-theme', bloodRedTheme, onDarkFontWeight, 'prism-dark'],
};
export const ClassicMatrixTheme: Theme = {
id: 'classic-matrix-theme',
kind: ThemeKind.Dark,
classNames: ['classic-matrix-theme', classicMatrixTheme, onDarkFontWeight, 'prism-dark'],
};
export const MidnightTheme: Theme = {
id: 'midnight-theme',
kind: ThemeKind.Dark,
classNames: ['midnight-theme', midnightTheme, onDarkFontWeight, 'prism-dark'],
};
export const LotusTerminalTheme: Theme = {
id: 'lotus-terminal-theme',
kind: ThemeKind.Dark,
@@ -60,7 +90,20 @@ export const LotusTerminalLightTheme: Theme = {
};
export const useThemes = (): Theme[] => {
const themes: Theme[] = useMemo(() => [LightTheme, SilverTheme, DarkTheme, ButterTheme], []);
const themes: Theme[] = useMemo(
() => [
LightTheme,
SilverTheme,
DarkTheme,
ButterTheme,
CyberpunkTheme,
OceanTheme,
BloodRedTheme,
ClassicMatrixTheme,
MidnightTheme,
],
[],
);
return themes;
};
@@ -72,6 +115,11 @@ export const useThemeNames = (): Record<string, string> =>
[SilverTheme.id]: 'Silver',
[DarkTheme.id]: 'Dark',
[ButterTheme.id]: 'Butter',
[CyberpunkTheme.id]: 'Cyberpunk',
[OceanTheme.id]: 'Ocean',
[BloodRedTheme.id]: 'Blood Red',
[ClassicMatrixTheme.id]: 'Classic Matrix',
[MidnightTheme.id]: 'Midnight',
}),
[],
);
+41 -36
View File
@@ -1,7 +1,16 @@
import React, { useEffect } from 'react';
import * as Sentry from '@sentry/react';
import { ErrorBoundary } from 'react-error-boundary';
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
import {
Box,
Button,
config,
OverlayContainerProvider,
PopOutContainerProvider,
Text,
toRem,
TooltipContainerProvider,
} from 'folds';
import { RouterProvider } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
@@ -17,6 +26,7 @@ import { settingsAtom } from '../state/settings';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
import { applyCustomAccent, removeCustomAccent } from '../utils/accentColor';
const FONT_MAP: Record<string, string> = {
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
@@ -51,6 +61,17 @@ function AppearanceEffects() {
}
}, [settings.mentionHighlightColor]);
useEffect(() => {
// Custom accent color applies only to non-TDS themes. When Lotus Terminal
// (TDS) is active it has its own fixed palette, so we remove any overrides.
const accent = settings.customAccentColor;
if (accent && !settings.lotusTerminal && applyCustomAccent(accent)) {
return () => removeCustomAccent();
}
removeCustomAccent();
return undefined;
}, [settings.customAccentColor, settings.lotusTerminal]);
useEffect(() => {
const font = FONT_MAP[settings.fontFamily ?? 'inter'] ?? FONT_MAP.inter;
document.body.style.setProperty('--font-secondary', font);
@@ -90,41 +111,25 @@ function App() {
const portalContainer = document.getElementById('portalContainer') ?? undefined;
return (
<Sentry.ErrorBoundary
fallback={({ error, resetError }) => (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100vh',
gap: '16px',
fontFamily: 'sans-serif',
padding: '24px',
textAlign: 'center',
}}
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="400"
style={{ height: '100vh', padding: config.space.S700, textAlign: 'center' }}
>
<h2 style={{ margin: 0 }}>Something went wrong</h2>
<p style={{ margin: 0, color: '#666', maxWidth: '400px' }}>
<Text size="H2">Something went wrong</Text>
<Text size="T300" priority="300" style={{ maxWidth: toRem(400) }}>
{error instanceof Error ? error.message : 'An unexpected error occurred.'}
</p>
<button
type="button"
onClick={resetError}
style={{
padding: '8px 20px',
borderRadius: '6px',
border: 'none',
background: '#5865f2',
color: '#fff',
cursor: 'pointer',
fontSize: '14px',
}}
>
Try again
</button>
</div>
</Text>
<Button variant="Primary" onClick={resetErrorBoundary}>
<Text as="span" size="B400">
Try again
</Text>
</Button>
</Box>
)}
>
<TooltipContainerProvider value={portalContainer}>
@@ -159,7 +164,7 @@ function App() {
</OverlayContainerProvider>
</PopOutContainerProvider>
</TooltipContainerProvider>
</Sentry.ErrorBoundary>
</ErrorBoundary>
);
}
+17 -27
View File
@@ -1,5 +1,6 @@
import React from 'react';
import { useRouteError, isRouteErrorResponse } from 'react-router-dom';
import { Box, Button, config, Text, toRem } from 'folds';
export function RouteError() {
const error = useRouteError();
@@ -11,33 +12,22 @@ export function RouteError() {
: 'An unexpected error occurred.';
return (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100dvh',
gap: '16px',
padding: '32px',
fontFamily: 'sans-serif',
}}
<Box
direction="Column"
alignItems="Center"
justifyContent="Center"
gap="400"
style={{ height: '100dvh', padding: config.space.S700 }}
>
<h2 style={{ margin: 0, fontSize: '1.25rem' }}>Something went wrong</h2>
<p style={{ margin: 0, opacity: 0.7, textAlign: 'center' }}>{message}</p>
<button
type="button"
onClick={() => window.location.reload()}
style={{
padding: '8px 20px',
borderRadius: '8px',
border: 'none',
cursor: 'pointer',
fontWeight: 600,
}}
>
Reload
</button>
</div>
<Text size="H3">Something went wrong</Text>
<Text size="T300" priority="300" style={{ textAlign: 'center', maxWidth: toRem(400) }}>
{message}
</Text>
<Button variant="Primary" onClick={() => window.location.reload()}>
<Text as="span" size="B400">
Reload
</Text>
</Button>
</Box>
);
}
+39 -10
View File
@@ -242,6 +242,7 @@ function MessageNotifications() {
roomId,
eventId,
body,
encrypted,
}: {
roomName: string;
roomAvatar?: string;
@@ -249,6 +250,7 @@ function MessageNotifications() {
roomId: string;
eventId: string;
body?: string;
encrypted?: boolean;
}) => {
const roomPath = mDirects.has(roomId)
? getDirectRoomPath(roomId, eventId)
@@ -267,10 +269,17 @@ function MessageNotifications() {
return;
}
// N109: the OS notification subsystem fetches icon/badge OUTSIDE the page,
// so the SW can't inject auth headers and authenticated-media URLs 401.
// Use the static app logo (as invite notifications already do).
// N106: never put decrypted E2EE plaintext into the OS notification (it
// persists in the notification center / lock screen / is readable by other
// apps). For encrypted rooms show only the sender; the in-page toast above
// still shows the preview while the user is actively looking at the screen.
const noti = new window.Notification(roomName, {
icon: roomAvatar,
badge: roomAvatar,
body: body ? `${username}: ${body}`.slice(0, 120) : username,
icon: LogoSVG,
badge: LogoSVG,
body: !encrypted && body ? `${username}: ${body}`.slice(0, 120) : username,
silent: true,
});
@@ -341,6 +350,7 @@ function MessageNotifications() {
roomId: room.roomId,
eventId,
body: (mEvent.getContent().body as string | undefined) ?? '',
encrypted: room.hasEncryptionStateEvent(),
});
}
@@ -390,16 +400,26 @@ function ReminderMonitor() {
const setToast = useSetAtom(toastQueueAtom);
const mDirects = useAtomValue(mDirectAtom);
const firedRef = useRef<Set<string>>(new Set());
const removingRef = useRef<Set<string>>(new Set());
// Read the latest reminders / DM map via refs so the poll interval below is
// created once — not torn down and restarted (which resets its 30s countdown
// and can indefinitely defer a near-due reminder) on every reminder sync (N115).
const remindersRef = useRef(reminders);
remindersRef.current = reminders;
const mDirectsRef = useRef(mDirects);
mDirectsRef.current = mDirects;
useEffect(() => {
const check = () => {
const now = Date.now();
reminders.forEach((r) => {
remindersRef.current.forEach((r) => {
if (r.timestamp > now) return;
const key = `${r.eventId}-${r.timestamp}`;
if (r.timestamp <= now && !firedRef.current.has(key)) {
// Show the toast exactly once.
if (!firedRef.current.has(key)) {
firedRef.current.add(key);
const room = mx.getRoom(r.roomId);
const hashPath = mDirects.has(r.roomId)
const hashPath = mDirectsRef.current.has(r.roomId)
? getDirectRoomPath(r.roomId, r.eventId)
: getHomeRoomPath(r.roomId, r.eventId);
setToast({
@@ -410,7 +430,15 @@ function ReminderMonitor() {
roomId: r.roomId,
hashPath,
});
removeReminder(r.eventId, r.timestamp);
}
// Persist the removal, retrying on a later tick if it fails — without
// re-showing the toast (N114). The server echo drops it from
// `reminders` once the write lands.
if (!removingRef.current.has(key)) {
removingRef.current.add(key);
removeReminder(r.eventId, r.timestamp).catch(() => {
removingRef.current.delete(key);
});
}
});
};
@@ -425,7 +453,7 @@ function ReminderMonitor() {
clearInterval(interval);
document.removeEventListener('visibilitychange', onVisible);
};
}, [mx, reminders, setToast, removeReminder, mDirects]);
}, [mx, setToast, removeReminder]);
return null;
}
@@ -459,11 +487,12 @@ function TauriUpdateFeature() {
firedRef.current = status.version;
setToast({
id: `tauri-update-${status.version}`,
displayName: 'Update Available',
body: `Lotus Chat ${status.version} is ready to install.`,
displayName: 'Update Available',
body: `Lotus Chat ${status.version} is ready. Click to install and restart.`,
roomName: 'System',
roomId: '',
onClick: install,
sticky: true,
});
}, [status, setToast, install]);
+36 -5
View File
@@ -15,7 +15,14 @@ import {
} from 'folds';
import { HttpApiEvent, HttpApiEventHandlerMap, MatrixClient } from 'matrix-js-sdk';
import FocusTrap from 'focus-trap-react';
import React, { MouseEventHandler, ReactNode, useCallback, useEffect, useState } from 'react';
import React, {
MouseEventHandler,
ReactNode,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import {
clearCacheAndReload,
clearLoginData,
@@ -35,7 +42,7 @@ import { useSyncState } from '../../hooks/useSyncState';
import { stopPropagation } from '../../utils/keyboard';
import { SyncStatus } from './SyncStatus';
import { AuthMetadataProvider } from '../../hooks/useAuthMetadata';
import { getFallbackSession } from '../../state/sessions';
import { getFallbackSession, removeFallbackSession } from '../../state/sessions';
import { AutoDiscovery } from './AutoDiscovery';
function ClientRootLoading() {
@@ -130,7 +137,10 @@ const useLogoutListener = (mx?: MatrixClient) => {
const handleLogout: HttpApiEventHandlerMap[HttpApiEvent.SessionLoggedOut] = async () => {
mx?.stopClient();
await mx?.clearStores();
window.localStorage.clear();
// Remove only the session credential keys — NOT settings, drafts, and
// other preferences (N98). The SDK's IndexedDB stores are cleared above;
// window.localStorage.clear() is reserved for the explicit reset path.
removeFallbackSession();
window.location.reload();
};
@@ -146,6 +156,11 @@ type ClientRootProps = {
};
export function ClientRoot({ children }: ClientRootProps) {
const [loading, setLoading] = useState(true);
const [syncError, setSyncError] = useState(false);
// Tracks whether the initial sync has ever reached PREPARED. After that,
// transient sync errors are handled by <SyncStatus>'s reconnection banner,
// so we must NOT pop the blocking error splash for them.
const hasPreparedRef = useRef(false);
const { baseUrl, userId } = getFallbackSession() ?? {};
const [loadState, loadMatrix] = useAsyncCallback<MatrixClient, Error, []>(
@@ -180,7 +195,14 @@ export function ClientRoot({ children }: ClientRootProps) {
mx,
useCallback((state) => {
if (state === 'PREPARED') {
hasPreparedRef.current = true;
setSyncError(false);
setLoading(false);
} else if (state === 'ERROR' || state === 'STOPPED') {
// Only surface the blocking error splash when the INITIAL sync fails
// (offline at startup, homeserver unreachable, non-retryable /sync
// error). After the first PREPARED, <SyncStatus> owns reconnection UX.
if (!hasPreparedRef.current) setSyncError(true);
}
}, []),
);
@@ -188,9 +210,11 @@ export function ClientRoot({ children }: ClientRootProps) {
return (
<AutoDiscovery userId={userId!} baseUrl={baseUrl!}>
<SpecVersions baseUrl={baseUrl!}>
{mx && <SyncStatus mx={mx} />}
{mx && !syncError && <SyncStatus mx={mx} />}
{loading && <ClientRootOptions mx={mx} />}
{(loadState.status === AsyncStatus.Error || startState.status === AsyncStatus.Error) && (
{(loadState.status === AsyncStatus.Error ||
startState.status === AsyncStatus.Error ||
syncError) && (
<SplashScreen>
<Box
direction="Column"
@@ -223,6 +247,13 @@ export function ClientRoot({ children }: ClientRootProps) {
{startState.status === AsyncStatus.Error && (
<Text>{`Failed to start. ${startState.error.message}`}</Text>
)}
{syncError &&
loadState.status !== AsyncStatus.Error &&
startState.status !== AsyncStatus.Error && (
<Text>
Failed to sync with your homeserver. Check your connection and try again.
</Text>
)}
{('error' in loadState ? (loadState as any).error?.message : undefined) !==
IDB_VERSION_CONFLICT && (
<Button variant="Critical" onClick={mx ? () => startMatrix(mx) : loadMatrix}>
+66 -25
View File
@@ -20,8 +20,6 @@ export class CallControl extends EventEmitter implements CallControlState {
private _pipMode = false;
private mediaStatePromiseResolver: undefined | (() => void);
private get document(): Document | undefined {
return this.iframe.contentDocument ?? this.iframe.contentWindow?.document;
}
@@ -183,13 +181,13 @@ export class CallControl extends EventEmitter implements CallControlState {
}
private async setMediaState(state: ElementMediaStatePayload) {
const data = await this.call.transport.send(ElementWidgetActions.DeviceMute, state);
return new Promise<typeof data>((resolve) => {
if (this.mediaStatePromiseResolver) {
this.mediaStatePromiseResolver();
}
this.mediaStatePromiseResolver = () => resolve(data);
});
// transport.send resolves once EC has ACK'd the command, which is enough to
// consider the mute applied. We deliberately do NOT gate completion on a
// follow-up DeviceMute state-echo: EC may elide it (e.g. when the requested
// state already matches its current state) or skip it during teardown,
// which would strand this promise forever and block applyState(). The echo,
// when it does arrive, is still handled authoritatively by onMediaState().
return this.call.transport.send(ElementWidgetActions.DeviceMute, state);
}
private setSound(sound: boolean): void {
@@ -233,11 +231,6 @@ export class CallControl extends EventEmitter implements CallControlState {
if (this.microphone && !this.sound) {
this.toggleSound();
}
if (this.mediaStatePromiseResolver) {
this.mediaStatePromiseResolver();
this.mediaStatePromiseResolver = undefined;
}
}
private onControlMutation() {
@@ -356,20 +349,68 @@ export class CallControl extends EventEmitter implements CallControlState {
const doc = this.document;
if (!doc) return;
// Find the mute icon / aria-label element that identifies this participant
const userEl = doc.querySelector<HTMLElement>(`[aria-label="${CSS.escape(userId)}"]`);
// Walk up to the nearest video tile container
const tile =
userEl?.closest<HTMLElement>('[data-testid="videoTile"]') ??
userEl?.closest<HTMLElement>('[data-video-fit]');
// EC labels participant tiles inconsistently across versions — the user's
// matrix id may be the full aria-label, a substring of it, or carried on a
// data attribute (and sometimes the visible label is the display name, not
// the id at all). Try several strategies before giving up, then walk up to
// the enclosing video tile.
const findTile = (): HTMLElement | undefined => {
const escaped = CSS.escape(userId);
const el =
doc.querySelector<HTMLElement>(`[aria-label="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-testid="videoTile"][aria-label*="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[aria-label*="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-member-id="${escaped}"]`) ??
doc.querySelector<HTMLElement>(`[data-id="${escaped}"]`) ??
undefined;
return (
el?.closest<HTMLElement>('[data-testid="videoTile"]') ??
el?.closest<HTMLElement>('[data-video-fit]') ??
el ??
undefined
);
};
if (!this.spotlight) {
this.spotlightButton?.click();
const applyFocus = () => {
const tile = findTile();
if (tile) {
tile.click();
} else if (import.meta.env.DEV) {
console.warn(`[CallControl] focusCameraParticipant: no tile matched ${userId}`);
}
};
if (this.spotlight) {
// Already in spotlight — pin immediately.
applyFocus();
return;
}
if (tile) {
tile.click();
}
// Switching to spotlight re-renders EC's layout asynchronously; clicking the
// tile in the same tick would land in the old (grid) DOM. A fixed frame
// delay is unreliable (EC's React commit can exceed it on slow devices), so
// watch the iframe DOM for a spotlight video tile to mount, then focus —
// with a hard timeout so the click is always attempted at least once.
this.spotlightButton?.click();
const tileSelector = '[data-testid="videoTile"]';
let settled = false;
let observer: MutationObserver | undefined;
let timer: ReturnType<typeof setTimeout> | undefined;
const finish = () => {
if (settled) return;
settled = true;
if (timer) clearTimeout(timer);
observer?.disconnect();
applyFocus();
};
observer = new MutationObserver(() => {
if (doc.querySelector(tileSelector)) finish();
});
observer.observe(doc.body, { childList: true, subtree: true });
timer = setTimeout(finish, 600);
// A tile may already be present immediately after toggling spotlight.
if (doc.querySelector(tileSelector)) finish();
}
public dispose() {
+37 -8
View File
@@ -70,7 +70,9 @@ export class CallEmbed {
private loadError?: CallLoadErrorReason;
private readonly loadErrorListeners = new Set<(reason: CallLoadErrorReason) => void>();
private readonly loadErrorListeners = new Set<
(reason: CallLoadErrorReason | undefined) => void
>();
// Arrow-function class fields so dispose() passes the exact same reference to mx.off()
private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev);
@@ -375,17 +377,44 @@ export class CallEmbed {
}
}
private notifyLoadListeners(reason: CallLoadErrorReason | undefined): void {
this.loadErrorListeners.forEach((cb) => {
try {
cb(reason);
} catch {
// a misbehaving subscriber must not block the others
}
});
}
/**
* Marks the load lifecycle as settled. Called on success (no reason) or on
* failure (reason set). Idempotent so the first signal wins.
* Marks the load lifecycle as settled.
*
* - Failure (reason set): the FIRST failure wins; a later success can still
* heal it (below). Once we've genuinely succeeded, later spurious failures
* are ignored.
* - Success (no reason): always clears the watchdog. Crucially, if we had
* previously settled as a failure (e.g. the 25s watchdog fired on a slow
* network but EC then finished loading), we self-heal: clear the error and
* notify subscribers with `undefined` so the recovery UI dismisses itself
* instead of stranding the user on an error screen over a live call.
*/
private settleLoad(reason?: CallLoadErrorReason): void {
if (this.loadSettled) return;
this.loadSettled = true;
this.clearLoadWatchdog();
if (reason) {
if (this.loadSettled) return;
this.loadSettled = true;
this.clearLoadWatchdog();
this.loadError = reason;
this.loadErrorListeners.forEach((cb) => cb(reason));
this.notifyLoadListeners(reason);
return;
}
this.clearLoadWatchdog();
const wasFailed = this.loadError !== undefined;
this.loadSettled = true;
this.loadError = undefined;
if (wasFailed) {
this.notifyLoadListeners(undefined);
}
}
@@ -402,7 +431,7 @@ export class CallEmbed {
* immediately so late subscribers still see the error.
* @returns an unsubscribe function.
*/
public onLoadError(callback: (reason: CallLoadErrorReason) => void): () => void {
public onLoadError(callback: (reason: CallLoadErrorReason | undefined) => void): () => void {
this.loadErrorListeners.add(callback);
if (this.loadError) callback(this.loadError);
return () => {
+38
View File
@@ -0,0 +1,38 @@
import { atom } from 'jotai';
import { atomWithStorage, createJSONStorage } from 'jotai/utils';
const STORAGE_KEY = 'cinny_recent_searches_v1';
const MAX_RECENT_SEARCHES = 10;
// Internal atom persists as a plain string[] (JSON-serializable).
const internalAtom = atomWithStorage<string[]>(
STORAGE_KEY,
[],
createJSONStorage(() => localStorage),
);
/**
* Global atom: string[] of the most recent distinct, non-empty search terms.
* Most-recent first, deduped, capped at MAX_RECENT_SEARCHES.
* Backed by localStorage so recent searches survive page refreshes.
*/
export const recentSearchesAtom = atom(
(get): string[] => get(internalAtom),
(_get, set, updater: string[] | ((prev: string[]) => string[])) => {
set(internalAtom, (prev) => {
const prevList = Array.isArray(prev) ? prev : [];
const next = typeof updater === 'function' ? updater(prevList) : updater;
return next;
});
},
);
/**
* Prepend a search term: dedupes (case-sensitive), drops empties, caps at 10.
*/
export const addRecentSearch = (prev: string[], term: string): string[] => {
const trimmed = term.trim();
if (!trimmed) return prev;
const withoutDupe = prev.filter((t) => t !== trimmed);
return [trimmed, ...withoutDupe].slice(0, MAX_RECENT_SEARCHES);
};
+2
View File
@@ -146,6 +146,7 @@ export interface Settings {
composerToolbarButtons: ComposerToolbarSettings;
mentionHighlightColor: string;
customAccentColor: string;
fontFamily: 'system' | 'inter' | 'jetbrains-mono' | 'fira-code';
afkAutoMute: boolean;
@@ -242,6 +243,7 @@ const defaultSettings: Settings = {
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
mentionHighlightColor: '',
customAccentColor: '',
fontFamily: 'inter',
afkAutoMute: false,
+1
View File
@@ -9,6 +9,7 @@ export type ToastNotif = {
roomId: string;
hashPath?: string; // overrides window.location.hash navigation when set
onClick?: () => void; // custom click handler; skips hash navigation when set
sticky?: boolean; // when true, does not auto-dismiss — use for action toasts that require a click
};
const baseAtom = atom<ToastNotif[]>([]);
+117
View File
@@ -0,0 +1,117 @@
import { color } from 'folds';
// Custom accent color support for non-TDS themes. The folds `Primary.*` tokens
// are imported as strings like "var(--oq6d07f)"; we extract the underlying CSS
// variable name at runtime and override it on `document.body`, mirroring the
// mention-highlight pattern in pages/App.tsx. When unset (or when the Lotus
// Terminal/TDS theme is active) the overrides are removed so the theme defaults
// take over again.
export type Rgb = { r: number; g: number; b: number };
const clamp = (n: number): number => Math.max(0, Math.min(255, Math.round(n)));
export const hexToRgb = (hex: string): Rgb | undefined => {
const m = /^#?([0-9a-fA-F]{6})$/.exec(hex.trim());
if (!m) return undefined;
const h = m[1];
return {
r: parseInt(h.slice(0, 2), 16),
g: parseInt(h.slice(2, 4), 16),
b: parseInt(h.slice(4, 6), 16),
};
};
const rgbToHex = ({ r, g, b }: Rgb): string =>
`#${[clamp(r), clamp(g), clamp(b)].map((c) => c.toString(16).padStart(2, '0')).join('')}`;
// Lighten/darken by moving each channel a percentage toward white/black.
export const lighten = ({ r, g, b }: Rgb, amount: number): Rgb => ({
r: r + (255 - r) * amount,
g: g + (255 - g) * amount,
b: b + (255 - b) * amount,
});
export const darken = ({ r, g, b }: Rgb, amount: number): Rgb => ({
r: r * (1 - amount),
g: g * (1 - amount),
b: b * (1 - amount),
});
export const rgba = ({ r, g, b }: Rgb, alpha: number): string =>
`rgba(${clamp(r)}, ${clamp(g)}, ${clamp(b)}, ${alpha})`;
// WCAG 2.1 relative luminance with gamma linearization (matches the mention
// highlight contrast logic in pages/App.tsx).
const toLinear = (c: number): number => {
const s = c / 255;
return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
};
export const relativeLuminance = ({ r, g, b }: Rgb): number =>
0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
// Choose contrasting text color over the given base (threshold 0.179).
export const contrastingText = (rgb: Rgb): string =>
relativeLuminance(rgb) > 0.179 ? '#000' : '#fff';
// Extract the underlying CSS variable name from a folds token string such as
// "var(--oq6d07f)" -> "--oq6d07f".
export const varNameFromToken = (token: string): string | undefined =>
token.match(/var\((--[^)]+)\)/)?.[1];
// The folds Primary token family, keyed by sub-token name.
const PRIMARY_TOKENS: Record<string, string> = {
Main: color.Primary.Main,
MainHover: color.Primary.MainHover,
MainActive: color.Primary.MainActive,
MainLine: color.Primary.MainLine,
OnMain: color.Primary.OnMain,
Container: color.Primary.Container,
ContainerHover: color.Primary.ContainerHover,
ContainerActive: color.Primary.ContainerActive,
ContainerLine: color.Primary.ContainerLine,
OnContainer: color.Primary.OnContainer,
};
// Derive the 10 Primary sub-token values from a single chosen base color.
export const derivePrimaryPalette = (base: Rgb): Record<string, string> => {
const baseHex = rgbToHex(base);
// If the base is very light, darken OnContainer slightly so it stays readable
// against the (light, low-alpha) container backgrounds.
const onContainer = relativeLuminance(base) > 0.6 ? rgbToHex(darken(base, 0.25)) : baseHex;
return {
Main: baseHex,
MainHover: rgbToHex(lighten(base, 0.08)),
MainActive: rgbToHex(darken(base, 0.08)),
MainLine: baseHex,
OnMain: contrastingText(base),
Container: rgba(base, 0.12),
ContainerHover: rgba(base, 0.16),
ContainerActive: rgba(base, 0.22),
ContainerLine: rgba(base, 0.4),
OnContainer: onContainer,
};
};
// Apply a custom accent color by overriding the folds Primary CSS variables on
// `document.body`. Returns true when applied, false when the input is invalid.
export const applyCustomAccent = (hex: string): boolean => {
const base = hexToRgb(hex);
if (!base) return false;
const palette = derivePrimaryPalette(base);
Object.entries(PRIMARY_TOKENS).forEach(([key, token]) => {
const varName = varNameFromToken(token);
if (varName) document.body.style.setProperty(varName, palette[key]);
});
return true;
};
// Remove all custom accent overrides, reverting to the active theme's defaults.
export const removeCustomAccent = (): void => {
Object.values(PRIMARY_TOKENS).forEach((token) => {
const varName = varNameFromToken(token);
if (varName) document.body.style.removeProperty(varName);
});
};
+11 -3
View File
@@ -169,12 +169,17 @@ const matrixErrorFromUnknown = (e: unknown): MatrixError => {
// HTTP statuses that should not be retried — client errors are deterministic
// (e.g. 413 payload too large, 400 bad request, 401/403 auth) and won't succeed on retry.
const isRetryableUploadError = (e: unknown): boolean => {
// A user-cancelled / aborted upload must never be retried. matrix-js-sdk's
// mx.cancelUpload() rejects the upload with a DOMException named "AbortError";
// without this guard the retry loop would resurrect an upload the user just
// cancelled.
if ((e as { name?: unknown } | null | undefined)?.name === 'AbortError') return false;
if (e instanceof MatrixError) {
const status = e.httpStatus;
// No status => network/transport failure (transient): retry.
if (typeof status !== 'number') return true;
// Retry on rate-limiting and server-side (5xx) errors only.
return status === 429 || status >= 500;
// Retry on request-timeout, rate-limiting and server-side (5xx) errors only.
return status === 408 || status === 429 || status >= 500;
}
// Non-Matrix errors are typically network/transport failures: retry.
return true;
@@ -307,6 +312,8 @@ export const addRoomIdToMDirect = async (
// (it can only be a DM room for one person)
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId];
// Guard against a corrupt m.direct where a value isn't an array.
if (!Array.isArray(roomIds)) return;
if (targetUserId !== userId) {
const indexOfRoomId = roomIds.indexOf(roomId);
@@ -316,7 +323,7 @@ export const addRoomIdToMDirect = async (
}
});
const roomIds = userIdToRoomIds[userId] || [];
const roomIds = Array.isArray(userIdToRoomIds[userId]) ? userIdToRoomIds[userId] : [];
if (roomIds.indexOf(roomId) === -1) {
roomIds.push(roomId);
}
@@ -334,6 +341,7 @@ export const removeRoomIdFromMDirect = async (mx: MatrixClient, roomId: string):
Object.keys(userIdToRoomIds).forEach((targetUserId) => {
const roomIds = userIdToRoomIds[targetUserId];
if (!Array.isArray(roomIds)) return;
const indexOfRoomId = roomIds.indexOf(roomId);
if (indexOfRoomId > -1) {
roomIds.splice(indexOfRoomId, 1);
+37 -7
View File
@@ -78,7 +78,7 @@ const PHRASES: Record<
},
};
const playPhrase = (style: SynthStyle, volume: number): void => {
const playPhrase = (style: SynthStyle, volume: number, destination: AudioNode): void => {
const ctx = getCtx();
if (!ctx) return;
const { type, gain: peak, notes } = PHRASES[style];
@@ -96,7 +96,7 @@ const playPhrase = (style: SynthStyle, volume: number): void => {
gain.gain.linearRampToValueAtTime(scaledPeak, start + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
osc.connect(gain);
gain.connect(ctx.destination);
gain.connect(destination);
osc.start(start);
osc.stop(start + dur + 0.02);
});
@@ -121,11 +121,41 @@ const startClassic = (volume: number, loop: boolean): (() => void) => {
};
const startSynth = (style: SynthStyle, volume: number, loop: boolean): (() => void) => {
playPhrase(style, volume);
if (!loop) return () => undefined;
const period = PHRASES[style].period * 1000;
const id = window.setInterval(() => playPhrase(style, volume), period);
return () => window.clearInterval(id);
const ctx = getCtx();
if (!ctx) return () => undefined;
// All notes route through a per-session master gain so stop() can silence
// everything instantly — including notes already scheduled slightly in the
// future — instead of letting the last phrase ring out after the user answers.
const master = ctx.createGain();
master.gain.value = 1;
master.connect(ctx.destination);
playPhrase(style, volume, master);
const id = loop
? window.setInterval(() => playPhrase(style, volume, master), PHRASES[style].period * 1000)
: 0;
let stopped = false;
return () => {
if (stopped) return;
stopped = true;
if (id) window.clearInterval(id);
try {
const now = ctx.currentTime;
master.gain.cancelScheduledValues(now);
master.gain.setValueAtTime(master.gain.value, now);
master.gain.linearRampToValueAtTime(0, now + 0.03);
} catch {
/* context may be closed */
}
window.setTimeout(() => {
try {
master.disconnect();
} catch {
/* already disconnected */
}
}, 100);
};
};
/**
+5
View File
@@ -155,6 +155,11 @@ export const sanitizeCustomHtml = (customHtml: string): string =>
allowProtocolRelative: false,
allowedClasses: {
code: ['language-*'],
// `pre` permits `class` (for `<pre class="language-*">` wrappers); without
// an allowedClasses entry, sanitize-html lets a remote sender put ARBITRARY
// class names on <pre>, activating site CSS (N100). Restrict to the same
// language-* whitelist as <code>.
pre: ['language-*'],
},
allowedStyles: {
'*': {
+4 -1
View File
@@ -2,6 +2,7 @@ import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from
import { cryptoCallbacks } from './secretStorageKeys';
import { clearNavToActivePathStore } from '../app/state/navToActivePath';
import { removeFallbackSession } from '../app/state/sessions';
import { pushSessionToSW } from '../sw-session';
type Session = {
@@ -75,7 +76,9 @@ export const logoutClient = async (mx: MatrixClient) => {
// ignore if failed to logout
}
await mx.clearStores();
window.localStorage.clear();
// Remove only the session credential keys, preserving user preferences and
// unsent drafts (N98). The factory-reset path is clearLoginData() below.
removeFallbackSession();
window.location.reload();
};
+295
View File
@@ -237,6 +237,301 @@ export const butterTheme = createTheme(color, {
},
});
export const cyberpunkTheme = createTheme(color, {
...darkThemeData,
Background: {
Container: '#0a0015',
ContainerHover: '#130722',
ContainerActive: '#1c0f30',
ContainerLine: '#26173d',
OnContainer: '#ECE6F5',
},
Surface: {
Container: '#130722',
ContainerHover: '#1c0f30',
ContainerActive: '#26173d',
ContainerLine: '#2f1f4a',
OnContainer: '#ECE6F5',
},
SurfaceVariant: {
Container: '#1c0f30',
ContainerHover: '#26173d',
ContainerActive: '#2f1f4a',
ContainerLine: '#392858',
OnContainer: '#ECE6F5',
},
Primary: {
Main: '#bf5fff',
MainHover: '#c873ff',
MainActive: '#cd7eff',
MainLine: '#d28aff',
OnMain: '#1a0033',
Container: '#3d1a5c',
ContainerHover: '#461e69',
ContainerActive: '#502276',
ContainerLine: '#592683',
OnContainer: '#EBD6FF',
},
Secondary: {
Main: '#ff2d9b',
MainHover: '#ff47a8',
MainActive: '#ff54af',
MainLine: '#ff61b6',
OnMain: '#33001a',
Container: '#5c0033',
ContainerHover: '#69003a',
ContainerActive: '#760041',
ContainerLine: '#830048',
OnContainer: '#FFD6EB',
},
Other: {
FocusRing: 'rgba(191, 95, 255, 0.5)',
Shadow: 'rgba(0, 0, 0, 1)',
Overlay: 'rgba(10, 0, 21, 0.9)',
},
});
export const oceanTheme = createTheme(color, {
...darkThemeData,
Background: {
Container: '#020b18',
ContainerHover: '#051426',
ContainerActive: '#091d34',
ContainerLine: '#0e2742',
OnContainer: '#DCEAF2',
},
Surface: {
Container: '#051426',
ContainerHover: '#091d34',
ContainerActive: '#0e2742',
ContainerLine: '#143150',
OnContainer: '#DCEAF2',
},
SurfaceVariant: {
Container: '#091d34',
ContainerHover: '#0e2742',
ContainerActive: '#143150',
ContainerLine: '#1a3b5e',
OnContainer: '#DCEAF2',
},
Primary: {
Main: '#00c9b1',
MainHover: '#1ad2bd',
MainActive: '#29d7c4',
MainLine: '#38dccb',
OnMain: '#00231f',
Container: '#004c43',
ContainerHover: '#00564c',
ContainerActive: '#006155',
ContainerLine: '#006b5e',
OnContainer: '#B3F0E8',
},
Secondary: {
Main: '#0096d6',
MainHover: '#1aa3dc',
MainActive: '#29aadf',
MainLine: '#38b1e2',
OnMain: '#001a26',
Container: '#003a52',
ContainerHover: '#00425e',
ContainerActive: '#004b6b',
ContainerLine: '#005377',
OnContainer: '#B3E2F5',
},
Other: {
FocusRing: 'rgba(0, 201, 177, 0.5)',
Shadow: 'rgba(0, 0, 0, 1)',
Overlay: 'rgba(2, 11, 24, 0.9)',
},
});
export const bloodRedTheme = createTheme(color, {
...darkThemeData,
Background: {
Container: '#0d0203',
ContainerHover: '#180608',
ContainerActive: '#240a0d',
ContainerLine: '#300e12',
OnContainer: '#F2DDDD',
},
Surface: {
Container: '#180608',
ContainerHover: '#240a0d',
ContainerActive: '#300e12',
ContainerLine: '#3c1318',
OnContainer: '#F2DDDD',
},
SurfaceVariant: {
Container: '#240a0d',
ContainerHover: '#300e12',
ContainerActive: '#3c1318',
ContainerLine: '#48181e',
OnContainer: '#F2DDDD',
},
Primary: {
Main: '#ff2233',
MainHover: '#ff3d4b',
MainActive: '#ff4a57',
MainLine: '#ff5763',
OnMain: '#330003',
Container: '#7a0010',
ContainerHover: '#8a0013',
ContainerActive: '#990015',
ContainerLine: '#a80018',
OnContainer: '#FFD1D6',
},
Secondary: {
Main: '#FFFFFF',
MainHover: '#E5E5E5',
MainActive: '#D9D9D9',
MainLine: '#CCCCCC',
OnMain: '#0d0203',
Container: '#3c1318',
ContainerHover: '#48181e',
ContainerActive: '#541d24',
ContainerLine: '#60222a',
OnContainer: '#F2DDDD',
},
Other: {
FocusRing: 'rgba(255, 34, 51, 0.5)',
Shadow: 'rgba(0, 0, 0, 1)',
Overlay: 'rgba(13, 2, 3, 0.9)',
},
});
export const classicMatrixTheme = createTheme(color, {
...darkThemeData,
Background: {
Container: '#000000',
ContainerHover: '#0a0f0a',
ContainerActive: '#121a12',
ContainerLine: '#1c281c',
OnContainer: '#C8E6C8',
},
Surface: {
Container: '#0a0f0a',
ContainerHover: '#121a12',
ContainerActive: '#1c281c',
ContainerLine: '#263626',
OnContainer: '#C8E6C8',
},
SurfaceVariant: {
Container: '#121a12',
ContainerHover: '#1c281c',
ContainerActive: '#263626',
ContainerLine: '#304530',
OnContainer: '#C8E6C8',
},
Primary: {
Main: '#00ff41',
MainHover: '#1aff57',
MainActive: '#29ff63',
MainLine: '#38ff6f',
OnMain: '#001a08',
Container: '#003311',
ContainerHover: '#003d14',
ContainerActive: '#004718',
ContainerLine: '#00521b',
OnContainer: '#9DFFB8',
},
Secondary: {
Main: '#C8E6C8',
MainHover: '#baddba',
MainActive: '#b0d6b0',
MainLine: '#a3cca3',
OnMain: '#000000',
Container: '#263626',
ContainerHover: '#304530',
ContainerActive: '#3a543a',
ContainerLine: '#446344',
OnContainer: '#DFF2DF',
},
Other: {
FocusRing: 'rgba(0, 255, 65, 0.5)',
Shadow: 'rgba(0, 0, 0, 1)',
Overlay: 'rgba(0, 0, 0, 0.9)',
},
});
export const midnightTheme = createTheme(color, {
...darkThemeData,
Background: {
Container: '#111827',
ContainerHover: '#1a2234',
ContainerActive: '#232d42',
ContainerLine: '#2c3850',
OnContainer: '#E5E9F0',
},
Surface: {
Container: '#1a2234',
ContainerHover: '#232d42',
ContainerActive: '#2c3850',
ContainerLine: '#35435e',
OnContainer: '#E5E9F0',
},
SurfaceVariant: {
Container: '#232d42',
ContainerHover: '#2c3850',
ContainerActive: '#35435e',
ContainerLine: '#3e4e6c',
OnContainer: '#E5E9F0',
},
Primary: {
Main: '#6b7ca8',
MainHover: '#7989b1',
MainActive: '#8493b8',
MainLine: '#8f9dbf',
OnMain: '#000000',
Container: '#2e3a55',
ContainerHover: '#354161',
ContainerActive: '#3c496d',
ContainerLine: '#435179',
OnContainer: '#D2DAEC',
},
Secondary: {
Main: '#E5E9F0',
MainHover: '#d4d9e3',
MainActive: '#c9cfdb',
MainLine: '#bdc4d3',
OnMain: '#111827',
Container: '#35435e',
ContainerHover: '#3e4e6c',
ContainerActive: '#47597a',
ContainerLine: '#506488',
OnContainer: '#E5E9F0',
},
Other: {
FocusRing: 'rgba(107, 124, 168, 0.5)',
Shadow: 'rgba(0, 0, 0, 1)',
Overlay: 'rgba(17, 24, 39, 0.9)',
},
});
export const lotusTerminalTheme = createTheme(color, {
Background: {
Container: '#030508',
-26
View File
@@ -1,5 +1,4 @@
/* eslint-disable import/first */
import * as Sentry from '@sentry/react';
import React from 'react';
import { createRoot } from 'react-dom/client';
import { enableMapSet } from 'immer';
@@ -7,31 +6,6 @@ import '@fontsource-variable/inter/index.css';
import 'folds/dist/style.css';
import { configClass, varsClass } from 'folds';
const sentryDsn = import.meta.env.VITE_SENTRY_DSN;
if (sentryDsn) {
Sentry.init({
dsn: sentryDsn,
environment: import.meta.env.MODE,
release: import.meta.env.VITE_APP_VERSION,
// browserTracingIntegration omitted — it injects sentry-trace/baggage headers
// into outgoing fetch calls, which breaks Synapse CORS on matrix.lotusguild.org
// No propagation targets — we don't control the Matrix server's CORS allow-list
tracePropagationTargets: [],
tracesSampleRate: 0,
// Don't send PII (IPs, usernames) — this is a private chat app
sendDefaultPii: false,
// Forward Sentry logs to the dashboard
enableLogs: true,
// Suppress benign PostmessageTransport / matrixRTC heartbeat timeouts (upstream library noise)
ignoreErrors: ['Request timed out'],
beforeSend(event) {
// Drop any event that may have leaked an access token into breadcrumbs/data
if (JSON.stringify(event).includes('access_token')) return null;
return event;
},
});
}
enableMapSet();
import './index.css';
+1 -16
View File
@@ -1,6 +1,5 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { sentryVitePlugin } from '@sentry/vite-plugin';
import { wasm } from '@rollup/plugin-wasm';
import inject from '@rollup/plugin-inject';
import { viteStaticCopy } from 'vite-plugin-static-copy';
@@ -261,20 +260,6 @@ export default defineConfig({
react(),
copyPdfWorker(),
lotusDenoise(),
...(process.env.SENTRY_AUTH_TOKEN
? [
sentryVitePlugin({
org: 'lotus-guild',
project: 'javascript-react',
authToken: process.env.SENTRY_AUTH_TOKEN,
sourcemaps: {
filesToDeleteAfterUpload: ['./dist/**/*.map'],
},
release: { name: process.env.VITE_APP_VERSION ?? 'lotus' },
telemetry: false,
}),
]
: []),
VitePWA({
srcDir: 'src',
filename: 'sw.ts',
@@ -302,7 +287,7 @@ export default defineConfig({
build: {
target: 'esnext',
outDir: 'dist',
sourcemap: process.env.SENTRY_AUTH_TOKEN ? 'hidden' : false,
sourcemap: false,
copyPublicDir: false,
// manualChunks must be in rolldownOptions (not rollupOptions) for Vite 8 / Rolldown
rolldownOptions: {