From b24ab838f8880ea0fd040adf3bdbf701bc85b30b Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Wed, 17 Jun 2026 20:26:43 -0400 Subject: [PATCH] feat: Remind Me Later, mobile bookmarks, bug fixes, and doc cleanup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Remind Me Later: message context menu item opens a preset time picker (20 min / 1 hr / 3 hr / tomorrow 9am); reminders persist to Matrix account data (io.lotus.reminders); ReminderMonitor fires a Lotus Toast when due, checks every 30s and on tab focus - Mobile Bookmarks: BookmarksPanel now renders on all screen sizes; passes isMobile prop for full-screen absolute overlay on mobile Bug fixes: - usePan.ts: memory leak from stale closure in document listener cleanup - EventReaders.tsx: replace hardcoded hex colors with TDS CSS variables - CallControls.tsx: replace hardcoded hex colors with TDS CSS variables - CustomHtml.css.ts: replace hardcoded yellow/black highlight with theme tokens Docs: - LOTUS_TODO.md: restore deleted content (Confirmed facts table, Pending Audits, P5-30 completed status, full feature descriptions), keep new additions (P4-7/8/9, P5-41–57, Implementation Reference), eliminate duplicate sections - LOTUS_BUGS.md: merge RESILIENCE_AUDIT.md findings into Architectural & Resilience Audit table; delete RESILIENCE_AUDIT.md - Remove stale LOTUS_DENOISE_ENGINEERING_REVIEW.md and LOTUS_TODO_REFERENCE.md Co-Authored-By: Claude Sonnet 4.6 --- LOTUS_BUGS.md | 265 ++++++++--- LOTUS_DENOISE_ENGINEERING_REVIEW.md | 54 --- LOTUS_TODO.md | 440 ++++++++++++++++-- LOTUS_TODO_REFERENCE.md | 214 --------- .../components/event-readers/EventReaders.tsx | 12 +- src/app/features/bookmarks/BookmarksPanel.tsx | 11 +- src/app/features/call/CallControls.tsx | 6 +- src/app/features/room/message/Message.tsx | 30 ++ .../features/room/message/RemindMeDialog.tsx | 122 +++++ src/app/hooks/usePan.ts | 57 +-- src/app/hooks/useReminders.ts | 75 +++ src/app/pages/client/ClientLayout.tsx | 11 +- src/app/pages/client/ClientNonUIFeatures.tsx | 46 ++ src/app/styles/CustomHtml.css.ts | 6 +- 14 files changed, 931 insertions(+), 418 deletions(-) delete mode 100644 LOTUS_DENOISE_ENGINEERING_REVIEW.md delete mode 100644 LOTUS_TODO_REFERENCE.md create mode 100644 src/app/features/room/message/RemindMeDialog.tsx create mode 100644 src/app/hooks/useReminders.ts diff --git a/LOTUS_BUGS.md b/LOTUS_BUGS.md index 6d6a65dc0..7f6d8ed04 100644 --- a/LOTUS_BUGS.md +++ b/LOTUS_BUGS.md @@ -8,86 +8,233 @@ This document tracks identified bugs, edge cases, and architectural discrepancie ## 🚩 Critical & UI Bugs -### 1. Avatar Decoration Displacement in Profile +### 1. No Camera Focus During Screenshare +- **File:** `cinny/src/app/features/call/CallControls.tsx` +- **Status:** **OPEN** +- **Issue:** Automatic screenshare spotlighting forces primary display override, preventing users from manually focusing on camera feeds. +- **Root Cause:** Current spotlighting logic prioritizes active screenshare streams over manual participant selections, effectively ignoring or overriding user-initiated focus states. +- **Proposed Fix:** Introduce a manual 'Focus' state that takes precedence over automatic screenshare spotlighting, implemented via a toggle/click UI on participant tiles. Update the video renderer to respect this manual override. -**File:** `src/app/components/user-profile/UserHero.tsx` -**Status:** **OPEN** +### 2. Chat Background Animation Flickering +- **File:** `cinny/src/app/features/lotus/chatBackground.ts` +- **Status:** **OPEN** +- **Issue:** Animated background properties cause visible flickering on message text and the composer area, particularly on browsers/GPUs susceptible to repaint-induced artifacts. +- **Root Cause:** Animation triggers excessive repaints or layout recalculations on descendant elements, likely due to animating non-GPU accelerated properties on parent containers without proper rendering context isolation. +- **Proposed Fix:** Promote background container to a compositor layer using `will-change: transform`, strictly limit animations to `transform` and `opacity` properties, and utilize `contain: paint;` to isolate the background rendering context. -- **Issue:** Avatar decorations appear displaced left of the avatar when viewing the profile modal. -- **Root Cause:** The `AvatarPresence` badge sticking out to the right shifts the center of the `inline-flex` container. The decoration centers on the container, not the avatar. -- **Recommended Fix:** Wrap only the `Avatar` component with `AvatarDecoration`. +### 3. Avatar Decorations in Element Call +- **File:** `cinny/src/app/components/avatar-decoration/AvatarDecoration.tsx` +- **Status:** **OPEN** +- **Issue:** Avatar decorations are failing to render within the call/room interface member lists. +- **Root Cause:** Likely a mismatch between the expected `member` object structure required by the `AvatarDecoration` component and the data actually provided by the call/room UI components. Matrix event data for decorations might not be propagating correctly to these UI member objects. +- **Proposed Fix:** Analyze the data propagation chain from Matrix events to the member object in `cinny/src/app/components/call` and `room`, ensuring that decoration-related properties are correctly mapped and passed to the `AvatarDecoration` component. -### 2. Inconsistent Settings Dropdown Styling +### 4. DM and Group Message Calls +- **File:** `cinny/src/app/components/CallEmbedProvider.tsx` +- **Status:** **OPEN** +- **Issue:** Incoming call ringtone is hardcoded, lacks volume control, and is suppressed if the user is already in an active call. +- **Root Cause:** Ringing logic is tightly coupled to `RTCNotification` events in `CallEmbedProvider.tsx`, using a hardcoded audio file path. It lacks an abstraction for sound management or user-configurable settings for ringtones/volumes. +- **Proposed Fix:** Migrate sound asset management to a dedicated audio service. Implement user-configurable settings for ringtone and notification volume. Update the `IncomingCallListener` to support ringing even during active calls (if appropriate) by enhancing event handling. -**Files:** `Profile.tsx`, `SystemNotification.tsx` -**Status:** **OPEN** +### 5. Seasonal Themes and Chat Backgrounds Design +- **File:** `cinny/src/app/hooks/useTheme.ts`, `cinny/src/app/features/lotus/chatBackground.ts` +- **Status:** **OPEN** +- **Issue:** Basic CSS or random moving lines are insufficient for high-fidelity wallpaper/theming. They lack professional design theory, coherence, and aesthetic depth. +- **Root Cause:** Current implementation relies on basic CSS, lacks advanced design theory, and does not leverage modern, performant CSS wallpaper techniques. +- **Proposed Fix (Extreme Depth Redesign):** + - **Research-Backed Implementation:** Implement advanced design techniques (layered `oklch` gradients, `backdrop-filter` for refractive "liquid glass" effects, GPU-accelerated `transform` animations) to create living, breathing backgrounds. + - **Performance Optimization:** Ensure all animations strictly use compositor-thread properties (`transform`, `opacity`) and apply `contain: paint` / `will-change: transform` to prevent layout thrashing/flickering. + - **Design Resources (Examples/Inspiration):** + - [Uiverse.io Patterns](https://uiverse.io/patterns) + - [MagicPattern CSS Backgrounds](https://www.magicpattern.design/tools/css-backgrounds) + - [Prismic Blog: CSS Background Effects](https://prismic.io/blog/css-background-effects) + - [CSS-Pattern.com](https://css-pattern.com) (Pure CSS pattern library) + - [BGJar](https://bgjar.com) (Performance-focused generators) + - **Goal:** Treat each theme/background as a week-long development sprint to ensure professional polish, WCAG AA contrast compliance for overlaying UI, and seamless integration with the Lotus TDS. -- **Issue:** Dropdowns for Status Expiry and Notification Sounds use raw HTML ``. Hide this section if `lotusTerminal` is `true`. + +--- + +### P5-15 Β· In-Call Soundboard + +**Mechanism:** Local-to-Global Audio Bridge via Web Audio API. + +- Create an `AudioContext` and a `MediaStreamDestinationNode`. +- Create an `AudioBufferSourceNode` for each clip. +- Route the mic `MediaStream` and the clip source to the destination node. +- Pass the destination's `.stream` to the call bridge. + +--- + +### P5-20 Β· Quick Reply from Browser Notification + +**Mechanism:** Service Worker `notificationclick` Action. + +```typescript +// src/sw.ts +self.addEventListener('notificationclick', (event) => { + if (event.action === 'reply' && event.reply) { + const { roomId, threadId } = event.notification.data; + const session = sessions.get(event.clientId); + 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, + }), + }); + } +}); +``` + +--- + +### P5-30 Β· Advanced ML Noise Suppression β€” Model Roadmap + +See shipped implementation in LOTUS_FEATURES.md β†’ "Noise Suppression (Advanced Multi-Tier)". + +**Models status:** +- **RNNoise** (sapphi, 48 kHz) β€” βœ… working, default fallback. Keep β€” runs on any hardware. +- **Speex** (sapphi, 48 kHz) β€” βœ… working, low value; candidate to drop. +- **DTLN** (@workadventure, 16 kHz) β€” 🟑 wired; sample-rate fix applied (was robotic at 48 kHz). **TODO: verify in a real call.** Narrowband (16 kHz) = slightly telephone-y even when correct. + +**Constraints:** client-side AudioWorklet, fully self-hosted, no GPU, self-hosted SFU (no LiveKit Cloud). + +**Roadmap:** +- [ ] Verify DTLN 16 kHz fix in a real call. +- [ ] **DeepFilterNet 3** β€” best self-hostable upgrade: Rustβ†’WASM, CPU real-time, 48 kHz fullband. Self-host `df_bg.wasm` + DFN3 ONNX model; wire a 48 kHz worklet. Audio quality unverifiable without a real-call test. +- [ ] **Desktop-only / HW-gated:** FRCRN (Alibaba) or NVIDIA Maxine (RTX/Tensor only). Runs in Tauri Rust backend + bridges a virtual mic into the webview. Must detect capability; web + weak HW falls back to RNNoise/DTLN. + +--- + +### P5-31 Β· Granular Voice & Screenshare Quality Controls + +**Mechanism:** WebRTC Encoding Parameters + Backend Quality Guard. + +- **State Event:** `io.lotus.room_quality` (state key `""`) containing: + ```json + { "audio_bitrate": 128000, "screen_max_res": "1080p", "screen_max_fps": 60 } + ``` +- **Screenshare:** In `src/app/plugins/call/CallControl.ts`, map the "Quality" setting to `getDisplayMedia` constraints. +- **Audio Bitrate:** After the call joins, find the `RTCRtpSender` for the audio track: + ```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); + ``` +- **Backend Sidecar:** Extend `voice-limit-guard.py` (LXC 151) to fetch `io.lotus.room_quality` and inject limits into the LiveKit JWT or return them as an authorized config packet. + +--- + +### P5-40 Β· Desktop β€” Proactive Update Notifications (Tauri) + +**Key Files:** `src/app/hooks/useTauriUpdater.ts`, `src/app/pages/client/ClientNonUIFeatures.tsx`, `src/app/features/toast/LotusToastContainer.tsx`. + +1. Create a `TauriUpdateFeature` component. Use `useTauriUpdater()` to get the `check` function and `status`. +2. In a `useEffect`, call `check()` on mount and then on a `setInterval` (every 12 hours). +3. When status transitions to `{ state: 'available', version: '...' }`, fire a Lotus Toast: "Lotus Chat v[version] is available!" with an "Update" button that calls `install()`. +4. Store `lastCheck` timestamp in `localStorage` to prevent redundant checks on refresh. + +--- + +### Mobile Bookmarks Visibility Fix + +**Issue:** `ClientLayout.tsx` explicitly restricts `BookmarksPanel` to `ScreenSize.Desktop` (lines 51-56). + +```tsx +// ClientLayout.tsx +{bookmarksOpen && ( + setBookmarksOpen(false)} + isMobile={screenSize !== ScreenSize.Desktop} + /> +)} +``` + +`BookmarksPanel.tsx` already supports the `isMobile` prop (line 127) to enable full-screen absolute positioning. No other changes required. + +--- + +### Remind Me Later (Slack-style) + +**Mechanism:** Account Data + Timer/Service Worker. + +- **Storage (`src/app/hooks/useReminders.ts`):** Store in account data `io.lotus.reminders` as `Array<{ id: string, roomId: string, eventId: string, timestamp: number }>`. +- **Context Menu (`src/app/features/room/message/MessageContextMenu.tsx`):** Add "Remind me" option β†’ opens date/time picker modal (reuse `JumpToTime.tsx` logic). +- **Trigger (foreground):** `setTimeout` in a hook inside `ReminderMonitor` in `ClientNonUIFeatures.tsx` β†’ pushes to `toastQueueAtom` in `state/toast.ts` when due. +- **Trigger (background):** Use Service Worker β€” `setTimeout` in the main thread will not fire when the PWA is suspended. + +--- + +### Mobile Usability Audit β€” Methodology + +1. **Viewport & Touch:** All interactive elements must have at least `44px Γ— 44px` touch targets. Audit for horizontal overflow (horizontal scrolling must be disabled). +2. **Modal Responsiveness:** All modals (Settings, Profile, etc.) MUST cover the full screen on mobile, not float as overlays. +3. **Sidebar / Panels:** On mobile, sidebar panels (Members, Bookmarks, Media) must become full-screen overlays (using a `Drawer` or `Modal` pattern) rather than side-by-side flexbox panels. +4. **Input & Composer:** Ensure the composer doesn't get obscured by the mobile keyboard. Test focus trap and blur behaviors. + +--- + ## Implementation Notes ### ⚠️ TDS DESIGN LAW (repeated here for emphasis) diff --git a/LOTUS_TODO_REFERENCE.md b/LOTUS_TODO_REFERENCE.md deleted file mode 100644 index 14771bc32..000000000 --- a/LOTUS_TODO_REFERENCE.md +++ /dev/null @@ -1,214 +0,0 @@ -# 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(null); - ``` -- **2. Layout (src/app/features/room/Room.tsx):** - Insert the `ThreadPanel` conditionally alongside the `RoomTimeline`. - ```tsx - { - 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 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 ; - 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-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 `` component. Hide this section if `lotusTerminal` is `true`. - -### P5-40 Β· Desktop β€” Proactive Update Notifications (Tauri) - -**Mechanism:** Global Background Check via `useTauriUpdater`. - -- **Objective:** Alert users to app updates without requiring a manual check in settings. -- **Key Files:** - - `src/app/hooks/useTauriUpdater.ts`: Logic source. - - `src/app/pages/client/ClientNonUIFeatures.tsx`: Background mounting point. - - `src/app/features/toast/LotusToastContainer.tsx`: UI for notification. -- **Implementation:** - 1. Create a `TauriUpdateFeature` component. - 2. Use `useTauriUpdater()` to get the `check` function and `status`. - 3. In a `useEffect`, call `check()` on mount and then on a `setInterval` (e.g., every 12 hours). - 4. Watch the `status`. When it transitions to `{ state: 'available', version: '...' }`, trigger an in-app **Lotus Toast**. - 5. The toast should say "Lotus Chat v[version] is available!" with an "Update" button that calls the `install()` function from the hook. - 6. **Persistence:** Store the `lastCheck` timestamp in `localStorage` to ensure the background check doesn't fire redundant commands every time the user refreshes or re-opens the app. - ---- - -## πŸ”Š Audio & Communications - -### 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. diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index 0e394b206..cc88fe60a 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -71,8 +71,8 @@ export const EventReaders = as<'div', EventReadersProps>( style={ lotusTerminal ? { - borderBottom: '1px solid rgba(0,212,255,0.30)', - boxShadow: '0 2px 12px rgba(0,212,255,0.08)', + borderBottom: '1px solid var(--lt-border-color)', + boxShadow: 'var(--lt-box-glow-cyan)', } : undefined } @@ -83,8 +83,8 @@ export const EventReaders = as<'div', EventReadersProps>( style={ lotusTerminal ? { - color: '#00D4FF', - textShadow: '0 0 6px rgba(0,212,255,0.45)', + color: 'var(--lt-accent-cyan)', + textShadow: 'var(--lt-glow-cyan)', letterSpacing: '0.05em', } : undefined @@ -144,8 +144,8 @@ export const EventReaders = as<'div', EventReadersProps>( style={ lotusTerminal ? { - color: '#FFB300', - textShadow: '0 0 5px rgba(255,179,0,0.45)', + color: 'var(--lt-accent-amber)', + textShadow: 'var(--lt-glow-amber)', fontSize: '0.72rem', } : { opacity: 0.6 } diff --git a/src/app/features/bookmarks/BookmarksPanel.tsx b/src/app/features/bookmarks/BookmarksPanel.tsx index bf2b9229a..cf678e86a 100644 --- a/src/app/features/bookmarks/BookmarksPanel.tsx +++ b/src/app/features/bookmarks/BookmarksPanel.tsx @@ -126,9 +126,10 @@ function BookmarkItem({ bookmark, onJump, onRemove }: BookmarkItemProps) { type BookmarksPanelProps = { onClose: () => void; + isMobile?: boolean; }; -export function BookmarksPanel({ onClose }: BookmarksPanelProps) { +export function BookmarksPanel({ onClose, isMobile }: BookmarksPanelProps) { const { bookmarks, removeBookmark } = useBookmarks(); const { navigateRoom } = useRoomNavigate(); const [filter, setFilter] = useState(''); @@ -154,10 +155,14 @@ export function BookmarksPanel({ onClose }: BookmarksPanelProps) { (); const [emojiBoardAnchor, setEmojiBoardAnchor] = useState(); const [forwardOpen, setForwardOpen] = useState(false); + const [remindOpen, setRemindOpen] = useState(false); const { addBookmark, removeBookmark, isBookmarked } = useBookmarks(); const senderDisplayName = @@ -1204,6 +1206,26 @@ export const Message = React.memo( )} + {!mEvent.isRedacted() && mEvent.getId() && ( + } + radii="300" + onClick={() => { + setRemindOpen(true); + closeMenu(); + }} + > + + Remind Me + + + )} {!isThreadedMessage && ( setForwardOpen(false)} /> )} + {remindOpen && mEvent.getId() && ( + setRemindOpen(false)} + /> + )} ); }, diff --git a/src/app/features/room/message/RemindMeDialog.tsx b/src/app/features/room/message/RemindMeDialog.tsx new file mode 100644 index 000000000..d38a7aa97 --- /dev/null +++ b/src/app/features/room/message/RemindMeDialog.tsx @@ -0,0 +1,122 @@ +import React, { useMemo } from 'react'; +import FocusTrap from 'focus-trap-react'; +import { + Box, + Button, + Header, + Icon, + IconButton, + Icons, + Overlay, + OverlayBackdrop, + OverlayCenter, + Text, + config, +} from 'folds'; +import { stopPropagation } from '../../../utils/keyboard'; +import { useReminders } from '../../../hooks/useReminders'; + +type RemindMeDialogProps = { + roomId: string; + eventId: string; + previewText: string; + onClose: () => void; +}; + +function getPresets(): Array<{ label: string; ms: number }> { + const tomorrow = new Date(); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(9, 0, 0, 0); + const tomorrowMs = tomorrow.getTime() - Date.now(); + const timeLabel = tomorrow.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + return [ + { label: 'In 20 minutes', ms: 20 * 60_000 }, + { label: 'In 1 hour', ms: 60 * 60_000 }, + { label: 'In 3 hours', ms: 3 * 60 * 60_000 }, + { label: `Tomorrow at ${timeLabel}`, ms: tomorrowMs }, + ]; +} + +export function RemindMeDialog({ roomId, eventId, previewText, onClose }: RemindMeDialogProps) { + const { addReminder } = useReminders(); + // eslint-disable-next-line react-hooks/exhaustive-deps + const presets = useMemo(() => getPresets(), []); + + const handlePick = async (ms: number) => { + await addReminder({ + roomId, + eventId, + timestamp: Date.now() + ms, + message: previewText || 'Reminder', + }); + onClose(); + }; + + return ( + }> + + + +
+ + + Remind Me + + + + +
+ {previewText && ( + + + {previewText} + + + )} + + {presets.map((p) => ( + + ))} + +
+
+
+
+ ); +} diff --git a/src/app/hooks/usePan.ts b/src/app/hooks/usePan.ts index a6ee38810..243f5239a 100644 --- a/src/app/hooks/usePan.ts +++ b/src/app/hooks/usePan.ts @@ -1,4 +1,4 @@ -import { MouseEventHandler, useEffect, useState } from 'react'; +import { MouseEventHandler, useEffect, useRef, useState } from 'react'; export type Pan = { translateX: number; @@ -16,36 +16,39 @@ export const usePan = (active: boolean) => { active ? 'grab' : 'initial', ); + // Track the exact handler references that were passed to addEventListener so + // we can remove them even if the component re-renders or unmounts mid-drag. + const attachedRef = useRef<{ move: (e: MouseEvent) => void; up: (e: MouseEvent) => void } | null>( + null, + ); + useEffect(() => { setCursor(active ? 'grab' : 'initial'); }, [active]); - const handleMouseMove = (evt: MouseEvent) => { - evt.preventDefault(); - evt.stopPropagation(); - - setPan((p) => { - const { translateX, translateY } = p; - const mX = translateX + evt.movementX; - const mY = translateY + evt.movementY; - - return { translateX: mX, translateY: mY }; - }); - }; - - const handleMouseUp = (evt: MouseEvent) => { - evt.preventDefault(); - setCursor('grab'); - - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); - }; - const handleMouseDown: MouseEventHandler = (evt) => { if (!active) return; evt.preventDefault(); setCursor('grabbing'); + const handleMouseMove = (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setPan((p) => ({ + translateX: p.translateX + e.movementX, + translateY: p.translateY + e.movementY, + })); + }; + + const handleMouseUp = (e: MouseEvent) => { + e.preventDefault(); + setCursor('grab'); + document.removeEventListener('mousemove', handleMouseMove); + document.removeEventListener('mouseup', handleMouseUp); + attachedRef.current = null; + }; + + attachedRef.current = { move: handleMouseMove, up: handleMouseUp }; document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); }; @@ -54,13 +57,15 @@ export const usePan = (active: boolean) => { if (!active) setPan(INITIAL_PAN); }, [active]); - // Clean up document listeners if component unmounts during an active drag + // Remove listeners if the component unmounts while a drag is in progress. useEffect( () => () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); + if (attachedRef.current) { + document.removeEventListener('mousemove', attachedRef.current.move); + document.removeEventListener('mouseup', attachedRef.current.up); + attachedRef.current = null; + } }, - // eslint-disable-next-line react-hooks/exhaustive-deps [], ); diff --git a/src/app/hooks/useReminders.ts b/src/app/hooks/useReminders.ts new file mode 100644 index 000000000..1619e24fd --- /dev/null +++ b/src/app/hooks/useReminders.ts @@ -0,0 +1,75 @@ +import { useCallback, useEffect, useState } from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; +import { useMatrixClient } from './useMatrixClient'; +import { useAccountDataCallback } from './useAccountDataCallback'; + +export type Reminder = { + roomId: string; + eventId: string; + timestamp: number; + message: string; +}; + +const REMINDERS_KEY = 'io.lotus.reminders'; + +type RemindersContent = { + reminders: Reminder[]; +}; + +function readReminders(mx: MatrixClient): Reminder[] { + return ( + (mx.getAccountData(REMINDERS_KEY as any)?.getContent() as RemindersContent | undefined) + ?.reminders ?? [] + ); +} + +export function useReminders(): { + reminders: Reminder[]; + addReminder: (r: Reminder) => Promise; + removeReminder: (eventId: string, timestamp: number) => Promise; + getReminders: () => Reminder[]; +} { + const mx = useMatrixClient(); + const [reminders, setReminders] = useState(() => readReminders(mx)); + + useAccountDataCallback( + mx, + useCallback( + (evt) => { + if (evt.getType() === REMINDERS_KEY) { + setReminders(evt.getContent()?.reminders ?? []); + } + }, + [setReminders], + ), + ); + + // Re-read on mx change + useEffect(() => { + setReminders(readReminders(mx)); + }, [mx]); + + const addReminder = useCallback( + async (r: Reminder) => { + const current = readReminders(mx); + const next = [...current, r]; + await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next }); + }, + [mx], + ); + + const removeReminder = useCallback( + async (eventId: string, timestamp: number) => { + const current = readReminders(mx); + const next = current.filter( + (r) => !(r.eventId === eventId && r.timestamp === timestamp), + ); + await (mx as any).setAccountData(REMINDERS_KEY, { reminders: next }); + }, + [mx], + ); + + const getReminders = useCallback(() => reminders, [reminders]); + + return { reminders, addReminder, removeReminder, getReminders }; +} diff --git a/src/app/pages/client/ClientLayout.tsx b/src/app/pages/client/ClientLayout.tsx index 3c37a29ee..2f18cc76f 100644 --- a/src/app/pages/client/ClientLayout.tsx +++ b/src/app/pages/client/ClientLayout.tsx @@ -44,10 +44,15 @@ export function ClientLayout({ nav, children }: ClientLayoutProps) { {children} - {bookmarksOpen && screenSize === ScreenSize.Desktop && ( + {bookmarksOpen && ( <> - - setBookmarksOpen(false)} /> + {screenSize === ScreenSize.Desktop && ( + + )} + setBookmarksOpen(false)} + isMobile={screenSize !== ScreenSize.Desktop} + /> )}
diff --git a/src/app/pages/client/ClientNonUIFeatures.tsx b/src/app/pages/client/ClientNonUIFeatures.tsx index 0fbd3198c..16d6a14bc 100644 --- a/src/app/pages/client/ClientNonUIFeatures.tsx +++ b/src/app/pages/client/ClientNonUIFeatures.tsx @@ -36,6 +36,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { usePresenceUpdater } from '../../hooks/usePresenceUpdater'; import { useDeepLinkNavigate } from '../../hooks/useDeepLinkNavigate'; import { toastQueueAtom } from '../../state/toast'; +import { useReminders } from '../../hooks/useReminders'; function isInQuietHours(start: string, end: string): boolean { const now = new Date(); @@ -382,6 +383,50 @@ function DeepLinkNavigator() { return null; } +function ReminderMonitor() { + const mx = useMatrixClient(); + const { reminders, removeReminder } = useReminders(); + const setToast = useSetAtom(toastQueueAtom); + const mDirects = useAtomValue(mDirectAtom); + const firedRef = useRef>(new Set()); + + useEffect(() => { + const check = () => { + const now = Date.now(); + reminders.forEach((r) => { + const key = `${r.eventId}-${r.timestamp}`; + if (r.timestamp <= now && !firedRef.current.has(key)) { + firedRef.current.add(key); + const room = mx.getRoom(r.roomId); + const hashPath = mDirects.has(r.roomId) + ? getDirectRoomPath(r.roomId) + : getHomeRoomPath(r.roomId); + setToast({ + id: `reminder-${key}`, + displayName: 'Reminder', + body: r.message, + roomName: room?.name ?? 'Unknown Room', + roomId: r.roomId, + hashPath, + }); + removeReminder(r.eventId, r.timestamp); + } + }); + }; + + check(); + const interval = setInterval(check, 30_000); + const onVisible = () => { if (document.visibilityState === 'visible') check(); }; + document.addEventListener('visibilitychange', onVisible); + return () => { + clearInterval(interval); + document.removeEventListener('visibilitychange', onVisible); + }; + }, [mx, reminders, setToast, removeReminder, mDirects]); + + return null; +} + function LotusDenoiseFeature() { const setToast = useSetAtom(toastQueueAtom); @@ -417,6 +462,7 @@ export function ClientNonUIFeatures({ children }: ClientNonUIFeaturesProps) { + {children} diff --git a/src/app/styles/CustomHtml.css.ts b/src/app/styles/CustomHtml.css.ts index 304d5b85e..05e0c835d 100644 --- a/src/app/styles/CustomHtml.css.ts +++ b/src/app/styles/CustomHtml.css.ts @@ -117,7 +117,7 @@ export const CodeBlockBottomShadow = style({ pointerEvents: 'none', height: config.space.S400, - background: `linear-gradient(to top, #00000022, #00000000)`, + background: `linear-gradient(to top, ${color.Surface.Container}22, transparent)`, }); const BaseList = style({}); @@ -255,7 +255,7 @@ export const EmoticonImg = style([ export const highlightText = style([ DefaultReset, { - backgroundColor: 'yellow', - color: 'black', + backgroundColor: color.Warning.Container, + color: color.Warning.OnContainer, }, ]);