Compare commits

...

12 Commits

Author SHA1 Message Date
jared 4bb7c1ffb5 fix(ui): replace hardcoded Cinny blue in polls with TDS tokens
CI / Build & Quality Checks (push) Successful in 10m31s
Trigger Desktop Build / trigger (push) Successful in 19s
Selected option borders and backgrounds used rgba(var(--mx-primary-rgb,
0,132,255), ...) which rendered as the default Cinny blue, ignoring the
Lotus Terminal Design System palette. Replaced with --accent-cyan,
--accent-cyan-dim, --accent-cyan-border, and --border-color.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-14 17:37:27 -04:00
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
31 changed files with 2668 additions and 400 deletions
+1
View File
@@ -5,3 +5,4 @@ devAssets
.DS_Store
.ideapackage-lock.json
public/decorations/
+130 -47
View File
@@ -1,69 +1,152 @@
# Lotus Chat — Bug Report & Technical Audit
**Date:** June 2026
This document tracks identified bugs, edge cases, and architectural discrepancies.
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.
---
## ✅ Resolved Issues (Recently Fixed)
## 🛡️ Critical Security & Privacy Regressions
- **GIF Sending Bypasses E2EE**: Fixed in `RoomInput.tsx`.
- **Scheduled Messages Bypass E2EE**: Fixed in `scheduledMessages.ts`.
- **Drag-and-Drop Overlay Persistence**: Fixed in `useFileDrop.ts`.
- **Stale Member List in Verification Banner**: Fixed in `useDeviceVerificationStatus.ts`.
- **Incomplete Python Comment Highlighting**: Fixed in `syntaxHighlight.ts`.
- **Search Button Hidden in E2EE Rooms**: Fixed in `RoomViewHeader.tsx`.
- **TDS Design Law Violations (Hardcoded Hex)**: Fixed in `GifPicker.tsx` and `VoiceMessageRecorder.tsx`.
- **Recent Emoji Sort Order**: Fixed in `recent-emoji.ts` (recency order, not frequency).
- **Encrypted Search Misses Historic Events**: Fixed in `useLocalMessageSearch.ts`.
- **Presence Updater Base URL Hack**: Fixed in `usePresenceUpdater.ts`.
- **Presence Badge Accessibility**: Fixed in `Presence.tsx` (`aria-label` on badge).
- **Presence Updater Wipes Custom Status**: Fixed in `usePresenceUpdater.ts` (removed `status_msg: ''`).
- **Manifest Main Icon Paths 404**: Fixed in `public/manifest.json` (`./public/android/``./res/android/`). Shortcut icon was already correct.
### 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.
---
## 🛡️ Critical Security & Logic
## 🚩 Functional & Logic Bugs
### 1. Edit History Broken for E2EE
### 1. Presence Updater Reverts Status Updates
**File:** `src/app/hooks/usePresenceUpdater.ts` (Line 20)
**Status:** ✅ RESOLVED (June 2026)
**File:** `src/app/features/room/message/EditHistoryModal.tsx`
**Status:** **FIXED**
* **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.
- **Issue:** The modal fetches edit history via raw `fetch`. Edit events not found in the room cache were constructed from raw encrypted content and never decrypted.
- **Fix:** Each newly constructed `MatrixEvent` is now passed through `mx.decryptEventIfNeeded()` before rendering when `evt.isEncrypted()` is true.
### 2. Audio Playback Rate Reset
**File:** `src/app/components/message/content/AudioContent.tsx` (Line 97)
**Status:** UX Bug
### 2. Service Worker Ephemeral Sessions
* **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.
**File:** `src/sw.ts`
**Status:** **NOT A BUG — by design**
### 3. Room Insights are Static (No Live Updates)
**File:** `src/app/features/room-settings/RoomInsights.tsx` (Line 60)
**Status:** Medium Priority
- The `sessions` Map is intentionally in-memory. The main window re-posts the session to the SW via `postMessage` on every load. Persisting access tokens in SW IndexedDB would duplicate credential storage unnecessarily and is not required for the current feature set.
---
## 📱 PWA & Mobile Issues
### 1. No PWA Precaching (Offline Mode Broken)
**File:** `src/sw.ts`, `vite.config.js`
**Status:** **DEFERRED — out of scope**
- Full offline Matrix requires persisting sync state, E2EE keys, and an event send queue. The SW exists for authenticated media and notifications, which it handles correctly. Adding Workbox precaching is a multi-sprint project with limited benefit for a Matrix client.
### 2. PiP Resize Impossible on Mobile
* **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:** **FIXED**
**Status:** ✅ RESOLVED (June 2026)
- **Issue:** Resize corner `onMouseDown` handlers did not fire on touch devices.
- **Fix:** Added `handleResizeTouchStart` using touch events with the same geometry math extracted into a shared `applyResize` helper. `onTouchStart` is now wired to all four resize corners.
* **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.
### 3. Double Background Animation (GPU Waste)
---
**File:** `src/app/pages/client/SidebarNav.tsx`, `src/app/features/room/RoomView.tsx`
**Status:** **FIXED**
## 🎨 UI/UX & Visual Consistency
- **Issue:** When Glassmorphism is enabled, the chat background was rendered on both `document.body` and `RoomView`, running the same CSS animation twice.
- **Fix:** `RoomView` now reads the `glassmorphismSidebar` setting and skips applying `chatBgStyle` when it is active, relying entirely on the `document.body` background that `SidebarNav` already mirrors.
### 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`.
+152 -14
View File
@@ -10,20 +10,22 @@ Last updated: June 2026.
1. [Branding & Identity](#branding--identity)
2. [LotusGuild Terminal Design System (TDS) v1.2](#lotusguild-terminal-design-system-tds-v12)
3. [Animated Chat Backgrounds (P5-4)](#animated-chat-backgrounds-p5-4)
4. [Glassmorphism Sidebar (P5-3)](#glassmorphism-sidebar-p5-3)
5. [Night Light / Blue Light Filter (P5-5)](#night-light--blue-light-filter-p5-5)
6. [Voice / Video Call Improvements](#voice--video-call-improvements)
7. [Per-Message Read Receipts](#per-message-read-receipts)
8. [Delivery Status Indicators](#delivery-status-indicators)
9. [Messaging Enhancements](#messaging-enhancements)
10. [Presence](#presence)
11. [UX & Composer](#ux--composer)
12. [Room Customization](#room-customization)
13. [Moderation](#moderation)
14. [Notifications](#notifications)
15. [Server Integration](#server-integration)
16. [Infrastructure](#infrastructure)
17. [Key Custom Files](#key-custom-files)
4. [Seasonal Theme Overlays (P5-12)](#seasonal-theme-overlays-p5-12)
5. [Avatar Decorations (P5-13/P5-14)](#avatar-decorations-p5-13p5-14)
6. [Glassmorphism Sidebar (P5-3)](#glassmorphism-sidebar-p5-3)
7. [Night Light / Blue Light Filter (P5-5)](#night-light--blue-light-filter-p5-5)
8. [Voice / Video Call Improvements](#voice--video-call-improvements)
9. [Per-Message Read Receipts](#per-message-read-receipts)
10. [Delivery Status Indicators](#delivery-status-indicators)
11. [Messaging Enhancements](#messaging-enhancements)
12. [Presence](#presence)
13. [UX & Composer](#ux--composer)
14. [Room Customization](#room-customization)
15. [Moderation](#moderation)
16. [Notifications](#notifications)
17. [Server Integration](#server-integration)
18. [Infrastructure](#infrastructure)
19. [Key Custom Files](#key-custom-files)
---
@@ -148,6 +150,16 @@ Strips all `animation` properties from the returned style object when either `pa
A "Pause Background Animations" toggle is exposed in **Settings → Appearance**. The preference is persisted and read by `getChatBg()` at render time.
### Animation Improvements (June 2026)
All five animated backgrounds were rewritten for smoother, more organic motion:
- **Digital Rain** — added a phosphor glow flicker (`animRainGlowKeyframe`, 2.1 s) layered on top of the column scroll; stripe opacity increased for better visibility
- **Star Drift** — each of the three dot layers now moves by exactly its own tile width/height per cycle (`130 px`, `190 px`, `260 px`), eliminating the visible seam on loop
- **Grid Pulse** — independent brightness oscillation (`animGridBrightnessKeyframe`, 3.3 s) runs alongside the size breathe (4 s) at a prime period ratio so they never synchronise
- **Aurora Flow** — four gradient layers now have individual `backgroundSize` values (`200%`, `250%`, `300%`, `220%`); the keyframe drives each layer through a distinct 5-stop path, replacing the robotic single back-and-forth
- **Fireflies** — glow pulse (`animFirefliesGlowKeyframe`, 2.3 s `filter: brightness`) and opacity blink (`animFirefliesBlinkKeyframe`, 1.7 s) added on top of the position drift; prime periods create unsynchronised bioluminescence
### Files
- `src/app/styles/Animations.css.ts` — vanilla-extract keyframe definitions
@@ -155,6 +167,109 @@ A "Pause Background Animations" toggle is exposed in **Settings → Appearance**
---
## Seasonal Theme Overlays (P5-12)
Decorative CSS-only overlays that activate automatically on holidays and events. Manually overrideable in **Settings → Appearance → Seasonal Theme**.
### Themes
| Theme | Window | Effect |
|---|---|---|
| 🎆 New Year | Dec 31Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
| 🏮 Lunar New Year | Jan 22Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
| 💖 Valentine's Day | Feb 1015 | ♥ hearts floating upward; soft pink ambient glow |
| 🍀 St. Patrick's Day | Mar 1518 | ☘ clovers drifting down; gold metallic shimmer top border |
| 🃏 April Fool's | Apr 1 | Glitch overlay: RGB channel separation, hue-rotate spikes, scanline sweep, "SIGNAL LOST" watermark |
| 🌱 Earth Day | Apr 2023 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
| 🍂 Autumn | Sep 21Oct 31 | Warm orange/amber leaf shapes rotating and falling |
| 👾 Arcade Day | Sep 12 | CRT scanlines; blinking pixel corner decorations; "INSERT COIN" prompt |
| 🚀 Deep Space Week | Oct 410 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
| 🎃 Halloween | Oct 15Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
| ❄️ Christmas | Dec 10Jan 2 | White dot snowfall in multiple layers at varied speeds |
### Implementation
- `SeasonalEffect` component mounted in `App.tsx` at `z-index: 9997` (below night light, above content)
- Auto-detection via `getActiveSeason(now: Date)` — themes checked in priority order (New Year > Valentine's > … > Autumn)
- `seasonalThemeOverride` setting: `'auto' | 'off' | <theme-name>` — persisted in `settingsAtom`
- All particle animations gated on `prefers-reduced-motion: reduce` — ambient overlays (tints, textures, shimmer) remain active
### Files
- `src/app/components/seasonal/SeasonalEffect.tsx` — theme detection, date ranges, all overlay components
- `src/app/components/seasonal/Seasonal.css.ts` — vanilla-extract keyframes (fall, leaf, float-up, bob, glitch, burst, warp, scanline, shimmer, etc.)
---
## Avatar Decorations (P5-13/P5-14)
Animated APNG overlay frames that float around user avatars, inspired by Discord's Avatar Decorations feature. Each decoration extends 8px beyond the avatar border on all sides, with a transparent center hole that reveals the avatar beneath. Other Lotus Chat users see your selected decoration in real time — stored in the Matrix profile via MSC4133.
### Decoration Library
99 hand-curated, original-IP decorations (no licensed character artwork) organized into 9 categories:
| Category | Count | Highlights |
|---|---|---|
| Gaming | 13 | Slither 'n Snack, Joystick, Space Invaders, Gaming Headsets |
| Cyber | 9 | Cybernetic, Glitch, Digital Sunrise, Futuristic UI (3 colors) |
| Space | 8 | Black Hole, Constellations, Solar Orbit, Aurora |
| Fantasy | 22 | Kitsune, Phoenix, Glowing Runes, D&D dice, Crystal Balls |
| Elements | 7 | Fire, Water, Air, Earth, Lightning, Ki Energy |
| Japanese | 6 | Kabuto, Oni Mask, Sakura Warrior, Straw Hat |
| Nature | 12 | **Lotus Flower**, Koi Pond, Sakura, Fall Leaves, Fireflies |
| Spooky | 13 | Candlelight, Witch Hat, Ghosts, Jack-o'-Lantern |
| Cozy | 11 | Cozy Cat, Fox Hat (3 colors), Cat Ears, Frog Hat |
All decoration files are 256×256 APNGs. They animate natively in all modern browsers via `<img>` elements.
### Architecture
**Profile storage — MSC4133:**
Decoration preference is stored in the public Matrix profile field `io.lotus.avatar_decoration` (a slug string, e.g. `lotus_flower`). Any Lotus Chat user viewing your profile sees your current decoration.
**CDN:**
Files are self-hosted on the Lotus Nextcloud instance. Direct access: `https://drive.lotusguild.org/public.php/dav/files/{token}/cinny-decorations/{slug}.png`. `<img>` elements load cross-origin freely — no CORS headers needed.
**Module-level cache with in-flight deduplication:**
`useAvatarDecoration(userId)` fetches the profile field once per user per session. A `Map<userId, slug|null>` cache prevents redundant requests; a second `pending` waiters map ensures multiple components requesting the same userId simultaneously share one HTTP request rather than firing duplicates.
**Wrapping pattern:**
`AvatarDecoration` renders a `position: relative; display: inline-flex` wrapper div. The decoration `<img>` is `position: absolute` with `top/left/right/bottom: -8px`, extending equally on all sides while the `z-index: 10` keeps it above the avatar. `onError` hides the image if the CDN file is absent. This wrapper sits outside `PresenceRingAvatar` so the presence ring and decoration layer are fully independent.
### Placement — Where Decorations Render
| Location | File |
|---|---|
| Message timeline | `src/app/features/room/message/Message.tsx` |
| Members drawer | `src/app/features/room/MembersDrawer.tsx` |
| `@mention` autocomplete | `src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx` |
| Inbox / notifications | `src/app/pages/client/inbox/Notifications.tsx` |
### Settings — Decoration Picker
**Settings → Account → Avatar Decoration** shows a scrollable grid of all decorations, grouped by category. Each cell is a 52×52px button with a live preview of the APNG. The currently selected decoration gets a 2px cyan border. "No Decoration" clears the field. Changes are saved only when the "Save" button is clicked (visible only when a change is pending). After save, `invalidateDecorationCache(userId)` forces other components to re-fetch.
### Catalog Sync Script
After deleting decoration files from the Nextcloud share, run:
```bash
npm run sync:decorations
```
The script (`scripts/syncDecorations.mjs`) sends HTTP HEAD requests to the CDN URL for every slug in `avatarDecorations.ts` and automatically removes entries for files that returned 404. Empty categories are pruned automatically. Review with `git diff`.
### Files
- `src/app/features/lotus/avatarDecorations.ts` — full catalog (`DECORATION_CATEGORIES`, `ALL_DECORATIONS`, `decorationUrl()`, `DECORATION_CDN`)
- `src/app/hooks/useAvatarDecoration.ts` — profile fetch, module-level cache, `invalidateDecorationCache()`
- `src/app/components/avatar-decoration/AvatarDecoration.tsx` — wrapper component with APNG overlay
- `src/app/features/settings/account/ProfileDecoration.tsx` — settings UI (picker grid, save button)
- `scripts/syncDecorations.mjs` — CDN sync script to prune deleted decorations from the catalog
---
## Glassmorphism Sidebar (P5-3)
An optional frosted-glass sidebar style toggled in **Settings → Appearance**.
@@ -538,6 +653,12 @@ Applied in:
- `@mention` autocomplete dropdown
- Inbox / notifications panel
### Status Revert Bug Fix (June 2026)
`usePresenceUpdater` previously captured the user's custom status message once via `localStorage.getItem` at effect initialization. When the user changed their status message in Profile Settings, subsequent automatic transitions back to `online` (e.g., returning from idle) would silently broadcast the old status message, reverting the custom status.
Fixed by replacing the single read with a `readStatus()` function called inside every `setOnline` and `setUnavailable` invocation, so the current localStorage value is always used.
### Document Title Unread Count
The browser tab title updates to reflect unread state:
@@ -565,6 +686,18 @@ When a user has `m.tz` set in their profile:
Hook: `src/app/hooks/useLocalTime.ts`
### User-to-User Private Notes (P5-34)
A private text note on any user's profile, visible only to the logged-in user and synced across all their devices.
- Textarea in the user profile popout (below device sessions), shown only when viewing another user — never on your own profile
- Auto-saves 800 ms after the last keystroke with a "Saving…" indicator
- Character counter appears when fewer than 100 characters remain (max 500)
- Stored in `io.lotus.user_notes` account data as `{ [userId]: string }` — deletes the key when the note is cleared
- Reactive: updates immediately if account data arrives from another device mid-session
Hook: `src/app/hooks/useUserNotes.ts`
---
## UX & Composer
@@ -906,3 +1039,8 @@ The `encUrlPreview` setting defaults to `true` rather than `false`. A security a
| `src/app/hooks/useExtendedProfile.ts` | MSC4133 extended profile fields (`m.pronouns`, `m.tz`) read/write |
| `src/app/hooks/useLocalTime.ts` | Derives current local time from `m.tz` profile field, updates every 60s |
| `src/app/components/url-preview/UrlPreviewCard.tsx` | 13 domain-specific URL preview layouts plus generic fallback with favicon |
| `src/app/features/lotus/avatarDecorations.ts` | Avatar decoration catalog, CDN URL, `decorationUrl()` helper |
| `src/app/hooks/useAvatarDecoration.ts` | Profile field fetch with module-level cache and in-flight deduplication |
| `src/app/components/avatar-decoration/AvatarDecoration.tsx` | APNG overlay wrapper rendered around avatars in timeline, members drawer, autocomplete |
| `src/app/features/settings/account/ProfileDecoration.tsx` | Settings decoration picker — scrollable grid, category headers, save button |
| `scripts/syncDecorations.mjs` | CDN HEAD-check sync script: removes catalog entries for deleted Nextcloud files |
+49 -18
View File
@@ -104,17 +104,6 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
---
## Known Bugs
### [!] BUG · Drag-and-drop file overlay doesn't dismiss on hover-away
**Confirmed bug** — drag a file over the window without dropping: the drop overlay persists.
**Fix:** Ensure `dragleave` fires correctly at the window/document level. Child element boundaries can cause spurious `dragleave` — use a counter or `relatedTarget` check.
**[AUDIT REQUIRED]** Find the drag-and-drop overlay component in `RoomInput.tsx` or the room view. Confirm the exact event listener structure.
**Complexity:** Low (bug fix).
---
## Priority 3 — Higher complexity / lower daily frequency
### [ ] P3-4 · Accessibility Improvements (WCAG 2.1 AA)
@@ -248,11 +237,9 @@ Themes:
---
### [ ] P5-9 · LFG (Looking for Group) Slash Command
### [MOVED] P5-9 · LFG (Looking for Group) Command → LotusBot
**What:** `/lfg` generates a formatted LFG post visible on ALL Matrix clients using standard `m.room.message` HTML. Fields: Game, Players Needed, Platform, Skill Level, Description, DM link. Other clients see clean formatted HTML; Lotus Chat renders an enhanced styled card.
**[AUDIT REQUIRED]** Test which HTML tags survive Matrix HTML sanitization on Element/FluffyChat before designing the card structure. Test with minimal HTML.
**Complexity:** Medium.
**Decision:** Implemented as `!lfg` in LotusBot rather than a client slash command. Bot-side rendering works consistently across all Matrix clients; client-side enhanced cards would only be visible to Lotus Chat users and require sanitizer auditing. The bot can also support richer flows (list active LFGs, DM interested players, auto-expire posts).
---
@@ -273,7 +260,7 @@ Themes:
---
### [ ] P5-12 · Seasonal / Event Themes
### [x] P5-12 · Seasonal / Event Themes
**What:** Automatic + manually toggleable seasonal overlays with CSS particle effects and accent color variants:
@@ -287,7 +274,7 @@ Themes:
---
### [ ] P5-13 · Avatar Frame / Border Decorations
### [x] P5-13 · Avatar Frame / Border Decorations
**What:** Decorative CSS rings/frames rendered around user avatars. Built-in options: TDS Glow (animated orange pulsing), Cyberpunk (rotating gradient), Minimal (thin ring), Gold (supporter cosmetic). Stored in Matrix account data `io.lotus.avatar_frame`. Only visible in Lotus Chat.
**[AUDIT REQUIRED]** Verify folds Avatar component allows overlay decoration without breaking child-type constraints (see previous white-circle avatar bug).
@@ -295,7 +282,7 @@ Themes:
---
### [ ] P5-14 · Animated Avatar Overlay Decorations (Discord-style)
### [x] P5-14 · Animated Avatar Overlay Decorations (Discord-style)
**What:** Animated WebM/GIF overlays that float around avatars (transparent center showing avatar). Curated built-in set OR user-uploaded mxc:// overlay. Stored in account data. Only Lotus Chat users see them.
**[AUDIT REQUIRED]** See #P5-13 audit. Also decide: curated set only vs user-uploadable.
@@ -350,6 +337,50 @@ Themes:
---
### [ ] P5-30 · Advanced ML Noise Suppression (Krisp-style)
**What:** High-end background noise cancellation using a pre-trained ML model (e.g. RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
**Note:** This is a top-tier feature request and an EXTREME COMPLEXITY project.
**[AUDIT REQUIRED]** Must verify if mixing a processed stream into Element Call's WebRTC implementation causes latency or AEC (Echo Cancellation) issues.
**Complexity:** Extreme.
---
### [ ] P5-31 · Granular Voice & Screenshare Quality Controls (Discord-style)
**What:** Let users (or room admins via room settings) adjust audio bitrates (e.g., 64kbps to 512kbps) and screenshare quality (resolution: 720p/1080p/Source, framerate: 15/30/60fps).
**Note:** Requires tight integration with the LiveKit SFU and custom state events for per-room quality caps.
**[AUDIT REQUIRED]** Must verify if current `lk-jwt-service` can be extended with custom bitrate/resolution claims or if a new sidecar (similar to `voice-limit-guard`) is needed for server-side enforcement.
**Complexity:** Extreme.
---
### [x] P5-34 · User-to-User Private Notes
**What:** A private "Notes" field on user profiles visible only to you. Syncs across all your devices.
**Matrix Tech:** Store in `io.lotus.user_notes` account data. Must be keyed by `userId`.
**Complexity:** Medium.
---
### [ ] P5-35 · Desktop — Notification Click Opens Room (DEFERRED)
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
**Status:** Deferred — `tauri-plugin-notification` has no Rust click/action callback API. Quick-reply would need a custom WinRT toast activator + COM registration, which can't be compile-tested without a Windows build environment.
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
**Complexity:** High (platform-specific native code required).
---
### [ ] P5-36 · Desktop — Windows Jump List (DEFERRED)
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
**Action when unblocked:** Revisit when a Tauri plugin abstracts the Windows Shell `ICustomDestinationList` interface, or when a Windows build environment is available for local iteration.
**Complexity:** High (Windows-only native COM).
---
## Blocked Features
These features are confirmed desirable but cannot be built until the listed dependency is resolved.
+212 -87
View File
@@ -1,109 +1,234 @@
# Lotus Chat — Implementation Reference for Backlog
# Lotus Chat — Technical Implementation Field Guide
**Date:** June 2026
This document provides technical guidance, file paths, and architectural notes for unimplemented items in `LOTUS_TODO.md` to assist engineers during development.
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.
**⚠️ Largest Feature**
- **Objective:** Add a right-side drawer to view and reply to threads (`m.thread` relations).
- **Key Files to Reference:**
- `src/app/features/room/RoomView.tsx`: Main layout. Needs to render the new `ThreadPanel` component conditionally.
- `src/app/features/room/MembersDrawer.tsx`: Use this as a pattern for side drawers (fixed width, toggleable).
- `src/app/features/room/message/Message.tsx`: Check `isThreadedMessage` logic and the `onReplyClick(ev, true)` handler.
- **Architecture:**
- Create `activeThreadEventIdAtom` in a new state file.
- `ThreadPanel` should reuse `Timeline` components but filter for events where `m.relates_to.event_id === activeThreadEventId` and `rel_type === 'm.thread'`.
- **SDK API:** Use `mx.getThread(eventId)` or the aggregations API: `GET /rooms/{roomId}/relations/{eventId}/m.thread`.
- **Note:** `RoomTimeline.tsx` currently has `handleReplyClick` (Line 978) which already supports starting threads.
---
## 🛠️ Priority 4 — Specialized Features
### P4-3 · Knock-to-join Notifications for Admins
- **Objective:** Alert admins when users are knocking and provide an easy way to approve/deny.
- **Key Files:**
- `src/app/features/room/MembersDrawer.tsx`: Already contains logic to show "Pending Requests" (Line 412).
- `src/app/hooks/useRoomsNotificationPreferences.ts`: Add logic to detect `Membership.Knock` events in joined rooms where the user has invite permissions.
- **Implementation:**
- Create a hook `usePendingKnocks(room)` that returns `room.getMembersWithMembership(Membership.Knock)`.
- Add a notification badge to the "Members" icon in the room header if knocks > 0.
### P4-4 · Math / LaTeX Rendering
- **Objective:** Render `$...$` and `$$...$$` blocks using KaTeX.
- **Key Files:**
- `src/app/utils/sanitize.ts`: **Critical.** The sanitizer currently strips many tags. You must allow specific KaTeX/MathML outputs.
- `src/app/plugins/react-custom-html-parser.ts`: Add a custom rule to detect LaTeX patterns in plain text or handle the specific HTML from the server.
- `src/app/styles/CustomHtml.css.ts`: Add KaTeX CSS import/styles.
* **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
### P5-1 · Custom Accent Color Picker (Non-TDS only)
**Mechanism:** Dynamic CSS variable injection.
- **Objective:** User-defined accent color for non-TDS themes.
- **Key Files:**
- `src/app/hooks/useTheme.ts`: Central theme logic.
- `src/app/state/settings.ts`: Add `customAccentColor: string` to the settings atom.
- **Implementation:**
- Inject a `<style>` block into `<head>` that overrides CSS variables.
- **Variables to target:** `--lt-accent-orange`, `--lt-accent-cyan`, `--lt-accent-green` (or their non-TDS equivalents in `folds`).
* **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-10 · Voice Channel User Limit
### P5-14 · Animated Avatar Overlay
**Mechanism:** CSS Pseudo-element wrapping.
- **Objective:** Prevent joining a voice channel if the user limit is reached.
- **Key Files:**
- `src/app/features/call/CallView.tsx`: Join logic site.
- **Implementation:**
- In `CallPrescreen` (Line 77), retrieve the `io.lotus.voice_limit` state event from the room.
- Compare `callMembers.length` with the `max_users` value from the event content.
- If current members >= limit, disable the `canJoin` flag and display a "Channel Full" message.
### P5-13 · Avatar Frame / Border Decorations
- **Objective:** Add cosmetic frames around user avatars.
- **Key Files:**
- `src/app/components/user-avatar/UserAvatar.tsx`: Rendering site.
- **Implementation:**
- Add an optional `frameName` prop to the `UserAvatar` component.
- Since `folds` components like `AvatarImage` are restrictive, wrap the entire return value (both fallback and image paths) in a new `Box` container that applies the frame/glow effects via CSS.
### P5-21 · Custom @Mention Highlight Color
- **Objective:** Persistent background highlight for messages that mention the user.
- **Key Files:**
- `src/app/components/message/layout/layout.css.ts`: Styling site.
- `src/app/features/room/message/Message.tsx`: Logic site.
- **Implementation:**
- In `layout.css.ts`, add a `mention` variant to the `MessageBase` recipe that sets a static `backgroundColor`.
- In `Message.tsx`, pass the `isMentioned` boolean (Line 800) into the `MessageBase` component as a new prop to trigger the highlight variant.
### P5-20 · Quick Reply from Browser Notification
- **Objective:** Inline reply in OS notifications.
- **Key Files:**
- `src/sw.ts`: Handle the `notificationclick` event.
- **Implementation:**
- Check for `event.reply` in the service worker.
- Use the `accessToken` and `baseUrl` stored in the `sessions` map (already implemented in `sw.ts`) to send a Matrix message via `fetch` directly from the Service Worker.
- **Crucial:** Ensure the message is sent as a relation if the notification was for a thread.
* **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.
---
## 🧪 Pending Audits Guidance
## 🛠️ Priority 4 — Specialized Features
### Audit-3 · Profile Banner Image
### P4-4 · Math / LaTeX Rendering
**Mechanism:** KaTeX injection into the HTML parser.
- **Task:** Check if MSC4133 or Matrix v1.16 defines a banner field.
- **Update:** Matrix spec does not currently have a stable `m.banner` field. Most clients use `org.matrix.msc4133.banner_url` (unstable).
- **Recommendation:** Use `mx.http.authedRequest` to experiment with this field on `matrix.lotusguild.org`.
* **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.
+5 -2
View File
@@ -58,7 +58,9 @@ The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the origina
- 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
- 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
@@ -69,10 +71,11 @@ The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the origina
### 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
- 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
+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",
+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');
+8 -11
View File
@@ -54,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';
@@ -325,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,
@@ -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,14 +49,16 @@ function UnknownMentionItem({
}
onClick={() => handleAutocomplete(userId, name)}
before={
<PresenceRingAvatar userId={userId}>
<Avatar size="200">
<UserAvatar
userId={userId}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
<AvatarDecoration userId={userId}>
<PresenceRingAvatar userId={userId}>
<Avatar size="200">
<UserAvatar
userId={userId}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
}
>
<Text style={{ flexGrow: 1 }} size="B400">
@@ -177,16 +180,18 @@ export function UserMentionAutocomplete({
</Text>
}
before={
<PresenceRingAvatar userId={roomMember.userId}>
<Avatar size="200">
<UserAvatar
userId={roomMember.userId}
src={avatarUrl ?? undefined}
alt={getName(roomMember)}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
<AvatarDecoration userId={roomMember.userId}>
<PresenceRingAvatar userId={roomMember.userId}>
<Avatar size="200">
<UserAvatar
userId={roomMember.userId}
src={avatarUrl ?? undefined}
alt={getName(roomMember)}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
}
>
<Text style={{ flexGrow: 1 }} size="B400" truncate>
@@ -283,9 +283,9 @@ export function PollContent({
padding: '7px 12px',
borderRadius: '8px',
background: selected
? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.12)'
? 'var(--accent-cyan-dim)'
: 'rgba(255,255,255,0.04)',
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.7)' : 'rgba(255,255,255,0.12)'}`,
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
fontSize: '0.88rem',
lineHeight: 1.4,
textAlign: 'left',
@@ -309,7 +309,7 @@ export function PollContent({
right: 'auto',
width: `${pct}%`,
background: selected
? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.08)'
? 'var(--accent-cyan-dim)'
: 'rgba(255,255,255,0.03)',
pointerEvents: 'none',
transition: 'width 0.3s ease',
@@ -325,9 +325,9 @@ export function PollContent({
flexShrink: 0,
width: '14px',
height: '14px',
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--accent-cyan-border)'}`,
borderRadius: '3px',
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
background: selected ? 'var(--accent-cyan)' : 'none',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
@@ -345,9 +345,9 @@ export function PollContent({
flexShrink: 0,
width: '14px',
height: '14px',
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--accent-cyan-border)'}`,
borderRadius: '50%',
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
background: selected ? 'var(--accent-cyan)' : 'none',
transition: 'all 0.15s',
}}
/>
+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} />;
}
+23 -20
View File
@@ -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,27 +48,29 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
)}
</div>
<div className={css.UserHeroAvatarContainer}>
<AvatarPresence
className={css.UserAvatarContainer}
badge={
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
}
>
<Avatar
as={avatarUrl ? 'button' : 'div'}
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
className={css.UserHeroAvatar}
size="500"
<AvatarDecoration userId={userId} inset={20}>
<AvatarPresence
className={css.UserAvatarContainer}
badge={
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
}
>
<UserAvatar
className={css.UserHeroAvatarImg}
userId={userId}
src={avatarUrl}
alt={userId}
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/>
</Avatar>
</AvatarPresence>
<Avatar
as={avatarUrl ? 'button' : 'div'}
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
className={css.UserHeroAvatar}
size="500"
>
<UserAvatar
className={css.UserHeroAvatarImg}
userId={userId}
src={avatarUrl}
alt={userId}
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
/>
</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>
);
+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`,
},
};
+25 -20
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,16 +151,18 @@ function MemberItem({
radii="400"
onClick={onClick}
before={
<PresenceRingAvatar userId={member.userId}>
<Avatar size="200">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
<AvatarDecoration userId={member.userId}>
<PresenceRingAvatar userId={member.userId}>
<Avatar size="200">
<UserAvatar
userId={member.userId}
src={avatarUrl ?? undefined}
alt={name}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
}
after={
<>
@@ -442,16 +445,18 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
gap="200"
style={{ padding: `0 ${config.space.S200}` }}
>
<PresenceRingAvatar userId={knockMember.userId}>
<Avatar size="200">
<UserAvatar
userId={knockMember.userId}
src={knockAvatarUrl ?? undefined}
alt={knockName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
<AvatarDecoration userId={knockMember.userId}>
<PresenceRingAvatar userId={knockMember.userId}>
<Avatar size="200">
<UserAvatar
userId={knockMember.userId}
src={knockAvatarUrl ?? undefined}
alt={knockName}
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
<Box grow="Yes" direction="Column">
<Text size="T400" truncate>
{knockName}
+24 -21
View File
@@ -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,27 +875,29 @@ export const Message = React.memo(
<AvatarBase
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
>
<PresenceRingAvatar userId={senderId}>
<Avatar
className={css.MessageAvatar}
as="button"
size="300"
data-user-id={senderId}
onClick={onUserClick}
>
<UserAvatar
userId={senderId}
src={
senderAvatarMxc
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
undefined)
: undefined
}
alt={senderDisplayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
<AvatarDecoration userId={senderId}>
<PresenceRingAvatar userId={senderId}>
<Avatar
className={css.MessageAvatar}
as="button"
size="300"
data-user-id={senderId}
onClick={onUserClick}
>
<UserAvatar
userId={senderId}
src={
senderAvatarMxc
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
undefined)
: undefined
}
alt={senderDisplayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
</AvatarBase>
);
@@ -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 = {
@@ -892,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>
);
}
+138 -56
View File
@@ -154,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();
@@ -341,6 +417,10 @@ function Appearance() {
'mentionHighlightColor',
);
const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily');
const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting(
settingsAtom,
'seasonalThemeOverride',
);
return (
<Box direction="Column" gap="100">
@@ -408,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"
@@ -507,26 +615,18 @@ function Appearance() {
title="UI Font"
description="Font used throughout the interface."
after={
<select
<SettingsSelect
value={fontFamily ?? 'inter'}
onChange={(e) =>
setFontFamily(e.target.value as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
onChange={(v) =>
setFontFamily(v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
}
style={{
background: 'var(--bg-surface)',
color: 'inherit',
border: '1px solid var(--border-interactive-normal)',
borderRadius: '6px',
padding: '4px 8px',
fontSize: '14px',
cursor: 'pointer',
}}
>
<option value="system">System Default</option>
<option value="inter">Inter (default)</option>
<option value="jetbrains-mono">JetBrains Mono</option>
<option value="fira-code">Fira Code</option>
</select>
options={[
{ value: 'system', label: 'System Default' },
{ value: 'inter', label: 'Inter (default)' },
{ value: 'jetbrains-mono', label: 'JetBrains Mono' },
{ value: 'fira-code', label: 'Fira Code' },
]}
/>
}
/>
</SequenceCard>
@@ -1214,25 +1314,17 @@ function Calls() {
title="Idle Timeout"
description="How long to wait before auto-muting."
after={
<select
value={afkTimeoutMinutes}
onChange={(e) => setAfkTimeoutMinutes(Number(e.target.value))}
style={{
background: 'var(--bg-surface)',
color: 'inherit',
border: '1px solid var(--border-interactive-normal)',
borderRadius: '6px',
padding: '4px 8px',
fontSize: 'inherit',
cursor: 'pointer',
}}
>
<option value={1}>1 minute</option>
<option value={5}>5 minutes</option>
<option value={10}>10 minutes</option>
<option value={20}>20 minutes</option>
<option value={30}>30 minutes</option>
</select>
<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' },
]}
/>
}
/>
)}
@@ -1242,26 +1334,16 @@ function Calls() {
title="Join & Leave Sounds"
description="Play a sound when someone joins or leaves a call you are in."
after={
<select
<SettingsSelect
value={callJoinLeaveSound}
onChange={(e) =>
handleJoinLeaveSoundChange(e.target.value as 'off' | 'chime' | 'soft' | 'retro')
}
style={{
background: 'var(--bg-surface)',
color: 'inherit',
border: '1px solid var(--border-interactive-normal)',
borderRadius: '6px',
padding: '4px 8px',
fontSize: 'inherit',
cursor: 'pointer',
}}
>
<option value="off">Off</option>
<option value="chime">Chime</option>
<option value="soft">Soft</option>
<option value="retro">Retro</option>
</select>
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>
+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;
}
+16 -8
View File
@@ -17,26 +17,34 @@ export function usePresenceUpdater() {
useEffect(() => {
const userId = mx.getUserId();
const storedStatus = userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
const setOnline = () =>
mx
// 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',
...(storedStatus ? { status_msg: storedStatus } : {}),
...(status ? { status_msg: status } : {}),
})
.catch(() => undefined);
const setUnavailable = (statusMsg?: string) =>
mx
};
const setUnavailable = (statusMsg?: string) => {
const status = readStatus();
return mx
.setPresence({
presence: 'unavailable',
...(statusMsg
? { status_msg: statusMsg }
: storedStatus
? { status_msg: storedStatus }
: status
? { status_msg: status }
: {}),
})
.catch(() => undefined);
};
const setOffline = () =>
mx.setPresence({ presence: 'offline', status_msg: '' }).catch(() => undefined);
+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 };
}
+2
View File
@@ -16,6 +16,7 @@ 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",
@@ -139,6 +140,7 @@ function App() {
<AppearanceEffects />
<TauriEffects />
<RouterProvider router={createRouter(clientConfig, screenSize)} />
<SeasonalEffect />
<NightLightOverlay />
<LotusToastContainer />
</JotaiProvider>
+24 -21
View File
@@ -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,27 +480,29 @@ function RoomNotificationsGroupComp({
<ModernLayout
before={
<AvatarBase>
<PresenceRingAvatar userId={event.sender}>
<Avatar size="300">
<UserAvatar
userId={event.sender}
src={
senderAvatarMxc
? (mxcUrlToHttp(
mx,
senderAvatarMxc,
useAuthentication,
48,
48,
'crop',
) ?? undefined)
: undefined
}
alt={displayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
<AvatarDecoration userId={event.sender}>
<PresenceRingAvatar userId={event.sender}>
<Avatar size="300">
<UserAvatar
userId={event.sender}
src={
senderAvatarMxc
? (mxcUrlToHttp(
mx,
senderAvatarMxc,
useAuthentication,
48,
48,
'crop',
) ?? undefined)
: undefined
}
alt={displayName}
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
/>
</Avatar>
</PresenceRingAvatar>
</AvatarDecoration>
</AvatarBase>
}
>
+17
View File
@@ -133,6 +133,21 @@ export interface Settings {
afkTimeoutMinutes: number;
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
seasonalThemeOverride:
| 'auto'
| 'off'
| 'halloween'
| 'christmas'
| 'newyear'
| 'autumn'
| 'aprilfools'
| 'lunar'
| 'valentines'
| 'stpatricks'
| 'earthday'
| 'deepspace'
| 'arcade';
}
const defaultSettings: Settings = {
@@ -208,6 +223,8 @@ const defaultSettings: Settings = {
afkTimeoutMinutes: 10,
callJoinLeaveSound: 'chime',
seasonalThemeOverride: 'auto',
};
export const getSettings = (): Settings => {
+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 },
});
+5
View File
@@ -46,6 +46,11 @@ const copyFiles = {
dest: 'public/',
rename: { stripBase: 1 },
},
{
src: 'public/fonts',
dest: '',
rename: { stripBase: 1 },
},
],
};