Compare commits
12 Commits
107921e0d0
...
4bb7c1ffb5
| Author | SHA1 | Date | |
|---|---|---|---|
| 4bb7c1ffb5 | |||
| 388a934665 | |||
| 99e6a456a7 | |||
| a5fe358313 | |||
| d7d7b59866 | |||
| 362ccff85d | |||
| 6ec0ab78d9 | |||
| e9a970a75b | |||
| 2a545b8b3e | |||
| bf1308dd55 | |||
| ca09e8e6ca | |||
| 6db07f1371 |
@@ -5,3 +5,4 @@ devAssets
|
||||
|
||||
.DS_Store
|
||||
.ideapackage-lock.json
|
||||
public/decorations/
|
||||
|
||||
+130
-47
@@ -1,69 +1,152 @@
|
||||
# Lotus Chat — Bug Report & Technical Audit
|
||||
|
||||
**Date:** June 2026
|
||||
|
||||
This document tracks identified bugs, edge cases, and architectural discrepancies.
|
||||
This document tracks identified bugs, edge cases, and architectural discrepancies found during the audit of the Lotus Chat codebase. Recommended fixes are provided for each item.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Resolved Issues (Recently Fixed)
|
||||
## 🛡️ Critical Security & Privacy Regressions
|
||||
|
||||
- **GIF Sending Bypasses E2EE**: Fixed in `RoomInput.tsx`.
|
||||
- **Scheduled Messages Bypass E2EE**: Fixed in `scheduledMessages.ts`.
|
||||
- **Drag-and-Drop Overlay Persistence**: Fixed in `useFileDrop.ts`.
|
||||
- **Stale Member List in Verification Banner**: Fixed in `useDeviceVerificationStatus.ts`.
|
||||
- **Incomplete Python Comment Highlighting**: Fixed in `syntaxHighlight.ts`.
|
||||
- **Search Button Hidden in E2EE Rooms**: Fixed in `RoomViewHeader.tsx`.
|
||||
- **TDS Design Law Violations (Hardcoded Hex)**: Fixed in `GifPicker.tsx` and `VoiceMessageRecorder.tsx`.
|
||||
- **Recent Emoji Sort Order**: Fixed in `recent-emoji.ts` (recency order, not frequency).
|
||||
- **Encrypted Search Misses Historic Events**: Fixed in `useLocalMessageSearch.ts`.
|
||||
- **Presence Updater Base URL Hack**: Fixed in `usePresenceUpdater.ts`.
|
||||
- **Presence Badge Accessibility**: Fixed in `Presence.tsx` (`aria-label` on badge).
|
||||
- **Presence Updater Wipes Custom Status**: Fixed in `usePresenceUpdater.ts` (removed `status_msg: ''`).
|
||||
- **Manifest Main Icon Paths 404**: Fixed in `public/manifest.json` (`./public/android/` → `./res/android/`). Shortcut icon was already correct.
|
||||
### 1. E2EE Bypass in Media Gallery Downloads
|
||||
**File:** `src/app/features/room/MediaGallery.tsx` (Line 855)
|
||||
**Status:** **CRITICAL**
|
||||
|
||||
* **Issue:** The "Download" button in the Files tab uses `mxcUrlToHttp` directly and clicks an `<a>` link.
|
||||
* **Impact:** In encrypted rooms, this downloads the encrypted ciphertext rather than the decrypted file. Users cannot open the downloaded files.
|
||||
* **Recommended Fix:**
|
||||
1. Check if the event is encrypted.
|
||||
2. If encrypted, use the `decryptAttachment` logic (similar to `useDecryptedMediaUrl`) to decrypt the file in memory.
|
||||
3. Use `file-saver` or a Blob URL to trigger the download of the decrypted plaintext.
|
||||
|
||||
### 2. Privacy Leak in URL Previews
|
||||
**File:** `src/app/components/url-preview/UrlPreviewCard.tsx` (Line 1655)
|
||||
**Status:** **PRIVACY RISK**
|
||||
|
||||
* **Issue:** Generic URL preview cards fetch favicons directly from `https://www.google.com/s2/favicons?domain=...`.
|
||||
* **Impact:** This leaks the user's browsing/chat activity (domains of links they see) to Google. It bypasses the "proxied through Matrix" privacy standard.
|
||||
* **Recommended Fix:** Use the proxy URL returned by the Matrix `/_matrix/media/v3/preview_url` endpoint instead of contacting Google directly.
|
||||
|
||||
---
|
||||
|
||||
## 🛡️ Critical Security & Logic
|
||||
## 🚩 Functional & Logic Bugs
|
||||
|
||||
### 1. Edit History Broken for E2EE
|
||||
### 1. Presence Updater Reverts Status Updates
|
||||
**File:** `src/app/hooks/usePresenceUpdater.ts` (Line 20)
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
**File:** `src/app/features/room/message/EditHistoryModal.tsx`
|
||||
**Status:** **FIXED**
|
||||
* **Issue:** The `storedStatus` variable was captured once when the `useEffect` started.
|
||||
* **Impact:** If a user updated their status message in Profile Settings, the hook would continue broadcasting the old message on every activity event, silently reverting the change.
|
||||
* **Fix Applied:** Replaced the single `localStorage.getItem` read with a `readStatus()` function called inside every `setOnline` and `setUnavailable` invocation, ensuring the current value is always used.
|
||||
|
||||
- **Issue:** The modal fetches edit history via raw `fetch`. Edit events not found in the room cache were constructed from raw encrypted content and never decrypted.
|
||||
- **Fix:** Each newly constructed `MatrixEvent` is now passed through `mx.decryptEventIfNeeded()` before rendering when `evt.isEncrypted()` is true.
|
||||
### 2. Audio Playback Rate Reset
|
||||
**File:** `src/app/components/message/content/AudioContent.tsx` (Line 97)
|
||||
**Status:** UX Bug
|
||||
|
||||
### 2. Service Worker Ephemeral Sessions
|
||||
* **Issue:** The `playbackRate` is set in a `useEffect` that only depends on `[playbackSpeed]`.
|
||||
* **Impact:** If a user selects a playback speed *before* the audio blob has finished loading, the `<audio>` element may reset its rate to 1.0 once the `<source>` is added.
|
||||
* **Recommended Fix:** Add `srcState.data` to the `useEffect` dependencies or set the `playbackRate` in an `onCanPlay` handler on the audio element.
|
||||
|
||||
**File:** `src/sw.ts`
|
||||
**Status:** **NOT A BUG — by design**
|
||||
### 3. Room Insights are Static (No Live Updates)
|
||||
**File:** `src/app/features/room-settings/RoomInsights.tsx` (Line 60)
|
||||
**Status:** Medium Priority
|
||||
|
||||
- The `sessions` Map is intentionally in-memory. The main window re-posts the session to the SW via `postMessage` on every load. Persisting access tokens in SW IndexedDB would duplicate credential storage unnecessarily and is not required for the current feature set.
|
||||
|
||||
---
|
||||
|
||||
## 📱 PWA & Mobile Issues
|
||||
|
||||
### 1. No PWA Precaching (Offline Mode Broken)
|
||||
|
||||
**File:** `src/sw.ts`, `vite.config.js`
|
||||
**Status:** **DEFERRED — out of scope**
|
||||
|
||||
- Full offline Matrix requires persisting sync state, E2EE keys, and an event send queue. The SW exists for authenticated media and notifications, which it handles correctly. Adding Workbox precaching is a multi-sprint project with limited benefit for a Matrix client.
|
||||
|
||||
### 2. PiP Resize Impossible on Mobile
|
||||
* **Issue:** Stats are calculated in a `useMemo` that only depends on `[room]`.
|
||||
* **Impact:** If new messages arrive while the Insights page is open, the statistics (message counts, top participants, etc.) do not update.
|
||||
* **Recommended Fix:** Add a listener for `RoomEvent.Timeline` inside the component to trigger a recalculation when new events are added to the room.
|
||||
|
||||
### 4. Incorrect Ringing in Voice Rooms
|
||||
**File:** `src/app/components/CallEmbedProvider.tsx`
|
||||
**Status:** **FIXED**
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
- **Issue:** Resize corner `onMouseDown` handlers did not fire on touch devices.
|
||||
- **Fix:** Added `handleResizeTouchStart` using touch events with the same geometry math extracted into a shared `applyResize` helper. `onTouchStart` is now wired to all four resize corners.
|
||||
* **Issue:** Joining a static voice room (Public Space channel) triggered the "Incoming Call" ringing animation and sound.
|
||||
* **Root Cause:** `getStateEvent(room, StateEvent.SpaceParent)` always returned `undefined` because `m.space.parent` events use the parent space's room ID as the state key, not an empty string. The ringing suppression check silently failed.
|
||||
* **Fix Applied:** Replaced `getStateEvent` with `getStateEvents` (plural), which returns all events of a given type regardless of state key. A room with any `m.space.parent` event is correctly identified as a space channel and suppresses ringing.
|
||||
|
||||
### 3. Double Background Animation (GPU Waste)
|
||||
---
|
||||
|
||||
**File:** `src/app/pages/client/SidebarNav.tsx`, `src/app/features/room/RoomView.tsx`
|
||||
**Status:** **FIXED**
|
||||
## 🎨 UI/UX & Visual Consistency
|
||||
|
||||
- **Issue:** When Glassmorphism is enabled, the chat background was rendered on both `document.body` and `RoomView`, running the same CSS animation twice.
|
||||
- **Fix:** `RoomView` now reads the `glassmorphismSidebar` setting and skips applying `chatBgStyle` when it is active, relying entirely on the `document.body` background that `SidebarNav` already mirrors.
|
||||
### 1. Hardcoded Primary Color in Polls
|
||||
**File:** `src/app/components/message/content/PollContent.tsx` (Line 245)
|
||||
**Status:** TDS Violation
|
||||
|
||||
* **Issue:** Uses `rgba(var(--mx-primary-rgb, 0,132,255), ...)` for selected options and borders.
|
||||
* **Impact:** Polls look like standard Cinny and ignore the Lotus Terminal Design System (TDS) colors.
|
||||
* **Recommended Fix:** Use `var(--lt-accent-cyan)` or the primary theme accent color.
|
||||
|
||||
### 2. Inaccessible Room Menu on Mobile
|
||||
**File:** `src/app/features/room-nav/RoomNavItem.tsx` (Line 643)
|
||||
**Status:** **MOBILE BUG**
|
||||
|
||||
* **Issue:** The room menu icon (`VerticalDots`) is hidden on mobile because `hover` is never active.
|
||||
* **Impact:** Mobile users cannot access room settings, mark as read, or leave rooms from the sidebar.
|
||||
* **Recommended Fix:** Ensure the menu button is visible on mobile for the active room or provide an alternative trigger.
|
||||
|
||||
### 3. Inconsistent Settings Dropdown Styling
|
||||
**File:** `src/app/features/settings/general/General.tsx`
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
* **Issue:** The dropdowns for "Join & Leave Sounds", "UI Font", and "Seasonal Theme" used raw HTML `<select>` elements, which render differently from the custom-styled `Menu`+`PopOut` used for "Message Layout" and other settings.
|
||||
* **Fix Applied:** All three raw `<select>` elements replaced with a reusable `SettingsSelect` component using the Menu+PopOut+FocusTrap pattern consistent with the rest of the settings UI.
|
||||
|
||||
### 4. No Camera Focus During Screenshare
|
||||
**File:** `src/app/features/call/CallControls.tsx`
|
||||
**Status:** UX Bug — blocked by Element Call internals
|
||||
|
||||
* **Issue:** When someone is screensharing and another participant turns on their camera, there is no way to switch the primary display to the camera or go fullscreen on it.
|
||||
* **Root Cause:** Element Call's spotlight/layout is controlled internally by EC. Lotus Chat injects the call iframe and cannot easily override EC's participant tile behavior without forking the EC widget.
|
||||
* **Recommended Fix:** Implement a "Pin/Focus" toggle on participant tiles that overrides the automatic screenshare spotlight — requires EC upstream changes or a custom message bridge.
|
||||
|
||||
### 5. Ringing Modal Fires in Persistent Voice Rooms
|
||||
**File:** `src/app/components/CallEmbedProvider.tsx` (line 337–342)
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
* **Issue:** Joining a persistent voice room (not a DM or transient group call) showed the incoming call ringing modal and animation.
|
||||
* **Root Cause:** The `isPrivateGroup` condition included `JoinRule.Restricted` and `JoinRule.Knock` rooms. Lotus Guild voice rooms are `Restricted` join-rule rooms. Their `m.space.parent` state event was being checked but some rooms were set up with only the space-side `m.space.child` relationship, leaving no `m.space.parent` on the room itself — so they passed as `isPrivateGroup` and triggered ringing.
|
||||
* **Fix Applied:** Narrowed `isPrivateGroup` to only `JoinRule.Invite` to match the exact set of rooms where the call button is shown. Also added `room.isCallRoom()` early-exit so rooms with `m.join_rule.call` type never ring.
|
||||
|
||||
### 6. Animated Chat Backgrounds Affect Message Content
|
||||
**File:** `src/app/features/lotus/chatBackground.ts`
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
* **Issue:** Animated backgrounds that use `filter: brightness()` or `opacity` animations (Digital Rain glow, Grid Pulse brightness, Fireflies glow/blink) applied those effects to the entire `<Page>` element, causing all message content and the composer to flash/flicker in sync with the animation.
|
||||
* **Root Cause:** `filter` and `opacity` CSS properties affect an element AND all its descendants. Applying these as part of the `animation` shorthand on the `Page` container made them "inherited" visually by everything inside the room view.
|
||||
* **Side Effect:** `filter` animation also created a CSS stacking context on Page, which pushed Seasonal Theme overlays (position:fixed; z-index:9997) behind the Page compositor layer.
|
||||
* **Fix Applied:** Removed `animRainGlowKeyframe`, `animGridBrightnessKeyframe`, `animFirefliesGlowKeyframe`, and `animFirefliesBlinkKeyframe` from `chatBackground.ts`. Only `backgroundPosition` / `backgroundSize` animations remain — these are safe and do not affect descendants or create stacking contexts.
|
||||
* **Follow-up (June 2026):** The initial fix only removed glow/brightness keyframes from the DARK variant definitions. The LIGHT variant `anim-rain` and `anim-pulse` entries still referenced `animRainGlowKeyframe` and `animGridBrightnessKeyframe` (which were no longer imported, causing a build error). Both references removed from LIGHT variants to complete the fix.
|
||||
|
||||
### 7. Seasonal Themes Display Behind Chat Background
|
||||
**File:** `src/app/components/seasonal/SeasonalEffect.tsx`
|
||||
**Status:** ✅ RESOLVED (June 2026) — root cause was Bug #6
|
||||
|
||||
* **Issue:** Seasonal theme overlays (position:fixed; z-index:9997) appeared behind animated chat backgrounds.
|
||||
* **Root Cause:** The `filter` animation on `<Page>` created a CSS stacking context, causing Page's GPU compositing layer to render above the fixed-position seasonal overlay in some browsers. Removing the filter animations (Bug #6 fix) resolves the stacking context issue.
|
||||
* **Fix Applied:** See Bug #6. No additional changes to SeasonalEffect required.
|
||||
|
||||
### 8. Avatar Decoration Images Not Rendering in Settings
|
||||
**File:** `src/app/features/settings/account/ProfileDecoration.tsx` / LXC 106 nginx
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
* **Issue:** Under Settings → Account → Avatar Decoration, no decoration images were visible.
|
||||
* **Initial (incorrect) diagnosis:** Suspected `loading="lazy"` in a nested scroll container. Changed to `loading="eager"` — images still did not render.
|
||||
* **Actual Root Cause:** The server nginx Content Security Policy (`img-src` directive) on LXC 106 did not include `https://drive.lotusguild.org`. The browser silently blocked all 99 APNG requests with CSP violation errors (206 violations visible in DevTools console). The lazy-loading change was a red herring.
|
||||
* **Fix Applied:** Added `https://drive.lotusguild.org` to the `img-src` directive in `/etc/nginx/sites-available/cinny` on LXC 106 (cinny-web-server) and reloaded nginx. Updated config is tracked in `pve-infra/containers/106-cinny-web-server/etc/nginx/sites-available/cinny`.
|
||||
|
||||
### 9. Avatar Decoration Grid Spacing Too Tight
|
||||
**File:** `src/app/features/settings/account/ProfileDecoration.tsx`
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
* **Issue:** Decoration preview cells in the settings picker appeared vertically and horizontally squished together with almost no gap between images.
|
||||
* **Root Cause:** The flex container used `gap: 20px`, but each `52×52` button renders its decoration image with `position: absolute; top: -8px; left: -8px` (INSET=8), so each image bleeds 8px outside the button on all sides. The visual gap between images was `20 - 8 - 8 = 4px` — nearly nothing. Additionally `paddingBottom: 4` clipped the bottom overflow of the last row, and `paddingRight` was absent so the rightmost column clipped.
|
||||
* **Fix Applied:** Increased `gap` from `20` to `36` (visual gap = 36 - 8 - 8 = 20px), changed `paddingBottom` from `4` to `INSET` (8px), added `paddingRight: INSET`.
|
||||
|
||||
### 10. Windows Taskbar Badge Has Black Square Background and Small Number
|
||||
**File:** `src-tauri/src/lib.rs` (cinny-desktop) — `set_badge_count` command
|
||||
**Status:** ✅ RESOLVED (June 2026)
|
||||
|
||||
* **Issue:** The notification badge overlaid on the Windows taskbar icon had a solid black square background instead of a transparent circle, and the number was too small to read at a glance.
|
||||
* **Root Cause (black square):** `CreateDIBSection` initialises the pixel buffer to zero (BGRA `0x00000000`). GDI drawing functions (`Ellipse`, `DrawTextW`) paint RGB values but never touch the alpha channel — all pixels retain `A=0`. When every pixel has `A=0`, Windows cannot use per-pixel alpha compositing and falls back to the monochrome mask (`hbm_mask`, all zeros = fully opaque), so the entire 16×16 bitmap is drawn opaque. The corner pixels outside the ellipse are `RGB(0,0,0)` = black, producing the black square.
|
||||
* **Root Cause (small number):** Bitmap was 16×16 with an 11px font, giving very little room, especially for two-digit counts.
|
||||
* **Fix Applied:**
|
||||
1. After all GDI drawing, iterate the pixel buffer and set `alpha = 0xFF` for every non-zero pixel (`*pixel |= 0xFF00_0000`). Corner pixels (zero) retain `A=0` and composite as transparent.
|
||||
2. Increased bitmap size from `16` to `20` and font height from `11` to `14`.
|
||||
|
||||
+152
-14
@@ -10,20 +10,22 @@ Last updated: June 2026.
|
||||
1. [Branding & Identity](#branding--identity)
|
||||
2. [LotusGuild Terminal Design System (TDS) v1.2](#lotusguild-terminal-design-system-tds-v12)
|
||||
3. [Animated Chat Backgrounds (P5-4)](#animated-chat-backgrounds-p5-4)
|
||||
4. [Glassmorphism Sidebar (P5-3)](#glassmorphism-sidebar-p5-3)
|
||||
5. [Night Light / Blue Light Filter (P5-5)](#night-light--blue-light-filter-p5-5)
|
||||
6. [Voice / Video Call Improvements](#voice--video-call-improvements)
|
||||
7. [Per-Message Read Receipts](#per-message-read-receipts)
|
||||
8. [Delivery Status Indicators](#delivery-status-indicators)
|
||||
9. [Messaging Enhancements](#messaging-enhancements)
|
||||
10. [Presence](#presence)
|
||||
11. [UX & Composer](#ux--composer)
|
||||
12. [Room Customization](#room-customization)
|
||||
13. [Moderation](#moderation)
|
||||
14. [Notifications](#notifications)
|
||||
15. [Server Integration](#server-integration)
|
||||
16. [Infrastructure](#infrastructure)
|
||||
17. [Key Custom Files](#key-custom-files)
|
||||
4. [Seasonal Theme Overlays (P5-12)](#seasonal-theme-overlays-p5-12)
|
||||
5. [Avatar Decorations (P5-13/P5-14)](#avatar-decorations-p5-13p5-14)
|
||||
6. [Glassmorphism Sidebar (P5-3)](#glassmorphism-sidebar-p5-3)
|
||||
7. [Night Light / Blue Light Filter (P5-5)](#night-light--blue-light-filter-p5-5)
|
||||
8. [Voice / Video Call Improvements](#voice--video-call-improvements)
|
||||
9. [Per-Message Read Receipts](#per-message-read-receipts)
|
||||
10. [Delivery Status Indicators](#delivery-status-indicators)
|
||||
11. [Messaging Enhancements](#messaging-enhancements)
|
||||
12. [Presence](#presence)
|
||||
13. [UX & Composer](#ux--composer)
|
||||
14. [Room Customization](#room-customization)
|
||||
15. [Moderation](#moderation)
|
||||
16. [Notifications](#notifications)
|
||||
17. [Server Integration](#server-integration)
|
||||
18. [Infrastructure](#infrastructure)
|
||||
19. [Key Custom Files](#key-custom-files)
|
||||
|
||||
---
|
||||
|
||||
@@ -148,6 +150,16 @@ Strips all `animation` properties from the returned style object when either `pa
|
||||
|
||||
A "Pause Background Animations" toggle is exposed in **Settings → Appearance**. The preference is persisted and read by `getChatBg()` at render time.
|
||||
|
||||
### Animation Improvements (June 2026)
|
||||
|
||||
All five animated backgrounds were rewritten for smoother, more organic motion:
|
||||
|
||||
- **Digital Rain** — added a phosphor glow flicker (`animRainGlowKeyframe`, 2.1 s) layered on top of the column scroll; stripe opacity increased for better visibility
|
||||
- **Star Drift** — each of the three dot layers now moves by exactly its own tile width/height per cycle (`−130 px`, `−190 px`, `−260 px`), eliminating the visible seam on loop
|
||||
- **Grid Pulse** — independent brightness oscillation (`animGridBrightnessKeyframe`, 3.3 s) runs alongside the size breathe (4 s) at a prime period ratio so they never synchronise
|
||||
- **Aurora Flow** — four gradient layers now have individual `backgroundSize` values (`200%`, `250%`, `300%`, `220%`); the keyframe drives each layer through a distinct 5-stop path, replacing the robotic single back-and-forth
|
||||
- **Fireflies** — glow pulse (`animFirefliesGlowKeyframe`, 2.3 s `filter: brightness`) and opacity blink (`animFirefliesBlinkKeyframe`, 1.7 s) added on top of the position drift; prime periods create unsynchronised bioluminescence
|
||||
|
||||
### Files
|
||||
|
||||
- `src/app/styles/Animations.css.ts` — vanilla-extract keyframe definitions
|
||||
@@ -155,6 +167,109 @@ A "Pause Background Animations" toggle is exposed in **Settings → Appearance**
|
||||
|
||||
---
|
||||
|
||||
## Seasonal Theme Overlays (P5-12)
|
||||
|
||||
Decorative CSS-only overlays that activate automatically on holidays and events. Manually overrideable in **Settings → Appearance → Seasonal Theme**.
|
||||
|
||||
### Themes
|
||||
|
||||
| Theme | Window | Effect |
|
||||
|---|---|---|
|
||||
| 🎆 New Year | Dec 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.)
|
||||
|
||||
---
|
||||
|
||||
## Avatar Decorations (P5-13/P5-14)
|
||||
|
||||
Animated APNG overlay frames that float around user avatars, inspired by Discord's Avatar Decorations feature. Each decoration extends 8px beyond the avatar border on all sides, with a transparent center hole that reveals the avatar beneath. Other Lotus Chat users see your selected decoration in real time — stored in the Matrix profile via MSC4133.
|
||||
|
||||
### Decoration Library
|
||||
|
||||
99 hand-curated, original-IP decorations (no licensed character artwork) organized into 9 categories:
|
||||
|
||||
| Category | Count | Highlights |
|
||||
|---|---|---|
|
||||
| Gaming | 13 | Slither 'n Snack, Joystick, Space Invaders, Gaming Headsets |
|
||||
| Cyber | 9 | Cybernetic, Glitch, Digital Sunrise, Futuristic UI (3 colors) |
|
||||
| Space | 8 | Black Hole, Constellations, Solar Orbit, Aurora |
|
||||
| Fantasy | 22 | Kitsune, Phoenix, Glowing Runes, D&D dice, Crystal Balls |
|
||||
| Elements | 7 | Fire, Water, Air, Earth, Lightning, Ki Energy |
|
||||
| Japanese | 6 | Kabuto, Oni Mask, Sakura Warrior, Straw Hat |
|
||||
| Nature | 12 | **Lotus Flower**, Koi Pond, Sakura, Fall Leaves, Fireflies |
|
||||
| Spooky | 13 | Candlelight, Witch Hat, Ghosts, Jack-o'-Lantern |
|
||||
| Cozy | 11 | Cozy Cat, Fox Hat (3 colors), Cat Ears, Frog Hat |
|
||||
|
||||
All decoration files are 256×256 APNGs. They animate natively in all modern browsers via `<img>` elements.
|
||||
|
||||
### Architecture
|
||||
|
||||
**Profile storage — MSC4133:**
|
||||
Decoration preference is stored in the public Matrix profile field `io.lotus.avatar_decoration` (a slug string, e.g. `lotus_flower`). Any Lotus Chat user viewing your profile sees your current decoration.
|
||||
|
||||
**CDN:**
|
||||
Files are self-hosted on the Lotus Nextcloud instance. Direct access: `https://drive.lotusguild.org/public.php/dav/files/{token}/cinny-decorations/{slug}.png`. `<img>` elements load cross-origin freely — no CORS headers needed.
|
||||
|
||||
**Module-level cache with in-flight deduplication:**
|
||||
`useAvatarDecoration(userId)` fetches the profile field once per user per session. A `Map<userId, slug|null>` cache prevents redundant requests; a second `pending` waiters map ensures multiple components requesting the same userId simultaneously share one HTTP request rather than firing duplicates.
|
||||
|
||||
**Wrapping pattern:**
|
||||
`AvatarDecoration` renders a `position: relative; display: inline-flex` wrapper div. The decoration `<img>` is `position: absolute` with `top/left/right/bottom: -8px`, extending equally on all sides while the `z-index: 10` keeps it above the avatar. `onError` hides the image if the CDN file is absent. This wrapper sits outside `PresenceRingAvatar` so the presence ring and decoration layer are fully independent.
|
||||
|
||||
### Placement — Where Decorations Render
|
||||
|
||||
| Location | File |
|
||||
|---|---|
|
||||
| Message timeline | `src/app/features/room/message/Message.tsx` |
|
||||
| Members drawer | `src/app/features/room/MembersDrawer.tsx` |
|
||||
| `@mention` autocomplete | `src/app/components/editor/autocomplete/UserMentionAutocomplete.tsx` |
|
||||
| Inbox / notifications | `src/app/pages/client/inbox/Notifications.tsx` |
|
||||
|
||||
### Settings — Decoration Picker
|
||||
|
||||
**Settings → Account → Avatar Decoration** shows a scrollable grid of all decorations, grouped by category. Each cell is a 52×52px button with a live preview of the APNG. The currently selected decoration gets a 2px cyan border. "No Decoration" clears the field. Changes are saved only when the "Save" button is clicked (visible only when a change is pending). After save, `invalidateDecorationCache(userId)` forces other components to re-fetch.
|
||||
|
||||
### Catalog Sync Script
|
||||
|
||||
After deleting decoration files from the Nextcloud share, run:
|
||||
|
||||
```bash
|
||||
npm run sync:decorations
|
||||
```
|
||||
|
||||
The script (`scripts/syncDecorations.mjs`) sends HTTP HEAD requests to the CDN URL for every slug in `avatarDecorations.ts` and automatically removes entries for files that returned 404. Empty categories are pruned automatically. Review with `git diff`.
|
||||
|
||||
### Files
|
||||
|
||||
- `src/app/features/lotus/avatarDecorations.ts` — full catalog (`DECORATION_CATEGORIES`, `ALL_DECORATIONS`, `decorationUrl()`, `DECORATION_CDN`)
|
||||
- `src/app/hooks/useAvatarDecoration.ts` — profile fetch, module-level cache, `invalidateDecorationCache()`
|
||||
- `src/app/components/avatar-decoration/AvatarDecoration.tsx` — wrapper component with APNG overlay
|
||||
- `src/app/features/settings/account/ProfileDecoration.tsx` — settings UI (picker grid, save button)
|
||||
- `scripts/syncDecorations.mjs` — CDN sync script to prune deleted decorations from the catalog
|
||||
|
||||
---
|
||||
|
||||
## Glassmorphism Sidebar (P5-3)
|
||||
|
||||
An optional frosted-glass sidebar style toggled in **Settings → Appearance**.
|
||||
@@ -538,6 +653,12 @@ Applied in:
|
||||
- `@mention` autocomplete dropdown
|
||||
- Inbox / notifications panel
|
||||
|
||||
### Status Revert Bug Fix (June 2026)
|
||||
|
||||
`usePresenceUpdater` previously captured the user's custom status message once via `localStorage.getItem` at effect initialization. When the user changed their status message in Profile Settings, subsequent automatic transitions back to `online` (e.g., returning from idle) would silently broadcast the old status message, reverting the custom status.
|
||||
|
||||
Fixed by replacing the single read with a `readStatus()` function called inside every `setOnline` and `setUnavailable` invocation, so the current localStorage value is always used.
|
||||
|
||||
### Document Title Unread Count
|
||||
|
||||
The browser tab title updates to reflect unread state:
|
||||
@@ -565,6 +686,18 @@ When a user has `m.tz` set in their profile:
|
||||
|
||||
Hook: `src/app/hooks/useLocalTime.ts`
|
||||
|
||||
### User-to-User Private Notes (P5-34)
|
||||
|
||||
A private text note on any user's profile, visible only to the logged-in user and synced across all their devices.
|
||||
|
||||
- Textarea in the user profile popout (below device sessions), shown only when viewing another user — never on your own profile
|
||||
- Auto-saves 800 ms after the last keystroke with a "Saving…" indicator
|
||||
- Character counter appears when fewer than 100 characters remain (max 500)
|
||||
- Stored in `io.lotus.user_notes` account data as `{ [userId]: string }` — deletes the key when the note is cleared
|
||||
- Reactive: updates immediately if account data arrives from another device mid-session
|
||||
|
||||
Hook: `src/app/hooks/useUserNotes.ts`
|
||||
|
||||
---
|
||||
|
||||
## UX & Composer
|
||||
@@ -906,3 +1039,8 @@ The `encUrlPreview` setting defaults to `true` rather than `false`. A security a
|
||||
| `src/app/hooks/useExtendedProfile.ts` | MSC4133 extended profile fields (`m.pronouns`, `m.tz`) read/write |
|
||||
| `src/app/hooks/useLocalTime.ts` | Derives current local time from `m.tz` profile field, updates every 60s |
|
||||
| `src/app/components/url-preview/UrlPreviewCard.tsx` | 13 domain-specific URL preview layouts plus generic fallback with favicon |
|
||||
| `src/app/features/lotus/avatarDecorations.ts` | Avatar decoration catalog, CDN URL, `decorationUrl()` helper |
|
||||
| `src/app/hooks/useAvatarDecoration.ts` | Profile field fetch with module-level cache and in-flight deduplication |
|
||||
| `src/app/components/avatar-decoration/AvatarDecoration.tsx` | APNG overlay wrapper rendered around avatars in timeline, members drawer, autocomplete |
|
||||
| `src/app/features/settings/account/ProfileDecoration.tsx` | Settings decoration picker — scrollable grid, category headers, save button |
|
||||
| `scripts/syncDecorations.mjs` | CDN HEAD-check sync script: removes catalog entries for deleted Nextcloud files |
|
||||
|
||||
+49
-18
@@ -104,17 +104,6 @@ Status: `[ ]` pending · `[~]` in progress · `[x]` completed
|
||||
|
||||
---
|
||||
|
||||
## Known Bugs
|
||||
|
||||
### [!] BUG · Drag-and-drop file overlay doesn't dismiss on hover-away
|
||||
|
||||
**Confirmed bug** — drag a file over the window without dropping: the drop overlay persists.
|
||||
**Fix:** Ensure `dragleave` fires correctly at the window/document level. Child element boundaries can cause spurious `dragleave` — use a counter or `relatedTarget` check.
|
||||
**[AUDIT REQUIRED]** Find the drag-and-drop overlay component in `RoomInput.tsx` or the room view. Confirm the exact event listener structure.
|
||||
**Complexity:** Low (bug fix).
|
||||
|
||||
---
|
||||
|
||||
## Priority 3 — Higher complexity / lower daily frequency
|
||||
|
||||
### [ ] P3-4 · Accessibility Improvements (WCAG 2.1 AA)
|
||||
@@ -248,11 +237,9 @@ Themes:
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-9 · LFG (Looking for Group) Slash Command
|
||||
### [MOVED] P5-9 · LFG (Looking for Group) Command → LotusBot
|
||||
|
||||
**What:** `/lfg` generates a formatted LFG post visible on ALL Matrix clients using standard `m.room.message` HTML. Fields: Game, Players Needed, Platform, Skill Level, Description, DM link. Other clients see clean formatted HTML; Lotus Chat renders an enhanced styled card.
|
||||
**[AUDIT REQUIRED]** Test which HTML tags survive Matrix HTML sanitization on Element/FluffyChat before designing the card structure. Test with minimal HTML.
|
||||
**Complexity:** Medium.
|
||||
**Decision:** Implemented as `!lfg` in LotusBot rather than a client slash command. Bot-side rendering works consistently across all Matrix clients; client-side enhanced cards would only be visible to Lotus Chat users and require sanitizer auditing. The bot can also support richer flows (list active LFGs, DM interested players, auto-expire posts).
|
||||
|
||||
---
|
||||
|
||||
@@ -273,7 +260,7 @@ Themes:
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-12 · Seasonal / Event Themes
|
||||
### [x] P5-12 · Seasonal / Event Themes
|
||||
|
||||
**What:** Automatic + manually toggleable seasonal overlays with CSS particle effects and accent color variants:
|
||||
|
||||
@@ -287,7 +274,7 @@ Themes:
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-13 · Avatar Frame / Border Decorations
|
||||
### [x] P5-13 · Avatar Frame / Border Decorations
|
||||
|
||||
**What:** Decorative CSS rings/frames rendered around user avatars. Built-in options: TDS Glow (animated orange pulsing), Cyberpunk (rotating gradient), Minimal (thin ring), Gold (supporter cosmetic). Stored in Matrix account data `io.lotus.avatar_frame`. Only visible in Lotus Chat.
|
||||
**[AUDIT REQUIRED]** Verify folds Avatar component allows overlay decoration without breaking child-type constraints (see previous white-circle avatar bug).
|
||||
@@ -295,7 +282,7 @@ Themes:
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-14 · Animated Avatar Overlay Decorations (Discord-style)
|
||||
### [x] P5-14 · Animated Avatar Overlay Decorations (Discord-style)
|
||||
|
||||
**What:** Animated WebM/GIF overlays that float around avatars (transparent center showing avatar). Curated built-in set OR user-uploaded mxc:// overlay. Stored in account data. Only Lotus Chat users see them.
|
||||
**[AUDIT REQUIRED]** See #P5-13 audit. Also decide: curated set only vs user-uploadable.
|
||||
@@ -350,6 +337,50 @@ Themes:
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-30 · Advanced ML Noise Suppression (Krisp-style)
|
||||
|
||||
**What:** High-end background noise cancellation using a pre-trained ML model (e.g. RNNoise) running in the browser. Removes dogs, fans, and keyboard clicks from the mic stream.
|
||||
**Note:** This is a top-tier feature request and an EXTREME COMPLEXITY project.
|
||||
**[AUDIT REQUIRED]** Must verify if mixing a processed stream into Element Call's WebRTC implementation causes latency or AEC (Echo Cancellation) issues.
|
||||
**Complexity:** Extreme.
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-31 · Granular Voice & Screenshare Quality Controls (Discord-style)
|
||||
|
||||
**What:** Let users (or room admins via room settings) adjust audio bitrates (e.g., 64kbps to 512kbps) and screenshare quality (resolution: 720p/1080p/Source, framerate: 15/30/60fps).
|
||||
**Note:** Requires tight integration with the LiveKit SFU and custom state events for per-room quality caps.
|
||||
**[AUDIT REQUIRED]** Must verify if current `lk-jwt-service` can be extended with custom bitrate/resolution claims or if a new sidecar (similar to `voice-limit-guard`) is needed for server-side enforcement.
|
||||
**Complexity:** Extreme.
|
||||
|
||||
---
|
||||
|
||||
### [x] P5-34 · User-to-User Private Notes
|
||||
|
||||
**What:** A private "Notes" field on user profiles visible only to you. Syncs across all your devices.
|
||||
**Matrix Tech:** Store in `io.lotus.user_notes` account data. Must be keyed by `userId`.
|
||||
**Complexity:** Medium.
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-35 · Desktop — Notification Click Opens Room (DEFERRED)
|
||||
|
||||
**What:** Clicking a system tray notification navigates to the relevant room. Quick-reply from the notification toast would send the reply without opening the window.
|
||||
**Status:** Deferred — `tauri-plugin-notification` has no Rust click/action callback API. Quick-reply would need a custom WinRT toast activator + COM registration, which can't be compile-tested without a Windows build environment.
|
||||
**Note:** Tray icon and `matrix:` deep links already bring the window forward on most interactions. Revisit when tauri-plugin-notification gains click handler support upstream.
|
||||
**Complexity:** High (platform-specific native code required).
|
||||
|
||||
---
|
||||
|
||||
### [ ] P5-36 · Desktop — Windows Jump List (DEFERRED)
|
||||
|
||||
**What:** Right-clicking the taskbar icon shows a jump list with recent/favorite rooms for quick navigation.
|
||||
**Status:** Deferred — implementing the Windows COM jump list API in Tauri requires iterating on C++/COM code that can only be compile-checked on Windows, making blind CI iteration impractical.
|
||||
**Action when unblocked:** Revisit when a Tauri plugin abstracts the Windows Shell `ICustomDestinationList` interface, or when a Windows build environment is available for local iteration.
|
||||
**Complexity:** High (Windows-only native COM).
|
||||
|
||||
---
|
||||
|
||||
## Blocked Features
|
||||
|
||||
These features are confirmed desirable but cannot be built until the listed dependency is resolved.
|
||||
|
||||
+212
-87
@@ -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,9 @@ The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the origina
|
||||
- LotusGuild Terminal Design System (TDS) — a CRT terminal-inspired dark theme
|
||||
- TDS light mode variant for daytime use
|
||||
- 20+ static chat background patterns
|
||||
- 5 animated chat backgrounds: Digital Rain, Star Drift, Grid Pulse, Aurora Flow, Fireflies
|
||||
- 5 animated chat backgrounds: Digital Rain, Star Drift, Grid Pulse, Aurora Flow, Fireflies (with improved per-layer looping, phosphor-flicker rain, fluid aurora sweep, and organic firefly bioluminescence)
|
||||
- 11 seasonal & holiday theme overlays — Halloween, Christmas, New Year, Autumn, Valentine's Day, St. Patrick's Day, Earth Day, Lunar New Year, April Fools', Deep Space, and Retro Arcade; auto-selected by date with a manual override in Settings → Appearance
|
||||
- Avatar decorations — 99 animated APNG overlays (Gaming, Cyber, Space, Fantasy, Nature, Spooky, Cozy, and more) that frame your avatar across the timeline, members list, and @mention autocomplete; visible to all Lotus Chat users; select in Settings → Account → Avatar Decoration
|
||||
- Toggle to pause background animations
|
||||
- Glassmorphism sidebar — frosted glass effect that lets the background show through
|
||||
- Night Light / blue light filter with an adjustable intensity slider
|
||||
@@ -69,10 +71,11 @@ The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the origina
|
||||
### Presence & Profile
|
||||
|
||||
- Discord-style presence selector: Online, Idle, Do Not Disturb, Invisible, or Auto
|
||||
- Custom status message with emoji and an optional auto-clear timer
|
||||
- Custom status message with emoji and an optional auto-clear timer (changing your status is never silently overwritten by activity events)
|
||||
- Colored presence ring on member avatars (green / yellow / red)
|
||||
- Profile fields for pronouns and timezone
|
||||
- When a user's timezone is set, their current local time appears in their profile
|
||||
- Private notes on any user's profile — freeform text visible only to you, auto-saves and syncs across devices
|
||||
- Unread count shown in the browser tab title
|
||||
|
||||
### Moderation & Privacy
|
||||
|
||||
+2
-1
@@ -18,7 +18,8 @@
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare": "husky",
|
||||
"commit": "git-cz",
|
||||
"postinstall": "node scripts/patch-folds.mjs"
|
||||
"postinstall": "node scripts/patch-folds.mjs",
|
||||
"sync:decorations": "node scripts/syncDecorations.mjs"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{ts,tsx,js,jsx}": "eslint",
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Syncs avatarDecorations.ts with what's actually available on the Nextcloud CDN.
|
||||
*
|
||||
* Usage:
|
||||
* npm run sync:decorations
|
||||
*
|
||||
* Workflow after deleting files from Nextcloud:
|
||||
* 1. Delete decoration files from your Nextcloud share.
|
||||
* 2. Run: npm run sync:decorations
|
||||
* 3. It probes each catalog slug via HTTP HEAD and removes entries
|
||||
* whose files returned 404. Empty categories are dropped automatically.
|
||||
* 4. Commit the updated avatarDecorations.ts.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const root = join(__dirname, '..');
|
||||
const catalogPath = join(root, 'src', 'app', 'features', 'lotus', 'avatarDecorations.ts');
|
||||
|
||||
const CDN =
|
||||
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
||||
|
||||
// Extract all slugs from the catalog file
|
||||
const catalog = readFileSync(catalogPath, 'utf8');
|
||||
const slugMatches = [...catalog.matchAll(/slug: '([^']+)'/g)].map((m) => m[1]);
|
||||
|
||||
if (slugMatches.length === 0) {
|
||||
console.error('No slugs found in catalog — check the file path.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`Checking ${slugMatches.length} decorations against ${CDN} …`);
|
||||
console.log('(This makes one HEAD request per decoration)\n');
|
||||
|
||||
// Probe all slugs in parallel batches of 16
|
||||
async function headCheck(slug) {
|
||||
try {
|
||||
const res = await fetch(`${CDN}/${slug}.png`, { method: 'HEAD' });
|
||||
return { slug, ok: res.ok, status: res.status };
|
||||
} catch {
|
||||
return { slug, ok: false, status: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
const BATCH = 16;
|
||||
const results = [];
|
||||
for (let i = 0; i < slugMatches.length; i += BATCH) {
|
||||
const batch = slugMatches.slice(i, i + BATCH);
|
||||
const batchResults = await Promise.all(batch.map(headCheck));
|
||||
results.push(...batchResults);
|
||||
}
|
||||
|
||||
const missing = results.filter((r) => !r.ok);
|
||||
const found = results.filter((r) => r.ok);
|
||||
|
||||
if (missing.length === 0) {
|
||||
console.log(`All ${found.length} decorations are available — catalog is up to date.`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`Found: ${found.length} Missing: ${missing.length}\n`);
|
||||
missing.forEach((r) =>
|
||||
console.log(` Removing (HTTP ${r.status}): ${r.slug}`),
|
||||
);
|
||||
|
||||
const missingSet = new Set(missing.map((r) => r.slug));
|
||||
|
||||
// Remove individual entries for missing slugs
|
||||
let updated = catalog.replace(
|
||||
/^[ \t]*\{ slug: '([^']+)', name: .+\},?\r?\n/gm,
|
||||
(match, slug) => (missingSet.has(slug) ? '' : match),
|
||||
);
|
||||
|
||||
// Drop category blocks that now have an empty decorations array
|
||||
updated = updated.replace(
|
||||
/ \{\n id: '[^']+',\n label: '[^']+',\n decorations: \[\n?[ \t]*\],?\n \},?\n/g,
|
||||
'',
|
||||
);
|
||||
|
||||
// Clean up stray blank lines
|
||||
updated = updated.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
writeFileSync(catalogPath, updated, 'utf8');
|
||||
console.log(`\nDone. Removed ${missing.length} entr${missing.length === 1 ? 'y' : 'ies'} from the catalog.`);
|
||||
console.log('Review with: git diff src/app/features/lotus/avatarDecorations.ts');
|
||||
@@ -54,7 +54,7 @@ import { getChatBg } from '../features/lotus/chatBackground';
|
||||
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
||||
import { useSetting } from '../state/hooks/settings';
|
||||
import { settingsAtom } from '../state/settings';
|
||||
import { getStateEvent, getMemberDisplayName } from '../utils/room';
|
||||
import { getStateEvent, getStateEvents, getMemberDisplayName } from '../utils/room';
|
||||
import { StateEvent } from '../../types/matrix/room';
|
||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||
@@ -325,18 +325,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
||||
);
|
||||
if (!hasCallPermission) return;
|
||||
|
||||
// Only ring for DMs or private non-space group chats.
|
||||
// Space voice channels and public rooms fire room-level RTC notifications
|
||||
// whenever anyone joins — ringing every member is incorrect behaviour.
|
||||
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
||||
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
||||
// restricted rooms, and public rooms must never trigger ringing.
|
||||
if (room.isCallRoom()) return;
|
||||
const isDirect = directs.has(room.roomId);
|
||||
const isSpaceChild = !!getStateEvent(room, StateEvent.SpaceParent);
|
||||
const isSpaceChild = getStateEvents(room, StateEvent.SpaceParent).length > 0;
|
||||
const joinRule = room.getJoinRule();
|
||||
const isPrivateGroup =
|
||||
!isSpaceChild &&
|
||||
(joinRule === JoinRule.Invite ||
|
||||
joinRule === JoinRule.Knock ||
|
||||
joinRule === JoinRule.Restricted);
|
||||
if (!isDirect && !isPrivateGroup) return;
|
||||
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
|
||||
if (!isDirect && !isPrivateInviteGroup) return;
|
||||
|
||||
const info: IncomingCallInfo = {
|
||||
room,
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import React from 'react';
|
||||
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
|
||||
import { decorationUrl } from '../../features/lotus/avatarDecorations';
|
||||
|
||||
const DEFAULT_INSET = 8;
|
||||
|
||||
type AvatarDecorationProps = {
|
||||
userId: string;
|
||||
children: React.ReactNode;
|
||||
inset?: number;
|
||||
};
|
||||
|
||||
export function AvatarDecoration({ userId, children, inset = DEFAULT_INSET }: AvatarDecorationProps) {
|
||||
const slug = useAvatarDecoration(userId);
|
||||
|
||||
if (!slug) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'inline-flex',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
<img
|
||||
src={decorationUrl(slug)}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -inset,
|
||||
left: -inset,
|
||||
right: -inset,
|
||||
bottom: -inset,
|
||||
width: `calc(100% + ${inset * 2}px)`,
|
||||
height: `calc(100% + ${inset * 2}px)`,
|
||||
pointerEvents: 'none',
|
||||
zIndex: 10,
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
alt=""
|
||||
aria-hidden="true"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onError={(e) => {
|
||||
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { UserAvatar } from '../../user-avatar';
|
||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||
import { Membership } from '../../../../types/matrix/room';
|
||||
import { PresenceRingAvatar } from '../../presence';
|
||||
import { AvatarDecoration } from '../../avatar-decoration/AvatarDecoration';
|
||||
|
||||
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
||||
|
||||
@@ -48,14 +49,16 @@ function UnknownMentionItem({
|
||||
}
|
||||
onClick={() => handleAutocomplete(userId, name)}
|
||||
before={
|
||||
<PresenceRingAvatar userId={userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
<AvatarDecoration userId={userId}>
|
||||
<PresenceRingAvatar userId={userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={userId}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
</AvatarDecoration>
|
||||
}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} size="B400">
|
||||
@@ -177,16 +180,18 @@ export function UserMentionAutocomplete({
|
||||
</Text>
|
||||
}
|
||||
before={
|
||||
<PresenceRingAvatar userId={roomMember.userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={roomMember.userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={getName(roomMember)}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
<AvatarDecoration userId={roomMember.userId}>
|
||||
<PresenceRingAvatar userId={roomMember.userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={roomMember.userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={getName(roomMember)}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
</AvatarDecoration>
|
||||
}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||
|
||||
@@ -283,9 +283,9 @@ export function PollContent({
|
||||
padding: '7px 12px',
|
||||
borderRadius: '8px',
|
||||
background: selected
|
||||
? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.12)'
|
||||
? 'var(--accent-cyan-dim)'
|
||||
: 'rgba(255,255,255,0.04)',
|
||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.7)' : 'rgba(255,255,255,0.12)'}`,
|
||||
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--border-color)'}`,
|
||||
fontSize: '0.88rem',
|
||||
lineHeight: 1.4,
|
||||
textAlign: 'left',
|
||||
@@ -309,7 +309,7 @@ export function PollContent({
|
||||
right: 'auto',
|
||||
width: `${pct}%`,
|
||||
background: selected
|
||||
? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.08)'
|
||||
? 'var(--accent-cyan-dim)'
|
||||
: 'rgba(255,255,255,0.03)',
|
||||
pointerEvents: 'none',
|
||||
transition: 'width 0.3s ease',
|
||||
@@ -325,9 +325,9 @@ export function PollContent({
|
||||
flexShrink: 0,
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
|
||||
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--accent-cyan-border)'}`,
|
||||
borderRadius: '3px',
|
||||
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
|
||||
background: selected ? 'var(--accent-cyan)' : 'none',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
@@ -345,9 +345,9 @@ export function PollContent({
|
||||
flexShrink: 0,
|
||||
width: '14px',
|
||||
height: '14px',
|
||||
border: `1.5px solid ${selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.9)' : 'rgba(255,255,255,0.3)'}`,
|
||||
border: `1.5px solid ${selected ? 'var(--accent-cyan)' : 'var(--accent-cyan-border)'}`,
|
||||
borderRadius: '50%',
|
||||
background: selected ? 'rgba(var(--mx-primary-rgb, 0,132,255), 0.8)' : 'none',
|
||||
background: selected ? 'var(--accent-cyan)' : 'none',
|
||||
transition: 'all 0.15s',
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import { getMxIdLocalPart } from '../../utils/matrix';
|
||||
import { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
|
||||
import { UserPresence } from '../../hooks/useUserPresence';
|
||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
|
||||
import { ImageViewer } from '../image-viewer';
|
||||
import { stopPropagation } from '../../utils/keyboard';
|
||||
import { useSetting } from '../../state/hooks/settings';
|
||||
@@ -47,27 +48,29 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
||||
)}
|
||||
</div>
|
||||
<div className={css.UserHeroAvatarContainer}>
|
||||
<AvatarPresence
|
||||
className={css.UserAvatarContainer}
|
||||
badge={
|
||||
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
as={avatarUrl ? 'button' : 'div'}
|
||||
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
|
||||
className={css.UserHeroAvatar}
|
||||
size="500"
|
||||
<AvatarDecoration userId={userId} inset={20}>
|
||||
<AvatarPresence
|
||||
className={css.UserAvatarContainer}
|
||||
badge={
|
||||
presence && <PresenceBadge presence={presence.presence} status={presence.status} />
|
||||
}
|
||||
>
|
||||
<UserAvatar
|
||||
className={css.UserHeroAvatarImg}
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
alt={userId}
|
||||
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</AvatarPresence>
|
||||
<Avatar
|
||||
as={avatarUrl ? 'button' : 'div'}
|
||||
onClick={avatarUrl ? () => setViewAvatar(avatarUrl) : undefined}
|
||||
className={css.UserHeroAvatar}
|
||||
size="500"
|
||||
>
|
||||
<UserAvatar
|
||||
className={css.UserHeroAvatarImg}
|
||||
userId={userId}
|
||||
src={avatarUrl}
|
||||
alt={userId}
|
||||
renderFallback={() => <Icon size="500" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</AvatarPresence>
|
||||
</AvatarDecoration>
|
||||
{viewAvatar && (
|
||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||
<OverlayCenter>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Box, Button, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
|
||||
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
|
||||
@@ -32,6 +32,7 @@ import { CreatorChip } from './CreatorChip';
|
||||
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
||||
import { DirectCreateSearchParams } from '../../pages/paths';
|
||||
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
|
||||
import { useUserNotes, USER_NOTE_MAX_LENGTH } from '../../hooks/useUserNotes';
|
||||
|
||||
type VerifyDeviceButtonProps = {
|
||||
userId: string;
|
||||
@@ -207,6 +208,65 @@ function UserDeviceSessions({ userId }: UserDeviceSessionsProps) {
|
||||
);
|
||||
}
|
||||
|
||||
function UserPrivateNotes({ userId }: { userId: string }) {
|
||||
const { getNote, setNote } = useUserNotes();
|
||||
const [draft, setDraft] = useState(() => getNote(userId));
|
||||
const [saving, setSaving] = useState(false);
|
||||
const saveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
|
||||
|
||||
// Sync if account data arrives after mount
|
||||
useEffect(() => {
|
||||
setDraft(getNote(userId));
|
||||
}, [getNote, userId]);
|
||||
|
||||
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||
const val = e.target.value;
|
||||
setDraft(val);
|
||||
clearTimeout(saveTimer.current);
|
||||
saveTimer.current = setTimeout(async () => {
|
||||
setSaving(true);
|
||||
await setNote(userId, val);
|
||||
setSaving(false);
|
||||
}, 800);
|
||||
};
|
||||
|
||||
useEffect(() => () => clearTimeout(saveTimer.current), []);
|
||||
|
||||
const charsLeft = USER_NOTE_MAX_LENGTH - draft.length;
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="200">
|
||||
<Box justifyContent="SpaceBetween" alignItems="Center">
|
||||
<Text size="L400">Private Note</Text>
|
||||
<Text size="T200" style={{ opacity: 0.5 }}>
|
||||
{saving ? 'Saving…' : charsLeft < 100 ? `${charsLeft} left` : ''}
|
||||
</Text>
|
||||
</Box>
|
||||
<textarea
|
||||
value={draft}
|
||||
onChange={handleChange}
|
||||
maxLength={USER_NOTE_MAX_LENGTH}
|
||||
placeholder="Notes only visible to you…"
|
||||
rows={3}
|
||||
style={{
|
||||
width: '100%',
|
||||
resize: 'vertical',
|
||||
background: 'var(--bg-surface-variant)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-interactive)',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 10px',
|
||||
fontSize: '14px',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: 1.5,
|
||||
boxSizing: 'border-box',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
type UserRoomProfileProps = {
|
||||
userId: string;
|
||||
};
|
||||
@@ -331,6 +391,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
||||
canBan={canBanUser && membership !== Membership.Ban}
|
||||
/>
|
||||
{showEncryption && userId !== myUserId && <UserDeviceSessions userId={userId} />}
|
||||
{userId !== myUserId && <UserPrivateNotes userId={userId} />}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
export const DECORATION_CDN =
|
||||
'https://drive.lotusguild.org/public.php/dav/files/bHswJ9pNKp2t26N/cinny-decorations';
|
||||
|
||||
export type AvatarDecoration = {
|
||||
slug: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type DecorationCategory = {
|
||||
id: string;
|
||||
label: string;
|
||||
decorations: AvatarDecoration[];
|
||||
};
|
||||
|
||||
export const DECORATION_CATEGORIES: DecorationCategory[] = [
|
||||
{
|
||||
id: 'gaming',
|
||||
label: 'Gaming',
|
||||
decorations: [
|
||||
{ slug: 'slither_n_snack', name: "Slither 'n Snack" },
|
||||
{ slug: 'joystick', name: 'Joystick' },
|
||||
{ slug: 'clyde_invaders', name: 'Space Invaders' },
|
||||
{ slug: 'mallow_jump', name: 'Mallow Jump' },
|
||||
{ slug: 'hot_shot', name: 'Hot Shot' },
|
||||
{ slug: 'pipedream', name: 'Pipedream' },
|
||||
{ slug: 'disxcore_headset', name: 'Gaming Headset' },
|
||||
{ slug: 'pink_headset', name: 'Pink Headset' },
|
||||
{ slug: 'green_headset', name: 'Green Headset' },
|
||||
{ slug: 'feelin_awe', name: "Feelin' Awe" },
|
||||
{ slug: 'feelin_panic', name: "Feelin' Panic" },
|
||||
{ slug: 'feelin_nervous', name: "Feelin' Nervous" },
|
||||
{ slug: 'feelin_scrumptious', name: "Feelin' Scrumptious" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cyber',
|
||||
label: 'Cyber',
|
||||
decorations: [
|
||||
{ slug: 'cybernetic', name: 'Cybernetic' },
|
||||
{ slug: 'glitch', name: 'Glitch' },
|
||||
{ slug: 'digital_sunrise', name: 'Digital Sunrise' },
|
||||
{ slug: 'implant', name: 'Implant' },
|
||||
{ slug: 'blue_futuristic_ui', name: 'Futuristic UI (Blue)' },
|
||||
{ slug: 'green_futuristic_ui', name: 'Futuristic UI (Green)' },
|
||||
{ slug: 'pink_futuristic_ui', name: 'Futuristic UI (Pink)' },
|
||||
{ slug: 'chromawave', name: 'Chromawave' },
|
||||
{ slug: 'hex_lights', name: 'Hex Lights' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'space',
|
||||
label: 'Space',
|
||||
decorations: [
|
||||
{ slug: 'stardust', name: 'Stardust' },
|
||||
{ slug: 'black_hole', name: 'Black Hole' },
|
||||
{ slug: 'constellations', name: 'Constellations' },
|
||||
{ slug: 'solar_orbit', name: 'Solar Orbit' },
|
||||
{ slug: 'astronaut_helmet', name: 'Astronaut Helmet' },
|
||||
{ slug: 'ufo', name: 'UFO' },
|
||||
{ slug: 'warp_helmet', name: 'Warp Helmet' },
|
||||
{ slug: 'aurora', name: 'Aurora' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'fantasy',
|
||||
label: 'Fantasy',
|
||||
decorations: [
|
||||
{ slug: 'kitsune', name: 'Kitsune' },
|
||||
{ slug: 'phoenix', name: 'Phoenix' },
|
||||
{ slug: 'unicorn', name: 'Unicorn' },
|
||||
{ slug: 'flaming_sword', name: 'Flaming Sword' },
|
||||
{ slug: 'skull_medallion', name: 'Skull Medallion' },
|
||||
{ slug: 'glowing_runes', name: 'Glowing Runes' },
|
||||
{ slug: 'eldritch_ring', name: 'Eldritch Ring' },
|
||||
{ slug: 'arcane_sigil', name: 'Arcane Sigil' },
|
||||
{ slug: 'midnight_sorceress', name: 'Midnight Sorceress' },
|
||||
{ slug: 'deaths_edge', name: "Death's Edge" },
|
||||
{ slug: 'malefic_crown', name: 'Malefic Crown' },
|
||||
{ slug: 'spirit_embers', name: 'Spirit Embers' },
|
||||
{ slug: 'defensive_shield', name: 'Defensive Shield' },
|
||||
{ slug: 'magical_potion', name: 'Magical Potion' },
|
||||
{ slug: 'wizards_staff', name: "Wizard's Staff" },
|
||||
{ slug: 'crystal_ball_purple', name: 'Crystal Ball (Purple)' },
|
||||
{ slug: 'crystal_ball_blue', name: 'Crystal Ball (Blue)' },
|
||||
{ slug: 'owlbear_cub', name: 'Owlbear Cub' },
|
||||
{ slug: 'owlbear_cub_snowy', name: 'Snowy Owlbear Cub' },
|
||||
{ slug: 'baby_displacer_beast', name: 'Baby Displacer Beast' },
|
||||
{ slug: 'dice_violet', name: 'Violet Dice' },
|
||||
{ slug: 'dice_azure', name: 'Azure Dice' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'elements',
|
||||
label: 'Elements',
|
||||
decorations: [
|
||||
{ slug: 'fire', name: 'Fire' },
|
||||
{ slug: 'water', name: 'Water' },
|
||||
{ slug: 'air', name: 'Air' },
|
||||
{ slug: 'earth', name: 'Earth' },
|
||||
{ slug: 'lightning', name: 'Lightning' },
|
||||
{ slug: 'balance', name: 'Balance' },
|
||||
{ slug: 'ki_energy', name: 'Ki Energy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'japanese',
|
||||
label: 'Japanese',
|
||||
decorations: [
|
||||
{ slug: 'kabuto', name: 'Kabuto' },
|
||||
{ slug: 'oni_mask', name: 'Oni Mask' },
|
||||
{ slug: 'sakura_warrior', name: 'Sakura Warrior' },
|
||||
{ slug: 'sakura_ink', name: 'Sakura Ink' },
|
||||
{ slug: 'shurikens_mask', name: "Shuriken's Mask" },
|
||||
{ slug: 'straw_hat', name: 'Straw Hat' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nature',
|
||||
label: 'Nature',
|
||||
decorations: [
|
||||
{ slug: 'lotus_flower', name: 'Lotus Flower' },
|
||||
{ slug: 'koi_pond', name: 'Koi Pond' },
|
||||
{ slug: 'sakura', name: 'Sakura' },
|
||||
{ slug: 'sakura_pink', name: 'Pink Sakura' },
|
||||
{ slug: 'fall_leaves', name: 'Fall Leaves' },
|
||||
{ slug: 'fall_leaves_scarlet', name: 'Scarlet Leaves' },
|
||||
{ slug: 'butterflies', name: 'Butterflies' },
|
||||
{ slug: 'honeyblossom', name: 'Honeyblossom' },
|
||||
{ slug: 'dandelion_duo', name: 'Dandelion Duo' },
|
||||
{ slug: 'lunar_lanterns', name: 'Lunar Lanterns' },
|
||||
{ slug: 'firecrackers', name: 'Firecrackers' },
|
||||
{ slug: 'dragons_smile', name: "Dragon's Smile" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'spooky',
|
||||
label: 'Spooky',
|
||||
decorations: [
|
||||
{ slug: 'candlelight', name: 'Candlelight' },
|
||||
{ slug: 'candlelight_crimson', name: 'Crimson Candlelight' },
|
||||
{ slug: 'witch_hat_midnight', name: 'Midnight Witch Hat' },
|
||||
{ slug: 'witch_hat_plum', name: 'Plum Witch Hat' },
|
||||
{ slug: 'hood_dark', name: 'Dark Hood' },
|
||||
{ slug: 'hood_crimson', name: 'Crimson Hood' },
|
||||
{ slug: 'zombie_food', name: 'Zombie Food' },
|
||||
{ slug: 'bloodthirsty', name: 'Bloodthirsty' },
|
||||
{ slug: 'bloodthirsty_gold', name: 'Bloodthirsty (Gold)' },
|
||||
{ slug: 'jack_o_lantern', name: "Jack-o'-Lantern" },
|
||||
{ slug: 'pumpkin_spice', name: 'Pumpkin Spice' },
|
||||
{ slug: 'spooky_cat_ears', name: 'Spooky Cat Ears' },
|
||||
{ slug: 'ghosts', name: 'Ghosts' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'cozy',
|
||||
label: 'Cozy',
|
||||
decorations: [
|
||||
{ slug: 'cozy_cat', name: 'Cozy Cat' },
|
||||
{ slug: 'rainy_mood', name: 'Rainy Mood' },
|
||||
{ slug: 'oasis', name: 'Oasis' },
|
||||
{ slug: 'cozy_headphones', name: 'Cozy Headphones' },
|
||||
{ slug: 'doodling', name: 'Doodling' },
|
||||
{ slug: 'fox_hat', name: 'Fox Hat' },
|
||||
{ slug: 'fox_hat_chestnut', name: 'Chestnut Fox Hat' },
|
||||
{ slug: 'fox_hat_snow', name: 'Snow Fox Hat' },
|
||||
{ slug: 'cat_ears', name: 'Cat Ears' },
|
||||
{ slug: 'frog_hat', name: 'Frog Hat' },
|
||||
{ slug: 'polar_bear_hat', name: 'Polar Bear Hat' },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const ALL_DECORATIONS: AvatarDecoration[] = DECORATION_CATEGORIES.flatMap(
|
||||
(c) => c.decorations,
|
||||
);
|
||||
|
||||
export function decorationUrl(slug: string): string {
|
||||
return `${DECORATION_CDN}/${slug}.png`;
|
||||
}
|
||||
@@ -197,11 +197,11 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
||||
].join(','),
|
||||
},
|
||||
|
||||
// Animated: Matrix digital rain — scrolling vertical green stripes
|
||||
// Animated: Matrix digital rain — scrolling stripe columns + phosphor glow flicker
|
||||
'anim-rain': {
|
||||
backgroundColor: '#010804',
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(180deg, rgba(0,255,136,0.13) 0px, rgba(0,255,136,0.13) 1px, transparent 1px, transparent 20px)',
|
||||
'repeating-linear-gradient(180deg, rgba(0,255,136,0.16) 0px, rgba(0,255,136,0.16) 1px, transparent 1px, transparent 20px)',
|
||||
'repeating-linear-gradient(180deg, rgba(0,255,136,0.07) 0px, rgba(0,255,136,0.07) 1px, transparent 1px, transparent 8px)',
|
||||
].join(','),
|
||||
backgroundSize: '40px 200px, 12px 200px',
|
||||
@@ -209,7 +209,7 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
||||
animation: `${animRainKeyframe} 8s linear infinite`,
|
||||
},
|
||||
|
||||
// Animated: drifting star field — three layers at different speeds
|
||||
// Animated: drifting star field — three seamlessly-tiling layers at different speeds
|
||||
'anim-stars': {
|
||||
backgroundColor: '#050510',
|
||||
backgroundImage: [
|
||||
@@ -219,10 +219,10 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
||||
].join(','),
|
||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
||||
animation: `${animStarsDriftKeyframe} 25s linear infinite`,
|
||||
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
|
||||
},
|
||||
|
||||
// Animated: neon grid pulse — grid lines that expand/contract
|
||||
// Animated: neon grid pulse — size breathe + independent brightness oscillation
|
||||
'anim-pulse': {
|
||||
backgroundColor: '#030508',
|
||||
backgroundImage: [
|
||||
@@ -235,31 +235,31 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
||||
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
|
||||
},
|
||||
|
||||
// Animated: aurora borealis — slowly drifting gradient bands
|
||||
// Animated: aurora borealis — four bands each travel an independent path
|
||||
'anim-aurora': {
|
||||
backgroundColor: '#020a10',
|
||||
backgroundImage: [
|
||||
'radial-gradient(ellipse at 20% 30%, rgba(0,255,136,0.10) 0%, transparent 55%)',
|
||||
'radial-gradient(ellipse at 80% 70%, rgba(0,100,255,0.10) 0%, transparent 55%)',
|
||||
'radial-gradient(ellipse at 50% 10%, rgba(191,95,255,0.08) 0%, transparent 50%)',
|
||||
'radial-gradient(ellipse at 60% 90%, rgba(0,212,255,0.08) 0%, transparent 50%)',
|
||||
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,255,136,0.12) 0%, transparent 70%)',
|
||||
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,100,255,0.12) 0%, transparent 70%)',
|
||||
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(191,95,255,0.09) 0%, transparent 65%)',
|
||||
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,212,255,0.09) 0%, transparent 65%)',
|
||||
].join(','),
|
||||
backgroundSize: '200% 200%',
|
||||
backgroundPosition: '0% 0%',
|
||||
animation: `${animAuroraKeyframe} 20s ease-in-out infinite`,
|
||||
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
|
||||
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
|
||||
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
|
||||
},
|
||||
|
||||
// Animated: fireflies — three layers of glowing dots at different speeds
|
||||
// Animated: fireflies — drift + brightness glow + opacity blink at prime periods
|
||||
'anim-fireflies': {
|
||||
backgroundColor: '#030508',
|
||||
backgroundImage: [
|
||||
'radial-gradient(circle, rgba(255,220,50,0.55) 1.5px, rgba(255,160,0,0.15) 3px, transparent 4px)',
|
||||
'radial-gradient(circle, rgba(255,200,30,0.45) 1px, rgba(255,140,0,0.12) 2.5px, transparent 3.5px)',
|
||||
'radial-gradient(circle, rgba(255,240,100,0.35) 1px, transparent 2px)',
|
||||
'radial-gradient(circle, rgba(255,220,50,0.7) 1.5px, rgba(255,160,0,0.18) 3px, transparent 4px)',
|
||||
'radial-gradient(circle, rgba(255,200,30,0.55) 1px, rgba(255,140,0,0.14) 2.5px, transparent 3.5px)',
|
||||
'radial-gradient(circle, rgba(255,240,100,0.4) 1px, transparent 2px)',
|
||||
].join(','),
|
||||
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
|
||||
backgroundPosition: '0 0, 120px 80px, 60px 140px',
|
||||
animation: `${animFirefliesKeyframe} 15s linear infinite`,
|
||||
animation: `${animFirefliesKeyframe} 30s linear infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -423,7 +423,7 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
||||
'anim-rain': {
|
||||
backgroundColor: '#f0fff4',
|
||||
backgroundImage: [
|
||||
'repeating-linear-gradient(180deg, rgba(0,160,80,0.14) 0px, rgba(0,160,80,0.14) 1px, transparent 1px, transparent 20px)',
|
||||
'repeating-linear-gradient(180deg, rgba(0,160,80,0.16) 0px, rgba(0,160,80,0.16) 1px, transparent 1px, transparent 20px)',
|
||||
'repeating-linear-gradient(180deg, rgba(0,160,80,0.07) 0px, rgba(0,160,80,0.07) 1px, transparent 1px, transparent 8px)',
|
||||
].join(','),
|
||||
backgroundSize: '40px 200px, 12px 200px',
|
||||
@@ -440,7 +440,7 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
||||
].join(','),
|
||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
||||
animation: `${animStarsDriftKeyframe} 25s linear infinite`,
|
||||
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
|
||||
},
|
||||
|
||||
'anim-pulse': {
|
||||
@@ -458,26 +458,26 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
||||
'anim-aurora': {
|
||||
backgroundColor: '#f0f8f4',
|
||||
backgroundImage: [
|
||||
'radial-gradient(ellipse at 20% 30%, rgba(0,160,80,0.12) 0%, transparent 55%)',
|
||||
'radial-gradient(ellipse at 80% 70%, rgba(0,80,200,0.12) 0%, transparent 55%)',
|
||||
'radial-gradient(ellipse at 50% 10%, rgba(140,60,220,0.09) 0%, transparent 50%)',
|
||||
'radial-gradient(ellipse at 60% 90%, rgba(0,160,200,0.09) 0%, transparent 50%)',
|
||||
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,160,80,0.13) 0%, transparent 70%)',
|
||||
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,80,200,0.13) 0%, transparent 70%)',
|
||||
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(140,60,220,0.10) 0%, transparent 65%)',
|
||||
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,160,200,0.10) 0%, transparent 65%)',
|
||||
].join(','),
|
||||
backgroundSize: '200% 200%',
|
||||
backgroundPosition: '0% 0%',
|
||||
animation: `${animAuroraKeyframe} 20s ease-in-out infinite`,
|
||||
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
|
||||
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
|
||||
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
|
||||
},
|
||||
|
||||
'anim-fireflies': {
|
||||
backgroundColor: '#fffdf0',
|
||||
backgroundImage: [
|
||||
'radial-gradient(circle, rgba(180,120,0,0.55) 1.5px, rgba(160,90,0,0.15) 3px, transparent 4px)',
|
||||
'radial-gradient(circle, rgba(160,100,0,0.45) 1px, rgba(140,80,0,0.12) 2.5px, transparent 3.5px)',
|
||||
'radial-gradient(circle, rgba(200,140,0,0.35) 1px, transparent 2px)',
|
||||
'radial-gradient(circle, rgba(180,120,0,0.70) 1.5px, rgba(160,90,0,0.18) 3px, transparent 4px)',
|
||||
'radial-gradient(circle, rgba(160,100,0,0.55) 1px, rgba(140,80,0,0.14) 2.5px, transparent 3.5px)',
|
||||
'radial-gradient(circle, rgba(200,140,0,0.40) 1px, transparent 2px)',
|
||||
].join(','),
|
||||
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
|
||||
backgroundPosition: '0 0, 120px 80px, 60px 140px',
|
||||
animation: `${animFirefliesKeyframe} 15s linear infinite`,
|
||||
animation: `${animFirefliesKeyframe} 30s linear infinite`,
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@ import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
||||
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
|
||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
|
||||
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
||||
|
||||
type MemberDrawerHeaderProps = {
|
||||
room: Room;
|
||||
@@ -150,16 +151,18 @@ function MemberItem({
|
||||
radii="400"
|
||||
onClick={onClick}
|
||||
before={
|
||||
<PresenceRingAvatar userId={member.userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={member.userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
<AvatarDecoration userId={member.userId}>
|
||||
<PresenceRingAvatar userId={member.userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={member.userId}
|
||||
src={avatarUrl ?? undefined}
|
||||
alt={name}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
</AvatarDecoration>
|
||||
}
|
||||
after={
|
||||
<>
|
||||
@@ -442,16 +445,18 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
||||
gap="200"
|
||||
style={{ padding: `0 ${config.space.S200}` }}
|
||||
>
|
||||
<PresenceRingAvatar userId={knockMember.userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={knockMember.userId}
|
||||
src={knockAvatarUrl ?? undefined}
|
||||
alt={knockName}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
<AvatarDecoration userId={knockMember.userId}>
|
||||
<PresenceRingAvatar userId={knockMember.userId}>
|
||||
<Avatar size="200">
|
||||
<UserAvatar
|
||||
userId={knockMember.userId}
|
||||
src={knockAvatarUrl ?? undefined}
|
||||
alt={knockName}
|
||||
renderFallback={() => <Icon size="50" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
</AvatarDecoration>
|
||||
<Box grow="Yes" direction="Column">
|
||||
<Text size="T400" truncate>
|
||||
{knockName}
|
||||
|
||||
@@ -83,6 +83,7 @@ import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
||||
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
||||
import { useBookmarks } from '../../../hooks/useBookmarks';
|
||||
import { PresenceRingAvatar } from '../../../components/presence';
|
||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||
|
||||
// Delivery status indicator for own messages
|
||||
function DeliveryStatus({
|
||||
@@ -874,27 +875,29 @@ export const Message = React.memo(
|
||||
<AvatarBase
|
||||
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
||||
>
|
||||
<PresenceRingAvatar userId={senderId}>
|
||||
<Avatar
|
||||
className={css.MessageAvatar}
|
||||
as="button"
|
||||
size="300"
|
||||
data-user-id={senderId}
|
||||
onClick={onUserClick}
|
||||
>
|
||||
<UserAvatar
|
||||
userId={senderId}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
|
||||
undefined)
|
||||
: undefined
|
||||
}
|
||||
alt={senderDisplayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
<AvatarDecoration userId={senderId}>
|
||||
<PresenceRingAvatar userId={senderId}>
|
||||
<Avatar
|
||||
className={css.MessageAvatar}
|
||||
as="button"
|
||||
size="300"
|
||||
data-user-id={senderId}
|
||||
onClick={onUserClick}
|
||||
>
|
||||
<UserAvatar
|
||||
userId={senderId}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? (mxcUrlToHttp(mx, senderAvatarMxc, useAuthentication, 48, 48, 'crop') ??
|
||||
undefined)
|
||||
: undefined
|
||||
}
|
||||
alt={senderDisplayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
</AvatarDecoration>
|
||||
</AvatarBase>
|
||||
);
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||
import { useUserPresence } from '../../../hooks/useUserPresence';
|
||||
import { ProfileDecoration } from './ProfileDecoration';
|
||||
import { EmojiBoard } from '../../../components/emoji-board';
|
||||
|
||||
type ProfileProps = {
|
||||
@@ -892,6 +893,7 @@ export function Profile() {
|
||||
<ProfileStatus />
|
||||
<ProfilePronouns />
|
||||
<ProfileTimezone />
|
||||
<ProfileDecoration />
|
||||
</SequenceCard>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,262 @@
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Box, Text, Spinner } from 'folds';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||
import { SettingTile } from '../../../components/setting-tile';
|
||||
import {
|
||||
DECORATION_CATEGORIES,
|
||||
DECORATION_CDN,
|
||||
decorationUrl,
|
||||
} from '../../lotus/avatarDecorations';
|
||||
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||
|
||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||
const CELL_SIZE = 72;
|
||||
|
||||
function DecorationPreviewCell({
|
||||
slug,
|
||||
name,
|
||||
selected,
|
||||
onSelect,
|
||||
}: {
|
||||
slug: string;
|
||||
name: string;
|
||||
selected: boolean;
|
||||
onSelect: (slug: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
title={name}
|
||||
aria-label={name}
|
||||
aria-pressed={selected}
|
||||
onClick={() => onSelect(slug)}
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: CELL_SIZE,
|
||||
height: CELL_SIZE,
|
||||
flexShrink: 0,
|
||||
border: `2px solid ${selected ? 'var(--accent-cyan)' : 'transparent'}`,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--bg-surface-variant)',
|
||||
cursor: 'pointer',
|
||||
padding: 0,
|
||||
boxShadow: selected ? '0 0 0 1px var(--accent-cyan)' : 'none',
|
||||
overflow: 'hidden',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`${DECORATION_CDN}/${slug}.png`}
|
||||
alt={name}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProfileDecoration() {
|
||||
const mx = useMatrixClient();
|
||||
const userId = mx.getUserId()!;
|
||||
|
||||
const [current, setCurrent] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
mx.http
|
||||
.authedRequest<Record<string, string>>(
|
||||
Method.Get,
|
||||
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
|
||||
)
|
||||
.then((res) => {
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
setCurrent(val);
|
||||
setSelected(val);
|
||||
})
|
||||
.catch(() => {
|
||||
setCurrent(null);
|
||||
setSelected(null);
|
||||
});
|
||||
}, [mx, userId]);
|
||||
|
||||
const [saveState, save] = useAsyncCallback(
|
||||
useCallback(
|
||||
async (slug: string | null) => {
|
||||
await mx.http.authedRequest(
|
||||
Method.Put,
|
||||
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
|
||||
undefined,
|
||||
{ [PROFILE_FIELD]: slug ?? '' },
|
||||
);
|
||||
setCurrent(slug);
|
||||
invalidateDecorationCache(userId);
|
||||
},
|
||||
[mx, userId],
|
||||
),
|
||||
);
|
||||
|
||||
const saving = saveState.status === AsyncStatus.Loading;
|
||||
const hasChanges = selected !== current;
|
||||
|
||||
const handleSelect = (slug: string) => {
|
||||
setSelected((prev) => (prev === slug ? null : slug));
|
||||
};
|
||||
|
||||
const handleClear = () => setSelected(null);
|
||||
|
||||
const handleSave = () => {
|
||||
if (!hasChanges || saving) return;
|
||||
save(selected);
|
||||
};
|
||||
|
||||
return (
|
||||
<SettingTile
|
||||
title={
|
||||
<Text as="span" size="L400">
|
||||
Avatar Decoration
|
||||
</Text>
|
||||
}
|
||||
description={
|
||||
<Text size="T200" priority="300">
|
||||
Shown on your avatar to all Lotus Chat users.
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Box direction="Column" gap="300">
|
||||
{/* Current selection preview */}
|
||||
<Box alignItems="Center" gap="300">
|
||||
<div
|
||||
style={{
|
||||
position: 'relative',
|
||||
width: CELL_SIZE,
|
||||
height: CELL_SIZE,
|
||||
flexShrink: 0,
|
||||
borderRadius: '50%',
|
||||
background: 'var(--bg-surface-variant)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{selected && (
|
||||
<img
|
||||
src={decorationUrl(selected)}
|
||||
alt="Selected decoration preview"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Box grow="Yes" direction="Column" gap="100">
|
||||
<Text size="T300">
|
||||
{selected
|
||||
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
|
||||
(d) => d.slug === selected,
|
||||
)?.name ?? selected)
|
||||
: 'None'}
|
||||
</Text>
|
||||
{selected && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
color: 'var(--tc-surface-low-contrast)',
|
||||
fontSize: '0.8rem',
|
||||
textAlign: 'left',
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
)}
|
||||
</Box>
|
||||
{hasChanges && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: 6,
|
||||
border: '1px solid var(--accent-cyan)',
|
||||
background: 'transparent',
|
||||
color: 'var(--accent-cyan)',
|
||||
cursor: saving ? 'not-allowed' : 'pointer',
|
||||
fontSize: '0.85rem',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 6,
|
||||
opacity: saving ? 0.6 : 1,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{saving && <Spinner size="100" variant="Secondary" />}
|
||||
{saving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{saveState.status === AsyncStatus.Error && (
|
||||
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
|
||||
Failed to save. Try again.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{/* Category grid */}
|
||||
<div
|
||||
style={{
|
||||
maxHeight: 480,
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
paddingRight: 4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: 24,
|
||||
}}
|
||||
>
|
||||
{DECORATION_CATEGORIES.map((category) => (
|
||||
<div key={category.id} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
<Text size="L400" style={{ opacity: 0.7 }}>
|
||||
{category.label}
|
||||
</Text>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
|
||||
gap: 20,
|
||||
}}
|
||||
>
|
||||
{category.decorations.map((d) => (
|
||||
<DecorationPreviewCell
|
||||
key={d.slug}
|
||||
slug={d.slug}
|
||||
name={d.name}
|
||||
selected={selected === d.slug}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Box>
|
||||
</SettingTile>
|
||||
);
|
||||
}
|
||||
@@ -154,6 +154,82 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
type SettingsSelectOption<T extends string> = { value: T; label: string };
|
||||
|
||||
function SettingsSelect<T extends string>({
|
||||
value,
|
||||
options,
|
||||
onChange,
|
||||
}: {
|
||||
value: T;
|
||||
options: SettingsSelectOption<T>[];
|
||||
onChange: (v: T) => void;
|
||||
}) {
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
|
||||
|
||||
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
};
|
||||
|
||||
const handleSelect = (v: T) => {
|
||||
onChange(v);
|
||||
setMenuCords(undefined);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button
|
||||
size="300"
|
||||
variant="Secondary"
|
||||
outlined
|
||||
fill="Soft"
|
||||
radii="300"
|
||||
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||
onClick={handleMenu}
|
||||
>
|
||||
<Text size="T300">{selectedLabel}</Text>
|
||||
</Button>
|
||||
<PopOut
|
||||
anchor={menuCords}
|
||||
offset={5}
|
||||
position="Bottom"
|
||||
align="End"
|
||||
content={
|
||||
<FocusTrap
|
||||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: () => setMenuCords(undefined),
|
||||
clickOutsideDeactivates: true,
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<Menu>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
{options.map((opt) => (
|
||||
<MenuItem
|
||||
key={opt.value}
|
||||
size="300"
|
||||
variant={opt.value === value ? 'Primary' : 'Surface'}
|
||||
radii="300"
|
||||
onClick={() => handleSelect(opt.value)}
|
||||
>
|
||||
<Text size="T300">{opt.label}</Text>
|
||||
</MenuItem>
|
||||
))}
|
||||
</Box>
|
||||
</Menu>
|
||||
</FocusTrap>
|
||||
}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function SystemThemePreferences() {
|
||||
const themeKind = useSystemThemeKind();
|
||||
const themeNames = useThemeNames();
|
||||
@@ -341,6 +417,10 @@ function Appearance() {
|
||||
'mentionHighlightColor',
|
||||
);
|
||||
const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily');
|
||||
const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting(
|
||||
settingsAtom,
|
||||
'seasonalThemeOverride',
|
||||
);
|
||||
|
||||
return (
|
||||
<Box direction="Column" gap="100">
|
||||
@@ -408,6 +488,34 @@ function Appearance() {
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Seasonal Theme"
|
||||
description="Decorative overlays that activate automatically on holidays and events, or choose one manually."
|
||||
after={
|
||||
<SettingsSelect
|
||||
value={seasonalThemeOverride ?? 'auto'}
|
||||
onChange={(v) => setSeasonalThemeOverride(v as typeof seasonalThemeOverride)}
|
||||
options={[
|
||||
{ value: 'auto', label: '🗓 Auto (date-based)' },
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'newyear', label: '🎆 New Year' },
|
||||
{ value: 'lunar', label: '🏮 Lunar New Year' },
|
||||
{ value: 'valentines', label: '💖 Valentine\'s Day' },
|
||||
{ value: 'stpatricks', label: '🍀 St. Patrick\'s Day' },
|
||||
{ value: 'aprilfools', label: '🃏 April Fool\'s Day' },
|
||||
{ value: 'earthday', label: '🌱 Earth Day' },
|
||||
{ value: 'autumn', label: '🍂 Autumn' },
|
||||
{ value: 'halloween', label: '🎃 Halloween' },
|
||||
{ value: 'christmas', label: '❄️ Christmas' },
|
||||
{ value: 'arcade', label: '👾 Retro Arcade Day' },
|
||||
{ value: 'deepspace', label: '🚀 Deep Space Week' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||
<SettingTile
|
||||
title="Show Profile on Every Message"
|
||||
@@ -507,26 +615,18 @@ function Appearance() {
|
||||
title="UI Font"
|
||||
description="Font used throughout the interface."
|
||||
after={
|
||||
<select
|
||||
<SettingsSelect
|
||||
value={fontFamily ?? 'inter'}
|
||||
onChange={(e) =>
|
||||
setFontFamily(e.target.value as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
|
||||
onChange={(v) =>
|
||||
setFontFamily(v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
|
||||
}
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: '14px',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="system">System Default</option>
|
||||
<option value="inter">Inter (default)</option>
|
||||
<option value="jetbrains-mono">JetBrains Mono</option>
|
||||
<option value="fira-code">Fira Code</option>
|
||||
</select>
|
||||
options={[
|
||||
{ value: 'system', label: 'System Default' },
|
||||
{ value: 'inter', label: 'Inter (default)' },
|
||||
{ value: 'jetbrains-mono', label: 'JetBrains Mono' },
|
||||
{ value: 'fira-code', label: 'Fira Code' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
@@ -1214,25 +1314,17 @@ function Calls() {
|
||||
title="Idle Timeout"
|
||||
description="How long to wait before auto-muting."
|
||||
after={
|
||||
<select
|
||||
value={afkTimeoutMinutes}
|
||||
onChange={(e) => setAfkTimeoutMinutes(Number(e.target.value))}
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value={1}>1 minute</option>
|
||||
<option value={5}>5 minutes</option>
|
||||
<option value={10}>10 minutes</option>
|
||||
<option value={20}>20 minutes</option>
|
||||
<option value={30}>30 minutes</option>
|
||||
</select>
|
||||
<SettingsSelect
|
||||
value={String(afkTimeoutMinutes ?? 10)}
|
||||
onChange={(v) => setAfkTimeoutMinutes(Number(v))}
|
||||
options={[
|
||||
{ value: '1', label: '1 minute' },
|
||||
{ value: '5', label: '5 minutes' },
|
||||
{ value: '10', label: '10 minutes' },
|
||||
{ value: '20', label: '20 minutes' },
|
||||
{ value: '30', label: '30 minutes' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
@@ -1242,26 +1334,16 @@ function Calls() {
|
||||
title="Join & Leave Sounds"
|
||||
description="Play a sound when someone joins or leaves a call you are in."
|
||||
after={
|
||||
<select
|
||||
<SettingsSelect
|
||||
value={callJoinLeaveSound}
|
||||
onChange={(e) =>
|
||||
handleJoinLeaveSoundChange(e.target.value as 'off' | 'chime' | 'soft' | 'retro')
|
||||
}
|
||||
style={{
|
||||
background: 'var(--bg-surface)',
|
||||
color: 'inherit',
|
||||
border: '1px solid var(--border-interactive-normal)',
|
||||
borderRadius: '6px',
|
||||
padding: '4px 8px',
|
||||
fontSize: 'inherit',
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
<option value="off">Off</option>
|
||||
<option value="chime">Chime</option>
|
||||
<option value="soft">Soft</option>
|
||||
<option value="retro">Retro</option>
|
||||
</select>
|
||||
onChange={(v) => handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro')}
|
||||
options={[
|
||||
{ value: 'off', label: 'Off' },
|
||||
{ value: 'chime', label: 'Chime' },
|
||||
{ value: 'soft', label: 'Soft' },
|
||||
{ value: 'retro', label: 'Retro' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</SequenceCard>
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Method } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from './useMatrixClient';
|
||||
|
||||
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||
|
||||
// Module-level cache — survives re-renders, lives for the app session.
|
||||
// userId → slug | null (null = fetched, no decoration set)
|
||||
const cache = new Map<string, string | null>();
|
||||
// Callbacks waiting for a userId's result
|
||||
const pending = new Map<string, Array<(val: string | null) => void>>();
|
||||
|
||||
function fetchDecoration(
|
||||
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
||||
userId: string,
|
||||
): Promise<string | null> {
|
||||
if (cache.has(userId)) return Promise.resolve(cache.get(userId) ?? null);
|
||||
|
||||
// De-duplicate in-flight requests for the same userId
|
||||
if (pending.has(userId)) {
|
||||
return new Promise((resolve) => {
|
||||
pending.get(userId)!.push(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
const waiters: Array<(val: string | null) => void> = [];
|
||||
pending.set(userId, waiters);
|
||||
|
||||
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`)
|
||||
.then((res) => {
|
||||
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||
cache.set(userId, val);
|
||||
return val;
|
||||
})
|
||||
.catch(() => {
|
||||
cache.set(userId, null);
|
||||
return null;
|
||||
})
|
||||
.finally(() => {
|
||||
const v = cache.get(userId) ?? null;
|
||||
pending.delete(userId);
|
||||
waiters.forEach((cb) => cb(v));
|
||||
});
|
||||
}
|
||||
|
||||
export function invalidateDecorationCache(userId: string): void {
|
||||
cache.delete(userId);
|
||||
}
|
||||
|
||||
export function useAvatarDecoration(userId: string): string | null {
|
||||
const mx = useMatrixClient();
|
||||
const [slug, setSlug] = useState<string | null>(() => cache.get(userId) ?? null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
fetchDecoration(
|
||||
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
|
||||
userId,
|
||||
).then((val) => {
|
||||
if (!cancelled) setSlug(val);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [mx, userId]);
|
||||
|
||||
return slug;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -97,6 +97,7 @@ import {
|
||||
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||
import { PresenceRingAvatar } from '../../../components/presence';
|
||||
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||
|
||||
type RoomNotificationsGroup = {
|
||||
roomId: string;
|
||||
@@ -479,27 +480,29 @@ function RoomNotificationsGroupComp({
|
||||
<ModernLayout
|
||||
before={
|
||||
<AvatarBase>
|
||||
<PresenceRingAvatar userId={event.sender}>
|
||||
<Avatar size="300">
|
||||
<UserAvatar
|
||||
userId={event.sender}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? (mxcUrlToHttp(
|
||||
mx,
|
||||
senderAvatarMxc,
|
||||
useAuthentication,
|
||||
48,
|
||||
48,
|
||||
'crop',
|
||||
) ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
<AvatarDecoration userId={event.sender}>
|
||||
<PresenceRingAvatar userId={event.sender}>
|
||||
<Avatar size="300">
|
||||
<UserAvatar
|
||||
userId={event.sender}
|
||||
src={
|
||||
senderAvatarMxc
|
||||
? (mxcUrlToHttp(
|
||||
mx,
|
||||
senderAvatarMxc,
|
||||
useAuthentication,
|
||||
48,
|
||||
48,
|
||||
'crop',
|
||||
) ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
alt={displayName}
|
||||
renderFallback={() => <Icon size="200" src={Icons.User} filled />}
|
||||
/>
|
||||
</Avatar>
|
||||
</PresenceRingAvatar>
|
||||
</AvatarDecoration>
|
||||
</AvatarBase>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
|
||||
@@ -46,6 +46,11 @@ const copyFiles = {
|
||||
dest: 'public/',
|
||||
rename: { stripBase: 1 },
|
||||
},
|
||||
{
|
||||
src: 'public/fonts',
|
||||
dest: '',
|
||||
rename: { stripBase: 1 },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user