Compare commits

...

2 Commits

Author SHA1 Message Date
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
16 changed files with 1645 additions and 228 deletions
+75 -47
View File
@@ -1,69 +1,97 @@
# 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:** UI Consistency
* **Issue:** The dropdowns for "Join & Leave Sounds" and "UI Font" use raw HTML `<select>` elements, which look different from the custom-styled `Menu` used elsewhere.
* **Recommended Fix:** Replace raw selects with the `Menu` + `PopOut` pattern used in the "Message Layout" setting.
### 4. No Camera Focus During Screenshare
**File:** `src/app/features/call/CallControls.tsx`
**Status:** UX Bug
* **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.
* **Recommended Fix:** Implement a "Pin/Focus" toggle on participant tiles that overrides the automatic screenshare spotlight.
+77 -14
View File
@@ -10,20 +10,21 @@ 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. [Glassmorphism Sidebar (P5-3)](#glassmorphism-sidebar-p5-3)
6. [Night Light / Blue Light Filter (P5-5)](#night-light--blue-light-filter-p5-5)
7. [Voice / Video Call Improvements](#voice--video-call-improvements)
8. [Per-Message Read Receipts](#per-message-read-receipts)
9. [Delivery Status Indicators](#delivery-status-indicators)
10. [Messaging Enhancements](#messaging-enhancements)
11. [Presence](#presence)
12. [UX & Composer](#ux--composer)
13. [Room Customization](#room-customization)
14. [Moderation](#moderation)
15. [Notifications](#notifications)
16. [Server Integration](#server-integration)
17. [Infrastructure](#infrastructure)
18. [Key Custom Files](#key-custom-files)
---
@@ -148,6 +149,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 +166,40 @@ 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.)
---
## Glassmorphism Sidebar (P5-3)
An optional frosted-glass sidebar style toggled in **Settings → Appearance**.
@@ -538,6 +583,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 +616,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
+28 -15
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).
---
@@ -350,6 +337,32 @@ 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.
---
### [ ] 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.
---
## 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.
+4 -2
View File
@@ -58,7 +58,8 @@ 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
- 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 +70,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
+5 -2
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';
@@ -329,7 +329,10 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
// Space voice channels and public rooms fire room-level RTC notifications
// whenever anyone joins — ringing every member is incorrect behaviour.
const isDirect = directs.has(room.roomId);
const isSpaceChild = !!getStateEvent(room, StateEvent.SpaceParent);
// m.space.parent uses the parent space ID as the state key, so getStateEvent
// (which defaults to stateKey='') always returns undefined. Use getStateEvents
// (no key filter) to detect any space parent relationship.
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
const joinRule = room.getJoinRule();
const isPrivateGroup =
!isSpaceChild &&
+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} />;
}
@@ -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>
);
+39 -35
View File
@@ -2,10 +2,14 @@ import { CSSProperties } from 'react';
import { ChatBackground } from '../../state/settings';
import {
animRainKeyframe,
animRainGlowKeyframe,
animStarsDriftKeyframe,
animGridPulseKeyframe,
animGridBrightnessKeyframe,
animAuroraKeyframe,
animFirefliesKeyframe,
animFirefliesGlowKeyframe,
animFirefliesBlinkKeyframe,
} from '../../styles/Animations.css';
export const BG_OPTIONS: { value: ChatBackground; label: string }[] = [
@@ -197,19 +201,19 @@ 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',
backgroundPosition: '0 0, 0 0',
animation: `${animRainKeyframe} 8s linear infinite`,
animation: `${animRainKeyframe} 8s linear infinite, ${animRainGlowKeyframe} 2.1s ease-in-out 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 +223,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: [
@@ -232,34 +236,34 @@ const DARK: Record<ChatBackground, CSSProperties> = {
'linear-gradient(90deg, rgba(0,212,255,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite, ${animGridBrightnessKeyframe} 3.3s 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, ${animFirefliesGlowKeyframe} 2.3s ease-in-out infinite, ${animFirefliesBlinkKeyframe} 1.7s ease-in-out infinite`,
},
};
@@ -423,12 +427,12 @@ 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',
backgroundPosition: '0 0, 0 0',
animation: `${animRainKeyframe} 8s linear infinite`,
animation: `${animRainKeyframe} 8s linear infinite, ${animRainGlowKeyframe} 2.1s ease-in-out infinite`,
},
'anim-stars': {
@@ -440,7 +444,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': {
@@ -452,32 +456,32 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
'linear-gradient(90deg, rgba(0,98,184,0.06) 1px, transparent 1px)',
].join(','),
backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px',
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite, ${animGridBrightnessKeyframe} 3.3s ease-in-out infinite`,
},
'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, ${animFirefliesGlowKeyframe} 2.3s ease-in-out infinite, ${animFirefliesBlinkKeyframe} 1.7s ease-in-out infinite`,
},
};
@@ -341,6 +341,10 @@ function Appearance() {
'mentionHighlightColor',
);
const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily');
const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting(
settingsAtom,
'seasonalThemeOverride',
);
return (
<Box direction="Column" gap="100">
@@ -408,6 +412,51 @@ 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={
<select
value={seasonalThemeOverride ?? 'auto'}
onChange={(e) =>
setSeasonalThemeOverride(
e.target.value as typeof seasonalThemeOverride,
)
}
style={{
background: 'var(--bg-surface-variant)',
color: 'inherit',
border: '1px solid var(--border-interactive)',
borderRadius: '6px',
padding: '4px 8px',
fontSize: '14px',
fontFamily: 'inherit',
cursor: 'pointer',
}}
>
<option value="auto">🗓 Auto (date-based)</option>
<option value="off">Off</option>
<optgroup label="Holidays">
<option value="newyear">🎆 New Year (Dec 31Jan 2)</option>
<option value="lunar">🏮 Lunar New Year (Jan 22Feb 5)</option>
<option value="valentines">💖 Valentine&apos;s Day (Feb 1015)</option>
<option value="stpatricks">🍀 St. Patrick&apos;s Day (Mar 1518)</option>
<option value="aprilfools">🃏 April Fool&apos;s Day (Apr 1)</option>
<option value="earthday">🌱 Earth Day (Apr 22)</option>
<option value="autumn">🍂 Autumn (Sep 21Oct 31)</option>
<option value="halloween">🎃 Halloween (Oct 15Nov 1)</option>
<option value="christmas"> Christmas (Dec 10Jan 2)</option>
</optgroup>
<optgroup label="Events">
<option value="arcade">👾 Retro Arcade Day (Sep 12)</option>
<option value="deepspace">🚀 Deep Space Week (Oct 410)</option>
</optgroup>
</select>
}
/>
</SequenceCard>
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
<SettingTile
title="Show Profile on Every Message"
+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>
+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 },
});