Compare commits
2 Commits
107921e0d0
...
ca09e8e6ca
| Author | SHA1 | Date | |
|---|---|---|---|
| ca09e8e6ca | |||
| 6db07f1371 |
+75
-47
@@ -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
@@ -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 31–Jan 2 | Radial firework bursts in gold, red, cyan, purple; gold shimmer sweep |
|
||||
| 🏮 Lunar New Year | Jan 22–Feb 5 | Floating paper lanterns bobbing; silk texture; gold shimmer accent |
|
||||
| 💖 Valentine's Day | Feb 10–15 | ♥ hearts floating upward; soft pink ambient glow |
|
||||
| 🍀 St. Patrick's Day | Mar 15–18 | ☘ 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 20–23 | 🌿🍃 leaf emoji drift; sage green ambient tint; vine accent on left edge |
|
||||
| 🍂 Autumn | Sep 21–Oct 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 4–10 | Warp-speed star streaks radiating from screen centre; nebula purple/blue ambient |
|
||||
| 🎃 Halloween | Oct 15–Nov 1 | Purple and orange glowing particles; SVG spider web in top-left corner; dark purple tint |
|
||||
| ❄️ Christmas | Dec 10–Jan 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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 10–15)
|
||||
if (m === 2 && d >= 10 && d <= 15) return 'valentines';
|
||||
// St. Patrick's Day (March 15–18)
|
||||
if (m === 3 && d >= 15 && d <= 18) return 'stpatricks';
|
||||
// April Fool's (April 1)
|
||||
if (m === 4 && d === 1) return 'aprilfools';
|
||||
// Earth Day (April 20–23)
|
||||
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 4–10)
|
||||
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 10–30)
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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 31–Jan 2)</option>
|
||||
<option value="lunar">🏮 Lunar New Year (Jan 22–Feb 5)</option>
|
||||
<option value="valentines">💖 Valentine's Day (Feb 10–15)</option>
|
||||
<option value="stpatricks">🍀 St. Patrick's Day (Mar 15–18)</option>
|
||||
<option value="aprilfools">🃏 April Fool's Day (Apr 1)</option>
|
||||
<option value="earthday">🌱 Earth Day (Apr 22)</option>
|
||||
<option value="autumn">🍂 Autumn (Sep 21–Oct 31)</option>
|
||||
<option value="halloween">🎃 Halloween (Oct 15–Nov 1)</option>
|
||||
<option value="christmas">❄️ Christmas (Dec 10–Jan 2)</option>
|
||||
</optgroup>
|
||||
<optgroup label="Events">
|
||||
<option value="arcade">👾 Retro Arcade Day (Sep 12)</option>
|
||||
<option value="deepspace">🚀 Deep Space Week (Oct 4–10)</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show Profile on Every Message"
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user