Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 388a934665 | |||
| 99e6a456a7 | |||
| a5fe358313 | |||
| d7d7b59866 | |||
| 362ccff85d | |||
| 6ec0ab78d9 | |||
| e9a970a75b | |||
| 2a545b8b3e | |||
| bf1308dd55 | |||
| ca09e8e6ca | |||
| 6db07f1371 | |||
| 107921e0d0 | |||
| 053b364a44 | |||
| 3282832a4a | |||
| 00524bebe0 | |||
| 9df4d2d7ee | |||
| 2c5f0b8b28 | |||
| 702e2e00eb | |||
| 2b1c3256b6 | |||
| 6a57c13c56 | |||
| 362f4943d4 | |||
| f15c4caf97 | |||
| aa48c9ef8a | |||
| 3df9c4d9e6 | |||
| 2178295eaa | |||
| 055dcec65b | |||
| 9a24feb914 | |||
| 46567555e1 | |||
| b41bfd35c0 | |||
| 6a83e67f95 | |||
| 469b9aa9c6 | |||
| 77a29ed3c6 | |||
| a30a3d3a47 | |||
| a9787ef041 | |||
| fcf16fd654 | |||
| 0a14ec63de | |||
| 5469740f4c | |||
| 891f2daf99 | |||
| b7daabe2e0 | |||
| d78f81c3a7 | |||
| 170d22eebb | |||
| c8ff7b0718 | |||
| bafd9cbe75 | |||
| f11b308f91 | |||
| 81e1a25de6 | |||
| 8ff2f33d3a | |||
| ea15db430c | |||
| 7e4178f7e2 |
@@ -0,0 +1,30 @@
|
|||||||
|
name: Trigger Desktop Build
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [lotus]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
trigger:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Bump cinny submodule
|
||||||
|
env:
|
||||||
|
TOKEN: ${{ secrets.RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
CINNY_SHA="${{ github.sha }}"
|
||||||
|
git clone "https://x-access-token:$TOKEN@code.lotusguild.org/LotusGuild/cinny-desktop.git" desktop
|
||||||
|
cd desktop
|
||||||
|
git config user.email "ci@lotusguild.org"
|
||||||
|
git config user.name "Lotus CI"
|
||||||
|
git submodule update --init cinny
|
||||||
|
git -C cinny fetch origin
|
||||||
|
git -C cinny checkout "$CINNY_SHA"
|
||||||
|
git add cinny
|
||||||
|
if git diff --cached --quiet; then
|
||||||
|
echo "Submodule already at $CINNY_SHA, nothing to do"
|
||||||
|
else
|
||||||
|
git commit -m "chore: bump cinny submodule to ${CINNY_SHA:0:8}"
|
||||||
|
git push origin main
|
||||||
|
echo "Pushed — cinny-desktop release.yml will start via on:push trigger"
|
||||||
|
fi
|
||||||
@@ -5,3 +5,4 @@ devAssets
|
|||||||
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.ideapackage-lock.json
|
.ideapackage-lock.json
|
||||||
|
public/decorations/
|
||||||
|
|||||||
@@ -0,0 +1,152 @@
|
|||||||
|
# Lotus Chat — Bug Report & Technical Audit
|
||||||
|
**Date:** June 2026
|
||||||
|
|
||||||
|
This document tracks identified bugs, edge cases, and architectural discrepancies found during the audit of the Lotus Chat codebase. Recommended fixes are provided for each item.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛡️ Critical Security & Privacy Regressions
|
||||||
|
|
||||||
|
### 1. E2EE Bypass in Media Gallery Downloads
|
||||||
|
**File:** `src/app/features/room/MediaGallery.tsx` (Line 855)
|
||||||
|
**Status:** **CRITICAL**
|
||||||
|
|
||||||
|
* **Issue:** The "Download" button in the Files tab uses `mxcUrlToHttp` directly and clicks an `<a>` link.
|
||||||
|
* **Impact:** In encrypted rooms, this downloads the encrypted ciphertext rather than the decrypted file. Users cannot open the downloaded files.
|
||||||
|
* **Recommended Fix:**
|
||||||
|
1. Check if the event is encrypted.
|
||||||
|
2. If encrypted, use the `decryptAttachment` logic (similar to `useDecryptedMediaUrl`) to decrypt the file in memory.
|
||||||
|
3. Use `file-saver` or a Blob URL to trigger the download of the decrypted plaintext.
|
||||||
|
|
||||||
|
### 2. Privacy Leak in URL Previews
|
||||||
|
**File:** `src/app/components/url-preview/UrlPreviewCard.tsx` (Line 1655)
|
||||||
|
**Status:** **PRIVACY RISK**
|
||||||
|
|
||||||
|
* **Issue:** Generic URL preview cards fetch favicons directly from `https://www.google.com/s2/favicons?domain=...`.
|
||||||
|
* **Impact:** This leaks the user's browsing/chat activity (domains of links they see) to Google. It bypasses the "proxied through Matrix" privacy standard.
|
||||||
|
* **Recommended Fix:** Use the proxy URL returned by the Matrix `/_matrix/media/v3/preview_url` endpoint instead of contacting Google directly.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚩 Functional & Logic Bugs
|
||||||
|
|
||||||
|
### 1. Presence Updater Reverts Status Updates
|
||||||
|
**File:** `src/app/hooks/usePresenceUpdater.ts` (Line 20)
|
||||||
|
**Status:** ✅ RESOLVED (June 2026)
|
||||||
|
|
||||||
|
* **Issue:** The `storedStatus` variable was captured once when the `useEffect` started.
|
||||||
|
* **Impact:** If a user updated their status message in Profile Settings, the hook would continue broadcasting the old message on every activity event, silently reverting the change.
|
||||||
|
* **Fix Applied:** Replaced the single `localStorage.getItem` read with a `readStatus()` function called inside every `setOnline` and `setUnavailable` invocation, ensuring the current value is always used.
|
||||||
|
|
||||||
|
### 2. Audio Playback Rate Reset
|
||||||
|
**File:** `src/app/components/message/content/AudioContent.tsx` (Line 97)
|
||||||
|
**Status:** UX Bug
|
||||||
|
|
||||||
|
* **Issue:** The `playbackRate` is set in a `useEffect` that only depends on `[playbackSpeed]`.
|
||||||
|
* **Impact:** If a user selects a playback speed *before* the audio blob has finished loading, the `<audio>` element may reset its rate to 1.0 once the `<source>` is added.
|
||||||
|
* **Recommended Fix:** Add `srcState.data` to the `useEffect` dependencies or set the `playbackRate` in an `onCanPlay` handler on the audio element.
|
||||||
|
|
||||||
|
### 3. Room Insights are Static (No Live Updates)
|
||||||
|
**File:** `src/app/features/room-settings/RoomInsights.tsx` (Line 60)
|
||||||
|
**Status:** Medium Priority
|
||||||
|
|
||||||
|
* **Issue:** Stats are calculated in a `useMemo` that only depends on `[room]`.
|
||||||
|
* **Impact:** If new messages arrive while the Insights page is open, the statistics (message counts, top participants, etc.) do not update.
|
||||||
|
* **Recommended Fix:** Add a listener for `RoomEvent.Timeline` inside the component to trigger a recalculation when new events are added to the room.
|
||||||
|
|
||||||
|
### 4. Incorrect Ringing in Voice Rooms
|
||||||
|
**File:** `src/app/components/CallEmbedProvider.tsx`
|
||||||
|
**Status:** ✅ RESOLVED (June 2026)
|
||||||
|
|
||||||
|
* **Issue:** Joining a static voice room (Public Space channel) triggered the "Incoming Call" ringing animation and sound.
|
||||||
|
* **Root Cause:** `getStateEvent(room, StateEvent.SpaceParent)` always returned `undefined` because `m.space.parent` events use the parent space's room ID as the state key, not an empty string. The ringing suppression check silently failed.
|
||||||
|
* **Fix Applied:** Replaced `getStateEvent` with `getStateEvents` (plural), which returns all events of a given type regardless of state key. A room with any `m.space.parent` event is correctly identified as a space channel and suppresses ringing.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 UI/UX & Visual Consistency
|
||||||
|
|
||||||
|
### 1. Hardcoded Primary Color in Polls
|
||||||
|
**File:** `src/app/components/message/content/PollContent.tsx` (Line 245)
|
||||||
|
**Status:** TDS Violation
|
||||||
|
|
||||||
|
* **Issue:** Uses `rgba(var(--mx-primary-rgb, 0,132,255), ...)` for selected options and borders.
|
||||||
|
* **Impact:** Polls look like standard Cinny and ignore the Lotus Terminal Design System (TDS) colors.
|
||||||
|
* **Recommended Fix:** Use `var(--lt-accent-cyan)` or the primary theme accent color.
|
||||||
|
|
||||||
|
### 2. Inaccessible Room Menu on Mobile
|
||||||
|
**File:** `src/app/features/room-nav/RoomNavItem.tsx` (Line 643)
|
||||||
|
**Status:** **MOBILE BUG**
|
||||||
|
|
||||||
|
* **Issue:** The room menu icon (`VerticalDots`) is hidden on mobile because `hover` is never active.
|
||||||
|
* **Impact:** Mobile users cannot access room settings, mark as read, or leave rooms from the sidebar.
|
||||||
|
* **Recommended Fix:** Ensure the menu button is visible on mobile for the active room or provide an alternative trigger.
|
||||||
|
|
||||||
|
### 3. Inconsistent Settings Dropdown Styling
|
||||||
|
**File:** `src/app/features/settings/general/General.tsx`
|
||||||
|
**Status:** ✅ RESOLVED (June 2026)
|
||||||
|
|
||||||
|
* **Issue:** The dropdowns for "Join & Leave Sounds", "UI Font", and "Seasonal Theme" used raw HTML `<select>` elements, which render differently from the custom-styled `Menu`+`PopOut` used for "Message Layout" and other settings.
|
||||||
|
* **Fix Applied:** All three raw `<select>` elements replaced with a reusable `SettingsSelect` component using the Menu+PopOut+FocusTrap pattern consistent with the rest of the settings UI.
|
||||||
|
|
||||||
|
### 4. No Camera Focus During Screenshare
|
||||||
|
**File:** `src/app/features/call/CallControls.tsx`
|
||||||
|
**Status:** UX Bug — blocked by Element Call internals
|
||||||
|
|
||||||
|
* **Issue:** When someone is screensharing and another participant turns on their camera, there is no way to switch the primary display to the camera or go fullscreen on it.
|
||||||
|
* **Root Cause:** Element Call's spotlight/layout is controlled internally by EC. Lotus Chat injects the call iframe and cannot easily override EC's participant tile behavior without forking the EC widget.
|
||||||
|
* **Recommended Fix:** Implement a "Pin/Focus" toggle on participant tiles that overrides the automatic screenshare spotlight — requires EC upstream changes or a custom message bridge.
|
||||||
|
|
||||||
|
### 5. Ringing Modal Fires in Persistent Voice Rooms
|
||||||
|
**File:** `src/app/components/CallEmbedProvider.tsx` (line 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`.
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
# Lotus Chat — Technical Implementation Field Guide
|
||||||
|
**Date:** June 2026
|
||||||
|
|
||||||
|
This document provides exhaustive, low-level implementation details for the remaining items in `LOTUS_TODO.md`. Follow these patterns to ensure code is "Lotus-perfect" (idiomatic, performant, and TDS-compliant).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🧵 Priority 3 — Higher Complexity
|
||||||
|
|
||||||
|
### P3-8 · Thread Panel (Full Side Drawer)
|
||||||
|
**Architecture:** Mirror the `MembersDrawer` pattern but with a specialized timeline.
|
||||||
|
|
||||||
|
* **1. State (src/app/state/room/thread.ts):**
|
||||||
|
```typescript
|
||||||
|
export const activeThreadIdAtom = atom<string | null>(null);
|
||||||
|
```
|
||||||
|
* **2. Layout (src/app/features/room/Room.tsx):**
|
||||||
|
Insert the `ThreadPanel` conditionally alongside the `RoomTimeline`.
|
||||||
|
```tsx
|
||||||
|
{activeThreadId && (
|
||||||
|
<>
|
||||||
|
<Line variant="Background" direction="Vertical" size="300" />
|
||||||
|
<ThreadPanel roomId={roomId} threadId={activeThreadId} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
```
|
||||||
|
* **3. Component (src/app/features/room/thread/ThreadPanel.tsx):**
|
||||||
|
* Use `room.getThread(threadId)` from the SDK.
|
||||||
|
* Render a `Header` with a "Close" button that sets `activeThreadIdAtom` to `null`.
|
||||||
|
* Reuse `RoomTimeline` but pass a filtered `EventTimelineSet`.
|
||||||
|
* **Pro Tip:** Use `thread.timelineSet` directly for the most accurate thread view.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Priority 5 — Gamer / Aesthetic / Customization
|
||||||
|
|
||||||
|
### P5-1 · Custom Accent Color Picker (Non-TDS only)
|
||||||
|
**Mechanism:** Dynamic CSS variable injection.
|
||||||
|
|
||||||
|
* **1. Setting (src/app/state/settings.ts):**
|
||||||
|
Add `customAccentColor: string` (hex).
|
||||||
|
* **2. Manager (src/app/pages/ThemeManager.tsx):**
|
||||||
|
Inside the `useEffect` that monitors theme changes:
|
||||||
|
```typescript
|
||||||
|
if (!lotusTerminal && customAccentColor) {
|
||||||
|
document.documentElement.style.setProperty('--lt-accent-orange', customAccentColor);
|
||||||
|
// Also derive a 'glow' version (e.g. 50% opacity)
|
||||||
|
document.documentElement.style.setProperty('--lt-accent-orange-glow', `${customAccentColor}80`);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **3. UI (src/app/features/settings/general/General.tsx):**
|
||||||
|
Use a `<Input type="color">` component. Hide this section if `lotusTerminal` is `true`.
|
||||||
|
|
||||||
|
### P5-14 · Animated Avatar Overlay
|
||||||
|
**Mechanism:** CSS Pseudo-element wrapping.
|
||||||
|
|
||||||
|
* **1. Wrapper (src/app/components/user-avatar/UserAvatar.tsx):**
|
||||||
|
```tsx
|
||||||
|
const avatar = (
|
||||||
|
<AvatarImage className={classNames(css.UserAvatar, className)} ... />
|
||||||
|
);
|
||||||
|
if (!frame) return avatar;
|
||||||
|
return (
|
||||||
|
<div className={css.AvatarFrameContainer} data-frame={frame}>
|
||||||
|
{avatar}
|
||||||
|
<div className={css.AvatarFrameEffect} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
* **2. CSS (src/app/components/user-avatar/UserAvatar.css.ts):**
|
||||||
|
Define animations like `rotate` or `pulse`. Use `position: absolute; inset: -4px` on the effect div to create a glowing ring around the avatar.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Priority 4 — Specialized Features
|
||||||
|
|
||||||
|
### P4-4 · Math / LaTeX Rendering
|
||||||
|
**Mechanism:** KaTeX injection into the HTML parser.
|
||||||
|
|
||||||
|
* **1. Sanitizer (src/app/utils/sanitize.ts):**
|
||||||
|
You must allow KaTeX-specific tags and classes (e.g., `span`, `annotation`, `math`). Use a specialized allowed list for math blocks.
|
||||||
|
* **2. Parser (src/app/plugins/react-custom-html-parser.tsx):**
|
||||||
|
Detect `$ ... $` and `$$ ... $$` patterns in text nodes.
|
||||||
|
```tsx
|
||||||
|
if (node.type === 'text') {
|
||||||
|
const parts = node.data.split(/(\$\$.*?\$\$|\$.*?\$)/g);
|
||||||
|
return parts.map(p => {
|
||||||
|
if (p.startsWith('$')) return <KaTeX math={p.replace(/\$/g, '')} />;
|
||||||
|
return p;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **3. CSS (src/app/styles/CustomHtml.css.ts):**
|
||||||
|
Import `katex/dist/katex.min.css` only when a math block is rendered to save initial bundle size.
|
||||||
|
|
||||||
|
### P4-6 · OIDC / SSO Next-Gen Auth (MSC3861)
|
||||||
|
**Mechanism:** Matrix Authentication Service (MAS) Integration.
|
||||||
|
|
||||||
|
* **Architecture:** Shift from password-based `/login` to OAuth2 `authorization_code` flow.
|
||||||
|
* **Key Files:** `src/app/pages/auth/Login.tsx` and `src/app/hooks/useAuth.ts`.
|
||||||
|
* **Implementation:**
|
||||||
|
1. Use `oidc-client-ts` or a similar lightweight OIDC library.
|
||||||
|
2. Check for `m.authentication` in `/.well-known/matrix/client`.
|
||||||
|
3. Redirect to the MAS authorization endpoint.
|
||||||
|
4. Handle the callback in a new `OidcCallback` route and store the OIDC `refresh_token`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎨 Priority 5 — Gamer / Aesthetic / Customization
|
||||||
|
|
||||||
|
### P5-12 · Seasonal / Event Themes
|
||||||
|
**Mechanism:** Date-aware global overlays.
|
||||||
|
|
||||||
|
* **1. Component (src/app/components/seasonal/SeasonalEffect.tsx):**
|
||||||
|
```tsx
|
||||||
|
const isHalloween = now.month === 9 && now.day >= 15; // etc.
|
||||||
|
if (!isHalloween) return null;
|
||||||
|
return <div className={css.HalloweenSpiderWeb} />;
|
||||||
|
```
|
||||||
|
* **2. Styles (src/app/styles/Animations.css.ts):**
|
||||||
|
Use `repeating-linear-gradient` for spider webs or `keyframes` for falling snow.
|
||||||
|
* **3. Mounting:** Place at the very end of `App.tsx` (above the `LotusToastContainer`) with `pointer-events: none`.
|
||||||
|
|
||||||
|
### P5-13 · Avatar Frame / Border Decorations
|
||||||
|
**Mechanism:** Static variant of P5-14.
|
||||||
|
|
||||||
|
* **Implementation:** Use a simple `border` or `box-shadow` in `UserAvatar.tsx` based on the `io.lotus.avatar_frame` account data. Unlike the animated version, this should avoid `keyframes` to save CPU on long member lists.
|
||||||
|
|
||||||
|
### P5-2 · Additional Color Theme Presets
|
||||||
|
**Mechanism:** Standard vanilla-extract theme multiplication.
|
||||||
|
|
||||||
|
* **Implementation:** Replicate the `lotusTerminalTheme` object in `src/colors.css.ts` for each new theme (Cyberpunk, Ocean, etc.). Ensure each theme defines all 50+ tokens required by the `folds` library and TDS.
|
||||||
|
|
||||||
|
### P5-15 · In-Call Soundboard
|
||||||
|
**Mechanism:** Local-to-Global Audio Bridge.
|
||||||
|
|
||||||
|
* **Architecture:** Use the `Web Audio API` to mix sounds into the `MediaStream` before it enters the Element Call widget.
|
||||||
|
* **Implementation:**
|
||||||
|
1. Create an `AudioContext`.
|
||||||
|
2. Create a `MediaStreamDestinationNode`.
|
||||||
|
3. Create an `AudioBufferSourceNode` for the clip.
|
||||||
|
4. Route the mic `MediaStream` and the clip source to the destination.
|
||||||
|
5. Pass the destination's `.stream` to the call bridge.
|
||||||
|
|
||||||
|
### P5-20 · Quick Reply from Browser Notification
|
||||||
|
**Mechanism:** Service Worker `notificationclick` Action.
|
||||||
|
|
||||||
|
* **1. Registration (src/sw.ts):**
|
||||||
|
```typescript
|
||||||
|
self.addEventListener('notificationclick', (event) => {
|
||||||
|
if (event.action === 'reply' && event.reply) {
|
||||||
|
const { roomId, threadId } = event.notification.data;
|
||||||
|
const session = sessions.get(event.clientId); // Uses existing session mapping
|
||||||
|
// Send via direct fetch to bypass SDK loading
|
||||||
|
fetch(`${session.baseUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { Authorization: `Bearer ${session.accessToken}` },
|
||||||
|
body: JSON.stringify({
|
||||||
|
msgtype: 'm.text',
|
||||||
|
body: event.reply,
|
||||||
|
'm.relates_to': threadId ? { rel_type: 'm.thread', event_id: threadId } : undefined
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔬 Extreme Complexity Projects
|
||||||
|
|
||||||
|
### P5-30 · Advanced ML Noise Suppression (Krisp-style)
|
||||||
|
**Mechanism:** RNNoise WASM + Web Audio Worklet Pipeline.
|
||||||
|
|
||||||
|
* **Objective:** Filter non-vocal noise from the microphone stream in real-time.
|
||||||
|
* **Architecture:**
|
||||||
|
1. **Engine:** Use `RNNoise` (Recurrent Neural Network for noise suppression). It is lightweight and highly effective for speech.
|
||||||
|
2. **Pipeline:** `Mic Stream` -> `AudioWorkletNode` (Processing) -> `MediaStreamDestination` -> `Element Call`.
|
||||||
|
* **Implementation Steps:**
|
||||||
|
1. **WASM Wrapper:** Compile the `RNNoise` C library to WebAssembly. Use a library like `rnnoise-wasm` or `noise-suppression-js`.
|
||||||
|
2. **Audio Worklet:** Create `src/app/utils/audio/RnnoiseWorklet.ts`. This must handle 480-sample chunks (10ms of audio at 48kHz), which is the standard frame size for RNNoise.
|
||||||
|
3. **Client Integration:**
|
||||||
|
* In `CallControl.ts`, intercept the `localStream`.
|
||||||
|
* Pass the stream through the Worklet.
|
||||||
|
* Crucially, you must ensure that the processed stream is used by the `RTCPeerConnection` within the Element Call iframe.
|
||||||
|
### P5-31 · Granular Voice & Screenshare Quality Controls
|
||||||
|
**Mechanism:** WebRTC Encoding Parameters + Backend Quality Guard.
|
||||||
|
|
||||||
|
* **Objective:** Per-room and per-user control over audio fidelity and screenshare smoothness.
|
||||||
|
* **Architecture:**
|
||||||
|
1. **State Event:** `io.lotus.room_quality` (state key `""`) containing:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"audio_bitrate": 128000,
|
||||||
|
"screen_max_res": "1080p",
|
||||||
|
"screen_max_fps": 60
|
||||||
|
}
|
||||||
|
```
|
||||||
|
2. **Client-Side (RoomInput / CallControl):**
|
||||||
|
* **Screenshare:** In `src/app/plugins/call/CallControl.ts`, when initiating screenshare, map the "Quality" setting to `getDisplayMedia` constraints:
|
||||||
|
```typescript
|
||||||
|
const constraints = {
|
||||||
|
video: {
|
||||||
|
width: { ideal: 1920 }, // 1080p
|
||||||
|
frameRate: { ideal: 60 }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
* **Audio Bitrate:** After the call joins, find the `RTCRtpSender` for the audio track and update parameters:
|
||||||
|
```typescript
|
||||||
|
const sender = peerConnection.getSenders().find(s => s.track?.kind === 'audio');
|
||||||
|
const params = sender.getParameters();
|
||||||
|
params.encodings[0].maxBitrate = roomBitrate || 128000;
|
||||||
|
await sender.setParameters(params);
|
||||||
|
```
|
||||||
|
3. **Backend Sidecar (The "Quality Guard"):**
|
||||||
|
* **Pattern:** Extend the `voice-limit-guard.py` (on LXC 151) to handle quality metadata.
|
||||||
|
* **Mechanism:** When a user requests a LiveKit JWT to join a room, the Guard fetches the `io.lotus.room_quality` event for that room via the Synapse Admin API.
|
||||||
|
* **Enforcement:** The Guard injects these limits into the LiveKit token claims (if supported) or simply returns them to the client as an authorized "config" packet that the client must respect.
|
||||||
|
* **Challenges:**
|
||||||
|
* **LiveKit Compatibility:** Ensuring the SFU doesn't over-compress a high-bitrate stream from a "Pro" user.
|
||||||
|
* **Network Stability:** High bitrates (512kbps audio + 60fps 1080p video) require significant upstream bandwidth. Implement a "Network Warning" UI if packets are dropped.
|
||||||
|
|
||||||
|
### P5-34 · User-to-User Private Notes
|
||||||
|
**Mechanism:** Encrypted account data map.
|
||||||
|
|
||||||
|
* **Objective:** Private, cross-session notes about other users.
|
||||||
|
* **Key Files:**
|
||||||
|
* `src/app/hooks/useUserNotes.ts`: New hook for CRUD operations.
|
||||||
|
* `src/app/components/user-profile/UserRoomProfile.tsx`: UI site.
|
||||||
|
* **Implementation:**
|
||||||
|
1. Store as a single map in `io.lotus.user_notes` account data: `{ "@alice:server": "Cool dev", "@bob:server": "Needs moderation" }`.
|
||||||
|
2. Wrap the entire map in E2EE if the client supports local account data encryption, or rely on standard Matrix account data privacy.
|
||||||
|
3. Add a "Notes" tab or textarea to the user profile popout.
|
||||||
@@ -1,280 +1,163 @@
|
|||||||
# Lotus Chat
|
# Lotus Chat
|
||||||
|
|
||||||
A Matrix client for [Lotus Guild](https://lotusguild.org) — forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1.
|
A Matrix chat client built for Lotus Guild — fast, private, and packed with the features you actually want.
|
||||||
|
|
||||||
Deployed at [chat.lotusguild.org](https://chat.lotusguild.org).
|
**Deployed at [chat.lotusguild.org](https://chat.lotusguild.org)** | Forked from [Cinny](https://github.com/cinnyapp/cinny) v4.12.1
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Changes from upstream Cinny
|
## Licensing & Attribution
|
||||||
|
|
||||||
### Branding & Identity
|
The source code is licensed under [AGPLv3](LICENSE), the same license as the upstream Cinny project. The source for this fork is public at [code.lotusguild.org/LotusGuild/cinny](https://code.lotusguild.org/LotusGuild/cinny).
|
||||||
|
|
||||||
- Package renamed to `lotus-chat`, description updated to "Lotus Chat — Matrix client for Lotus Guild"
|
The Lotus Chat logo (`lotus_chat.png`) is a derivative work based on the original Cinny logo by Ajay Bura and contributors, used under [CC BY 4.0](https://creativecommons.org/licenses/by/4.0/). The modified logo is © Lotus Guild and is also made available under CC BY 4.0.
|
||||||
- App title changed from "Cinny" to "Lotus Chat" throughout
|
|
||||||
- Favicon, PWA icons, and all icon sizes (57×57 → 180×180 Apple touch icons) replaced with Lotus.png variants
|
|
||||||
- Logo in About dialog and Auth page replaced with official Lotus.png
|
|
||||||
- Auth footer rewritten: shows dynamic version from `package.json`, links to lotusguild.org, chat.lotusguild.org, and matrix.lotusguild.org
|
|
||||||
- Welcome page tagline changed from "Yet another matrix client" to "A Matrix client for Lotus Guild"
|
|
||||||
- Encryption key export filename changed from `cinny-keys.txt` to `lotus-keys.txt`
|
|
||||||
- `manifest.json` updated with Lotus name, description, and branding colors
|
|
||||||
|
|
||||||
### LotusGuild Terminal Design System (TDS) v1.2
|
|
||||||
|
|
||||||
A full custom theme engine layered on top of Cinny's vanilla-extract theming:
|
|
||||||
|
|
||||||
**Dark mode** (`LotusTerminalTheme`):
|
|
||||||
- CRT terminal aesthetic: scanline overlay, vignette, phosphor glow
|
|
||||||
- Palette: bg `#030508`, orange `#FF6B00`, cyan `#00D4FF`, green `#00FF88`, text `#c4d9ee`
|
|
||||||
- Monospace font stack, terminal-style scrollbars
|
|
||||||
- Custom hex-grid and circuit-board CSS background patterns
|
|
||||||
- Matrix-style boot messages on the welcome page (press Escape to skip)
|
|
||||||
- CSS variables: `--lt-*` family covering colors, glow effects, borders, animations
|
|
||||||
|
|
||||||
**Light mode** (`LotusTerminalLightTheme`):
|
|
||||||
- Full light palette: bg `#edf0f5`, orange `#c44e00`, cyan `#0062b8`, green `#006d35`, text `#111827`
|
|
||||||
- No CRT effects (scanlines, vignette disabled)
|
|
||||||
- Light-mode scrollbars, adjusted code block colors, semantic color overrides
|
|
||||||
- Scoped to `html[data-theme="light"] body.lotusTerminalBodyClass`
|
|
||||||
- `ThemeManager.tsx` sets `data-theme` attribute based on active theme kind
|
|
||||||
|
|
||||||
**Chat Backgrounds** (20+ custom patterns, all TDS-aware):
|
|
||||||
- Blueprint grid, carbon fiber, starfield, topographic contours, herringbone, crosshatch
|
|
||||||
- Chevron, polka dots, triangles, plaid
|
|
||||||
- All patterns use CSS custom properties — adapt to both TDS dark and light themes
|
|
||||||
- Settings toggle for showing per-message sender profiles
|
|
||||||
|
|
||||||
### Voice / Video Call Improvements
|
|
||||||
|
|
||||||
- **Element Call 0.19.4**: Upgraded from 0.16.3. Dist copied to `public/element-call/` by vite at build time.
|
|
||||||
- **Camera default OFF**: Camera no longer persists across sessions via localStorage. Always starts disabled. Optional `cameraOnJoin` setting for explicit opt-in.
|
|
||||||
- **Deafen button**: Tooltip corrected to "Deafen" / "Undeafen" (was "Turn Off Sound" / "Turn On Sound")
|
|
||||||
- **Screenshare confirmation**: A confirm dialog appears before screenshare is broadcast to call participants
|
|
||||||
- **Auto-revert spotlight on screenshare**: When someone starts screensharing, EC normally forces all participants into spotlight view. Patched in `CallControl.ts` `onControlMutation()` — detects the screenshare button going `primary` and clicks `gridButton` after 600ms to revert to grid layout. Participants choose to watch screenshare manually.
|
|
||||||
- **Push to Talk (PTT)**:
|
|
||||||
- Configurable keybind (default: Space) via Settings > General > Calls
|
|
||||||
- Mic activates on keydown, deactivates on keyup; mic muted on tab blur/focus to prevent stuck-on mic
|
|
||||||
- Visual indicator: plain folds `Chip` by default; when LotusGuild TDS is active: orange `PTT — Hold SPACE` / green `● LIVE` in JetBrains Mono
|
|
||||||
- Listens on both main window and EC iframe `contentWindow` for reliable key capture
|
|
||||||
- Implemented via `CallControl.setMicrophone()` public method on the widget bridge
|
|
||||||
- **Mic state preservation**: when enabling PTT mode mid-call, the user's previous mic state is saved and restored when PTT is disabled — prevents unwanted unmute if the user had manually muted before switching to PTT.
|
|
||||||
- **Noise suppression toggle**: Settings > General > Calls — passes `noiseSuppression` URL parameter to the embedded Element Call widget
|
|
||||||
- **Call button scoping**: The upstream Cinny 4.12.1 call button (voice + video dropdown) is restricted to DMs and private group chats only. Specifically: direct messages, or invite-only rooms that have no `m.space.parent` state event (i.e. not a space/guild text channel). Public rooms and space channels are excluded to prevent accidental mass-notifications. `Room.tsx` switches to CallView layout when a call embed is active in the current room.
|
|
||||||
- **Poll display**: `m.poll.start` events (both stable Matrix 1.7 `m.poll` content key and MSC3381 unstable `org.matrix.msc3381.poll.start`) render as read-only poll cards inside the standard message bubble — question and answer options shown. Registered as top-level event renderers AND inside the `EncryptedContent` callback so encrypted polls also display after decryption. "Open in Element to vote" note displayed. Implemented in `PollContent.tsx`.
|
|
||||||
- **Deleted message placeholder**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events no longer disappear from the timeline. Instead they reach the existing `RedactedContent` component (trash icon + italic "This message has been deleted" with reason if provided), matching Element, FluffyChat, Commet, and Nheko behaviour. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
|
|
||||||
- **Picture-in-picture (PiP)**: When navigating away from a call room while in an active call, the call embed shrinks to a 280x158px floating window in the bottom-right corner. The PiP window is **draggable** — drag it anywhere on screen to move it out of the way. Clicking (without dragging) navigates back to the call room. Drag vs click distinguished by a 5px movement threshold; touch drag supported. Imperative style overrides on `callEmbedRef.current` via `useEffect` — a wrapper div cannot be used because `useCallEmbedPlacementSync` writes `top/left/width/height` directly onto that element.
|
|
||||||
- **Call embed positioning**: `useCallEmbedPlacementSync` uses `getBoundingClientRect()` (not `offsetTop/Left`) for accurate viewport-relative coordinates on the `position:fixed` container. Position is synced immediately on mount via `useEffect` in addition to the ResizeObserver, so the embed is placed correctly the instant the call view renders. The `[pipMode, callVisible]` effect in `CallEmbedProvider` only clears pip-specific styles when actually exiting pip mode — no longer clobbers the position set by `syncCallEmbedPlacement` on every `callVisible` toggle.
|
|
||||||
- **Dark mode in element-call**: After joining, `CallEmbed.applyStyles()` injects `:root { color-scheme: dark|light }` into the iframe document so `@media (prefers-color-scheme)` rules inside element-call resolve to the correct Cinny theme regardless of the OS system preference. `themeKind` is stored on the `CallEmbed` instance and updated on every `setTheme()` call, so live theme switching also re-injects the CSS. Without this, users with OS light mode would see a white background even when Cinny is in dark mode.
|
|
||||||
- **Call embed wallpaper**: The user's `chatBackground` pattern (Blueprint, Carbon, Stars…) is applied as the `backgroundImage`/`backgroundColor` of `div[data-call-embed-container]` when the call is in full view (not PiP). The iframe `html, body` is forced to `background: none !important` so the pattern shows through. When `chatBackground` is `none`, behaviour is unchanged.
|
|
||||||
|
|
||||||
### Moderation
|
|
||||||
|
|
||||||
- **Report Room**: A "Report Room" option in the room header menu (⋮) allows users to report a room to homeserver admins with a reason and abuse category (Spam / Harassment / Inappropriate Content / Other). Calls `POST /_matrix/client/v3/rooms/{roomId}/report` (MSC4151, confirmed supported on matrix.lotusguild.org). Implemented in `ReportRoomModal.tsx` with loading/success/error states.
|
|
||||||
- **Policy List / Ban List Viewer (MSC2313)**: A "Policy Lists" tab in Room / Space Settings (admin-only, power-level gated) shows all subscribed `m.policy.rule.*` rooms and their contents — banned users, banned rooms, and banned servers — each with entity, reason, and recommendation fields. Subscribe (join the policy room) and Unsubscribe (leave) actions are provided. Enforcement remains solely with the Draupnir bot; this UI is a read-only complement.
|
|
||||||
|
|
||||||
### Messaging Enhancements
|
|
||||||
|
|
||||||
- **Rich room topics**: Room topics that contain formatted text (bold, links, italic) are now rendered with full HTML formatting. Falls back to plain text if no `formatted_body` is present. Activates when any room admin sets a formatted topic.
|
|
||||||
- **Edit history viewer**: Clicking the "edited" label on any edited message opens a modal showing every prior version with timestamps. Fetches all `m.replace` relations for the event and displays them oldest-to-newest. Previously the "edited" label was visible but unclickable. E2EE fix: the "Original" entry now uses `getClearContent()` (bypasses the replacing-event chain, returns the decrypted pre-edit body) instead of `event.content` which is still raw ciphertext for encrypted messages — fixes "(no text)" shown for almost all E2EE message originals.
|
|
||||||
- **Inline GIF preview**: Giphy and Tenor share links sent as plain text auto-embed as animated GIFs inline in the timeline. URL patterns are detected client-side; the image is fetched via the homeserver's `/_matrix/media/v3/preview_url` proxy (no direct contact with Giphy/Tenor from the client). Rendered as `<img loading="lazy">` — respects the existing URL preview enabled/disabled setting.
|
|
||||||
- **GIF picker**: Giphy-powered GIF search and send. Button appears in the message composer only when `gifApiKey` is set in `config.json`. Sends GIF as `m.image` — fetches blob, uploads via `mx.uploadContent`, sends with `mx.sendMessage`. `FocusTrap` handles click-outside / Escape to close. When TDS is active: dark navy background (`#060c14`), orange dim border, `// GIF_SEARCH` header, CSS overrides for Giphy SDK search bar (dark bg, orange border/focus ring, JetBrains Mono), custom orange scrollbar. All TDS styles live in `lotus-terminal.css.ts` — no runtime `<style>` injection, eliminating flash of unstyled content.
|
|
||||||
- **Message forwarding**: Forward any message to any room from the message context menu.
|
|
||||||
- **Draft persistence**: Unsent message drafts survive page reload via `localStorage` (`draft-msg-<roomId>`). Jotai in-memory atom is primary; localStorage is used as fallback on reload and cleared on send.
|
|
||||||
- **Message search date range**: From/To date pickers in the search filter bar. Sends `from_ts`/`to_ts` epoch ms to the Matrix `/search` endpoint. Chip shows active range with X to clear.
|
|
||||||
- **Image/video captions**: Caption text field on image and video upload — sent as a single event with the media.
|
|
||||||
- **Location sharing**: Map embed view for incoming location events + static share button. Renders `m.location` events inline with a map tile.
|
|
||||||
- **Deleted message placeholders**: Redacted `m.room.message`, `m.room.encrypted`, and `m.sticker` events render as "This message has been deleted" with reason (if provided) rather than disappearing. One-line change in the `eventRenderer` filter in `RoomTimeline.tsx`.
|
|
||||||
- **Message bookmarks / saved messages**: Right-click any message → "Bookmark" / "Remove Bookmark". A star icon in the sidebar nav opens `BookmarksPanel.tsx` — a right-side panel listing all saved messages with room name, preview text, relative timestamp, filter input, "Jump to message" deep-link, and individual remove buttons. Stored in `io.lotus.bookmarks` account data (max 500 entries); syncs across devices. Implemented in `src/app/features/bookmarks/BookmarksPanel.tsx` + `src/app/hooks/useBookmarks.ts`.
|
|
||||||
- **Message scheduling (MSC4140)**: Clock button next to send opens `ScheduleMessageModal.tsx` with a message textarea and datetime picker. Messages sent via the MSC4140 delayed events API (`org.matrix.msc4140`), confirmed supported on `matrix.lotusguild.org`. A collapsible tray above the composer lists pending scheduled messages with Cancel buttons. Utilities in `src/app/utils/scheduledMessages.ts`.
|
|
||||||
- **Richer link preview cards**: `UrlPreviewCard.tsx` renders domain-specific cards for 13 sites: YouTube (thumbnail + ▶ play overlay), Vimeo, GitHub (repo parse), Twitter/X (tweet text + media parse), Reddit (subreddit + upvotes + comments), Spotify (artwork), Twitch (LIVE badge + game), Steam, Wikipedia, Discord (server invite), npm, Stack Overflow, and IMDb (poster). Generic cards gain a favicon from Google's S2 service. Cards that produce no renderable content are suppressed.
|
|
||||||
- **File upload compression (opt-in)**: JPEG and PNG files in the upload preview show a "Compress" checkbox. When checked, a Canvas API call (`toBlob(..., 'image/jpeg', 0.82)`) compresses the image client-side. Original and compressed sizes are shown side-by-side. Compression is strictly opt-in — unchecked by default, skipped for GIF/SVG/WebP.
|
|
||||||
|
|
||||||
### Room Customization
|
|
||||||
|
|
||||||
- **Personal room name overrides**: Right-click any room in the sidebar → "Rename for me…" to set a local display name visible only to you. Other members see the original name unchanged. A small pencil icon marks rooms with a custom local name. Stored in Matrix account data (`io.lotus.room_names`). Uses `io.lotus.room_names` account data key (based on MSC4431).
|
|
||||||
- **Export room history**: Room Settings → Export tab. Supports Plain Text, JSON, and HTML formats with optional start/end date range filters. Paginates backwards via `mx.paginateEventTimeline()` with a live progress counter. E2EE-aware — events that failed decryption are skipped rather than exported as garbled ciphertext. Downloads via `Blob` + `<a download>`. Implemented in `src/app/features/room-settings/ExportRoomHistory.tsx`.
|
|
||||||
- **Room activity / mod log**: Room Settings → Activity tab. Filterable log of `m.room.member` (join/leave/kick/ban/unban/invite), `m.room.power_levels`, `m.room.name`, `m.room.topic`, `m.room.avatar`, and `m.room.server_acl` events. Human-readable descriptions, relative timestamps, type-filter dropdown, Load More pagination, and auto-paginate on mount. Implemented in `src/app/features/room-settings/RoomActivityLog.tsx`.
|
|
||||||
- **Server ACL editor**: Room Settings → Server ACL tab. Reads and writes `m.room.server_acl` state events. Editable allow and deny server lists with wildcard pattern validation (`*.example.com`). "Allow IP literal addresses" toggle. Read-only view shown to users without the required power level. Implemented in `src/app/features/room-settings/RoomServerACL.tsx`.
|
|
||||||
- **Room stats / insights**: Room Settings → Insights tab (not the default tab). Derives all statistics from the local timeline cache only; a disclaimer banner clarifies this. Shows top 5 active members (bar chart), top 5 reactions (emoji chips), media breakdown (images/videos/audio/files tiles), and a 24-hour activity heatmap (CSS bar chart). Implemented in `src/app/features/room-settings/RoomInsights.tsx`.
|
|
||||||
|
|
||||||
### Per-Message Read Receipts
|
|
||||||
|
|
||||||
Full per-message read receipt system — shows who has read each message directly in the timeline.
|
|
||||||
|
|
||||||
**Architecture:**
|
|
||||||
- `useRoomReadPositions(room)` hook — computes a `Map<eventId, userId[]>` from all joined members' `room.getEventReadUpTo()` positions. Subscribes to `RoomEvent.Receipt` for live updates (debounced at 150ms to batch burst updates from mass-read events).
|
|
||||||
- `nearestRenderableId(liveEvents, evtId)` — receipts can land on reaction/edit events that `RoomTimeline` skips (renders `null`). This walks backwards from the receipt event through the live timeline until it finds a non-reaction/non-edit event to attach to.
|
|
||||||
- `ReadPositionsContext` — React context providing the positions map from `RoomTimeline` down to all `Message` instances without prop drilling.
|
|
||||||
- `ReadReceiptAvatars` component — renders a pill-shaped row of overlapping `StackedAvatar` circles (24px, `SurfaceVariant` outline) below messages with readers. Pill uses `color.SurfaceVariant.Container` background for visibility on any wallpaper. Max 5 avatars shown + `+N` overflow count. Avatar fallback uses `colorMXID(userId)` for distinctive per-user color.
|
|
||||||
- Clicking the pill opens the **"Seen by" modal** (`EventReaders`) listing all readers with their avatar, display name, and a formatted read timestamp ("Today at 3:42 PM", "Yesterday at 10:15 AM", "May 14 at 9:00 AM"). Timestamps use `room.getReadReceiptForUserId(userId)?.data.ts` and respect the user's 24-hour clock setting.
|
|
||||||
- Authenticated media (`mxcUrlToHttp` utility) used for all avatar loads, matching the correct Lotus utility signature.
|
|
||||||
|
|
||||||
### Delivery Status Indicators
|
|
||||||
|
|
||||||
Own messages display a small status marker below the message content (when no read receipts are visible yet):
|
|
||||||
- `⟳` — message is being sent / encrypting
|
|
||||||
- `✓` — message confirmed sent (local echo)
|
|
||||||
- `✕` — message failed to send (shown in red; orange glow in TDS mode)
|
|
||||||
- Status hidden once the server confirms receipt (`status === null`) — read receipts take over at that point
|
|
||||||
|
|
||||||
### URL Preview Cards (TDS)
|
|
||||||
|
|
||||||
URL preview cards (`UrlPreviewCard`) styled for terminal mode:
|
|
||||||
- Dark transparent background with cyan border-left accent (Anduril Orange)
|
|
||||||
- Link text in cyan, hover switches to orange with glow
|
|
||||||
- Light TDS variant: off-white background with blue accent
|
|
||||||
|
|
||||||
### Reaction Chips (TDS)
|
|
||||||
|
|
||||||
Emoji reaction buttons styled for terminal mode via `button[data-reaction-key]` selector:
|
|
||||||
- Unselected: `rgba(0,212,255,0.06)` background, cyan border
|
|
||||||
- Hover: brighter background + box-shadow glow
|
|
||||||
- Own reaction (aria-pressed=true): orange tint `rgba(255,107,0,0.12)`, orange border
|
|
||||||
- Light TDS: equivalent blue/orange variants
|
|
||||||
|
|
||||||
|
|
||||||
### DM Call Improvements
|
|
||||||
|
|
||||||
|
|
||||||
- **Incoming call ring**: DM calls trigger a ring tone with Answer/Decline UI. 30-second auto-dismiss if unanswered. Implemented in `Room.tsx` and `RoomViewHeader.tsx`.
|
|
||||||
|
|
||||||
### Room Customization
|
|
||||||
|
|
||||||
- **Room emoji prefix**: A leading emoji in a room name (e.g. 🎮 general) renders at 1.15× size in the sidebar for visual impact. Matrix room names already support Unicode — this is purely a rendering enhancement in `RoomNavItem.tsx`. All three room-name inputs (Create Room, Room Settings, "Rename for me…" dialog) now include a 😊 emoji picker button that prepends the selected emoji to the name field.
|
|
||||||
|
|
||||||
### Presence
|
|
||||||
|
|
||||||
- **Discord-style presence selector**: Clicking your avatar in the bottom-left sidebar opens a popout with five status options — Online (green), Idle (yellow), Do Not Disturb (red, broadcasts `unavailable` with `status_msg: 'dnd'`), Invisible (grey outline, broadcasts `offline`), and Auto (activity-tracking, the original behaviour). The selected status persists across reloads via the settings atom. A colored badge on the avatar reflects the current status at a glance. `usePresenceUpdater` short-circuits immediately for manual modes; full idle-timer and visibility-change logic only runs in Auto mode. Settings also exposed via `src/app/state/settings.ts` (`presenceStatus` field).
|
|
||||||
- **Custom status message**: Set a short status text (up to 64 characters) with an emoji picker, shown below your display name in member lists and presence displays. Accessible via Settings → Account → Profile. Includes an **auto-clear timer** (options: 30 minutes, 1 hour, 4 hours, 1 day, 3 days, 7 days) — after the timer expires, the status is automatically cleared by setting `status_msg: ''` via `mx.setPresence`. A character counter (shown when ≥ 56/64 chars) prevents overflow. Implemented in `src/app/features/settings/account/Profile.tsx`.
|
|
||||||
- **Presence badges on members**: Online/busy/away dots shown next to users in the room members drawer and settings members panel (`PresenceBadge` component from `src/app/components/presence/Presence.tsx`).
|
|
||||||
- **Presence avatar border ring**: A 2px colored `outline` ring on user avatars throughout the app shows presence at a glance — green (online), yellow (idle), red (DND), no ring (offline). Implemented as `PresenceRingAvatar` component (`src/app/components/presence/PresenceRingAvatar.tsx`) using `React.cloneElement` to inject `outline` + `outlineOffset` directly onto the child `Avatar` element — the ring follows the avatar's actual `border-radius` regardless of shape. Applied to: message timeline sender avatars, members drawer, @mention autocomplete, and inbox notification senders.
|
|
||||||
- **Document title unread count**: Tab title updates to `(N) Lotus Chat` for mentions, `· Lotus Chat` for unreads, `Lotus Chat` when clear.
|
|
||||||
- **Extended profile fields (MSC4133)**: Settings → Account → Profile includes Pronouns (`m.pronouns`) and Timezone (`m.tz`) fields, saved via MSC4133 `PUT /_matrix/client/unstable/uk.tcpip.msc4133/{userId}/{field}`. Both fields are displayed in user profile panels. Implemented via `src/app/hooks/useExtendedProfile.ts`.
|
|
||||||
- **User local time in profile**: When a user has `m.tz` set, their profile panel shows a clock icon, their current local time, and the timezone abbreviation (e.g. EST, JST). Updates every 60 seconds. Respects the viewer's `hour24Clock` setting. Implemented via `src/app/hooks/useLocalTime.ts`.
|
|
||||||
|
|
||||||
### UX & Composer
|
|
||||||
|
|
||||||
- **Message length counter**: A muted character counter appears just left of the send button while typing, disappearing when the composer is empty. Resets on room switch.
|
|
||||||
- **Quick emoji reactions on hover**: The 3 most-recently-used emoji reactions appear directly in the message hover toolbar (between the emoji-board button and Reply), so reacting requires a single click rather than opening the 3-dots menu first. Clicking a quick-reaction also closes any open emoji picker. Powered by `useRecentEmoji` sourced from Matrix account data.
|
|
||||||
- **In-app notification toasts**: When a message or invite notification fires and the browser window is focused, a slim TDS-styled toast card slides in from the bottom-right instead of triggering an OS notification. Card shows: 24px avatar (initials fallback), sender name in orange, truncated message body, room name, × dismiss, 4 s auto-dismiss. Clicking navigates directly to the correct room (DM or home path) or the invites inbox. OS notifications are unchanged when the window is not focused. Implemented in `src/app/features/toast/LotusToastContainer.tsx` + `src/app/state/toast.ts`.
|
|
||||||
- **Collapsible long messages**: Messages exceeding ~20 lines are auto-collapsed with a "Read more ↓" button. Click to expand inline; a "Collapse ↑" button re-folds. Threshold (in lines) configurable in Settings → Appearance. Uses CSS `max-height` + `overflow: hidden` — works correctly with code blocks and embedded media. Respects `prefers-reduced-motion`.
|
|
||||||
- **Message send animation**: Own sent messages fade and scale into the timeline (0.15 s ease-out: `scale(0.97)→scale(1)`, `opacity 0.4→1`). Incoming messages are unaffected. Respects `prefers-reduced-motion`.
|
|
||||||
- **Right-click room context menu**: Expanded sidebar room context menu — **Mute** now opens a duration submenu (15 min / 1 hr / 8 hr / 24 hr / Indefinite) with auto-restore after the selected window; **Copy Room Link** copies the `matrix.to` URL with a "Copied!" flash; **Mark as Read** marks the room read to the latest event; plus Leave Room and Room Settings shortcuts.
|
|
||||||
- **Unverified device warning**: `warnOnUnverifiedDevices` setting (default off). When enabled via Settings → General → Privacy, a warning banner appears above the composer in encrypted rooms that contain unverified devices, showing the count. Sending is never blocked — the banner is informational only. Uses the existing `useUnverifiedDeviceCount()` hook.
|
|
||||||
- **Sidebar room filter**: A search-icon input at the top of the Home and DMs sidebar tabs filters rooms by display name in real time. Clears on tab switch. Styled to match the members-drawer search bar (`size="400"`, search prefix icon).
|
|
||||||
- **DM last message preview**: Each DM row in the sidebar shows a truncated message body (48 chars) and relative timestamp (`Xm`, `Xhr`, `Yesterday`, `D MMM`) below the room name, sourced reactively from `useRoomLatestRenderedEvent`. Encrypted rooms show "Encrypted message" only on actual decryption failure.
|
|
||||||
- **Room sort order**: Sort icon in the Rooms sidebar header lets users sort non-space rooms by Recent Activity (default), A→Z, or Unread First. Persists via `homeRoomSort` setting.
|
|
||||||
- **Favorite rooms**: Right-click any room → "Add to Favorites" / "Remove from Favorites". Favorited rooms (using the standard Matrix `m.favourite` tag) appear in a collapsible "Favorites" section above the main room list on the Home tab. Syncs across devices via account data.
|
|
||||||
- **Poll creation**: Polls can be created directly from the composer — `Icons.OrderList` button opens a modal with question field, 2–10 answer options (add/remove), and Single/Multiple choice toggle. Sends a stable `m.poll.start` event. (Poll display & voting were already supported.)
|
|
||||||
- **Voice message playback speed**: `0.75×` → `1×` → `1.5×` → `2×` speed toggle pill on voice message player — cycles on click via `playbackRate` on the `<audio>` element.
|
|
||||||
- **Invite link + QR code**: Room settings → General shows a "Share Room" tile with the `matrix.to` invite URL and a QR code. The Invite modal also has a `⊞` toggle button showing a QR panel when clicked. Both use `api.qrserver.com` (added to CSP on LXC 106).
|
|
||||||
- **Private read receipts**: Settings → General → Privacy — "Private Read Receipts" toggle. When on, sends `m.read.private` instead of `m.read` so other room members can't see when you've read messages.
|
|
||||||
- **Media gallery**: A right-side drawer (photo icon in room header, Desktop only) showing Images | Videos | Files tabs. Reads already-decrypted timeline events — works in E2EE rooms. Encrypted-blob images show a lock-icon placeholder. Load More paginates backwards via `mx.paginateEventTimeline()`.
|
|
||||||
- **Knock-to-join**: When a room's join rule is `knock`, RoomIntro shows "Request to Join" (calls `mx.knockRoom()`) with "Request sent" pending state. Room admins see a "Pending Requests" section in the members drawer with Approve / Deny buttons.
|
|
||||||
- **Code syntax highlighting** (TDS mode): Fenced code blocks in messages highlight keywords (cyan), strings (green), numbers (orange), comments (italic dim), function names (purple) using inline `--lt-accent-*` CSS variables. Custom tokenizer in `syntaxHighlight.ts` — supports JS/TS/JSX/TSX, Python, Rust. Falls back to ReactPrism for other languages.
|
|
||||||
|
|
||||||
### Settings (Appearance)
|
|
||||||
|
|
||||||
- **Animated Chat Backgrounds**: Five CSS-only animated wallpapers added to the background picker — Digital Rain (two-layer vertical stripe scroll with parallax), Star Drift (three-layer radial-gradient star field drifting diagonally), Grid Pulse (neon grid lines expanding/contracting via `backgroundSize` keyframe), Aurora Flow (four radial gradient ellipses sweeping across a 200% canvas), Fireflies (three layers of glowing dots drifting). All use vanilla-extract `keyframes()` — no canvas, GPU-composited. Respects `prefers-reduced-motion: reduce` (animation stripped at call time). "Pause Background Animations" toggle in Settings → Appearance provides an in-app override. Implemented in `src/app/styles/Animations.css.ts` + `src/app/features/lotus/chatBackground.ts`.
|
|
||||||
- **Glassmorphism Sidebar**: Settings → Appearance toggle (off by default). When enabled, the left sidebar becomes semi-transparent (`background: rgba(3,5,8,0.55)`) with `backdrop-filter: blur(12px)` so chat background patterns show through as a frosted glass effect. Fix: the active chat background is mirrored onto `document.body` via `useEffect` in `SidebarNav.tsx` so the blur has content to work through (previously the sidebar was a flex sibling with nothing physically behind it). Implemented as a vanilla-extract `SidebarGlass` class applied to the `<Sidebar>` container in `SidebarNav.tsx`.
|
|
||||||
|
|
||||||
|
|
||||||
- **Night Light / Blue Light Filter**: Warm orange overlay (`rgba(255,140,0,N%)`) across the entire UI. Toggle + intensity slider (5–80%) in Settings → Appearance. `position:fixed; pointer-events:none; z-index:9998`. Persists across sessions.
|
|
||||||
|
|
||||||
### Notification Enhancements
|
|
||||||
|
|
||||||
- **Custom notification sounds**: `messageSoundId` / `inviteSoundId` settings select per-category notification sound (`notification.ogg`, `invite.ogg`, `call.ogg`, or None). Settings → Notifications expands the sound toggle with Message Sound + Invite Sound selects and ▶ preview buttons. Shared `notificationSounds.ts` module.
|
|
||||||
- **Notification quiet hours**: `quietHoursEnabled` / `quietHoursStart` / `quietHoursEnd` settings suppress all desktop notifications and sounds during a configured time window. Handles overnight spans (e.g. 23:00–08:00). Settings → Notifications: Quiet Hours card with toggle + start/end time pickers.
|
|
||||||
- **Full push rule editor**: Settings → Notifications → Advanced Push Rules section. Covers override, room, sender, and underride rule kinds. Each row has a human-readable label for built-in rules, an enable/disable toggle, and a delete button for custom rules. An add-rule form at the bottom of the room and sender sections lets users create new per-room or per-sender push rules by entering the room/user ID.
|
|
||||||
|
|
||||||
### Calls (Extended)
|
|
||||||
|
|
||||||
- **Push-to-Deafen**: Press `M` during a call to toggle speaker mute (deafen). Configurable in Settings → General → Calls alongside the PTT key. Skips editable elements; guards `e.repeat`; uses `el.ownerDocument.body` for iframe safety.
|
|
||||||
- **TDS typing indicator dots**: When Lotus Terminal mode is active, the animated typing indicator dots turn TDS orange (`var(--lt-accent-orange)`) via `color: currentColor` inheritance.
|
|
||||||
|
|
||||||
### Server Integration
|
|
||||||
|
|
||||||
- **Server support contact (MSC1929)**: Settings → Help & About displays the homeserver admin contact fetched from `/.well-known/matrix/support`. Shows the admin's Matrix ID and a link to the support page when the homeserver has configured this endpoint. Degrades gracefully when not configured (section is hidden on 404 or network error). In TDS mode the contact text and link render in `--lt-accent-cyan`. Implemented in `src/app/features/settings/about/About.tsx`.
|
|
||||||
- **Server notices**: Rooms of type `m.server_notice` (system messages from the homeserver) now render with a distinct "Server Notice" `<Chip variant="Warning">` badge in the room header and a disabled composer showing "This is a server notice room — you cannot send messages here." Previously indistinguishable from regular DMs. Badge in `src/app/features/room/RoomViewHeader.tsx`; composer guard in `src/app/features/room/RoomInput.tsx`.
|
|
||||||
|
|
||||||
### Infrastructure
|
|
||||||
|
|
||||||
- **Authenticated media**: All avatar/media loads use `mxcUrlToHttp(mx, mxcUrl, useAuthentication, w, h, 'crop')` from `../../utils/matrix` — the Lotus utility that handles MSC3916 authenticated media. (Upstream Cinny uses the SDK method with incorrect argument order for authenticated endpoints.)
|
|
||||||
- **Upstream tracking**: `git remote add upstream https://github.com/cinnyapp/cinny.git`. Merge strategy: `git fetch upstream && git merge upstream/main`. Daily check via `cinny-upstream-check.sh` on LXC 106 — notifies Matrix on new upstream commits.
|
|
||||||
- **Rolldown CJS interop — millify**: `src/app/plugins/millify.ts` uses a named import (`import { millify as millifyPlugin } from 'millify'`) instead of a default import. Rolldown's `__toESM` helper with `mode=1` sets `a.default = module_object` (not the function itself) when `hasOwnProperty` prevents the copy — calling `millifyPlugin()` would throw `(0, zc.default) is not a function`. Named import bypasses the interop entirely.
|
|
||||||
- **Sentry noise filter**: `ignoreErrors: ['Request timed out']` added to `Sentry.init` in `src/index.tsx` to suppress unhandled rejections from the matrixRTC delayed-event heartbeat (matrix-sdk) and the widget PostmessageTransport initial-load race (matrix-widget-api). Neither is actionable from client code.
|
|
||||||
- **URL preview default in encrypted rooms**: `encUrlPreview` default changed from `false` to `true` in `src/app/state/settings.ts`. A security note is shown next to the toggle in Settings → General explaining that the homeserver fetches the URL (and sees it) but not the message content.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Build
|
## Features
|
||||||
|
|
||||||
|
### Messaging
|
||||||
|
|
||||||
|
- See who has read each message, and track delivery status (sending / sent / failed)
|
||||||
|
- Bookmark any message and revisit saved messages from the sidebar
|
||||||
|
- Schedule messages to send at a specific time
|
||||||
|
- Click "edited" on any message to see the full edit history
|
||||||
|
- Drafts are saved automatically and survive page reloads
|
||||||
|
- Long messages collapse automatically — click "Read more" to expand
|
||||||
|
- Forward messages to other rooms
|
||||||
|
- Create and view polls directly in chat
|
||||||
|
- Share your location with an inline map embed
|
||||||
|
- Add captions to image and video uploads
|
||||||
|
- Optionally compress images before uploading — shows before/after file sizes
|
||||||
|
- GIF links from Giphy and Tenor auto-preview inline
|
||||||
|
- Search for and send GIFs from a built-in GIF picker
|
||||||
|
- Control voice message playback speed: 0.75× / 1× / 1.5× / 2×
|
||||||
|
- Search messages with a date range filter
|
||||||
|
- Room topics support rich formatting (bold, links, italics)
|
||||||
|
- Deleted messages show a placeholder instead of disappearing
|
||||||
|
- Code blocks highlight syntax for JS/TS, Python, and Rust
|
||||||
|
- Rich link preview cards for YouTube, GitHub, Twitter/X, Reddit, Spotify, Twitch, Steam, Wikipedia, Discord, npm, Stack Overflow, and IMDb
|
||||||
|
|
||||||
|
### Calls & Voice
|
||||||
|
|
||||||
|
- Push to Talk with a configurable keybind (default: Space)
|
||||||
|
- Push to Deafen with the M key
|
||||||
|
- Camera starts turned off by default when joining a call
|
||||||
|
- Screenshare requires confirmation before going live
|
||||||
|
- Toggle noise suppression on or off
|
||||||
|
- Calls float in a draggable picture-in-picture window when you navigate away
|
||||||
|
- Your chat background shows through the call view
|
||||||
|
- Dark/light mode inside calls matches your Lotus Chat theme
|
||||||
|
- Calls are available in DMs and private groups only — no accidental mass rings
|
||||||
|
- AFK auto-mute: mic is automatically silenced after a configurable idle timeout (1–30 min); a toast confirms the action
|
||||||
|
- Voice channel user limit: admins can cap how many people can be in a room's call — enforced server-side for every Matrix client (not just Lotus Chat); others see "Channel Full" until a spot opens
|
||||||
|
- Custom join/leave sound effects when someone enters or leaves your call — choose Chime, Soft, Retro, or off
|
||||||
|
|
||||||
|
### Customization & Appearance
|
||||||
|
|
||||||
|
- LotusGuild Terminal Design System (TDS) — a CRT terminal-inspired dark theme
|
||||||
|
- TDS light mode variant for daytime use
|
||||||
|
- 20+ static chat background patterns
|
||||||
|
- 5 animated chat backgrounds: Digital Rain, Star Drift, Grid Pulse, Aurora Flow, Fireflies (with improved per-layer looping, phosphor-flicker rain, fluid aurora sweep, and organic firefly bioluminescence)
|
||||||
|
- 11 seasonal & holiday theme overlays — Halloween, Christmas, New Year, Autumn, Valentine's Day, St. Patrick's Day, Earth Day, Lunar New Year, April Fools', Deep Space, and Retro Arcade; auto-selected by date with a manual override in Settings → Appearance
|
||||||
|
- Avatar decorations — 99 animated APNG overlays (Gaming, Cyber, Space, Fantasy, Nature, Spooky, Cozy, and more) that frame your avatar across the timeline, members list, and @mention autocomplete; visible to all Lotus Chat users; select in Settings → Account → Avatar Decoration
|
||||||
|
- Toggle to pause background animations
|
||||||
|
- Glassmorphism sidebar — frosted glass effect that lets the background show through
|
||||||
|
- Night Light / blue light filter with an adjustable intensity slider
|
||||||
|
- Emoji prefixes on room names render larger in the sidebar (e.g. 🎮 general)
|
||||||
|
- Rename any room for yourself only — other members see the original name
|
||||||
|
- Emoji picker on all room name inputs
|
||||||
|
|
||||||
|
### Presence & Profile
|
||||||
|
|
||||||
|
- Discord-style presence selector: Online, Idle, Do Not Disturb, Invisible, or Auto
|
||||||
|
- Custom status message with emoji and an optional auto-clear timer (changing your status is never silently overwritten by activity events)
|
||||||
|
- Colored presence ring on member avatars (green / yellow / red)
|
||||||
|
- Profile fields for pronouns and timezone
|
||||||
|
- When a user's timezone is set, their current local time appears in their profile
|
||||||
|
- Private notes on any user's profile — freeform text visible only to you, auto-saves and syncs across devices
|
||||||
|
- Unread count shown in the browser tab title
|
||||||
|
|
||||||
|
### Moderation & Privacy
|
||||||
|
|
||||||
|
- Report any room to homeserver admins from the room menu
|
||||||
|
- View policy lists and ban lists (Draupnir-compatible, read-only)
|
||||||
|
- Toggle private read receipts so others can't see when you've read messages
|
||||||
|
- Optional warning when an encrypted room contains unverified devices
|
||||||
|
- Full push rule editor in notification settings
|
||||||
|
- View and edit Server ACL rules in room settings
|
||||||
|
- Filterable room activity / mod log (joins, kicks, bans, power level changes, etc.)
|
||||||
|
- Room stats and insights panel (active members, top reactions, media breakdown, activity heatmap)
|
||||||
|
- Export room history as plain text, JSON, or HTML with optional date range filter
|
||||||
|
|
||||||
|
### Notifications
|
||||||
|
|
||||||
|
- In-app toast notifications appear bottom-right when the window is focused
|
||||||
|
- Custom notification sounds per category (messages, invites)
|
||||||
|
- Quiet hours — suppress notifications during a configured time window
|
||||||
|
- Click a toast to jump directly to the room or DM
|
||||||
|
|
||||||
|
### UX
|
||||||
|
|
||||||
|
- Filter and search rooms in the sidebar
|
||||||
|
- Favorite rooms sync across devices and appear in a pinned section
|
||||||
|
- Sort rooms by recent activity, alphabetical, or unread first
|
||||||
|
- DM rows show a message preview and relative timestamp
|
||||||
|
- Right-click a room for a context menu: mute with duration, copy link, mark as read
|
||||||
|
- Quick emoji reactions appear on message hover — one click to react
|
||||||
|
- Knock-to-join: request access to a room; admins approve or deny from the members list
|
||||||
|
- Media gallery drawer: browse all images, videos, and files shared in a room
|
||||||
|
- Invite link and QR code in room settings
|
||||||
|
- Pending knock requests shown in the members list for room admins with a live badge count on the Members button
|
||||||
|
- Homeserver support contact displayed in Help & About (MSC1929)
|
||||||
|
- Server notice rooms are visually distinct from regular DMs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Desktop App
|
||||||
|
|
||||||
|
Lotus Chat has a desktop app for Windows, macOS, and Linux. It wraps the same web client in a native window with automatic background updates — no need to reinstall for new versions.
|
||||||
|
|
||||||
|
### Download
|
||||||
|
|
||||||
|
Download the latest release from the [Releases page on code.lotusguild.org](https://code.lotusguild.org).
|
||||||
|
|
||||||
|
### SmartScreen Warning (Windows)
|
||||||
|
|
||||||
|
When you first run the installer on Windows, you may see a popup that says **"Windows protected your PC"** with the app listed as an unknown publisher. This is normal.
|
||||||
|
|
||||||
|
**Why it happens:** Windows SmartScreen flags any app that does not have an expensive commercial code-signing certificate from a major CA. Lotus Chat is signed with its own key for update verification, but that key is not in Microsoft's pre-approved list.
|
||||||
|
|
||||||
|
**How to install anyway:**
|
||||||
|
|
||||||
|
1. Click **"More info"** in the SmartScreen dialog.
|
||||||
|
2. A **"Run anyway"** button will appear.
|
||||||
|
3. Click it to proceed with installation.
|
||||||
|
|
||||||
|
After the first install, automatic in-app updates handle all future versions — you will not see this prompt again for updates.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## For Developers
|
||||||
|
|
||||||
|
The source code lives in `/root/code/cinny`. All changes should be made on the `lotus` branch. Push to `origin/lotus` and CI will automatically build and deploy to [chat.lotusguild.org](https://chat.lotusguild.org) in approximately 11 minutes — no manual build or deploy steps required.
|
||||||
|
|
||||||
|
See [LOTUS_FEATURES.md](LOTUS_FEATURES.md) for the full feature changelog and [LOTUS_TODO.md](LOTUS_TODO.md) for the work backlog.
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm ci
|
npm ci && npm run build # outputs to dist/
|
||||||
npm run build # outputs to dist/
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Vite's render-chunks phase requires ~6 GB Node heap. If OOM killed, set:
|
If the build is killed due to out-of-memory:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
NODE_OPTIONS=--max_old_space_size=6144 npm run build
|
NODE_OPTIONS=--max_old_space_size=6144 npm run build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Development workflow
|
### CI/CD
|
||||||
|
|
||||||
All code changes should be made in the local clone at `/root/code/cinny` on the dev box, then committed and pushed to `origin/lotus`. The CI/CD pipeline handles everything from there — no manual build or deploy steps needed.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
edit → commit → git push # ~11 minutes → auto-deployed to chat.lotusguild.org
|
edit → commit → git push → ~11 min → live at chat.lotusguild.org
|
||||||
```
|
```
|
||||||
|
|
||||||
Pipeline (`.gitea/workflows/ci.yml` + `lotus_deploy.sh` on LXC 106):
|
|
||||||
1. Push triggers a Gitea Actions build — TypeScript check, ESLint, Prettier, bundle size report
|
|
||||||
2. Build must pass as the CI gate; quality checks are informational (`continue-on-error`)
|
|
||||||
3. A Gitea webhook fires `lotus_deploy.sh` on LXC 106, which polls the API until CI passes (up to 15 min), then pulls `origin/lotus`, runs `npm ci && npm run build`, and rsyncs to `/var/www/html/`
|
|
||||||
|
|
||||||
LXC 106's stored Gitea credential is **read-only** — it can only pull. Pushes must be done from the dev box with your personal credentials (entered manually, never cached).
|
|
||||||
|
|
||||||
## Deployment
|
|
||||||
|
|
||||||
Built files are served from `/var/www/html/` on LXC 106 (nginx). Config lives at `/opt/lotus-cinny/config.json` (vite copies it to `dist/`):
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"defaultHomeserver": 0,
|
|
||||||
"homeserverList": ["matrix.lotusguild.org"],
|
|
||||||
"allowCustomHomeservers": false,
|
|
||||||
"gifApiKey": "<giphy_key>"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Key Custom Files
|
|
||||||
|
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
|
||||||
| `src/lotus-terminal.css.ts` | All TDS CSS tokens, global styles, light/dark variants |
|
|
||||||
| `src/lotus-boot.ts` | Boot sequence animation (runs once per session) |
|
|
||||||
| `src/app/hooks/useRoomReadPositions.ts` | Per-message read receipt position map |
|
|
||||||
| `src/app/features/room/ReadPositionsContext.ts` | React context for read positions |
|
|
||||||
| `src/app/components/read-receipt-avatars/` | Read receipt avatar pill component |
|
|
||||||
| `src/app/components/event-readers/EventReaders.tsx` | "Seen by" modal with timestamps |
|
|
||||||
| `src/app/components/GifPicker.tsx` | GIF search + send |
|
|
||||||
| `src/app/features/call/CallControls.tsx` | PTT badge + keybind logic |
|
|
||||||
| `src/app/plugins/call/CallControl.ts` | EC widget bridge (screenshare revert, PTT mic) |
|
|
||||||
| `src/app/components/CallEmbedProvider.tsx` | PiP + draggable call embed, call wallpaper carry-over |
|
|
||||||
| `src/app/plugins/call/CallEmbed.ts` | EC widget bridge: iframe setup, `color-scheme` dark/light injection, built-in control hiding, theme sync |
|
|
||||||
| `src/app/plugins/millify.ts` | Named import fix for Rolldown CJS interop (prevents `zc.default is not a function` crash) |
|
|
||||||
| `src/app/features/room/MediaGallery.tsx` | Right-side media gallery drawer (images/videos/files) |
|
|
||||||
| `src/app/features/room/PollCreator.tsx` | Poll creation modal (single/multiple choice, 2–10 options) |
|
|
||||||
| `src/app/features/common-settings/general/RoomShareInvite.tsx` | Invite link + QR code tile for room settings |
|
|
||||||
| `src/app/utils/syntaxHighlight.ts` | TDS code syntax tokenizer (JS/TS/Python/Rust → inline CSS vars) |
|
|
||||||
| `src/app/features/room-settings/ExportRoomHistory.tsx` | Export room messages to plain text / JSON / HTML with date range filter and E2EE awareness |
|
|
||||||
| `src/app/features/room-settings/RoomActivityLog.tsx` | Filterable mod log of room state events (joins, kicks, bans, power level changes, etc.) |
|
|
||||||
| `src/app/features/room-settings/RoomServerACL.tsx` | Server ACL viewer/editor (allow/deny lists, IP literal toggle, power-level gated) |
|
|
||||||
| `src/app/features/room-settings/RoomInsights.tsx` | Room stats panel: top members bar chart, top reactions, media breakdown, 24h heatmap |
|
|
||||||
| `src/app/features/bookmarks/BookmarksPanel.tsx` | Saved messages sidebar panel with filter, jump-to-message, and remove |
|
|
||||||
| `src/app/hooks/useBookmarks.ts` | Read/write `io.lotus.bookmarks` account data for message bookmarks |
|
|
||||||
| `src/app/features/room/ScheduleMessageModal.tsx` | Schedule-message modal with datetime picker; sends via MSC4140 delayed events API |
|
|
||||||
| `src/app/utils/scheduledMessages.ts` | Utilities for MSC4140 scheduled message state and cancel endpoint |
|
|
||||||
| `src/app/hooks/useExtendedProfile.ts` | Read/write MSC4133 extended profile fields (`m.pronouns`, `m.tz`) |
|
|
||||||
| `src/app/hooks/useLocalTime.ts` | Formats user local time from `m.tz` IANA zone; updates every 60 s |
|
|
||||||
| `src/app/components/url-preview/UrlPreviewCard.tsx` | Domain-specific URL preview cards for 13 sites + generic favicon fallback |
|
|
||||||
|
|||||||
@@ -29,10 +29,8 @@
|
|||||||
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link
|
<link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet" />
|
||||||
href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:ital,wght@0,400;0,600;0,700;1,400&family=VT323&display=swap"
|
<link rel="stylesheet" href="/fonts/custom-fonts.css" />
|
||||||
rel="stylesheet"
|
|
||||||
/>
|
|
||||||
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
<link id="favicon" rel="shortcut icon" href="./public/favicon.ico" />
|
||||||
|
|
||||||
<link rel="manifest" href="/manifest.json" />
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
|||||||
|
After Width: | Height: | Size: 851 KiB |
|
After Width: | Height: | Size: 944 KiB |
@@ -18,7 +18,8 @@
|
|||||||
"typecheck": "tsc --noEmit",
|
"typecheck": "tsc --noEmit",
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"commit": "git-cz",
|
"commit": "git-cz",
|
||||||
"postinstall": "node scripts/patch-folds.mjs"
|
"postinstall": "node scripts/patch-folds.mjs",
|
||||||
|
"sync:decorations": "node scripts/syncDecorations.mjs"
|
||||||
},
|
},
|
||||||
"lint-staged": {
|
"lint-staged": {
|
||||||
"*.{ts,tsx,js,jsx}": "eslint",
|
"*.{ts,tsx,js,jsx}": "eslint",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 631 B After Width: | Height: | Size: 128 KiB |
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=en>
|
||||||
|
<meta charset=utf-8>
|
||||||
|
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||||
|
<title>Error 404 (Not Found)!!1</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||||
|
</style>
|
||||||
|
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||||
|
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||||
|
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=en>
|
||||||
|
<meta charset=utf-8>
|
||||||
|
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||||
|
<title>Error 404 (Not Found)!!1</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||||
|
</style>
|
||||||
|
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||||
|
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||||
|
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang=en>
|
||||||
|
<meta charset=utf-8>
|
||||||
|
<meta name=viewport content="initial-scale=1, minimum-scale=1, width=device-width">
|
||||||
|
<title>Error 404 (Not Found)!!1</title>
|
||||||
|
<style>
|
||||||
|
*{margin:0;padding:0}html,code{font:15px/22px arial,sans-serif}html{background:#fff;color:#222;padding:15px}body{margin:7% auto 0;max-width:390px;min-height:180px;padding:30px 0 15px}* > body{background:url(//www.google.com/images/errors/robot.png) 100% 5px no-repeat;padding-right:205px}p{margin:11px 0 22px;overflow:hidden}ins{color:#777;text-decoration:none}a img{border:0}@media screen and (max-width:772px){body{background:none;margin-top:0;max-width:none;padding-right:0}}#logo{background:url(//www.google.com/images/branding/googlelogo/1x/googlelogo_color_150x54dp.png) no-repeat;margin-left:-5px}@media only screen and (min-resolution:192dpi){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat 0% 0%/100% 100%;-moz-border-image:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) 0}}@media only screen and (-webkit-min-device-pixel-ratio:2){#logo{background:url(//www.google.com/images/branding/googlelogo/2x/googlelogo_color_150x54dp.png) no-repeat;-webkit-background-size:100% 100%}}#logo{display:inline-block;height:54px;width:150px}
|
||||||
|
</style>
|
||||||
|
<a href=//www.google.com/><span id=logo aria-label=Google></span></a>
|
||||||
|
<p><b>404.</b> <ins>That’s an error.</ins>
|
||||||
|
<p>The requested URL <code>/s/jetbrainsmono/v18/tDbY2o-flEEny0FZhsfKu5WU4xD-IQ.woff2</code> was not found on this server. <ins>That’s all we know.</ins>
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
/* Self-hosted fonts — avoids tracking prevention in desktop WebView2 */
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: italic;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/JetBrainsMono-italic-400.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/JetBrainsMono-normal-400.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'JetBrains Mono';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/JetBrainsMono-normal-700.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Fira Code';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/FiraCode-400.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: 'Fira Code';
|
||||||
|
font-style: normal;
|
||||||
|
font-weight: 600;
|
||||||
|
font-display: swap;
|
||||||
|
src: url('/fonts/FiraCode-600.woff2') format('woff2');
|
||||||
|
unicode-range:
|
||||||
|
U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329,
|
||||||
|
U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
|
||||||
|
}
|
||||||
@@ -11,47 +11,47 @@
|
|||||||
"theme_color": "#980000",
|
"theme_color": "#980000",
|
||||||
"icons": [
|
"icons": [
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-36x36.png",
|
"src": "./res/android/android-chrome-36x36.png",
|
||||||
"sizes": "36x36",
|
"sizes": "36x36",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-48x48.png",
|
"src": "./res/android/android-chrome-48x48.png",
|
||||||
"sizes": "48x48",
|
"sizes": "48x48",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-72x72.png",
|
"src": "./res/android/android-chrome-72x72.png",
|
||||||
"sizes": "72x72",
|
"sizes": "72x72",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-96x96.png",
|
"src": "./res/android/android-chrome-96x96.png",
|
||||||
"sizes": "96x96",
|
"sizes": "96x96",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-144x144.png",
|
"src": "./res/android/android-chrome-144x144.png",
|
||||||
"sizes": "144x144",
|
"sizes": "144x144",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-192x192.png",
|
"src": "./res/android/android-chrome-192x192.png",
|
||||||
"sizes": "192x192",
|
"sizes": "192x192",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-256x256.png",
|
"src": "./res/android/android-chrome-256x256.png",
|
||||||
"sizes": "256x256",
|
"sizes": "256x256",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-384x384.png",
|
"src": "./res/android/android-chrome-384x384.png",
|
||||||
"sizes": "384x384",
|
"sizes": "384x384",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"src": "./public/android/android-chrome-512x512.png",
|
"src": "./res/android/android-chrome-512x512.png",
|
||||||
"sizes": "512x512",
|
"sizes": "512x512",
|
||||||
"type": "image/png"
|
"type": "image/png"
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 26 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 40 KiB |
|
Before Width: | Height: | Size: 2.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 3.1 KiB |
|
Before Width: | Height: | Size: 130 KiB After Width: | Height: | Size: 134 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 8.9 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 35 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 39 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 6.7 KiB After Width: | Height: | Size: 4.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 4.3 KiB |
|
Before Width: | Height: | Size: 9.9 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 6.2 KiB |
@@ -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');
|
||||||
@@ -42,6 +42,7 @@ import { ScreenSize, useScreenSizeContext } from '../hooks/useScreenSize';
|
|||||||
import { useMatrixClient } from '../hooks/useMatrixClient';
|
import { useMatrixClient } from '../hooks/useMatrixClient';
|
||||||
import CallSound from '../../../public/sound/call.ogg';
|
import CallSound from '../../../public/sound/call.ogg';
|
||||||
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
import { useCallMembersChange, useCallSession } from '../hooks/useCall';
|
||||||
|
import { useCallJoinLeaveSounds } from '../hooks/useCallJoinLeaveSounds';
|
||||||
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
import { useRemoteAllMuted } from '../hooks/useCallSpeakers';
|
||||||
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
import { useRoomAvatar, useRoomName } from '../hooks/useRoomMeta';
|
||||||
import { mDirectAtom } from '../state/mDirectList';
|
import { mDirectAtom } from '../state/mDirectList';
|
||||||
@@ -53,7 +54,7 @@ import { getChatBg } from '../features/lotus/chatBackground';
|
|||||||
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
import { useTheme, ThemeKind } from '../hooks/useTheme';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/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 { StateEvent } from '../../types/matrix/room';
|
||||||
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
import { getPowersLevelFromMatrixEvent } from '../hooks/usePowerLevels';
|
||||||
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
import { getRoomCreatorsForRoomId } from '../hooks/useRoomCreators';
|
||||||
@@ -324,18 +325,15 @@ function IncomingCallListener({ callEmbed, joined }: IncomingCallListenerProps)
|
|||||||
);
|
);
|
||||||
if (!hasCallPermission) return;
|
if (!hasCallPermission) return;
|
||||||
|
|
||||||
// Only ring for DMs or private non-space group chats.
|
// Only ring in rooms where the call button is visible: DMs or invite-only rooms
|
||||||
// Space voice channels and public rooms fire room-level RTC notifications
|
// with no space parent. Persistent voice rooms (call rooms), space channels,
|
||||||
// whenever anyone joins — ringing every member is incorrect behaviour.
|
// restricted rooms, and public rooms must never trigger ringing.
|
||||||
|
if (room.isCallRoom()) return;
|
||||||
const isDirect = directs.has(room.roomId);
|
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 joinRule = room.getJoinRule();
|
||||||
const isPrivateGroup =
|
const isPrivateInviteGroup = !isSpaceChild && joinRule === JoinRule.Invite;
|
||||||
!isSpaceChild &&
|
if (!isDirect && !isPrivateInviteGroup) return;
|
||||||
(joinRule === JoinRule.Invite ||
|
|
||||||
joinRule === JoinRule.Knock ||
|
|
||||||
joinRule === JoinRule.Restricted);
|
|
||||||
if (!isDirect && !isPrivateGroup) return;
|
|
||||||
|
|
||||||
const info: IncomingCallInfo = {
|
const info: IncomingCallInfo = {
|
||||||
room,
|
room,
|
||||||
@@ -406,6 +404,7 @@ function CallUtils({ embed }: { embed: CallEmbed }) {
|
|||||||
const setCallEmbed = useSetAtom(callEmbedAtom);
|
const setCallEmbed = useSetAtom(callEmbedAtom);
|
||||||
|
|
||||||
useCallMemberSoundSync(embed);
|
useCallMemberSoundSync(embed);
|
||||||
|
useCallJoinLeaveSounds(embed);
|
||||||
useCallThemeSync(embed);
|
useCallThemeSync(embed);
|
||||||
useCallHangupEvent(
|
useCallHangupEvent(
|
||||||
embed,
|
embed,
|
||||||
@@ -722,23 +721,20 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
document.addEventListener('touchend', onTouchEnd);
|
document.addEventListener('touchend', onTouchEnd);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
function applyResize(
|
||||||
e.stopPropagation();
|
el: HTMLElement,
|
||||||
e.preventDefault();
|
corner: Corner,
|
||||||
const el = callEmbedRef.current;
|
sx: number,
|
||||||
if (!el) return;
|
sy: number,
|
||||||
normaliseToTopLeft(el);
|
sw: number,
|
||||||
const sx = e.clientX;
|
sh: number,
|
||||||
const sy = e.clientY;
|
sl: number,
|
||||||
const sw = el.offsetWidth;
|
st: number,
|
||||||
const sh = el.offsetHeight;
|
cx: number,
|
||||||
const sl = parseFloat(el.style.left);
|
cy: number,
|
||||||
const st = parseFloat(el.style.top);
|
) {
|
||||||
document.body.style.cursor = `${corner}-resize`;
|
const dx = cx - sx;
|
||||||
document.body.style.userSelect = 'none';
|
const dy = cy - sy;
|
||||||
const onMove = (ev: MouseEvent) => {
|
|
||||||
const dx = ev.clientX - sx;
|
|
||||||
const dy = ev.clientY - sy;
|
|
||||||
let w = sw;
|
let w = sw;
|
||||||
let h = sh;
|
let h = sh;
|
||||||
let l = sl;
|
let l = sl;
|
||||||
@@ -771,6 +767,24 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
el.style.height = `${h}px`;
|
el.style.height = `${h}px`;
|
||||||
el.style.left = `${l}px`;
|
el.style.left = `${l}px`;
|
||||||
el.style.top = `${t}px`;
|
el.style.top = `${t}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResizeMouseDown = (e: React.MouseEvent, corner: Corner) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const el = callEmbedRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
normaliseToTopLeft(el);
|
||||||
|
const sx = e.clientX;
|
||||||
|
const sy = e.clientY;
|
||||||
|
const sw = el.offsetWidth;
|
||||||
|
const sh = el.offsetHeight;
|
||||||
|
const sl = parseFloat(el.style.left);
|
||||||
|
const st = parseFloat(el.style.top);
|
||||||
|
document.body.style.cursor = `${corner}-resize`;
|
||||||
|
document.body.style.userSelect = 'none';
|
||||||
|
const onMove = (ev: MouseEvent) => {
|
||||||
|
applyResize(el, corner, sx, sy, sw, sh, sl, st, ev.clientX, ev.clientY);
|
||||||
};
|
};
|
||||||
const onUp = () => {
|
const onUp = () => {
|
||||||
document.removeEventListener('mousemove', onMove);
|
document.removeEventListener('mousemove', onMove);
|
||||||
@@ -789,6 +803,38 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
document.addEventListener('mouseup', onUp);
|
document.addEventListener('mouseup', onUp);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleResizeTouchStart = (e: React.TouchEvent, corner: Corner) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
const el = callEmbedRef.current;
|
||||||
|
if (!el || e.touches.length !== 1) return;
|
||||||
|
normaliseToTopLeft(el);
|
||||||
|
const touch = e.touches[0];
|
||||||
|
const sx = touch.clientX;
|
||||||
|
const sy = touch.clientY;
|
||||||
|
const sw = el.offsetWidth;
|
||||||
|
const sh = el.offsetHeight;
|
||||||
|
const sl = parseFloat(el.style.left);
|
||||||
|
const st = parseFloat(el.style.top);
|
||||||
|
const onMove = (ev: TouchEvent) => {
|
||||||
|
if (ev.touches.length !== 1) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
const t = ev.touches[0];
|
||||||
|
applyResize(el, corner, sx, sy, sw, sh, sl, st, t.clientX, t.clientY);
|
||||||
|
};
|
||||||
|
const onEnd = () => {
|
||||||
|
document.removeEventListener('touchmove', onMove);
|
||||||
|
document.removeEventListener('touchend', onEnd);
|
||||||
|
activeDragCleanupRef.current = null;
|
||||||
|
};
|
||||||
|
activeDragCleanupRef.current = () => {
|
||||||
|
document.removeEventListener('touchmove', onMove);
|
||||||
|
document.removeEventListener('touchend', onEnd);
|
||||||
|
};
|
||||||
|
document.addEventListener('touchmove', onMove, { passive: false });
|
||||||
|
document.addEventListener('touchend', onEnd);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CallEmbedContextProvider value={callEmbed}>
|
<CallEmbedContextProvider value={callEmbed}>
|
||||||
{callEmbed && <CallUtils embed={callEmbed} />}
|
{callEmbed && <CallUtils embed={callEmbed} />}
|
||||||
@@ -871,6 +917,7 @@ export function CallEmbedProvider({ children }: CallEmbedProviderProps) {
|
|||||||
<div
|
<div
|
||||||
key={corner}
|
key={corner}
|
||||||
onMouseDown={(ev) => handleResizeMouseDown(ev, corner)}
|
onMouseDown={(ev) => handleResizeMouseDown(ev, corner)}
|
||||||
|
onTouchStart={(ev) => handleResizeTouchStart(ev, corner)}
|
||||||
onClick={(ev) => ev.stopPropagation()}
|
onClick={(ev) => ev.stopPropagation()}
|
||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ function GifPickerInner({ onSelect, requestClose, lotusTerminal }: GifPickerInne
|
|||||||
<div
|
<div
|
||||||
style={{
|
style={{
|
||||||
padding: '5px 10px 4px',
|
padding: '5px 10px 4px',
|
||||||
borderBottom: '1px solid rgba(255,107,0,0.2)',
|
borderBottom: '1px solid color-mix(in srgb, var(--lt-accent-orange) 20%, transparent)',
|
||||||
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
|
fontFamily: "'JetBrains Mono', 'Cascadia Code', monospace",
|
||||||
fontSize: '10px',
|
fontSize: '10px',
|
||||||
fontWeight: 700,
|
fontWeight: 700,
|
||||||
letterSpacing: '0.1em',
|
letterSpacing: '0.1em',
|
||||||
color: '#FF6B00',
|
color: 'var(--lt-accent-orange)',
|
||||||
userSelect: 'none',
|
userSelect: 'none',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -82,11 +82,12 @@ export function GifPicker({ apiKey, onSelect, requestClose }: GifPickerProps) {
|
|||||||
|
|
||||||
const containerStyle = lotusTerminal
|
const containerStyle = lotusTerminal
|
||||||
? {
|
? {
|
||||||
background: '#060c14',
|
background: 'var(--lt-bg-secondary)',
|
||||||
border: '1px solid rgba(255,107,0,0.35)',
|
border: '1px solid color-mix(in srgb, var(--lt-accent-orange) 35%, transparent)',
|
||||||
borderRadius: '4px',
|
borderRadius: '4px',
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
boxShadow: '0 4px 24px rgba(255,107,0,0.10), 0 0 0 1px rgba(255,107,0,0.08)',
|
boxShadow:
|
||||||
|
'0 4px 24px color-mix(in srgb, var(--lt-accent-orange) 10%, transparent), 0 0 0 1px color-mix(in srgb, var(--lt-accent-orange) 8%, transparent)',
|
||||||
width: `${PICKER_WIDTH}px`,
|
width: `${PICKER_WIDTH}px`,
|
||||||
}
|
}
|
||||||
: {
|
: {
|
||||||
|
|||||||
@@ -203,7 +203,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
width: toRem(8),
|
width: toRem(8),
|
||||||
height: toRem(8),
|
height: toRem(8),
|
||||||
borderRadius: '50%',
|
borderRadius: '50%',
|
||||||
background: lotusTerminal ? '#FF6B00' : 'var(--tc-danger-normal)',
|
background: lotusTerminal ? 'var(--lt-accent-orange)' : 'var(--tc-danger-normal)',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
animation: 'pttLivePulse 900ms ease-in-out infinite',
|
animation: 'pttLivePulse 900ms ease-in-out infinite',
|
||||||
}}
|
}}
|
||||||
@@ -214,7 +214,11 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
minWidth: toRem(32),
|
minWidth: toRem(32),
|
||||||
fontVariantNumeric: 'tabular-nums',
|
fontVariantNumeric: 'tabular-nums',
|
||||||
...(lotusTerminal
|
...(lotusTerminal
|
||||||
? { fontFamily: 'JetBrains Mono, monospace', color: '#00FF88', fontWeight: 700 }
|
? {
|
||||||
|
fontFamily: 'JetBrains Mono, monospace',
|
||||||
|
color: 'var(--lt-accent-green)',
|
||||||
|
fontWeight: 700,
|
||||||
|
}
|
||||||
: {}),
|
: {}),
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -233,7 +237,7 @@ export function VoiceMessageRecorder({ onSend, onError }: VoiceRecorderProps) {
|
|||||||
width: toRem(2),
|
width: toRem(2),
|
||||||
height: toRem(2 + (h / barMax) * 16),
|
height: toRem(2 + (h / barMax) * 16),
|
||||||
borderRadius: toRem(1),
|
borderRadius: toRem(1),
|
||||||
background: lotusTerminal ? '#00FF88' : 'var(--tc-primary-normal)',
|
background: lotusTerminal ? 'var(--lt-accent-green)' : 'var(--tc-primary-normal)',
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useAvatarDecoration } from '../../hooks/useAvatarDecoration';
|
||||||
|
import { decorationUrl } from '../../features/lotus/avatarDecorations';
|
||||||
|
|
||||||
|
const DEFAULT_INSET = 8;
|
||||||
|
|
||||||
|
type AvatarDecorationProps = {
|
||||||
|
userId: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
inset?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AvatarDecoration({ userId, children, inset = DEFAULT_INSET }: AvatarDecorationProps) {
|
||||||
|
const slug = useAvatarDecoration(userId);
|
||||||
|
|
||||||
|
if (!slug) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
display: 'inline-flex',
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<img
|
||||||
|
src={decorationUrl(slug)}
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: -inset,
|
||||||
|
left: -inset,
|
||||||
|
right: -inset,
|
||||||
|
bottom: -inset,
|
||||||
|
width: `calc(100% + ${inset * 2}px)`,
|
||||||
|
height: `calc(100% + ${inset * 2}px)`,
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 10,
|
||||||
|
objectFit: 'contain',
|
||||||
|
}}
|
||||||
|
alt=""
|
||||||
|
aria-hidden="true"
|
||||||
|
loading="lazy"
|
||||||
|
decoding="async"
|
||||||
|
onError={(e) => {
|
||||||
|
(e.currentTarget as HTMLImageElement).style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { UserAvatar } from '../../user-avatar';
|
|||||||
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
|
||||||
import { Membership } from '../../../../types/matrix/room';
|
import { Membership } from '../../../../types/matrix/room';
|
||||||
import { PresenceRingAvatar } from '../../presence';
|
import { PresenceRingAvatar } from '../../presence';
|
||||||
|
import { AvatarDecoration } from '../../avatar-decoration/AvatarDecoration';
|
||||||
|
|
||||||
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
type MentionAutoCompleteHandler = (userId: string, name: string) => void;
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ function UnknownMentionItem({
|
|||||||
}
|
}
|
||||||
onClick={() => handleAutocomplete(userId, name)}
|
onClick={() => handleAutocomplete(userId, name)}
|
||||||
before={
|
before={
|
||||||
|
<AvatarDecoration userId={userId}>
|
||||||
<PresenceRingAvatar userId={userId}>
|
<PresenceRingAvatar userId={userId}>
|
||||||
<Avatar size="200">
|
<Avatar size="200">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -56,6 +58,7 @@ function UnknownMentionItem({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</PresenceRingAvatar>
|
</PresenceRingAvatar>
|
||||||
|
</AvatarDecoration>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} size="B400">
|
<Text style={{ flexGrow: 1 }} size="B400">
|
||||||
@@ -177,6 +180,7 @@ export function UserMentionAutocomplete({
|
|||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
before={
|
before={
|
||||||
|
<AvatarDecoration userId={roomMember.userId}>
|
||||||
<PresenceRingAvatar userId={roomMember.userId}>
|
<PresenceRingAvatar userId={roomMember.userId}>
|
||||||
<Avatar size="200">
|
<Avatar size="200">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -187,6 +191,7 @@ export function UserMentionAutocomplete({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</PresenceRingAvatar>
|
</PresenceRingAvatar>
|
||||||
|
</AvatarDecoration>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
<Text style={{ flexGrow: 1 }} size="B400" truncate>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import React, { ReactNode, useId } from 'react';
|
import React, { ReactNode } from 'react';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
|
import { Presence, usePresenceLabel } from '../../hooks/useUserPresence';
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ type PresenceBadgeProps = {
|
|||||||
};
|
};
|
||||||
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
||||||
const label = usePresenceLabel();
|
const label = usePresenceLabel();
|
||||||
const badgeLabelId = useId();
|
const ariaLabel = status ? `${label[presence]} — ${status}` : label[presence];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
@@ -36,7 +36,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
|||||||
offset={4}
|
offset={4}
|
||||||
delay={200}
|
delay={200}
|
||||||
tooltip={
|
tooltip={
|
||||||
<Tooltip id={badgeLabelId}>
|
<Tooltip>
|
||||||
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
<Box style={{ maxWidth: toRem(250) }} alignItems="Baseline" gap="100">
|
||||||
<Text size="L400">{label[presence]}</Text>
|
<Text size="L400">{label[presence]}</Text>
|
||||||
{status && <Text size="T200">•</Text>}
|
{status && <Text size="T200">•</Text>}
|
||||||
@@ -47,7 +47,7 @@ export function PresenceBadge({ presence, status, size }: PresenceBadgeProps) {
|
|||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
<Badge
|
<Badge
|
||||||
aria-labelledby={badgeLabelId}
|
aria-label={ariaLabel}
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
size={size}
|
size={size}
|
||||||
variant={PresenceToColor[presence]}
|
variant={PresenceToColor[presence]}
|
||||||
|
|||||||
@@ -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 { BreakWord, LineClamp2, LineClamp3 } from '../../styles/Text.css';
|
||||||
import { UserPresence } from '../../hooks/useUserPresence';
|
import { UserPresence } from '../../hooks/useUserPresence';
|
||||||
import { AvatarPresence, PresenceBadge } from '../presence';
|
import { AvatarPresence, PresenceBadge } from '../presence';
|
||||||
|
import { AvatarDecoration } from '../avatar-decoration/AvatarDecoration';
|
||||||
import { ImageViewer } from '../image-viewer';
|
import { ImageViewer } from '../image-viewer';
|
||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { useSetting } from '../../state/hooks/settings';
|
import { useSetting } from '../../state/hooks/settings';
|
||||||
@@ -47,6 +48,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={css.UserHeroAvatarContainer}>
|
<div className={css.UserHeroAvatarContainer}>
|
||||||
|
<AvatarDecoration userId={userId} inset={20}>
|
||||||
<AvatarPresence
|
<AvatarPresence
|
||||||
className={css.UserAvatarContainer}
|
className={css.UserAvatarContainer}
|
||||||
badge={
|
badge={
|
||||||
@@ -68,6 +70,7 @@ export function UserHero({ userId, avatarUrl, presence }: UserHeroProps) {
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</AvatarPresence>
|
</AvatarPresence>
|
||||||
|
</AvatarDecoration>
|
||||||
{viewAvatar && (
|
{viewAvatar && (
|
||||||
<Overlay open backdrop={<OverlayBackdrop />}>
|
<Overlay open backdrop={<OverlayBackdrop />}>
|
||||||
<OverlayCenter>
|
<OverlayCenter>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { Box, Button, config, Icon, IconButton, Icons, Spinner, Text, toRem } from 'folds';
|
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 { useNavigate } from 'react-router-dom';
|
||||||
import { VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
|
import { VerificationRequest } from 'matrix-js-sdk/lib/crypto-api';
|
||||||
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
|
import { AsyncState, AsyncStatus, useAsync } from '../../hooks/useAsyncCallback';
|
||||||
@@ -32,6 +32,7 @@ import { CreatorChip } from './CreatorChip';
|
|||||||
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
import { getDirectCreatePath, withSearchParam } from '../../pages/pathUtils';
|
||||||
import { DirectCreateSearchParams } from '../../pages/paths';
|
import { DirectCreateSearchParams } from '../../pages/paths';
|
||||||
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
|
import { useExtendedProfile } from '../../hooks/useExtendedProfile';
|
||||||
|
import { useUserNotes, USER_NOTE_MAX_LENGTH } from '../../hooks/useUserNotes';
|
||||||
|
|
||||||
type VerifyDeviceButtonProps = {
|
type VerifyDeviceButtonProps = {
|
||||||
userId: string;
|
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 = {
|
type UserRoomProfileProps = {
|
||||||
userId: string;
|
userId: string;
|
||||||
};
|
};
|
||||||
@@ -331,6 +391,7 @@ export function UserRoomProfile({ userId }: UserRoomProfileProps) {
|
|||||||
canBan={canBanUser && membership !== Membership.Ban}
|
canBan={canBanUser && membership !== Membership.Ban}
|
||||||
/>
|
/>
|
||||||
{showEncryption && userId !== myUserId && <UserDeviceSessions userId={userId} />}
|
{showEncryption && userId !== myUserId && <UserDeviceSessions userId={userId} />}
|
||||||
|
{userId !== myUserId && <UserPrivateNotes userId={userId} />}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import { useResizeObserver } from '../../hooks/useResizeObserver';
|
|||||||
import { stopPropagation } from '../../utils/keyboard';
|
import { stopPropagation } from '../../utils/keyboard';
|
||||||
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback';
|
||||||
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
import { useCallEmbedRef } from '../../hooks/useCallEmbed';
|
||||||
|
import { useAfkAutoMute } from '../../hooks/useAfkAutoMute';
|
||||||
|
|
||||||
type CallControlsProps = {
|
type CallControlsProps = {
|
||||||
callEmbed: CallEmbed;
|
callEmbed: CallEmbed;
|
||||||
@@ -71,6 +72,8 @@ export function CallControls({ callEmbed }: CallControlsProps) {
|
|||||||
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
const { microphone, video, sound, screenshare, spotlight, screenshareAudioMuted } =
|
||||||
useCallControlState(callEmbed.control);
|
useCallControlState(callEmbed.control);
|
||||||
|
|
||||||
|
useAfkAutoMute(callEmbed);
|
||||||
|
|
||||||
const [cords, setCords] = useState<RectCords>();
|
const [cords, setCords] = useState<RectCords>();
|
||||||
const [shareConfirm, setShareConfirm] = useState(false);
|
const [shareConfirm, setShareConfirm] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import { useRoomPermissions } from '../../hooks/useRoomPermissions';
|
|||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { StateEvent } from '../../../types/matrix/room';
|
import { StateEvent } from '../../../types/matrix/room';
|
||||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||||
|
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||||
|
import { VoiceLimitContent } from '../common-settings/general/RoomVoiceLimit';
|
||||||
import { CallMemberRenderer } from './CallMemberCard';
|
import { CallMemberRenderer } from './CallMemberCard';
|
||||||
import * as css from './styles.css';
|
import * as css from './styles.css';
|
||||||
import { CallControls } from './CallControls';
|
import { CallControls } from './CallControls';
|
||||||
@@ -74,6 +76,14 @@ function AlreadyInCallMessage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ChannelFullMessage({ current, max }: { current: number; max: number }) {
|
||||||
|
return (
|
||||||
|
<Text style={{ margin: 'auto', color: color.Critical.Main }} size="L400" align="Center">
|
||||||
|
Channel Full ({current}/{max}) — Wait for someone to leave before joining.
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function CallPrescreen() {
|
function CallPrescreen() {
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const room = useRoom();
|
const room = useRoom();
|
||||||
@@ -96,7 +106,14 @@ function CallPrescreen() {
|
|||||||
const callEmbed = useCallEmbed();
|
const callEmbed = useCallEmbed();
|
||||||
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
const inOtherCall = callEmbed && callEmbed.roomId !== room.roomId;
|
||||||
|
|
||||||
const canJoin = hasPermission && livekitSupported && rtcSupported;
|
// Voice channel user limit (io.lotus.voice_limit). 0 / absent means no limit.
|
||||||
|
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
|
||||||
|
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
|
||||||
|
// A user already counted in the session is rejoining and should not be blocked.
|
||||||
|
const alreadyMember = callMembers.some((m) => m.sender === mx.getSafeUserId());
|
||||||
|
const channelFull = maxUsers > 0 && !alreadyMember && callMembers.length >= maxUsers;
|
||||||
|
|
||||||
|
const canJoin = hasPermission && livekitSupported && rtcSupported && !channelFull;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Scroll variant="Surface" hideTrack>
|
<Scroll variant="Surface" hideTrack>
|
||||||
@@ -117,16 +134,17 @@ function CallPrescreen() {
|
|||||||
<CallMemberRenderer members={callMembers} />
|
<CallMemberRenderer members={callMembers} />
|
||||||
<PrescreenControls canJoin={canJoin} />
|
<PrescreenControls canJoin={canJoin} />
|
||||||
<Box className={css.PrescreenMessage} alignItems="Center">
|
<Box className={css.PrescreenMessage} alignItems="Center">
|
||||||
{!inOtherCall &&
|
{!inOtherCall && !hasPermission && <NoPermissionMessage />}
|
||||||
(hasPermission ? (
|
{!inOtherCall && hasPermission && channelFull && (
|
||||||
|
<ChannelFullMessage current={callMembers.length} max={maxUsers} />
|
||||||
|
)}
|
||||||
|
{!inOtherCall && hasPermission && !channelFull && (
|
||||||
<JoinMessage
|
<JoinMessage
|
||||||
hasParticipant={hasParticipant}
|
hasParticipant={hasParticipant}
|
||||||
livekitSupported={livekitSupported}
|
livekitSupported={livekitSupported}
|
||||||
rtcSupported={rtcSupported}
|
rtcSupported={rtcSupported}
|
||||||
/>
|
/>
|
||||||
) : (
|
)}
|
||||||
<NoPermissionMessage />
|
|
||||||
))}
|
|
||||||
{inOtherCall && <AlreadyInCallMessage />}
|
{inOtherCall && <AlreadyInCallMessage />}
|
||||||
</Box>
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { FormEventHandler, useCallback } from 'react';
|
||||||
|
import { Box, Button, color, Input, Spinner, Text } from 'folds';
|
||||||
|
import { MatrixError } from 'matrix-js-sdk';
|
||||||
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
|
import { SequenceCardStyle } from '../../room-settings/styles.css';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { useRoom } from '../../../hooks/useRoom';
|
||||||
|
import { StateEvent } from '../../../../types/matrix/room';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { useStateEvent } from '../../../hooks/useStateEvent';
|
||||||
|
import { RoomPermissionsAPI } from '../../../hooks/useRoomPermissions';
|
||||||
|
|
||||||
|
export type VoiceLimitContent = {
|
||||||
|
max_users?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RoomVoiceLimitProps = {
|
||||||
|
permissions: RoomPermissionsAPI;
|
||||||
|
};
|
||||||
|
export function RoomVoiceLimit({ permissions }: RoomVoiceLimitProps) {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const room = useRoom();
|
||||||
|
|
||||||
|
const canEdit = permissions.stateEvent(StateEvent.LotusVoiceLimit, mx.getSafeUserId());
|
||||||
|
|
||||||
|
const limitEvent = useStateEvent(room, StateEvent.LotusVoiceLimit);
|
||||||
|
const maxUsers = limitEvent?.getContent<VoiceLimitContent>().max_users ?? 0;
|
||||||
|
|
||||||
|
const [submitState, submit] = useAsyncCallback(
|
||||||
|
useCallback(
|
||||||
|
async (value: number) => {
|
||||||
|
const content: VoiceLimitContent = value > 0 ? { max_users: value } : {};
|
||||||
|
await mx.sendStateEvent(room.roomId, StateEvent.LotusVoiceLimit as any, content);
|
||||||
|
},
|
||||||
|
[mx, room.roomId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const submitting = submitState.status === AsyncStatus.Loading;
|
||||||
|
|
||||||
|
const handleSubmit: FormEventHandler<HTMLFormElement> = (evt) => {
|
||||||
|
evt.preventDefault();
|
||||||
|
const target = evt.target as HTMLFormElement;
|
||||||
|
const limitInput = target.elements.namedItem('limitInput') as HTMLInputElement | null;
|
||||||
|
if (!limitInput) return;
|
||||||
|
const parsed = parseInt(limitInput.value, 10);
|
||||||
|
const value = Number.isNaN(parsed) || parsed < 0 ? 0 : parsed;
|
||||||
|
submit(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Voice Channel Limit"
|
||||||
|
description="Set the maximum number of participants allowed in this room's voice call. Set to 0 for no limit. Enforced on the server for all Matrix clients."
|
||||||
|
>
|
||||||
|
<Box as="form" onSubmit={handleSubmit} gap="200" alignItems="Center">
|
||||||
|
<Box style={{ maxWidth: '100px' }} grow="Yes">
|
||||||
|
<Input
|
||||||
|
key={maxUsers}
|
||||||
|
name="limitInput"
|
||||||
|
defaultValue={maxUsers}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={99}
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
radii="300"
|
||||||
|
readOnly={!canEdit}
|
||||||
|
disabled={!canEdit}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
size="300"
|
||||||
|
variant="Primary"
|
||||||
|
fill="Solid"
|
||||||
|
radii="300"
|
||||||
|
disabled={!canEdit || submitting}
|
||||||
|
before={submitting ? <Spinner size="100" variant="Primary" fill="Solid" /> : undefined}
|
||||||
|
>
|
||||||
|
<Text size="B300">Save</Text>
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
{submitState.status === AsyncStatus.Error && (
|
||||||
|
<Text style={{ color: color.Critical.Main }} size="T200">
|
||||||
|
{(submitState.error as MatrixError).message}
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
</SettingTile>
|
||||||
|
</SequenceCard>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,3 +6,4 @@ export * from './RoomProfile';
|
|||||||
export * from './RoomPublish';
|
export * from './RoomPublish';
|
||||||
export * from './RoomShareInvite';
|
export * from './RoomShareInvite';
|
||||||
export * from './RoomUpgrade';
|
export * from './RoomUpgrade';
|
||||||
|
export * from './RoomVoiceLimit';
|
||||||
|
|||||||
@@ -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(','),
|
].join(','),
|
||||||
},
|
},
|
||||||
|
|
||||||
// Animated: Matrix digital rain — scrolling vertical green stripes
|
// Animated: Matrix digital rain — scrolling stripe columns + phosphor glow flicker
|
||||||
'anim-rain': {
|
'anim-rain': {
|
||||||
backgroundColor: '#010804',
|
backgroundColor: '#010804',
|
||||||
backgroundImage: [
|
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)',
|
'repeating-linear-gradient(180deg, rgba(0,255,136,0.07) 0px, rgba(0,255,136,0.07) 1px, transparent 1px, transparent 8px)',
|
||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: '40px 200px, 12px 200px',
|
backgroundSize: '40px 200px, 12px 200px',
|
||||||
@@ -209,7 +209,7 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
|||||||
animation: `${animRainKeyframe} 8s linear infinite`,
|
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': {
|
'anim-stars': {
|
||||||
backgroundColor: '#050510',
|
backgroundColor: '#050510',
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
@@ -219,10 +219,10 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
||||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
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': {
|
'anim-pulse': {
|
||||||
backgroundColor: '#030508',
|
backgroundColor: '#030508',
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
@@ -235,31 +235,31 @@ const DARK: Record<ChatBackground, CSSProperties> = {
|
|||||||
animation: `${animGridPulseKeyframe} 4s ease-in-out infinite`,
|
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': {
|
'anim-aurora': {
|
||||||
backgroundColor: '#020a10',
|
backgroundColor: '#020a10',
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
'radial-gradient(ellipse at 20% 30%, rgba(0,255,136,0.10) 0%, transparent 55%)',
|
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,255,136,0.12) 0%, transparent 70%)',
|
||||||
'radial-gradient(ellipse at 80% 70%, rgba(0,100,255,0.10) 0%, transparent 55%)',
|
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,100,255,0.12) 0%, transparent 70%)',
|
||||||
'radial-gradient(ellipse at 50% 10%, rgba(191,95,255,0.08) 0%, transparent 50%)',
|
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(191,95,255,0.09) 0%, transparent 65%)',
|
||||||
'radial-gradient(ellipse at 60% 90%, rgba(0,212,255,0.08) 0%, transparent 50%)',
|
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,212,255,0.09) 0%, transparent 65%)',
|
||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: '200% 200%',
|
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
|
||||||
backgroundPosition: '0% 0%',
|
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
|
||||||
animation: `${animAuroraKeyframe} 20s ease-in-out infinite`,
|
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': {
|
'anim-fireflies': {
|
||||||
backgroundColor: '#030508',
|
backgroundColor: '#030508',
|
||||||
backgroundImage: [
|
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,220,50,0.7) 1.5px, rgba(255,160,0,0.18) 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,200,30,0.55) 1px, rgba(255,140,0,0.14) 2.5px, transparent 3.5px)',
|
||||||
'radial-gradient(circle, rgba(255,240,100,0.35) 1px, transparent 2px)',
|
'radial-gradient(circle, rgba(255,240,100,0.4) 1px, transparent 2px)',
|
||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
|
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
|
||||||
backgroundPosition: '0 0, 120px 80px, 60px 140px',
|
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': {
|
'anim-rain': {
|
||||||
backgroundColor: '#f0fff4',
|
backgroundColor: '#f0fff4',
|
||||||
backgroundImage: [
|
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)',
|
'repeating-linear-gradient(180deg, rgba(0,160,80,0.07) 0px, rgba(0,160,80,0.07) 1px, transparent 1px, transparent 8px)',
|
||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: '40px 200px, 12px 200px',
|
backgroundSize: '40px 200px, 12px 200px',
|
||||||
@@ -440,7 +440,7 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
|||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
backgroundSize: '130px 130px, 190px 190px, 260px 260px',
|
||||||
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
backgroundPosition: '0 0, 65px 32px, 32px 97px',
|
||||||
animation: `${animStarsDriftKeyframe} 25s linear infinite`,
|
animation: `${animStarsDriftKeyframe} 30s linear infinite`,
|
||||||
},
|
},
|
||||||
|
|
||||||
'anim-pulse': {
|
'anim-pulse': {
|
||||||
@@ -458,26 +458,26 @@ const LIGHT: Record<ChatBackground, CSSProperties> = {
|
|||||||
'anim-aurora': {
|
'anim-aurora': {
|
||||||
backgroundColor: '#f0f8f4',
|
backgroundColor: '#f0f8f4',
|
||||||
backgroundImage: [
|
backgroundImage: [
|
||||||
'radial-gradient(ellipse at 20% 30%, rgba(0,160,80,0.12) 0%, transparent 55%)',
|
'radial-gradient(ellipse 80% 60% at 20% 30%, rgba(0,160,80,0.13) 0%, transparent 70%)',
|
||||||
'radial-gradient(ellipse at 80% 70%, rgba(0,80,200,0.12) 0%, transparent 55%)',
|
'radial-gradient(ellipse 70% 50% at 80% 70%, rgba(0,80,200,0.13) 0%, transparent 70%)',
|
||||||
'radial-gradient(ellipse at 50% 10%, rgba(140,60,220,0.09) 0%, transparent 50%)',
|
'radial-gradient(ellipse 90% 70% at 50% 10%, rgba(140,60,220,0.10) 0%, transparent 65%)',
|
||||||
'radial-gradient(ellipse at 60% 90%, rgba(0,160,200,0.09) 0%, transparent 50%)',
|
'radial-gradient(ellipse 75% 55% at 60% 90%, rgba(0,160,200,0.10) 0%, transparent 65%)',
|
||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: '200% 200%',
|
backgroundSize: '200% 200%, 250% 250%, 300% 300%, 220% 220%',
|
||||||
backgroundPosition: '0% 0%',
|
backgroundPosition: '0% 0%, 100% 0%, 50% 100%, 0% 50%',
|
||||||
animation: `${animAuroraKeyframe} 20s ease-in-out infinite`,
|
animation: `${animAuroraKeyframe} 28s ease-in-out infinite`,
|
||||||
},
|
},
|
||||||
|
|
||||||
'anim-fireflies': {
|
'anim-fireflies': {
|
||||||
backgroundColor: '#fffdf0',
|
backgroundColor: '#fffdf0',
|
||||||
backgroundImage: [
|
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(180,120,0,0.70) 1.5px, rgba(160,90,0,0.18) 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(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.35) 1px, transparent 2px)',
|
'radial-gradient(circle, rgba(200,140,0,0.40) 1px, transparent 2px)',
|
||||||
].join(','),
|
].join(','),
|
||||||
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
|
backgroundSize: '200px 200px, 280px 280px, 160px 160px',
|
||||||
backgroundPosition: '0 0, 120px 80px, 60px 140px',
|
backgroundPosition: '0 0, 120px 80px, 60px 140px',
|
||||||
animation: `${animFirefliesKeyframe} 15s linear infinite`,
|
animation: `${animFirefliesKeyframe} 30s linear infinite`,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,10 @@ export const useLocalMessageSearch = () => {
|
|||||||
|
|
||||||
encryptedRoomsCount += 1;
|
encryptedRoomsCount += 1;
|
||||||
|
|
||||||
const events = room.getLiveTimeline().getEvents();
|
const events = room
|
||||||
|
.getUnfilteredTimelineSet()
|
||||||
|
.getTimelines()
|
||||||
|
.flatMap((tl) => tl.getEvents());
|
||||||
if (events.length === 0) continue;
|
if (events.length === 0) continue;
|
||||||
|
|
||||||
searchedRoomsCount += 1;
|
searchedRoomsCount += 1;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
RoomPublish,
|
RoomPublish,
|
||||||
RoomShareInvite,
|
RoomShareInvite,
|
||||||
RoomUpgrade,
|
RoomUpgrade,
|
||||||
|
RoomVoiceLimit,
|
||||||
} from '../../common-settings/general';
|
} from '../../common-settings/general';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
|
||||||
@@ -54,6 +55,10 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<RoomEncryption permissions={permissions} />
|
<RoomEncryption permissions={permissions} />
|
||||||
<RoomPublish permissions={permissions} />
|
<RoomPublish permissions={permissions} />
|
||||||
</Box>
|
</Box>
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Voice</Text>
|
||||||
|
<RoomVoiceLimit permissions={permissions} />
|
||||||
|
</Box>
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
<Text size="L400">Addresses</Text>
|
<Text size="L400">Addresses</Text>
|
||||||
<RoomPublishedAddresses permissions={permissions} />
|
<RoomPublishedAddresses permissions={permissions} />
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ import { useCrossSigningActive } from '../../hooks/useCrossSigning';
|
|||||||
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
|
import { MemberVerificationBadge } from '../../components/MemberVerificationBadge';
|
||||||
import { useUserPresence } from '../../hooks/useUserPresence';
|
import { useUserPresence } from '../../hooks/useUserPresence';
|
||||||
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
|
import { PresenceBadge, PresenceRingAvatar } from '../../components/presence';
|
||||||
|
import { AvatarDecoration } from '../../components/avatar-decoration/AvatarDecoration';
|
||||||
|
|
||||||
type MemberDrawerHeaderProps = {
|
type MemberDrawerHeaderProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -150,6 +151,7 @@ function MemberItem({
|
|||||||
radii="400"
|
radii="400"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
before={
|
before={
|
||||||
|
<AvatarDecoration userId={member.userId}>
|
||||||
<PresenceRingAvatar userId={member.userId}>
|
<PresenceRingAvatar userId={member.userId}>
|
||||||
<Avatar size="200">
|
<Avatar size="200">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -160,6 +162,7 @@ function MemberItem({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</PresenceRingAvatar>
|
</PresenceRingAvatar>
|
||||||
|
</AvatarDecoration>
|
||||||
}
|
}
|
||||||
after={
|
after={
|
||||||
<>
|
<>
|
||||||
@@ -442,6 +445,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||||||
gap="200"
|
gap="200"
|
||||||
style={{ padding: `0 ${config.space.S200}` }}
|
style={{ padding: `0 ${config.space.S200}` }}
|
||||||
>
|
>
|
||||||
|
<AvatarDecoration userId={knockMember.userId}>
|
||||||
<PresenceRingAvatar userId={knockMember.userId}>
|
<PresenceRingAvatar userId={knockMember.userId}>
|
||||||
<Avatar size="200">
|
<Avatar size="200">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -452,6 +456,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) {
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</PresenceRingAvatar>
|
</PresenceRingAvatar>
|
||||||
|
</AvatarDecoration>
|
||||||
<Box grow="Yes" direction="Column">
|
<Box grow="Yes" direction="Column">
|
||||||
<Text size="T400" truncate>
|
<Text size="T400" truncate>
|
||||||
{knockName}
|
{knockName}
|
||||||
|
|||||||
@@ -209,6 +209,15 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
const imagePackRooms: Room[] = useImagePackRooms(roomId, roomToParents);
|
||||||
|
|
||||||
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [toolbar, setToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
|
const [composerToolbarButtons] = useSetting(settingsAtom, 'composerToolbarButtons');
|
||||||
|
const showFormat = composerToolbarButtons?.showFormat ?? true;
|
||||||
|
const showEmoji = composerToolbarButtons?.showEmoji ?? true;
|
||||||
|
const showSticker = composerToolbarButtons?.showSticker ?? true;
|
||||||
|
const showGif = composerToolbarButtons?.showGif ?? true;
|
||||||
|
const showLocation = composerToolbarButtons?.showLocation ?? true;
|
||||||
|
const showPoll = composerToolbarButtons?.showPoll ?? true;
|
||||||
|
const showVoice = composerToolbarButtons?.showVoice ?? true;
|
||||||
|
const showSchedule = composerToolbarButtons?.showSchedule ?? true;
|
||||||
const [locating, setLocating] = React.useState(false);
|
const [locating, setLocating] = React.useState(false);
|
||||||
const [locationError, setLocationError] = React.useState<string | null>(null);
|
const [locationError, setLocationError] = React.useState<string | null>(null);
|
||||||
const handleShareLocation = () => {
|
const handleShareLocation = () => {
|
||||||
@@ -933,6 +942,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
}
|
}
|
||||||
after={
|
after={
|
||||||
<>
|
<>
|
||||||
|
{showFormat && (
|
||||||
<IconButton
|
<IconButton
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
size="300"
|
size="300"
|
||||||
@@ -943,6 +953,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
>
|
>
|
||||||
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
<Icon src={toolbar ? Icons.AlphabetUnderline : Icons.Alphabet} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{(showEmoji || showSticker) && (
|
||||||
<UseStateProvider initial={undefined}>
|
<UseStateProvider initial={undefined}>
|
||||||
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
{(emojiBoardTab: EmojiBoardTab | undefined, setEmojiBoardTab) => (
|
||||||
<PopOut
|
<PopOut
|
||||||
@@ -978,7 +990,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
</React.Suspense>
|
</React.Suspense>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{!hideStickerBtn && (
|
{showSticker && !hideStickerBtn && (
|
||||||
<IconButton
|
<IconButton
|
||||||
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
aria-pressed={emojiBoardTab === EmojiBoardTab.Sticker}
|
||||||
aria-label="Insert sticker"
|
aria-label="Insert sticker"
|
||||||
@@ -993,6 +1005,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
|
{showEmoji && (
|
||||||
<IconButton
|
<IconButton
|
||||||
ref={emojiBtnRef}
|
ref={emojiBtnRef}
|
||||||
aria-label="Insert emoji"
|
aria-label="Insert emoji"
|
||||||
@@ -1007,14 +1020,18 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
<Icon
|
<Icon
|
||||||
src={Icons.Smile}
|
src={Icons.Smile}
|
||||||
filled={
|
filled={
|
||||||
hideStickerBtn ? !!emojiBoardTab : emojiBoardTab === EmojiBoardTab.Emoji
|
hideStickerBtn
|
||||||
|
? !!emojiBoardTab
|
||||||
|
: emojiBoardTab === EmojiBoardTab.Emoji
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
)}
|
||||||
</PopOut>
|
</PopOut>
|
||||||
)}
|
)}
|
||||||
</UseStateProvider>
|
</UseStateProvider>
|
||||||
{!!gifApiKey && (
|
)}
|
||||||
|
{!!gifApiKey && showGif && (
|
||||||
<UseStateProvider initial={false}>
|
<UseStateProvider initial={false}>
|
||||||
{(gifOpen: boolean, setGifOpen) => (
|
{(gifOpen: boolean, setGifOpen) => (
|
||||||
<PopOut
|
<PopOut
|
||||||
@@ -1093,6 +1110,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
{locationError}
|
{locationError}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{showLocation && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleShareLocation}
|
onClick={handleShareLocation}
|
||||||
disabled={locating}
|
disabled={locating}
|
||||||
@@ -1108,6 +1126,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
<Icon src={Icons.SpaceGlobe} size="100" />
|
<Icon src={Icons.SpaceGlobe} size="100" />
|
||||||
)}
|
)}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{showPoll && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={() => setPollOpen(true)}
|
onClick={() => setPollOpen(true)}
|
||||||
aria-label="Create poll"
|
aria-label="Create poll"
|
||||||
@@ -1118,6 +1138,8 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
>
|
>
|
||||||
<Icon src={Icons.OrderList} size="100" />
|
<Icon src={Icons.OrderList} size="100" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
)}
|
||||||
|
{showVoice && (
|
||||||
<VoiceMessageRecorder
|
<VoiceMessageRecorder
|
||||||
onSend={handleVoiceSend}
|
onSend={handleVoiceSend}
|
||||||
onError={(err) => {
|
onError={(err) => {
|
||||||
@@ -1125,6 +1147,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
setTimeout(() => setLocationError(null), 4000);
|
setTimeout(() => setLocationError(null), 4000);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
{charCount > 0 && (
|
{charCount > 0 && (
|
||||||
<Text
|
<Text
|
||||||
size="T200"
|
size="T200"
|
||||||
@@ -1141,6 +1164,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
{charCount}
|
{charCount}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
{showSchedule && (
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={handleScheduleClick}
|
onClick={handleScheduleClick}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
@@ -1151,6 +1175,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||||||
>
|
>
|
||||||
<Icon src={Icons.Clock} size="100" />
|
<Icon src={Icons.Clock} size="100" />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
)}
|
||||||
<IconButton
|
<IconButton
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
variant="SurfaceVariant"
|
variant="SurfaceVariant"
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
const [chatBackground] = useSetting(settingsAtom, 'chatBackground');
|
||||||
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
const [lotusTerminal] = useSetting(settingsAtom, 'lotusTerminal');
|
||||||
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
const [pauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||||
|
const [glassmorphismSidebar] = useSetting(settingsAtom, 'glassmorphismSidebar');
|
||||||
const theme = useTheme();
|
const theme = useTheme();
|
||||||
const isDark = theme.kind === ThemeKind.Dark;
|
const isDark = theme.kind === ThemeKind.Dark;
|
||||||
|
|
||||||
@@ -97,14 +98,19 @@ export function RoomView({ eventId }: { eventId?: string }) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// When glassmorphism is active, document.body already carries the background so the
|
||||||
|
// sidebar blur has something to work through. Skip applying it here to avoid running
|
||||||
|
// the same CSS animation twice (one per layer = double GPU work).
|
||||||
const chatBgStyle = useMemo(
|
const chatBgStyle = useMemo(
|
||||||
() =>
|
() =>
|
||||||
getChatBg(
|
glassmorphismSidebar
|
||||||
|
? {}
|
||||||
|
: getChatBg(
|
||||||
lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground,
|
lotusTerminal && chatBackground === 'none' ? 'tactical' : chatBackground,
|
||||||
isDark,
|
isDark,
|
||||||
pauseAnimations,
|
pauseAnimations,
|
||||||
),
|
),
|
||||||
[chatBackground, lotusTerminal, isDark, pauseAnimations],
|
[chatBackground, lotusTerminal, isDark, pauseAnimations, glassmorphismSidebar],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed';
|
|||||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||||
import { webRTCSupported } from '../../utils/rtc';
|
import { webRTCSupported } from '../../utils/rtc';
|
||||||
import { MediaGallery } from './MediaGallery';
|
import { MediaGallery } from './MediaGallery';
|
||||||
|
import { usePendingKnocks } from '../../hooks/usePendingKnocks';
|
||||||
|
|
||||||
type RoomMenuProps = {
|
type RoomMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
@@ -423,7 +424,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
|
|
||||||
const pinnedEvents = useRoomPinnedEvents(room);
|
const pinnedEvents = useRoomPinnedEvents(room);
|
||||||
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
const encryptionEvent = useStateEvent(room, StateEvent.RoomEncryption);
|
||||||
const encryptedRoom = !!encryptionEvent;
|
const _encryptedRoom = !!encryptionEvent;
|
||||||
const avatarMxc = useRoomAvatar(room, direct);
|
const avatarMxc = useRoomAvatar(room, direct);
|
||||||
const name = useLocalRoomName(room);
|
const name = useLocalRoomName(room);
|
||||||
const topic = useRoomTopic(room);
|
const topic = useRoomTopic(room);
|
||||||
@@ -433,6 +434,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
|
|
||||||
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
const [peopleDrawer, setPeopleDrawer] = useSetting(settingsAtom, 'isPeopleDrawer');
|
||||||
const [galleryOpen, setGalleryOpen] = useState(false);
|
const [galleryOpen, setGalleryOpen] = useState(false);
|
||||||
|
const pendingKnocks = usePendingKnocks(room);
|
||||||
|
|
||||||
const handleSearchClick = () => {
|
const handleSearchClick = () => {
|
||||||
const searchParams: _SearchPathSearchParams = {
|
const searchParams: _SearchPathSearchParams = {
|
||||||
@@ -558,7 +560,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
<Box shrink="No">
|
<Box shrink="No">
|
||||||
{!encryptedRoom && (
|
{
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={4}
|
offset={4}
|
||||||
@@ -579,7 +581,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
</IconButton>
|
</IconButton>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
}
|
||||||
<TooltipProvider
|
<TooltipProvider
|
||||||
position="Bottom"
|
position="Bottom"
|
||||||
offset={4}
|
offset={4}
|
||||||
@@ -682,14 +684,45 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{(triggerRef) => (
|
{(triggerRef) => (
|
||||||
|
<div style={{ position: 'relative', display: 'inline-flex' }}>
|
||||||
<IconButton
|
<IconButton
|
||||||
fill="None"
|
fill="None"
|
||||||
ref={triggerRef}
|
ref={triggerRef}
|
||||||
onClick={handleMemberToggle}
|
onClick={handleMemberToggle}
|
||||||
aria-label="Toggle member list"
|
aria-label={
|
||||||
|
pendingKnocks.length > 0
|
||||||
|
? `Toggle member list, ${pendingKnocks.length} pending join request${pendingKnocks.length > 1 ? 's' : ''}`
|
||||||
|
: 'Toggle member list'
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<Icon size="400" src={Icons.User} />
|
<Icon size="400" src={Icons.User} />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
{pendingKnocks.length > 0 && (
|
||||||
|
<Badge
|
||||||
|
aria-hidden
|
||||||
|
variant="Warning"
|
||||||
|
fill="Solid"
|
||||||
|
radii="Pill"
|
||||||
|
size="200"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
top: '-2px',
|
||||||
|
right: '-2px',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
fontSize: '9px',
|
||||||
|
minWidth: '14px',
|
||||||
|
height: '14px',
|
||||||
|
padding: '0 3px',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
lineHeight: 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{pendingKnocks.length > 9 ? '9+' : pendingKnocks.length}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -110,13 +110,14 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
|||||||
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
|
if (!fetchRes.ok) throw new Error(`HTTP ${fetchRes.status}`);
|
||||||
const res = (await fetchRes.json()) as EditHistoryResponse;
|
const res = (await fetchRes.json()) as EditHistoryResponse;
|
||||||
const rawEvents = res.chunk ?? [];
|
const rawEvents = res.chunk ?? [];
|
||||||
const events = rawEvents
|
const events = await Promise.all(
|
||||||
|
rawEvents
|
||||||
.filter(isRawEditEvent)
|
.filter(isRawEditEvent)
|
||||||
.sort((a, b) => a.origin_server_ts - b.origin_server_ts)
|
.sort((a, b) => a.origin_server_ts - b.origin_server_ts)
|
||||||
.map((raw) => {
|
.map(async (raw) => {
|
||||||
const existing = room.findEventById(raw.event_id);
|
const existing = room.findEventById(raw.event_id);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
return new MatrixEvent({
|
const evt = new MatrixEvent({
|
||||||
type: raw.type,
|
type: raw.type,
|
||||||
content: raw.content,
|
content: raw.content,
|
||||||
origin_server_ts: raw.origin_server_ts,
|
origin_server_ts: raw.origin_server_ts,
|
||||||
@@ -124,7 +125,12 @@ export function EditHistoryModal({ room, mEvent, onClose }: EditHistoryModalProp
|
|||||||
room_id: roomId,
|
room_id: roomId,
|
||||||
sender: mEvent.getSender() ?? '',
|
sender: mEvent.getSender() ?? '',
|
||||||
});
|
});
|
||||||
});
|
if (evt.isEncrypted()) {
|
||||||
|
await mx.decryptEventIfNeeded(evt);
|
||||||
|
}
|
||||||
|
return evt;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return { events, hasMore: !!res.next_batch };
|
return { events, hasMore: !!res.next_batch };
|
||||||
}, [mx, roomId, eventId, room, mEvent]),
|
}, [mx, roomId, eventId, room, mEvent]),
|
||||||
|
|||||||
@@ -83,6 +83,7 @@ import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag';
|
|||||||
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
import { ForwardMessageDialog } from './ForwardMessageDialog';
|
||||||
import { useBookmarks } from '../../../hooks/useBookmarks';
|
import { useBookmarks } from '../../../hooks/useBookmarks';
|
||||||
import { PresenceRingAvatar } from '../../../components/presence';
|
import { PresenceRingAvatar } from '../../../components/presence';
|
||||||
|
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||||
|
|
||||||
// Delivery status indicator for own messages
|
// Delivery status indicator for own messages
|
||||||
function DeliveryStatus({
|
function DeliveryStatus({
|
||||||
@@ -874,6 +875,7 @@ export const Message = React.memo(
|
|||||||
<AvatarBase
|
<AvatarBase
|
||||||
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
className={messageLayout === MessageLayout.Bubble ? css.BubbleAvatarBase : undefined}
|
||||||
>
|
>
|
||||||
|
<AvatarDecoration userId={senderId}>
|
||||||
<PresenceRingAvatar userId={senderId}>
|
<PresenceRingAvatar userId={senderId}>
|
||||||
<Avatar
|
<Avatar
|
||||||
className={css.MessageAvatar}
|
className={css.MessageAvatar}
|
||||||
@@ -895,6 +897,7 @@ export const Message = React.memo(
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</PresenceRingAvatar>
|
</PresenceRingAvatar>
|
||||||
|
</AvatarDecoration>
|
||||||
</AvatarBase>
|
</AvatarBase>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import { createUploadAtom, UploadSuccess } from '../../../state/upload';
|
|||||||
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
import { CompactUploadCardRenderer } from '../../../components/upload-card';
|
||||||
import { useCapabilities } from '../../../hooks/useCapabilities';
|
import { useCapabilities } from '../../../hooks/useCapabilities';
|
||||||
import { useUserPresence } from '../../../hooks/useUserPresence';
|
import { useUserPresence } from '../../../hooks/useUserPresence';
|
||||||
|
import { ProfileDecoration } from './ProfileDecoration';
|
||||||
import { EmojiBoard } from '../../../components/emoji-board';
|
import { EmojiBoard } from '../../../components/emoji-board';
|
||||||
|
|
||||||
type ProfileProps = {
|
type ProfileProps = {
|
||||||
@@ -317,6 +318,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
const STATUS_EXPIRY_KEY = (id: string) => `lotus-status-expiry-${id}`;
|
||||||
|
const STATUS_MSG_KEY = (id: string) => `lotus-status-msg-${id}`;
|
||||||
|
|
||||||
const CLEAR_AFTER_OPTIONS = [
|
const CLEAR_AFTER_OPTIONS = [
|
||||||
{ label: 'Never', value: '0' },
|
{ label: 'Never', value: '0' },
|
||||||
@@ -344,7 +346,9 @@ function ProfileStatus() {
|
|||||||
const userId = mx.getUserId()!;
|
const userId = mx.getUserId()!;
|
||||||
const presence = useUserPresence(userId);
|
const presence = useUserPresence(userId);
|
||||||
|
|
||||||
const [statusMsg, setStatusMsg] = useState<string>(presence?.status ?? '');
|
const [statusMsg, setStatusMsg] = useState<string>(
|
||||||
|
presence?.status ?? localStorage.getItem(STATUS_MSG_KEY(userId)) ?? '',
|
||||||
|
);
|
||||||
const [clearAfter, setClearAfter] = useState('0');
|
const [clearAfter, setClearAfter] = useState('0');
|
||||||
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
const [emojiAnchor, setEmojiAnchor] = useState<RectCords>();
|
||||||
|
|
||||||
@@ -354,16 +358,22 @@ function ProfileStatus() {
|
|||||||
return stored ? parseInt(stored, 10) : 0;
|
return stored ? parseInt(stored, 10) : 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync input when another device changes the status
|
// Sync input when another device changes the status.
|
||||||
|
// Only update if the server actually has a value — ignore empty sync events
|
||||||
|
// caused by Synapse clearing status_msg on reconnect.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setStatusMsg(presence?.status ?? '');
|
if (presence?.status) {
|
||||||
}, [presence?.status]);
|
setStatusMsg(presence.status);
|
||||||
|
localStorage.setItem(STATUS_MSG_KEY(userId), presence.status);
|
||||||
|
}
|
||||||
|
}, [presence?.status, userId]);
|
||||||
|
|
||||||
// Drive the auto-clear timer off expiryTs so re-saving cancels the old timer
|
// Drive the auto-clear timer off expiryTs so re-saving cancels the old timer
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!expiryTs) return undefined;
|
if (!expiryTs) return undefined;
|
||||||
const remaining = expiryTs - Date.now();
|
const remaining = expiryTs - Date.now();
|
||||||
const clearStatus = () => {
|
const clearStatus = () => {
|
||||||
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
setExpiryTs(0);
|
setExpiryTs(0);
|
||||||
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
||||||
@@ -403,6 +413,12 @@ function ProfileStatus() {
|
|||||||
const msg = statusMsg.trim();
|
const msg = statusMsg.trim();
|
||||||
saveStatus(msg).catch(() => undefined);
|
saveStatus(msg).catch(() => undefined);
|
||||||
|
|
||||||
|
if (msg) {
|
||||||
|
localStorage.setItem(STATUS_MSG_KEY(userId), msg);
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||||
|
}
|
||||||
|
|
||||||
const delayMs = getMsFromOption(clearAfter);
|
const delayMs = getMsFromOption(clearAfter);
|
||||||
if (msg && delayMs > 0) {
|
if (msg && delayMs > 0) {
|
||||||
const ts = Date.now() + delayMs;
|
const ts = Date.now() + delayMs;
|
||||||
@@ -416,6 +432,7 @@ function ProfileStatus() {
|
|||||||
|
|
||||||
const handleClear = () => {
|
const handleClear = () => {
|
||||||
setStatusMsg('');
|
setStatusMsg('');
|
||||||
|
localStorage.removeItem(STATUS_MSG_KEY(userId));
|
||||||
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
localStorage.removeItem(STATUS_EXPIRY_KEY(userId));
|
||||||
setExpiryTs(0);
|
setExpiryTs(0);
|
||||||
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
||||||
@@ -722,30 +739,36 @@ function ProfileTimezone() {
|
|||||||
const [savedTimezone, setSavedTimezone] = useState<string>('');
|
const [savedTimezone, setSavedTimezone] = useState<string>('');
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
const cached = mx
|
||||||
|
.getAccountData('im.lotus.timezone' as any)
|
||||||
|
?.getContent<{ timezone: string }>();
|
||||||
|
if (cached?.timezone) {
|
||||||
|
setTimezone(cached.timezone);
|
||||||
|
setSavedTimezone(cached.timezone);
|
||||||
|
}
|
||||||
|
// Also fetch from server in case account data hasn't synced yet
|
||||||
mx.http
|
mx.http
|
||||||
.authedRequest<{ 'm.tz': string }>(Method.Get, `/profile/${encodeURIComponent(userId)}/m.tz`)
|
.authedRequest<{ timezone: string }>(
|
||||||
|
Method.Get,
|
||||||
|
`/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`,
|
||||||
|
)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const val = res['m.tz'] ?? '';
|
const val = res.timezone ?? '';
|
||||||
setTimezone(val);
|
setTimezone(val);
|
||||||
setSavedTimezone(val);
|
setSavedTimezone(val);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
setTimezone('');
|
/* no stored timezone yet */
|
||||||
setSavedTimezone('');
|
|
||||||
});
|
});
|
||||||
}, [mx, userId]);
|
}, [mx, userId]);
|
||||||
|
|
||||||
const [saveState, saveTimezone] = useAsyncCallback(
|
const [saveState, saveTimezone] = useAsyncCallback(
|
||||||
useCallback(
|
useCallback(
|
||||||
(value: string) =>
|
(value: string) =>
|
||||||
mx.http
|
(mx as any).setAccountData('im.lotus.timezone', { timezone: value }).then(() => {
|
||||||
.authedRequest(Method.Put, `/profile/${encodeURIComponent(userId)}/m.tz`, undefined, {
|
|
||||||
'm.tz': value,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
setSavedTimezone(value);
|
setSavedTimezone(value);
|
||||||
}),
|
}),
|
||||||
[mx, userId],
|
[mx],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const saving = saveState.status === AsyncStatus.Loading;
|
const saving = saveState.status === AsyncStatus.Loading;
|
||||||
@@ -870,6 +893,7 @@ export function Profile() {
|
|||||||
<ProfileStatus />
|
<ProfileStatus />
|
||||||
<ProfilePronouns />
|
<ProfilePronouns />
|
||||||
<ProfileTimezone />
|
<ProfileTimezone />
|
||||||
|
<ProfileDecoration />
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Box, Text, Spinner } from 'folds';
|
||||||
|
import { Method } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||||
|
import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback';
|
||||||
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import {
|
||||||
|
DECORATION_CATEGORIES,
|
||||||
|
DECORATION_CDN,
|
||||||
|
decorationUrl,
|
||||||
|
} from '../../lotus/avatarDecorations';
|
||||||
|
import { invalidateDecorationCache } from '../../../hooks/useAvatarDecoration';
|
||||||
|
|
||||||
|
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||||
|
const CELL_SIZE = 72;
|
||||||
|
|
||||||
|
function DecorationPreviewCell({
|
||||||
|
slug,
|
||||||
|
name,
|
||||||
|
selected,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
selected: boolean;
|
||||||
|
onSelect: (slug: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
title={name}
|
||||||
|
aria-label={name}
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={() => onSelect(slug)}
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: CELL_SIZE,
|
||||||
|
height: CELL_SIZE,
|
||||||
|
flexShrink: 0,
|
||||||
|
border: `2px solid ${selected ? 'var(--accent-cyan)' : 'transparent'}`,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--bg-surface-variant)',
|
||||||
|
cursor: 'pointer',
|
||||||
|
padding: 0,
|
||||||
|
boxShadow: selected ? '0 0 0 1px var(--accent-cyan)' : 'none',
|
||||||
|
overflow: 'hidden',
|
||||||
|
outline: 'none',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={`${DECORATION_CDN}/${slug}.png`}
|
||||||
|
alt={name}
|
||||||
|
loading="eager"
|
||||||
|
decoding="async"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ProfileDecoration() {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const userId = mx.getUserId()!;
|
||||||
|
|
||||||
|
const [current, setCurrent] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
mx.http
|
||||||
|
.authedRequest<Record<string, string>>(
|
||||||
|
Method.Get,
|
||||||
|
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
|
||||||
|
)
|
||||||
|
.then((res) => {
|
||||||
|
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||||
|
setCurrent(val);
|
||||||
|
setSelected(val);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
setCurrent(null);
|
||||||
|
setSelected(null);
|
||||||
|
});
|
||||||
|
}, [mx, userId]);
|
||||||
|
|
||||||
|
const [saveState, save] = useAsyncCallback(
|
||||||
|
useCallback(
|
||||||
|
async (slug: string | null) => {
|
||||||
|
await mx.http.authedRequest(
|
||||||
|
Method.Put,
|
||||||
|
`/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`,
|
||||||
|
undefined,
|
||||||
|
{ [PROFILE_FIELD]: slug ?? '' },
|
||||||
|
);
|
||||||
|
setCurrent(slug);
|
||||||
|
invalidateDecorationCache(userId);
|
||||||
|
},
|
||||||
|
[mx, userId],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const saving = saveState.status === AsyncStatus.Loading;
|
||||||
|
const hasChanges = selected !== current;
|
||||||
|
|
||||||
|
const handleSelect = (slug: string) => {
|
||||||
|
setSelected((prev) => (prev === slug ? null : slug));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleClear = () => setSelected(null);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!hasChanges || saving) return;
|
||||||
|
save(selected);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SettingTile
|
||||||
|
title={
|
||||||
|
<Text as="span" size="L400">
|
||||||
|
Avatar Decoration
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Text size="T200" priority="300">
|
||||||
|
Shown on your avatar to all Lotus Chat users.
|
||||||
|
</Text>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Box direction="Column" gap="300">
|
||||||
|
{/* Current selection preview */}
|
||||||
|
<Box alignItems="Center" gap="300">
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'relative',
|
||||||
|
width: CELL_SIZE,
|
||||||
|
height: CELL_SIZE,
|
||||||
|
flexShrink: 0,
|
||||||
|
borderRadius: '50%',
|
||||||
|
background: 'var(--bg-surface-variant)',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{selected && (
|
||||||
|
<img
|
||||||
|
src={decorationUrl(selected)}
|
||||||
|
alt="Selected decoration preview"
|
||||||
|
style={{
|
||||||
|
position: 'absolute',
|
||||||
|
inset: 0,
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
objectFit: 'contain',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Box grow="Yes" direction="Column" gap="100">
|
||||||
|
<Text size="T300">
|
||||||
|
{selected
|
||||||
|
? (DECORATION_CATEGORIES.flatMap((c) => c.decorations).find(
|
||||||
|
(d) => d.slug === selected,
|
||||||
|
)?.name ?? selected)
|
||||||
|
: 'None'}
|
||||||
|
</Text>
|
||||||
|
{selected && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleClear}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: 'none',
|
||||||
|
padding: 0,
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'var(--tc-surface-low-contrast)',
|
||||||
|
fontSize: '0.8rem',
|
||||||
|
textAlign: 'left',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
{hasChanges && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving}
|
||||||
|
style={{
|
||||||
|
padding: '6px 14px',
|
||||||
|
borderRadius: 6,
|
||||||
|
border: '1px solid var(--accent-cyan)',
|
||||||
|
background: 'transparent',
|
||||||
|
color: 'var(--accent-cyan)',
|
||||||
|
cursor: saving ? 'not-allowed' : 'pointer',
|
||||||
|
fontSize: '0.85rem',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: 6,
|
||||||
|
opacity: saving ? 0.6 : 1,
|
||||||
|
flexShrink: 0,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{saving && <Spinner size="100" variant="Secondary" />}
|
||||||
|
{saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{saveState.status === AsyncStatus.Error && (
|
||||||
|
<Text size="T200" style={{ color: 'var(--tc-critical-normal)' }}>
|
||||||
|
Failed to save. Try again.
|
||||||
|
</Text>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Category grid */}
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
maxHeight: 480,
|
||||||
|
overflowY: 'auto',
|
||||||
|
overflowX: 'hidden',
|
||||||
|
paddingRight: 4,
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
gap: 24,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{DECORATION_CATEGORIES.map((category) => (
|
||||||
|
<div key={category.id} style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||||
|
<Text size="L400" style={{ opacity: 0.7 }}>
|
||||||
|
{category.label}
|
||||||
|
</Text>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(auto-fill, ${CELL_SIZE}px)`,
|
||||||
|
gap: 20,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{category.decorations.map((d) => (
|
||||||
|
<DecorationPreviewCell
|
||||||
|
key={d.slug}
|
||||||
|
slug={d.slug}
|
||||||
|
name={d.name}
|
||||||
|
selected={selected === d.slug}
|
||||||
|
onSelect={handleSelect}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Box>
|
||||||
|
</SettingTile>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
PopOut,
|
PopOut,
|
||||||
RectCords,
|
RectCords,
|
||||||
Scroll,
|
Scroll,
|
||||||
|
Spinner,
|
||||||
Switch,
|
Switch,
|
||||||
Text,
|
Text,
|
||||||
toRem,
|
toRem,
|
||||||
@@ -37,6 +38,7 @@ import { SequenceCard } from '../../../components/sequence-card';
|
|||||||
import { useSetting } from '../../../state/hooks/settings';
|
import { useSetting } from '../../../state/hooks/settings';
|
||||||
import {
|
import {
|
||||||
ChatBackground,
|
ChatBackground,
|
||||||
|
ComposerToolbarSettings,
|
||||||
DateFormat,
|
DateFormat,
|
||||||
MessageLayout,
|
MessageLayout,
|
||||||
MessageSpacing,
|
MessageSpacing,
|
||||||
@@ -62,6 +64,8 @@ import { useMessageLayoutItems } from '../../../hooks/useMessageLayout';
|
|||||||
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
import { useMessageSpacingItems } from '../../../hooks/useMessageSpacing';
|
||||||
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
import { useDateFormatItems } from '../../../hooks/useDateFormat';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
|
import { useTauriUpdater } from '../../../hooks/useTauriUpdater';
|
||||||
|
import { playCallJoinSound } from '../../../utils/callSounds';
|
||||||
|
|
||||||
type ThemeSelectorProps = {
|
type ThemeSelectorProps = {
|
||||||
themeNames: Record<string, string>;
|
themeNames: Record<string, string>;
|
||||||
@@ -150,6 +154,82 @@ function SelectTheme({ disabled }: { disabled?: boolean }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SettingsSelectOption<T extends string> = { value: T; label: string };
|
||||||
|
|
||||||
|
function SettingsSelect<T extends string>({
|
||||||
|
value,
|
||||||
|
options,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
value: T;
|
||||||
|
options: SettingsSelectOption<T>[];
|
||||||
|
onChange: (v: T) => void;
|
||||||
|
}) {
|
||||||
|
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
const selectedLabel = options.find((o) => o.value === value)?.label ?? value;
|
||||||
|
|
||||||
|
const handleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||||
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSelect = (v: T) => {
|
||||||
|
onChange(v);
|
||||||
|
setMenuCords(undefined);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="300"
|
||||||
|
variant="Secondary"
|
||||||
|
outlined
|
||||||
|
fill="Soft"
|
||||||
|
radii="300"
|
||||||
|
after={<Icon size="300" src={Icons.ChevronBottom} />}
|
||||||
|
onClick={handleMenu}
|
||||||
|
>
|
||||||
|
<Text size="T300">{selectedLabel}</Text>
|
||||||
|
</Button>
|
||||||
|
<PopOut
|
||||||
|
anchor={menuCords}
|
||||||
|
offset={5}
|
||||||
|
position="Bottom"
|
||||||
|
align="End"
|
||||||
|
content={
|
||||||
|
<FocusTrap
|
||||||
|
focusTrapOptions={{
|
||||||
|
initialFocus: false,
|
||||||
|
onDeactivate: () => setMenuCords(undefined),
|
||||||
|
clickOutsideDeactivates: true,
|
||||||
|
isKeyForward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||||
|
isKeyBackward: (evt: KeyboardEvent) =>
|
||||||
|
evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||||
|
escapeDeactivates: stopPropagation,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Menu>
|
||||||
|
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||||
|
{options.map((opt) => (
|
||||||
|
<MenuItem
|
||||||
|
key={opt.value}
|
||||||
|
size="300"
|
||||||
|
variant={opt.value === value ? 'Primary' : 'Surface'}
|
||||||
|
radii="300"
|
||||||
|
onClick={() => handleSelect(opt.value)}
|
||||||
|
>
|
||||||
|
<Text size="T300">{opt.label}</Text>
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</Menu>
|
||||||
|
</FocusTrap>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function SystemThemePreferences() {
|
function SystemThemePreferences() {
|
||||||
const themeKind = useSystemThemeKind();
|
const themeKind = useSystemThemeKind();
|
||||||
const themeNames = useThemeNames();
|
const themeNames = useThemeNames();
|
||||||
@@ -332,6 +412,15 @@ function Appearance() {
|
|||||||
'glassmorphismSidebar',
|
'glassmorphismSidebar',
|
||||||
);
|
);
|
||||||
const [pauseAnimations, setPauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
const [pauseAnimations, setPauseAnimations] = useSetting(settingsAtom, 'pauseAnimations');
|
||||||
|
const [mentionHighlightColor, setMentionHighlightColor] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'mentionHighlightColor',
|
||||||
|
);
|
||||||
|
const [fontFamily, setFontFamily] = useSetting(settingsAtom, 'fontFamily');
|
||||||
|
const [seasonalThemeOverride, setSeasonalThemeOverride] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'seasonalThemeOverride',
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
@@ -399,6 +488,34 @@ function Appearance() {
|
|||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</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">
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
<SettingTile
|
<SettingTile
|
||||||
title="Show Profile on Every Message"
|
title="Show Profile on Every Message"
|
||||||
@@ -493,6 +610,66 @@ function Appearance() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="UI Font"
|
||||||
|
description="Font used throughout the interface."
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={fontFamily ?? 'inter'}
|
||||||
|
onChange={(v) =>
|
||||||
|
setFontFamily(v as 'system' | 'inter' | 'jetbrains-mono' | 'fira-code')
|
||||||
|
}
|
||||||
|
options={[
|
||||||
|
{ value: 'system', label: 'System Default' },
|
||||||
|
{ value: 'inter', label: 'Inter (default)' },
|
||||||
|
{ value: 'jetbrains-mono', label: 'JetBrains Mono' },
|
||||||
|
{ value: 'fira-code', label: 'Fira Code' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="@Mention Highlight Color"
|
||||||
|
description="Color used to highlight messages that mention you. Leave empty to use the theme default."
|
||||||
|
after={
|
||||||
|
<Box alignItems="Center" gap="200">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={mentionHighlightColor || '#4caf50'}
|
||||||
|
onChange={(e) => setMentionHighlightColor(e.target.value)}
|
||||||
|
style={{
|
||||||
|
width: '36px',
|
||||||
|
height: '28px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: '4px',
|
||||||
|
border: 'none',
|
||||||
|
padding: '2px',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{mentionHighlightColor && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setMentionHighlightColor('')}
|
||||||
|
style={{
|
||||||
|
background: 'none',
|
||||||
|
border: '1px solid var(--border-interactive-normal)',
|
||||||
|
borderRadius: '6px',
|
||||||
|
padding: '2px 8px',
|
||||||
|
cursor: 'pointer',
|
||||||
|
color: 'inherit',
|
||||||
|
fontSize: '12px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Reset
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -855,6 +1032,25 @@ function Editor() {
|
|||||||
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
const [enterForNewline, setEnterForNewline] = useSetting(settingsAtom, 'enterForNewline');
|
||||||
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
const [isMarkdown, setIsMarkdown] = useSetting(settingsAtom, 'isMarkdown');
|
||||||
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
const [editorToolbar, setEditorToolbar] = useSetting(settingsAtom, 'editorToolbar');
|
||||||
|
const [composerToolbarButtons, setComposerToolbarButtons] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'composerToolbarButtons',
|
||||||
|
);
|
||||||
|
|
||||||
|
const toggleToolbarButton = (key: keyof ComposerToolbarSettings) => {
|
||||||
|
setComposerToolbarButtons({ ...composerToolbarButtons, [key]: !composerToolbarButtons[key] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const TOOLBAR_CHIPS: Array<{ key: keyof ComposerToolbarSettings; label: string }> = [
|
||||||
|
{ key: 'showFormat', label: 'Format' },
|
||||||
|
{ key: 'showEmoji', label: 'Emoji' },
|
||||||
|
{ key: 'showSticker', label: 'Sticker' },
|
||||||
|
{ key: 'showGif', label: 'GIF' },
|
||||||
|
{ key: 'showLocation', label: 'Location' },
|
||||||
|
{ key: 'showPoll', label: 'Poll' },
|
||||||
|
{ key: 'showVoice', label: 'Voice' },
|
||||||
|
{ key: 'showSchedule', label: 'Schedule' },
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box direction="Column" gap="100">
|
<Box direction="Column" gap="100">
|
||||||
@@ -881,6 +1077,38 @@ function Editor() {
|
|||||||
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
|
after={<Switch variant="Primary" value={editorToolbar} onChange={setEditorToolbar} />}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="300"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="Composer Toolbar"
|
||||||
|
description="Tap a button to show or hide it in the message composer."
|
||||||
|
/>
|
||||||
|
<Box
|
||||||
|
wrap="Wrap"
|
||||||
|
gap="200"
|
||||||
|
style={{ padding: `0 ${config.space.S400} ${config.space.S300}` }}
|
||||||
|
>
|
||||||
|
{TOOLBAR_CHIPS.map(({ key, label }) => {
|
||||||
|
const active = composerToolbarButtons?.[key] ?? true;
|
||||||
|
return (
|
||||||
|
<Chip
|
||||||
|
key={key}
|
||||||
|
variant={active ? 'Primary' : 'Secondary'}
|
||||||
|
outlined={active}
|
||||||
|
radii="Pill"
|
||||||
|
onClick={() => toggleToolbarButton(key)}
|
||||||
|
aria-pressed={active}
|
||||||
|
>
|
||||||
|
<Text size="T300">{label}</Text>
|
||||||
|
</Chip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Box>
|
||||||
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -984,6 +1212,17 @@ function Calls() {
|
|||||||
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
const [pttMode, setPttMode] = useSetting(settingsAtom, 'pttMode');
|
||||||
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
const [pttKey, setPttKey] = useSetting(settingsAtom, 'pttKey');
|
||||||
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
const [deafenKey, setDeafenKey] = useSetting(settingsAtom, 'deafenKey');
|
||||||
|
const [afkAutoMute, setAfkAutoMute] = useSetting(settingsAtom, 'afkAutoMute');
|
||||||
|
const [afkTimeoutMinutes, setAfkTimeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||||||
|
const [callJoinLeaveSound, setCallJoinLeaveSound] = useSetting(
|
||||||
|
settingsAtom,
|
||||||
|
'callJoinLeaveSound',
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleJoinLeaveSoundChange = (value: 'off' | 'chime' | 'soft' | 'retro') => {
|
||||||
|
setCallJoinLeaveSound(value);
|
||||||
|
if (value !== 'off') playCallJoinSound(value);
|
||||||
|
};
|
||||||
|
|
||||||
const pttBind = useKeyBind(setPttKey);
|
const pttBind = useKeyBind(setPttKey);
|
||||||
const deafenBind = useKeyBind(setDeafenKey);
|
const deafenBind = useKeyBind(setDeafenKey);
|
||||||
@@ -1059,6 +1298,55 @@ function Calls() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</SequenceCard>
|
</SequenceCard>
|
||||||
|
<SequenceCard
|
||||||
|
className={SequenceCardStyle}
|
||||||
|
variant="SurfaceVariant"
|
||||||
|
direction="Column"
|
||||||
|
gap="400"
|
||||||
|
>
|
||||||
|
<SettingTile
|
||||||
|
title="AFK Auto-Mute"
|
||||||
|
description="Automatically mute your microphone when you have been silent in a call."
|
||||||
|
after={<Switch variant="Primary" value={afkAutoMute} onChange={setAfkAutoMute} />}
|
||||||
|
/>
|
||||||
|
{afkAutoMute && (
|
||||||
|
<SettingTile
|
||||||
|
title="Idle Timeout"
|
||||||
|
description="How long to wait before auto-muting."
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={String(afkTimeoutMinutes ?? 10)}
|
||||||
|
onChange={(v) => setAfkTimeoutMinutes(Number(v))}
|
||||||
|
options={[
|
||||||
|
{ value: '1', label: '1 minute' },
|
||||||
|
{ value: '5', label: '5 minutes' },
|
||||||
|
{ value: '10', label: '10 minutes' },
|
||||||
|
{ value: '20', label: '20 minutes' },
|
||||||
|
{ value: '30', label: '30 minutes' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SequenceCard>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile
|
||||||
|
title="Join & Leave Sounds"
|
||||||
|
description="Play a sound when someone joins or leaves a call you are in."
|
||||||
|
after={
|
||||||
|
<SettingsSelect
|
||||||
|
value={callJoinLeaveSound}
|
||||||
|
onChange={(v) => handleJoinLeaveSoundChange(v as 'off' | 'chime' | 'soft' | 'retro')}
|
||||||
|
options={[
|
||||||
|
{ value: 'off', label: 'Off' },
|
||||||
|
{ value: 'chime', label: 'Chime' },
|
||||||
|
{ value: 'soft', label: 'Soft' },
|
||||||
|
{ value: 'retro', label: 'Retro' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SequenceCard>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1361,6 +1649,46 @@ function Messages() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function AppUpdates() {
|
||||||
|
const { isTauri, status, check, install } = useTauriUpdater();
|
||||||
|
if (!isTauri) return null;
|
||||||
|
|
||||||
|
const description =
|
||||||
|
status.state === 'checking'
|
||||||
|
? 'Checking for updates...'
|
||||||
|
: status.state === 'up-to-date'
|
||||||
|
? 'Lotus Chat is up to date.'
|
||||||
|
: status.state === 'available'
|
||||||
|
? `Update available: v${status.version}`
|
||||||
|
: status.state === 'installing'
|
||||||
|
? 'Installing update, the app will restart shortly...'
|
||||||
|
: status.state === 'error'
|
||||||
|
? `Update check failed: ${status.message}`
|
||||||
|
: 'Check for a new version of Lotus Chat.';
|
||||||
|
|
||||||
|
const after =
|
||||||
|
status.state === 'available' ? (
|
||||||
|
<Button size="300" radii="300" onClick={install}>
|
||||||
|
<Text size="B300">Install & Restart</Text>
|
||||||
|
</Button>
|
||||||
|
) : status.state === 'checking' || status.state === 'installing' ? (
|
||||||
|
<Spinner variant="Secondary" size="200" />
|
||||||
|
) : (
|
||||||
|
<Button size="300" radii="300" variant="Secondary" onClick={check}>
|
||||||
|
<Text size="B300">Check</Text>
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">App Updates</Text>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<SettingTile title="Check for Updates" description={description} after={after} />
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type GeneralProps = {
|
type GeneralProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
};
|
};
|
||||||
@@ -1391,6 +1719,7 @@ export function General({ requestClose }: GeneralProps) {
|
|||||||
<Messages />
|
<Messages />
|
||||||
<Privacy />
|
<Privacy />
|
||||||
<Calls />
|
<Calls />
|
||||||
|
<AppUpdates />
|
||||||
</Box>
|
</Box>
|
||||||
</PageContent>
|
</PageContent>
|
||||||
</Scroll>
|
</Scroll>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Box, Text, IconButton, Icon, Icons, Scroll } from 'folds';
|
import { useAtomValue, useSetAtom } from 'jotai';
|
||||||
|
import { Box, Text, IconButton, Icon, Icons, Scroll, config, toRem } from 'folds';
|
||||||
import { Page, PageContent, PageHeader } from '../../../components/page';
|
import { Page, PageContent, PageHeader } from '../../../components/page';
|
||||||
import { SystemNotification } from './SystemNotification';
|
import { SystemNotification } from './SystemNotification';
|
||||||
import { AllMessagesNotifications } from './AllMessages';
|
import { AllMessagesNotifications } from './AllMessages';
|
||||||
@@ -9,6 +10,101 @@ import { PushRuleEditor } from './PushRuleEditor';
|
|||||||
import { SequenceCard } from '../../../components/sequence-card';
|
import { SequenceCard } from '../../../components/sequence-card';
|
||||||
import { SequenceCardStyle } from '../styles.css';
|
import { SequenceCardStyle } from '../styles.css';
|
||||||
import { SettingTile } from '../../../components/setting-tile';
|
import { SettingTile } from '../../../components/setting-tile';
|
||||||
|
import { settingsAtom, Settings } from '../../../state/settings';
|
||||||
|
|
||||||
|
const PRESETS: Array<{
|
||||||
|
label: string;
|
||||||
|
emoji: string;
|
||||||
|
description: string;
|
||||||
|
patch: Partial<Settings>;
|
||||||
|
}> = [
|
||||||
|
{
|
||||||
|
label: 'Gaming',
|
||||||
|
emoji: '🎮',
|
||||||
|
description: 'Notifications on, sounds off',
|
||||||
|
patch: {
|
||||||
|
showNotifications: true,
|
||||||
|
isNotificationSounds: false,
|
||||||
|
messageSoundId: 'none',
|
||||||
|
inviteSoundId: 'none',
|
||||||
|
quietHoursEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Work',
|
||||||
|
emoji: '💼',
|
||||||
|
description: 'All notifications and sounds on',
|
||||||
|
patch: {
|
||||||
|
showNotifications: true,
|
||||||
|
isNotificationSounds: true,
|
||||||
|
messageSoundId: 'notification',
|
||||||
|
inviteSoundId: 'invite',
|
||||||
|
quietHoursEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Sleep',
|
||||||
|
emoji: '🌙',
|
||||||
|
description: 'All notifications off',
|
||||||
|
patch: {
|
||||||
|
showNotifications: false,
|
||||||
|
isNotificationSounds: false,
|
||||||
|
quietHoursEnabled: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function NotificationPresets() {
|
||||||
|
const settings = useAtomValue(settingsAtom);
|
||||||
|
const setSettings = useSetAtom(settingsAtom);
|
||||||
|
|
||||||
|
const applyPreset = (patch: Partial<Settings>) => {
|
||||||
|
setSettings({ ...settings, ...patch });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box direction="Column" gap="100">
|
||||||
|
<Text size="L400">Quick Presets</Text>
|
||||||
|
<SequenceCard className={SequenceCardStyle} variant="SurfaceVariant" direction="Column">
|
||||||
|
<Box gap="300" style={{ padding: config.space.S300, flexWrap: 'wrap' }}>
|
||||||
|
{PRESETS.map((preset) => (
|
||||||
|
<button
|
||||||
|
key={preset.label}
|
||||||
|
type="button"
|
||||||
|
onClick={() => applyPreset(preset.patch)}
|
||||||
|
title={preset.description}
|
||||||
|
style={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: toRem(4),
|
||||||
|
padding: `${toRem(8)} ${toRem(16)}`,
|
||||||
|
borderRadius: config.radii.R300,
|
||||||
|
border: '1px solid var(--border-interactive-normal)',
|
||||||
|
background: 'var(--bg-surface-low)',
|
||||||
|
color: 'inherit',
|
||||||
|
cursor: 'pointer',
|
||||||
|
minWidth: toRem(80),
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{ fontSize: toRem(24) }}>{preset.emoji}</span>
|
||||||
|
<Text size="T300" style={{ fontWeight: 600 }}>
|
||||||
|
{preset.label}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
size="T200"
|
||||||
|
priority="300"
|
||||||
|
style={{ textAlign: 'center', maxWidth: toRem(120) }}
|
||||||
|
>
|
||||||
|
{preset.description}
|
||||||
|
</Text>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
</SequenceCard>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
type NotificationsProps = {
|
type NotificationsProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
@@ -34,6 +130,7 @@ export function Notifications({ requestClose }: NotificationsProps) {
|
|||||||
<Scroll hideTrack visibility="Hover">
|
<Scroll hideTrack visibility="Hover">
|
||||||
<PageContent>
|
<PageContent>
|
||||||
<Box direction="Column" gap="700">
|
<Box direction="Column" gap="700">
|
||||||
|
<NotificationPresets />
|
||||||
<SystemNotification />
|
<SystemNotification />
|
||||||
<AllMessagesNotifications />
|
<AllMessagesNotifications />
|
||||||
<SpecialMessagesNotifications />
|
<SpecialMessagesNotifications />
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useSetAtom } from 'jotai';
|
||||||
|
import { CallEmbed } from '../plugins/call';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { toastQueueAtom } from '../state/toast';
|
||||||
|
|
||||||
|
const SILENCE_RMS_THRESHOLD = 0.008;
|
||||||
|
const CHECK_INTERVAL_MS = 500;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monitors microphone audio while in a call. If the mic stays active but
|
||||||
|
* silent for longer than the configured timeout, the mic is muted and a
|
||||||
|
* toast is shown. Cleans up its own AudioContext and stream on unmount.
|
||||||
|
*/
|
||||||
|
export function useAfkAutoMute(callEmbed: CallEmbed | undefined): void {
|
||||||
|
const [enabled] = useSetting(settingsAtom, 'afkAutoMute');
|
||||||
|
const [timeoutMinutes] = useSetting(settingsAtom, 'afkTimeoutMinutes');
|
||||||
|
const setToast = useSetAtom(toastQueueAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!callEmbed || !enabled) return;
|
||||||
|
|
||||||
|
let stream: MediaStream | undefined;
|
||||||
|
let audioCtx: AudioContext | undefined;
|
||||||
|
let intervalId: ReturnType<typeof setInterval> | undefined;
|
||||||
|
let silenceStart: number | null = null;
|
||||||
|
let active = true;
|
||||||
|
const timeoutMs = timeoutMinutes * 60 * 1000;
|
||||||
|
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getUserMedia({ audio: true, video: false })
|
||||||
|
.then((s) => {
|
||||||
|
if (!active) {
|
||||||
|
s.getTracks().forEach((t) => t.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
stream = s;
|
||||||
|
audioCtx = new AudioContext();
|
||||||
|
const source = audioCtx.createMediaStreamSource(stream);
|
||||||
|
const analyser = audioCtx.createAnalyser();
|
||||||
|
analyser.fftSize = 256;
|
||||||
|
source.connect(analyser);
|
||||||
|
const buffer = new Float32Array(analyser.fftSize);
|
||||||
|
|
||||||
|
intervalId = setInterval(() => {
|
||||||
|
if (!active) return;
|
||||||
|
analyser.getFloatTimeDomainData(buffer);
|
||||||
|
const rms = Math.sqrt(buffer.reduce((sum, v) => sum + v * v, 0) / buffer.length);
|
||||||
|
|
||||||
|
if (rms > SILENCE_RMS_THRESHOLD) {
|
||||||
|
// Audio detected — reset the silence timer
|
||||||
|
silenceStart = null;
|
||||||
|
} else if (callEmbed.control.microphone) {
|
||||||
|
// Mic is on but silent — start or advance the timer
|
||||||
|
if (silenceStart === null) silenceStart = Date.now();
|
||||||
|
else if (Date.now() - silenceStart >= timeoutMs) {
|
||||||
|
callEmbed.control.setMicrophone(false);
|
||||||
|
setToast({
|
||||||
|
id: `afk-mute-${Date.now()}`,
|
||||||
|
displayName: 'Lotus Chat',
|
||||||
|
body: 'Your microphone was muted after inactivity.',
|
||||||
|
roomName: 'Voice call',
|
||||||
|
roomId: callEmbed.roomId,
|
||||||
|
});
|
||||||
|
silenceStart = null;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Mic is already muted — don't count silence
|
||||||
|
silenceStart = null;
|
||||||
|
}
|
||||||
|
}, CHECK_INTERVAL_MS);
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
if (intervalId !== undefined) clearInterval(intervalId);
|
||||||
|
stream?.getTracks().forEach((t) => t.stop());
|
||||||
|
audioCtx?.close().catch(() => undefined);
|
||||||
|
};
|
||||||
|
}, [callEmbed, enabled, timeoutMinutes, setToast]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Method } from 'matrix-js-sdk';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
|
||||||
|
const PROFILE_FIELD = 'io.lotus.avatar_decoration';
|
||||||
|
|
||||||
|
// Module-level cache — survives re-renders, lives for the app session.
|
||||||
|
// userId → slug | null (null = fetched, no decoration set)
|
||||||
|
const cache = new Map<string, string | null>();
|
||||||
|
// Callbacks waiting for a userId's result
|
||||||
|
const pending = new Map<string, Array<(val: string | null) => void>>();
|
||||||
|
|
||||||
|
function fetchDecoration(
|
||||||
|
authedRequest: (method: Method, path: string) => Promise<Record<string, string>>,
|
||||||
|
userId: string,
|
||||||
|
): Promise<string | null> {
|
||||||
|
if (cache.has(userId)) return Promise.resolve(cache.get(userId) ?? null);
|
||||||
|
|
||||||
|
// De-duplicate in-flight requests for the same userId
|
||||||
|
if (pending.has(userId)) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
pending.get(userId)!.push(resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const waiters: Array<(val: string | null) => void> = [];
|
||||||
|
pending.set(userId, waiters);
|
||||||
|
|
||||||
|
return authedRequest(Method.Get, `/profile/${encodeURIComponent(userId)}/${PROFILE_FIELD}`)
|
||||||
|
.then((res) => {
|
||||||
|
const val = (res[PROFILE_FIELD] as string | undefined) ?? null;
|
||||||
|
cache.set(userId, val);
|
||||||
|
return val;
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
cache.set(userId, null);
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
const v = cache.get(userId) ?? null;
|
||||||
|
pending.delete(userId);
|
||||||
|
waiters.forEach((cb) => cb(v));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateDecorationCache(userId: string): void {
|
||||||
|
cache.delete(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAvatarDecoration(userId: string): string | null {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [slug, setSlug] = useState<string | null>(() => cache.get(userId) ?? null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchDecoration(
|
||||||
|
(method, path) => mx.http.authedRequest<Record<string, string>>(method, path),
|
||||||
|
userId,
|
||||||
|
).then((val) => {
|
||||||
|
if (!cancelled) setSlug(val);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [mx, userId]);
|
||||||
|
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { useCallMembersChange, useCallSession } from './useCall';
|
|||||||
import { CallPreferences } from '../state/callPreferences';
|
import { CallPreferences } from '../state/callPreferences';
|
||||||
import { useSetting } from '../state/hooks/settings';
|
import { useSetting } from '../state/hooks/settings';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { unlockCallSounds } from '../utils/callSounds';
|
||||||
|
|
||||||
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
|
const CallEmbedContext = createContext<CallEmbed | undefined>(undefined);
|
||||||
|
|
||||||
@@ -84,6 +85,10 @@ export const useCallStart = (dm = false) => {
|
|||||||
if (!container) {
|
if (!container) {
|
||||||
throw new Error('Failed to start call, No embed container element found!');
|
throw new Error('Failed to start call, No embed container element found!');
|
||||||
}
|
}
|
||||||
|
// startCall is always invoked from a click/tap handler — unlock the Web
|
||||||
|
// Audio context now (within the gesture) so join/leave sounds that fire
|
||||||
|
// later, without any gesture, are audible to everyone in the call.
|
||||||
|
unlockCallSounds();
|
||||||
const callEmbed = createCallEmbed(
|
const callEmbed = createCallEmbed(
|
||||||
mx,
|
mx,
|
||||||
room,
|
room,
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
|
import { CallMembership } from 'matrix-js-sdk/lib/matrixrtc/CallMembership';
|
||||||
|
import { CallEmbed } from '../plugins/call';
|
||||||
|
import { useSetting } from '../state/hooks/settings';
|
||||||
|
import { settingsAtom } from '../state/settings';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useCallMembersChange, useCallSession } from './useCall';
|
||||||
|
import { useCallJoined } from './useCallEmbed';
|
||||||
|
import { playCallJoinSound, playCallLeaveSound } from '../utils/callSounds';
|
||||||
|
|
||||||
|
const membershipKey = (m: CallMembership): string => `${m.sender}|${m.deviceId}`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Plays a local sound effect when another participant joins or leaves
|
||||||
|
* the call you are in. Style (or off) is configured in Settings → Calls.
|
||||||
|
*/
|
||||||
|
export function useCallJoinLeaveSounds(embed: CallEmbed): void {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const [style] = useSetting(settingsAtom, 'callJoinLeaveSound');
|
||||||
|
const joined = useCallJoined(embed);
|
||||||
|
const session = useCallSession(embed.room);
|
||||||
|
|
||||||
|
const prevKeysRef = useRef<Set<string> | null>(null);
|
||||||
|
|
||||||
|
// Snapshot current members when the session (re)starts so we never play
|
||||||
|
// sounds for participants who were already present.
|
||||||
|
useEffect(() => {
|
||||||
|
prevKeysRef.current = new Set(session.memberships.map(membershipKey));
|
||||||
|
}, [session]);
|
||||||
|
|
||||||
|
useCallMembersChange(
|
||||||
|
session,
|
||||||
|
useCallback(
|
||||||
|
(members: CallMembership[]) => {
|
||||||
|
const next = new Set(members.map(membershipKey));
|
||||||
|
const prev = prevKeysRef.current ?? next;
|
||||||
|
prevKeysRef.current = next;
|
||||||
|
|
||||||
|
if (!joined || style === 'off') return;
|
||||||
|
|
||||||
|
const myPrefix = `${mx.getSafeUserId()}|`;
|
||||||
|
let someoneJoined = false;
|
||||||
|
let someoneLeft = false;
|
||||||
|
next.forEach((key) => {
|
||||||
|
if (!prev.has(key) && !key.startsWith(myPrefix)) someoneJoined = true;
|
||||||
|
});
|
||||||
|
prev.forEach((key) => {
|
||||||
|
if (!next.has(key) && !key.startsWith(myPrefix)) someoneLeft = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (someoneJoined) playCallJoinSound(style);
|
||||||
|
if (someoneLeft) playCallLeaveSound(style);
|
||||||
|
},
|
||||||
|
[joined, style, mx],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import { useCallback, useEffect } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { useRoomNavigate } from './useRoomNavigate';
|
||||||
|
import { isRoomId } from '../utils/matrix';
|
||||||
|
import { getHomeRoomPath, withSearchParam } from '../pages/pathUtils';
|
||||||
|
import { _RoomSearchParams } from '../pages/paths';
|
||||||
|
import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../plugins/matrix-to';
|
||||||
|
|
||||||
|
/** Desktop (Tauri) injects deep links via a DOM CustomEvent — see lib.rs. */
|
||||||
|
const DEEP_LINK_EVENT = 'lotus-deeplink';
|
||||||
|
|
||||||
|
const inTauri = (): boolean => '__TAURI_INTERNALS__' in window;
|
||||||
|
|
||||||
|
const tryDecode = (value: string): string => {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert a `matrix:` URI (MSC2312) to its `matrix.to` equivalent so we can
|
||||||
|
* reuse the existing matrix.to parsers. Returns undefined for forms we don't
|
||||||
|
* route (e.g. user links, which need an anchor/room context to open a profile).
|
||||||
|
*
|
||||||
|
* matrix:r/room:server -> https://matrix.to/#/#room:server
|
||||||
|
* matrix:roomid/id:server/e/$ev -> https://matrix.to/#/!id:server/$ev
|
||||||
|
*/
|
||||||
|
const matrixUriToMatrixTo = (uri: string): string | undefined => {
|
||||||
|
const body = uri.slice('matrix:'.length);
|
||||||
|
const [pathPart, queryPart] = body.split('?');
|
||||||
|
const segments = pathPart.split('/');
|
||||||
|
if (segments.length < 2) return undefined;
|
||||||
|
|
||||||
|
const [kind, rawId, evKind, rawEventId] = segments;
|
||||||
|
const sigil = kind === 'r' ? '#' : kind === 'roomid' ? '!' : undefined;
|
||||||
|
if (!sigil || !rawId) return undefined;
|
||||||
|
|
||||||
|
let fragment = `${sigil}${tryDecode(rawId)}`;
|
||||||
|
if (evKind === 'e' && rawEventId) {
|
||||||
|
fragment += `/$${tryDecode(rawEventId)}`;
|
||||||
|
}
|
||||||
|
const query = queryPart ? `?${queryPart}` : '';
|
||||||
|
return `https://matrix.to/#/${fragment}${query}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Routes deep links opened from outside the app (Windows `matrix:` links,
|
||||||
|
* forwarded from Tauri) into the client, mirroring the navigation in
|
||||||
|
* useMentionClickHandler. No-op outside Tauri.
|
||||||
|
*/
|
||||||
|
export const useDeepLinkNavigate = (): void => {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||||
|
|
||||||
|
const navigateUri = useCallback(
|
||||||
|
(rawUrl: string) => {
|
||||||
|
const href = rawUrl.startsWith('matrix:') ? matrixUriToMatrixTo(rawUrl) : rawUrl;
|
||||||
|
if (!href || !testMatrixTo(href)) return;
|
||||||
|
|
||||||
|
const roomEvent = parseMatrixToRoomEvent(href);
|
||||||
|
const target = roomEvent ?? parseMatrixToRoom(href);
|
||||||
|
if (!target) return; // users / unsupported forms
|
||||||
|
|
||||||
|
const { roomIdOrAlias, viaServers } = target;
|
||||||
|
const eventId = roomEvent?.eventId;
|
||||||
|
|
||||||
|
// Already-joined room/space: navigate directly.
|
||||||
|
if (isRoomId(roomIdOrAlias) && mx.getRoom(roomIdOrAlias)) {
|
||||||
|
if (mx.getRoom(roomIdOrAlias)?.isSpaceRoom()) navigateSpace(roomIdOrAlias);
|
||||||
|
else navigateRoom(roomIdOrAlias, eventId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise route through the home path (triggers the join/preview flow).
|
||||||
|
const path = getHomeRoomPath(roomIdOrAlias, eventId);
|
||||||
|
navigate(
|
||||||
|
viaServers && viaServers.length > 0
|
||||||
|
? withSearchParam<_RoomSearchParams>(path, { viaServers: viaServers.join(',') })
|
||||||
|
: path,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[mx, navigate, navigateRoom, navigateSpace],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!inTauri()) return undefined;
|
||||||
|
const handler = (evt: Event) => {
|
||||||
|
const { detail } = evt as CustomEvent<unknown>;
|
||||||
|
if (typeof detail === 'string') navigateUri(detail);
|
||||||
|
};
|
||||||
|
window.addEventListener(DEEP_LINK_EVENT, handler);
|
||||||
|
return () => window.removeEventListener(DEEP_LINK_EVENT, handler);
|
||||||
|
}, [navigateUri]);
|
||||||
|
};
|
||||||
@@ -115,8 +115,10 @@ export const useRoomUnverifiedDeviceCount = (
|
|||||||
|
|
||||||
const memberIds = useMemo(
|
const memberIds = useMemo(
|
||||||
() => room.getJoinedMembers().map((m) => m.userId),
|
() => room.getJoinedMembers().map((m) => m.userId),
|
||||||
|
// room.roomId guards against room changes; getJoinedMemberCount() ensures
|
||||||
|
// the list refreshes when members join/leave so new devices get checked.
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[room.roomId],
|
[room.roomId, room.getJoinedMemberCount()],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updateCount = useCallback(async () => {
|
const updateCount = useCallback(async () => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export const useExtendedProfile = (userId: string): ExtendedProfile => {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
|
const myUserId = mx.getUserId();
|
||||||
|
|
||||||
const fetchField = async <T extends Record<string, string>>(
|
const fetchField = async <T extends Record<string, string>>(
|
||||||
field: string,
|
field: string,
|
||||||
@@ -28,14 +29,32 @@ export const useExtendedProfile = (userId: string): ExtendedProfile => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
Promise.all([
|
const run = async () => {
|
||||||
|
const [pronouns, tzFromProfile] = await Promise.all([
|
||||||
fetchField<{ 'm.pronouns': string }>('m.pronouns'),
|
fetchField<{ 'm.pronouns': string }>('m.pronouns'),
|
||||||
fetchField<{ 'm.tz': string }>('m.tz'),
|
fetchField<{ 'm.tz': string }>('m.tz'),
|
||||||
]).then(([pronouns, timezone]) => {
|
]);
|
||||||
|
|
||||||
|
let timezone = tzFromProfile;
|
||||||
|
// Standard Synapse doesn't support m.tz — fall back to account data for own profile
|
||||||
|
if (!timezone && userId === myUserId) {
|
||||||
|
try {
|
||||||
|
const res = await mx.http.authedRequest<{ timezone: string }>(
|
||||||
|
Method.Get,
|
||||||
|
`/user/${encodeURIComponent(userId)}/account_data/im.lotus.timezone`,
|
||||||
|
);
|
||||||
|
timezone = res.timezone || undefined;
|
||||||
|
} catch {
|
||||||
|
// not set yet
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setExtProfile({ pronouns: pronouns || undefined, timezone: timezone || undefined });
|
setExtProfile({ pronouns: pronouns || undefined, timezone: timezone || undefined });
|
||||||
}
|
}
|
||||||
});
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
|
|||||||
@@ -42,7 +42,13 @@ export const useFileDropZone = (
|
|||||||
setActive(true);
|
setActive(true);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const handleDragLeave = () => {
|
const handleDragLeave = (evt: DragEvent) => {
|
||||||
|
if (evt.relatedTarget === null) {
|
||||||
|
// Mouse left the browser window — reset unconditionally
|
||||||
|
dragCounterRef.current = 0;
|
||||||
|
setActive(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
dragCounterRef.current -= 1;
|
dragCounterRef.current -= 1;
|
||||||
if (dragCounterRef.current <= 0) {
|
if (dragCounterRef.current <= 0) {
|
||||||
dragCounterRef.current = 0;
|
dragCounterRef.current = 0;
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { useEffect, useReducer } from 'react';
|
||||||
|
import { MatrixEvent, Room, RoomMember, RoomMemberEvent } from 'matrix-js-sdk';
|
||||||
|
import { Membership } from '../../types/matrix/room';
|
||||||
|
import { useMatrixClient } from './useMatrixClient';
|
||||||
|
import { readPowerLevel, usePowerLevelsContext } from './usePowerLevels';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the list of members currently knocking on the room, reactively.
|
||||||
|
* Returns an empty array if the current user lacks invite power level.
|
||||||
|
*/
|
||||||
|
export function usePendingKnocks(room: Room): RoomMember[] {
|
||||||
|
const mx = useMatrixClient();
|
||||||
|
const powerLevels = usePowerLevelsContext();
|
||||||
|
const [, forceUpdate] = useReducer((n: number) => n + 1, 0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (_evt: MatrixEvent, member: RoomMember) => {
|
||||||
|
if (member.roomId === room.roomId) forceUpdate();
|
||||||
|
};
|
||||||
|
mx.on(RoomMemberEvent.Membership, handler);
|
||||||
|
return () => {
|
||||||
|
mx.removeListener(RoomMemberEvent.Membership, handler);
|
||||||
|
};
|
||||||
|
}, [mx, room.roomId]);
|
||||||
|
|
||||||
|
const myUserId = mx.getUserId();
|
||||||
|
const myPowerLevel = readPowerLevel.user(powerLevels, myUserId ?? undefined);
|
||||||
|
const invitePowerLevel = readPowerLevel.action(powerLevels, 'invite');
|
||||||
|
const canApprove = myPowerLevel >= invitePowerLevel;
|
||||||
|
|
||||||
|
return canApprove ? room.getMembersWithMembership(Membership.Knock) : [];
|
||||||
|
}
|
||||||
@@ -16,10 +16,35 @@ export function usePresenceUpdater() {
|
|||||||
const lastActivityRef = useRef(0);
|
const lastActivityRef = useRef(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const setOnline = () =>
|
const userId = mx.getUserId();
|
||||||
mx.setPresence({ presence: 'online', status_msg: '' }).catch(() => undefined);
|
|
||||||
const setUnavailable = (statusMsg = '') =>
|
// Read status from localStorage at call time so manual updates from the
|
||||||
mx.setPresence({ presence: 'unavailable', status_msg: statusMsg }).catch(() => undefined);
|
// Profile settings are never overwritten by a stale closure value.
|
||||||
|
const readStatus = () =>
|
||||||
|
userId ? (localStorage.getItem(`lotus-status-msg-${userId}`) ?? '') : '';
|
||||||
|
|
||||||
|
const setOnline = () => {
|
||||||
|
const status = readStatus();
|
||||||
|
return mx
|
||||||
|
.setPresence({
|
||||||
|
presence: 'online',
|
||||||
|
...(status ? { status_msg: status } : {}),
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
|
const setUnavailable = (statusMsg?: string) => {
|
||||||
|
const status = readStatus();
|
||||||
|
return mx
|
||||||
|
.setPresence({
|
||||||
|
presence: 'unavailable',
|
||||||
|
...(statusMsg
|
||||||
|
? { status_msg: statusMsg }
|
||||||
|
: status
|
||||||
|
? { status_msg: status }
|
||||||
|
: {}),
|
||||||
|
})
|
||||||
|
.catch(() => undefined);
|
||||||
|
};
|
||||||
const setOffline = () =>
|
const setOffline = () =>
|
||||||
mx.setPresence({ presence: 'offline', status_msg: '' }).catch(() => undefined);
|
mx.setPresence({ presence: 'offline', status_msg: '' }).catch(() => undefined);
|
||||||
|
|
||||||
@@ -44,7 +69,7 @@ export function usePresenceUpdater() {
|
|||||||
// presenceStatus === 'auto' — original activity-tracking behavior.
|
// presenceStatus === 'auto' — original activity-tracking behavior.
|
||||||
const startIdleTimer = () => {
|
const startIdleTimer = () => {
|
||||||
clearTimeout(idleTimerRef.current);
|
clearTimeout(idleTimerRef.current);
|
||||||
idleTimerRef.current = window.setTimeout(() => {
|
idleTimerRef.current = setTimeout(() => {
|
||||||
isIdleRef.current = true;
|
isIdleRef.current = true;
|
||||||
setUnavailable();
|
setUnavailable();
|
||||||
}, IDLE_TIMEOUT_MS);
|
}, IDLE_TIMEOUT_MS);
|
||||||
@@ -75,9 +100,8 @@ export function usePresenceUpdater() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePageHide = () => {
|
const handlePageHide = () => {
|
||||||
const userId = mx.getUserId();
|
|
||||||
const token = mx.getAccessToken();
|
const token = mx.getAccessToken();
|
||||||
const baseUrl = (mx as unknown as { baseUrl: string }).baseUrl;
|
const baseUrl = mx.getHomeserverUrl();
|
||||||
if (!userId || !token || !baseUrl) return;
|
if (!userId || !token || !baseUrl) return;
|
||||||
|
|
||||||
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
|
fetch(`${baseUrl}/_matrix/client/v3/presence/${encodeURIComponent(userId)}/status`, {
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { useEffect, useRef } from 'react';
|
||||||
|
import { useAtomValue } from 'jotai';
|
||||||
|
import { roomToUnreadAtom } from '../state/room/roomToUnread';
|
||||||
|
|
||||||
|
// Tauri v2 injects __TAURI_INTERNALS__ into the webview at runtime.
|
||||||
|
// We use it directly so cinny doesn't need @tauri-apps/api as a dependency.
|
||||||
|
type TauriInternals = { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||||
|
const tauriInvoke = (): TauriInternals['invoke'] | undefined =>
|
||||||
|
(window as unknown as { __TAURI_INTERNALS__?: TauriInternals }).__TAURI_INTERNALS__?.invoke;
|
||||||
|
|
||||||
|
export function useTauriNotificationBadge() {
|
||||||
|
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
||||||
|
const prevHighlightsRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const invoke = tauriInvoke();
|
||||||
|
if (!invoke) return;
|
||||||
|
|
||||||
|
let totalHighlights = 0;
|
||||||
|
roomToUnread.forEach((unread) => {
|
||||||
|
totalHighlights += unread.highlight;
|
||||||
|
});
|
||||||
|
|
||||||
|
invoke('set_badge_count', { count: totalHighlights }).catch(() => {});
|
||||||
|
// Mirror the unread state onto the tray icon (visible when minimized to tray).
|
||||||
|
invoke('set_tray_unread', { unread: totalHighlights > 0 }).catch(() => {});
|
||||||
|
|
||||||
|
// Flash the taskbar button when new mentions arrive and the window is not
|
||||||
|
// focused, then reset the baseline.
|
||||||
|
if (totalHighlights > prevHighlightsRef.current && !document.hasFocus()) {
|
||||||
|
invoke('flash_window').catch(() => {});
|
||||||
|
}
|
||||||
|
prevHighlightsRef.current = totalHighlights;
|
||||||
|
}, [roomToUnread]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import { useState, useCallback } from 'react';
|
||||||
|
|
||||||
|
type TauriInternals = { invoke: (cmd: string, args?: Record<string, unknown>) => Promise<unknown> };
|
||||||
|
const tauriInvoke = (): TauriInternals['invoke'] | undefined =>
|
||||||
|
(window as unknown as { __TAURI_INTERNALS__?: TauriInternals }).__TAURI_INTERNALS__?.invoke;
|
||||||
|
|
||||||
|
type UpdateStatus =
|
||||||
|
| { state: 'idle' }
|
||||||
|
| { state: 'checking' }
|
||||||
|
| { state: 'up-to-date' }
|
||||||
|
| { state: 'available'; version: string }
|
||||||
|
| { state: 'installing' }
|
||||||
|
| { state: 'error'; message: string };
|
||||||
|
|
||||||
|
export function useTauriUpdater() {
|
||||||
|
const isTauri = !!tauriInvoke();
|
||||||
|
const [status, setStatus] = useState<UpdateStatus>({ state: 'idle' });
|
||||||
|
|
||||||
|
const check = useCallback(async () => {
|
||||||
|
const invoke = tauriInvoke();
|
||||||
|
if (!invoke) return;
|
||||||
|
setStatus({ state: 'checking' });
|
||||||
|
try {
|
||||||
|
const result = (await invoke('check_for_update')) as { available: boolean; version?: string };
|
||||||
|
setStatus(
|
||||||
|
result.available && result.version
|
||||||
|
? { state: 'available', version: result.version }
|
||||||
|
: { state: 'up-to-date' },
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus({ state: 'error', message: String(e) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const install = useCallback(async () => {
|
||||||
|
const invoke = tauriInvoke();
|
||||||
|
if (!invoke) return;
|
||||||
|
setStatus({ state: 'installing' });
|
||||||
|
try {
|
||||||
|
await invoke('install_update');
|
||||||
|
} catch (e) {
|
||||||
|
setStatus({ state: 'error', message: String(e) });
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { isTauri, status, check, install };
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -29,20 +29,24 @@ export const useUserPresence = (userId: string): UserPresence | undefined => {
|
|||||||
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
const [presence, setPresence] = useState(() => (user ? getUserPresence(user) : undefined));
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Subscribe on mx (MatrixClient) rather than on individual User objects.
|
||||||
|
// User objects have a default 10-listener limit; the same user can appear
|
||||||
|
// in many components simultaneously (avatars, member list, etc.) and
|
||||||
|
// per-user subscription causes MaxListenersExceededWarning at 11+.
|
||||||
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
|
const updatePresence: UserEventHandlerMap[UserEvent.Presence] = (event, u) => {
|
||||||
if (u.userId === user?.userId) {
|
if (u.userId === user?.userId) {
|
||||||
setPresence(getUserPresence(user));
|
setPresence(getUserPresence(u));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
user?.on(UserEvent.Presence, updatePresence);
|
mx.on(UserEvent.Presence, updatePresence);
|
||||||
user?.on(UserEvent.CurrentlyActive, updatePresence);
|
mx.on(UserEvent.CurrentlyActive, updatePresence);
|
||||||
user?.on(UserEvent.LastPresenceTs, updatePresence);
|
mx.on(UserEvent.LastPresenceTs, updatePresence);
|
||||||
return () => {
|
return () => {
|
||||||
user?.removeListener(UserEvent.Presence, updatePresence);
|
mx.removeListener(UserEvent.Presence, updatePresence);
|
||||||
user?.removeListener(UserEvent.CurrentlyActive, updatePresence);
|
mx.removeListener(UserEvent.CurrentlyActive, updatePresence);
|
||||||
user?.removeListener(UserEvent.LastPresenceTs, updatePresence);
|
mx.removeListener(UserEvent.LastPresenceTs, updatePresence);
|
||||||
};
|
};
|
||||||
}, [user]);
|
}, [mx, user]);
|
||||||
|
|
||||||
return presence;
|
return presence;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useEffect } from 'react';
|
||||||
import * as Sentry from '@sentry/react';
|
import * as Sentry from '@sentry/react';
|
||||||
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
|
import { Provider as JotaiProvider, useAtomValue } from 'jotai';
|
||||||
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
|
import { OverlayContainerProvider, PopOutContainerProvider, TooltipContainerProvider } from 'folds';
|
||||||
@@ -15,6 +15,49 @@ import { ScreenSizeProvider, useScreenSize } from '../hooks/useScreenSize';
|
|||||||
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
import { useCompositionEndTracking } from '../hooks/useComposingCheck';
|
||||||
import { settingsAtom } from '../state/settings';
|
import { settingsAtom } from '../state/settings';
|
||||||
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
import { LotusToastContainer } from '../features/toast/LotusToastContainer';
|
||||||
|
import { useTauriNotificationBadge } from '../hooks/useTauriNotificationBadge';
|
||||||
|
import { SeasonalEffect } from '../components/seasonal/SeasonalEffect';
|
||||||
|
|
||||||
|
const FONT_MAP: Record<string, string> = {
|
||||||
|
system: "system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif",
|
||||||
|
inter: "'InterVariable', sans-serif",
|
||||||
|
'jetbrains-mono': "'JetBrains Mono', monospace",
|
||||||
|
'fira-code': "'Fira Code', monospace",
|
||||||
|
};
|
||||||
|
|
||||||
|
function AppearanceEffects() {
|
||||||
|
const settings = useAtomValue(settingsAtom);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const color = settings.mentionHighlightColor;
|
||||||
|
if (color) {
|
||||||
|
document.body.style.setProperty('--mention-highlight-bg', color);
|
||||||
|
// compute black or white text based on hex luminance
|
||||||
|
const r = parseInt(color.slice(1, 3), 16);
|
||||||
|
const g = parseInt(color.slice(3, 5), 16);
|
||||||
|
const b = parseInt(color.slice(5, 7), 16);
|
||||||
|
const lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
document.body.style.setProperty('--mention-highlight-text', lum > 0.5 ? '#000' : '#fff');
|
||||||
|
document.body.style.setProperty('--mention-highlight-border', color);
|
||||||
|
} else {
|
||||||
|
document.body.style.removeProperty('--mention-highlight-bg');
|
||||||
|
document.body.style.removeProperty('--mention-highlight-text');
|
||||||
|
document.body.style.removeProperty('--mention-highlight-border');
|
||||||
|
}
|
||||||
|
}, [settings.mentionHighlightColor]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const font = FONT_MAP[settings.fontFamily ?? 'inter'] ?? FONT_MAP.inter;
|
||||||
|
document.body.style.setProperty('--font-secondary', font);
|
||||||
|
}, [settings.fontFamily]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function TauriEffects() {
|
||||||
|
useTauriNotificationBadge();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
function NightLightOverlay() {
|
function NightLightOverlay() {
|
||||||
const settings = useAtomValue(settingsAtom);
|
const settings = useAtomValue(settingsAtom);
|
||||||
@@ -94,7 +137,10 @@ function App() {
|
|||||||
<ClientConfigProvider value={clientConfig}>
|
<ClientConfigProvider value={clientConfig}>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<JotaiProvider>
|
<JotaiProvider>
|
||||||
|
<AppearanceEffects />
|
||||||
|
<TauriEffects />
|
||||||
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
<RouterProvider router={createRouter(clientConfig, screenSize)} />
|
||||||
|
<SeasonalEffect />
|
||||||
<NightLightOverlay />
|
<NightLightOverlay />
|
||||||
<LotusToastContainer />
|
<LotusToastContainer />
|
||||||
</JotaiProvider>
|
</JotaiProvider>
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import { useSelectedRoom } from '../../hooks/router/useSelectedRoom';
|
|||||||
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
import { useInboxNotificationsSelected } from '../../hooks/router/useInbox';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
import { usePresenceUpdater } from '../../hooks/usePresenceUpdater';
|
||||||
|
import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate';
|
||||||
import { toastQueueAtom } from '../../state/toast';
|
import { toastQueueAtom } from '../../state/toast';
|
||||||
|
|
||||||
function isInQuietHours(start: string, end: string): boolean {
|
function isInQuietHours(start: string, end: string): boolean {
|
||||||
@@ -376,6 +377,11 @@ type ClientNonUIFeaturesProps = {
|
|||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function DeepLinkNavigator() {
|
||||||
|
useDeepLinkNavigate();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -385,6 +391,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) {
|
|||||||
<PresenceUpdater />
|
<PresenceUpdater />
|
||||||
<InviteNotifications />
|
<InviteNotifications />
|
||||||
<MessageNotifications />
|
<MessageNotifications />
|
||||||
|
<DeepLinkNavigator />
|
||||||
{children}
|
{children}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ import {
|
|||||||
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag';
|
||||||
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
import { useRoomCreators } from '../../../hooks/useRoomCreators';
|
||||||
import { PresenceRingAvatar } from '../../../components/presence';
|
import { PresenceRingAvatar } from '../../../components/presence';
|
||||||
|
import { AvatarDecoration } from '../../../components/avatar-decoration/AvatarDecoration';
|
||||||
|
|
||||||
type RoomNotificationsGroup = {
|
type RoomNotificationsGroup = {
|
||||||
roomId: string;
|
roomId: string;
|
||||||
@@ -479,6 +480,7 @@ function RoomNotificationsGroupComp({
|
|||||||
<ModernLayout
|
<ModernLayout
|
||||||
before={
|
before={
|
||||||
<AvatarBase>
|
<AvatarBase>
|
||||||
|
<AvatarDecoration userId={event.sender}>
|
||||||
<PresenceRingAvatar userId={event.sender}>
|
<PresenceRingAvatar userId={event.sender}>
|
||||||
<Avatar size="300">
|
<Avatar size="300">
|
||||||
<UserAvatar
|
<UserAvatar
|
||||||
@@ -500,6 +502,7 @@ function RoomNotificationsGroupComp({
|
|||||||
/>
|
/>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
</PresenceRingAvatar>
|
</PresenceRingAvatar>
|
||||||
|
</AvatarDecoration>
|
||||||
</AvatarBase>
|
</AvatarBase>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ export class CallEmbed {
|
|||||||
header: 'none',
|
header: 'none',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!room.isCallRoom() && CallEmbed.startingCall(intent)) {
|
if (CallEmbed.startingCall(intent)) {
|
||||||
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
params.append('sendNotificationType', CallEmbed.dmCall(intent) ? 'ring' : 'notification');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,10 +15,7 @@ export const getRecentEmojis = (mx: MatrixClient, limit?: number): IEmoji[] => {
|
|||||||
const recentEmoji = recentEmojiEvent?.getContent<IRecentEmojiContent>().recent_emoji;
|
const recentEmoji = recentEmojiEvent?.getContent<IRecentEmojiContent>().recent_emoji;
|
||||||
if (!Array.isArray(recentEmoji)) return [];
|
if (!Array.isArray(recentEmoji)) return [];
|
||||||
|
|
||||||
return recentEmoji
|
return recentEmoji.slice(0, limit).reduce<IEmoji[]>((list, [unicode]) => {
|
||||||
.sort((e1, e2) => e2[1] - e1[1])
|
|
||||||
.slice(0, limit)
|
|
||||||
.reduce<IEmoji[]>((list, [unicode]) => {
|
|
||||||
const emoji = emojis.find((e) => e.unicode === unicode);
|
const emoji = emojis.find((e) => e.unicode === unicode);
|
||||||
if (emoji) list.push(emoji);
|
if (emoji) list.push(emoji);
|
||||||
return list;
|
return list;
|
||||||
|
|||||||
@@ -38,6 +38,28 @@ export enum MessageLayout {
|
|||||||
Bubble = 2,
|
Bubble = 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ComposerToolbarSettings {
|
||||||
|
showFormat: boolean;
|
||||||
|
showEmoji: boolean;
|
||||||
|
showSticker: boolean;
|
||||||
|
showGif: boolean;
|
||||||
|
showLocation: boolean;
|
||||||
|
showPoll: boolean;
|
||||||
|
showVoice: boolean;
|
||||||
|
showSchedule: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_COMPOSER_TOOLBAR: ComposerToolbarSettings = {
|
||||||
|
showFormat: true,
|
||||||
|
showEmoji: true,
|
||||||
|
showSticker: true,
|
||||||
|
showGif: true,
|
||||||
|
showLocation: true,
|
||||||
|
showPoll: true,
|
||||||
|
showVoice: true,
|
||||||
|
showSchedule: true,
|
||||||
|
};
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
themeId?: string;
|
themeId?: string;
|
||||||
useSystemTheme: boolean;
|
useSystemTheme: boolean;
|
||||||
@@ -101,6 +123,31 @@ export interface Settings {
|
|||||||
warnOnUnverifiedDevices: boolean;
|
warnOnUnverifiedDevices: boolean;
|
||||||
|
|
||||||
pauseAnimations: boolean;
|
pauseAnimations: boolean;
|
||||||
|
|
||||||
|
composerToolbarButtons: ComposerToolbarSettings;
|
||||||
|
|
||||||
|
mentionHighlightColor: string;
|
||||||
|
fontFamily: 'system' | 'inter' | 'jetbrains-mono' | 'fira-code';
|
||||||
|
|
||||||
|
afkAutoMute: boolean;
|
||||||
|
afkTimeoutMinutes: number;
|
||||||
|
|
||||||
|
callJoinLeaveSound: 'off' | 'chime' | 'soft' | 'retro';
|
||||||
|
|
||||||
|
seasonalThemeOverride:
|
||||||
|
| 'auto'
|
||||||
|
| 'off'
|
||||||
|
| 'halloween'
|
||||||
|
| 'christmas'
|
||||||
|
| 'newyear'
|
||||||
|
| 'autumn'
|
||||||
|
| 'aprilfools'
|
||||||
|
| 'lunar'
|
||||||
|
| 'valentines'
|
||||||
|
| 'stpatricks'
|
||||||
|
| 'earthday'
|
||||||
|
| 'deepspace'
|
||||||
|
| 'arcade';
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultSettings: Settings = {
|
const defaultSettings: Settings = {
|
||||||
@@ -166,13 +213,33 @@ const defaultSettings: Settings = {
|
|||||||
warnOnUnverifiedDevices: false,
|
warnOnUnverifiedDevices: false,
|
||||||
|
|
||||||
pauseAnimations: false,
|
pauseAnimations: false,
|
||||||
|
|
||||||
|
composerToolbarButtons: DEFAULT_COMPOSER_TOOLBAR,
|
||||||
|
|
||||||
|
mentionHighlightColor: '',
|
||||||
|
fontFamily: 'inter',
|
||||||
|
|
||||||
|
afkAutoMute: false,
|
||||||
|
afkTimeoutMinutes: 10,
|
||||||
|
|
||||||
|
callJoinLeaveSound: 'chime',
|
||||||
|
|
||||||
|
seasonalThemeOverride: 'auto',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getSettings = (): Settings => {
|
export const getSettings = (): Settings => {
|
||||||
try {
|
try {
|
||||||
const settings = localStorage.getItem(STORAGE_KEY);
|
const settings = localStorage.getItem(STORAGE_KEY);
|
||||||
if (settings === null) return defaultSettings;
|
if (settings === null) return defaultSettings;
|
||||||
return { ...defaultSettings, ...(JSON.parse(settings) as Settings) };
|
const saved = JSON.parse(settings) as Partial<Settings>;
|
||||||
|
return {
|
||||||
|
...defaultSettings,
|
||||||
|
...saved,
|
||||||
|
composerToolbarButtons: {
|
||||||
|
...DEFAULT_COMPOSER_TOOLBAR,
|
||||||
|
...(saved.composerToolbarButtons ?? {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
return defaultSettings;
|
return defaultSettings;
|
||||||
|
|||||||
@@ -70,38 +70,86 @@ export const MsgAppearClass = style({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// Animated chat background keyframes
|
// ─── Animated chat background keyframes ───────────────────────────────────────
|
||||||
|
|
||||||
// Animated chat background keyframes
|
/**
|
||||||
|
* Digital Rain — vertical stripe scroll with a phosphor-glow flicker layered on top.
|
||||||
/** Matrix rain — two stripe layers scroll at different speeds for parallax depth */
|
* Two stripe layers at different column widths and speeds create parallax depth.
|
||||||
|
*/
|
||||||
export const animRainKeyframe = keyframes({
|
export const animRainKeyframe = keyframes({
|
||||||
from: { backgroundPosition: '0 0, 0 0' },
|
from: { backgroundPosition: '0 0, 0 0' },
|
||||||
to: { backgroundPosition: '0 200px, 0 100px' },
|
to: { backgroundPosition: '0 200px, 0 100px' },
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Drifting stars — three layers drift diagonally */
|
/** Phosphor flicker brightness pulse layered over the rain scroll. */
|
||||||
export const animStarsDriftKeyframe = keyframes({
|
export const animRainGlowKeyframe = keyframes({
|
||||||
from: { backgroundPosition: '0 0, 65px 32px, 32px 97px' },
|
'0%': { filter: 'brightness(0.85)' },
|
||||||
to: { backgroundPosition: '130px 130px, 195px 162px, 162px 227px' },
|
'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({
|
export const animGridPulseKeyframe = keyframes({
|
||||||
'0%': { backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px' },
|
'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' },
|
'100%': { backgroundSize: '60px 60px, 60px 60px, 12px 12px, 12px 12px' },
|
||||||
});
|
});
|
||||||
|
|
||||||
/** Aurora — sweeps backgroundPosition in large cycle */
|
/** Brightness oscillation layered on top of the grid size pulse. */
|
||||||
export const animAuroraKeyframe = keyframes({
|
export const animGridBrightnessKeyframe = keyframes({
|
||||||
'0%': { backgroundPosition: '0% 0%' },
|
'0%': { filter: 'brightness(0.8)' },
|
||||||
'50%': { backgroundPosition: '-50% -25%' },
|
'50%': { filter: 'brightness(1.3)' },
|
||||||
'100%': { backgroundPosition: '0% 0%' },
|
'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({
|
export const animFirefliesKeyframe = keyframes({
|
||||||
from: { backgroundPosition: '0 0, 120px 80px, 60px 140px' },
|
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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -169,9 +169,9 @@ export const Mention = recipe({
|
|||||||
variants: {
|
variants: {
|
||||||
highlight: {
|
highlight: {
|
||||||
true: {
|
true: {
|
||||||
backgroundColor: color.Success.Container,
|
backgroundColor: `var(--mention-highlight-bg, ${color.Success.Container as string})`,
|
||||||
color: color.Success.OnContainer,
|
color: `var(--mention-highlight-text, ${color.Success.OnContainer as string})`,
|
||||||
boxShadow: `0 0 0 ${config.borderWidth.B300} ${color.Success.ContainerLine}`,
|
boxShadow: `0 0 0 ${config.borderWidth.B300} var(--mention-highlight-border, ${color.Success.ContainerLine as string})`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
focus: {
|
focus: {
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
export type CallSoundStyle = 'chime' | 'soft' | 'retro';
|
||||||
|
|
||||||
|
let sharedCtx: AudioContext | undefined;
|
||||||
|
|
||||||
|
const getAudioContext = (): AudioContext | undefined => {
|
||||||
|
try {
|
||||||
|
if (!sharedCtx || sharedCtx.state === 'closed') sharedCtx = new AudioContext();
|
||||||
|
if (sharedCtx.state === 'suspended') sharedCtx.resume().catch(() => undefined);
|
||||||
|
return sharedCtx;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create and resume the shared AudioContext from within a user gesture
|
||||||
|
* (e.g. clicking "Join"). Browsers block AudioContext playback until it has
|
||||||
|
* been started by a gesture, so join/leave sounds — which fire later without
|
||||||
|
* any gesture — would otherwise be silent. Call this on call entry so every
|
||||||
|
* participant's later membership-change sounds are actually audible.
|
||||||
|
*/
|
||||||
|
export const unlockCallSounds = (): void => {
|
||||||
|
getAudioContext();
|
||||||
|
};
|
||||||
|
|
||||||
|
type Note = {
|
||||||
|
freq: number;
|
||||||
|
/** Offset from now, in seconds */
|
||||||
|
at: number;
|
||||||
|
/** Duration in seconds */
|
||||||
|
dur: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const playNotes = (notes: Note[], type: OscillatorType, peakGain: number): void => {
|
||||||
|
const ctx = getAudioContext();
|
||||||
|
if (!ctx) return;
|
||||||
|
const now = ctx.currentTime;
|
||||||
|
notes.forEach(({ freq, at, dur }) => {
|
||||||
|
const osc = ctx.createOscillator();
|
||||||
|
const gain = ctx.createGain();
|
||||||
|
osc.type = type;
|
||||||
|
osc.frequency.value = freq;
|
||||||
|
const start = now + at;
|
||||||
|
// Short attack/decay envelope to avoid clicks
|
||||||
|
gain.gain.setValueAtTime(0, start);
|
||||||
|
gain.gain.linearRampToValueAtTime(peakGain, start + 0.015);
|
||||||
|
gain.gain.exponentialRampToValueAtTime(0.0001, start + dur);
|
||||||
|
osc.connect(gain);
|
||||||
|
gain.connect(ctx.destination);
|
||||||
|
osc.start(start);
|
||||||
|
osc.stop(start + dur + 0.02);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const SOUNDS: Record<CallSoundStyle, { join: () => void; leave: () => void }> = {
|
||||||
|
chime: {
|
||||||
|
join: () =>
|
||||||
|
playNotes(
|
||||||
|
[
|
||||||
|
{ freq: 587.33, at: 0, dur: 0.12 },
|
||||||
|
{ freq: 880, at: 0.1, dur: 0.2 },
|
||||||
|
],
|
||||||
|
'sine',
|
||||||
|
0.25,
|
||||||
|
),
|
||||||
|
leave: () =>
|
||||||
|
playNotes(
|
||||||
|
[
|
||||||
|
{ freq: 880, at: 0, dur: 0.12 },
|
||||||
|
{ freq: 587.33, at: 0.1, dur: 0.2 },
|
||||||
|
],
|
||||||
|
'sine',
|
||||||
|
0.25,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
soft: {
|
||||||
|
join: () => playNotes([{ freq: 523.25, at: 0, dur: 0.4 }], 'triangle', 0.18),
|
||||||
|
leave: () => playNotes([{ freq: 392, at: 0, dur: 0.4 }], 'triangle', 0.18),
|
||||||
|
},
|
||||||
|
retro: {
|
||||||
|
join: () =>
|
||||||
|
playNotes(
|
||||||
|
[
|
||||||
|
{ freq: 440, at: 0, dur: 0.07 },
|
||||||
|
{ freq: 554.37, at: 0.07, dur: 0.07 },
|
||||||
|
{ freq: 659.25, at: 0.14, dur: 0.14 },
|
||||||
|
],
|
||||||
|
'square',
|
||||||
|
0.1,
|
||||||
|
),
|
||||||
|
leave: () =>
|
||||||
|
playNotes(
|
||||||
|
[
|
||||||
|
{ freq: 659.25, at: 0, dur: 0.07 },
|
||||||
|
{ freq: 554.37, at: 0.07, dur: 0.07 },
|
||||||
|
{ freq: 440, at: 0.14, dur: 0.14 },
|
||||||
|
],
|
||||||
|
'square',
|
||||||
|
0.1,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const playCallJoinSound = (style: CallSoundStyle): void => SOUNDS[style]?.join();
|
||||||
|
|
||||||
|
export const playCallLeaveSound = (style: CallSoundStyle): void => SOUNDS[style]?.leave();
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
export const targetFromEvent = (evt: Event, selector: string): Element | undefined => {
|
export const targetFromEvent = (evt: Event, selector: string): Element | undefined => {
|
||||||
const targets = evt.composedPath() as Element[];
|
const targets = evt.composedPath() as Element[];
|
||||||
|
if (targets.length > 0) {
|
||||||
return targets.find((target) => target.matches?.(selector));
|
return targets.find((target) => target.matches?.(selector));
|
||||||
|
}
|
||||||
|
// composedPath() is empty when the event is no longer dispatching (e.g. inside a
|
||||||
|
// portal-within-portal in React 19). Walk up the DOM from evt.target instead.
|
||||||
|
let el = evt.target instanceof Element ? evt.target : null;
|
||||||
|
while (el) {
|
||||||
|
if (el.matches(selector)) return el;
|
||||||
|
el = el.parentElement;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const editableActiveElement = (): boolean =>
|
export const editableActiveElement = (): boolean =>
|
||||||
|
|||||||
@@ -222,7 +222,7 @@ export function tokenize(code: string, lang: string): SyntaxToken[] {
|
|||||||
if (
|
if (
|
||||||
code[i] === '#' &&
|
code[i] === '#' &&
|
||||||
(normalised === 'python' || normalised === 'py') &&
|
(normalised === 'python' || normalised === 'py') &&
|
||||||
(i === 0 || code[i - 1] === '\n')
|
(i === 0 || code[i - 1] === '\n' || code[i - 1] === ' ' || code[i - 1] === '\t')
|
||||||
) {
|
) {
|
||||||
const nlHash = code.indexOf('\n', i);
|
const nlHash = code.indexOf('\n', i);
|
||||||
const closeIdx = nlHash === -1 ? len : nlHash;
|
const closeIdx = nlHash === -1 ? len : nlHash;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
/* semantic surface vars used by poll, location, upload card, gif picker */
|
/* semantic surface vars used by poll, location, upload card, gif picker */
|
||||||
--bg-surface: #ffffff;
|
--bg-surface: #ffffff;
|
||||||
--bg-surface-low: rgba(0, 0, 0, 0.04);
|
--bg-surface-low: rgba(0, 0, 0, 0.04);
|
||||||
|
--bg-surface-variant: rgba(0, 0, 0, 0.07);
|
||||||
--bg-surface-active: rgba(0, 0, 0, 0.1);
|
--bg-surface-active: rgba(0, 0, 0, 0.1);
|
||||||
--bg-surface-border: rgba(0, 0, 0, 0.14);
|
--bg-surface-border: rgba(0, 0, 0, 0.14);
|
||||||
--text-primary: #1a1a1a;
|
--text-primary: #1a1a1a;
|
||||||
@@ -37,6 +38,7 @@
|
|||||||
/* semantic surface vars — dark overrides */
|
/* semantic surface vars — dark overrides */
|
||||||
--bg-surface: #25272e;
|
--bg-surface: #25272e;
|
||||||
--bg-surface-low: rgba(255, 255, 255, 0.05);
|
--bg-surface-low: rgba(255, 255, 255, 0.05);
|
||||||
|
--bg-surface-variant: rgba(255, 255, 255, 0.08);
|
||||||
--bg-surface-active: rgba(255, 255, 255, 0.1);
|
--bg-surface-active: rgba(255, 255, 255, 0.1);
|
||||||
--bg-surface-border: rgba(255, 255, 255, 0.12);
|
--bg-surface-border: rgba(255, 255, 255, 0.12);
|
||||||
--text-primary: #e0e5ed;
|
--text-primary: #e0e5ed;
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ export enum StateEvent {
|
|||||||
|
|
||||||
PoniesRoomEmotes = 'im.ponies.room_emotes',
|
PoniesRoomEmotes = 'im.ponies.room_emotes',
|
||||||
PowerLevelTags = 'in.cinny.room.power_level_tags',
|
PowerLevelTags = 'in.cinny.room.power_level_tags',
|
||||||
|
LotusVoiceLimit = 'io.lotus.voice_limit',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum MessageEvent {
|
export enum MessageEvent {
|
||||||
|
|||||||
@@ -13,9 +13,19 @@ import buildConfig from './build.config';
|
|||||||
const copyFiles = {
|
const copyFiles = {
|
||||||
targets: [
|
targets: [
|
||||||
{
|
{
|
||||||
|
// Element Call's dist must land flat in public/element-call/ so the call
|
||||||
|
// widget URL (/public/element-call/index.html) resolves. v4.x of
|
||||||
|
// vite-plugin-static-copy preserves the full source path under dest, so
|
||||||
|
// we strip the 4 leading segments of the source base
|
||||||
|
// (node_modules/@element-hq/element-call-embedded/dist) — mirroring the
|
||||||
|
// stripBase pattern used by the android/locales targets below. The old
|
||||||
|
// `rename: 'element-call'` form silently produced
|
||||||
|
// public/node_modules/.../dist/ under v4.x, 404ing the widget (calls
|
||||||
|
// broke on cinny-desktop; web only worked because its deployed copy was
|
||||||
|
// a stale artifact from before the vite-plugin-static-copy v4 bump).
|
||||||
src: 'node_modules/@element-hq/element-call-embedded/dist',
|
src: 'node_modules/@element-hq/element-call-embedded/dist',
|
||||||
dest: 'public',
|
dest: 'public/element-call',
|
||||||
rename: 'element-call',
|
rename: { stripBase: 4 },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
src: 'config.json',
|
src: 'config.json',
|
||||||
@@ -36,6 +46,11 @@ const copyFiles = {
|
|||||||
dest: 'public/',
|
dest: 'public/',
|
||||||
rename: { stripBase: 1 },
|
rename: { stripBase: 1 },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
src: 'public/fonts',
|
||||||
|
dest: '',
|
||||||
|
rename: { stripBase: 1 },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||