Compare commits

..

48 Commits

Author SHA1 Message Date
jared 388a934665 Show avatar decoration in user profile modal
CI / Build & Quality Checks (push) Successful in 10m29s
Trigger Desktop Build / trigger (push) Successful in 11s
Wraps the profile hero avatar with AvatarDecoration so a user's chosen
decoration ring is visible when viewing their profile panel. Added an
optional `inset` prop to AvatarDecoration so the profile hero can use
a larger bleed (20px) proportional to the bigger avatar size.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 16:19:47 -04:00
jared 99e6a456a7 fix: use CSS grid + plain divs for decoration grid, eliminate flex-shrink overlap
CI / Build & Quality Checks (push) Successful in 10m33s
Trigger Desktop Build / trigger (push) Successful in 15s
The outer Box(direction=Column, gap=300) was a flex column with flex-shrink:1
on children. With maxHeight:480 + overflowY:auto, when total content exceeded
480px the flex children compressed into each other, making cells appear to
overlap regardless of the gap on the inner flex container.

Replace with:
- Plain div scroll container (display:flex flex-direction:column gap:24)
  so children never shrink — they overflow into scroll area
- Plain div per category (gap:10 between label and grid)
- CSS grid (auto-fill, 72px columns, gap:20) for cells so row spacing is
  explicit and cannot be affected by flex layout math

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 15:32:19 -04:00
jared a5fe358313 fix: rewrite decoration grid to contain images within cells
CI / Build & Quality Checks (push) Successful in 11m39s
Trigger Desktop Build / trigger (push) Successful in 6s
The INSET overflow approach (position:absolute images extending beyond
52×52 buttons) was fundamentally broken: absolutely positioned children
don't contribute to flex row height, so rowGap controlled button-to-button
spacing but image pixels still painted into the gap, causing visual overlap
regardless of how large rowGap was set.

New approach: 72×72 circle cells, overflow:hidden, image fills the cell
via inset:0 with objectFit:contain. Gap of 16px is actual clear space
between cell edges — no math needed. Also bumped maxHeight 420→480.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 14:49:42 -04:00
jared d7d7b59866 fix: add public/fonts to viteStaticCopy targets
CI / Build & Quality Checks (push) Successful in 10m33s
Trigger Desktop Build / trigger (push) Successful in 5s
public/fonts/ (custom-fonts.css + woff2 files) was never listed in the
static copy plugin config, so it was absent from every dist build output.
The site served HTML instead of CSS for /fonts/custom-fonts.css, causing
JetBrains Mono and Fira Code to 404. Adding the target copies the directory
to dist/fonts/ where nginx serves it correctly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 14:17:36 -04:00
jared 362ccff85d fix: increase decoration grid row gap for images that overflow cell bounds
rowGap 36→52 (40px visual gap between image rows), columnGap separate at 28.
The 52×52 buttons have 8px image overflow on each side so row gap needed to
account for 8+visual+8 = actual gap. Previous 36→20px visual was still tight.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 14:13:38 -04:00
jared 6ec0ab78d9 fix: LIGHT variant bg animations, decoration grid spacing, bug doc updates
CI / Build & Quality Checks (push) Successful in 10m30s
Trigger Desktop Build / trigger (push) Successful in 6s
- chatBackground.ts: remove animRainGlowKeyframe and animGridBrightnessKeyframe
  from LIGHT anim-rain and anim-pulse definitions (these were removed from the
  import and from DARK variants in the previous session but the LIGHT variants
  were missed, leaving stale references that would cause a build error)
- ProfileDecoration.tsx: increase decoration grid gap 20→36 (visual gap was
  only 4px due to 8px image overflow beyond each 52×52 button), fix paddingBottom
  4→8 and add paddingRight:8 to prevent edge clipping
- LOTUS_BUGS.md: correct bug #8 root cause (CSP, not lazy-loading), add
  bugs #9 (grid spacing) and #10 (Windows taskbar badge)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 13:19:11 -04:00
jared e9a970a75b fix: settings dropdowns, background animations, ringing, avatar decorations
CI / Build & Quality Checks (push) Successful in 10m22s
Trigger Desktop Build / trigger (push) Successful in 7s
Settings dropdowns (Bug #3):
- Add reusable SettingsSelect component using Menu+PopOut+FocusTrap — exact
  same pattern as Message Layout, so all dropdowns look consistent
- Replace raw <select> for Seasonal Theme, UI Font, AFK Timeout, and
  Join & Leave Sounds with SettingsSelect

Animated chat backgrounds bleeding onto content (Bug #6 / #7):
- Remove filter:brightness() and opacity animations from chatBackground.ts
  (animRainGlowKeyframe, animGridBrightnessKeyframe, animFirefliesGlowKeyframe,
  animFirefliesBlinkKeyframe). These were applied to the Page element which
  caused ALL descendants (messages, composer) to flash in sync.
  Also created a CSS stacking context on Page that pushed SeasonalEffect
  (position:fixed; z-index:9997) behind the animated background layer.
- Only backgroundPosition / backgroundSize animations remain — safe, do not
  affect descendants, and do not create stacking contexts.
- Remove now-unused animation keyframe imports from chatBackground.ts.

Voice ringing in persistent rooms (Bug #5):
- Narrow the ringing condition from (Invite|Knock|Restricted) to only Invite,
  matching exactly the rooms where the call button is visible.
- Add room.isCallRoom() early-exit so m.join_rule:call rooms never ring.

Avatar decoration images not loading (Bug #8):
- Change loading="lazy" → loading="eager" in DecorationPreviewCell.
  Lazy loading does not reliably trigger for images inside nested overflow
  scroll containers (the settings panel scroll area), so images never loaded.

Docs: LOTUS_BUGS.md updated with root cause and resolution for all 5 new bugs.
Docs: LOTUS_TODO.md adds P5-35/P5-36 (deferred desktop notification/jump list).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 12:20:47 -04:00
jared 2a545b8b3e feat: avatar decorations follow-up — Nextcloud CDN, sync script, docs
CI / Build & Quality Checks (push) Successful in 10m36s
Trigger Desktop Build / trigger (push) Successful in 5s
- Point DECORATION_CDN at Lotus Nextcloud WebDAV share instead of external
  avatardecoration.com; all 99 APNG files are self-hosted and served via
  direct DAV URL (no CORS issue for <img> elements)
- Add onError handler to AvatarDecoration.tsx to silently hide the overlay
  if a file is missing or the CDN is unreachable
- Rewrite scripts/syncDecorations.mjs: now sends HTTP HEAD requests to the
  live Nextcloud CDN (batches of 16 in parallel) and removes catalog entries
  for files that return non-2xx; empty categories are pruned automatically.
  Workflow: delete files from Nextcloud → run `npm run sync:decorations` →
  commit the updated avatarDecorations.ts. No local files needed.
- Add public/decorations/ to .gitignore; delete the 85 MB local APNG cache
  that was downloaded during development (files live on Nextcloud now)
- Add sync:decorations script to package.json
- Update LOTUS_FEATURES.md, LOTUS_TODO.md (P5-13 + P5-14 ✓), README.md
  with avatar decoration documentation and catalog sync workflow

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 12:02:50 -04:00
jared bf1308dd55 feat: avatar decorations (P5-13)
CI / Build & Quality Checks (push) Successful in 10m30s
Trigger Desktop Build / trigger (push) Successful in 19s
256×256 APNG overlays from avatardecoration.com (open CORS CDN).
Stored in the user's Matrix profile as io.lotus.avatar_decoration via
MSC4133 so all Lotus Chat users see each other's decorations.

- avatarDecorations.ts: curated catalog of 110 original-IP decorations
  across 9 categories (Gaming, Cyber, Space, Fantasy, Elements,
  Japanese, Nature, Spooky, Cozy)
- useAvatarDecoration: per-user profile fetch with module-level cache
  and in-flight deduplication so concurrent renders for the same userId
  share one HTTP request
- AvatarDecoration: position:relative wrapper that overlays the APNG
  8px beyond the avatar on all sides; renders nothing when no decoration
  is set (zero cost for undecorated users)
- ProfileDecoration: scrollable grid picker in Settings → Profile,
  grouped by category with live preview; Save button appears only when
  the selection differs from what's saved
- Applied at all five avatar display sites: message timeline, members
  drawer, knock list, @mention autocomplete, notifications inbox

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 11:24:04 -04:00
jared ca09e8e6ca feat: presence fix, voice ringing fix, user private notes + doc updates
CI / Build & Quality Checks (push) Successful in 10m22s
Trigger Desktop Build / trigger (push) Successful in 5s
- usePresenceUpdater: replace stale closure with readStatus() called at
  invocation time so changing custom status in Profile Settings is never
  silently overwritten by subsequent activity events
- CallEmbedProvider: fix m.space.parent state-key lookup by switching
  getStateEvent → getStateEvents (plural); space channel voice rooms no
  longer trigger the incoming-call ring/animation
- Add useUserNotes hook (io.lotus.user_notes account data, reactive via
  useAccountDataCallback, 500-char limit, cross-device sync)
- UserRoomProfile: add UserPrivateNotes textarea with 800ms debounced
  auto-save, saving indicator, char counter when <100 chars remain;
  shown only when viewing another user's profile
- LOTUS_FEATURES.md: add Private Notes section, Status Revert fix note,
  animation improvements subsection, Seasonal Themes section
- LOTUS_BUGS.md: mark presence revert + voice ringing bugs as resolved
- README.md + landing/index.html: document all new June 2026 features

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 00:47:14 -04:00
jared 6db07f1371 feat: seasonal theme overlays + improved animated chat backgrounds
Adds 11 CSS-only seasonal overlays (Halloween, Christmas, New Year, Autumn,
April Fool's, Lunar New Year, Valentine's Day, St. Patrick's Day, Earth Day,
Deep Space, Retro Arcade) with date-based auto-detection and a manual override
dropdown in Settings → Appearance → Seasonal Theme. All themes respect
prefers-reduced-motion. SeasonalEffect mounts at z-index 9997 in App.tsx.

Also rewrites all 5 animated chat background keyframes for smoother, more
organic motion: Digital Rain gains a phosphor glow flicker; Star Drift now
loops each layer by exactly its own tile size (no more seam); Grid Pulse adds
an independent brightness oscillation at a prime period; Aurora Flow drives
all four gradient layers through distinct paths; Fireflies adds glow-pulse and
opacity-blink animations at prime periods for unsynchronised bioluminescence.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 00:33:04 -04:00
jared 107921e0d0 feat: desktop tray unread overlay + taskbar flash on new mention
CI / Build & Quality Checks (push) Successful in 10m29s
Trigger Desktop Build / trigger (push) Successful in 15s
Extends useTauriNotificationBadge to also invoke set_tray_unread (mirror the
unread-mention state onto the tray icon, visible when minimized) and flash_window
(flash the taskbar button when new mentions arrive while the window is unfocused).
No-op outside Tauri.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 23:53:39 -04:00
jared 053b364a44 feat: route desktop matrix: deep links into the client
CI / Build & Quality Checks (push) Successful in 15m17s
Trigger Desktop Build / trigger (push) Successful in 4s
Adds useDeepLinkNavigate (mounted in ClientNonUIFeatures): listens for the
'lotus-deeplink' DOM CustomEvent that the Tauri shell dispatches when a
matrix:/matrix.to link opens the app, converts matrix: URIs to matrix.to, and
navigates via the same path as useMentionClickHandler (reusing matrix-to.ts
parsers + useRoomNavigate). No-op outside Tauri.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 18:52:23 -04:00
jared 3282832a4a assets: make lotus_chat_transparent.png genuinely transparent
CI / Build & Quality Checks (push) Successful in 10m39s
Trigger Desktop Build / trigger (push) Successful in 5s
The committed file had the transparency 'checkerboard' baked in as opaque
gray/white pixels (it was fully opaque), so it would have produced icons with a
checkerboard background. Flood-filled the dove to protect it, removed the
checkerboard, and restored the dove — the lotus line-art + white dove now sit on
a real alpha-transparent background. Used as the source for the desktop icons.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 14:57:02 -04:00
jared 00524bebe0 fix: correct voice limit setting description to reflect server-side enforcement
CI / Build & Quality Checks (push) Successful in 10m14s
Trigger Desktop Build / trigger (push) Successful in 5s
The Room Settings description still said 'Enforced locally by Lotus Chat
clients' from before the voice-limit-guard was deployed. The cap is now
enforced server-side (via the lk-jwt-service guard) for all Matrix clients.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 13:41:22 -04:00
jared 9df4d2d7ee fix: Element Call assets land flat in public/element-call (fixes desktop calls)
CI / Build & Quality Checks (push) Successful in 10m20s
Trigger Desktop Build / trigger (push) Successful in 10s
vite-plugin-static-copy v4 preserves the full source path under dest, so the
old { src: '.../dist', dest: 'public', rename: 'element-call' } target placed
Element Call at dist/public/node_modules/@element-hq/element-call-embedded/dist/
instead of dist/public/element-call/. The call widget URL
(/public/element-call/index.html) therefore 404'd.

This broke voice/video calls on cinny-desktop (served via tauri-plugin-localhost
from a fresh build). The web client only kept working because its deployed
/public/element-call/ was a stale artifact from before the v4 bump — the next
web rebuild would have broken calls there too.

Fix: copy the dist directory to public/element-call with rename:{stripBase:4}
to strip the source path segments, mirroring the android/locales targets.
Verified: a fresh build now produces dist/public/element-call/index.html +
assets/ with intact relative asset refs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-13 12:26:46 -04:00
jared 2c5f0b8b28 fix: make call join/leave sounds audible to all participants + server-side hard voice limit docs
CI / Build & Quality Checks (push) Successful in 10m29s
Trigger Desktop Build / trigger (push) Successful in 5s
Sounds (P5-16): browsers block the Web Audio context until a user gesture
starts it, so join/leave sounds — which fire later with no gesture — were
silent. unlockCallSounds() now primes/resumes the shared AudioContext inside
the Join click (centralized in useCallStart so every join path is covered),
making the per-client sounds reliably audible to everyone in the call.

Voice limit (P5-10): the limit is now a hard, cross-client server-side cap
enforced by the voice-limit-guard sidecar (matrix repo) that fronts
lk-jwt-service and refuses LiveKit tokens when a room is full. Updated
LOTUS_FEATURES.md / README.md / LOTUS_TODO.md to reflect that the client
'Channel Full' check is UX only and the server is authoritative.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 23:45:31 -04:00
jared 702e2e00eb feat: voice channel user limit (P5-10) + call join/leave sounds (P5-16)
CI / Build & Quality Checks (push) Successful in 10m54s
Trigger Desktop Build / trigger (push) Successful in 6s
P5-10 Voice Channel User Limit:
- New StateEvent.LotusVoiceLimit (io.lotus.voice_limit) with { max_users }
- RoomVoiceLimit admin control in Room Settings > General > Voice
  (power-level gated via permissions.stateEvent)
- CallPrescreen reads the limit reactively and disables Join with a
  'Channel Full (N/N)' message at capacity; existing members can rejoin

P5-16 Custom Join/Leave Sound Effects:
- useCallJoinLeaveSounds hook wired into CallUtils; detects participant
  join/leave via MatrixRTCSession membership changes (sender|deviceId),
  filters out self, only fires while joined
- Sounds synthesized in-browser with Web Audio (callSounds.ts) - no
  assets bundled; styles Off/Chime/Soft/Retro
- 'Join & Leave Sounds' selector in Settings > Calls (previews on change)

Docs: LOTUS_FEATURES.md, README.md, LOTUS_TODO.md (P5-10/P5-16 marked done)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 22:20:22 -04:00
jared 2b1c3256b6 docs: document P4-3 (knock admin badge) and P5-11 (AFK auto-mute)
CI / Build & Quality Checks (push) Successful in 10m32s
Trigger Desktop Build / trigger (push) Successful in 5s
- LOTUS_FEATURES.md: added sections for Knock-to-Join Notifications for
  Admins and AFK Auto-Mute in Voice under their parent headings
- LOTUS_TODO.md: marked P4-3 and P5-11 as [x] completed
- README.md: updated Calls & Voice bullet list with AFK auto-mute entry;
  expanded knock admin badge entry with badge-count detail

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 21:38:14 -04:00
jared 6a57c13c56 fix: fall back to DOM traversal in targetFromEvent when composedPath is empty
CI / Build & Quality Checks (push) Successful in 10m44s
Trigger Desktop Build / trigger (push) Successful in 22s
composedPath() returns an empty array once the native event is no longer
dispatching. In React 19, events from a portal-within-portal (EmojiBoard
inside #portalContainer, which already hosts the Settings Overlay) can reach
the synthetic event handler after the native dispatch window closes.

The fallback walks up from evt.target so handleGroupItemClick still finds
the emoji button and calls onEmojiSelect — fixing emoji selection in the
status-message editor in Settings > Account > Profile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 21:31:07 -04:00
jared 362f4943d4 feat: knock notifications for admins + AFK auto-mute in calls
CI / Build & Quality Checks (push) Successful in 10m26s
Trigger Desktop Build / trigger (push) Successful in 14s
P4-3 — Knock-to-join Notifications for Admins:
- usePendingKnocks() hook reactively tracks knocking members via
  RoomMemberEvent.Membership; returns empty array if user lacks invite power
- Members icon in RoomViewHeader shows a Warning badge with the knock count
  when there are pending requests; badge updates in real time without
  needing to open the drawer; aria-label updated to describe pending count

P5-11 — AFK Auto-Mute in Voice:
- useAfkAutoMute() hook opens a monitoring-only getUserMedia stream,
  connects it to an AnalyserNode, and polls RMS every 500ms
- If mic is on and RMS stays below threshold for the configured timeout,
  calls callEmbed.control.setMicrophone(false) and shows an in-app toast
- Hook is called inside CallControls so monitoring is only active during calls
- Settings: afkAutoMute toggle + afkTimeoutMinutes select (1/5/10/20/30 min,
  default 10) added to Settings → Calls

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 19:53:19 -04:00
jared f15c4caf97 ci: remove redundant workflow_dispatch from trigger-desktop.yml
CI / Build & Quality Checks (push) Successful in 10m46s
Trigger Desktop Build / trigger (push) Successful in 50s
Pushing to cinny-desktop main already fires release.yml via on:push.
The explicit API dispatch call was redundant, caused double-job runs,
and failed with 401 when the temporary admin token expired. Removed.
DISPATCH_TOKEN secret is no longer needed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 18:35:45 -04:00
jared aa48c9ef8a Fix three open bugs from LOTUS_BUGS.md
CI / Build & Quality Checks (push) Successful in 10m38s
Trigger Desktop Build / trigger (push) Failing after 5s
- EditHistoryModal: decrypt fetched edit events in E2EE rooms via
  mx.decryptEventIfNeeded() before rendering; previously events not
  found in the room cache showed ciphertext or "(no text)"
- CallEmbedProvider: add touch support for PiP resize corners;
  extracted shared applyResize() helper; onTouchStart wired to all
  four corners alongside existing onMouseDown
- RoomView: skip chatBgStyle when glassmorphism is active; document.body
  already carries the background for the blur effect, rendering it twice
  doubled CSS animation work unnecessarily

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-12 18:17:35 -04:00
jared 3df9c4d9e6 fix: switch trigger dispatch to DISPATCH_TOKEN with verified Actions scope
CI / Build & Quality Checks (push) Successful in 10m51s
Trigger Desktop Build / trigger (push) Successful in 8s
ACTIONS_TOKEN's dispatch attempts were failing silently. DISPATCH_TOKEN is
a new cinny repo secret with confirmed actions:write scope. Also fix the
HTTP check to use -ge/-lt arithmetic instead of -lt/-gt.

NOTE: DISPATCH_TOKEN should be replaced with a permanent Gitea API token
that has actions:write scope (create in Gitea user settings → Applications).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 15:43:11 -04:00
jared 2178295eaa fix: use ACTIONS_TOKEN for workflow dispatch, log HTTP status
Trigger Desktop Build / trigger (push) Failing after 11m46s
CI / Build & Quality Checks (push) Failing after 11m58s
RELEASE_TOKEN may lack Actions write scope. ACTIONS_TOKEN already exists
as a repo secret and is the correct token for dispatching workflows.
Also capture and print the HTTP response so failures are visible in logs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 13:21:13 -04:00
jared 055dcec65b fix: restore workflow_dispatch to cinny-desktop — push trigger doesn't fire in this runner setup
CI / Build & Quality Checks (push) Successful in 10m22s
Trigger Desktop Build / trigger (push) Failing after 5s
2026-06-11 11:50:42 -04:00
jared 9a24feb914 ci: trigger desktop build
CI / Build & Quality Checks (push) Has been cancelled
Trigger Desktop Build / trigger (push) Successful in 5s
2026-06-11 11:47:06 -04:00
jared 46567555e1 fix: ESLint errors and Prettier formatting
CI / Build & Quality Checks (push) Successful in 10m27s
Trigger Desktop Build / trigger (push) Successful in 5s
ESLint errors:
- usePresenceUpdater: remove redundant `const userId` inside handlePageHide
  that shadowed the outer declaration (no-shadow)
- RoomViewHeader: prefix unused encryptedRoom with _ (no-unused-vars)

Prettier: reformat 14 files to match project style

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 22:55:32 -04:00
jared b41bfd35c0 fix: persist status message and timezone across reconnects
CI / Build & Quality Checks (push) Successful in 10m36s
Trigger Desktop Build / trigger (push) Successful in 12s
Status message: Synapse clears status_msg when a user goes offline/reconnects.
Fix by caching to localStorage and re-sending on setOnline(). The sync
effect no longer overwrites the local value with an empty server event.

Timezone: PUT /profile/{userId}/m.tz is MSC1769 (unstable) and not
supported by standard Synapse — save/load silently fails. Fix by using
Matrix account data (im.lotus.timezone) instead, which is fully
supported. Own profile falls back to account data; other users still
try the m.tz profile endpoint (for federated servers that support it).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 21:31:58 -04:00
jared 6a83e67f95 fix: voice channels no longer ring on join
CI / Build & Quality Checks (push) Successful in 10m53s
Trigger Desktop Build / trigger (push) Successful in 10s
Omitting sendNotificationType for call rooms caused Element Call to
default to ring behavior. Now all starting-call events explicitly set
notification (or ring for DMs). Voice channels always get notification.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:33:00 -04:00
jared 469b9aa9c6 feat: in-app update checker + Spinner import for General settings
- Add useTauriUpdater hook (check_for_update / install_update commands)
- Add AppUpdates section to General settings (Tauri-only, hidden on web)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 20:31:35 -04:00
jared 77a29ed3c6 fix: presence MaxListeners leak + self-host fonts to fix tracking prevention
CI / Build & Quality Checks (push) Successful in 10m29s
Trigger Desktop Build / trigger (push) Successful in 5s
Presence: subscribe on MatrixClient (mx) instead of individual User objects.
User EventEmitters have a 10-listener default; the same user appears in many
mounted components simultaneously (member list, avatars, presence rings) and
was accumulating 11+ listeners per event, causing MaxListenersExceededWarning
on User.presence, User.currentlyActive, and User.lastPresenceTs.

Fonts: download JetBrains Mono and Fira Code latin subsets to public/fonts/
and replace Google Fonts CDN links (two external origins) with a local CSS
file. Windows WebView2 tracking prevention was blocking googleapis.com and
gstatic.com, logging 18+ "blocked" warnings per session. VT323 remains on
Google Fonts as it's decorative and not a default option.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 18:39:03 -04:00
jared a30a3d3a47 fix(ci): remove redundant workflow_dispatch — push already triggers build
CI / Build & Quality Checks (push) Successful in 10m48s
Trigger Desktop Build / trigger (push) Successful in 10s
trigger-desktop.yml was pushing a submodule bump commit (which fires
release.yml via the push event) AND then explicitly dispatching
release.yml via the API, causing every cinny push to produce two
back-to-back desktop builds. Drop the dispatch step; the push alone
is sufficient.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:59:56 -04:00
jared a9787ef041 feat: Windows taskbar notification badge counter
CI / Build & Quality Checks (push) Successful in 10m31s
Trigger Desktop Build / trigger (push) Successful in 13s
Adds a Tauri command (set_badge_count) that draws a red circle badge
with a highlight count onto the Windows taskbar button overlay icon,
matching Discord's behavior. Badge shows @mention/highlight count only
(not total messages), clears to zero when all highlights are read.

Frontend: useTauriNotificationBadge hook reads roomToUnreadAtom and
calls set_badge_count via window.__TAURI_INTERNALS__.invoke whenever
the unread map changes. No-ops silently in the browser (non-Tauri).
Mounted as TauriEffects inside JotaiProvider in App.tsx.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:32:38 -04:00
jared fcf16fd654 refactor(settings): replace composer toolbar toggle rows with chip grid
8 individual SettingTile rows collapsed into a single wrapped chip grid.
Active chips show as Primary+outlined; inactive as Secondary. Clicking
any chip toggles it. Drops from ~83 lines to ~25 and reads at a glance.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:15:34 -04:00
jared 0a14ec63de feat: add Lotus Chat custom logo across all web app icons
CI / Build & Quality Checks (push) Successful in 10m36s
Trigger Desktop Build / trigger (push) Successful in 16s
Replaces all PWA, Android, Apple touch, and favicon assets with the
custom Lotus Chat logo (lotus_chat.png, 1254x1254 source).

Adds attribution section to README per CC BY 4.0 requirements:
the logo is a derivative of the original Cinny logo by Ajay Bura,
used under CC BY 4.0, and the modified version is likewise CC BY 4.0.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 17:01:06 -04:00
jared 5469740f4c feat: P5-21 mention color, P5-22 font selector, P5-27 notification presets; update docs
CI / Build & Quality Checks (push) Successful in 10m27s
Trigger Desktop Build / trigger (push) Successful in 7s
- P5-21: Custom @mention highlight color picker in Settings → Appearance.
  CSS vars with luminance-computed text color; resets cleanly to theme default.
- P5-22: Font selector (System, Inter, JetBrains Mono, Fira Code) in
  Settings → Appearance. Fira Code added to Google Fonts preload.
- P5-27: Gaming/Work/Sleep preset buttons at top of Settings → Notifications.
  Each atomically applies a group of notification settings.
- AppearanceEffects component in App.tsx applies CSS vars on settings change.
- LOTUS_BUGS.md: mark presence + manifest icon bugs as resolved.
- LOTUS_TODO.md: mark P3-6, P5-21, P5-22, P5-27 as [x].
- LOTUS_FEATURES.md: document all four completed features.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 15:39:35 -04:00
jared 891f2daf99 feat(P3-6): configurable composer toolbar buttons
CI / Build & Quality Checks (push) Successful in 10m24s
Trigger Desktop Build / trigger (push) Successful in 7s
Each button (Format, Emoji, Sticker, GIF, Location, Poll, Voice,
Schedule) can be individually hidden in Settings → Editor.
All default to on, stored in composerToolbarButtons object.
getSettings deep-merges the nested object so new buttons default
to true for existing users with saved settings.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 13:20:29 -04:00
jared b7daabe2e0 fix: presence no longer wipes custom status, correct manifest icon paths
CI / Build & Quality Checks (push) Successful in 10m36s
Trigger Desktop Build / trigger (push) Failing after 7s
- Remove status_msg from setOnline/setUnavailable so server preserves
  any custom status message the user has set
- Fix manifest icons array paths (./public/android/ -> ./res/android/)
  Junior had it backwards: shortcut icon was correct, main icons were wrong

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 11:36:55 -04:00
jared d78f81c3a7 fix(ci): show HTTP response body when dispatch fails for debugging
CI / Build & Quality Checks (push) Successful in 10m43s
Trigger Desktop Build / trigger (push) Failing after 7s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 10:05:50 -04:00
jared 170d22eebb docs: update LOTUS_BUGS.md to reflect all recently fixed items
CI / Build & Quality Checks (push) Successful in 10m39s
Trigger Desktop Build / trigger (push) Failing after 6s
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:05:25 -04:00
jared c8ff7b0718 fix: remaining LOTUS_BUGS.md items — hardcoded hex and a11y
- GifPicker: replace #FF6B00 and #060c14 with var(--lt-accent-orange) and
  var(--lt-bg-secondary); rgba opacity values use color-mix()
- VoiceMessageRecorder: replace #FF6B00 and #00FF88 waveform/dot colors
  with var(--lt-accent-orange) / var(--lt-accent-green)
- Presence: replace aria-labelledby referencing a lazy tooltip ID with a
  direct aria-label so screen readers always have the text in the DOM

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-10 00:01:12 -04:00
jared bafd9cbe75 fix: address confirmed bugs from LOTUS_BUGS.md audit
CI / Build & Quality Checks (push) Successful in 10m54s
Trigger Desktop Build / trigger (push) Failing after 8s
- useFileDrop: reset drag overlay when mouse leaves browser window
  (relatedTarget === null signals viewport exit, counter was getting stuck)
- useDeviceVerificationStatus: add member count to useMemo deps so new
  room members' devices get checked, not just initial joined members
- index.css: define --bg-surface-variant used by VoiceMessageRecorder,
  MessageSearch, SearchFilters, UserRoomProfile (was falling back to transparent)
- syntaxHighlight: fix Python inline comments — # after space/tab was
  treated as plain text; only start-of-line was recognised
- usePresenceUpdater: replace internal baseUrl cast with mx.getHomeserverUrl()
- useLocalMessageSearch: scan all linked timelines via getUnfilteredTimelineSet()
  not just the live window, so scrolled-back history is included in E2EE search
- RoomViewHeader: show search button in encrypted rooms — local search is
  implemented and handles them; the guard was a holdover from before it existed
- recent-emoji: return emojis in recency order (array is already unshifted on
  use) instead of sorting by total usage count

Skipped: media gallery memory leak (needs virtualization refactor),
bookmark race condition (needs queue/lock), Night Light portal coverage
(position:fixed already covers full viewport — not a real bug).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 22:56:06 -04:00
jared f11b308f91 fix(ci): use workflow_dispatch to trigger cinny-desktop build
CI / Build & Quality Checks (push) Successful in 11m31s
Trigger Desktop Build / trigger (push) Failing after 6s
Replace the contents-API workaround with a direct workflow_dispatch call
using ACTIONS_TOKEN (which has Actions:write scope). Cascade prevention
in Gitea blocked all previous push-based approaches.

The submodule bump is kept for correct SHA tracking in git history.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 20:43:25 -04:00
jared 81e1a25de6 fix(ci): trigger cinny-desktop build via Gitea contents API
CI / Build & Quality Checks (push) Successful in 11m46s
Trigger Desktop Build / trigger (push) Successful in 29s
Gitea suppresses workflow events from Actions runner pushes to prevent
infinite loops, so the submodule bump commit was never firing release.yml.
Add a second step that writes .cinny-version via the REST contents API —
that creates a user-attributed commit Gitea does not suppress.

The submodule bump is kept for correct SHA tracking; the API commit is
the actual trigger.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:31:42 -04:00
jared 8ff2f33d3a fix(ci): replace workflow dispatch with submodule bump push
CI / Build & Quality Checks (push) Successful in 12m21s
Trigger Desktop Build / trigger (push) Successful in 8s
workflow_dispatch API requires Actions:write token scope which
RELEASE_TOKEN doesn't have. Worse, even a successful dispatch would
check out the old pinned submodule SHA, not the new cinny commit.

New approach: clone cinny-desktop, point the cinny submodule at the
current commit SHA, commit, and push. The push to cinny-desktop/main
fires release.yml naturally — no special permissions needed beyond
repo write (which RELEASE_TOKEN already has for release uploads).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 19:02:58 -04:00
jared ea15db430c docs: add feature reference, bug tracker, and implementation notes
CI / Build & Quality Checks (push) Successful in 11m51s
Trigger Desktop Build / trigger (push) Failing after 4s
- LOTUS_FEATURES.md: full reference of every custom Lotus feature with
  implementation details, file paths, and API notes
- LOTUS_BUGS.md: audit of confirmed bugs with root causes and fixes
- LOTUS_TODO_REFERENCE.md: technical implementation notes for backlog items
- LOTUS_TODO.md: trim completed audit results section, link to FEATURES doc
- README.md: rewrite to marketing-friendly feature list format; fix
  incorrect claim that screenshare auto-reverts view to grid (removed)

Fix: auto-revert spotlight on screenshare was removed (600ms grid-click
caused fullscreen to show avatars); corrected in LOTUS_FEATURES.md and
removed from README.md.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 18:41:24 -04:00
jared 7e4178f7e2 Trigger cinny-desktop build on lotus branch push
CI / Build & Quality Checks (push) Successful in 10m34s
Trigger Desktop Build / trigger (push) Successful in 5s
2026-06-07 12:32:47 -04:00
98 changed files with 5377 additions and 1637 deletions
+30
View File
@@ -0,0 +1,30 @@
name: Trigger Desktop Build
on:
push:
branches: [lotus]
jobs:
trigger:
runs-on: ubuntu-latest
steps:
- name: Bump cinny submodule
env:
TOKEN: ${{ secrets.RELEASE_TOKEN }}
run: |
CINNY_SHA="${{ github.sha }}"
git clone "https://x-access-token:$TOKEN@code.lotusguild.org/LotusGuild/cinny-desktop.git" desktop
cd desktop
git config user.email "ci@lotusguild.org"
git config user.name "Lotus CI"
git submodule update --init cinny
git -C cinny fetch origin
git -C cinny checkout "$CINNY_SHA"
git add cinny
if git diff --cached --quiet; then
echo "Submodule already at $CINNY_SHA, nothing to do"
else
git commit -m "chore: bump cinny submodule to ${CINNY_SHA:0:8}"
git push origin main
echo "Pushed — cinny-desktop release.yml will start via on:push trigger"
fi
+1
View File
@@ -5,3 +5,4 @@ devAssets
.DS_Store
.ideapackage-lock.json
public/decorations/
+152
View File
@@ -0,0 +1,152 @@
# Lotus Chat — Bug Report & Technical Audit
**Date:** June 2026
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.
---
## 🛡️ Critical Security & Privacy Regressions
### 1. E2EE Bypass in Media Gallery Downloads
**File:** `src/app/features/room/MediaGallery.tsx` (Line 855)
**Status:** **CRITICAL**
* **Issue:** The "Download" button in the Files tab uses `mxcUrlToHttp` directly and clicks an `<a>` link.
* **Impact:** In encrypted rooms, this downloads the encrypted ciphertext rather than the decrypted file. Users cannot open the downloaded files.
* **Recommended Fix:**
1. Check if the event is encrypted.
2. If encrypted, use the `decryptAttachment` logic (similar to `useDecryptedMediaUrl`) to decrypt the file in memory.
3. Use `file-saver` or a Blob URL to trigger the download of the decrypted plaintext.
### 2. Privacy Leak in URL Previews
**File:** `src/app/components/url-preview/UrlPreviewCard.tsx` (Line 1655)
**Status:** **PRIVACY RISK**
* **Issue:** Generic URL preview cards fetch favicons directly from `https://www.google.com/s2/favicons?domain=...`.
* **Impact:** This leaks the user's browsing/chat activity (domains of links they see) to Google. It bypasses the "proxied through Matrix" privacy standard.
* **Recommended Fix:** Use the proxy URL returned by the Matrix `/_matrix/media/v3/preview_url` endpoint instead of contacting Google directly.
---
## 🚩 Functional & Logic Bugs
### 1. Presence Updater Reverts Status Updates
**File:** `src/app/hooks/usePresenceUpdater.ts` (Line 20)
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** The `storedStatus` variable was captured once when the `useEffect` started.
* **Impact:** If a user updated their status message in Profile Settings, the hook would continue broadcasting the old message on every activity event, silently reverting the change.
* **Fix Applied:** Replaced the single `localStorage.getItem` read with a `readStatus()` function called inside every `setOnline` and `setUnavailable` invocation, ensuring the current value is always used.
### 2. Audio Playback Rate Reset
**File:** `src/app/components/message/content/AudioContent.tsx` (Line 97)
**Status:** UX Bug
* **Issue:** The `playbackRate` is set in a `useEffect` that only depends on `[playbackSpeed]`.
* **Impact:** If a user selects a playback speed *before* the audio blob has finished loading, the `<audio>` element may reset its rate to 1.0 once the `<source>` is added.
* **Recommended Fix:** Add `srcState.data` to the `useEffect` dependencies or set the `playbackRate` in an `onCanPlay` handler on the audio element.
### 3. Room Insights are Static (No Live Updates)
**File:** `src/app/features/room-settings/RoomInsights.tsx` (Line 60)
**Status:** Medium Priority
* **Issue:** Stats are calculated in a `useMemo` that only depends on `[room]`.
* **Impact:** If new messages arrive while the Insights page is open, the statistics (message counts, top participants, etc.) do not update.
* **Recommended Fix:** Add a listener for `RoomEvent.Timeline` inside the component to trigger a recalculation when new events are added to the room.
### 4. Incorrect Ringing in Voice Rooms
**File:** `src/app/components/CallEmbedProvider.tsx`
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** Joining a static voice room (Public Space channel) triggered the "Incoming Call" ringing animation and sound.
* **Root Cause:** `getStateEvent(room, StateEvent.SpaceParent)` always returned `undefined` because `m.space.parent` events use the parent space's room ID as the state key, not an empty string. The ringing suppression check silently failed.
* **Fix Applied:** Replaced `getStateEvent` with `getStateEvents` (plural), which returns all events of a given type regardless of state key. A room with any `m.space.parent` event is correctly identified as a space channel and suppresses ringing.
---
## 🎨 UI/UX & Visual Consistency
### 1. Hardcoded Primary Color in Polls
**File:** `src/app/components/message/content/PollContent.tsx` (Line 245)
**Status:** TDS Violation
* **Issue:** Uses `rgba(var(--mx-primary-rgb, 0,132,255), ...)` for selected options and borders.
* **Impact:** Polls look like standard Cinny and ignore the Lotus Terminal Design System (TDS) colors.
* **Recommended Fix:** Use `var(--lt-accent-cyan)` or the primary theme accent color.
### 2. Inaccessible Room Menu on Mobile
**File:** `src/app/features/room-nav/RoomNavItem.tsx` (Line 643)
**Status:** **MOBILE BUG**
* **Issue:** The room menu icon (`VerticalDots`) is hidden on mobile because `hover` is never active.
* **Impact:** Mobile users cannot access room settings, mark as read, or leave rooms from the sidebar.
* **Recommended Fix:** Ensure the menu button is visible on mobile for the active room or provide an alternative trigger.
### 3. Inconsistent Settings Dropdown Styling
**File:** `src/app/features/settings/general/General.tsx`
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** The dropdowns for "Join & Leave Sounds", "UI Font", and "Seasonal Theme" used raw HTML `<select>` elements, which render differently from the custom-styled `Menu`+`PopOut` used for "Message Layout" and other settings.
* **Fix Applied:** All three raw `<select>` elements replaced with a reusable `SettingsSelect` component using the Menu+PopOut+FocusTrap pattern consistent with the rest of the settings UI.
### 4. No Camera Focus During Screenshare
**File:** `src/app/features/call/CallControls.tsx`
**Status:** UX Bug — blocked by Element Call internals
* **Issue:** When someone is screensharing and another participant turns on their camera, there is no way to switch the primary display to the camera or go fullscreen on it.
* **Root Cause:** Element Call's spotlight/layout is controlled internally by EC. Lotus Chat injects the call iframe and cannot easily override EC's participant tile behavior without forking the EC widget.
* **Recommended Fix:** Implement a "Pin/Focus" toggle on participant tiles that overrides the automatic screenshare spotlight — requires EC upstream changes or a custom message bridge.
### 5. Ringing Modal Fires in Persistent Voice Rooms
**File:** `src/app/components/CallEmbedProvider.tsx` (line 337342)
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** Joining a persistent voice room (not a DM or transient group call) showed the incoming call ringing modal and animation.
* **Root Cause:** The `isPrivateGroup` condition included `JoinRule.Restricted` and `JoinRule.Knock` rooms. Lotus Guild voice rooms are `Restricted` join-rule rooms. Their `m.space.parent` state event was being checked but some rooms were set up with only the space-side `m.space.child` relationship, leaving no `m.space.parent` on the room itself — so they passed as `isPrivateGroup` and triggered ringing.
* **Fix Applied:** Narrowed `isPrivateGroup` to only `JoinRule.Invite` to match the exact set of rooms where the call button is shown. Also added `room.isCallRoom()` early-exit so rooms with `m.join_rule.call` type never ring.
### 6. Animated Chat Backgrounds Affect Message Content
**File:** `src/app/features/lotus/chatBackground.ts`
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** Animated backgrounds that use `filter: brightness()` or `opacity` animations (Digital Rain glow, Grid Pulse brightness, Fireflies glow/blink) applied those effects to the entire `<Page>` element, causing all message content and the composer to flash/flicker in sync with the animation.
* **Root Cause:** `filter` and `opacity` CSS properties affect an element AND all its descendants. Applying these as part of the `animation` shorthand on the `Page` container made them "inherited" visually by everything inside the room view.
* **Side Effect:** `filter` animation also created a CSS stacking context on Page, which pushed Seasonal Theme overlays (position:fixed; z-index:9997) behind the Page compositor layer.
* **Fix Applied:** Removed `animRainGlowKeyframe`, `animGridBrightnessKeyframe`, `animFirefliesGlowKeyframe`, and `animFirefliesBlinkKeyframe` from `chatBackground.ts`. Only `backgroundPosition` / `backgroundSize` animations remain — these are safe and do not affect descendants or create stacking contexts.
* **Follow-up (June 2026):** The initial fix only removed glow/brightness keyframes from the DARK variant definitions. The LIGHT variant `anim-rain` and `anim-pulse` entries still referenced `animRainGlowKeyframe` and `animGridBrightnessKeyframe` (which were no longer imported, causing a build error). Both references removed from LIGHT variants to complete the fix.
### 7. Seasonal Themes Display Behind Chat Background
**File:** `src/app/components/seasonal/SeasonalEffect.tsx`
**Status:** ✅ RESOLVED (June 2026) — root cause was Bug #6
* **Issue:** Seasonal theme overlays (position:fixed; z-index:9997) appeared behind animated chat backgrounds.
* **Root Cause:** The `filter` animation on `<Page>` created a CSS stacking context, causing Page's GPU compositing layer to render above the fixed-position seasonal overlay in some browsers. Removing the filter animations (Bug #6 fix) resolves the stacking context issue.
* **Fix Applied:** See Bug #6. No additional changes to SeasonalEffect required.
### 8. Avatar Decoration Images Not Rendering in Settings
**File:** `src/app/features/settings/account/ProfileDecoration.tsx` / LXC 106 nginx
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** Under Settings → Account → Avatar Decoration, no decoration images were visible.
* **Initial (incorrect) diagnosis:** Suspected `loading="lazy"` in a nested scroll container. Changed to `loading="eager"` — images still did not render.
* **Actual Root Cause:** The server nginx Content Security Policy (`img-src` directive) on LXC 106 did not include `https://drive.lotusguild.org`. The browser silently blocked all 99 APNG requests with CSP violation errors (206 violations visible in DevTools console). The lazy-loading change was a red herring.
* **Fix Applied:** Added `https://drive.lotusguild.org` to the `img-src` directive in `/etc/nginx/sites-available/cinny` on LXC 106 (cinny-web-server) and reloaded nginx. Updated config is tracked in `pve-infra/containers/106-cinny-web-server/etc/nginx/sites-available/cinny`.
### 9. Avatar Decoration Grid Spacing Too Tight
**File:** `src/app/features/settings/account/ProfileDecoration.tsx`
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** Decoration preview cells in the settings picker appeared vertically and horizontally squished together with almost no gap between images.
* **Root Cause:** The flex container used `gap: 20px`, but each `52×52` button renders its decoration image with `position: absolute; top: -8px; left: -8px` (INSET=8), so each image bleeds 8px outside the button on all sides. The visual gap between images was `20 - 8 - 8 = 4px` — nearly nothing. Additionally `paddingBottom: 4` clipped the bottom overflow of the last row, and `paddingRight` was absent so the rightmost column clipped.
* **Fix Applied:** Increased `gap` from `20` to `36` (visual gap = 36 - 8 - 8 = 20px), changed `paddingBottom` from `4` to `INSET` (8px), added `paddingRight: INSET`.
### 10. Windows Taskbar Badge Has Black Square Background and Small Number
**File:** `src-tauri/src/lib.rs` (cinny-desktop) — `set_badge_count` command
**Status:** ✅ RESOLVED (June 2026)
* **Issue:** The notification badge overlaid on the Windows taskbar icon had a solid black square background instead of a transparent circle, and the number was too small to read at a glance.
* **Root Cause (black square):** `CreateDIBSection` initialises the pixel buffer to zero (BGRA `0x00000000`). GDI drawing functions (`Ellipse`, `DrawTextW`) paint RGB values but never touch the alpha channel — all pixels retain `A=0`. When every pixel has `A=0`, Windows cannot use per-pixel alpha compositing and falls back to the monochrome mask (`hbm_mask`, all zeros = fully opaque), so the entire 16×16 bitmap is drawn opaque. The corner pixels outside the ellipse are `RGB(0,0,0)` = black, producing the black square.
* **Root Cause (small number):** Bitmap was 16×16 with an 11px font, giving very little room, especially for two-digit counts.
* **Fix Applied:**
1. After all GDI drawing, iterate the pixel buffer and set `alpha = 0xFF` for every non-zero pixel (`*pixel |= 0xFF00_0000`). Corner pixels (zero) retain `A=0` and composite as transparent.
2. Increased bitmap size from `16` to `20` and font height from `11` to `14`.
+1046
View File
File diff suppressed because it is too large Load Diff
+108 -933
View File
File diff suppressed because it is too large Load Diff
+234
View File
@@ -0,0 +1,234 @@
# Lotus Chat — Technical Implementation Field Guide
**Date:** June 2026
This document provides exhaustive, low-level implementation details for the remaining items in `LOTUS_TODO.md`. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
---
## 🧵 Priority 3 — Higher Complexity
### P3-8 · Thread Panel (Full Side Drawer)
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
* **1. State (src/app/state/room/thread.ts):**
```typescript
export const activeThreadIdAtom = atom<string | null>(null);
```
* **2. Layout (src/app/features/room/Room.tsx):**
Insert the `ThreadPanel` conditionally alongside the `RoomTimeline`.
```tsx
{activeThreadId && (
<>
<Line variant="Background" direction="Vertical" size="300" />
<ThreadPanel roomId={roomId} threadId={activeThreadId} />
</>
)}
```
* **3. Component (src/app/features/room/thread/ThreadPanel.tsx):**
* Use `room.getThread(threadId)` from the SDK.
* Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`.
* Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`.
* **Pro Tip:** Use `thread.timelineSet` directly for the most accurate thread view.
---
## 🎨 Priority 5 — Gamer / Aesthetic / Customization
### P5-1 · Custom Accent Color Picker (Non-TDS only)
**Mechanism:** Dynamic CSS variable injection.
* **1. Setting (src/app/state/settings.ts):**
Add `customAccentColor: string` (hex).
* **2. Manager (src/app/pages/ThemeManager.tsx):**
Inside the `useEffect` that monitors theme changes:
```typescript
if (!lotusTerminal && customAccentColor) {
document.documentElement.style.setProperty('--lt-accent-orange', customAccentColor);
// Also derive a 'glow' version (e.g. 50% opacity)
document.documentElement.style.setProperty('--lt-accent-orange-glow', `${customAccentColor}80`);
}
```
* **3. UI (src/app/features/settings/general/General.tsx):**
Use a `<Input type="color">` component. Hide this section if `lotusTerminal` is `true`.
### P5-14 · Animated Avatar Overlay
**Mechanism:** CSS Pseudo-element wrapping.
* **1. Wrapper (src/app/components/user-avatar/UserAvatar.tsx):**
```tsx
const avatar = (
<AvatarImage className={classNames(css.UserAvatar, className)} ... />
);
if (!frame) return avatar;
return (
<div className={css.AvatarFrameContainer} data-frame={frame}>
{avatar}
<div className={css.AvatarFrameEffect} />
</div>
);
```
* **2. CSS (src/app/components/user-avatar/UserAvatar.css.ts):**
Define animations like `rotate` or `pulse`. Use `position: absolute; inset: -4px` on the effect div to create a glowing ring around the avatar.
---
## 🛠️ Priority 4 — Specialized Features
### P4-4 · Math / LaTeX Rendering
**Mechanism:** KaTeX injection into the HTML parser.
* **1. Sanitizer (src/app/utils/sanitize.ts):**
You must allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
* **2. Parser (src/app/plugins/react-custom-html-parser.tsx):**
Detect `$ ... $` and `$$ ... $$` patterns in text nodes.
```tsx
if (node.type === 'text') {
const parts = node.data.split(/(\$\$.*?\$\$|\$.*?\$)/g);
return parts.map(p => {
if (p.startsWith('$')) return <KaTeX math={p.replace(/\$/g, '')} />;
return p;
});
}
```
* **3. CSS (src/app/styles/CustomHtml.css.ts):**
Import `katex/dist/katex.min.css` only when a math block is rendered to save initial bundle size.
### P4-6 · OIDC / SSO Next-Gen Auth (MSC3861)
**Mechanism:** Matrix Authentication Service (MAS) Integration.
* **Architecture:** Shift from password-based `/login` to OAuth2 `authorization_code` flow.
* **Key Files:** `src/app/pages/auth/Login.tsx` and `src/app/hooks/useAuth.ts`.
* **Implementation:**
1. Use `oidc-client-ts` or a similar lightweight OIDC library.
2. Check for `m.authentication` in `/.well-known/matrix/client`.
3. Redirect to the MAS authorization endpoint.
4. Handle the callback in a new `OidcCallback` route and store the OIDC `refresh_token`.
---
## 🎨 Priority 5 — Gamer / Aesthetic / Customization
### P5-12 · Seasonal / Event Themes
**Mechanism:** Date-aware global overlays.
* **1. Component (src/app/components/seasonal/SeasonalEffect.tsx):**
```tsx
const isHalloween = now.month === 9 && now.day >= 15; // etc.
if (!isHalloween) return null;
return <div className={css.HalloweenSpiderWeb} />;
```
* **2. Styles (src/app/styles/Animations.css.ts):**
Use `repeating-linear-gradient` for spider webs or `keyframes` for falling snow.
* **3. Mounting:** Place at the very end of `App.tsx` (above the `LotusToastContainer`) with `pointer-events: none`.
### P5-13 · Avatar Frame / Border Decorations
**Mechanism:** Static variant of P5-14.
* **Implementation:** Use a simple `border` or `box-shadow` in `UserAvatar.tsx` based on the `io.lotus.avatar_frame` account data. Unlike the animated version, this should avoid `keyframes` to save CPU on long member lists.
### P5-2 · Additional Color Theme Presets
**Mechanism:** Standard vanilla-extract theme multiplication.
* **Implementation:** Replicate the `lotusTerminalTheme` object in `src/colors.css.ts` for each new theme (Cyberpunk, Ocean, etc.). Ensure each theme defines all 50+ tokens required by the `folds` library and TDS.
### P5-15 · In-Call Soundboard
**Mechanism:** Local-to-Global Audio Bridge.
* **Architecture:** Use the `Web Audio API` to mix sounds into the `MediaStream` before it enters the Element Call widget.
* **Implementation:**
1. Create an `AudioContext`.
2. Create a `MediaStreamDestinationNode`.
3. Create an `AudioBufferSourceNode` for the clip.
4. Route the mic `MediaStream` and the clip source to the destination.
5. Pass the destination's `.stream` to the call bridge.
### P5-20 · Quick Reply from Browser Notification
**Mechanism:** Service Worker `notificationclick` Action.
* **1. Registration (src/sw.ts):**
```typescript
self.addEventListener('notificationclick', (event) => {
if (event.action === 'reply' && event.reply) {
const { roomId, threadId } = event.notification.data;
const session = sessions.get(event.clientId); // Uses existing session mapping
// Send via direct fetch to bypass SDK loading
fetch(`${session.baseUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message`, {
method: 'POST',
headers: { Authorization: `Bearer ${session.accessToken}` },
body: JSON.stringify({
msgtype: 'm.text',
body: event.reply,
'm.relates_to': threadId ? { rel_type: 'm.thread', event_id: threadId } : undefined
})
});
}
});
```
---
## 🔬 Extreme Complexity Projects
### P5-30 · Advanced ML Noise Suppression (Krisp-style)
**Mechanism:** RNNoise WASM + Web Audio Worklet Pipeline.
* **Objective:** Filter non-vocal noise from the microphone stream in real-time.
* **Architecture:**
1. **Engine:** Use `RNNoise` (Recurrent Neural Network for noise suppression). It is lightweight and highly effective for speech.
2. **Pipeline:** `Mic Stream` -> `AudioWorkletNode` (Processing) -> `MediaStreamDestination` -> `Element Call`.
* **Implementation Steps:**
1. **WASM Wrapper:** Compile the `RNNoise` C library to WebAssembly. Use a library like `rnnoise-wasm` or `noise-suppression-js`.
2. **Audio Worklet:** Create `src/app/utils/audio/RnnoiseWorklet.ts`. This must handle 480-sample chunks (10ms of audio at 48kHz), which is the standard frame size for RNNoise.
3. **Client Integration:**
* In `CallControl.ts`, intercept the `localStream`.
* Pass the stream through the Worklet.
* Crucially, you must ensure that the processed stream is used by the `RTCPeerConnection` within the Element Call iframe.
### P5-31 · Granular Voice & Screenshare Quality Controls
**Mechanism:** WebRTC Encoding Parameters + Backend Quality Guard.
* **Objective:** Per-room and per-user control over audio fidelity and screenshare smoothness.
* **Architecture:**
1. **State Event:** `io.lotus.room_quality` (state key `""`) containing:
```json
{
"audio_bitrate": 128000,
"screen_max_res": "1080p",
"screen_max_fps": 60
}
```
2. **Client-Side (RoomInput / CallControl):**
* **Screenshare:** In `src/app/plugins/call/CallControl.ts`, when initiating screenshare, map the "Quality" setting to `getDisplayMedia` constraints:
```typescript
const constraints = {
video: {
width: { ideal: 1920 }, // 1080p
frameRate: { ideal: 60 }
}
};
```
* **Audio Bitrate:** After the call joins, find the `RTCRtpSender` for the audio track and update parameters:
```typescript
const sender = peerConnection.getSenders().find(s => s.track?.kind === 'audio');
const params = sender.getParameters();
params.encodings[0].maxBitrate = roomBitrate || 128000;
await sender.setParameters(params);
```
3. **Backend Sidecar (The "Quality Guard"):**
* **Pattern:** Extend the `voice-limit-guard.py` (on LXC 151) to handle quality metadata.
* **Mechanism:** When a user requests a LiveKit JWT to join a room, the Guard fetches the `io.lotus.room_quality` event for that room via the Synapse Admin API.
* **Enforcement:** The Guard injects these limits into the LiveKit token claims (if supported) or simply returns them to the client as an authorized "config" packet that the client must respect.
* **Challenges:**
* **LiveKit Compatibility:** Ensuring the SFU doesn't over-compress a high-bitrate stream from a "Pro" user.
* **Network Stability:** High bitrates (512kbps audio + 60fps 1080p video) require significant upstream bandwidth. Implement a "Network Warning" UI if packets are dropped.
### P5-34 · User-to-User Private Notes
**Mechanism:** Encrypted account data map.
* **Objective:** Private, cross-session notes about other users.
* **Key Files:**
* `src/app/hooks/useUserNotes.ts`: New hook for CRUD operations.
* `src/app/components/user-profile/UserRoomProfile.tsx`: UI site.
* **Implementation:**
1. Store as a single map in `io.lotus.user_notes` account data: `{ "@alice:server": "Cool dev", "@bob:server": "Needs moderation" }`.
2. Wrap the entire map in E2EE if the client supports local account data encryption, or rely on standard Matrix account data privacy.
3. Add a "Notes" tab or textarea to the user profile popout.
+141 -258
View File
@@ -1,280 +1,163 @@
# Lotus Chat
A Matrix client for [Lotus Guild](https://lotusguild.org) — forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1.
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).
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** &nbsp;|&nbsp; Forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1
---
## Changes from upstream Cinny
## Licensing & Attribution
### Branding & Identity
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).
- Package renamed to `lotus-chat`, description updated to "Lotus Chat — Matrix client for Lotus Guild"
- App title changed from "Cinny" to "Lotus Chat" throughout
- Favicon, PWA icons, and all icon sizes (57×57 → 180×180 Apple touch icons) replaced with Lotus.png variants
- Logo in About dialog and Auth page replaced with official Lotus.png
- Auth footer rewritten: shows dynamic version from `package.json`, links to lotusguild.org, chat.lotusguild.org, and matrix.lotusguild.org
- Welcome page tagline changed from "Yet another matrix client" to "A Matrix client for Lotus Guild"
- Encryption key export filename changed from `cinny-keys.txt` to `lotus-keys.txt`
- `manifest.json` updated with Lotus name, description, and branding colors
### LotusGuild Terminal Design System (TDS) v1.2
A full custom theme engine layered on top of Cinny's vanilla-extract theming:
**Dark mode** (`LotusTerminalTheme`):
- CRT terminal aesthetic: scanline overlay, vignette, phosphor glow
- Palette: bg `#030508`, orange `#FF6B00`, cyan `#00D4FF`, green `#00FF88`, text `#c4d9ee`
- Monospace font stack, terminal-style scrollbars
- Custom hex-grid and circuit-board CSS background patterns
- Matrix-style boot messages on the welcome page (press Escape to skip)
- CSS variables: `--lt-*` family covering colors, glow effects, borders, animations
**Light mode** (`LotusTerminalLightTheme`):
- Full light palette: bg `#edf0f5`, orange `#c44e00`, cyan `#0062b8`, green `#006d35`, text `#111827`
- No CRT effects (scanlines, vignette disabled)
- Light-mode scrollbars, adjusted code block colors, semantic color overrides
- Scoped to `html[data-theme="light"] body.lotusTerminalBodyClass`
- `ThemeManager.tsx` sets `data-theme` attribute based on active theme kind
**Chat Backgrounds** (20+ custom patterns, all TDS-aware):
- Blueprint grid, carbon fiber, starfield, topographic contours, herringbone, crosshatch
- Chevron, polka dots, triangles, plaid
- All patterns use CSS custom properties — adapt to both TDS dark and light themes
- Settings toggle for showing per-message sender profiles
### Voice / Video Call Improvements
- **Element Call 0.19.4**: Upgraded from 0.16.3. Dist copied to `public/element-call/` by vite at build time.
- **Camera default OFF**: Camera no longer persists across sessions via localStorage. Always starts disabled. Optional `cameraOnJoin` setting for explicit opt-in.
- **Deafen button**: Tooltip corrected to "Deafen" / "Undeafen" (was "Turn Off Sound" / "Turn On Sound")
- **Screenshare confirmation**: A confirm dialog appears before screenshare is broadcast to call participants
- **Auto-revert spotlight on screenshare**: When someone starts screensharing, EC normally forces all participants into spotlight view. Patched in `CallControl.ts` `onControlMutation()` — detects the screenshare button going `primary` and clicks `gridButton` after 600ms to revert to grid layout. Participants choose to watch screenshare manually.
- **Push to Talk (PTT)**:
- Configurable keybind (default: Space) via Settings > General > Calls
- Mic activates on keydown, deactivates on keyup; mic muted on tab blur/focus to prevent stuck-on mic
- Visual indicator: plain folds `Chip` by default; when LotusGuild TDS is active: orange `PTT — Hold SPACE` / green `● LIVE` in JetBrains Mono
- Listens on both main window and EC iframe `contentWindow` for reliable key capture
- Implemented via `CallControl.setMicrophone()` public method on the widget bridge
- **Mic state preservation**: when enabling PTT mode mid-call, the user's previous mic state is saved and restored when PTT is disabled — prevents unwanted unmute if the user had manually muted before switching to PTT.
- **Noise suppression toggle**: Settings > General > Calls — passes `noiseSuppression` URL parameter to the embedded Element Call widget
- **Call button scoping**: The upstream Cinny 4.12.1 call button (voice + video dropdown) is restricted to DMs and private group chats only. Specifically: direct messages, or invite-only rooms that have no `m.space.parent` state event (i.e. not a space/guild text channel). Public rooms and space channels are excluded to prevent accidental mass-notifications. `Room.tsx` switches to CallView layout when a call embed is active in the current room.
- **Poll display**: `m.poll.start` events (both stable Matrix 1.7 `m.poll` content key and MSC3381 unstable `org.matrix.msc3381.poll.start`) render as read-only poll cards inside the standard message bubble — question and answer options shown. Registered as top-level event renderers AND inside the `EncryptedContent` callback so encrypted polls also display after decryption. "Open in Element to vote" note displayed. Implemented in `PollContent.tsx`.
- **Deleted message placeholder**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events no longer disappear from the timeline. Instead they reach the existing `RedactedContent` component (trash icon + italic "This message has been deleted" with reason if provided), matching Element, FluffyChat, Commet, and Nheko behaviour. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
- **Picture-in-picture (PiP)**: When navigating away from a call room while in an active call, the call embed shrinks to a 280x158px floating window in the bottom-right corner. The PiP window is **draggable** — drag it anywhere on screen to move it out of the way. Clicking (without dragging) navigates back to the call room. Drag vs click distinguished by a 5px movement threshold; touch drag supported. Imperative style overrides on `callEmbedRef.current` via `useEffect` — a wrapper div cannot be used because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element.
- **Call embed positioning**: `useCallEmbedPlacementSync` uses `getBoundingClientRect()` (not `offsetTop/Left`) for accurate viewport-relative coordinates on the `position:fixed` container. Position is synced immediately on mount via `useEffect` in addition to the ResizeObserver, so the embed is placed correctly the instant the call view renders. The `[pipMode, callVisible]` effect in `CallEmbedProvider` only clears pip-specific styles when actually exiting pip mode — no longer clobbers the position set by `syncCallEmbedPlacement` on every `callVisible` toggle.
- **Dark mode in element-call**: After joining, `CallEmbed.applyStyles()` injects `:root { color-scheme: dark|light }` into the iframe document so `@media (prefers-color-scheme)` rules inside element-call resolve to the correct Cinny theme regardless of the OS system preference. `themeKind` is stored on the `CallEmbed` instance and updated on every `setTheme()` call, so live theme switching also re-injects the CSS. Without this, users with OS light mode would see a white background even when Cinny is in dark mode.
- **Call embed wallpaper**: The user's `chatBackground` pattern (Blueprint, Carbon, Stars…) is applied as the `backgroundImage`/`backgroundColor` of `div[data-call-embed-container]` when the call is in full view (not PiP). The iframe `html, body` is forced to `background: none !important` so the pattern shows through. When `chatBackground` is `none`, behaviour is unchanged.
### Moderation
- **Report Room**: A "Report Room" option in the room header menu (⋮) allows users to report a room to homeserver admins with a reason and abuse category (Spam / Harassment / Inappropriate Content / Other). Calls `POST /_matrix/client/v3/rooms/{roomId}/report` (MSC4151, confirmed supported on matrix.lotusguild.org). Implemented in `ReportRoomModal.tsx` with loading/success/error states.
- **Policy List / Ban List Viewer (MSC2313)**: A "Policy Lists" tab in Room / Space Settings (admin-only, power-level gated) shows all subscribed `m.policy.rule.*` rooms and their contents — banned users, banned rooms, and banned servers — each with entity, reason, and recommendation fields. Subscribe (join the policy room) and Unsubscribe (leave) actions are provided. Enforcement remains solely with the Draupnir bot; this UI is a read-only complement.
### Messaging Enhancements
- **Rich room topics**: Room topics that contain formatted text (bold, links, italic) are now rendered with full HTML formatting. Falls back to plain text if no `formatted_body` is present. Activates when any room admin sets a formatted topic.
- **Edit history viewer**: Clicking the "edited" label on any edited message opens a modal showing every prior version with timestamps. Fetches all `m.replace` relations for the event and displays them oldest-to-newest. Previously the "edited" label was visible but unclickable. E2EE fix: the "Original" entry now uses `getClearContent()` (bypasses the replacing-event chain, returns the decrypted pre-edit body) instead of `event.content` which is still raw ciphertext for encrypted messages — fixes "(no text)" shown for almost all E2EE message originals.
- **Inline GIF preview**: Giphy and Tenor share links sent as plain text auto-embed as animated GIFs inline in the timeline. URL patterns are detected client-side; the image is fetched via the homeserver's `/_matrix/media/v3/preview_url` proxy (no direct contact with Giphy/Tenor from the client). Rendered as `<img loading="lazy">` — respects the existing URL preview enabled/disabled setting.
- **GIF picker**: Giphy-powered GIF search and send. Button appears in the message composer only when `gifApiKey` is set in `config.json`. Sends GIF as `m.image` — fetches blob, uploads via `mx.uploadContent`, sends with `mx.sendMessage`. `FocusTrap` handles click-outside / Escape to close. When TDS is active: dark navy background (`#060c14`), orange dim border, `// GIF_SEARCH` header, CSS overrides for Giphy SDK search bar (dark bg, orange border/focus ring, JetBrains Mono), custom orange scrollbar. All TDS styles live in `lotus-terminal.css.ts` — no runtime `<style>` injection, eliminating flash of unstyled content.
- **Message forwarding**: Forward any message to any room from the message context menu.
- **Draft persistence**: Unsent message drafts survive page reload via `localStorage` (`draft-msg-<roomId>`). Jotai in-memory atom is primary; localStorage is used as fallback on reload and cleared on send.
- **Message search date range**: From/To date pickers in the search filter bar. Sends `from_ts`/`to_ts` epoch ms to the Matrix `/search` endpoint. Chip shows active range with X to clear.
- **Image/video captions**: Caption text field on image and video upload — sent as a single event with the media.
- **Location sharing**: Map embed view for incoming location events + static share button. Renders `m.location` events inline with a map tile.
- **Deleted message placeholders**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events render as "This message has been deleted" with reason (if provided) rather than disappearing. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
- **Message bookmarks / saved messages**: Right-click any message → "Bookmark" / "Remove Bookmark". A star icon in the sidebar nav opens `BookmarksPanel.tsx` — a right-side panel listing all saved messages with room name, preview text, relative timestamp, filter input, "Jump to message" deep-link, and individual remove buttons. Stored in `io.lotus.bookmarks` account data (max 500 entries); syncs across devices. Implemented in `src/app/features/bookmarks/BookmarksPanel.tsx` + `src/app/hooks/useBookmarks.ts`.
- **Message scheduling (MSC4140)**: Clock button next to send opens `ScheduleMessageModal.tsx` with a message textarea and datetime picker. Messages sent via the MSC4140 delayed events API (`org.matrix.msc4140`), confirmed supported on `matrix.lotusguild.org`. A collapsible tray above the composer lists pending scheduled messages with Cancel buttons. Utilities in `src/app/utils/scheduledMessages.ts`.
- **Richer link preview cards**: `UrlPreviewCard.tsx` renders domain-specific cards for 13 sites: YouTube (thumbnail + ▶ play overlay), Vimeo, GitHub (repo parse), Twitter/X (tweet text + media parse), Reddit (subreddit + upvotes + comments), Spotify (artwork), Twitch (LIVE badge + game), Steam, Wikipedia, Discord (server invite), npm, Stack Overflow, and IMDb (poster). Generic cards gain a favicon from Google's S2 service. Cards that produce no renderable content are suppressed.
- **File upload compression (opt-in)**: JPEG and PNG files in the upload preview show a "Compress" checkbox. When checked, a Canvas API call (`toBlob(..., 'image/jpeg', 0.82)`) compresses the image client-side. Original and compressed sizes are shown side-by-side. Compression is strictly opt-in — unchecked by default, skipped for GIF/SVG/WebP.
### Room Customization
- **Personal room name overrides**: Right-click any room in the sidebar → "Rename for me…" to set a local display name visible only to you. Other members see the original name unchanged. A small pencil icon marks rooms with a custom local name. Stored in Matrix account data (`io.lotus.room_names`). Uses `io.lotus.room_names` account data key (based on MSC4431).
- **Export room history**: Room Settings → Export tab. Supports Plain Text, JSON, and HTML formats with optional start/end date range filters. Paginates backwards via `mx.paginateEventTimeline()` with a live progress counter. E2EE-aware — events that failed decryption are skipped rather than exported as garbled ciphertext. Downloads via `Blob` + `<a download>`. Implemented in `src/app/features/room-settings/ExportRoomHistory.tsx`.
- **Room activity / mod log**: Room Settings → Activity tab. Filterable log of `m.room.member` (join/leave/kick/ban/unban/invite), `m.room.power_levels`, `m.room.name`, `m.room.topic`, `m.room.avatar`, and `m.room.server_acl` events. Human-readable descriptions, relative timestamps, type-filter dropdown, Load More pagination, and auto-paginate on mount. Implemented in `src/app/features/room-settings/RoomActivityLog.tsx`.
- **Server ACL editor**: Room Settings → Server ACL tab. Reads and writes `m.room.server_acl` state events. Editable allow and deny server lists with wildcard pattern validation (`*.example.com`). "Allow IP literal addresses" toggle. Read-only view shown to users without the required power level. Implemented in `src/app/features/room-settings/RoomServerACL.tsx`.
- **Room stats / insights**: Room Settings → Insights tab (not the default tab). Derives all statistics from the local timeline cache only; a disclaimer banner clarifies this. Shows top 5 active members (bar chart), top 5 reactions (emoji chips), media breakdown (images/videos/audio/files tiles), and a 24-hour activity heatmap (CSS bar chart). Implemented in `src/app/features/room-settings/RoomInsights.tsx`.
### Per-Message Read Receipts
Full per-message read receipt system — shows who has read each message directly in the timeline.
**Architecture:**
- `useRoomReadPositions(room)` hook — computes a `Map<eventId, userId[]>` from all joined members' `room.getEventReadUpTo()` positions. Subscribes to `RoomEvent.Receipt` for live updates (debounced at 150ms to batch burst updates from mass-read events).
- `nearestRenderableId(liveEvents, evtId)` — receipts can land on reaction/edit events that `RoomTimeline` skips (renders `null`). This walks backwards from the receipt event through the live timeline until it finds a non-reaction/non-edit event to attach to.
- `ReadPositionsContext` — React context providing the positions map from `RoomTimeline` down to all `Message` instances without prop drilling.
- `ReadReceiptAvatars` component — renders a pill-shaped row of overlapping `StackedAvatar` circles (24px, `SurfaceVariant` outline) below messages with readers. Pill uses `color.SurfaceVariant.Container` background for visibility on any wallpaper. Max 5 avatars shown + `+N` overflow count. Avatar fallback uses `colorMXID(userId)` for distinctive per-user color.
- Clicking the pill opens the **"Seen by" modal** (`EventReaders`) listing all readers with their avatar, display name, and a formatted read timestamp ("Today at 3:42 PM", "Yesterday at 10:15 AM", "May 14 at 9:00 AM"). Timestamps use `room.getReadReceiptForUserId(userId)?.data.ts` and respect the user's 24-hour clock setting.
- Authenticated media (`mxcUrlToHttp` utility) used for all avatar loads, matching the correct Lotus utility signature.
### Delivery Status Indicators
Own messages display a small status marker below the message content (when no read receipts are visible yet):
- `⟳` — message is being sent / encrypting
- `✓` — message confirmed sent (local echo)
- `✕` — message failed to send (shown in red; orange glow in TDS mode)
- Status hidden once the server confirms receipt (`status === null`) — read receipts take over at that point
### URL Preview Cards (TDS)
URL preview cards (`UrlPreviewCard`) styled for terminal mode:
- Dark transparent background with cyan border-left accent (Anduril Orange)
- Link text in cyan, hover switches to orange with glow
- Light TDS variant: off-white background with blue accent
### Reaction Chips (TDS)
Emoji reaction buttons styled for terminal mode via `button[data-reaction-key]` selector:
- Unselected: `rgba(0,212,255,0.06)` background, cyan border
- Hover: brighter background + box-shadow glow
- Own reaction (aria-pressed=true): orange tint `rgba(255,107,0,0.12)`, orange border
- Light TDS: equivalent blue/orange variants
### DM Call Improvements
- **Incoming call ring**: DM calls trigger a ring tone with Answer/Decline UI. 30-second auto-dismiss if unanswered. Implemented in `Room.tsx` and `RoomViewHeader.tsx`.
### Room Customization
- **Room emoji prefix**: A leading emoji in a room name (e.g. 🎮 general) renders at 1.15× size in the sidebar for visual impact. Matrix room names already support Unicode — this is purely a rendering enhancement in `RoomNavItem.tsx`. All three room-name inputs (Create Room, Room Settings, "Rename for me…" dialog) now include a 😊 emoji picker button that prepends the selected emoji to the name field.
### Presence
- **Discord-style presence selector**: Clicking your avatar in the bottom-left sidebar opens a popout with five status options — Online (green), Idle (yellow), Do Not Disturb (red, broadcasts `unavailable` with `status_msg: 'dnd'`), Invisible (grey outline, broadcasts `offline`), and Auto (activity-tracking, the original behaviour). The selected status persists across reloads via the settings atom. A colored badge on the avatar reflects the current status at a glance. `usePresenceUpdater` short-circuits immediately for manual modes; full idle-timer and visibility-change logic only runs in Auto mode. Settings also exposed via `src/app/state/settings.ts` (`presenceStatus` field).
- **Custom status message**: Set a short status text (up to 64 characters) with an emoji picker, shown below your display name in member lists and presence displays. Accessible via Settings → Account → Profile. Includes an **auto-clear timer** (options: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days) — after the timer expires, the status is automatically cleared by setting `status_msg: ''` via `mx.setPresence`. A character counter (shown when ≥ 56/64 chars) prevents overflow. Implemented in `src/app/features/settings/account/Profile.tsx`.
- **Presence badges on members**: Online/busy/away dots shown next to users in the room members drawer and settings members panel (`PresenceBadge` component from `src/app/components/presence/Presence.tsx`).
- **Presence avatar border ring**: A 2px colored `outline` ring on user avatars throughout the app shows presence at a glance — green (online), yellow (idle), red (DND), no ring (offline). Implemented as `PresenceRingAvatar` component (`src/app/components/presence/PresenceRingAvatar.tsx`) using `React.cloneElement` to inject `outline` + `outlineOffset` directly onto the child `Avatar` element — the ring follows the avatar's actual `border-radius` regardless of shape. Applied to: message timeline sender avatars, members drawer, @mention autocomplete, and inbox notification senders.
- **Document title unread count**: Tab title updates to `(N) Lotus Chat` for mentions, `· Lotus Chat` for unreads, `Lotus Chat` when clear.
- **Extended profile fields (MSC4133)**: Settings → Account → Profile includes Pronouns (`m.pronouns`) and Timezone (`m.tz`) fields, saved via MSC4133 `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`. Both fields are displayed in user profile panels. Implemented via `src/app/hooks/useExtendedProfile.ts`.
- **User local time in profile**: When a user has `m.tz` set, their profile panel shows a clock icon, their current local time, and the timezone abbreviation (e.g. EST, JST). Updates every 60 seconds. Respects the viewer's `hour24Clock` setting. Implemented via `src/app/hooks/useLocalTime.ts`.
### UX & Composer
- **Message length counter**: A muted character counter appears just left of the send button while typing, disappearing when the composer is empty. Resets on room switch.
- **Quick emoji reactions on hover**: The 3 most-recently-used emoji reactions appear directly in the message hover toolbar (between the emoji-board button and Reply), so reacting requires a single click rather than opening the 3-dots menu first. Clicking a quick-reaction also closes any open emoji picker. Powered by `useRecentEmoji` sourced from Matrix account data.
- **In-app notification toasts**: When a message or invite notification fires and the browser window is focused, a slim TDS-styled toast card slides in from the bottom-right instead of triggering an OS notification. Card shows: 24px avatar (initials fallback), sender name in orange, truncated message body, room name, × dismiss, 4 s auto-dismiss. Clicking navigates directly to the correct room (DM or home path) or the invites inbox. OS notifications are unchanged when the window is not focused. Implemented in `src/app/features/toast/LotusToastContainer.tsx` + `src/app/state/toast.ts`.
- **Collapsible long messages**: Messages exceeding ~20 lines are auto-collapsed with a "Read more ↓" button. Click to expand inline; a "Collapse ↑" button re-folds. Threshold (in lines) configurable in Settings → Appearance. Uses CSS `max-height` + `overflow: hidden` — works correctly with code blocks and embedded media. Respects `prefers-reduced-motion`.
- **Message send animation**: Own sent messages fade and scale into the timeline (0.15 s ease-out: `scale(0.97)→scale(1)`, `opacity 0.4→1`). Incoming messages are unaffected. Respects `prefers-reduced-motion`.
- **Right-click room context menu**: Expanded sidebar room context menu — **Mute** now opens a duration submenu (15 min / 1 hr / 8 hr / 24 hr / Indefinite) with auto-restore after the selected window; **Copy Room Link** copies the `matrix.to` URL with a "Copied!" flash; **Mark as Read** marks the room read to the latest event; plus Leave Room and Room Settings shortcuts.
- **Unverified device warning**: `warnOnUnverifiedDevices` setting (default off). When enabled via Settings → General → Privacy, a warning banner appears above the composer in encrypted rooms that contain unverified devices, showing the count. Sending is never blocked — the banner is informational only. Uses the existing `useUnverifiedDeviceCount()` hook.
- **Sidebar room filter**: A search-icon input at the top of the Home and DMs sidebar tabs filters rooms by display name in real time. Clears on tab switch. Styled to match the members-drawer search bar (`size="400"`, search prefix icon).
- **DM last message preview**: Each DM row in the sidebar shows a truncated message body (48 chars) and relative timestamp (`Xm`, `Xhr`, `Yesterday`, `D MMM`) below the room name, sourced reactively from `useRoomLatestRenderedEvent`. Encrypted rooms show "Encrypted message" only on actual decryption failure.
- **Room sort order**: Sort icon in the Rooms sidebar header lets users sort non-space rooms by Recent Activity (default), A→Z, or Unread First. Persists via `homeRoomSort` setting.
- **Favorite rooms**: Right-click any room → "Add to Favorites" / "Remove from Favorites". Favorited rooms (using the standard Matrix `m.favourite` tag) appear in a collapsible "Favorites" section above the main room list on the Home tab. Syncs across devices via account data.
- **Poll creation**: Polls can be created directly from the composer — `Icons.OrderList` button opens a modal with question field, 210 answer options (add/remove), and Single/Multiple choice toggle. Sends a stable `m.poll.start` event. (Poll display & voting were already supported.)
- **Voice message playback speed**: `0.75×``1×``1.5×``2×` speed toggle pill on voice message player — cycles on click via `playbackRate` on the `<audio>` element.
- **Invite link + QR code**: Room settings → General shows a "Share Room" tile with the `matrix.to` invite URL and a QR code. The Invite modal also has a `⊞` toggle button showing a QR panel when clicked. Both use `api.qrserver.com` (added to CSP on LXC 106).
- **Private read receipts**: Settings → General → Privacy — "Private Read Receipts" toggle. When on, sends `m.read.private` instead of `m.read` so other room members can't see when you've read messages.
- **Media gallery**: A right-side drawer (photo icon in room header, Desktop only) showing Images | Videos | Files tabs. Reads already-decrypted timeline events — works in E2EE rooms. Encrypted-blob images show a lock-icon placeholder. Load More paginates backwards via `mx.paginateEventTimeline()`.
- **Knock-to-join**: When a room's join rule is `knock`, RoomIntro shows "Request to Join" (calls `mx.knockRoom()`) with "Request sent" pending state. Room admins see a "Pending Requests" section in the members drawer with Approve / Deny buttons.
- **Code syntax highlighting** (TDS mode): Fenced code blocks in messages highlight keywords (cyan), strings (green), numbers (orange), comments (italic dim), function names (purple) using inline `--lt-accent-*` CSS variables. Custom tokenizer in `syntaxHighlight.ts` — supports JS/TS/JSX/TSX, Python, Rust. Falls back to ReactPrism for other languages.
### Settings (Appearance)
- **Animated Chat Backgrounds**: Five CSS-only animated wallpapers added to the background picker — Digital Rain (two-layer vertical stripe scroll with parallax), Star Drift (three-layer radial-gradient star field drifting diagonally), Grid Pulse (neon grid lines expanding/contracting via `backgroundSize` keyframe), Aurora Flow (four radial gradient ellipses sweeping across a 200% canvas), Fireflies (three layers of glowing dots drifting). All use vanilla-extract `keyframes()` — no canvas, GPU-composited. Respects `prefers-reduced-motion: reduce` (animation stripped at call time). "Pause Background Animations" toggle in Settings → Appearance provides an in-app override. Implemented in `src/app/styles/Animations.css.ts` + `src/app/features/lotus/chatBackground.ts`.
- **Glassmorphism Sidebar**: Settings → Appearance toggle (off by default). When enabled, the left sidebar becomes semi-transparent (`background: rgba(3,5,8,0.55)`) with `backdrop-filter: blur(12px)` so chat background patterns show through as a frosted glass effect. Fix: the active chat background is mirrored onto `document.body` via `useEffect` in `SidebarNav.tsx` so the blur has content to work through (previously the sidebar was a flex sibling with nothing physically behind it). Implemented as a vanilla-extract `SidebarGlass` class applied to the `<Sidebar>` container in `SidebarNav.tsx`.
- **Night Light / Blue Light Filter**: Warm orange overlay (`rgba(255,140,0,N%)`) across the entire UI. Toggle + intensity slider (580%) in Settings → Appearance. `position:fixed; pointer-events:none; z-index:9998`. Persists across sessions.
### Notification Enhancements
- **Custom notification sounds**: `messageSoundId` / `inviteSoundId` settings select per-category notification sound (`notification.ogg`, `invite.ogg`, `call.ogg`, or None). Settings → Notifications expands the sound toggle with Message Sound + Invite Sound selects and ▶ preview buttons. Shared `notificationSounds.ts` module.
- **Notification quiet hours**: `quietHoursEnabled` / `quietHoursStart` / `quietHoursEnd` settings suppress all desktop notifications and sounds during a configured time window. Handles overnight spans (e.g. 23:0008:00). Settings → Notifications: Quiet Hours card with toggle + start/end time pickers.
- **Full push rule editor**: Settings → Notifications → Advanced Push Rules section. Covers override, room, sender, and underride rule kinds. Each row has a human-readable label for built-in rules, an enable/disable toggle, and a delete button for custom rules. An add-rule form at the bottom of the room and sender sections lets users create new per-room or per-sender push rules by entering the room/user ID.
### Calls (Extended)
- **Push-to-Deafen**: Press `M` during a call to toggle speaker mute (deafen). Configurable in Settings → General → Calls alongside the PTT key. Skips editable elements; guards `e.repeat`; uses `el.ownerDocument.body` for iframe safety.
- **TDS typing indicator dots**: When Lotus Terminal mode is active, the animated typing indicator dots turn TDS orange (`var(--lt-accent-orange)`) via `color: currentColor` inheritance.
### Server Integration
- **Server support contact (MSC1929)**: Settings → Help & About displays the homeserver admin contact fetched from `/.well-known/matrix/support`. Shows the admin's Matrix ID and a link to the support page when the homeserver has configured this endpoint. Degrades gracefully when not configured (section is hidden on 404 or network error). In TDS mode the contact text and link render in `--lt-accent-cyan`. Implemented in `src/app/features/settings/about/About.tsx`.
- **Server notices**: Rooms of type `m.server_notice` (system messages from the homeserver) now render with a distinct "Server Notice" `<Chip variant="Warning">` badge in the room header and a disabled composer showing "This is a server notice room — you cannot send messages here." Previously indistinguishable from regular DMs. Badge in `src/app/features/room/RoomViewHeader.tsx`; composer guard in `src/app/features/room/RoomInput.tsx`.
### Infrastructure
- **Authenticated media**: All avatar/media loads use `mxcUrlToHttp(mx, mxcUrl, useAuthentication, w, h, 'crop')` from `../../utils/matrix` — the Lotus utility that handles MSC3916 authenticated media. (Upstream Cinny uses the SDK method with incorrect argument order for authenticated endpoints.)
- **Upstream tracking**: `git remote add upstream https://github.com/cinnyapp/cinny.git`. Merge strategy: `git fetch upstream && git merge upstream/main`. Daily check via `cinny-upstream-check.sh` on LXC 106 — notifies Matrix on new upstream commits.
- **Rolldown CJS interop — millify**: `src/app/plugins/millify.ts` uses a named import (`import { millify as millifyPlugin } from 'millify'`) instead of a default import. Rolldown's `__toESM` helper with `mode=1` sets `a.default = module_object` (not the function itself) when `hasOwnProperty` prevents the copy — calling `millifyPlugin()` would throw `(0, zc.default) is not a function`. Named import bypasses the interop entirely.
- **Sentry noise filter**: `ignoreErrors: ['Request timed out']` added to `Sentry.init` in `src/index.tsx` to suppress unhandled rejections from the matrixRTC delayed-event heartbeat (matrix-sdk) and the widget PostmessageTransport initial-load race (matrix-widget-api). Neither is actionable from client code.
- **URL preview default in encrypted rooms**: `encUrlPreview` default changed from `false` to `true` in `src/app/state/settings.ts`. A security note is shown next to the toggle in Settings → General explaining that the homeserver fetches the URL (and sees it) but not the message content.
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.
---
## Build
## Features
### Messaging
- See who has read each message, and track delivery status (sending / sent / failed)
- Bookmark any message and revisit saved messages from the sidebar
- Schedule messages to send at a specific time
- Click "edited" on any message to see the full edit history
- Drafts are saved automatically and survive page reloads
- Long messages collapse automatically — click "Read more" to expand
- Forward messages to other rooms
- Create and view polls directly in chat
- Share your location with an inline map embed
- Add captions to image and video uploads
- Optionally compress images before uploading — shows before/after file sizes
- GIF links from Giphy and Tenor auto-preview inline
- Search for and send GIFs from a built-in GIF picker
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
- Search messages with a date range filter
- Room topics support rich formatting (bold, links, italics)
- Deleted messages show a placeholder instead of disappearing
- Code blocks highlight syntax for JS/TS, Python, and Rust
- Rich link preview cards for YouTube, GitHub, Twitter/X, Reddit, Spotify, Twitch, Steam, Wikipedia, Discord, npm, Stack Overflow, and IMDb
### Calls & Voice
- Push to Talk with a configurable keybind (default: Space)
- Push to Deafen with the M key
- Camera starts turned off by default when joining a call
- Screenshare requires confirmation before going live
- Toggle noise suppression on or off
- Calls float in a draggable picture-in-picture window when you navigate away
- Your chat background shows through the call view
- Dark/light mode inside calls matches your Lotus Chat theme
- Calls are available in DMs and private groups only — no accidental mass rings
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (130 min); a toast confirms the action
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
### Customization & Appearance
- LotusGuild Terminal Design System (TDS) — a CRT terminal-inspired dark theme
- TDS light mode variant for daytime use
- 20+ static chat background patterns
- 5 animated chat backgrounds: Digital Rain, Star Drift, Grid Pulse, Aurora Flow, Fireflies (with improved per-layer looping, phosphor-flicker rain, fluid aurora sweep, and organic firefly bioluminescence)
- 11 seasonal & holiday theme overlays — Halloween, Christmas, New Year, Autumn, Valentine's Day, St. Patrick's Day, Earth Day, Lunar New Year, April Fools', Deep Space, and Retro Arcade; auto-selected by date with a manual override in Settings → Appearance
- Avatar decorations — 99 animated APNG overlays (Gaming, Cyber, Space, Fantasy, Nature, Spooky, Cozy, and more) that frame your avatar across the timeline, members list, and @mention autocomplete; visible to all Lotus Chat users; select in Settings → Account → Avatar Decoration
- Toggle to pause background animations
- Glassmorphism sidebar — frosted glass effect that lets the background show through
- Night Light / blue light filter with an adjustable intensity slider
- Emoji prefixes on room names render larger in the sidebar (e.g. 🎮 general)
- Rename any room for yourself only — other members see the original name
- Emoji picker on all room name inputs
### Presence & Profile
- Discord-style presence selector: Online, Idle, Do Not Disturb, Invisible, or Auto
- Custom status message with emoji and an optional auto-clear timer (changing your status is never silently overwritten by activity events)
- Colored presence ring on member avatars (green / yellow / red)
- Profile fields for pronouns and timezone
- When a user's timezone is set, their current local time appears in their profile
- Private notes on any user's profile — freeform text visible only to you, auto-saves and syncs across devices
- Unread count shown in the browser tab title
### Moderation & Privacy
- Report any room to homeserver admins from the room menu
- View policy lists and ban lists (Draupnir-compatible, read-only)
- Toggle private read receipts so others can't see when you've read messages
- Optional warning when an encrypted room contains unverified devices
- Full push rule editor in notification settings
- View and edit Server ACL rules in room settings
- Filterable room activity / mod log (joins, kicks, bans, power level changes, etc.)
- Room stats and insights panel (active members, top reactions, media breakdown, activity heatmap)
- Export room history as plain text, JSON, or HTML with optional date range filter
### Notifications
- In-app toast notifications appear bottom-right when the window is focused
- Custom notification sounds per category (messages, invites)
- Quiet hours — suppress notifications during a configured time window
- Click a toast to jump directly to the room or DM
### UX
- Filter and search rooms in the sidebar
- Favorite rooms sync across devices and appear in a pinned section
- Sort rooms by recent activity, alphabetical, or unread first
- DM rows show a message preview and relative timestamp
- Right-click a room for a context menu: mute with duration, copy link, mark as read
- Quick emoji reactions appear on message hover — one click to react
- Knock-to-join: request access to a room; admins approve or deny from the members list
- Media gallery drawer: browse all images, videos, and files shared in a room
- Invite link and QR code in room settings
- Pending knock requests shown in the members list for room admins with a live badge count on the Members button
- Homeserver support contact displayed in Help & About (MSC1929)
- Server notice rooms are visually distinct from regular DMs
---
## Desktop App
Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same web client in a native window with automatic background updates — no need to reinstall for new versions.
### Download
Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
### SmartScreen Warning (Windows)
When you first run the installer on Windows, you may see a popup that says **"Windows protected your PC"** with the app listed as an unknown publisher. This is normal.
**Why it happens:** Windows SmartScreen flags any app that does not have an expensive commercial code-signing certificate from a major CA. Lotus Chat is signed with its own key for update verification, but that key is not in Microsoft's pre-approved list.
**How to install anyway:**
1. Click **"More info"** in the SmartScreen dialog.
2. A **"Run anyway"** button will appear.
3. Click it to proceed with installation.
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
---
## For Developers
The source code lives in `/root/code/cinny`. All changes should be made on the `lotus` branch. Push to `origin/lotus` and CI will automatically build and deploy to [chat.lotusguild.org](https://chat.lotusguild.org) in approximately 11 minutes — no manual build or deploy steps required.
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
### Build
```bash
npm ci
npm run build # outputs to dist/
npm ci && npm run build # outputs to dist/
```
Vite's render-chunks phase requires ~6 GB Node heap. If OOM killed, set:
If the build is killed due to out-of-memory:
```bash
NODE_OPTIONS=--max_old_space_size=6144 npm run build
```
## Development workflow
All code changes should be made in the local clone at `/root/code/cinny` on the dev box, then committed and pushed to `origin/lotus`. The CI/CD pipeline handles everything from there — no manual build or deploy steps needed.
### CI/CD
```
edit → commit → git push # ~11 minutes → auto-deployed to chat.lotusguild.org
edit → commit → git push ~11 min → live at chat.lotusguild.org
```
Pipeline (`.gitea/workflows/ci.yml` + `lotus_deploy.sh` on LXC 106):
1. Push triggers a Gitea Actions build — TypeScript check, ESLint, Prettier, bundle size report
2. Build must pass as the CI gate; quality checks are informational (`continue-on-error`)
3. A Gitea webhook fires `lotus_deploy.sh` on LXC 106, which polls the API until CI passes (up to 15 min), then pulls `origin/lotus`, runs `npm ci && npm run build`, and rsyncs to `/var/www/html/`
LXC 106's stored Gitea credential is **read-only** — it can only pull. Pushes must be done from the dev box with your personal credentials (entered manually, never cached).
## Deployment
Built files are served from `/var/www/html/` on LXC 106 (nginx). Config lives at `/opt/lotus-cinny/config.json` (vite copies it to `dist/`):
```json
{
"defaultHomeserver": 0,
"homeserverList": ["matrix.lotusguild.org"],
"allowCustomHomeservers": false,
"gifApiKey": "<giphy_key>"
}
```
## Key Custom Files
| File | Purpose |
|------|---------|
| `src/lotus-terminal.css.ts` | All TDS CSS tokens, global styles, light/dark variants |
| `src/lotus-boot.ts` | Boot sequence animation (runs once per session) |
| `src/app/hooks/useRoomReadPositions.ts` | Per-message read receipt position map |
| `src/app/features/room/ReadPositionsContext.ts` | React context for read positions |
| `src/app/components/read-receipt-avatars/` | Read receipt avatar pill component |
| `src/app/components/event-readers/EventReaders.tsx` | "Seen by" modal with timestamps |
| `src/app/components/GifPicker.tsx` | GIF search + send |
| `src/app/features/call/CallControls.tsx` | PTT badge + keybind logic |
| `src/app/plugins/call/CallControl.ts` | EC widget bridge (screenshare revert, PTT mic) |
| `src/app/components/CallEmbedProvider.tsx` | PiP + draggable call embed, call wallpaper carry-over |
| `src/app/plugins/call/CallEmbed.ts` | EC widget bridge: iframe setup, `color-scheme` dark/light injection, built-in control hiding, theme sync |
| `src/app/plugins/millify.ts` | Named import fix for Rolldown CJS interop (prevents `zc.default is not a function` crash) |
| `src/app/features/room/MediaGallery.tsx` | Right-side media gallery drawer (images/videos/files) |
| `src/app/features/room/PollCreator.tsx` | Poll creation modal (single/multiple choice, 210 options) |
| `src/app/features/common-settings/general/RoomShareInvite.tsx` | Invite link + QR code tile for room settings |
| `src/app/utils/syntaxHighlight.ts` | TDS code syntax tokenizer (JS/TS/Python/Rust → inline CSS vars) |
| `src/app/features/room-settings/ExportRoomHistory.tsx` | Export room messages to plain text / JSON / HTML with date range filter and E2EE awareness |
| `src/app/features/room-settings/RoomActivityLog.tsx` | Filterable mod log of room state events (joins, kicks, bans, power level changes, etc.) |
| `src/app/features/room-settings/RoomServerACL.tsx` | Server ACL viewer/editor (allow/deny lists, IP literal toggle, power-level gated) |
| `src/app/features/room-settings/RoomInsights.tsx` | Room stats panel: top members bar chart, top reactions, media breakdown, 24h heatmap |
| `src/app/features/bookmarks/BookmarksPanel.tsx` | Saved messages sidebar panel with filter, jump-to-message, and remove |
| `src/app/hooks/useBookmarks.ts` | Read/write `io.lotus.bookmarks` account data for message bookmarks |
| `src/app/features/room/ScheduleMessageModal.tsx` | Schedule-message modal with datetime picker; sends via MSC4140 delayed events API |
| `src/app/utils/scheduledMessages.ts` | Utilities for MSC4140 scheduled message state and cancel endpoint |
| `src/app/hooks/useExtendedProfile.ts` | Read/write MSC4133 extended profile fields (`m.pronouns`, `m.tz`) |
| `src/app/hooks/useLocalTime.ts` | Formats user local time from `m.tz` IANA zone; updates every 60 s |
| `src/app/components/url-preview/UrlPreviewCard.tsx` | Domain-specific URL preview cards for 13 sites + generic favicon fallback |
+2 -4
View File
@@ -29,10 +29,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap"
rel="stylesheet"
/>
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/fonts/custom-fonts.css" />
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
<link rel="manifest" href="/manifest.json" />
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 851 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 944 KiB

+2 -1
View File
@@ -18,7 +18,8 @@
"typecheck": "tsc --noEmit",
"prepare": "husky",
"commit": "git-cz",
"postinstall": "node scripts/patch-folds.mjs"
"postinstall": "node scripts/patch-folds.mjs",
"sync:decorations": "node scripts/syncDecorations.mjs"
},
"lint-staged": {
"*.{ts,tsx,js,jsx}": "eslint",
Binary file not shown.

Before

Width:  |  Height:  |  Size: 631 B

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.
Binary file not shown.
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>Thats an error.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>Thats all we know.</ins>
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>Thats an error.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>Thats all we know.</ins>
+11
View File
@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang=en>
<meta charset=utf-8>
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
<title>Error 404 (Not Found)!!1</title>
<style>
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
</style>
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
<p><b>404.</b> <ins>Thats an error.</ins>
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>Thats all we know.</ins>
Binary file not shown.
Binary file not shown.
Binary file not shown.
+51
View File
@@ -0,0 +1,51 @@
/* Self-hosted fonts — avoids tracking prevention in desktop WebView2 */
@font-face {
font-family: 'JetBrains Mono';
font-style: italic;
font-weight: 400;
font-display: swap;
src: url('/fonts/JetBrainsMono-italic-400.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/JetBrainsMono-normal-400.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'JetBrains Mono';
font-style: normal;
font-weight: 700;
font-display: swap;
src: url('/fonts/JetBrainsMono-normal-700.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Fira Code';
font-style: normal;
font-weight: 400;
font-display: swap;
src: url('/fonts/FiraCode-400.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: 'Fira Code';
font-style: normal;
font-weight: 600;
font-display: swap;
src: url('/fonts/FiraCode-600.woff2') format('woff2');
unicode-range:
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
+9 -9
View File
@@ -11,47 +11,47 @@
"theme_color": "#980000",
"icons": [
{
"src": "./public/android/android-chrome-36x36.png",
"src": "./res/android/android-chrome-36x36.png",
"sizes": "36x36",
"type": "image/png"
},
{
"src": "./public/android/android-chrome-48x48.png",
"src": "./res/android/android-chrome-48x48.png",
"sizes": "48x48",
"type": "image/png"
},
{
"src": "./public/android/android-chrome-72x72.png",
"src": "./res/android/android-chrome-72x72.png",
"sizes": "72x72",
"type": "image/png"
},
{
"src": "./public/android/android-chrome-96x96.png",
"src": "./res/android/android-chrome-96x96.png",
"sizes": "96x96",
"type": "image/png"
},
{
"src": "./public/android/android-chrome-144x144.png",
"src": "./res/android/android-chrome-144x144.png",
"sizes": "144x144",
"type": "image/png"
},
{
"src": "./public/android/android-chrome-192x192.png",
"src": "./res/android/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "./public/android/android-chrome-256x256.png",
"src": "./res/android/android-chrome-256x256.png",
"sizes": "256x256",
"type": "image/png"
},
{
"src": "./public/android/android-chrome-384x384.png",
"src": "./res/android/android-chrome-384x384.png",
"sizes": "384x384",
"type": "image/png"
},
{
"src": "./public/android/android-chrome-512x512.png",
"src": "./res/android/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 38 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 92 KiB

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 3.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.4 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 20 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 39 KiB

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.7 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.9 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

+89
View File
@@ -0,0 +1,89 @@
#!/usr/bin/env node
/**
* Syncs avatarDecorations.ts with what's actually available on the Nextcloud CDN.
*
* Usage:
* npm run sync:decorations
*
* Workflow after deleting files from Nextcloud:
* 1. Delete decoration files from your Nextcloud share.
* 2. Run: npm run sync:decorations
* 3. It probes each catalog slug via HTTP HEAD and removes entries
* whose files returned 404. Empty categories are dropped automatically.
* 4. Commit the updated avatarDecorations.ts.
*/
import { readFileSync, writeFileSync } from 'fs';
import { join, dirname } from 'path';
import { fileURLToPath } from 'url';
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';
// 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) {
console.error('No slugs found in catalog — check the file path.');
process.exit(1);
}
console.log(`Checking ${slugMatches.length} decorations against ${CDN}`);
console.log('(This makes one HEAD request per decoration)\n');
// Probe all slugs in parallel batches of 16
async function headCheck(slug) {
try {
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
return { slug, ok: res.ok, status: res.status };
} catch {
return { slug, ok: false, status: 0 };
}
}
const BATCH = 16;
const results = [];
for (let i = 0; i < slugMatches.length; i += BATCH) {
const batch = slugMatches.slice(i, i + BATCH);
const batchResults = await Promise.all(batch.map(headCheck));
results.push(...batchResults);
}
const missing = results.filter((r) => !r.ok);
const found = results.filter((r) => r.ok);
if (missing.length === 0) {
console.log(`All ${found.length} decorations are available — catalog is up to date.`);
process.exit(0);
}
console.log(`Found: ${found.length} Missing: ${missing.length}\n`);
missing.forEach((r) =>
console.log(` Removing (HTTP ${r.status}): ${r.slug}`),
);
const missingSet = new Set(missing.map((r) => r.slug));
// Remove individual entries for missing slugs
let updated = catalog.replace(
/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm,
(match, slug) => (missingSet.has(slug) ? '' : match),
);
// Drop category blocks that now have an empty decorations array
updated = updated.replace(
/ \{\n id: '[^']+',\n label: '[^']+',\n decorations: \[\n?[ \t]*\],?\n \},?\n/g,
'',
);
// Clean up stray blank lines
updated = updated.replace(/\n{3,}/g, '\n\n');
writeFileSync(catalogPath, updated, 'utf8');
console.log(`\nDone. Removed ${missing.length} entr${missing.length === 1 ? 'y' : 'ies'} from the catalog.`);
console.log('Review with: git diff src/app/features/lotus/avatarDecorations.ts');
+75 -28
View File
@@ -42,6 +42,7 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
import { useMatrixClient } from '../hooks/useMatrixClient';
import CallSound from '../../../public/sound/call.ogg';
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
import { mDirectAtom } from '../state/mDirectList';
@@ -53,7 +54,7 @@ import { getChatBg } from '../features/lotus/chatBackground';
import { useTheme, ThemeKind } from '../hooks/useTheme';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { getStateEvent, getMemberDisplayName } from '../utils/room';
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
import { StateEvent } from '../../types/matrix/room';
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
@@ -324,18 +325,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
);
if (!hasCallPermission) return;
// Only ring for DMs or private non-space group chats.
// Space voice channels and public rooms fire room-level RTC notifications
// whenever anyone joins — ringing every member is incorrect behaviour.
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
// with no space parent. Persistent voice rooms (call rooms), space channels,
// restricted rooms, and public rooms must never trigger ringing.
if (room.isCallRoom()) return;
const isDirect = directs.has(room.roomId);
const isSpaceChild = !!getStateEvent(room, StateEvent.SpaceParent);
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
const joinRule = room.getJoinRule();
const isPrivateGroup =
!isSpaceChild &&
(joinRule === JoinRule.Invite ||
joinRule === JoinRule.Knock ||
joinRule === JoinRule.Restricted);
if (!isDirect && !isPrivateGroup) return;
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
if (!isDirect && !isPrivateInviteGroup) return;
const info: IncomingCallInfo = {
room,
@@ -406,6 +404,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
const setCallEmbed = useSetAtom(callEmbedAtom);
useCallMemberSoundSync(embed);
useCallJoinLeaveSounds(embed);
useCallThemeSync(embed);
useCallHangupEvent(
embed,
@@ -722,23 +721,20 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
document.addEventListener('touchend', onTouchEnd);
};
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
e.stopPropagation();
e.preventDefault();
const el = callEmbedRef.current;
if (!el) return;
normaliseToTopLeft(el);
const sx = e.clientX;
const sy = e.clientY;
const sw = el.offsetWidth;
const sh = el.offsetHeight;
const sl = parseFloat(el.style.left);
const st = parseFloat(el.style.top);
document.body.style.cursor = `${corner}-resize`;
document.body.style.userSelect = 'none';
const onMove = (ev: MouseEvent) => {
const dx = ev.clientX - sx;
const dy = ev.clientY - sy;
function applyResize(
el: HTMLElement,
corner: Corner,
sx: number,
sy: number,
sw: number,
sh: number,
sl: number,
st: number,
cx: number,
cy: number,
) {
const dx = cx - sx;
const dy = cy - sy;
let w = sw;
let h = sh;
let l = sl;
@@ -771,6 +767,24 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
el.style.height = `${h}px`;
el.style.left = `${l}px`;
el.style.top = `${t}px`;
}
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
e.stopPropagation();
e.preventDefault();
const el = callEmbedRef.current;
if (!el) return;
normaliseToTopLeft(el);
const sx = e.clientX;
const sy = e.clientY;
const sw = el.offsetWidth;
const sh = el.offsetHeight;
const sl = parseFloat(el.style.left);
const st = parseFloat(el.style.top);
document.body.style.cursor = `${corner}-resize`;
document.body.style.userSelect = 'none';
const onMove = (ev: MouseEvent) => {
applyResize(el, corner, sx, sy, sw, sh, sl, st, ev.clientX, ev.clientY);
};
const onUp = () => {
document.removeEventListener('mousemove', onMove);
@@ -789,6 +803,38 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
document.addEventListener('mouseup', onUp);
};
const handleResizeTouchStart = (e: React.TouchEvent, corner: Corner) => {
e.stopPropagation();
e.preventDefault();
const el = callEmbedRef.current;
if (!el || e.touches.length !== 1) return;
normaliseToTopLeft(el);
const touch = e.touches[0];
const sx = touch.clientX;
const sy = touch.clientY;
const sw = el.offsetWidth;
const sh = el.offsetHeight;
const sl = parseFloat(el.style.left);
const st = parseFloat(el.style.top);
const onMove = (ev: TouchEvent) => {
if (ev.touches.length !== 1) return;
ev.preventDefault();
const t = ev.touches[0];
applyResize(el, corner, sx, sy, sw, sh, sl, st, t.clientX, t.clientY);
};
const onEnd = () => {
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onEnd);
activeDragCleanupRef.current = null;
};
activeDragCleanupRef.current = () => {
document.removeEventListener('touchmove', onMove);
document.removeEventListener('touchend', onEnd);
};
document.addEventListener('touchmove', onMove, { passive: false });
document.addEventListener('touchend', onEnd);
};
return (
<CallEmbedContextProvider value={callEmbed}>
{callEmbed && <CallUtils embed={callEmbed} />}
@@ -871,6 +917,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
<div
key={corner}
onMouseDown={(ev) => handleResizeMouseDown(ev, corner)}
onTouchStart={(ev) => handleResizeTouchStart(ev, corner)}
onClick={(ev) => ev.stopPropagation()}
style={{
position: 'absolute',
+6 -5
View File
@@ -36,12 +36,12 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
<div
style={{
padding: '5px 10px 4px',
borderBottom: '1px solid rgba(255,107,0,0.2)',
borderBottom: '1px solid color-mix(in srgb, var(--lt-accent-orange) 20%, transparent)',
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
fontSize: '10px',
fontWeight: 700,
letterSpacing: '0.1em',
color: '#FF6B00',
color: 'var(--lt-accent-orange)',
userSelect: 'none',
}}
>
@@ -82,11 +82,12 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
const containerStyle = lotusTerminal
? {
background: '#060c14',
border: '1px solid rgba(255,107,0,0.35)',
background: 'var(--lt-bg-secondary)',
border: '1px solid color-mix(in srgb, var(--lt-accent-orange) 35%, transparent)',
borderRadius: '4px',
overflow: 'hidden',
boxShadow: '0 4px 24px rgba(255,107,0,0.10), 0 0 0 1px rgba(255,107,0,0.08)',
boxShadow:
'0 4px 24px color-mix(in srgb, var(--lt-accent-orange) 10%, transparent), 0 0 0 1px color-mix(in srgb, var(--lt-accent-orange) 8%, transparent)',
width: `${PICKER_WIDTH}px`,
}
: {
+7 -3
View File
@@ -203,7 +203,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
width: toRem(8),
height: toRem(8),
borderRadius: '50%',
background: lotusTerminal ? '#FF6B00' : 'var(--tc-danger-normal)',
background: lotusTerminal ? 'var(--lt-accent-orange)' : 'var(--tc-danger-normal)',
flexShrink: 0,
animation: 'pttLivePulse 900ms ease-in-out infinite',
}}
@@ -214,7 +214,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
minWidth: toRem(32),
fontVariantNumeric: 'tabular-nums',
...(lotusTerminal
? { fontFamily: 'JetBrains Mono, monospace', color: '#00FF88', fontWeight: 700 }
? {
fontFamily: 'JetBrains Mono, monospace',
color: 'var(--lt-accent-green)',
fontWeight: 700,
}
: {}),
}}
>
@@ -233,7 +237,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
width: toRem(2),
height: toRem(2 + (h / barMax) * 16),
borderRadius: toRem(1),
background: lotusTerminal ? '#00FF88' : 'var(--tc-primary-normal)',
background: lotusTerminal ? 'var(--lt-accent-green)' : 'var(--tc-primary-normal)',
flexShrink: 0,
}}
/>
@@ -0,0 +1,53 @@
import React from 'react';
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
import { decorationUrl } from '../../features/lotus/avatarDecorations';
const DEFAULT_INSET = 8;
type AvatarDecorationProps = {
userId: string;
children: React.ReactNode;
inset?: number;
};
export function AvatarDecoration({ userId, children, inset = DEFAULT_INSET }: AvatarDecorationProps) {
const slug = useAvatarDecoration(userId);
if (!slug) {
return <>{children}</>;
}
return (
<div
style={{
position: 'relative',
display: 'inline-flex',
flexShrink: 0,
}}
>
{children}
<img
src={decorationUrl(slug)}
style={{
position: 'absolute',
top: -inset,
left: -inset,
right: -inset,
bottom: -inset,
width: `calc(100% + ${inset * 2}px)`,
height: `calc(100% + ${inset * 2}px)`,
pointerEvents: 'none',
zIndex: 10,
objectFit: 'contain',
}}
alt=""
aria-hidden="true"
loading="lazy"
decoding="async"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = 'none';
}}
/>
</div>
);
}
@@ -21,6 +21,7 @@ import { UserAvatar } from '../../user-avatar';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { Membership } from '../../../../types/matrix/room';
import { PresenceRingAvatar } from '../../presence';
import { AvatarDecoration } from '../../avatar-decoration/AvatarDecoration';
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
@@ -48,6 +49,7 @@ function UnknownMentionItem({
}
onClick={() => handleAutocomplete(userId, name)}
before={
<AvatarDecoration userId={userId}>
<PresenceRingAvatar userId={userId}>
<Avatar size="200">
<UserAvatar
@@ -56,6 +58,7 @@ function UnknownMentionItem({
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
}
>
<Text style={{ flexGrow: 1 }} size="B400">
@@ -177,6 +180,7 @@ export function UserMentionAutocomplete({
</Text>
}
before={
<AvatarDecoration userId={roomMember.userId}>
<PresenceRingAvatar userId={roomMember.userId}>
<Avatar size="200">
<UserAvatar
@@ -187,6 +191,7 @@ export function UserMentionAutocomplete({
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
+4 -4
View File
@@ -10,7 +10,7 @@ import {
TooltipProvider,
toRem,
} from 'folds';
import React, { ReactNode, useId } from 'react';
import React, { ReactNode } from 'react';
import * as css from './styles.css';
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
@@ -27,7 +27,7 @@ type PresenceBadgeProps = {
};
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
const label = usePresenceLabel();
const badgeLabelId = useId();
const ariaLabel = status ? `${label[presence]}${status}` : label[presence];
return (
<TooltipProvider
@@ -36,7 +36,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
offset={4}
delay={200}
tooltip={
<Tooltip id={badgeLabelId}>
<Tooltip>
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
<Text size="L400">{label[presence]}</Text>
{status && <Text size="T200"></Text>}
@@ -47,7 +47,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
>
{(triggerRef) => (
<Badge
aria-labelledby={badgeLabelId}
aria-label={ariaLabel}
ref={triggerRef}
size={size}
variant={PresenceToColor[presence]}
+136
View File
@@ -0,0 +1,136 @@
import { keyframes } from '@vanilla-extract/css';
/** Generic fall: particles drop from top to bottom with a slight rotate. */
export const animSeasonFall = keyframes({
'0%': { transform: 'translateY(-20px) translateX(0) rotate(0deg)', opacity: '0' },
'5%': { opacity: '1' },
'90%': { opacity: '0.8' },
'100%': { transform: 'translateY(110vh) translateX(25px) rotate(360deg)', opacity: '0' },
});
/** Leaf fall: exaggerated horizontal sway as the leaf tumbles down. */
export const animLeafFall = keyframes({
'0%': { transform: 'translateY(-20px) translateX(0) rotate(-20deg)', opacity: '0' },
'8%': { opacity: '0.85' },
'25%': { transform: 'translateY(25vh) translateX(35px) rotate(40deg)' },
'50%': { transform: 'translateY(50vh) translateX(-25px) rotate(130deg)' },
'75%': { transform: 'translateY(75vh) translateX(45px) rotate(260deg)' },
'92%': { opacity: '0.6' },
'100%': { transform: 'translateY(110vh) translateX(5px) rotate(380deg)', opacity: '0' },
});
/** Float up: hearts / embers rise from the bottom. */
export const animFloatUp = keyframes({
'0%': { transform: 'translateY(0) scale(0.6) translateX(0)', opacity: '0' },
'8%': { opacity: '0.9' },
'50%': { transform: 'translateY(-50vh) scale(1) translateX(15px)' },
'85%': { opacity: '0.4' },
'100%': { transform: 'translateY(-105vh) scale(1.3) translateX(-10px)', opacity: '0' },
});
/** Bob: lanterns gently rise and fall with a slight tilt. */
export const animBob = keyframes({
'0%': { transform: 'translateY(0px) rotate(-4deg)' },
'50%': { transform: 'translateY(-18px) rotate(4deg)' },
'100%': { transform: 'translateY(0px) rotate(-4deg)' },
});
/** Lantern tassel sway (used on the tassel element only). */
export const animTasselSway = keyframes({
'0%': { transform: 'rotate(-8deg)' },
'50%': { transform: 'rotate(8deg)' },
'100%': { transform: 'rotate(-8deg)' },
});
/** Glitch jitter: rapid position jumps that feel like a signal error. */
export const animGlitch = keyframes({
'0%': { transform: 'translate(0, 0)' },
'2%': { transform: 'translate(-4px, 2px)' },
'4%': { transform: 'translate(4px, -2px)' },
'6%': { transform: 'translate(0, 0)' },
'48%': { transform: 'translate(0, 0)' },
'50%': { transform: 'translate(3px, -3px)' },
'52%': { transform: 'translate(-3px, 3px)' },
'54%': { transform: 'translate(0, 0)' },
'78%': { transform: 'translate(0, 0)' },
'80%': { transform: 'translate(-5px, 1px)' },
'82%': { transform: 'translate(0, 0)' },
'100%': { transform: 'translate(0, 0)' },
});
/** Glitch color: hue + saturation spikes that look like a corrupted signal. */
export const animGlitchColor = keyframes({
'0%': { filter: 'hue-rotate(0deg) saturate(1)' },
'8%': { filter: 'hue-rotate(180deg) saturate(3)' },
'9%': { filter: 'hue-rotate(0deg) saturate(1)' },
'55%': { filter: 'hue-rotate(0deg) saturate(1)' },
'57%': { filter: 'hue-rotate(90deg) saturate(2)' },
'58%': { filter: 'hue-rotate(0deg) saturate(1)' },
'80%': { filter: 'hue-rotate(0deg) saturate(1)' },
'82%': { filter: 'hue-rotate(270deg) saturate(2.5)' },
'83%': { filter: 'hue-rotate(0deg) saturate(1)' },
'100%': { filter: 'hue-rotate(0deg) saturate(1)' },
});
/** Glitch scanline: a horizontal band sweeps across, flickering. */
export const animGlitchScan = keyframes({
'0%': { transform: 'translateY(-100%)' },
'100%': { transform: 'translateY(100vh)' },
});
/** Burst: circle expands outward from a point and fades — firework petal. */
export const animBurst = keyframes({
'0%': { transform: 'scale(0) rotate(0deg)', opacity: '1' },
'50%': { opacity: '0.7' },
'100%': { transform: 'scale(1) rotate(45deg)', opacity: '0' },
});
/** Firework trail: a small dot rockets upward before bursting. */
export const animRocket = keyframes({
'0%': { transform: 'translateY(0)', opacity: '1' },
'100%': { transform: 'translateY(-40vh)', opacity: '0' },
});
/** Deep space warp: stars streak from center outward. */
export const animWarp = keyframes({
'0%': { transform: 'scale(0.05) translate(0, 0)', opacity: '0' },
'10%': { opacity: '1' },
'100%': { transform: 'scale(4) translate(0, 0)', opacity: '0' },
});
/** Arcade scanline flicker. */
export const animScanline = keyframes({
'0%': { opacity: '0.12' },
'50%': { opacity: '0.04' },
'100%': { opacity: '0.12' },
});
/** Arcade pixel blink: decorative corner glyphs blink. */
export const animPixelBlink = keyframes({
'0%, 49%': { opacity: '1' },
'50%, 100%': { opacity: '0' },
});
/** Gold shimmer: a shine sweeps across a metallic surface. */
export const animGoldShimmer = keyframes({
'0%': { backgroundPosition: '-300% 0' },
'100%': { backgroundPosition: '300% 0' },
});
/** Clover drift: gentle fall with a slow spin. */
export const animCloverDrift = keyframes({
'0%': { transform: 'translateY(-20px) rotate(0deg)', opacity: '0' },
'5%': { opacity: '0.7' },
'90%': { opacity: '0.5' },
'100%': { transform: 'translateY(110vh) rotate(720deg)', opacity: '0' },
});
/** Earth Day leaf sway: gentle horizontal oscillation for ambient leaf particles. */
export const animEarthLeafDrift = keyframes({
'0%': { transform: 'translateY(-10px) translateX(0) rotate(0deg)', opacity: '0' },
'8%': { opacity: '0.6' },
'30%': { transform: 'translateY(30vh) translateX(20px) rotate(90deg)' },
'60%': { transform: 'translateY(60vh) translateX(-15px) rotate(200deg)' },
'90%': { opacity: '0.4' },
'100%': { transform: 'translateY(110vh) translateX(10px) rotate(340deg)', opacity: '0' },
});
@@ -0,0 +1,802 @@
import React, { useMemo } from 'react';
import { useAtomValue } from 'jotai';
import { settingsAtom } from '../../state/settings';
import {
animSeasonFall,
animLeafFall,
animFloatUp,
animBob,
animTasselSway,
animGlitch,
animGlitchColor,
animGlitchScan,
animBurst,
animWarp,
animScanline,
animPixelBlink,
animGoldShimmer,
animCloverDrift,
animEarthLeafDrift,
} from './Seasonal.css';
export type SeasonTheme =
| 'halloween'
| 'christmas'
| 'newyear'
| 'autumn'
| 'aprilfools'
| 'lunar'
| 'valentines'
| 'stpatricks'
| 'earthday'
| 'deepspace'
| 'arcade';
function getActiveSeason(now: Date): SeasonTheme | null {
const m = now.getMonth() + 1; // 1-12
const d = now.getDate();
// New Year takes highest priority (Dec 31 Jan 2)
if ((m === 12 && d === 31) || (m === 1 && d <= 2)) return 'newyear';
// Valentine's Day (Feb 1015)
if (m === 2 && d >= 10 && d <= 15) return 'valentines';
// St. Patrick's Day (March 1518)
if (m === 3 && d >= 15 && d <= 18) return 'stpatricks';
// April Fool's (April 1)
if (m === 4 && d === 1) return 'aprilfools';
// Earth Day (April 2023)
if (m === 4 && d >= 20 && d <= 23) return 'earthday';
// Lunar New Year (Jan 22 Feb 5, approximate fixed window)
if ((m === 1 && d >= 22) || (m === 2 && d <= 5)) return 'lunar';
// International Video Game Day (Sept 12)
if (m === 9 && d === 12) return 'arcade';
// World Space Week (Oct 410)
if (m === 10 && d >= 4 && d <= 10) return 'deepspace';
// Halloween (Oct 15 Nov 1)
if ((m === 10 && d >= 15) || (m === 11 && d === 1)) return 'halloween';
// Christmas (Dec 1030)
if (m === 12 && d >= 10) return 'christmas';
// Autumn (Sept 21 Oct 31, excluding Halloween/Deep Space windows above)
if ((m === 9 && d >= 21) || (m === 10 && d <= 14)) return 'autumn';
return null;
}
// ─── Individual theme overlays ────────────────────────────────────────────────
function HalloweenOverlay({ reduced }: { reduced: boolean }) {
const particles = Array.from({ length: 22 });
return (
<>
{/* Dark purple ambient tint */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(25,0,45,0.22)',
backgroundImage:
'radial-gradient(ellipse at 50% 50%, rgba(100,0,180,0.08) 0%, transparent 70%)',
}}
/>
{/* Spider web corners */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
width: '160px',
height: '160px',
backgroundImage: `url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160' viewBox='0 0 160 160'><g stroke='rgba(180,120,255,0.35)' stroke-width='0.7' fill='none'><line x1='0' y1='0' x2='80' y2='80'/><line x1='40' y1='0' x2='80' y2='80'/><line x1='80' y1='0' x2='80' y2='80'/><line x1='0' y1='40' x2='80' y2='80'/><line x1='0' y1='80' x2='80' y2='80'/><ellipse cx='80' cy='80' rx='20' ry='20'/><ellipse cx='80' cy='80' rx='40' ry='40'/><ellipse cx='80' cy='80' rx='60' ry='60'/><ellipse cx='80' cy='80' rx='80' ry='80'/></g></svg>")`,
backgroundRepeat: 'no-repeat',
opacity: 0.7,
}}
/>
{/* Falling purple/orange particles */}
{!reduced &&
particles.map((_, i) => {
const isOrange = i % 3 === 0;
const size = 4 + (i % 3) * 2;
const left = ((i * 4597 + 137) % 100);
const duration = 8 + (i % 7) * 1.5;
const delay = (i * 0.45) % 7;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-8px',
left: `${left}%`,
width: `${size}px`,
height: `${size}px`,
borderRadius: '50%',
backgroundColor: isOrange ? 'rgba(255,100,0,0.75)' : 'rgba(160,0,255,0.7)',
boxShadow: isOrange
? '0 0 8px rgba(255,100,0,0.5)'
: '0 0 8px rgba(160,0,255,0.5)',
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
}}
/>
);
})}
</>
);
}
function ChristmasOverlay({ reduced }: { reduced: boolean }) {
const flakes = Array.from({ length: 28 });
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'radial-gradient(ellipse at 50% 0%, rgba(220,240,255,0.06) 0%, transparent 60%)',
}}
/>
{!reduced &&
flakes.map((_, i) => {
const size = 3 + (i % 4) * 2;
const left = ((i * 3571 + 251) % 100);
const duration = 10 + (i % 8) * 2;
const delay = (i * 0.55) % 10;
const drift = ((i % 5) - 2) * 12;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-10px',
left: `${left}%`,
width: `${size}px`,
height: `${size}px`,
borderRadius: '50%',
backgroundColor: 'rgba(255,255,255,0.82)',
boxShadow: '0 0 4px rgba(200,230,255,0.6)',
animation: `${animSeasonFall} ${duration}s ease-in ${delay}s infinite`,
transform: `translateX(${drift}px)`,
}}
/>
);
})}
</>
);
}
function NewYearOverlay({ reduced }: { reduced: boolean }) {
const bursts = [
{ x: 20, y: 25, color: '#ffd700', delay: 0 },
{ x: 75, y: 15, color: '#ff4466', delay: 1.2 },
{ x: 50, y: 35, color: '#00d4ff', delay: 2.4 },
{ x: 15, y: 60, color: '#ffd700', delay: 3.6 },
{ x: 85, y: 45, color: '#aa44ff', delay: 0.8 },
{ x: 40, y: 20, color: '#ff8800', delay: 2.0 },
];
const petals = Array.from({ length: 8 });
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(10,5,0,0.15)',
backgroundImage:
'radial-gradient(ellipse at 50% 50%, rgba(255,200,0,0.04) 0%, transparent 70%)',
}}
/>
{!reduced &&
bursts.map((b, bi) =>
petals.map((_, pi) => {
const angle = (pi / petals.length) * 360;
const dist = 80 + (pi % 3) * 30;
const duration = 1.6 + (bi % 3) * 0.4;
const period = 3.5 + bi * 0.8;
return (
<div
key={`${bi}-${pi}`}
aria-hidden="true"
style={{
position: 'absolute',
left: `${b.x}%`,
top: `${b.y}%`,
width: `${dist}px`,
height: '2px',
backgroundColor: b.color,
boxShadow: `0 0 6px ${b.color}`,
transformOrigin: '0 50%',
transform: `rotate(${angle}deg)`,
animation: `${animBurst} ${duration}s ease-out ${b.delay + pi * 0.05}s ${period}s infinite`,
borderRadius: '1px',
opacity: 0,
}}
/>
);
})
)}
{/* Gold shimmer overlay */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'linear-gradient(105deg, transparent 30%, rgba(255,215,0,0.06) 50%, transparent 70%)',
backgroundSize: '200% 100%',
animation: reduced ? 'none' : `${animGoldShimmer} 4s linear infinite`,
}}
/>
</>
);
}
function AutumnOverlay({ reduced }: { reduced: boolean }) {
const leaves = Array.from({ length: 18 });
const colors = [
'rgba(220,80,20,0.75)',
'rgba(200,120,0,0.7)',
'rgba(180,50,10,0.7)',
'rgba(230,150,0,0.65)',
'rgba(160,80,0,0.6)',
];
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'radial-gradient(ellipse at 50% 100%, rgba(180,80,0,0.06) 0%, transparent 60%)',
}}
/>
{!reduced &&
leaves.map((_, i) => {
const left = ((i * 5381 + 179) % 100);
const duration = 12 + (i % 6) * 2;
const delay = (i * 0.65) % 12;
const size = 10 + (i % 4) * 4;
const color = colors[i % colors.length];
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-15px',
left: `${left}%`,
width: `${size}px`,
height: `${size * 0.7}px`,
borderRadius: '50% 0 50% 0',
backgroundColor: color,
boxShadow: `0 0 4px ${color}`,
animation: `${animLeafFall} ${duration}s ease-in ${delay}s infinite`,
}}
/>
);
})}
</>
);
}
function AprilFoolsOverlay({ reduced }: { reduced: boolean }) {
return (
<>
{/* RGB channel separation layers */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(255,0,0,0.04)',
transform: 'translateX(2px)',
mixBlendMode: 'multiply',
animation: reduced ? 'none' : `${animGlitch} 5s step-end infinite`,
}}
/>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0,255,255,0.04)',
transform: 'translateX(-2px)',
mixBlendMode: 'multiply',
animation: reduced ? 'none' : `${animGlitch} 5s step-end 0.3s infinite`,
}}
/>
{/* Color corruption */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
animation: reduced ? 'none' : `${animGlitchColor} 7s step-end infinite`,
}}
/>
{/* Sweeping scanline */}
{!reduced && (
<div
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
right: 0,
height: '3px',
backgroundColor: 'rgba(0,255,136,0.35)',
boxShadow: '0 0 8px rgba(0,255,136,0.5)',
animation: `${animGlitchScan} 2.8s linear infinite`,
}}
/>
)}
{/* "ERROR" watermark */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%) rotate(-15deg)',
fontSize: '80px',
fontWeight: 900,
fontFamily: 'monospace',
color: 'rgba(255,0,0,0.07)',
letterSpacing: '0.1em',
pointerEvents: 'none',
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
SIGNAL LOST
</div>
</>
);
}
function LunarNewYearOverlay({ reduced }: { reduced: boolean }) {
const lanterns = Array.from({ length: 9 });
return (
<>
{/* Silk-like texture overlay */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(140,0,0,0.08)',
backgroundImage: [
'repeating-linear-gradient(45deg, rgba(200,20,0,0.03) 0px, rgba(200,20,0,0.03) 1px, transparent 1px, transparent 8px)',
'repeating-linear-gradient(135deg, rgba(200,20,0,0.03) 0px, rgba(200,20,0,0.03) 1px, transparent 1px, transparent 8px)',
].join(','),
}}
/>
{/* Gold shimmer sweep */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'linear-gradient(100deg, transparent 25%, rgba(255,200,0,0.07) 45%, rgba(255,220,50,0.1) 50%, rgba(255,200,0,0.07) 55%, transparent 75%)',
backgroundSize: '300% 100%',
animation: reduced ? 'none' : `${animGoldShimmer} 5s linear infinite`,
}}
/>
{/* Floating paper lanterns */}
{lanterns.map((_, i) => {
const left = 5 + ((i * 4603 + 311) % 90);
const top = 8 + ((i * 2311 + 97) % 55);
const duration = 3.5 + (i % 4) * 0.7;
const delay = i * 0.5;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
left: `${left}%`,
top: `${top}%`,
animation: reduced ? 'none' : `${animBob} ${duration}s ease-in-out ${delay}s infinite`,
}}
>
{/* Lantern top cap */}
<div
style={{
width: '18px',
height: '5px',
backgroundColor: '#ffd700',
borderRadius: '2px',
margin: '0 auto',
boxShadow: '0 0 4px rgba(255,215,0,0.6)',
}}
/>
{/* Lantern body */}
<div
style={{
width: '24px',
height: '32px',
backgroundColor: '#cc0000',
borderRadius: '50%',
border: '1.5px solid #ffd700',
boxShadow: '0 0 14px rgba(200,0,0,0.5), inset 0 0 10px rgba(255,200,0,0.2)',
margin: '1px auto',
}}
/>
{/* Lantern bottom cap */}
<div
style={{
width: '18px',
height: '5px',
backgroundColor: '#ffd700',
borderRadius: '2px',
margin: '0 auto',
}}
/>
{/* Tassel */}
<div
style={{
width: '2px',
height: '14px',
backgroundColor: '#ffd700',
margin: '0 auto',
animation: reduced ? 'none' : `${animTasselSway} ${duration * 0.6}s ease-in-out ${delay}s infinite`,
transformOrigin: 'top center',
}}
/>
</div>
);
})}
</>
);
}
function ValentinesOverlay({ reduced }: { reduced: boolean }) {
const hearts = Array.from({ length: 18 });
const colors = [
'rgba(255,100,140,0.8)',
'rgba(255,150,180,0.65)',
'rgba(220,70,110,0.7)',
'rgba(255,180,200,0.55)',
];
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'radial-gradient(ellipse at 50% 100%, rgba(255,100,140,0.06) 0%, transparent 55%)',
}}
/>
{!reduced &&
hearts.map((_, i) => {
const left = 3 + ((i * 6271 + 443) % 94);
const duration = 9 + (i % 6) * 1.8;
const delay = (i * 0.6) % 9;
const size = 14 + (i % 4) * 5;
const color = colors[i % colors.length];
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
bottom: '-20px',
left: `${left}%`,
fontSize: `${size}px`,
color,
filter: 'drop-shadow(0 0 4px rgba(255,100,140,0.4))',
animation: `${animFloatUp} ${duration}s ease-in ${delay}s infinite`,
userSelect: 'none',
}}
>
</div>
);
})}
</>
);
}
function StPatricksOverlay({ reduced }: { reduced: boolean }) {
const clovers = Array.from({ length: 18 });
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(ellipse at 50% 0%, rgba(0,160,60,0.07) 0%, transparent 50%)',
'radial-gradient(ellipse at 50% 100%, rgba(0,130,50,0.05) 0%, transparent 40%)',
].join(','),
}}
/>
{/* Moving metallic gold shimmer on the accent border at top */}
<div
aria-hidden="true"
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
backgroundImage:
'linear-gradient(90deg, transparent 0%, #ffd700 20%, #fff4a0 40%, #ffd700 60%, transparent 100%)',
backgroundSize: '300% 100%',
animation: reduced ? 'none' : `${animGoldShimmer} 3s linear infinite`,
}}
/>
{!reduced &&
clovers.map((_, i) => {
const left = ((i * 4129 + 223) % 100);
const duration = 14 + (i % 6) * 2;
const delay = (i * 0.7) % 12;
const size = 14 + (i % 3) * 6;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-20px',
left: `${left}%`,
fontSize: `${size}px`,
opacity: 0.45 + (i % 3) * 0.1,
filter: 'drop-shadow(0 0 3px rgba(0,180,60,0.3))',
animation: `${animCloverDrift} ${duration}s linear ${delay}s infinite`,
userSelect: 'none',
}}
>
</div>
);
})}
</>
);
}
function EarthDayOverlay({ reduced }: { reduced: boolean }) {
const leaves = Array.from({ length: 16 });
const leafEmoji = ['🌿', '🍃', '🌱', '🍀'];
return (
<>
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage: [
'radial-gradient(ellipse at 30% 70%, rgba(60,160,60,0.07) 0%, transparent 50%)',
'radial-gradient(ellipse at 70% 30%, rgba(100,180,80,0.05) 0%, transparent 45%)',
].join(','),
}}
/>
{/* Vine line along the left edge */}
<div
aria-hidden="true"
style={{
position: 'absolute',
left: 0,
top: 0,
bottom: 0,
width: '3px',
backgroundImage:
'linear-gradient(180deg, transparent 0%, rgba(60,160,60,0.4) 20%, rgba(80,180,60,0.6) 50%, rgba(60,160,60,0.4) 80%, transparent 100%)',
}}
/>
{!reduced &&
leaves.map((_, i) => {
const left = 3 + ((i * 5023 + 317) % 92);
const duration = 13 + (i % 5) * 2;
const delay = (i * 0.75) % 11;
const size = 14 + (i % 3) * 5;
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: '-20px',
left: `${left}%`,
fontSize: `${size}px`,
opacity: 0.5 + (i % 3) * 0.1,
animation: `${animEarthLeafDrift} ${duration}s ease-in ${delay}s infinite`,
userSelect: 'none',
}}
>
{leafEmoji[i % leafEmoji.length]}
</div>
);
})}
</>
);
}
function DeepSpaceOverlay({ reduced }: { reduced: boolean }) {
const stars = Array.from({ length: 24 });
return (
<>
{/* Deep space ambient */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundColor: 'rgba(0,0,8,0.3)',
backgroundImage: [
'radial-gradient(ellipse at 30% 40%, rgba(80,0,180,0.10) 0%, transparent 50%)',
'radial-gradient(ellipse at 70% 60%, rgba(0,60,180,0.10) 0%, transparent 50%)',
'radial-gradient(ellipse at 50% 20%, rgba(120,0,200,0.07) 0%, transparent 40%)',
].join(','),
}}
/>
{/* Warp streak particles emanating from center */}
{!reduced &&
stars.map((_, i) => {
const angle = (i / stars.length) * 360;
const duration = 2.5 + (i % 5) * 0.4;
const delay = (i * 0.18) % 2.5;
const period = 3 + (i % 4) * 0.5;
const size = 1 + (i % 3);
const colors = ['rgba(200,180,255,0.9)', 'rgba(150,200,255,0.8)', 'rgba(255,255,255,0.7)'];
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
left: '50%',
top: '50%',
width: `${80 + i * 6}px`,
height: `${size}px`,
backgroundColor: colors[i % colors.length],
transformOrigin: '0 50%',
transform: `rotate(${angle}deg)`,
boxShadow: `0 0 ${size * 2}px ${colors[i % colors.length]}`,
animation: `${animWarp} ${duration}s ease-out ${delay}s ${period}s infinite`,
opacity: 0,
}}
/>
);
})}
</>
);
}
function ArcadeOverlay({ reduced }: { reduced: boolean }) {
return (
<>
{/* CRT scanlines */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'repeating-linear-gradient(0deg, rgba(0,0,0,0.12) 0px, rgba(0,0,0,0.12) 1px, transparent 1px, transparent 3px)',
animation: reduced ? 'none' : `${animScanline} 3s ease-in-out infinite`,
}}
/>
{/* Pixel corner decorations */}
{(['0,0', '0,auto', 'auto,0', 'auto,auto'] as const).map((corner, i) => {
const [t, b] = corner.split(',');
return (
<div
key={i}
aria-hidden="true"
style={{
position: 'absolute',
top: t === '0' ? '8px' : undefined,
bottom: b === '0' ? '8px' : undefined,
left: i % 2 === 0 ? '8px' : undefined,
right: i % 2 === 1 ? '8px' : undefined,
fontFamily: 'monospace',
fontSize: '11px',
color: 'rgba(0,255,136,0.5)',
letterSpacing: '0.05em',
animation: reduced ? 'none' : `${animPixelBlink} ${1 + i * 0.3}s step-end infinite`,
userSelect: 'none',
}}
>
{['[■]', '[■]', '[■]', '[■]'][i]}
</div>
);
})}
{/* "INSERT COIN" prompt */}
<div
aria-hidden="true"
style={{
position: 'absolute',
bottom: '16px',
left: '50%',
transform: 'translateX(-50%)',
fontFamily: 'monospace',
fontSize: '12px',
letterSpacing: '0.2em',
color: 'rgba(255,220,0,0.4)',
animation: reduced ? 'none' : `${animPixelBlink} 1.2s step-end infinite`,
userSelect: 'none',
whiteSpace: 'nowrap',
}}
>
INSERT COIN
</div>
{/* Vignette */}
<div
aria-hidden="true"
style={{
position: 'absolute',
inset: 0,
backgroundImage:
'radial-gradient(ellipse at 50% 50%, transparent 60%, rgba(0,0,0,0.35) 100%)',
}}
/>
</>
);
}
// ─── Wrapper ──────────────────────────────────────────────────────────────────
function SeasonalOverlay({
theme,
reduced,
}: {
theme: SeasonTheme;
reduced: boolean;
}) {
const overlayMap: Record<SeasonTheme, React.ReactNode> = {
halloween: <HalloweenOverlay reduced={reduced} />,
christmas: <ChristmasOverlay reduced={reduced} />,
newyear: <NewYearOverlay reduced={reduced} />,
autumn: <AutumnOverlay reduced={reduced} />,
aprilfools: <AprilFoolsOverlay reduced={reduced} />,
lunar: <LunarNewYearOverlay reduced={reduced} />,
valentines: <ValentinesOverlay reduced={reduced} />,
stpatricks: <StPatricksOverlay reduced={reduced} />,
earthday: <EarthDayOverlay reduced={reduced} />,
deepspace: <DeepSpaceOverlay reduced={reduced} />,
arcade: <ArcadeOverlay reduced={reduced} />,
};
return (
<div
aria-hidden="true"
style={{
position: 'fixed',
inset: 0,
pointerEvents: 'none',
zIndex: 9997,
overflow: 'hidden',
}}
>
{overlayMap[theme]}
</div>
);
}
export function SeasonalEffect() {
const settings = useAtomValue(settingsAtom);
const reduced =
typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const theme = useMemo<SeasonTheme | null>(() => {
const override = settings.seasonalThemeOverride ?? 'auto';
if (override === 'off') return null;
if (override === 'auto') return getActiveSeason(new Date());
return override as SeasonTheme;
}, [settings.seasonalThemeOverride]);
if (!theme) return null;
return <SeasonalOverlay theme={theme} reduced={reduced} />;
}
@@ -19,6 +19,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
import { UserPresence } from '../../hooks/useUserPresence';
import { AvatarPresence, PresenceBadge } from '../presence';
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
import { ImageViewer } from '../image-viewer';
import { stopPropagation } from '../../utils/keyboard';
import { useSetting } from '../../state/hooks/settings';
@@ -47,6 +48,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
)}
</div>
<div className={css.UserHeroAvatarContainer}>
<AvatarDecoration userId={userId} inset={20}>
<AvatarPresence
className={css.UserAvatarContainer}
badge={
@@ -68,6 +70,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
/>
</Avatar>
</AvatarPresence>
</AvatarDecoration>
{viewAvatar && (
<Overlay open backdrop={<OverlayBackdrop />}>
<OverlayCenter>
@@ -1,5 +1,5 @@
import { Box, Button, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
import React, { useCallback, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
@@ -32,6 +32,7 @@ import { CreatorChip } from './CreatorChip';
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
import { DirectCreateSearchParams } from '../../pages/paths';
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
import { useUserNotes, USER_NOTE_MAX_LENGTH } from '../../hooks/useUserNotes';
type VerifyDeviceButtonProps = {
userId: string;
@@ -207,6 +208,65 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
);
}
function UserPrivateNotes({ userId }: { userId: string }) {
const { getNote, setNote } = useUserNotes();
const [draft, setDraft] = useState(() => getNote(userId));
const [saving, setSaving] = useState(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Sync if account data arrives after mount
useEffect(() => {
setDraft(getNote(userId));
}, [getNote, userId]);
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
const val = e.target.value;
setDraft(val);
clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(async () => {
setSaving(true);
await setNote(userId, val);
setSaving(false);
}, 800);
};
useEffect(() => () => clearTimeout(saveTimer.current), []);
const charsLeft = USER_NOTE_MAX_LENGTH - draft.length;
return (
<Box direction="Column" gap="200">
<Box justifyContent="SpaceBetween" alignItems="Center">
<Text size="L400">Private Note</Text>
<Text size="T200" style={{ opacity: 0.5 }}>
{saving ? 'Saving…' : charsLeft < 100 ? `${charsLeft} left` : ''}
</Text>
</Box>
<textarea
value={draft}
onChange={handleChange}
maxLength={USER_NOTE_MAX_LENGTH}
placeholder="Notes only visible to you…"
rows={3}
style={{
width: '100%',
resize: 'vertical',
background: 'var(--bg-surface-variant)',
color: 'inherit',
border: '1px solid var(--border-interactive)',
borderRadius: '6px',
padding: '8px 10px',
fontSize: '14px',
fontFamily: 'inherit',
lineHeight: 1.5,
boxSizing: 'border-box',
outline: 'none',
}}
/>
</Box>
);
}
type UserRoomProfileProps = {
userId: string;
};
@@ -331,6 +391,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
canBan={canBanUser && membership !== Membership.Ban}
/>
{showEncryption && userId !== myUserId && <UserDeviceSessions userId={userId} />}
{userId !== myUserId && <UserPrivateNotes userId={userId} />}
</Box>
</Box>
);
+3
View File
@@ -35,6 +35,7 @@ import { useResizeObserver } from '../../hooks/useResizeObserver';
import { stopPropagation } from '../../utils/keyboard';
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
type CallControlsProps = {
callEmbed: CallEmbed;
@@ -71,6 +72,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
useCallControlState(callEmbed.control);
useAfkAutoMute(callEmbed);
const [cords, setCords] = useState<RectCords>();
const [shareConfirm, setShareConfirm] = useState(false);
useEffect(() => {
+24 -6
View File
@@ -10,6 +10,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { StateEvent } from '../../../types/matrix/room';
import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useStateEvent } from '../../hooks/useStateEvent';
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
import { CallMemberRenderer } from './CallMemberCard';
import * as css from './styles.css';
import { CallControls } from './CallControls';
@@ -74,6 +76,14 @@ function AlreadyInCallMessage() {
);
}
function ChannelFullMessage({ current, max }: { current: number; max: number }) {
return (
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
Channel Full ({current}/{max}) Wait for someone to leave before joining.
</Text>
);
}
function CallPrescreen() {
const mx = useMatrixClient();
const room = useRoom();
@@ -96,7 +106,14 @@ function CallPrescreen() {
const callEmbed = useCallEmbed();
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
const canJoin = hasPermission && livekitSupported && rtcSupported;
// Voice channel user limit (io.lotus.voice_limit). 0 / absent means no limit.
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
// A user already counted in the session is rejoining and should not be blocked.
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull;
return (
<Scroll variant="Surface" hideTrack>
@@ -117,16 +134,17 @@ function CallPrescreen() {
<CallMemberRenderer members={callMembers} />
<PrescreenControls canJoin={canJoin} />
<Box className={css.PrescreenMessage} alignItems="Center">
{!inOtherCall &&
(hasPermission ? (
{!inOtherCall && !hasPermission && <NoPermissionMessage />}
{!inOtherCall && hasPermission && channelFull && (
<ChannelFullMessage current={callMembers.length} max={maxUsers} />
)}
{!inOtherCall && hasPermission && !channelFull && (
<JoinMessage
hasParticipant={hasParticipant}
livekitSupported={livekitSupported}
rtcSupported={rtcSupported}
/>
) : (
<NoPermissionMessage />
))}
)}
{inOtherCall && <AlreadyInCallMessage />}
</Box>
</Box>
@@ -0,0 +1,98 @@
import React, { FormEventHandler, useCallback } from 'react';
import { Box, Button, color, Input, Spinner, Text } from 'folds';
import { MatrixError } from 'matrix-js-sdk';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../../room-settings/styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useRoom } from '../../../hooks/useRoom';
import { StateEvent } from '../../../../types/matrix/room';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { useStateEvent } from '../../../hooks/useStateEvent';
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
export type VoiceLimitContent = {
max_users?: number;
};
type RoomVoiceLimitProps = {
permissions: RoomPermissionsAPI;
};
export function RoomVoiceLimit({ permissions }: RoomVoiceLimitProps) {
const mx = useMatrixClient();
const room = useRoom();
const canEdit = permissions.stateEvent(StateEvent.LotusVoiceLimit, mx.getSafeUserId());
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
const [submitState, submit] = useAsyncCallback(
useCallback(
async (value: number) => {
const content: VoiceLimitContent = value > 0 ? { max_users: value } : {};
await mx.sendStateEvent(room.roomId, StateEvent.LotusVoiceLimit as any, content);
},
[mx, room.roomId],
),
);
const submitting = submitState.status === AsyncStatus.Loading;
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
evt.preventDefault();
const target = evt.target as HTMLFormElement;
const limitInput = target.elements.namedItem('limitInput') as HTMLInputElement | null;
if (!limitInput) return;
const parsed = parseInt(limitInput.value, 10);
const value = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
submit(value);
};
return (
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="Voice Channel Limit"
description="Set the maximum number of participants allowed in this room's voice call. Set to 0 for no limit. Enforced on the server for all Matrix clients."
>
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center">
<Box style={{ maxWidth: '100px' }} grow="Yes">
<Input
key={maxUsers}
name="limitInput"
defaultValue={maxUsers}
type="number"
min={0}
max={99}
size="300"
variant="Secondary"
radii="300"
readOnly={!canEdit}
disabled={!canEdit}
/>
</Box>
<Button
type="submit"
size="300"
variant="Primary"
fill="Solid"
radii="300"
disabled={!canEdit || submitting}
before={submitting ? <Spinner size="100" variant="Primary" fill="Solid" /> : undefined}
>
<Text size="B300">Save</Text>
</Button>
</Box>
{submitState.status === AsyncStatus.Error && (
<Text style={{ color: color.Critical.Main }} size="T200">
{(submitState.error as MatrixError).message}
</Text>
)}
</SettingTile>
</SequenceCard>
);
}
@@ -6,3 +6,4 @@ export * from './RoomProfile';
export * from './RoomPublish';
export * from './RoomShareInvite';
export * from './RoomUpgrade';
export * from './RoomVoiceLimit';
+179
View File
@@ -0,0 +1,179 @@
export const DECORATION_CDN =
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
export type AvatarDecoration = {
slug: string;
name: string;
};
export type DecorationCategory = {
id: string;
label: string;
decorations: AvatarDecoration[];
};
export const DECORATION_CATEGORIES: DecorationCategory[] = [
{
id: 'gaming',
label: 'Gaming',
decorations: [
{ slug: 'slither_n_snack', name: "Slither 'n Snack" },
{ slug: 'joystick', name: 'Joystick' },
{ slug: 'clyde_invaders', name: 'Space Invaders' },
{ slug: 'mallow_jump', name: 'Mallow Jump' },
{ slug: 'hot_shot', name: 'Hot Shot' },
{ slug: 'pipedream', name: 'Pipedream' },
{ slug: 'disxcore_headset', name: 'Gaming Headset' },
{ slug: 'pink_headset', name: 'Pink Headset' },
{ slug: 'green_headset', name: 'Green Headset' },
{ slug: 'feelin_awe', name: "Feelin' Awe" },
{ slug: 'feelin_panic', name: "Feelin' Panic" },
{ slug: 'feelin_nervous', name: "Feelin' Nervous" },
{ slug: 'feelin_scrumptious', name: "Feelin' Scrumptious" },
],
},
{
id: 'cyber',
label: 'Cyber',
decorations: [
{ slug: 'cybernetic', name: 'Cybernetic' },
{ slug: 'glitch', name: 'Glitch' },
{ slug: 'digital_sunrise', name: 'Digital Sunrise' },
{ slug: 'implant', name: 'Implant' },
{ slug: 'blue_futuristic_ui', name: 'Futuristic UI (Blue)' },
{ slug: 'green_futuristic_ui', name: 'Futuristic UI (Green)' },
{ slug: 'pink_futuristic_ui', name: 'Futuristic UI (Pink)' },
{ slug: 'chromawave', name: 'Chromawave' },
{ slug: 'hex_lights', name: 'Hex Lights' },
],
},
{
id: 'space',
label: 'Space',
decorations: [
{ slug: 'stardust', name: 'Stardust' },
{ slug: 'black_hole', name: 'Black Hole' },
{ slug: 'constellations', name: 'Constellations' },
{ slug: 'solar_orbit', name: 'Solar Orbit' },
{ slug: 'astronaut_helmet', name: 'Astronaut Helmet' },
{ slug: 'ufo', name: 'UFO' },
{ slug: 'warp_helmet', name: 'Warp Helmet' },
{ slug: 'aurora', name: 'Aurora' },
],
},
{
id: 'fantasy',
label: 'Fantasy',
decorations: [
{ slug: 'kitsune', name: 'Kitsune' },
{ slug: 'phoenix', name: 'Phoenix' },
{ slug: 'unicorn', name: 'Unicorn' },
{ slug: 'flaming_sword', name: 'Flaming Sword' },
{ slug: 'skull_medallion', name: 'Skull Medallion' },
{ slug: 'glowing_runes', name: 'Glowing Runes' },
{ slug: 'eldritch_ring', name: 'Eldritch Ring' },
{ slug: 'arcane_sigil', name: 'Arcane Sigil' },
{ slug: 'midnight_sorceress', name: 'Midnight Sorceress' },
{ slug: 'deaths_edge', name: "Death's Edge" },
{ slug: 'malefic_crown', name: 'Malefic Crown' },
{ slug: 'spirit_embers', name: 'Spirit Embers' },
{ slug: 'defensive_shield', name: 'Defensive Shield' },
{ slug: 'magical_potion', name: 'Magical Potion' },
{ slug: 'wizards_staff', name: "Wizard's Staff" },
{ slug: 'crystal_ball_purple', name: 'Crystal Ball (Purple)' },
{ slug: 'crystal_ball_blue', name: 'Crystal Ball (Blue)' },
{ slug: 'owlbear_cub', name: 'Owlbear Cub' },
{ slug: 'owlbear_cub_snowy', name: 'Snowy Owlbear Cub' },
{ slug: 'baby_displacer_beast', name: 'Baby Displacer Beast' },
{ slug: 'dice_violet', name: 'Violet Dice' },
{ slug: 'dice_azure', name: 'Azure Dice' },
],
},
{
id: 'elements',
label: 'Elements',
decorations: [
{ slug: 'fire', name: 'Fire' },
{ slug: 'water', name: 'Water' },
{ slug: 'air', name: 'Air' },
{ slug: 'earth', name: 'Earth' },
{ slug: 'lightning', name: 'Lightning' },
{ slug: 'balance', name: 'Balance' },
{ slug: 'ki_energy', name: 'Ki Energy' },
],
},
{
id: 'japanese',
label: 'Japanese',
decorations: [
{ slug: 'kabuto', name: 'Kabuto' },
{ slug: 'oni_mask', name: 'Oni Mask' },
{ slug: 'sakura_warrior', name: 'Sakura Warrior' },
{ slug: 'sakura_ink', name: 'Sakura Ink' },
{ slug: 'shurikens_mask', name: "Shuriken's Mask" },
{ slug: 'straw_hat', name: 'Straw Hat' },
],
},
{
id: 'nature',
label: 'Nature',
decorations: [
{ slug: 'lotus_flower', name: 'Lotus Flower' },
{ slug: 'koi_pond', name: 'Koi Pond' },
{ slug: 'sakura', name: 'Sakura' },
{ slug: 'sakura_pink', name: 'Pink Sakura' },
{ slug: 'fall_leaves', name: 'Fall Leaves' },
{ slug: 'fall_leaves_scarlet', name: 'Scarlet Leaves' },
{ slug: 'butterflies', name: 'Butterflies' },
{ slug: 'honeyblossom', name: 'Honeyblossom' },
{ slug: 'dandelion_duo', name: 'Dandelion Duo' },
{ slug: 'lunar_lanterns', name: 'Lunar Lanterns' },
{ slug: 'firecrackers', name: 'Firecrackers' },
{ slug: 'dragons_smile', name: "Dragon's Smile" },
],
},
{
id: 'spooky',
label: 'Spooky',
decorations: [
{ slug: 'candlelight', name: 'Candlelight' },
{ slug: 'candlelight_crimson', name: 'Crimson Candlelight' },
{ slug: 'witch_hat_midnight', name: 'Midnight Witch Hat' },
{ slug: 'witch_hat_plum', name: 'Plum Witch Hat' },
{ slug: 'hood_dark', name: 'Dark Hood' },
{ slug: 'hood_crimson', name: 'Crimson Hood' },
{ slug: 'zombie_food', name: 'Zombie Food' },
{ slug: 'bloodthirsty', name: 'Bloodthirsty' },
{ slug: 'bloodthirsty_gold', name: 'Bloodthirsty (Gold)' },
{ slug: 'jack_o_lantern', name: "Jack-o'-Lantern" },
{ slug: 'pumpkin_spice', name: 'Pumpkin Spice' },
{ slug: 'spooky_cat_ears', name: 'Spooky Cat Ears' },
{ slug: 'ghosts', name: 'Ghosts' },
],
},
{
id: 'cozy',
label: 'Cozy',
decorations: [
{ slug: 'cozy_cat', name: 'Cozy Cat' },
{ slug: 'rainy_mood', name: 'Rainy Mood' },
{ slug: 'oasis', name: 'Oasis' },
{ slug: 'cozy_headphones', name: 'Cozy Headphones' },
{ slug: 'doodling', name: 'Doodling' },
{ slug: 'fox_hat', name: 'Fox Hat' },
{ slug: 'fox_hat_chestnut', name: 'Chestnut Fox Hat' },
{ slug: 'fox_hat_snow', name: 'Snow Fox Hat' },
{ slug: 'cat_ears', name: 'Cat Ears' },
{ slug: 'frog_hat', name: 'Frog Hat' },
{ slug: 'polar_bear_hat', name: 'Polar Bear Hat' },
],
},
];
export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap(
(c) => c.decorations,
);
export function decorationUrl(slug: string): string {
return `${DECORATION_CDN}/${slug}.png`;
}
+31 -31
View File
@@ -197,11 +197,11 @@ const DARK: Record<ChatBackground, CSSProperties> = {
].join(','),
},
// Animated: Matrix digital rain — scrolling vertical green stripes
// Animated: Matrix digital rain — scrolling stripe columns + phosphor glow flicker
'anim-rain': {
backgroundColor: '#010804',
backgroundImage: [
'repeating-linear-gradient(180deg, rgba(0,255,136,0.13) 0px, rgba(0,255,136,0.13) 1px, transparent 1px, transparent 20px)',
'repeating-linear-gradient(180deg, rgba(0,255,136,0.16) 0px, rgba(0,255,136,0.16) 1px, transparent 1px, transparent 20px)',
'repeating-linear-gradient(180deg, rgba(0,255,136,0.07) 0px, rgba(0,255,136,0.07) 1px, transparent 1px, transparent 8px)',
].join(','),
backgroundSize: '40px 200px, 12px 200px',
@@ -209,7 +209,7 @@ const DARK: Record<ChatBackground, CSSProperties> = {
animation: `${animRainKeyframe} 8s linear infinite`,
},
// Animated: drifting star field — three layers at different speeds
// Animated: drifting star field — three seamlessly-tiling layers at different speeds
'anim-stars': {
backgroundColor: '#050510',
backgroundImage: [
@@ -219,10 +219,10 @@ const DARK: Record<ChatBackground, CSSProperties> = {
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
animation: `${animStarsDriftKeyframe} 25s linear infinite`,
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
},
// Animated: neon grid pulse — grid lines that expand/contract
// Animated: neon grid pulse — size breathe + independent brightness oscillation
'anim-pulse': {
backgroundColor: '#030508',
backgroundImage: [
@@ -235,31 +235,31 @@ const DARK: Record<ChatBackground, CSSProperties> = {
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
},
// Animated: aurora borealis — slowly drifting gradient bands
// Animated: aurora borealis — four bands each travel an independent path
'anim-aurora': {
backgroundColor: '#020a10',
backgroundImage: [
'radial-gradient(ellipse at 20% 30%, rgba(0,255,136,0.10) 0%, transparent 55%)',
'radial-gradient(ellipse at 80% 70%, rgba(0,100,255,0.10) 0%, transparent 55%)',
'radial-gradient(ellipse at 50% 10%, rgba(191,95,255,0.08) 0%, transparent 50%)',
'radial-gradient(ellipse at 60% 90%, rgba(0,212,255,0.08) 0%, transparent 50%)',
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,255,136,0.12) 0%, transparent 70%)',
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,100,255,0.12) 0%, transparent 70%)',
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(191,95,255,0.09) 0%, transparent 65%)',
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,212,255,0.09) 0%, transparent 65%)',
].join(','),
backgroundSize: '200% 200%',
backgroundPosition: '0% 0%',
animation: `${animAuroraKeyframe} 20s ease-in-out infinite`,
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
},
// Animated: fireflies — three layers of glowing dots at different speeds
// Animated: fireflies — drift + brightness glow + opacity blink at prime periods
'anim-fireflies': {
backgroundColor: '#030508',
backgroundImage: [
'radial-gradient(circle, rgba(255,220,50,0.55) 1.5px, rgba(255,160,0,0.15) 3px, transparent 4px)',
'radial-gradient(circle, rgba(255,200,30,0.45) 1px, rgba(255,140,0,0.12) 2.5px, transparent 3.5px)',
'radial-gradient(circle, rgba(255,240,100,0.35) 1px, transparent 2px)',
'radial-gradient(circle, rgba(255,220,50,0.7) 1.5px, rgba(255,160,0,0.18) 3px, transparent 4px)',
'radial-gradient(circle, rgba(255,200,30,0.55) 1px, rgba(255,140,0,0.14) 2.5px, transparent 3.5px)',
'radial-gradient(circle, rgba(255,240,100,0.4) 1px, transparent 2px)',
].join(','),
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
backgroundPosition: '0 0, 120px 80px, 60px 140px',
animation: `${animFirefliesKeyframe} 15s linear infinite`,
animation: `${animFirefliesKeyframe} 30s linear infinite`,
},
};
@@ -423,7 +423,7 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
'anim-rain': {
backgroundColor: '#f0fff4',
backgroundImage: [
'repeating-linear-gradient(180deg, rgba(0,160,80,0.14) 0px, rgba(0,160,80,0.14) 1px, transparent 1px, transparent 20px)',
'repeating-linear-gradient(180deg, rgba(0,160,80,0.16) 0px, rgba(0,160,80,0.16) 1px, transparent 1px, transparent 20px)',
'repeating-linear-gradient(180deg, rgba(0,160,80,0.07) 0px, rgba(0,160,80,0.07) 1px, transparent 1px, transparent 8px)',
].join(','),
backgroundSize: '40px 200px, 12px 200px',
@@ -440,7 +440,7 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
].join(','),
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
backgroundPosition: '0 0, 65px 32px, 32px 97px',
animation: `${animStarsDriftKeyframe} 25s linear infinite`,
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
},
'anim-pulse': {
@@ -458,26 +458,26 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
'anim-aurora': {
backgroundColor: '#f0f8f4',
backgroundImage: [
'radial-gradient(ellipse at 20% 30%, rgba(0,160,80,0.12) 0%, transparent 55%)',
'radial-gradient(ellipse at 80% 70%, rgba(0,80,200,0.12) 0%, transparent 55%)',
'radial-gradient(ellipse at 50% 10%, rgba(140,60,220,0.09) 0%, transparent 50%)',
'radial-gradient(ellipse at 60% 90%, rgba(0,160,200,0.09) 0%, transparent 50%)',
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,160,80,0.13) 0%, transparent 70%)',
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,80,200,0.13) 0%, transparent 70%)',
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(140,60,220,0.10) 0%, transparent 65%)',
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,160,200,0.10) 0%, transparent 65%)',
].join(','),
backgroundSize: '200% 200%',
backgroundPosition: '0% 0%',
animation: `${animAuroraKeyframe} 20s ease-in-out infinite`,
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
},
'anim-fireflies': {
backgroundColor: '#fffdf0',
backgroundImage: [
'radial-gradient(circle, rgba(180,120,0,0.55) 1.5px, rgba(160,90,0,0.15) 3px, transparent 4px)',
'radial-gradient(circle, rgba(160,100,0,0.45) 1px, rgba(140,80,0,0.12) 2.5px, transparent 3.5px)',
'radial-gradient(circle, rgba(200,140,0,0.35) 1px, transparent 2px)',
'radial-gradient(circle, rgba(180,120,0,0.70) 1.5px, rgba(160,90,0,0.18) 3px, transparent 4px)',
'radial-gradient(circle, rgba(160,100,0,0.55) 1px, rgba(140,80,0,0.14) 2.5px, transparent 3.5px)',
'radial-gradient(circle, rgba(200,140,0,0.40) 1px, transparent 2px)',
].join(','),
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
backgroundPosition: '0 0, 120px 80px, 60px 140px',
animation: `${animFirefliesKeyframe} 15s linear infinite`,
animation: `${animFirefliesKeyframe} 30s linear infinite`,
},
};
@@ -50,7 +50,10 @@ export const useLocalMessageSearch = () => {
encryptedRoomsCount += 1;
const events = room.getLiveTimeline().getEvents();
const events = room
.getUnfilteredTimelineSet()
.getTimelines()
.flatMap((tl) => tl.getEvents());
if (events.length === 0) continue;
searchedRoomsCount += 1;
@@ -13,6 +13,7 @@ import {
RoomPublish,
RoomShareInvite,
RoomUpgrade,
RoomVoiceLimit,
} from '../../common-settings/general';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
@@ -54,6 +55,10 @@ export function General({ requestClose }: GeneralProps) {
<RoomEncryption permissions={permissions} />
<RoomPublish permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Voice</Text>
<RoomVoiceLimit permissions={permissions} />
</Box>
<Box direction="Column" gap="100">
<Text size="L400">Addresses</Text>
<RoomPublishedAddresses permissions={permissions} />
+5
View File
@@ -69,6 +69,7 @@ import { useCrossSigningActive } from '../../hooks/useCrossSigning';
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
import { useUserPresence } from '../../hooks/useUserPresence';
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
type MemberDrawerHeaderProps = {
room: Room;
@@ -150,6 +151,7 @@ function MemberItem({
radii="400"
onClick={onClick}
before={
<AvatarDecoration userId={member.userId}>
<PresenceRingAvatar userId={member.userId}>
<Avatar size="200">
<UserAvatar
@@ -160,6 +162,7 @@ function MemberItem({
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
}
after={
<>
@@ -442,6 +445,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
gap="200"
style={{ padding: `0 ${config.space.S200}` }}
>
<AvatarDecoration userId={knockMember.userId}>
<PresenceRingAvatar userId={knockMember.userId}>
<Avatar size="200">
<UserAvatar
@@ -452,6 +456,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
<Box grow="Yes" direction="Column">
<Text size="T400" truncate>
{knockName}
+28 -3
View File
@@ -209,6 +209,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
const showFormat = composerToolbarButtons?.showFormat ?? true;
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
const showSticker = composerToolbarButtons?.showSticker ?? true;
const showGif = composerToolbarButtons?.showGif ?? true;
const showLocation = composerToolbarButtons?.showLocation ?? true;
const showPoll = composerToolbarButtons?.showPoll ?? true;
const showVoice = composerToolbarButtons?.showVoice ?? true;
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
const [locating, setLocating] = React.useState(false);
const [locationError, setLocationError] = React.useState<string | null>(null);
const handleShareLocation = () => {
@@ -933,6 +942,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
}
after={
<>
{showFormat && (
<IconButton
variant="SurfaceVariant"
size="300"
@@ -943,6 +953,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
>
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
</IconButton>
)}
{(showEmoji || showSticker) && (
<UseStateProvider initial={undefined}>
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
<PopOut
@@ -978,7 +990,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
</React.Suspense>
}
>
{!hideStickerBtn && (
{showSticker && !hideStickerBtn && (
<IconButton
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
aria-label="Insert sticker"
@@ -993,6 +1005,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
/>
</IconButton>
)}
{showEmoji && (
<IconButton
ref={emojiBtnRef}
aria-label="Insert emoji"
@@ -1007,14 +1020,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Icon
src={Icons.Smile}
filled={
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
hideStickerBtn
? !!emojiBoardTab
: emojiBoardTab === EmojiBoardTab.Emoji
}
/>
</IconButton>
)}
</PopOut>
)}
</UseStateProvider>
{!!gifApiKey && (
)}
{!!gifApiKey && showGif && (
<UseStateProvider initial={false}>
{(gifOpen: boolean, setGifOpen) => (
<PopOut
@@ -1093,6 +1110,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
{locationError}
</Text>
)}
{showLocation && (
<IconButton
onClick={handleShareLocation}
disabled={locating}
@@ -1108,6 +1126,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
<Icon src={Icons.SpaceGlobe} size="100" />
)}
</IconButton>
)}
{showPoll && (
<IconButton
onClick={() => setPollOpen(true)}
aria-label="Create poll"
@@ -1118,6 +1138,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
>
<Icon src={Icons.OrderList} size="100" />
</IconButton>
)}
{showVoice && (
<VoiceMessageRecorder
onSend={handleVoiceSend}
onError={(err) => {
@@ -1125,6 +1147,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
setTimeout(() => setLocationError(null), 4000);
}}
/>
)}
{charCount > 0 && (
<Text
size="T200"
@@ -1141,6 +1164,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
{charCount}
</Text>
)}
{showSchedule && (
<IconButton
onClick={handleScheduleClick}
variant="SurfaceVariant"
@@ -1151,6 +1175,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
>
<Icon src={Icons.Clock} size="100" />
</IconButton>
)}
<IconButton
onClick={submit}
variant="SurfaceVariant"
+8 -2
View File
@@ -62,6 +62,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const [glassmorphismSidebar] = useSetting(settingsAtom, 'glassmorphismSidebar');
const theme = useTheme();
const isDark = theme.kind === ThemeKind.Dark;
@@ -97,14 +98,19 @@ export function RoomView({ eventId }: { eventId?: string }) {
),
);
// When glassmorphism is active, document.body already carries the background so the
// sidebar blur has something to work through. Skip applying it here to avoid running
// the same CSS animation twice (one per layer = double GPU work).
const chatBgStyle = useMemo(
() =>
getChatBg(
glassmorphismSidebar
? {}
: getChatBg(
lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground,
isDark,
pauseAnimations,
),
[chatBackground, lotusTerminal, isDark, pauseAnimations],
[chatBackground, lotusTerminal, isDark, pauseAnimations, glassmorphismSidebar],
);
return (
+37 -4
View File
@@ -73,6 +73,7 @@ import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { webRTCSupported } from '../../utils/rtc';
import { MediaGallery } from './MediaGallery';
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
type RoomMenuProps = {
room: Room;
@@ -423,7 +424,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const pinnedEvents = useRoomPinnedEvents(room);
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
const encryptedRoom = !!encryptionEvent;
const _encryptedRoom = !!encryptionEvent;
const avatarMxc = useRoomAvatar(room, direct);
const name = useLocalRoomName(room);
const topic = useRoomTopic(room);
@@ -433,6 +434,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
const [galleryOpen, setGalleryOpen] = useState(false);
const pendingKnocks = usePendingKnocks(room);
const handleSearchClick = () => {
const searchParams: _SearchPathSearchParams = {
@@ -558,7 +560,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</Box>
<Box shrink="No">
{!encryptedRoom && (
{
<TooltipProvider
position="Bottom"
offset={4}
@@ -579,7 +581,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
</IconButton>
)}
</TooltipProvider>
)}
}
<TooltipProvider
position="Bottom"
offset={4}
@@ -682,14 +684,45 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
}
>
{(triggerRef) => (
<div style={{ position: 'relative', display: 'inline-flex' }}>
<IconButton
fill="None"
ref={triggerRef}
onClick={handleMemberToggle}
aria-label="Toggle member list"
aria-label={
pendingKnocks.length > 0
? `Toggle member list, ${pendingKnocks.length} pending join request${pendingKnocks.length > 1 ? 's' : ''}`
: 'Toggle member list'
}
>
<Icon size="400" src={Icons.User} />
</IconButton>
{pendingKnocks.length > 0 && (
<Badge
aria-hidden
variant="Warning"
fill="Solid"
radii="Pill"
size="200"
style={{
position: 'absolute',
top: '-2px',
right: '-2px',
pointerEvents: 'none',
fontSize: '9px',
minWidth: '14px',
height: '14px',
padding: '0 3px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
lineHeight: 1,
}}
>
{pendingKnocks.length > 9 ? '9+' : pendingKnocks.length}
</Badge>
)}
</div>
)}
</TooltipProvider>
)}
@@ -110,13 +110,14 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
const res = (await fetchRes.json()) as EditHistoryResponse;
const rawEvents = res.chunk ?? [];
const events = rawEvents
const events = await Promise.all(
rawEvents
.filter(isRawEditEvent)
.sort((a, b) => a.origin_server_ts - b.origin_server_ts)
.map((raw) => {
.map(async (raw) => {
const existing = room.findEventById(raw.event_id);
if (existing) return existing;
return new MatrixEvent({
const evt = new MatrixEvent({
type: raw.type,
content: raw.content,
origin_server_ts: raw.origin_server_ts,
@@ -124,7 +125,12 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
room_id: roomId,
sender: mEvent.getSender() ?? '',
});
});
if (evt.isEncrypted()) {
await mx.decryptEventIfNeeded(evt);
}
return evt;
}),
);
return { events, hasMore: !!res.next_batch };
}, [mx, roomId, eventId, room, mEvent]),
@@ -83,6 +83,7 @@ import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
import { ForwardMessageDialog } from './ForwardMessageDialog';
import { useBookmarks } from '../../../hooks/useBookmarks';
import { PresenceRingAvatar } from '../../../components/presence';
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
// Delivery status indicator for own messages
function DeliveryStatus({
@@ -874,6 +875,7 @@ export const Message = React.memo(
<AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
>
<AvatarDecoration userId={senderId}>
<PresenceRingAvatar userId={senderId}>
<Avatar
className={css.MessageAvatar}
@@ -895,6 +897,7 @@ export const Message = React.memo(
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
</AvatarBase>
);
+38 -14
View File
@@ -48,6 +48,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload';
import { CompactUploadCardRenderer } from '../../../components/upload-card';
import { useCapabilities } from '../../../hooks/useCapabilities';
import { useUserPresence } from '../../../hooks/useUserPresence';
import { ProfileDecoration } from './ProfileDecoration';
import { EmojiBoard } from '../../../components/emoji-board';
type ProfileProps = {
@@ -317,6 +318,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
}
const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
const CLEAR_AFTER_OPTIONS = [
{ label: 'Never', value: '0' },
@@ -344,7 +346,9 @@ function ProfileStatus() {
const userId = mx.getUserId()!;
const presence = useUserPresence(userId);
const [statusMsg, setStatusMsg] = useState<string>(presence?.status ?? '');
const [statusMsg, setStatusMsg] = useState<string>(
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
);
const [clearAfter, setClearAfter] = useState('0');
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
@@ -354,16 +358,22 @@ function ProfileStatus() {
return stored ? parseInt(stored, 10) : 0;
});
// Sync input when another device changes the status
// Sync input when another device changes the status.
// Only update if the server actually has a value — ignore empty sync events
// caused by Synapse clearing status_msg on reconnect.
useEffect(() => {
setStatusMsg(presence?.status ?? '');
}, [presence?.status]);
if (presence?.status) {
setStatusMsg(presence.status);
localStorage.setItem(STATUS_MSG_KEY(userId), presence.status);
}
}, [presence?.status, userId]);
// Drive the auto-clear timer off expiryTs so re-saving cancels the old timer
useEffect(() => {
if (!expiryTs) return undefined;
const remaining = expiryTs - Date.now();
const clearStatus = () => {
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0);
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
@@ -403,6 +413,12 @@ function ProfileStatus() {
const msg = statusMsg.trim();
saveStatus(msg).catch(() => undefined);
if (msg) {
localStorage.setItem(STATUS_MSG_KEY(userId), msg);
} else {
localStorage.removeItem(STATUS_MSG_KEY(userId));
}
const delayMs = getMsFromOption(clearAfter);
if (msg && delayMs > 0) {
const ts = Date.now() + delayMs;
@@ -416,6 +432,7 @@ function ProfileStatus() {
const handleClear = () => {
setStatusMsg('');
localStorage.removeItem(STATUS_MSG_KEY(userId));
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
setExpiryTs(0);
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
@@ -722,30 +739,36 @@ function ProfileTimezone() {
const [savedTimezone, setSavedTimezone] = useState<string>('');
useEffect(() => {
const cached = mx
.getAccountData('im.lotus.timezone' as any)
?.getContent<{ timezone: string }>();
if (cached?.timezone) {
setTimezone(cached.timezone);
setSavedTimezone(cached.timezone);
}
// Also fetch from server in case account data hasn't synced yet
mx.http
.authedRequest<{ 'm.tz': string }>(Method.Get, `/profile/${encodeURIComponent(userId)}/m.tz`)
.authedRequest<{ timezone: string }>(
Method.Get,
`/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`,
)
.then((res) => {
const val = res['m.tz'] ?? '';
const val = res.timezone ?? '';
setTimezone(val);
setSavedTimezone(val);
})
.catch(() => {
setTimezone('');
setSavedTimezone('');
/* no stored timezone yet */
});
}, [mx, userId]);
const [saveState, saveTimezone] = useAsyncCallback(
useCallback(
(value: string) =>
mx.http
.authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, {
'm.tz': value,
})
.then(() => {
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }).then(() => {
setSavedTimezone(value);
}),
[mx, userId],
[mx],
),
);
const saving = saveState.status === AsyncStatus.Loading;
@@ -870,6 +893,7 @@ export function Profile() {
<ProfileStatus />
<ProfilePronouns />
<ProfileTimezone />
<ProfileDecoration />
</SequenceCard>
</Box>
);
@@ -0,0 +1,262 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Box, Text, Spinner } from 'folds';
import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
import { SettingTile } from '../../../components/setting-tile';
import {
DECORATION_CATEGORIES,
DECORATION_CDN,
decorationUrl,
} from '../../lotus/avatarDecorations';
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
const CELL_SIZE = 72;
function DecorationPreviewCell({
slug,
name,
selected,
onSelect,
}: {
slug: string;
name: string;
selected: boolean;
onSelect: (slug: string) => void;
}) {
return (
<button
type="button"
title={name}
aria-label={name}
aria-pressed={selected}
onClick={() => onSelect(slug)}
style={{
position: 'relative',
width: CELL_SIZE,
height: CELL_SIZE,
flexShrink: 0,
border: `2px solid ${selected ? 'var(--accent-cyan)' : 'transparent'}`,
borderRadius: '50%',
background: 'var(--bg-surface-variant)',
cursor: 'pointer',
padding: 0,
boxShadow: selected ? '0 0 0 1px var(--accent-cyan)' : 'none',
overflow: 'hidden',
outline: 'none',
}}
>
<img
src={`${DECORATION_CDN}/${slug}.png`}
alt={name}
loading="eager"
decoding="async"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
pointerEvents: 'none',
}}
/>
</button>
);
}
export function ProfileDecoration() {
const mx = useMatrixClient();
const userId = mx.getUserId()!;
const [current, setCurrent] = useState<string | null>(null);
const [selected, setSelected] = useState<string | null>(null);
useEffect(() => {
mx.http
.authedRequest<Record<string, string>>(
Method.Get,
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
)
.then((res) => {
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
setCurrent(val);
setSelected(val);
})
.catch(() => {
setCurrent(null);
setSelected(null);
});
}, [mx, userId]);
const [saveState, save] = useAsyncCallback(
useCallback(
async (slug: string | null) => {
await mx.http.authedRequest(
Method.Put,
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
undefined,
{ [PROFILE_FIELD]: slug ?? '' },
);
setCurrent(slug);
invalidateDecorationCache(userId);
},
[mx, userId],
),
);
const saving = saveState.status === AsyncStatus.Loading;
const hasChanges = selected !== current;
const handleSelect = (slug: string) => {
setSelected((prev) => (prev === slug ? null : slug));
};
const handleClear = () => setSelected(null);
const handleSave = () => {
if (!hasChanges || saving) return;
save(selected);
};
return (
<SettingTile
title={
<Text as="span" size="L400">
Avatar Decoration
</Text>
}
description={
<Text size="T200" priority="300">
Shown on your avatar to all Lotus Chat users.
</Text>
}
>
<Box direction="Column" gap="300">
{/* Current selection preview */}
<Box alignItems="Center" gap="300">
<div
style={{
position: 'relative',
width: CELL_SIZE,
height: CELL_SIZE,
flexShrink: 0,
borderRadius: '50%',
background: 'var(--bg-surface-variant)',
overflow: 'hidden',
}}
>
{selected && (
<img
src={decorationUrl(selected)}
alt="Selected decoration preview"
style={{
position: 'absolute',
inset: 0,
width: '100%',
height: '100%',
objectFit: 'contain',
pointerEvents: 'none',
}}
/>
)}
</div>
<Box grow="Yes" direction="Column" gap="100">
<Text size="T300">
{selected
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
(d) => d.slug === selected,
)?.name ?? selected)
: 'None'}
</Text>
{selected && (
<button
type="button"
onClick={handleClear}
style={{
background: 'none',
border: 'none',
padding: 0,
cursor: 'pointer',
color: 'var(--tc-surface-low-contrast)',
fontSize: '0.8rem',
textAlign: 'left',
}}
>
Remove
</button>
)}
</Box>
{hasChanges && (
<button
type="button"
onClick={handleSave}
disabled={saving}
style={{
padding: '6px 14px',
borderRadius: 6,
border: '1px solid var(--accent-cyan)',
background: 'transparent',
color: 'var(--accent-cyan)',
cursor: saving ? 'not-allowed' : 'pointer',
fontSize: '0.85rem',
display: 'flex',
alignItems: 'center',
gap: 6,
opacity: saving ? 0.6 : 1,
flexShrink: 0,
}}
>
{saving && <Spinner size="100" variant="Secondary" />}
{saving ? 'Saving…' : 'Save'}
</button>
)}
</Box>
{saveState.status === AsyncStatus.Error && (
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
Failed to save. Try again.
</Text>
)}
{/* Category grid */}
<div
style={{
maxHeight: 480,
overflowY: 'auto',
overflowX: 'hidden',
paddingRight: 4,
display: 'flex',
flexDirection: 'column',
gap: 24,
}}
>
{DECORATION_CATEGORIES.map((category) => (
<div key={category.id} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
<Text size="L400" style={{ opacity: 0.7 }}>
{category.label}
</Text>
<div
style={{
display: 'grid',
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
gap: 20,
}}
>
{category.decorations.map((d) => (
<DecorationPreviewCell
key={d.slug}
slug={d.slug}
name={d.name}
selected={selected === d.slug}
onSelect={handleSelect}
/>
))}
</div>
</div>
))}
</div>
</Box>
</SettingTile>
);
}
@@ -26,6 +26,7 @@ import {
PopOut,
RectCords,
Scroll,
Spinner,
Switch,
Text,
toRem,
@@ -37,6 +38,7 @@ import { SequenceCard } from '../../../components/sequence-card';
import { useSetting } from '../../../state/hooks/settings';
import {
ChatBackground,
ComposerToolbarSettings,
DateFormat,
MessageLayout,
MessageSpacing,
@@ -62,6 +64,8 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
import { useDateFormatItems } from '../../../hooks/useDateFormat';
import { SequenceCardStyle } from '../styles.css';
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
import { playCallJoinSound } from '../../../utils/callSounds';
type ThemeSelectorProps = {
themeNames: Record<string, string>;
@@ -150,6 +154,82 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
);
}
type SettingsSelectOption<T extends string> = { value: T; label: string };
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"
onClick={() => handleSelect(opt.value)}
>
<Text size="T300">{opt.label}</Text>
</MenuItem>
))}
</Box>
</Menu>
</FocusTrap>
}
/>
</>
);
}
function SystemThemePreferences() {
const themeKind = useSystemThemeKind();
const themeNames = useThemeNames();
@@ -332,6 +412,15 @@ function Appearance() {
'glassmorphismSidebar',
);
const [pauseAnimations, setPauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
const [mentionHighlightColor, setMentionHighlightColor] = useSetting(
settingsAtom,
'mentionHighlightColor',
);
const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily');
const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting(
settingsAtom,
'seasonalThemeOverride',
);
return (
<Box direction="Column" gap="100">
@@ -399,6 +488,34 @@ function Appearance() {
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Seasonal Theme"
description="Decorative overlays that activate automatically on holidays and events, or choose one manually."
after={
<SettingsSelect
value={seasonalThemeOverride ?? 'auto'}
onChange={(v) => setSeasonalThemeOverride(v as typeof seasonalThemeOverride)}
options={[
{ value: 'auto', label: '🗓 Auto (date-based)' },
{ value: 'off', label: 'Off' },
{ value: 'newyear', label: '🎆 New Year' },
{ value: 'lunar', label: '🏮 Lunar New Year' },
{ value: 'valentines', label: '💖 Valentine\'s Day' },
{ value: 'stpatricks', label: '🍀 St. Patrick\'s Day' },
{ value: 'aprilfools', label: '🃏 April Fool\'s Day' },
{ value: 'earthday', label: '🌱 Earth Day' },
{ value: 'autumn', label: '🍂 Autumn' },
{ value: 'halloween', label: '🎃 Halloween' },
{ value: 'christmas', label: '❄️ Christmas' },
{ value: 'arcade', label: '👾 Retro Arcade Day' },
{ value: 'deepspace', label: '🚀 Deep Space Week' },
]}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Profile on Every Message"
@@ -493,6 +610,66 @@ function Appearance() {
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="UI Font"
description="Font used throughout the interface."
after={
<SettingsSelect
value={fontFamily ?? 'inter'}
onChange={(v) =>
setFontFamily(v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
}
options={[
{ value: 'system', label: 'System Default' },
{ value: 'inter', label: 'Inter (default)' },
{ value: 'jetbrains-mono', label: 'JetBrains Mono' },
{ value: 'fira-code', label: 'Fira Code' },
]}
/>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="@Mention Highlight Color"
description="Color used to highlight messages that mention you. Leave empty to use the theme default."
after={
<Box alignItems="Center" gap="200">
<input
type="color"
value={mentionHighlightColor || '#4caf50'}
onChange={(e) => setMentionHighlightColor(e.target.value)}
style={{
width: '36px',
height: '28px',
cursor: 'pointer',
borderRadius: '4px',
border: 'none',
padding: '2px',
}}
/>
{mentionHighlightColor && (
<button
type="button"
onClick={() => setMentionHighlightColor('')}
style={{
background: 'none',
border: '1px solid var(--border-interactive-normal)',
borderRadius: '6px',
padding: '2px 8px',
cursor: 'pointer',
color: 'inherit',
fontSize: '12px',
}}
>
Reset
</button>
)}
</Box>
}
/>
</SequenceCard>
</Box>
);
}
@@ -855,6 +1032,25 @@ function Editor() {
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
const [composerToolbarButtons, setComposerToolbarButtons] = useSetting(
settingsAtom,
'composerToolbarButtons',
);
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
};
const TOOLBAR_CHIPS: Array<{ key: keyof ComposerToolbarSettings; label: string }> = [
{ key: 'showFormat', label: 'Format' },
{ key: 'showEmoji', label: 'Emoji' },
{ key: 'showSticker', label: 'Sticker' },
{ key: 'showGif', label: 'GIF' },
{ key: 'showLocation', label: 'Location' },
{ key: 'showPoll', label: 'Poll' },
{ key: 'showVoice', label: 'Voice' },
{ key: 'showSchedule', label: 'Schedule' },
];
return (
<Box direction="Column" gap="100">
@@ -881,6 +1077,38 @@ function Editor() {
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="300"
>
<SettingTile
title="Composer Toolbar"
description="Tap a button to show or hide it in the message composer."
/>
<Box
wrap="Wrap"
gap="200"
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
>
{TOOLBAR_CHIPS.map(({ key, label }) => {
const active = composerToolbarButtons?.[key] ?? true;
return (
<Chip
key={key}
variant={active ? 'Primary' : 'Secondary'}
outlined={active}
radii="Pill"
onClick={() => toggleToolbarButton(key)}
aria-pressed={active}
>
<Text size="T300">{label}</Text>
</Chip>
);
})}
</Box>
</SequenceCard>
</Box>
);
}
@@ -984,6 +1212,17 @@ function Calls() {
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
settingsAtom,
'callJoinLeaveSound',
);
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
setCallJoinLeaveSound(value);
if (value !== 'off') playCallJoinSound(value);
};
const pttBind = useKeyBind(setPttKey);
const deafenBind = useKeyBind(setDeafenKey);
@@ -1059,6 +1298,55 @@ function Calls() {
}
/>
</SequenceCard>
<SequenceCard
className={SequenceCardStyle}
variant="SurfaceVariant"
direction="Column"
gap="400"
>
<SettingTile
title="AFK Auto-Mute"
description="Automatically mute your microphone when you have been silent in a call."
after={<Switch variant="Primary" value={afkAutoMute} onChange={setAfkAutoMute} />}
/>
{afkAutoMute && (
<SettingTile
title="Idle Timeout"
description="How long to wait before auto-muting."
after={
<SettingsSelect
value={String(afkTimeoutMinutes ?? 10)}
onChange={(v) => setAfkTimeoutMinutes(Number(v))}
options={[
{ value: '1', label: '1 minute' },
{ value: '5', label: '5 minutes' },
{ value: '10', label: '10 minutes' },
{ value: '20', label: '20 minutes' },
{ value: '30', label: '30 minutes' },
]}
/>
}
/>
)}
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Join & Leave Sounds"
description="Play a sound when someone joins or leaves a call you are in."
after={
<SettingsSelect
value={callJoinLeaveSound}
onChange={(v) => handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro')}
options={[
{ value: 'off', label: 'Off' },
{ value: 'chime', label: 'Chime' },
{ value: 'soft', label: 'Soft' },
{ value: 'retro', label: 'Retro' },
]}
/>
}
/>
</SequenceCard>
</Box>
);
}
@@ -1361,6 +1649,46 @@ function Messages() {
);
}
function AppUpdates() {
const { isTauri, status, check, install } = useTauriUpdater();
if (!isTauri) return null;
const description =
status.state === 'checking'
? 'Checking for updates...'
: status.state === 'up-to-date'
? 'Lotus Chat is up to date.'
: status.state === 'available'
? `Update available: v${status.version}`
: status.state === 'installing'
? 'Installing update, the app will restart shortly...'
: status.state === 'error'
? `Update check failed: ${status.message}`
: 'Check for a new version of Lotus Chat.';
const after =
status.state === 'available' ? (
<Button size="300" radii="300" onClick={install}>
<Text size="B300">Install &amp; Restart</Text>
</Button>
) : status.state === 'checking' || status.state === 'installing' ? (
<Spinner variant="Secondary" size="200" />
) : (
<Button size="300" radii="300" variant="Secondary" onClick={check}>
<Text size="B300">Check</Text>
</Button>
);
return (
<Box direction="Column" gap="100">
<Text size="L400">App Updates</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile title="Check for Updates" description={description} after={after} />
</SequenceCard>
</Box>
);
}
type GeneralProps = {
requestClose: () => void;
};
@@ -1391,6 +1719,7 @@ export function General({ requestClose }: GeneralProps) {
<Messages />
<Privacy />
<Calls />
<AppUpdates />
</Box>
</PageContent>
</Scroll>
@@ -1,5 +1,6 @@
import React from 'react';
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
import { useAtomValue, useSetAtom } from 'jotai';
import { Box, Text, IconButton, Icon, Icons, Scroll, config, toRem } from 'folds';
import { Page, PageContent, PageHeader } from '../../../components/page';
import { SystemNotification } from './SystemNotification';
import { AllMessagesNotifications } from './AllMessages';
@@ -9,6 +10,101 @@ import { PushRuleEditor } from './PushRuleEditor';
import { SequenceCard } from '../../../components/sequence-card';
import { SequenceCardStyle } from '../styles.css';
import { SettingTile } from '../../../components/setting-tile';
import { settingsAtom, Settings } from '../../../state/settings';
const PRESETS: Array<{
label: string;
emoji: string;
description: string;
patch: Partial<Settings>;
}> = [
{
label: 'Gaming',
emoji: '🎮',
description: 'Notifications on, sounds off',
patch: {
showNotifications: true,
isNotificationSounds: false,
messageSoundId: 'none',
inviteSoundId: 'none',
quietHoursEnabled: false,
},
},
{
label: 'Work',
emoji: '💼',
description: 'All notifications and sounds on',
patch: {
showNotifications: true,
isNotificationSounds: true,
messageSoundId: 'notification',
inviteSoundId: 'invite',
quietHoursEnabled: false,
},
},
{
label: 'Sleep',
emoji: '🌙',
description: 'All notifications off',
patch: {
showNotifications: false,
isNotificationSounds: false,
quietHoursEnabled: false,
},
},
];
function NotificationPresets() {
const settings = useAtomValue(settingsAtom);
const setSettings = useSetAtom(settingsAtom);
const applyPreset = (patch: Partial<Settings>) => {
setSettings({ ...settings, ...patch });
};
return (
<Box direction="Column" gap="100">
<Text size="L400">Quick Presets</Text>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<Box gap="300" style={{ padding: config.space.S300, flexWrap: 'wrap' }}>
{PRESETS.map((preset) => (
<button
key={preset.label}
type="button"
onClick={() => applyPreset(preset.patch)}
title={preset.description}
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: toRem(4),
padding: `${toRem(8)} ${toRem(16)}`,
borderRadius: config.radii.R300,
border: '1px solid var(--border-interactive-normal)',
background: 'var(--bg-surface-low)',
color: 'inherit',
cursor: 'pointer',
minWidth: toRem(80),
}}
>
<span style={{ fontSize: toRem(24) }}>{preset.emoji}</span>
<Text size="T300" style={{ fontWeight: 600 }}>
{preset.label}
</Text>
<Text
size="T200"
priority="300"
style={{ textAlign: 'center', maxWidth: toRem(120) }}
>
{preset.description}
</Text>
</button>
))}
</Box>
</SequenceCard>
</Box>
);
}
type NotificationsProps = {
requestClose: () => void;
@@ -34,6 +130,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
<Scroll hideTrack visibility="Hover">
<PageContent>
<Box direction="Column" gap="700">
<NotificationPresets />
<SystemNotification />
<AllMessagesNotifications />
<SpecialMessagesNotifications />
+83
View File
@@ -0,0 +1,83 @@
import { useEffect } from 'react';
import { useSetAtom } from 'jotai';
import { CallEmbed } from '../plugins/call';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { toastQueueAtom } from '../state/toast';
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.
*/
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
const setToast = useSetAtom(toastQueueAtom);
useEffect(() => {
if (!callEmbed || !enabled) return;
let stream: MediaStream | undefined;
let audioCtx: AudioContext | undefined;
let intervalId: ReturnType<typeof setInterval> | undefined;
let silenceStart: number | null = null;
let active = true;
const timeoutMs = timeoutMinutes * 60 * 1000;
navigator.mediaDevices
.getUserMedia({ audio: true, video: false })
.then((s) => {
if (!active) {
s.getTracks().forEach((t) => t.stop());
return;
}
stream = s;
audioCtx = new AudioContext();
const source = audioCtx.createMediaStreamSource(stream);
const analyser = audioCtx.createAnalyser();
analyser.fftSize = 256;
source.connect(analyser);
const buffer = new Float32Array(analyser.fftSize);
intervalId = setInterval(() => {
if (!active) return;
analyser.getFloatTimeDomainData(buffer);
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
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
silenceStart = null;
}
}, CHECK_INTERVAL_MS);
})
.catch(() => undefined);
return () => {
active = false;
if (intervalId !== undefined) clearInterval(intervalId);
stream?.getTracks().forEach((t) => t.stop());
audioCtx?.close().catch(() => undefined);
};
}, [callEmbed, enabled, timeoutMinutes, setToast]);
}
+68
View File
@@ -0,0 +1,68 @@
import { useEffect, useState } from 'react';
import { Method } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
// Module-level cache — survives re-renders, lives for the app session.
// userId → slug | null (null = fetched, no decoration set)
const cache = new Map<string, string | null>();
// Callbacks waiting for a userId's result
const pending = new Map<string, Array<(val: string | null) => void>>();
function fetchDecoration(
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
userId: string,
): Promise<string | null> {
if (cache.has(userId)) return Promise.resolve(cache.get(userId) ?? null);
// De-duplicate in-flight requests for the same userId
if (pending.has(userId)) {
return new Promise((resolve) => {
pending.get(userId)!.push(resolve);
});
}
const waiters: Array<(val: string | null) => void> = [];
pending.set(userId, waiters);
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`)
.then((res) => {
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
cache.set(userId, val);
return val;
})
.catch(() => {
cache.set(userId, null);
return null;
})
.finally(() => {
const v = cache.get(userId) ?? null;
pending.delete(userId);
waiters.forEach((cb) => cb(v));
});
}
export function invalidateDecorationCache(userId: string): void {
cache.delete(userId);
}
export function useAvatarDecoration(userId: string): string | null {
const mx = useMatrixClient();
const [slug, setSlug] = useState<string | null>(() => cache.get(userId) ?? null);
useEffect(() => {
let cancelled = false;
fetchDecoration(
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
userId,
).then((val) => {
if (!cancelled) setSlug(val);
});
return () => {
cancelled = true;
};
}, [mx, userId]);
return slug;
}
+5
View File
@@ -16,6 +16,7 @@ import { useCallMembersChange, useCallSession } from './useCall';
import { CallPreferences } from '../state/callPreferences';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { unlockCallSounds } from '../utils/callSounds';
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
@@ -84,6 +85,10 @@ export const useCallStart = (dm = false) => {
if (!container) {
throw new Error('Failed to start call, No embed container element found!');
}
// startCall is always invoked from a click/tap handler — unlock the Web
// Audio context now (within the gesture) so join/leave sounds that fire
// later, without any gesture, are audible to everyone in the call.
unlockCallSounds();
const callEmbed = createCallEmbed(
mx,
room,
+57
View File
@@ -0,0 +1,57 @@
import { useCallback, useEffect, useRef } from 'react';
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
import { CallEmbed } from '../plugins/call';
import { useSetting } from '../state/hooks/settings';
import { settingsAtom } from '../state/settings';
import { useMatrixClient } from './useMatrixClient';
import { useCallMembersChange, useCallSession } from './useCall';
import { useCallJoined } from './useCallEmbed';
import { playCallJoinSound, playCallLeaveSound } from '../utils/callSounds';
const membershipKey = (m: CallMembership): string => `${m.sender}|${m.deviceId}`;
/**
* Plays a local sound effect when another participant joins or leaves
* the call you are in. Style (or off) is configured in Settings → Calls.
*/
export function useCallJoinLeaveSounds(embed: CallEmbed): void {
const mx = useMatrixClient();
const [style] = useSetting(settingsAtom, 'callJoinLeaveSound');
const joined = useCallJoined(embed);
const session = useCallSession(embed.room);
const prevKeysRef = useRef<Set<string> | null>(null);
// Snapshot current members when the session (re)starts so we never play
// sounds for participants who were already present.
useEffect(() => {
prevKeysRef.current = new Set(session.memberships.map(membershipKey));
}, [session]);
useCallMembersChange(
session,
useCallback(
(members: CallMembership[]) => {
const next = new Set(members.map(membershipKey));
const prev = prevKeysRef.current ?? next;
prevKeysRef.current = next;
if (!joined || style === 'off') return;
const myPrefix = `${mx.getSafeUserId()}|`;
let someoneJoined = false;
let someoneLeft = false;
next.forEach((key) => {
if (!prev.has(key) && !key.startsWith(myPrefix)) someoneJoined = true;
});
prev.forEach((key) => {
if (!next.has(key) && !key.startsWith(myPrefix)) someoneLeft = true;
});
if (someoneJoined) playCallJoinSound(style);
if (someoneLeft) playCallLeaveSound(style);
},
[joined, style, mx],
),
);
}
+98
View File
@@ -0,0 +1,98 @@
import { useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useMatrixClient } from './useMatrixClient';
import { useRoomNavigate } from './useRoomNavigate';
import { isRoomId } from '../utils/matrix';
import { getHomeRoomPath, withSearchParam } from '../pages/pathUtils';
import { _RoomSearchParams } from '../pages/paths';
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../plugins/matrix-to';
/** Desktop (Tauri) injects deep links via a DOM CustomEvent — see lib.rs. */
const DEEP_LINK_EVENT = 'lotus-deeplink';
const inTauri = (): boolean => '__TAURI_INTERNALS__' in window;
const tryDecode = (value: string): string => {
try {
return decodeURIComponent(value);
} catch {
return value;
}
};
/**
* Convert a `matrix:` URI (MSC2312) to its `matrix.to` equivalent so we can
* reuse the existing matrix.to parsers. Returns undefined for forms we don't
* route (e.g. user links, which need an anchor/room context to open a profile).
*
* matrix:r/room:server -> https://matrix.to/#/#room:server
* matrix:roomid/id:server/e/$ev -> https://matrix.to/#/!id:server/$ev
*/
const matrixUriToMatrixTo = (uri: string): string | undefined => {
const body = uri.slice('matrix:'.length);
const [pathPart, queryPart] = body.split('?');
const segments = pathPart.split('/');
if (segments.length < 2) return undefined;
const [kind, rawId, evKind, rawEventId] = segments;
const sigil = kind === 'r' ? '#' : kind === 'roomid' ? '!' : undefined;
if (!sigil || !rawId) return undefined;
let fragment = `${sigil}${tryDecode(rawId)}`;
if (evKind === 'e' && rawEventId) {
fragment += `/$${tryDecode(rawEventId)}`;
}
const query = queryPart ? `?${queryPart}` : '';
return `https://matrix.to/#/${fragment}${query}`;
};
/**
* Routes deep links opened from outside the app (Windows `matrix:` links,
* forwarded from Tauri) into the client, mirroring the navigation in
* useMentionClickHandler. No-op outside Tauri.
*/
export const useDeepLinkNavigate = (): void => {
const mx = useMatrixClient();
const navigate = useNavigate();
const { navigateRoom, navigateSpace } = useRoomNavigate();
const navigateUri = useCallback(
(rawUrl: string) => {
const href = rawUrl.startsWith('matrix:') ? matrixUriToMatrixTo(rawUrl) : rawUrl;
if (!href || !testMatrixTo(href)) return;
const roomEvent = parseMatrixToRoomEvent(href);
const target = roomEvent ?? parseMatrixToRoom(href);
if (!target) return; // users / unsupported forms
const { roomIdOrAlias, viaServers } = target;
const eventId = roomEvent?.eventId;
// Already-joined room/space: navigate directly.
if (isRoomId(roomIdOrAlias) && mx.getRoom(roomIdOrAlias)) {
if (mx.getRoom(roomIdOrAlias)?.isSpaceRoom()) navigateSpace(roomIdOrAlias);
else navigateRoom(roomIdOrAlias, eventId);
return;
}
// Otherwise route through the home path (triggers the join/preview flow).
const path = getHomeRoomPath(roomIdOrAlias, eventId);
navigate(
viaServers && viaServers.length > 0
? withSearchParam<_RoomSearchParams>(path, { viaServers: viaServers.join(',') })
: path,
);
},
[mx, navigate, navigateRoom, navigateSpace],
);
useEffect(() => {
if (!inTauri()) return undefined;
const handler = (evt: Event) => {
const { detail } = evt as CustomEvent<unknown>;
if (typeof detail === 'string') navigateUri(detail);
};
window.addEventListener(DEEP_LINK_EVENT, handler);
return () => window.removeEventListener(DEEP_LINK_EVENT, handler);
}, [navigateUri]);
};
+3 -1
View File
@@ -115,8 +115,10 @@ export const useRoomUnverifiedDeviceCount = (
const memberIds = useMemo(
() => room.getJoinedMembers().map((m) => m.userId),
// room.roomId guards against room changes; getJoinedMemberCount() ensures
// the list refreshes when members join/leave so new devices get checked.
// eslint-disable-next-line react-hooks/exhaustive-deps
[room.roomId],
[room.roomId, room.getJoinedMemberCount()],
);
const updateCount = useCallback(async () => {
+22 -3
View File
@@ -13,6 +13,7 @@ export const useExtendedProfile = (userId: string): ExtendedProfile => {
useEffect(() => {
let cancelled = false;
const myUserId = mx.getUserId();
const fetchField = async <T extends Record<string, string>>(
field: string,
@@ -28,14 +29,32 @@ export const useExtendedProfile = (userId: string): ExtendedProfile => {
}
};
Promise.all([
const run = async () => {
const [pronouns, tzFromProfile] = await Promise.all([
fetchField<{ 'm.pronouns': string }>('m.pronouns'),
fetchField<{ 'm.tz': string }>('m.tz'),
]).then(([pronouns, timezone]) => {
]);
let timezone = tzFromProfile;
// Standard Synapse doesn't support m.tz — fall back to account data for own profile
if (!timezone && userId === myUserId) {
try {
const res = await mx.http.authedRequest<{ timezone: string }>(
Method.Get,
`/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`,
);
timezone = res.timezone || undefined;
} catch {
// not set yet
}
}
if (!cancelled) {
setExtProfile({ pronouns: pronouns || undefined, timezone: timezone || undefined });
}
});
};
run();
return () => {
cancelled = true;
+7 -1
View File
@@ -42,7 +42,13 @@ export const useFileDropZone = (
setActive(true);
}
};
const handleDragLeave = () => {
const handleDragLeave = (evt: DragEvent) => {
if (evt.relatedTarget === null) {
// Mouse left the browser window — reset unconditionally
dragCounterRef.current = 0;
setActive(false);
return;
}
dragCounterRef.current -= 1;
if (dragCounterRef.current <= 0) {
dragCounterRef.current = 0;
+32
View File
@@ -0,0 +1,32 @@
import { useEffect, useReducer } from 'react';
import { MatrixEvent, Room, RoomMember, RoomMemberEvent } from 'matrix-js-sdk';
import { Membership } from '../../types/matrix/room';
import { useMatrixClient } from './useMatrixClient';
import { readPowerLevel, usePowerLevelsContext } from './usePowerLevels';
/**
* Returns the list of members currently knocking on the room, reactively.
* Returns an empty array if the current user lacks invite power level.
*/
export function usePendingKnocks(room: Room): RoomMember[] {
const mx = useMatrixClient();
const powerLevels = usePowerLevelsContext();
const [, forceUpdate] = useReducer((n: number) => n + 1, 0);
useEffect(() => {
const handler = (_evt: MatrixEvent, member: RoomMember) => {
if (member.roomId === room.roomId) forceUpdate();
};
mx.on(RoomMemberEvent.Membership, handler);
return () => {
mx.removeListener(RoomMemberEvent.Membership, handler);
};
}, [mx, room.roomId]);
const myUserId = mx.getUserId();
const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined);
const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite');
const canApprove = myPowerLevel >= invitePowerLevel;
return canApprove ? room.getMembersWithMembership(Membership.Knock) : [];
}
+31 -7
View File
@@ -16,10 +16,35 @@ export function usePresenceUpdater() {
const lastActivityRef = useRef(0);
useEffect(() => {
const setOnline = () =>
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
const setUnavailable = (statusMsg = '') =>
mx.setPresence({ presence: 'unavailable', status_msg: statusMsg }).catch(() => undefined);
const userId = mx.getUserId();
// Read status from localStorage at call time so manual updates from the
// Profile settings are never overwritten by a stale closure value.
const readStatus = () =>
userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
const setOnline = () => {
const status = readStatus();
return mx
.setPresence({
presence: 'online',
...(status ? { status_msg: status } : {}),
})
.catch(() => undefined);
};
const setUnavailable = (statusMsg?: string) => {
const status = readStatus();
return mx
.setPresence({
presence: 'unavailable',
...(statusMsg
? { status_msg: statusMsg }
: status
? { status_msg: status }
: {}),
})
.catch(() => undefined);
};
const setOffline = () =>
mx.setPresence({ presence: 'offline', status_msg: '' }).catch(() => undefined);
@@ -44,7 +69,7 @@ export function usePresenceUpdater() {
// presenceStatus === 'auto' — original activity-tracking behavior.
const startIdleTimer = () => {
clearTimeout(idleTimerRef.current);
idleTimerRef.current = window.setTimeout(() => {
idleTimerRef.current = setTimeout(() => {
isIdleRef.current = true;
setUnavailable();
}, IDLE_TIMEOUT_MS);
@@ -75,9 +100,8 @@ export function usePresenceUpdater() {
};
const handlePageHide = () => {
const userId = mx.getUserId();
const token = mx.getAccessToken();
const baseUrl = (mx as unknown as { baseUrl: string }).baseUrl;
const baseUrl = mx.getHomeserverUrl();
if (!userId || !token || !baseUrl) return;
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
@@ -0,0 +1,35 @@
import { useEffect, useRef } from 'react';
import { useAtomValue } from 'jotai';
import { roomToUnreadAtom } from '../state/room/roomToUnread';
// Tauri v2 injects __TAURI_INTERNALS__ into the webview at runtime.
// We use it directly so cinny doesn't need @tauri-apps/api as a dependency.
type TauriInternals = { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
const tauriInvoke = (): TauriInternals['invoke'] | undefined =>
(window as unknown as { __TAURI_INTERNALS__?: TauriInternals }).__TAURI_INTERNALS__?.invoke;
export function useTauriNotificationBadge() {
const roomToUnread = useAtomValue(roomToUnreadAtom);
const prevHighlightsRef = useRef(0);
useEffect(() => {
const invoke = tauriInvoke();
if (!invoke) return;
let totalHighlights = 0;
roomToUnread.forEach((unread) => {
totalHighlights += unread.highlight;
});
invoke('set_badge_count', { count: totalHighlights }).catch(() => {});
// Mirror the unread state onto the tray icon (visible when minimized to tray).
invoke('set_tray_unread', { unread: totalHighlights > 0 }).catch(() => {});
// Flash the taskbar button when new mentions arrive and the window is not
// focused, then reset the baseline.
if (totalHighlights > prevHighlightsRef.current && !document.hasFocus()) {
invoke('flash_window').catch(() => {});
}
prevHighlightsRef.current = totalHighlights;
}, [roomToUnread]);
}
+47
View File
@@ -0,0 +1,47 @@
import { useState, useCallback } from 'react';
type TauriInternals = { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
const tauriInvoke = (): TauriInternals['invoke'] | undefined =>
(window as unknown as { __TAURI_INTERNALS__?: TauriInternals }).__TAURI_INTERNALS__?.invoke;
type UpdateStatus =
| { state: 'idle' }
| { state: 'checking' }
| { state: 'up-to-date' }
| { state: 'available'; version: string }
| { state: 'installing' }
| { state: 'error'; message: string };
export function useTauriUpdater() {
const isTauri = !!tauriInvoke();
const [status, setStatus] = useState<UpdateStatus>({ state: 'idle' });
const check = useCallback(async () => {
const invoke = tauriInvoke();
if (!invoke) return;
setStatus({ state: 'checking' });
try {
const result = (await invoke('check_for_update')) as { available: boolean; version?: string };
setStatus(
result.available && result.version
? { state: 'available', version: result.version }
: { state: 'up-to-date' },
);
} catch (e) {
setStatus({ state: 'error', message: String(e) });
}
}, []);
const install = useCallback(async () => {
const invoke = tauriInvoke();
if (!invoke) return;
setStatus({ state: 'installing' });
try {
await invoke('install_update');
} catch (e) {
setStatus({ state: 'error', message: String(e) });
}
}, []);
return { isTauri, status, check, install };
}
+56
View File
@@ -0,0 +1,56 @@
import { useCallback, useEffect, useState } from 'react';
import { MatrixClient } from 'matrix-js-sdk';
import { useMatrixClient } from './useMatrixClient';
import { useAccountDataCallback } from './useAccountDataCallback';
const NOTES_KEY = 'io.lotus.user_notes';
export const USER_NOTE_MAX_LENGTH = 500;
type UserNotesContent = Record<string, string>;
function readNotes(mx: MatrixClient): UserNotesContent {
return (mx.getAccountData(NOTES_KEY as any)?.getContent() as UserNotesContent | undefined) ?? {};
}
export function useUserNotes(): {
getNote: (userId: string) => string;
setNote: (userId: string, note: string) => Promise<void>;
} {
const mx = useMatrixClient();
const [notes, setNotes] = useState<UserNotesContent>(() => readNotes(mx));
useAccountDataCallback(
mx,
useCallback(
(evt) => {
if (evt.getType() === NOTES_KEY) {
setNotes(evt.getContent<UserNotesContent>() ?? {});
}
},
[],
),
);
useEffect(() => {
setNotes(readNotes(mx));
}, [mx]);
const getNote = useCallback((userId: string) => notes[userId] ?? '', [notes]);
const setNote = useCallback(
async (userId: string, note: string) => {
const current = readNotes(mx);
const updated = { ...current };
const trimmed = note.trim().slice(0, USER_NOTE_MAX_LENGTH);
if (trimmed) {
updated[userId] = trimmed;
} else {
delete updated[userId];
}
await (mx as any).setAccountData(NOTES_KEY, updated);
},
[mx],
);
return { getNote, setNote };
}
+12 -8
View File
@@ -29,20 +29,24 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
useEffect(() => {
// Subscribe on mx (MatrixClient) rather than on individual User objects.
// User objects have a default 10-listener limit; the same user can appear
// in many components simultaneously (avatars, member list, etc.) and
// per-user subscription causes MaxListenersExceededWarning at 11+.
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
if (u.userId === user?.userId) {
setPresence(getUserPresence(user));
setPresence(getUserPresence(u));
}
};
user?.on(UserEvent.Presence, updatePresence);
user?.on(UserEvent.CurrentlyActive, updatePresence);
user?.on(UserEvent.LastPresenceTs, updatePresence);
mx.on(UserEvent.Presence, updatePresence);
mx.on(UserEvent.CurrentlyActive, updatePresence);
mx.on(UserEvent.LastPresenceTs, updatePresence);
return () => {
user?.removeListener(UserEvent.Presence, updatePresence);
user?.removeListener(UserEvent.CurrentlyActive, updatePresence);
user?.removeListener(UserEvent.LastPresenceTs, updatePresence);
mx.removeListener(UserEvent.Presence, updatePresence);
mx.removeListener(UserEvent.CurrentlyActive, updatePresence);
mx.removeListener(UserEvent.LastPresenceTs, updatePresence);
};
}, [user]);
}, [mx, user]);
return presence;
};
+47 -1
View File
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useEffect } from 'react';
import * as Sentry from '@sentry/react';
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
@@ -15,6 +15,49 @@ import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
import { settingsAtom } from '../state/settings';
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
const FONT_MAP: Record<string, string> = {
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
inter: "'InterVariable', sans-serif",
'jetbrains-mono': "'JetBrains Mono', monospace",
'fira-code': "'Fira Code', monospace",
};
function AppearanceEffects() {
const settings = useAtomValue(settingsAtom);
useEffect(() => {
const color = settings.mentionHighlightColor;
if (color) {
document.body.style.setProperty('--mention-highlight-bg', color);
// compute black or white text based on hex luminance
const r = parseInt(color.slice(1, 3), 16);
const g = parseInt(color.slice(3, 5), 16);
const b = parseInt(color.slice(5, 7), 16);
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
document.body.style.setProperty('--mention-highlight-text', lum > 0.5 ? '#000' : '#fff');
document.body.style.setProperty('--mention-highlight-border', color);
} else {
document.body.style.removeProperty('--mention-highlight-bg');
document.body.style.removeProperty('--mention-highlight-text');
document.body.style.removeProperty('--mention-highlight-border');
}
}, [settings.mentionHighlightColor]);
useEffect(() => {
const font = FONT_MAP[settings.fontFamily ?? 'inter'] ?? FONT_MAP.inter;
document.body.style.setProperty('--font-secondary', font);
}, [settings.fontFamily]);
return null;
}
function TauriEffects() {
useTauriNotificationBadge();
return null;
}
function NightLightOverlay() {
const settings = useAtomValue(settingsAtom);
@@ -94,7 +137,10 @@ function App() {
<ClientConfigProvider value={clientConfig}>
<QueryClientProvider client={queryClient}>
<JotaiProvider>
<AppearanceEffects />
<TauriEffects />
<RouterProvider router={createRouter(clientConfig, screenSize)} />
<SeasonalEffect />
<NightLightOverlay />
<LotusToastContainer />
</JotaiProvider>
@@ -34,6 +34,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
import { toastQueueAtom } from '../../state/toast';
function isInQuietHours(start: string, end: string): boolean {
@@ -376,6 +377,11 @@ type ClientNonUIFeaturesProps = {
children: ReactNode;
};
function DeepLinkNavigator() {
useDeepLinkNavigate();
return null;
}
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
return (
<>
@@ -385,6 +391,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
<PresenceUpdater />
<InviteNotifications />
<MessageNotifications />
<DeepLinkNavigator />
{children}
</>
);
@@ -97,6 +97,7 @@ import {
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { PresenceRingAvatar } from '../../../components/presence';
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
type RoomNotificationsGroup = {
roomId: string;
@@ -479,6 +480,7 @@ function RoomNotificationsGroupComp({
<ModernLayout
before={
<AvatarBase>
<AvatarDecoration userId={event.sender}>
<PresenceRingAvatar userId={event.sender}>
<Avatar size="300">
<UserAvatar
@@ -500,6 +502,7 @@ function RoomNotificationsGroupComp({
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
</AvatarBase>
}
>
+1 -1
View File
@@ -130,7 +130,7 @@ export class CallEmbed {
header: 'none',
});
if (!room.isCallRoom() && CallEmbed.startingCall(intent)) {
if (CallEmbed.startingCall(intent)) {
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
}
+1 -4
View File
@@ -15,10 +15,7 @@ export const getRecentEmojis = (mx: MatrixClient, limit?: number): IEmoji[] => {
const recentEmoji = recentEmojiEvent?.getContent<IRecentEmojiContent>().recent_emoji;
if (!Array.isArray(recentEmoji)) return [];
return recentEmoji
.sort((e1, e2) => e2[1] - e1[1])
.slice(0, limit)
.reduce<IEmoji[]>((list, [unicode]) => {
return recentEmoji.slice(0, limit).reduce<IEmoji[]>((list, [unicode]) => {
const emoji = emojis.find((e) => e.unicode === unicode);
if (emoji) list.push(emoji);
return list;
+68 -1
View File
@@ -38,6 +38,28 @@ export enum MessageLayout {
Bubble = 2,
}
export interface ComposerToolbarSettings {
showFormat: boolean;
showEmoji: boolean;
showSticker: boolean;
showGif: boolean;
showLocation: boolean;
showPoll: boolean;
showVoice: boolean;
showSchedule: boolean;
}
export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
showFormat: true,
showEmoji: true,
showSticker: true,
showGif: true,
showLocation: true,
showPoll: true,
showVoice: true,
showSchedule: true,
};
export interface Settings {
themeId?: string;
useSystemTheme: boolean;
@@ -101,6 +123,31 @@ export interface Settings {
warnOnUnverifiedDevices: boolean;
pauseAnimations: boolean;
composerToolbarButtons: ComposerToolbarSettings;
mentionHighlightColor: string;
fontFamily: 'system' | 'inter' | 'jetbrains-mono' | 'fira-code';
afkAutoMute: boolean;
afkTimeoutMinutes: number;
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
seasonalThemeOverride:
| 'auto'
| 'off'
| 'halloween'
| 'christmas'
| 'newyear'
| 'autumn'
| 'aprilfools'
| 'lunar'
| 'valentines'
| 'stpatricks'
| 'earthday'
| 'deepspace'
| 'arcade';
}
const defaultSettings: Settings = {
@@ -166,13 +213,33 @@ const defaultSettings: Settings = {
warnOnUnverifiedDevices: false,
pauseAnimations: false,
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
mentionHighlightColor: '',
fontFamily: 'inter',
afkAutoMute: false,
afkTimeoutMinutes: 10,
callJoinLeaveSound: 'chime',
seasonalThemeOverride: 'auto',
};
export const getSettings = (): Settings => {
try {
const settings = localStorage.getItem(STORAGE_KEY);
if (settings === null) return defaultSettings;
return { ...defaultSettings, ...(JSON.parse(settings) as Settings) };
const saved = JSON.parse(settings) as Partial<Settings>;
return {
...defaultSettings,
...saved,
composerToolbarButtons: {
...DEFAULT_COMPOSER_TOOLBAR,
...(saved.composerToolbarButtons ?? {}),
},
};
} catch {
localStorage.removeItem(STORAGE_KEY);
return defaultSettings;
+65 -17
View File
@@ -70,38 +70,86 @@ export const MsgAppearClass = style({
},
});
// Animated chat background keyframes
// ─── Animated chat background keyframes ───────────────────────────────────────
// Animated chat background keyframes
/** Matrix rain — two stripe layers scroll at different speeds for parallax depth */
/**
* Digital Rain — vertical stripe scroll with a phosphor-glow flicker layered on top.
* Two stripe layers at different column widths and speeds create parallax depth.
*/
export const animRainKeyframe = keyframes({
from: { backgroundPosition: '0 0, 0 0' },
to: { backgroundPosition: '0 200px, 0 100px' },
});
/** Drifting stars — three layers drift diagonally */
export const animStarsDriftKeyframe = keyframes({
from: { backgroundPosition: '0 0, 65px 32px, 32px 97px' },
to: { backgroundPosition: '130px 130px, 195px 162px, 162px 227px' },
/** Phosphor flicker brightness pulse layered over the rain scroll. */
export const animRainGlowKeyframe = keyframes({
'0%': { filter: 'brightness(0.85)' },
'30%': { filter: 'brightness(1.25)' },
'60%': { filter: 'brightness(0.9)' },
'80%': { filter: 'brightness(1.1)' },
'100%': { filter: 'brightness(0.85)' },
});
/** Grid pulse — expands/contracts backgroundSize slightly */
/**
* Star Drift — three dot layers, each moving by exactly one tile width/height per
* cycle so loops are seamless even though each layer drifts at a different speed.
* Layer sizes: 130 px, 190 px, 260 px → displacement equals one tile each.
*/
export const animStarsDriftKeyframe = keyframes({
from: { backgroundPosition: '0 0, 65px 32px, 32px 97px' },
to: { backgroundPosition: '-130px -130px, -125px -158px, -228px -163px' },
});
/**
* Grid Pulse — subtle backgroundSize breathe so the grid feels alive.
* Paired with animGridBrightnessKeyframe at a different period for organic feel.
*/
export const animGridPulseKeyframe = keyframes({
'0%': { backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px' },
'50%': { backgroundSize: '66px 66px, 66px 66px, 13px 13px, 13px 13px' },
'50%': { backgroundSize: '68px 68px, 68px 68px, 13px 13px, 13px 13px' },
'100%': { backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px' },
});
/** Aurora — sweeps backgroundPosition in large cycle */
export const animAuroraKeyframe = keyframes({
'0%': { backgroundPosition: '0% 0%' },
'50%': { backgroundPosition: '-50% -25%' },
'100%': { backgroundPosition: '0% 0%' },
/** Brightness oscillation layered on top of the grid size pulse. */
export const animGridBrightnessKeyframe = keyframes({
'0%': { filter: 'brightness(0.8)' },
'50%': { filter: 'brightness(1.3)' },
'100%': { filter: 'brightness(0.8)' },
});
/** Fireflies — three layers of glowing dots drift diagonally */
/**
* Aurora Flow — four gradient layers each travel a distinct path through the
* 200%300% canvas, driven by one multi-stop keyframe so they never sync.
* Each background-position value corresponds to one radial-gradient layer.
*/
export const animAuroraKeyframe = keyframes({
'0%': { backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%' },
'20%': { backgroundPosition: '60% 40%, 20% 80%, 80% 20%, 100% 0%' },
'40%': { backgroundPosition: '100% 100%, 0% 100%, 100% 0%, 50% 50%' },
'60%': { backgroundPosition: '40% 80%, 80% 30%, 20% 70%, 0% 100%' },
'80%': { backgroundPosition: '0% 50%, 50% 0%, 0% 50%, 100% 50%' },
'100%': { backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%' },
});
/**
* Fireflies drift — three dot layers move at slightly different diagonal vectors
* (each offset equals exactly one tile so loops are seamless).
*/
export const animFirefliesKeyframe = keyframes({
from: { backgroundPosition: '0 0, 120px 80px, 60px 140px' },
to: { backgroundPosition: '200px 150px, 320px 230px, 260px 290px' },
to: { backgroundPosition: '-200px -200px, -80px -120px, -140px -60px' },
});
/** Brightness surge — gives fireflies a "glow pulse" life cycle. */
export const animFirefliesGlowKeyframe = keyframes({
'0%': { filter: 'brightness(0.4)' },
'50%': { filter: 'brightness(1.8)' },
'100%': { filter: 'brightness(0.4)' },
});
/** Opacity blink — runs at a prime period relative to the glow for organic feel. */
export const animFirefliesBlinkKeyframe = keyframes({
'0%': { opacity: 0.35 },
'50%': { opacity: 1 },
'100%': { opacity: 0.35 },
});
+3 -3
View File
@@ -169,9 +169,9 @@ export const Mention = recipe({
variants: {
highlight: {
true: {
backgroundColor: color.Success.Container,
color: color.Success.OnContainer,
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Success.ContainerLine}`,
backgroundColor: `var(--mention-highlight-bg, ${color.Success.Container as string})`,
color: `var(--mention-highlight-text, ${color.Success.OnContainer as string})`,
boxShadow: `0 0 0 ${config.borderWidth.B300} var(--mention-highlight-border, ${color.Success.ContainerLine as string})`,
},
},
focus: {
+106
View File
@@ -0,0 +1,106 @@
export type CallSoundStyle = 'chime' | 'soft' | 'retro';
let sharedCtx: AudioContext | undefined;
const getAudioContext = (): AudioContext | undefined => {
try {
if (!sharedCtx || sharedCtx.state === 'closed') sharedCtx = new AudioContext();
if (sharedCtx.state === 'suspended') sharedCtx.resume().catch(() => undefined);
return sharedCtx;
} catch {
return undefined;
}
};
/**
* Create and resume the shared AudioContext from within a user gesture
* (e.g. clicking "Join"). Browsers block AudioContext playback until it has
* been started by a gesture, so join/leave sounds which fire later without
* any gesture would otherwise be silent. Call this on call entry so every
* participant's later membership-change sounds are actually audible.
*/
export const unlockCallSounds = (): void => {
getAudioContext();
};
type Note = {
freq: number;
/** Offset from now, in seconds */
at: number;
/** Duration in seconds */
dur: number;
};
const playNotes = (notes: Note[], type: OscillatorType, peakGain: number): void => {
const ctx = getAudioContext();
if (!ctx) return;
const now = ctx.currentTime;
notes.forEach(({ freq, at, dur }) => {
const osc = ctx.createOscillator();
const gain = ctx.createGain();
osc.type = type;
osc.frequency.value = freq;
const start = now + at;
// Short attack/decay envelope to avoid clicks
gain.gain.setValueAtTime(0, start);
gain.gain.linearRampToValueAtTime(peakGain, start + 0.015);
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
osc.connect(gain);
gain.connect(ctx.destination);
osc.start(start);
osc.stop(start + dur + 0.02);
});
};
const SOUNDS: Record<CallSoundStyle, { join: () => void; leave: () => void }> = {
chime: {
join: () =>
playNotes(
[
{ freq: 587.33, at: 0, dur: 0.12 },
{ freq: 880, at: 0.1, dur: 0.2 },
],
'sine',
0.25,
),
leave: () =>
playNotes(
[
{ freq: 880, at: 0, dur: 0.12 },
{ freq: 587.33, at: 0.1, dur: 0.2 },
],
'sine',
0.25,
),
},
soft: {
join: () => playNotes([{ freq: 523.25, at: 0, dur: 0.4 }], 'triangle', 0.18),
leave: () => playNotes([{ freq: 392, at: 0, dur: 0.4 }], 'triangle', 0.18),
},
retro: {
join: () =>
playNotes(
[
{ freq: 440, at: 0, dur: 0.07 },
{ freq: 554.37, at: 0.07, dur: 0.07 },
{ freq: 659.25, at: 0.14, dur: 0.14 },
],
'square',
0.1,
),
leave: () =>
playNotes(
[
{ freq: 659.25, at: 0, dur: 0.07 },
{ freq: 554.37, at: 0.07, dur: 0.07 },
{ freq: 440, at: 0.14, dur: 0.14 },
],
'square',
0.1,
),
},
};
export const playCallJoinSound = (style: CallSoundStyle): void => SOUNDS[style]?.join();
export const playCallLeaveSound = (style: CallSoundStyle): void => SOUNDS[style]?.leave();
+10
View File
@@ -1,6 +1,16 @@
export const targetFromEvent = (evt: Event, selector: string): Element | undefined => {
const targets = evt.composedPath() as Element[];
if (targets.length > 0) {
return targets.find((target) => target.matches?.(selector));
}
// composedPath() is empty when the event is no longer dispatching (e.g. inside a
// portal-within-portal in React 19). Walk up the DOM from evt.target instead.
let el = evt.target instanceof Element ? evt.target : null;
while (el) {
if (el.matches(selector)) return el;
el = el.parentElement;
}
return undefined;
};
export const editableActiveElement = (): boolean =>
+1 -1
View File
@@ -222,7 +222,7 @@ export function tokenize(code: string, lang: string): SyntaxToken[] {
if (
code[i] === '#' &&
(normalised === 'python' || normalised === 'py') &&
(i === 0 || code[i - 1] === '\n')
(i === 0 || code[i - 1] === '\n' || code[i - 1] === ' ' || code[i - 1] === '\t')
) {
const nlHash = code.indexOf('\n', i);
const closeIdx = nlHash === -1 ? len : nlHash;
+2
View File
@@ -12,6 +12,7 @@
/* semantic surface vars used by poll, location, upload card, gif picker */
--bg-surface: #ffffff;
--bg-surface-low: rgba(0, 0, 0, 0.04);
--bg-surface-variant: rgba(0, 0, 0, 0.07);
--bg-surface-active: rgba(0, 0, 0, 0.1);
--bg-surface-border: rgba(0, 0, 0, 0.14);
--text-primary: #1a1a1a;
@@ -37,6 +38,7 @@
/* semantic surface vars — dark overrides */
--bg-surface: #25272e;
--bg-surface-low: rgba(255, 255, 255, 0.05);
--bg-surface-variant: rgba(255, 255, 255, 0.08);
--bg-surface-active: rgba(255, 255, 255, 0.1);
--bg-surface-border: rgba(255, 255, 255, 0.12);
--text-primary: #e0e5ed;
+1
View File
@@ -40,6 +40,7 @@ export enum StateEvent {
PoniesRoomEmotes = 'im.ponies.room_emotes',
PowerLevelTags = 'in.cinny.room.power_level_tags',
LotusVoiceLimit = 'io.lotus.voice_limit',
}
export enum MessageEvent {
+17 -2
View File
@@ -13,9 +13,19 @@ import buildConfig from './build.config';
const copyFiles = {
targets: [
{
// Element Call's dist must land flat in public/element-call/ so the call
// widget URL (/public/element-call/index.html) resolves. v4.x of
// vite-plugin-static-copy preserves the full source path under dest, so
// we strip the 4 leading segments of the source base
// (node_modules/@element-hq/element-call-embedded/dist) — mirroring the
// stripBase pattern used by the android/locales targets below. The old
// `rename: 'element-call'` form silently produced
// public/node_modules/.../dist/ under v4.x, 404ing the widget (calls
// broke on cinny-desktop; web only worked because its deployed copy was
// a stale artifact from before the vite-plugin-static-copy v4 bump).
src: 'node_modules/@element-hq/element-call-embedded/dist',
dest: 'public',
rename: 'element-call',
dest: 'public/element-call',
rename: { stripBase: 4 },
},
{
src: 'config.json',
@@ -36,6 +46,11 @@ const copyFiles = {
dest: 'public/',
rename: { stripBase: 1 },
},
{
src: 'public/fonts',
dest: '',
rename: { stripBase: 1 },
},
],
};