diff --git a/.agents/AGENTS.md b/.agents/AGENTS.md new file mode 100644 index 000000000..55265ce03 --- /dev/null +++ b/.agents/AGENTS.md @@ -0,0 +1,130 @@ +# Quick Reference for Agents + +Fast lookup guide for file paths and essential patterns. **For detailed workflows, see [agents-workflow.md](agents-workflow.md)**. + +--- + +## 🌐 Quorum Ecosystem + +This repo is part of a **multi-repo ecosystem**. Data syncs across all clients. + +| Repository | Purpose | URL | +|------------|---------|-----| +| **quorum-desktop** | Web + Electron (this repo) | `github.com/QuilibriumNetwork/quorum-desktop` | +| **quorum-mobile** | React Native + Expo | `github.com/QuilibriumNetwork/quorum-mobile` | +| **quorum-shared** | Shared types, hooks, sync | `github.com/QuilibriumNetwork/quorum-shared` | + +**Before implementing features**: Check if mobile has it → use same shared types for sync compatibility. + +→ **Full Guide**: [Quorum Ecosystem Architecture](docs/quorum-shared-architecture.md) + +--- + +## 📁 Key File Locations + +| Category | Location | Key Files | +|----------|----------|-----------| +| **Primitives** | `src/components/primitives/` | Button, Input, Modal, Text, etc. | +| **Components** | `src/components/` | Business logic components | +| **Hooks** | `src/hooks/` | Custom React hooks | +| **Types** | `src/types/` | TypeScript definitions | +| **Services** | `src/services/` | API calls, utilities | +| **Styling** | `src/styles/` | SCSS variables, themes | +| **Router** | `src/components/Router/` | ModalRouter.tsx | + +## 📱 Platform Detection +```tsx +import { isWeb, isMobile, isElectron } from 'src/utils/platform'; +``` + +## 🎨 Essential Styling +- **Guidelines**: `docs/styling-guidelines.md` ⭐ **READ FIRST** +- **Tailwind**: Simple styles (< 7 classes) +- **SCSS**: Complex/shared styles +- **Variables**: Always use CSS variables, never hex +- **Theme**: `dark` class on `` switches themes + +## ⚡ Essential Patterns + +### Primitive Usage (Platform-Aware) +```tsx +// ALWAYS use primitives for interactions + + + +// TEXT: Platform-specific choice +// Shared (.tsx): Use helpers - , <Paragraph> +// Web-only (.web.tsx): Use Text + as - <Text as="h1"> +// Both typography & legacy props valid long-term +``` + +### Theme Integration +```tsx +import { useTheme } from '../components/primitives/theme'; +const theme = useTheme(); +``` + +### i18n +```tsx +import { Trans } from '@lingui/react/macro'; +<Trans>User text</Trans> +``` + +## 🔧 Development Commands + +```bash +yarn dev # Start dev server (ask user) +yarn build # Build project (you can run) +yarn validate # Type check + lint (run after changes) +yarn lint # Lint only +yarn format # Format (run on modified files) +``` + +### WSL/Windows +```bash +# Get current Windows path and run commands +WINDOWS_PATH=$(pwd | sed 's|^/mnt/\([a-z]\)/|\U\1:/|' | sed 's|/|\\|g') +cmd.exe /c "cd /d $WINDOWS_PATH && yarn validate" +``` + +## 📦 Package Management +**Use `yarn` only** - never `npm` (creates conflicts) + +## 📦 @quilibrium/quorum-shared + +Shared package providing types, hooks, and utilities for Quorum apps (web + mobile). + +| Module | Purpose | Common Import | +|--------|---------|---------------| +| **Types** | Space, Message, Channel, User, etc. | `import type { Space, Message } from '@quilibrium/quorum-shared'` | +| **Storage** | StorageAdapter interface | `import type { StorageAdapter } from '@quilibrium/quorum-shared'` | +| **Sync** | Hash-based delta sync protocol | `import { SyncService, createMemberDigest } from '@quilibrium/quorum-shared'` | +| **Hooks** | React Query hooks | `import { useSpaces, useMessages } from '@quilibrium/quorum-shared'` | +| **Utils** | Logger, encoding, formatting | `import { logger } from '@quilibrium/quorum-shared'` | +| **Crypto** | E2E encryption, Ed448 signing | `import { WasmCryptoProvider } from '@quilibrium/quorum-shared'` | + +→ **Full Guide**: [quorum-shared-architecture.md](docs/quorum-shared-architecture.md) + +## 🎯 Quick Component Lookup + +| Component | Use For | +|-----------|---------| +| `Button` | Interactive buttons | +| `Input` | Text inputs | +| `Modal` | Modal dialogs | +| `Text` / `Title` / `Paragraph` | Text content | +| `FlexRow` / `FlexColumn` | Simple layouts | +| `Container` | Styled wrappers | + +→ **Full API**: `docs/features/primitives/API-REFERENCE.md` + +## 📚 Key Documentation + +- **Workflow Guide**: [agents-workflow.md](agents-workflow.md) +- **Full Index**: [INDEX.md](INDEX.md) +- **Primitives**: `docs/features/primitives/INDEX.md` +- **Styling**: `docs/styling-guidelines.md` + +--- + +_Quick reference only - see [agents-workflow.md](agents-workflow.md) for detailed processes_ diff --git a/.agents/INDEX.md b/.agents/INDEX.md new file mode 100644 index 000000000..bb4001bf4 --- /dev/null +++ b/.agents/INDEX.md @@ -0,0 +1,410 @@ +# Documentation Index + +This is the main index for all documentation, bug reports, and task management. + +## 📖 Documentation + +- [Complete Guide: Cross-Platform React Components for Web + Native](docs/cross-platform-components-guide.md) +- [Component Management & Development Guide](docs/component-management-guide.md) +- [Config Sync System](docs/config-sync-system.md) +- [Cross-Platform Repository Implementation](docs/cross-platform-repository-implementation.md) +- [Cryptographic Architecture](docs/cryptographic-architecture.md) +- [Expo Dev Testing Guide](docs/expo-dev-testing-guide.md) +- [IndexedDB Schema Reference: `quorum_db`](docs/quorum-db-schema.md) +- [Quorum Data Management Architecture](docs/data-management-architecture-guide.md) +- [Quorum Ecosystem Architecture](docs/quorum-shared-architecture.md) +- [Styling Guidelines](docs/styling-guidelines.md) + +### Development +- [Unused Dependencies Analysis](docs/development/unused-dependencies-analysis.md) + +### Features +- [Action Queue](docs/features/action-queue.md) +- [Avatar & Initials System](docs/features/avatar-initials-system.md) +- [Channel and Space Mute System](docs/features/channel-space-mute-system.md) +- [Cross-Platform Theming System](docs/features/cross-platform-theming.md) +- [Delete Confirmation System](docs/features/delete-confirmation-system.md) +- [Desktop Notifications Feature](docs/features/desktop-notifications.md) +- [Dropdown Panels](docs/features/dropdown-panels.md) +- [Input & Textarea Validation Reference](docs/features/input-validation-reference.md) +- [Invite System Documentation](docs/features/invite-system-analysis.md) +- [Kick User System Documentation](docs/features/kick-user-system.md) +- [Mention Pills UI System](docs/features/mention-pills-ui-system.md) +- [Modal Save Overlay System](docs/features/modal-save-overlay.md) +- [Modal System Documentation](docs/features/modals.md) +- [Mute Conversation System](docs/features/mute-conversation-system.md) +- [Mute User System Documentation](docs/features/mute-user-system.md) +- [Notification Indicators System](docs/features/notification-indicators-system.md) +- [Offline Support](docs/features/offline-support.md) +- [ReactTooltip Mobile Support Documentation](docs/features/reacttooltip-mobile.md) +- [Responsive Layout System Documentation](docs/features/responsive-layout.md) +- [Security Architecture](docs/features/security.md) +- [Space Folders](docs/features/space-folders.md) +- [Space Settings Modal - Fixes Section](docs/features/space-settings-fixes-section.md) +- [Toast Notifications](docs/features/toast-notifications.md) +- [Touch Interaction System](docs/features/touch-interaction-system.md) +- [Unified Notification System (Mentions + Replies)](docs/features/mention-notification-system.md) +- [User Config Sync on Existing Accounts](docs/features/user-config-sync.md) +- [🔍 Global Message Search - Implementation Guide & Documentation](docs/features/search-feature.md) + +### Features / Messages +- [Auto-Jump to First Unread Message](docs/features/messages/auto-jump-first-unread.md) +- [Bookmarks Feature](docs/features/messages/bookmarks.md) +- [Client-Side Image Compression & Thumbnail System](docs/features/messages/client-side-image-compression.md) +- [DM Conversation List Previews](docs/features/messages/dm-conversation-list-previews.md) +- [Emoji Picker React - Styling & Customization](docs/features/messages/emoji-picker-react-customization.md) +- [Hash Navigation to Old Messages](docs/features/messages/hash-navigation-to-old-messages.md) +- [Markdown Renderer](docs/features/messages/markdown-renderer.md) +- [Markdown Stripping](docs/features/messages/markdown-stripping.md) +- [Message Highlight System](docs/features/messages/message-highlight-system.md) +- [Message Preview Rendering](docs/features/messages/message-preview-rendering.md) +- [Message Sending Indicator](docs/features/messages/message-sending-indicator.md) +- [Message Signing System](docs/features/messages/message-signing-system.md) +- [Mobile Message Actions Implementation](docs/features/messages/message-actions-mobile.md) +- [New Messages Separator](docs/features/messages/new-messages-separator.md) +- [Pinned Messages Feature](docs/features/messages/pinned-messages.md) +- [YouTube Facade Optimization](docs/features/messages/youtube-facade-optimization.md) + +### Features / Primitives +- [Introduction and Core Concepts](docs/features/primitives/01-introduction-and-concepts.md) +- [Primitives Quick Reference](docs/features/primitives/02-primitives-quick-reference.md) +- [When to Use Primitives vs Raw HTML](docs/features/primitives/03-when-to-use-primitives.md) +- [Web-to-Native Migration Guide](docs/features/primitives/04-web-to-native-migration.md) +- [Primitive Styling Guidelines](docs/features/primitives/05-primitive-styling-guide.md) +- [Primitives API Reference](docs/features/primitives/API-REFERENCE.md) + +### Space Permissions +- [Read-Only Channels System](docs/space-permissions/read-only-channels-system.md) +- [Space Permissions Architecture](docs/space-permissions/space-permissions-architecture.md) +- [Space Roles System](docs/space-permissions/space-roles-system.md) + +## 🐛 Bug Reports + +### Active Issues +- [Config Sync Space Loss Race Condition](bugs/config-sync-space-loss-race-condition.md) +- [DirectMessage Invite Loading Performance Issue](bugs/directmessage-invite-loading-performance.md) +- [Encryption State Evals Causing Config Sync Bloat](bugs/encryption-state-evals-bloat.md) +- [Expired Invite Card Validation Timing Issue](bugs/expired-invite-card-validation-timing.md) +- [JoinSpaceModal "Invalid JSON" Error Due to Network Issues](bugs/joinspacemodal-invalid-json-network-error.md) +- [Kick User Button Remains Enabled After User is Kicked](bugs/kick-user-button-state-after-kick.md) +- [Markdown Line Break Inconsistency](bugs/markdown-line-break-inconsistency.md) +- [Message Hash Navigation Conflict Bug](bugs/message-hash-navigation-conflict.md) +- [MessageDB Context: IndexedDB Platform Compatibility Issue](bugs/messagedb-cross-platform-storage-issue.md) +- [Modal Gesture Handling Technical Debt](bugs/modal-gesture-handling-technical-debt.md) +- [Pinned Messages Panel Button Clicks Bug](bugs/pinned-messages-panel-clicks-and-message-list-disappearing.md) +- [Public Invite Link Intermittent Expiration Bug](bugs/public-invite-link-intermittent-expiration.md) +- [Safari Passkey Session Loss - Random Logout on Browser Restart](bugs/safari-passkey-session-loss-random-logout.md) +- [Space Owner Privacy Limitation](bugs/space-owner-privacy-limitation.md) +- [UserProfile Modal Positioning - Viewport Boundary Detection](bugs/userprofile-modal-positioning-viewport-boundary.md) + +### Solved Issues +- [Action Queue Bypasses SDK Encryption for Identity Keys](bugs/.solved/action-queue-plaintext-private-keys.md) +- [Android 7.0 File Download Fix - Implementation Plan](bugs/.solved/SOLVED_android-file-download-huawei-p9-lite.md) +- [Bug: Auto-Jump to First Unread Message - Blocked by Virtuoso Scroll Positioning](bugs/.solved/auto-jump-unread-virtuoso-scroll-conflict.md) +- [Bug: Mention Dropdown Not Showing When Typing Just `@` or `#`](bugs/.solved/mention-dropdown-not-showing-on-empty-query.md) +- [Bug: Message Scroll Issues - Send Jank & Delete Auto-Scroll](bugs/.solved/message-scroll-jank-on-send.md) +- [Channel/Group Save Race Condition](bugs/.solved/channel-group-save-race-condition.md) +- [Config Save Missing React Query Cache Update Causes Stale allowSync](bugs/.solved/config-save-stale-cache-allowsync.md) +- [Deleted Messages Reappear After Peer Sync](bugs/.solved/deleted-messages-reappear-via-sync.md) +- [DM "Mark All as Read" Context Menu - UI Not Updating Immediately](bugs/.solved/dm-mark-all-read-no-immediate-ui-update.md) +- [DM Offline Navigation Shows Empty View](bugs/.solved/dm-offline-navigation-empty.md) +- [DM Send Fails with "Cannot read properties of undefined (reading 'address')"](bugs/.solved/dm-send-fails-address-undefined.md) +- [DM Shows "Unknown User" After Conversation Starts](bugs/.solved/dm-unknown-user-identity-not-revealed.md) +- [Folder Click to Expand/Collapse Not Working](bugs/.solved/folder-click-expand-not-working.md) +- [FolderEditorModal Race Condition on Auto-Open After Folder Creation](bugs/.solved/folder-editor-modal-race-condition.md) +- [Ghost Message Appears After Deleting a Message](bugs/.solved/ghost-message-after-delete.md) +- [Icon Color Not Saving Issue](bugs/.solved/icon-color-not-saving-issue.md) +- [Kick User UX Improvements](bugs/.solved/kick-user-ux-improvements.md) +- [MobileDrawer Virtuoso Double Scroller CSS Issues](bugs/.solved/mobiledrawer-virtuoso-double-scroller.md) +- [New Messages Separator - Intersection Observer Dismissal Issues](bugs/.solved/new-messages-separator-intersection-observer-issues.md) +- [NewDirectMessage Modal: URL-to-State Conversion](bugs/.solved/SOLVED_newdirectmessage-modal-url-to-state-conversion.md) +- [Offline Navigation Issues](bugs/.solved/offline-navigation-issues.md) +- [Performance Issue: UserSettingsModal Save Times Scale Poorly with Number of Spaces](bugs/.solved/usersettingsmodal-scalability-performance.md) +- [React Hooks Violation: Conditional Return Before Hooks](bugs/.solved/SOLVED_react-hooks-violation-conditional-return.md) +- [Solved Search Results Page Refresh And Focus Stealing](bugs/.solved/SOLVED_search-results-page-refresh-and-focus-stealing.md) +- [SOLVED: Fast Refresh Export Incompatibility](bugs/.solved/SOLVED_fast-refresh-export-incompatibility.md) +- [SOLVED: Modal NavMenu Z-Index Stacking Issue](bugs/.solved/SOLVED_modal-navmenu-zindex-stacking.md) +- [SOLVED: Right Sidebar Overlay Issue - Navbar Still Visible](bugs/.solved/SOLVED_right-sidebar-overlay.md) +- [Space Creation Config Save Race Condition](bugs/.solved/space-creation-config-save-race-condition.md) +- [Tooltip Flickering at Top-Left on Fast Mouse Movement](bugs/.solved/SOLVED_tooltip-flickering-top-left-on-fast-mouse-movement.md) +- [Triple Ratchet State Save Destroys Template/Evals Fields](bugs/.solved/triple-ratchet-state-save-destroys-invite-fields.md) +- [user:kick Role Permission is Non-Functional for Non-Owners](bugs/.solved/user-kick-role-permission-non-functional.md) +- [WebSocket processQueue Stuck - Blocking Outbound Messages](bugs/.solved/websocket-processqueue-stuck-blocking-outbound.md) + +### .Archived +- [Brave Browser React Hook Errors - ONGOING INVESTIGATION](bugs/.archived/brave-browser-react-hook-errors.md) +- [Bug: Auto-jump to First Unread Breaks Initial Message Sync](bugs/.archived/auto-jump-unread-breaks-message-sync.md) +- [Role with message:delete permission can delete space owner's messages](bugs/.archived/message-delete-owner-vulnerability.md) +- [Space Owner Delete Permissions Bug](bugs/.archived/space-owner-delete-permissions-bug.md) + +## 📋 Tasks + +### Pending Tasks + +- [Add User Bio Field to UserSettingsModal General Tab](tasks/add-user-bio-field.md) +- [Conversation Deletion State Synchronization](tasks/conversation-deletion-state-sync.md) +- [Date Translation with Moment.js Locale Configuration](tasks/i18n-date-translation-moment-locale.md) +- [Device Identification Improvement](tasks/device-identification-improvement.md) +- [Fix Lingui "Uncompiled Message Detected" Warning](tasks/fix-lingui-uncompiled-message-warning.md) +- [Full Markdown Support Inside Spoilers](tasks/spoiler-full-markdown-support.md) +- [Implement Channel Ordering Feature](tasks/channel-ordering-feature.md) +- [Implement Comprehensive Accessibility Features](tasks/accessibility-features-implementation.md) +- [Implement Message Forwarding with Privacy Controls](tasks/message-forwarding-with-privacy-controls.md) +- [Implement New Member Badge in Spaces](tasks/new-member-badge-spaces.md) +- [Implement Role-Based Channel Visibility](tasks/channel-role-visibility.md) +- [Implement Smart Context Extraction Around Mentions in NotificationPanel](tasks/notification-mention-context-extraction.md) +- [Multi-User Invite Selection Enhancement](tasks/multi-user-invite-selection.md) +- [Passkey Import UX Issue](tasks/passkey-import-ux-issue.md) +- [Primitives Migration to quorum-shared](tasks/primitives-migration-to-quorum-shared.md) +- [Public Space Directory Feature](tasks/public-space-directory.md) +- [Remove Deprecated Enhanced Mention Format Backward Compatibility Code](tasks/remove-enhanced-mention-format-compat.md) +- [Remove Text Primitive from Web App](tasks/remove-text-primitive-web.md) +- [SDK PasskeyModal Customization Props Enhancement](tasks/sdk-passkey-modal-customization-props.md) +- [Service Worker for App Update Detection](tasks/service-worker-app-updates.md) +- [Show Users Who Reacted (Tooltip + Modal)](tasks/show-reaction-users-tooltip.md) +- [Space Tags](tasks/space-tags.md) +- [Task: Implement Delete Public Invite Link Feature](tasks/delete-public-invite-link.md) +- [Test Suite Implementation Plan for Quorum Desktop](tasks/test-suite-plan.md) +- [Tombstone Cleanup Strategy for Deleted Messages](tasks/tombstone-cleanup-strategy.md) +- [User Data Backup & Restore Feature](tasks/user-data-backup-restore-feature.md) +- [User Status Feature Implementation Plan](tasks/user-status.md) + +### .Archived +- [🚀 Search Performance Optimization - Revised Implementation Plan](tasks/.archived/2025-11-12-search-performance-optimization-original.md) +- [Auto-Jump to First Unread Message + Fix Hash Navigation](tasks/.archived/auto-jump-first-unread.md) +- [Background Action Queue with Web Worker Crypto](tasks/.archived/background-action-queue-with-worker-crypto.md) +- [Cache Space Key Metadata to Reduce Config Save Overhead](tasks/.archived/config-save-space-key-caching.md) +- [ClickToCopyContent Component Analysis](tasks/.archived/click-to-copy-content-analysis.md) +- [Cross-Platform Key Backup Implementation](tasks/.archived/cross-platform-key-backup.md) +- [Font Size Management Analysis & Recommendations](tasks/.archived/font-size-management-analysis-2025.md) +- [Modal System Consolidation via Route Wrapper Component](tasks/.archived/modal-system-consolidation.md) +- [Optimize Mention Interactivity Disable Feature](tasks/.archived/optimize-mention-interactivity-disable-feature.md) +- [Research: Mobile Markdown Formatting Options](tasks/.archived/markdown-toolbar-editor_mobile.md) +- [Search Performance Optimization - Architecture Analysis Report](tasks/.archived/search-performance-optimization_ANALYSIS.md) +- [Search Performance Optimization - Executive Summary](tasks/.archived/search-performance-analysis_SUMMARY.md) +- [Shared Git Hooks Setup](tasks/.archived/shared-git-hooks-setup.md) +- [Space/Channel Message Deletion Placeholders](tasks/.archived/space-message-deletion-placeholders.md) +- [Task: Fix Search Results Limitation and Pagination](tasks/.archived/search-results-limitation-and-navigation-fix.md) +- [Text Primitive Analysis: Typography Prop & Helpers Confusion](tasks/.archived/text-primitive-analysis.md) +- [Tooltip Shared Mode for NavMenu Flickering Fix](tasks/.archived/tooltip-shared-mode-navmenu-flickering.md) +- [Unify Modal Systems: Migrate Layout-Level Modals to ModalProvider](tasks/.archived/modal-system-unification.md) + +### .Archived Css Refactor +- [CSS Inventory - Complete Codebase Analysis](tasks/.archived/css-refactor/css-inventory.md) +- [CSS Refactor Analysis Report](tasks/.archived/css-refactor/analysis.md) +- [CSS Refactor Plan - Claude Code Execution Strategy](tasks/.archived/css-refactor/css-refactor-plan.md) + +### Docusaurus +- [Docusaurus Developer Documentation Blueprint](tasks/docusaurus/docusaurus-blueprint-v2.md) +- [Docusaurus Quorum Users Faq](tasks/docusaurus/docusaurus-quorum-users-faq.md) +- [Docusaurus Setup & Implementation Task](tasks/docusaurus/docusaurus-setup-implementation.md) + +### Messagedb +- [High-Risk Optimization Opportunities](tasks/messagedb/messagedb-optimization-3.md) +- [Low/Medium Risk Optimization Opportunities](tasks/messagedb/messagedb-optimization-1.md) +- [MessageDB Refactoring - Current State](tasks/messagedb/messagedb-current-state.md) +- [MessageService.ts Analysis](tasks/messagedb/messageservice-analysis.md) + +### Messagedb .Archived +- [handleNewMessage Integration Tests Guide](tasks/messagedb/.archived/messageservice-handlenewmessage-tests.md) +- [handleNewMessage Refactoring Plan](tasks/messagedb/.archived/messageservice-handlenewmessage-refactor.md) +- [MessageDB: Optimization Plan (Phase 4)](tasks/messagedb/.archived/messagedb-optimization-2.md) + +### Messagedb .Done +- [Extract encryptAndSendToSpace() Helper](tasks/messagedb/.done/messageservice-extract-encrypt-helper.md) + +### Mobile Dev +- [Business Logic Extraction & Native Preparation Plan](tasks/mobile-dev/business-logic-extraction-plan.md) +- [Component Architecture Masterplan - Desktop/Mobile Unification](tasks/mobile-dev/components-shared-arch-masterplan.md) +- [Cross-Platform Development : Revised Plan](tasks/mobile-dev/plan-quick-recap.md) +- [Cross-Platform Hooks Refactoring Plan](tasks/mobile-dev/cross-platform-hooks-refactoring-plan.md) +- [CSS to Mobile Colors Sync Script](tasks/mobile-dev/css-to-mobile-colors-sync.md) +- [File Upload Hooks Consolidation Task](tasks/mobile-dev/file-upload-hooks-consolidation.md) +- [Mobile Development Plan - Improved Version](tasks/mobile-dev/mobile-dev-plan.md) +- [Mobile Image Compression Implementation](tasks/mobile-dev/mobile-image-compression.md) +- [Mobile Internationalization (i18n) Implementation Plan](tasks/mobile-dev/internationalization-i18n-implementation-plan.md) +- [Mobile/Touch Implementation Transition Plan](tasks/mobile-dev/mobile-touch-transition-plan.md) +- [Native Business Components Implementation Plan](tasks/mobile-dev/native-business-components-plan.md) +- [Primitive Migration Audit Report](tasks/mobile-dev/primitive-migration-audit.md) +- [Quilibrium SDK Mobile Integration Issue](tasks/mobile-dev/mobile-sdk-integration-issue.md) +- [React Native Upgrade Risk Assessment](tasks/mobile-dev/upgrade-to-react-80.md) +- [Sdk Shim Temporary Solutions](tasks/mobile-dev/sdk-shim-temporary-solutions.md) + +### Mobile Dev Docs +- [Component Architecture Workflow - Detailed Explanation](tasks/mobile-dev/docs/component-architecture-workflow-explained.md) +- [Mobile/Desktop Behavioral Differences Audit Plan](tasks/mobile-dev/docs/mobile-desktop-audit.md) +- [Primitives Testing Guide](tasks/mobile-dev/docs/primitives-testing.md) +- [Third-Party Component Migration Report](tasks/mobile-dev/docs/third-party-component-migration-report.md) +- [Web/Native Repository Structure](tasks/mobile-dev/docs/web-and-native-repo-structure.md) + +### Search Optimization +- [Design Decisions & Rationale](tasks/search-optimization/decisions.md) +- [Future Search Optimization Phases](tasks/search-optimization/future-phases.md) +- [Quick Wins - Search Improvements](tasks/search-optimization/quick-wins.md) +- [Search Performance Optimization](tasks/search-optimization/README.md) + +## 📋 Completed Tasks + +- [AccentColorSwitcher Cross-Platform Migration + Persistence](tasks/.done/accent-color-switcher-cross-platform-migration.md) +- [Add Context to Desktop Notifications](tasks/.done/rich-desktop-notifications-context.md) +- [Add DM-Specific Action Queue Handlers](tasks/.done/dm-action-queue-handlers.md) +- [Add Unified Context Menu System for Sidebar Items](tasks/.done/dm-conversation-context-menu.md) +- [Add YAML Frontmatter to Knowledge Base System](tasks/.done/yaml-frontmatter-knowledge-base.md) +- [Analysis Report & Implementation Plan: Strategic Portal Integration for Overlay Components (v7)](tasks/.done/modal-portal-refactor-analysis.md) +- [Android 7.0 File Download Fix - Implementation Plan](tasks/.done/FAILED_android-file-download-fix.md) +- [Background Action Queue](tasks/.done/background-action-queue.md) +- [Callout Primitive Audit Report](tasks/.done/callout-primitive-audit.md) +- [Callout Primitive System](tasks/.done/callout-primitive-system.md) +- [Channel & Group Icon Selection Feature](tasks/.done/channel-group-icon-selection.md) +- [Channel Business Logic Extraction - Lessons Learned](tasks/.done/channel-business-logic-extraction-lessons.md) +- [Channel Mute System](tasks/.done/channel-mute-system.md) +- [Client-Side Image Processing for Large File Uploads](tasks/.done/client-side-image-processing.md) +- [Color Switcher Implementation](tasks/.done/color-switcher.md) +- [Combined Text + Image Messages](tasks/.done/combined-text-image-messages.md) +- [Complex Modal Primitive Migration Template](tasks/.done/usersettingsmodal-primitive-migration.md) +- [CRITICAL: Improve MessageDB Test Coverage for Refactoring Safety](tasks/.done/messagedb-unit-tests.md) +- [Cross-Platform Component Audit](tasks/.done/components-audit.md) +- [Cross-Platform Key Backup Implementation](tasks/.done/cross-platform-key-backup.md) +- [Custom ContentEditable Mention Pills for Message Composer](tasks/.done/mention-pills-in-message-textarea.md) +- [Date Separators UI Enhancement](tasks/.done/date-separators-messages-list.md) +- [Delete Confirmation System Implementation](tasks/.done/delete-confirmation-system.md) +- [Direct Message Deletion Feature](tasks/.done/dm-message-deletion.md) +- [Direct Message Features Comparison Analysis](tasks/.done/direct-message-features-comparison.md) +- [DirectMessage Navigation State Persistence](tasks/.done/dm-navigation-state-persistence.md) +- [DM Contacts Search and Filtering Feature](tasks/.done/dm-contacts-search-and-filtering.md) +- [DM Unread Indicators - Exclude Current User's Messages](tasks/.done/dm-unread-indicators-sender-filtering.md) +- [Dropdown Panels Consolidation & Mobile Bottom Sheet Implementation](tasks/.done/dropdown-panels-consolidation.md) +- [Edit Message Feature](tasks/.done/edit-message-feature.md) +- [Emoji Picker Responsive Design Improvement](tasks/.done/emojipicker-responsive.md) +- [Enhance Markdown Renderer Fallback System](tasks/.done/markdown-renderer-fallback-system-enhancement.md) +- [Enhanced Mention Format with CSS Highlighting for Message Composer](tasks/.done/mentions-improve-UX-in-messagecomposer.md) +- [Expo Go to Expo Dev Client Migration Plan - Simplified](tasks/.done/expo-go-to-dev-client-migration.md) +- [Extend Desktop Notifications to Space Mentions and Replies](tasks/.done/extend-desktop-notifications-to-space-mentions-replies.md) +- [Fix False Desktop Notifications in Background Tabs](tasks/.done/fix-false-desktop-notifications-background-tab.md) +- [Fix Mention Interactivity Consistency](tasks/.done/fix-mention-interactivity-consistency.md) +- [Fix Message Length Limit Bypass Vulnerability](tasks/.done/fix-message-length-bypass-vulnerability.md) +- [Fix Own Messages Counted as Unread](tasks/.done/fix-own-messages-counted-as-unread.md) +- [Fix Read-Only Channel Bypass Vulnerability](tasks/.done/fix-read-only-channel-bypass-vulnerability.md) +- [Fix SpaceIcon Notification Bubble Cache Invalidation](tasks/.done/spaceicon-notification-cache-invalidation.md) +- [Fix: React Query "undefined" Error in DM Conversations](tasks/.done/fix-react-query-undefined-error-in-dms.md) +- [Implement Channel Mention Feature (#channelname)](tasks/.done/channel-mention-feature.md) +- [Implement Collapsible Role Groups in Channel Members Sidebar](tasks/.done/collapsible-role-groups-sidebar.md) +- [Implement Copy Message Raw Text Option](tasks/.done/copy-message-text.md) +- [Implement Discord-Style Message Grouping with Compact Headers](tasks/.done/message-grouping-compact-headers.md) +- [Implement Dm Unread Features](tasks/.done/implement-dm-unread-features.md) +- [Implement Mention Notification Bubbles](tasks/.done/mention-notification-bubbles.md) +- [Implement Message Sending Indicator](tasks/.done/message-sending-indicator.md) +- [Implement Per-Space Display Names and Avatars with Account Section](tasks/.done/per-space-display-name-avatar.md) +- [Implement Spoiler Syntax (||text||) in Markdown Renderer](tasks/.done/spoiler-syntax-markdown-renderer.md) +- [Implement User Mute Feature](tasks/.done/implement-mute-feature.md) +- [Implementation Plan: Repository Restructure for Cross-Platform Development](tasks/.done/implementation-plan-repo-restructure.md) +- [Improve Mention Dropdown UX - Show Immediately on @ or #](tasks/.done/mention-dropdown-immediate-display.md) +- [Jump to Present Button](tasks/.done/jump-to-present-button-messages-list.md) +- [Lazy Loading Implementation for Message Lists](tasks/.done/lazy-loading-implementation.md) +- [Markdown Toolbar Editor](tasks/.done/markdown-toolbar-editor.md) +- [Mention Counts Performance Optimization](tasks/.done/mention-counts-performance-optimization.md) +- [Mention Highlight Duration - Extended to 60 Seconds](tasks/.done/mention-highlight-60s-duration.md) +- [Mention Notification Settings - Phase 4](tasks/.done/mention-notification-settings-phase4.md) +- [Mention Notification System - Critical Race Condition Fix](tasks/.done/mention-notification-ux-improvements.md) +- [Mention Pills Code Abstraction & Refactoring](tasks/.done/mention-pills-abstraction-refactor.md) +- [Mention Rendering: Word Boundary Analysis](tasks/.done/mention-rendering-word-boundary-analysis.md) +- [Message Bookmarking Feature](tasks/.done/message-bookmarking-feature.md) +- [Message Context Menu System](tasks/.done/message-context-menu-system.md) +- [Message Handling Report](tasks/.done/messages-report.md) +- [Message Highlight System - Complete Analysis & Recommendations](tasks/.done/message-highlight-system-optimization.md) +- [Message Link Rendering (Discord-style)](tasks/.done/message-link-rendering.md) +- [Message Markdown Support Implementation](tasks/.done/message-markdown-support.md) +- [Message Rate Limiting with User Experience Feedback](tasks/.done/message-rate-limiting-with-ux-feedback.md) +- [MessageDB Circular Dependency Status](tasks/.done/messagedb-circular-dependecy-status.md) +- [MessageDB.tsx Refactoring Analysis and TAsk](tasks/.done/DONE_messagedb-refactoring.md) +- [Mobile Message Options UX Enhancement](tasks/.done/message-options-mobile.md) +- [Modal Animation Consistency Implementation](tasks/.done/new-modals-effect.md) +- [Modal Migration Analysis & Progress Report](tasks/.done/DONE_modal-migration-plan.md) +- [Mute DM Conversation](tasks/.done/mute-dm-conversation.md) +- [Notification Dropdown/Inbox UI Implementation](tasks/.done/notification-inbox-ui.md) +- [Offline Detection and Optimistic Message Reliability](tasks/.done/offline-detection-and-optimistic-message-reliability.md) +- [Per-Space Profile Data Flow Analysis](tasks/.done/per-space-profile-data-flow-analysis.md) +- [Persist DM Contact Profile from Message Data](tasks/.done/dm-contact-profile-refresh.md) +- [Phase 1 Complete: Component Audit Infrastructure & Initial Analysis](tasks/.done/components-audit-dashboard.md) +- [Pinned Messages Cross-Client Synchronization](tasks/.done/pinned-messages-sync.md) +- [Pinned Messages Feature - Implementation Plan](tasks/.done/pinned-messages-feature.md) +- [Primitives System Optimization](tasks/.done/primitives-optimization.md) +- [Public Invite Links Improvement Task](tasks/.done/invite-links-improve.md) +- [ReactTooltip Mobile Optimization](tasks/.done/reacttoolltip-mobile.md) +- [Read-Only Channels with Role Managers](tasks/.done/read-only-channels-with-role-managers.md) +- [Refactor Message Highlighting System](tasks/.done/message-highlight-system-refactor.md) +- [Refactor Modal Placement in Layout.tsx](tasks/.done/refactor-modal-placement.md) +- [Rehype-Raw Security Fix - Complete Recap](tasks/.done/rehype-raw-security-fix-recap-2025-11-07.md) +- [Remove ResponsiveContainer Primitive and Inline Styles](tasks/.done/layout-css-grid-refactor.md) +- [Rename .agents to .agents](tasks/.done/rename-readme-to-agents.md) +- [Replace FontAwesome with Tabler Icons](tasks/.done/replace-fontawesome-with-tabler-icons.md) +- [Reply Notification System](tasks/.done/reply-notification-system.md) +- [Responsive Layout Implementation Plan](tasks/.done/responsive-layout.md) +- [Responsive Layout Requirements](tasks/.done/responsive-layout_requirements.md) +- [Role Mention Notifications Implementation](tasks/.done/role-mention-notifications.md) +- [Scroll Performance Optimization](tasks/.done/scroll-performance-optimization.md) +- [Search Components Primitive Migration](tasks/.done/search-primitive-migration.md) +- [Security Analysis: MessageMarkdownRenderer Component](tasks/.done/security-analysis-message-markdown-renderer-2025-11-07.md) +- [Space Folders - Discord-Style Grouping Feature](tasks/.done/space-folders-discord-style.md) +- [Space Folders: Toggle Indicator Fix for Spaces Inside Folders](tasks/.done/space-folders-toggle-indicator-fix.md) +- [Space Icon Mention Bubbles](tasks/.done/space-icon-mention-bubbles.md) +- [Space Initials Fallback - Optional Space Images](tasks/.done/space-initials-fallback.md) +- [Space Recovery Tool in UserSettingsModal](tasks/.done/space-recovery-tool.md) +- [Sync Toast Notifications](tasks/.done/sync-toast-notifications.md) +- [Tailwind @apply Conversion Audit](tasks/.done/tailwind-apply-conversion-audit.md) +- [Task: Enhanced Direct Message Conversation List](tasks/.done/dm-conversation-list-preview.md) +- [Task: Fix Hash Navigation to Old Messages (#msg-messageId Pattern)](tasks/.done/fix-hash-navigation-to-old-messages.md) +- [Task: Improve User Mention System](tasks/.done/improve-user-mention-system.md) +- [Task: Markdown Stripping Utility](tasks/.done/markdown-stripping-utility.md) +- [Text Styling Consolidation](tasks/.done/text-styling-consolidation-plan.md) +- [Toast System - Simple Bug Fixes](tasks/.done/toast-system-simple-fix.md) +- [Toast System Refactoring](tasks/.done/toast-system-refactoring.md) +- [UI Folder Reorganization Task](tasks/.done/ui-folder-reorganization.md) +- [Unify Unread Message Indicators: Channels & DMs](tasks/.done/unify-unread-indicators-channels-dms.md) +- [Unread Message Visual Indicators Implementation](tasks/.done/unread-message-visual-indicators.md) +- [User Config Sync on Existing Accounts](tasks/.done/user-config-sync-on-existing-accounts.md) +- [User Initials Avatars](tasks/.done/user-initials.md) +- [XSS Prevention - Final Implementation](tasks/.done/xss-prevention-final-implementation-2025-11-08.md) +- [🎯 Objective: Implement Global Message Search](tasks/.done/global-search-plan.md) +- [🔍 Global Message Search Implementation Plan](tasks/.done/global-search.md) +- [🔍 Users List Filtering Feature](tasks/.done/users-list-filtering-feature.md) + +## 📊 Reports + +### Active Reports + +- [Channel Ordering Feature Analysis](reports/channel-ordering-feature-analysis_2026-01-07.md) +- [Comprehensive Privacy & Security Audit - All Features](reports/privacy-security-comprehensive-audit_2025-12-21.md) +- [Cryptographic Code Best Practices: Abstraction vs Duplication](reports/cryptographic-code-best-practices_2025-12-20.md) +- [dnd-kit Touch Drag-and-Drop Best Practices Research](reports/dnd-kit-touch-best-practices_2025-12-11.md) +- [File Size & Service Extraction Best Practices](reports/file-size-best-practices_2025-12-20.md) +- [Mention Pills Feature Research & Validation](reports/mention-pills-research.md) +- [Primitives Gap Analysis: quorum-shared Migration](reports/primitives-gap-analysis-quorum-shared_2026-01-14.md) +- [Quorum Privacy Analysis](reports/privacy-analysis-quorum-2025-12-03.md) +- [quorum-shared Migration Analysis](reports/quorum-shared-migration-analysis_2026-01-05.md) +- [Security Audit: Markdown Renderer & Mentions System](reports/security-audit-markdown-mentions-2025-11-18.md) +- [Sync Optimizations Report](reports/sync-optimizations_2025-12-19.md) +- [Web-to-Native Feature Sync Compatibility Analysis](reports/web-native-feature-sync-analysis_2025-12-04.md) + +### Action Queue +- [Action Queue Summary](reports/action-queue/000-action-queue-summary.md) +- [DM "Sending..." Indicator Hangs Indefinitely](reports/action-queue/001-dm-sending-indicator-hang.md) +- [WebSocket Queue Starvation Causes DM Delays](reports/action-queue/002-websocket-queue-starvation.md) +- [Action Queue vs Legacy DM Message Implementation - Code Comparison Audit](reports/action-queue/003-DM-message-code-comparison-audit.md) +- [Action Queue vs Legacy Space Message Implementation - Code Comparison Audit](reports/action-queue/004-space-message-code-comparison-audit.md) +- [DM Sync Non-Deterministic Failures](reports/action-queue/005-dm-sync-non-deterministic-failures.md) +- [006: Action Queue Stores Identity Keys in Plaintext](reports/action-queue/006-plaintext-private-keys-bug.md) +- [007: Fix - Don't Store Keys in Action Queue](reports/action-queue/007-plaintext-private-keys-fix.md) +- [008: Action Queue Endpoint Dependencies & Failure Modes](reports/action-queue/008-endpoint-dependencies.md) +- [009: DM Offline Support - Conditional Action Queue Routing](reports/action-queue/009-dm-offline-registration-persistence-fix.md) +- [DM Messages Not Delivered - Device Registration Inbox Mismatch](reports/action-queue/010-dm-registration-inbox-mismatch-fix.md) +- [DM Debug Console Snippets](reports/action-queue/011-dm-debug-console-snippets.md) + +### Onboarding Flow +- [Passkey Authentication Flow Analysis](reports/onboarding-flow/passkey-authentication-flow-analysis-2025-11-23.md) +- [Passkey Flow - Simplified User Journey](reports/onboarding-flow/passkey-flow-simplified-2025-11-23.md) +- [Quorum Authentication Flows](reports/onboarding-flow/new-onboarding-flow-inline-passkey-2025-12-08.md) + +--- + +**Last Updated**: 2026-01-14 19:56:49 \ No newline at end of file diff --git a/.agents/agents-workflow.md b/.agents/agents-workflow.md new file mode 100644 index 000000000..b211eba61 --- /dev/null +++ b/.agents/agents-workflow.md @@ -0,0 +1,590 @@ +# Agents Workflow Guide + +Best practices for AI-assisted development on the Quorum Desktop project. + +--- + +## Table of Contents + +1. [Before Starting Any Task](#before-starting-any-task) +2. [Common Workflows](#common-workflows) +3. [Key Resources by Task Type](#key-resources-by-task-type) +4. [Documentation Guidelines](#documentation-guidelines) +5. [Tips for Effective AI Development](#tips-for-effective-ai-development) +6. [Common Pitfalls](#common-pitfalls) + +--- + +## Before Starting Any Task + +### Step 1: Check Quick Reference + +**Always start here**: [AGENTS.md](AGENTS.md) + +This file contains: +- File paths for all major components +- Common architectural patterns +- Quick component lookup table +- Development command reference + +### Step 2: Search Documentation Index + +**Navigate to**: [INDEX.md](INDEX.md) + +Use browser search (Ctrl+F) to find relevant documentation: +- Search for feature names (e.g., "modal", "search", "theming") +- Search for component names (e.g., "Button", "Input") +- Search for file paths (e.g., "MessageDB", "primitives") + +### Step 3: Review Related Documentation + +Before implementing, read relevant docs: +- **For UI work**: Check `docs/features/primitives/` folder +- **For features**: Check `docs/features/` folder +- **For bugs**: Check `bugs/` folder for similar issues +- **For architecture**: Check `docs/` root-level guides + +--- + +## Common Workflows + +### Adding a New Feature + +**Steps**: +1. ✅ Check if similar feature exists in `docs/features/` or `tasks/.done/` +2. ✅ Review primitives documentation if UI involved +3. ✅ Read cross-platform architecture guide +4. ✅ Implement following mobile-first patterns +5. ✅ Test on both web and mobile (if applicable) +6. ✅ Document if pattern is reusable + +**Key Resources**: +- [Cross-Platform Components Guide](docs/cross-platform-components-guide.md) +- [Primitives INDEX](docs/features/primitives/INDEX.md) +- [API Reference](docs/features/primitives/API-REFERENCE.md) + +--- + +### Fixing a Bug + +**Steps**: +1. ✅ Search `bugs/` for existing reports +2. ✅ Check `bugs/.solved/` for similar past issues +3. ✅ Document solution approach if non-trivial +4. ✅ Move bug report to `.solved/` if documented + +**Key Resources**: +- [Bug Reports](bugs/) - Active issues +- [Solved Issues](bugs/.solved/) - Historical solutions + +--- + +### Refactoring Components + +**Steps**: +1. ✅ Read primitives guides thoroughly +2. ✅ Check migration patterns in `tasks/mobile-dev/` +3. ✅ Test on both platforms after changes +4. ✅ Update related documentation + +**Key Resources**: +- [Primitives Migration Guide](docs/features/primitives/04-web-to-native-migration.md) +- [Component Architecture](docs/cross-platform-components-guide.md) +- [Styling Guide](docs/features/primitives/05-primitive-styling-guide.md) + +--- + +### Working with Modals + +**Steps**: +1. ✅ Read [Modal System Documentation](docs/features/modals.md) +2. ✅ Determine which modal system to use (ModalProvider vs Layout-Level) +3. ✅ Use `ModalContainer` or `Modal` primitives +4. ✅ Follow routing patterns in `Router/ModalRouter.tsx` + +**Key Resources**: +- [Modal System Documentation](docs/features/modals.md) +- [API Reference - Modal](docs/features/primitives/API-REFERENCE.md#modal) +- [API Reference - ModalContainer](docs/features/primitives/API-REFERENCE.md#modalcontainer) + +--- + +### Styling Components + +**Core Rules:** +- **Use Tailwind in JSX** for simple styles (< 7 classes) +- **Use raw CSS in .scss** for complex/shared styles +- **NEVER use `@apply`** (anti-pattern - loses benefits of both) +- **Always use `rem`** instead of `px` (follow Tailwind spacing scale) +- **Always use CSS variables** for colors (never hardcode hex values) + +**Theme System:** +- Light/dark themes controlled via `dark` class on `<html>` +- Accent colors: `accent-50` → `accent-900` (dynamic theming support) +- Surface colors: `surface-00` → `surface-10` +- Text colors: `text-strong`, `text-main`, `text-subtle`, `text-muted` + +**Utility Colors (RGB-based):** +- `danger`, `warning`, `success`, `info` +- Usage: `rgb(var(--danger))` or `rgb(var(--danger) / 0.5)` for opacity +- Tailwind classes: `text-danger`, `bg-danger`, `border-danger` + +**Steps**: +1. ✅ Use semantic CSS variables from `src/index.css` +2. ✅ Apply via Tailwind utilities or component props +3. ✅ Use theme colors via `useTheme()` hook +4. ✅ Test on both light and dark themes +5. ✅ Verify mobile compatibility + +**Key Resources**: +- [Primitive Styling Guide](docs/features/primitives/05-primitive-styling-guide.md) +- [Theming System](docs/features/cross-platform-theming.md) +- [Styling Guidelines](docs/styling-guidelines.md) ⭐ **Complete guide** + +--- + +### Adding Primitives + +**Steps**: +1. ✅ Create folder in `src/components/primitives/ComponentName/` +2. ✅ Add `.web.tsx` and `.native.tsx` versions +3. ✅ Create shared `types.ts` interface +4. ✅ Export from `src/components/primitives/index.ts` +5. ✅ Update [API Reference](docs/features/primitives/API-REFERENCE.md) +6. ✅ Add examples to [Quick Reference](docs/features/primitives/02-primitives-AGENTS.md) + +**Key Resources**: +- [Primitives Introduction](docs/features/primitives/01-introduction-and-concepts.md) +- [When to Use Primitives](docs/features/primitives/03-when-to-use-primitives.md) +- [API Reference Template](docs/features/primitives/API-REFERENCE.md) + +--- + +## Key Resources by Task Type + +### UI Components + +| Task | Primary Documentation | +|------|----------------------| +| Build new component | [Cross-Platform Components Guide](docs/cross-platform-components-guide.md) | +| Use existing primitive | [API Reference](docs/features/primitives/API-REFERENCE.md) | +| Choose primitive vs HTML | [When to Use Primitives](docs/features/primitives/03-when-to-use-primitives.md) | +| Migrate web component | [Migration Guide](docs/features/primitives/04-web-to-native-migration.md) | +| Style component | [Styling Guide](docs/features/primitives/05-primitive-styling-guide.md) | + +### Primitives Reference + +**Quick lookup** - for complete details see [API Reference](docs/features/primitives/API-REFERENCE.md): + +| Component | Use For | +|-----------|---------| +| `Button`, `Input`, `Select`, `Switch` | **Always use** - Interactive elements | +| `Modal`, `ModalContainer` | **Always use** - Modal boundaries | +| `Text`, `Title`, `Paragraph`, `Label` | **Platform-specific** - See [text guidance](docs/features/primitives/03-when-to-use-primitives.md#text-component-decision-framework) | +| `FlexRow`, `FlexColumn`, `Container` | **Case-by-case** - Simple layouts | + +### Modal Systems + +| Task | Primary Documentation | +|------|----------------------| +| Implement modal | [Modal System Documentation](docs/features/modals.md) | +| Choose modal system | [Modal System - Architecture](docs/features/modals.md#modal-system-architecture) | +| Modal props lookup | [API Reference - Modal](docs/features/primitives/API-REFERENCE.md#modal) | + +### Theming & Colors + +| Task | Primary Documentation | +|------|----------------------| +| Theme integration | [Cross-Platform Theming](docs/features/cross-platform-theming.md) | +| Color system usage | [Styling Guide](docs/features/primitives/05-primitive-styling-guide.md) | +| Accent colors | [Theming System](docs/features/cross-platform-theming.md) | + +### Search & Performance + +| Task | Primary Documentation | +|------|----------------------| +| Search implementation | [Search Feature Guide](docs/features/search-feature.md) | +| Performance optimization | [Search Performance Task](tasks/search-performance-optimization.md) | +| MessageDB queries | [Data Management Architecture](docs/data-management-architecture-guide.md) | + +### Mobile Development + +| Task | Primary Documentation | +|------|----------------------| +| Component architecture | [Component Architecture Workflow](tasks/mobile-dev/docs/component-architecture-workflow-explained.md) | +| Testing primitives | [Primitives Testing Guide](tasks/mobile-dev/docs/primitives-testing.md) | +| Repository structure | [Web/Native Repo Structure](tasks/mobile-dev/docs/web-and-native-repo-structure.md) | +| Platform detection | [Quick Reference - Platform Detection](../AGENTS.md#mobile-first-development) | + +--- + +## Documentation Guidelines + +### When to Create New Documentation + +Create documentation when: +- ✅ Feature has >3 components or >100 lines of logic +- ✅ Pattern will be reused across multiple features +- ✅ Bug required >2 hours to solve and solution is non-obvious +- ✅ Architecture decision affects multiple areas of codebase + +### Documentation Structure + +**For Features** (`docs/features/`): +```markdown +# Feature Name + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Overview +Brief description of the feature + +## Architecture +Technical implementation details + +## Usage Examples +Code examples showing how to use + +## Related Documentation +Links to related docs + +--- + +_Created: YYYY-MM-DD_ +``` + +**For Bugs** (`bugs/`): +```markdown +# Bug Description + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms +What goes wrong + +## Root Cause +Why it happens + +## Solution +How it was fixed + +## Prevention +How to avoid in future + +--- + +_Created: YYYY-MM-DD_ +``` + +**For Reports** (`reports/`): +```markdown +# Report Title + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Executive Summary +Brief overview of key findings and recommendations + +## Scope & Methodology +- **Scope**: What was analyzed/investigated +- **Methodology**: How the analysis was conducted +- **Tools/Approaches**: Specific methods or tools used + +## Findings +### [Finding Category 1] +- **Issue**: Description of what was found +- **Impact**: Severity/importance level +- **Evidence**: Supporting details, file locations + +## Recommendations +### High Priority +1. **[Action Item 1]** + - **Why**: Justification + - **How**: Implementation approach + - **Files**: `src/path/to/file.ts:123` + +## Action Items +- [ ] **[Specific task]** - Priority: [level] + +## Related Documentation +Links to related tasks, bugs, documentation + +--- + +_Created: YYYY-MM-DD_ +_Report Type: [Audit/Research/Analysis/Assessment]_ +``` + +### AI-Generated Documentation + +**IMPORTANT**: All documentation created by AI agents MUST include a warning disclaimer immediately below the title: + +```markdown +# Document Title + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Overview +... +``` + +**Why**: AI-generated documentation may contain inaccuracies or outdated information. The warning helps developers know to verify critical details before relying on the documentation. + +**Applies to**: +- ✅ All docs in `.agents/` created by AI +- ✅ Feature documentation in `docs/features/` created by AI +- ✅ Bug reports in `bugs/` created by AI +- ✅ Reports in `reports/` created by AI +- ✅ Architecture guides created by AI +- ❌ Human-written or human-verified documentation (remove the warning after verification) + +### Naming Conventions + +**Files**: +- Use kebab-case: `feature-name.md` +- **Reports**: Use date format: `report-name_YYYY-MM-DD.md` (e.g., `security-audit_2025-11-19.md`) +- Be descriptive: ❌ `fix.md` ✅ `modal-zindex-stacking-fix.md` + +**Folders**: +- Active work: Root folder (`bugs/`, `tasks/`, `reports/`) +- Completed work: `.done/` or `.solved/` subfolders +- Archived/deprecated: `.archived/` subfolder + +--- + +## Tips for Effective AI Development + +### 1. Read Before Writing + +**Why**: Understanding existing patterns saves time and prevents conflicts + +**How**: +- Use AGENTS.md for fast pattern lookup +- Read related docs before implementing +- Check `.done/` and `.solved/` for completed examples + +### 2. Use Primitives Strategically + +**Why**: Ensures consistency where it matters, flexibility where it helps + +**⚡ Quick Rules:** +- **Interactive elements**: Always use primitives (Button, Input, Select, Modal, Switch) +- **Text elements**: Platform-specific approach (helpers for shared, Text+as for web-only) +- **Layout containers**: Case-by-case evaluation + +**📖 Complete Guidance**: See [When to Use Primitives](docs/features/primitives/03-when-to-use-primitives.md) +- Decision framework (5 questions) +- Platform-specific text guidance (helpers vs Text+as) +- Typography vs legacy props coexistence +- Detailed examples and anti-patterns + +**How**: +- Always check [API Reference](docs/features/primitives/API-REFERENCE.md) first for interactive elements +- Apply the [decision framework](docs/features/primitives/03-when-to-use-primitives.md#decision-framework) when unsure + +### 3. Think Mobile-First + +**Why**: Mobile constraints ensure robust design + +**How**: +- Ask: "Will this work on mobile?" before every change +- Test touch interactions, not just mouse/hover +- Use responsive primitives (`ResponsiveContainer`, `isMobile()`) + +### 4. Document Edge Cases + +**Why**: Non-obvious solutions get lost without documentation + +**How**: +- Add to `bugs/` if solution took >2 hours +- Add to `docs/features/` if pattern is reusable +- Link related docs at bottom of files + +### 5. Cross-Reference Documentation + +**Why**: Reduces cognitive load, improves discoverability + +**How**: +- Add "Related Documentation" section to all docs +- Link to API Reference when mentioning components +- Link to architecture guides when explaining patterns + +--- + +## Common Pitfalls + +### ❌ Avoid These Mistakes + +#### 1. Wrong Primitive Usage Decisions + +**Problem**: Inconsistent primitive usage leads to maintenance issues and platform incompatibility. + +**Solution**: Follow the systematic approach in [When to Use Primitives](docs/features/primitives/03-when-to-use-primitives.md): +- Always use primitives for interactive elements (Button, Input, etc.) +- Apply the 5-question decision framework for other components +- Use platform-specific text approach (helpers vs Text+as) + +**Examples**: See [practical examples](docs/features/primitives/03-when-to-use-primitives.md#practical-examples) for good vs over-engineered approaches. + +--- + +#### 2. Breaking React Hooks Rules + +**Bad**: +```tsx +if (someCondition) return <Loading />; +useEffect(() => { ... }, []); // Hook called conditionally! +``` + +**Good**: +```tsx +useEffect(() => { ... }, []); +if (someCondition) return <Loading />; +``` + +**Why**: Violates React's Rules of Hooks + +**Reference**: [React Hooks Violation Bug](bugs/.solved/SOLVED_react-hooks-violation-conditional-return.md) + +--- + +#### 3. Using npm Instead of yarn + +**Bad**: +```bash +npm install package-name +``` + +**Good**: +```bash +yarn add package-name +``` + +**Why**: Creates package-lock.json conflicts with yarn.lock + +**Reference**: [CLAUDE.md - Package Management](../../CLAUDE.md#package-management) + +--- + +#### 4. Not Testing on Mobile + +**Bad**: +- Implement feature +- Test on desktop only +- Commit + +**Good**: +- Implement feature +- Test on desktop +- Test on mobile (or verify mobile compatibility) +- Commit + +**Why**: Mobile-first ensures cross-platform compatibility + +--- + +#### 5. Ignoring Documentation + +**Bad**: +- Implement without reading docs +- Reinvent existing patterns +- Create incompatible solutions + +**Good**: +- Read AGENTS.md first +- Search INDEX.md for related docs +- Follow existing patterns + +**Why**: Consistency reduces bugs and maintenance burden + +--- + +#### 6. Forgetting WSL/Windows Commands + +**Bad** (in WSL with Windows Node.js): +```bash +npx tsc --noEmit +yarn lint +``` + +**Good**: +```bash +# Convert current path to Windows format and run commands +WINDOWS_PATH=$(pwd | sed 's|^/mnt/\([a-z]\)/|\U\1:/|' | sed 's|/|\\|g') +cmd.exe /c "cd /d $WINDOWS_PATH && npx tsc --noEmit" +cmd.exe /c "cd /d $WINDOWS_PATH && yarn lint" +``` + +**Why**: Node.js is installed on Windows, not WSL + +**Reference**: [AGENTS.md - Development Commands](../AGENTS.md#development-commands-wslwindows) + +--- + +## Workflow Checklist + +### Before Starting Task + +- [ ] Read AGENTS.md for relevant patterns +- [ ] Search INDEX.md for related documentation +- [ ] Review active bugs in `bugs/` folder +- [ ] Check primitives documentation if UI work involved + +### During Development + +- [ ] Use primitives strategically (always for interactions, case-by-case for layout) +- [ ] Reference [primitives decision framework](docs/features/primitives/03-when-to-use-primitives.md#decision-framework) when unsure +- [ ] Think mobile-first (test on mobile) +- [ ] Follow React Hooks rules (no conditional returns before hooks) +- [ ] Use semantic CSS variables and theme colors +- [ ] Run linter and formatter on changed files + +### Before Committing + +- [ ] Test on both web and mobile (if applicable) +- [ ] Run type checking, linting, formatting +- [ ] Verify no package-lock.json created +- [ ] Write descriptive commit message (never mention "Claude" or "Anthropic") + +### After Completing Task + +- [ ] Update documentation if pattern is reusable +- [ ] Move task to `.done/` folder if documented +- [ ] Document bugs/edge cases in `bugs/` if non-trivial +- [ ] Add cross-reference links to related docs + +--- + +## Quick Reference Summary + +**Most Important Files**: +1. [AGENTS.md](AGENTS.md) - Start here for every task +2. [INDEX.md](INDEX.md) - Find documentation +3. [API Reference](docs/features/primitives/API-REFERENCE.md) - Component props lookup +4. [Cross-Platform Guide](docs/cross-platform-components-guide.md) - Architecture patterns + +**Most Common Workflows**: +1. Adding UI Component → [Primitives INDEX](docs/features/primitives/INDEX.md) +2. Implementing Modal → [Modal System](docs/features/modals.md) +3. Fixing Styling → [Styling Guide](docs/features/primitives/05-primitive-styling-guide.md) +4. Working with Theme → [Theming System](docs/features/cross-platform-theming.md) + +**Most Common Pitfalls**: +1. ❌ Raw HTML instead of primitives +2. ❌ Breaking React Hooks rules +3. ❌ Using npm instead of yarn +4. ❌ Not testing on mobile + +--- + +## Related Documentation + +- [AGENTS.md](AGENTS.md) - Fast lookup for common tasks +- [INDEX.md](INDEX.md) - Complete documentation index +- [CLAUDE.md](../../CLAUDE.md) - Project setup and guidelines +- [Cross-Platform Components Guide](docs/cross-platform-components-guide.md) - Architecture patterns +- [Primitives INDEX](docs/features/primitives/INDEX.md) - Primitives documentation hub + +--- + +_Created: 2025-10-08 | Last updated: 2025-11-19_ diff --git a/.agents/bugs/.archived/auto-jump-unread-breaks-message-sync.md b/.agents/bugs/.archived/auto-jump-unread-breaks-message-sync.md new file mode 100644 index 000000000..849ef67ff --- /dev/null +++ b/.agents/bugs/.archived/auto-jump-unread-breaks-message-sync.md @@ -0,0 +1,260 @@ +--- +type: bug +title: 'Bug: Auto-jump to First Unread Breaks Initial Message Sync' +status: archived +created: 2026-01-09T00:00:00.000Z +updated: '2026-01-09' +--- + +# Bug: Auto-jump to First Unread Breaks Initial Message Sync + +**Status:** 🔴 Critical - Affects core messaging functionality +**Created:** 2025-11-11 +**Commit Range:** `e617d63f` (working) → `a63f609f` (broken) + +--- + +## Summary + +When User B enters a channel where User A has posted multiple messages in a row, User B only sees the first message initially. On each refresh, one additional message appears, requiring multiple refreshes to see all messages. This regression was introduced by the auto-jump-to-first-unread feature implementation. + +--- + +## Reproduction Steps + +1. **Setup:** User A posts multiple messages in a channel (e.g., 5 messages in a row) +2. **Action:** User B opens/enters that channel for the first time +3. **Expected:** User B should see all messages from User A +4. **Actual:** User B only sees the first message +5. **Workaround:** Each refresh loads one more message until all are visible + +--- + +## Root Cause Analysis + +The bug is in `buildMessagesFetcher.ts` where we determine the initial cursor for loading messages: + +```typescript +// buildMessagesFetcher.ts:64-70 +if (!cursor) { + effectiveCursor = await determineInitialCursor({ + messageDB, + spaceId, + channelId, + }); +} +``` + +### The Problem Flow + +1. **Initial Load:** When User B first enters the channel: + - `lastReadTimestamp = 0` (never read before) + - `getFirstUnreadMessage()` returns the FIRST message from User A + - We set `cursor = firstUnread.timestamp + 1` + +2. **Wrong Behavior:** In `getMessages()` (lines 256-278 in `messages.ts`): + + ```typescript + if (!cursor) { + // Initial load - get latest messages (✅ this works) + range = IDBKeyRange.bound( + [spaceId, channelId, 0], + [spaceId, channelId, Number.MAX_VALUE] + ); + } else { + // Get messages older than cursor (❌ this is triggered!) + range = IDBKeyRange.bound( + [spaceId, channelId, 0], + [spaceId, channelId, cursor], + false, + true // exclude the cursor value itself + ); + } + ``` + +3. **The Issue:** + - Because `cursor` is now set (to `firstUnread.timestamp + 1`), we take the `else` branch + - This gets messages OLDER than the cursor (backward direction) + - With `limit = 100`, we get UP TO 100 messages older than the first unread + - But if there are NO messages older (User A's first message IS the first), we get an empty or partial result + - The cursor logic is designed for PAGINATION, not for JUMPING to a specific point + +4. **Why Refresh Helps:** + - After the first load, React Query caches the initial message + - On refresh, the query re-runs with a different state + - Eventually, through pagination or state changes, more messages load + +--- + +## Technical Details + +### Key Files Affected + +- `src/hooks/queries/messages/buildMessagesFetcher.ts:62-70` +- `src/db/messages.ts:233-324` (`getMessages` method) +- `src/db/messages.ts:1520-1573` (`getFirstUnreadMessage` method - NEW) + +### Why It Worked at e617d63f + +At the older commit, there was NO auto-jump logic. The fetcher always started with: + +```typescript +cursor: cursor?.cursor; // undefined on initial load +``` + +This triggered the "Initial load - get latest messages" path in `getMessages()`, which correctly loads the most recent 100 messages. + +### The Conceptual Flaw + +The auto-jump feature assumes we can "jump" to a timestamp and load messages around it. However, `getMessages()` is designed as a **unidirectional paginator**: + +- **No cursor:** Load from the end (latest messages), go backward +- **With cursor + backward:** Load messages OLDER than cursor +- **With cursor + forward:** Load messages NEWER than cursor + +There's no mode for "load messages AROUND this cursor" which is what auto-jump needs. + +--- + +## Proposed Solutions + +### Option 1: Load in Two Phases (Recommended) + +When jumping to first unread: + +1. Load messages NEWER than firstUnread (forward direction) +2. Also load messages OLDER than firstUnread (backward direction) +3. Combine both sets + +```typescript +// Pseudo-code +if (jumpingToUnread) { + const newer = await getMessages({ + cursor: firstUnread.timestamp, + direction: 'forward', + }); + const older = await getMessages({ + cursor: firstUnread.timestamp, + direction: 'backward', + }); + return { messages: [...older.messages, ...newer.messages] }; +} +``` + +### Option 2: Change Initial Direction + +When jumping to first unread, load in FORWARD direction from that point: + +```typescript +async function determineInitialCursor() { + // ... existing logic ... + return firstUnread + ? { + cursor: firstUnread.timestamp, + direction: 'forward' as const, + } + : null; +} +``` + +Then in the fetcher, use this direction for initial load. + +### Option 3: Extend getMessages API + +Add a new mode to `getMessages()` for "center loading": + +```typescript +getMessages({ + cursor, + direction: 'center', // NEW: load around this point + limit: 100, // 50 before, 50 after +}); +``` + +This would require modifying the core `getMessages` implementation in `messages.ts`. + +--- + +## Impact Assessment + +- **Severity:** Critical - breaks basic message viewing +- **Affected Users:** Any user viewing a channel with unread messages +- **Data Loss:** No data loss, but poor UX +- **Performance:** No performance impact beyond the bug itself + +--- + +## Testing Notes + +To test the fix: + +1. Reset User B's read state for a channel (or use incognito) +2. Have User A post 5+ messages rapidly +3. User B opens the channel +4. Verify ALL messages from User A are visible immediately +5. Test with various scenarios: + - First time entering a channel + - Returning to a channel with unreads + - Channel with hash navigation (#msg-xxx) + - Channel with no unreads + +--- + +## Related Code + +**Commits in question:** + +- `e617d63f`: Add XSS prevention (working state) +- `a63f609f`: WIP: Auto-jump to first unread (broken state) + +**Key methods:** + +- `buildMessagesFetcher` (src/hooks/queries/messages/buildMessagesFetcher.ts) +- `determineInitialCursor` (same file, NEW) +- `getMessages` (src/db/messages.ts:233) +- `getFirstUnreadMessage` (src/db/messages.ts:1520, NEW) + +--- + +## Questions for Discussion + +1. **What is the desired behavior?** When jumping to first unread: + - Should we show messages AROUND the unread (context)? + - Or start FROM the unread and load newer messages? + - Or load ALL messages up to the unread? + +2. **How does this interact with pagination?** + - After jumping, how should "load more" behave? + - Should we load older messages or just newer ones? + +3. **Mobile considerations:** + - Does this affect mobile differently? + - Is the pagination logic the same on React Native? + +--- + +## Temporary Workaround + +Disable the auto-jump feature by reverting the changes in `buildMessagesFetcher.ts`: + +```typescript +const buildMessagesFetcher = ({ messageDB, spaceId, channelId }) => + wrapPaginatedFetcher(async ({ pageParam: cursor }) => { + const response = await messageDB.getMessages({ + spaceId, + channelId, + cursor: cursor?.cursor, // Restore original behavior + direction: cursor?.direction, + }); + return response; + }); +``` + +--- + +**Next Steps:** + +1. Decide on desired UX behavior (see questions above) +2. Choose implementation approach (Option 1, 2, or 3) +3. Implement fix with comprehensive tests +4. Test across different scenarios (see Testing Notes) diff --git a/.agents/bugs/.archived/brave-browser-react-hook-errors.md b/.agents/bugs/.archived/brave-browser-react-hook-errors.md new file mode 100644 index 000000000..813bb8f96 --- /dev/null +++ b/.agents/bugs/.archived/brave-browser-react-hook-errors.md @@ -0,0 +1,267 @@ +--- +type: bug +title: Brave Browser React Hook Errors - ONGOING INVESTIGATION +status: archived +created: 2026-01-09T00:00:00.000Z +updated: '2026-01-09' +--- + +# Brave Browser React Hook Errors - ONGOING INVESTIGATION + +**Date:** July 30, 2025 +**Status:** 🔄 **ONGOING** - Multiple Solutions Attempted, Still Experiencing Hook Errors +**Branch:** feat/mobile-app +**Priority:** Medium (Development Experience Issue - Non-blocking) + +## Bug Description + +Brave browser exhibits intermittent "Invalid hook call" errors that prevent the app from loading in development mode, while Chrome works consistently. The issue appears to be **non-deterministic** and may be related to browser-specific JavaScript module loading behavior. + +## Error Messages (Still Occurring) + +``` +Warning: Invalid hook call. Hooks can only be called inside of the body of a function component. This could happen for one of the following reasons: +1. You might have mismatching versions of React and the renderer (such as React DOM) +2. You might be breaking the Rules of Hooks +3. You might have more than one copy of React in the same app + +chunk-YMK7XHJB.js?v=0d8c5ba4:1347 Uncaught TypeError: Cannot read properties of null (reading 'useContext') + at Object.useContext (chunk-YMK7XHJB.js?v=0d8c5ba4:1347:29) + at useIsRestoring (@tanstack_react-query.js?v=0d8c5ba4:2989:35) + at useBaseQuery (@tanstack_react-query.js?v=0d8c5ba4:3161:23) + at useSuspenseQuery (@tanstack_react-query.js?v=0d8c5ba4:3248:10) + at useRegistration (useRegistration.ts:10:10) + at RegistrationProvider (RegistrationPersister.tsx:41:34) +``` + +## Root Cause Analysis - COMPLETED + +### Git Bisect Investigation Results + +**✅ Commit e748ef8** (last new-style commit): **NO** hook errors in Brave +**❌ Commit 4f15c06** ("Implement cross-platform theming foundation and Button primitive"): **YES** hook errors in Brave + +**Primary Issue**: React hooks being loaded during module initialization due to direct exports from primitive theme system, causing browser-specific compatibility issues. + +## Solutions Attempted + +### 1. Factory Function Pattern ❌ + +**Approach**: Replace direct exports with factory functions to lazy-load hooks. + +**Implementation**: + +```typescript +// Before (Direct Exports) +export { CrossPlatformThemeProvider, useCrossPlatformTheme } from './theme'; + +// After (Factory Functions) +export const createCrossPlatformThemeProvider = () => { + const { CrossPlatformThemeProvider } = require('./theme/ThemeProvider'); + return CrossPlatformThemeProvider; +}; +``` + +**Result**: Fixed initial hook loading issues but **still experiencing hook errors** in Brave. Created complexity for mobile development. + +### 2. Dual Export Pattern ❌ + +**Approach**: Provide both direct exports (for mobile) and factory functions (for web). + +**Implementation**: + +```typescript +// Direct exports for mobile development (React Native compatible) +export { + CrossPlatformThemeProvider, + useCrossPlatformTheme, +} from './theme/ThemeProvider'; + +// Factory functions for web development (fixes Brave browser hook errors) +export const createCrossPlatformThemeProvider = () => { + const { CrossPlatformThemeProvider } = require('./theme/ThemeProvider'); + return CrossPlatformThemeProvider; +}; +``` + +**Result**: **Still experiencing hook errors** in Brave. Web app not using factory functions consistently. + +### 3. Industry-Standard Conditional Exports ❌ + +**Approach**: Use environment detection with React.lazy() for web, direct exports for React Native. + +**Implementation**: + +```typescript +export const CrossPlatformThemeProvider = + typeof window !== 'undefined' + ? lazy(() => + import('./ThemeProvider').then((m) => ({ + default: m.CrossPlatformThemeProvider, + })) + ) + : require('./ThemeProvider').CrossPlatformThemeProvider; +``` + +**Result**: **Still experiencing hook errors** in Brave. Lazy loading doesn't solve the core module loading issue. + +## Research Findings + +### Industry Best Practices (2025) + +Based on web search research: + +1. **Context API + Custom Hooks** - Most recommended pattern for React Native Web theming +2. **CSS-in-JS libraries** (Styled Components, Emotion) for theme implementation +3. **Component libraries with built-in theming** (React Native Paper, UI Kitten) +4. **Factory functions NOT commonly mentioned** for theming solutions + +### Hook Error Root Causes + +Research confirms the "Invalid hook call" error is typically caused by: + +1. **Multiple React instances** in the same application +2. **Dynamic module loading** with hooks (Module Federation issue) +3. **Browser-specific JavaScript engine differences** in module loading + +## Current Status + +### Browser Compatibility + +- ❌ **Brave**: Intermittent hook errors, **non-deterministic behavior** +- ✅ **Chrome**: Consistently works +- ✅ **Production**: No issues reported + +### Development Impact + +- **Low-Medium Impact**: Brave users may need to refresh or clear cache occasionally +- **Workaround Available**: Use Chrome for development or clear Vite cache +- **Non-blocking**: Does not affect production or core functionality + +## Current Architecture + +### Theme System Structure + +``` +src/components/primitives/ +├── index.ts # Environment-aware exports +├── theme/ +│ ├── index.ts # Conditional lazy/direct exports +│ ├── ThemeProvider.tsx # Core theme logic with hooks +│ └── colors.ts # Static color exports (safe) +└── Select/Select.web.tsx # Cleaned unused imports +``` + +### Mobile Playground + +- ✅ **Working**: All mobile playground functionality preserved +- ✅ **Mirrors Main App**: Uses same export structure as main app +- ✅ **React Native Compatible**: Direct exports work without issues + +## Potential Solutions (Not Yet Attempted) + +### 1. Vite Configuration Approach + +```javascript +// vite.config.js +export default { + resolve: { + alias: { + react: path.resolve('./node_modules/react'), + 'react-dom': path.resolve('./node_modules/react-dom'), + }, + }, +}; +``` + +### 2. React Instance Deduplication + +```javascript +// webpack.config.js or vite equivalent +module.exports = { + resolve: { + alias: { + react: require.resolve('react'), + }, + }, +}; +``` + +### 3. SDK Architecture Changes + +- Move React to peer dependencies in SDK +- Eliminate duplicate React instances entirely +- Implement proper module federation configuration + +## Decision: Leave As-Is + +**Rationale**: + +1. **Non-deterministic issue** suggests deeper browser/bundler compatibility problem +2. **Multiple solutions attempted** without complete resolution +3. **Low priority** - affects only Brave development experience +4. **Workarounds available** - Chrome works perfectly, cache clearing helps +5. **No production impact** - issue isolated to development environment + +**Risk Assessment**: **Low** - Development inconvenience only, no functional impact + +## Files Modified During Investigation + +### Core Architecture Files + +- `src/components/primitives/index.ts` - Multiple export pattern attempts +- `src/components/primitives/theme/index.ts` - Conditional exports implementation +- `src/components/primitives/Select/Select.web.tsx` - Removed unused imports + +### Mobile Playground (Working) + +- `src/dev/playground/mobile/components/primitives/index.ts` - Mirrors main app +- `src/dev/playground/mobile/components/primitives/theme/index.ts` - Environment-aware exports +- Mobile components working with direct theme imports + +### Investigation Documentation + +- `.agents/bugs/brave-browser-react-hook-errors-ONGOING.md` - This file + +## Long-Term Recommendations + +### High Priority (If Issue Becomes Blocking) + +1. **Deep-dive Vite configuration** for React instance management +2. **SDK refactoring** to eliminate duplicate React dependencies +3. **Module federation configuration** review + +### Low Priority (Nice to Have) + +1. **Browser compatibility testing** across all major browsers +2. **Performance analysis** of lazy loading vs direct exports +3. **Alternative theming libraries** evaluation (React Native Paper, etc.) + +## Success Criteria (Partial Achievement) + +- ✅ **Identified root cause**: Theme system hook loading during module initialization +- ✅ **Preserved mobile capabilities**: Playground and mobile development unaffected +- ✅ **Zero breaking changes**: Existing functionality maintained +- ✅ **Multiple solution attempts**: Factory functions, dual exports, conditional exports +- ❌ **Complete Brave compatibility**: Still experiencing intermittent issues +- ❌ **Deterministic behavior**: Issue remains non-deterministic + +## Conclusion + +While we've made significant progress understanding and addressing the root cause, the Brave browser hook errors persist despite multiple industry-standard solution attempts. The issue appears to be a complex interaction between: + +1. **Browser-specific JavaScript engines** (Chrome V8 vs Brave) +2. **Vite bundling behavior** with React hooks +3. **Duplicate React instances** in the development environment + +Given the **non-blocking nature** and **available workarounds**, the decision is to **leave the current architecture as-is** and focus development efforts on higher-priority features. + + +--- + +_Investigation period: July 30, 2025_ +_Solutions attempted: Factory functions, dual exports, conditional lazy loading_ +_Result: Partial improvement, issue remains non-deterministic_ +_Decision: Accept current state due to low priority and available workarounds_ + +Updated: July 30, 2025 14:30 UTC diff --git a/.agents/bugs/.archived/message-delete-owner-vulnerability.md b/.agents/bugs/.archived/message-delete-owner-vulnerability.md new file mode 100644 index 000000000..8c2e4a349 --- /dev/null +++ b/.agents/bugs/.archived/message-delete-owner-vulnerability.md @@ -0,0 +1,36 @@ +--- +type: bug +title: 'Role with message:delete permission can delete space owner''s messages' +status: archived +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: '2026-01-09' +--- + +# Role with message:delete permission can delete space owner's messages + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms +A user with `message:delete` role permission can delete any user's messages, including the space owner's messages. Once deleted, messages are permanently gone with no recovery path. + +## Root Cause +The receive-side validation in `MessageService.ts` (lines 797-886) checks if the sender has `message:delete` permission but does not verify whether the target message belongs to the space owner. + +Due to privacy constraints, other clients cannot identify who the space owner is (owner identity is intentionally hidden), making receive-side owner protection impossible without revealing owner identity. + +## Impact +- **Severity**: Medium-High +- **Permanent data loss**: Deleted messages cannot be recovered +- **Trust model**: Space owners must carefully control who gets `message:delete` permission + +## Potential Solutions +1. **Accept as design trade-off** - Document that delete permission requires high trust +2. **Owner self-protection** - Owner's client ignores delete requests for own messages (partial mitigation) +3. **Message tombstones** - Keep deleted message metadata for owner to restore (complex) + +## Related +- Same pattern exists for `user:mute` but mute is recoverable (owner can unmute self) +- File: `src/services/MessageService.ts:797-886` + +--- diff --git a/.agents/bugs/.archived/space-owner-delete-permissions-bug.md b/.agents/bugs/.archived/space-owner-delete-permissions-bug.md new file mode 100644 index 000000000..ddede5792 --- /dev/null +++ b/.agents/bugs/.archived/space-owner-delete-permissions-bug.md @@ -0,0 +1,215 @@ +--- +type: bug +title: Space Owner Delete Permissions Bug +status: archived +created: 2025-09-11T00:00:00.000Z +updated: '2026-01-09' +related_issues: + - '#68' +--- + +# Space Owner Delete Permissions Bug + +https://github.com/QuilibriumNetwork/quorum-desktop/issues/68 + +## Issue Description + +**Space owners cannot delete other users' messages** despite having the correct UI permissions. Delete buttons appear for space owners but clicking them does nothing - messages are not deleted locally or on other clients. + +## Current Behavior vs Expected Behavior + +### Current State + +- ✅ **Users with delete role permissions**: Can delete ANY message in regular channels +- ❌ **Space owners**: See delete buttons but cannot delete other users' messages +- ✅ **Read-only channel managers**: Can delete ANY message in read-only channels +- ✅ **Self-delete**: All users can delete their own messages + +### Expected Behavior + +- Space owners should be able to delete any message in any channel within their space + +## Technical Analysis + +### Symptoms + +1. **UI Level**: Space owners correctly see delete buttons on other users' messages (permission checking works) +2. **Processing Level**: Clicking delete does nothing - no local deletion, no network synchronization +3. **User Experience**: Buttons appear but are non-functional, creating confusion + +### Root Cause + +The issue is in the message processing architecture within `src/components/context/MessageDB.tsx`. The delete message processing logic has validation for: + +1. ✅ **Self-delete**: Users can delete their own messages +2. ✅ **Role-based permissions**: Users with `message:delete` role permission +3. ✅ **Read-only managers**: Channel-specific manager permissions +4. ❌ **Space owners**: No validation logic for space ownership + +### Architecture Challenge + +**Key Technical Problem**: Space ownership is determined by cryptographic key possession (`messageDB.getSpaceKey(spaceId, 'owner')`), but this validation only works in the owner's local context. + +The system has dual processing paths: + +- **`saveMessage`**: Validates incoming messages (has access to sender's local keys) +- **`addMessage`**: Applies messages to UI cache (cannot access sender's keys for validation) + +Current working permissions (roles, self-delete, read-only managers) use different validation patterns that don't require cross-client key verification. + +### Security Requirements + +Any solution must ensure: + +1. **Authentication**: Only actual space owners can delete messages (no privilege escalation) +2. **Message Integrity**: Proper cryptographic validation of delete requests +3. **Network Synchronization**: Delete messages must propagate correctly to all clients +4. **Validation Consistency**: Both local processing and remote message acceptance must work + +## Files Involved + +- **Primary**: `src/components/context/MessageDB.tsx` - Message processing and validation logic +- **Secondary**: `src/hooks/business/channels/useChannelMessages.ts` - UI permission checking +- **Related**: `src/utils/permissions.ts` - Permission utility functions + +## Implementation Notes for Developers + +1. **Space Owner Detection**: Uses `messageDB.getSpaceKey(spaceId, 'owner')` pattern +2. **Processing Architecture**: Dual-path validation system requires secure owner verification in both contexts +3. **Working Patterns**: Reference read-only manager and traditional role implementations for architectural guidance +4. **Security First**: Previous implementation attempts were reverted due to security vulnerabilities + +## Proposed Solution: Add `ownerAddress` to Space Type + +### Solution Overview + +Add an `ownerAddress` field to the Space type, following the same trust model as roles. This aligns with how Discord/Telegram handle ownership - as metadata that all clients can see and trust. + +### Implementation Details + +1. **Modify Space Type** (`src/api/quorumApi.ts`): +```typescript +export type Space = { + spaceId: string; + spaceName: string; + ownerAddress?: string; // Optional for backwards compatibility + // ... existing fields +} +``` + +2. **Set During Space Creation** (`src/components/context/MessageDB.tsx`): +```typescript +// Derive owner address from public key +const ownerSh = await sha256.digest(Buffer.from(ownerPair.public_key)); +const ownerAddress = base58btc.baseEncode(ownerSh.bytes); + +const space = { + spaceId: spaceAddress, + spaceName: spaceName, + ownerAddress: ownerAddress, // Add this + // ... rest of fields +} +``` + +3. **Check in Deletion Logic**: +```typescript +// In both saveMessage and addMessage functions +if (decryptedContent.content.senderId === space.ownerAddress) { + // Honor delete from space owner + shouldHonorDelete = true; +} +``` + +### Why This Solution Works + +1. **Consistent Security Model**: Uses same trust model as roles - space data is signed on creation/update +2. **No Additional Verification Needed**: Like roles, once the space data is verified and stored, it's trusted +3. **Simple Implementation**: Follows existing patterns in the codebase +4. **Aligns with Industry Standards**: Discord and Telegram use similar approaches + +### Migration for Existing Spaces - FEASIBLE + +✅ **UPDATE**: After further analysis, migration IS feasible using existing infrastructure! + +#### How Migration Would Work + +The system already has secure space update verification: + +1. **Space updates are signed** with the owner's private key +2. **Server has registered owner public keys** (`owner_public_keys` array) +3. **Other clients verify signatures** before accepting updates (lines 1308-1368 in MessageDB.tsx) + +This means we can safely add `ownerAddress` to existing spaces: + +```typescript +// In updateSpace function +if (!space.ownerAddress && owner_key) { + const sh = await sha256.digest(Buffer.from(owner_key.publicKey, 'hex')); + space.ownerAddress = base58btc.baseEncode(sh.bytes); +} +``` + +#### Migration Strategies + +1. **Automatic Migration** (Recommended): + - When space owner makes ANY update (name, roles, channels, etc.) + - System automatically adds `ownerAddress` if missing + - Transparent to users, gradual rollout + +2. **Semi-Automatic Migration**: + - Prompt appears when owner opens SpaceEditor + - "Update space for enhanced permissions" message + - One-click update + +3. **Manual Migration**: + - Add "Enable Owner Permissions" button in settings + - Only shows for spaces without `ownerAddress` + - Explicit user action required + +#### Implementation Complexity: LOW-MEDIUM + +**Simple because:** +- Existing verification infrastructure handles security +- No new cryptographic operations needed +- Other clients automatically accept valid updates +- Can be done incrementally (non-breaking) + +**Considerations:** +- Need backwards compatibility during transition +- Some spaces may remain unmigrated if owners don't update +- Testing across different client versions + +### Security Considerations + +- The `ownerAddress` is derived from the owner's public key at space creation +- Space manifests are cryptographically signed, preventing tampering +- Delete messages are already signed, allowing verification of sender identity +- Trust model matches existing role system (trust the Space object after initial verification) + +--- + +## Implementation Path + +### Phase 1: Core Implementation +1. Add `ownerAddress?: string` to Space type (optional field) +2. Set `ownerAddress` during new space creation +3. Update deletion permission checks to honor `ownerAddress` + +### Phase 2: Migration Support +1. Modify `updateSpace` to auto-add `ownerAddress` when missing +2. Test signature verification across clients +3. Deploy with backwards compatibility + +### Phase 3: User Experience +1. Consider adding migration prompt or make it fully automatic +2. Document for users with admin role workaround for unmigrated spaces +3. Monitor migration adoption rate + +--- + + +**Priority**: High - Space owners cannot perform expected administrative actions +**Migration Impact**: MEDIUM - Existing spaces CAN be migrated using existing secure update mechanism +**Implementation Difficulty**: LOW-MEDIUM - Uses existing infrastructure + +**Last Updated**: 2025-09-13 diff --git a/.agents/bugs/.solved/SOLVED_android-file-download-huawei-p9-lite.md b/.agents/bugs/.solved/SOLVED_android-file-download-huawei-p9-lite.md new file mode 100644 index 000000000..4326c9931 --- /dev/null +++ b/.agents/bugs/.solved/SOLVED_android-file-download-huawei-p9-lite.md @@ -0,0 +1,529 @@ +--- +type: bug +title: Android 7.0 File Download Fix - Implementation Plan +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-08-11T00:00:00.000Z +--- + +# Android 7.0 File Download Fix - Implementation Plan + +This is only partially solved (giveAndoird 7 users the possibility to manually save the key in a txt file), but it's also a minor issue as those users are an extrenely low minority (well below 1%). + +## Problem Analysis + +### Issue Description + +The key backup feature in the mobile onboarding flow fails to work properly on older Android devices, specifically: + +- **Device**: Huawei with Android 7.0 +- **Symptom**: Opens phone screen with no clear option to save file +- **Working**: Feature works correctly on newer Motorola Edge 50 +- **Impact**: Critical onboarding step fails for users on older Android devices + +### Technical Root Cause + +Current implementation in `src/hooks/platform/files/useFileDownload.native.ts` relies entirely on: + +- `expo-file-system`'s Storage Access Framework (SAF) +- `StorageAccessFramework.requestDirectoryPermissionsAsync()` method +- SAF has inconsistent behavior on older Android versions (API 24/Android 7.0) +- Different Android manufacturers (Huawei, Samsung, etc.) implement SAF differently + +### Code Location + +- **Primary Issue**: `src/hooks/platform/files/useFileDownload.native.ts:41-86` +- **Business Logic**: `src/hooks/business/files/useKeyBackupLogic.ts:53-84` +- **Component Usage**: `src/components/onboarding/Onboarding.native.tsx:121-128` + +## Solution Architecture + +### Multi-Tier Fallback System + +Implement a robust 3-tier fallback system that works on both iOS and Android: + +#### iOS Implementation Tiers + +1. **Primary**: react-native-blob-util with iOS native file save +2. **Fallback**: Current expo-sharing implementation (already working) +3. **Ultimate**: Native iOS share sheet with "Save to Files" + +#### Android Implementation Tiers + +1. **Primary**: Enhanced SAF with better error detection and device compatibility +2. **Fallback**: react-native-blob-util Android Download Manager (works on Android 7.0+) +3. **Ultimate**: expo-sharing dialog as last resort + +### Safety Strategy + +- **Backup Original**: Create `.original.ts` copy of current implementation +- **Runtime Flag**: Environment/config flag to switch between implementations +- **Gradual Rollout**: Test new implementation with instant rollback capability +- **No Breaking Changes**: Maintain existing API interface + +## Implementation Details + +### Dependencies + +```bash +# Install react-native-blob-util for enhanced file operations +yarn add react-native-blob-util +``` + +**Note**: `expo-sharing` and `expo-file-system` are already available in package.json + +### Files to Modify/Create + +#### 1. Backup Current Implementation + +- **Create**: `src/hooks/platform/files/useFileDownload.native.original.ts` +- **Content**: Exact copy of current `useFileDownload.native.ts` +- **Purpose**: Safety rollback mechanism + +#### 2. Enhanced Implementation + +- **Modify**: `src/hooks/platform/files/useFileDownload.native.ts` +- **Changes**: + - Add react-native-blob-util integration + - Implement tier-based fallback system + - Add device/manufacturer detection + - Enhanced error handling + +#### 3. Configuration Updates + +- **Modify**: `mobile/app.json` +- **Add**: Android storage permissions if needed +- **Changes**: Ensure compatibility with older Android versions + +### Code Architecture Changes + +#### New Interface Extensions + +```typescript +interface FileDownloadConfig { + useOriginalImplementation?: boolean; + enableTierLogging?: boolean; + maxRetryAttempts?: number; +} +``` + +#### Enhanced Error Handling + +- Device-specific error detection +- Manufacturer compatibility checks +- Graceful degradation between tiers +- User-friendly error messages + +## Technical Specifications + +### Android Tier Implementation + +#### Tier 1: Enhanced SAF + +- Current SAF implementation with better error detection +- Device/manufacturer compatibility matrix +- Timeout handling and retry logic + +#### Tier 2: react-native-blob-util Download Manager + +```typescript +// Android Download Manager approach +await RNBlobUtil.config({ + addAndroidDownloads: { + useDownloadManager: true, + notification: false, + mediaScannable: true, + path: `${RNBlobUtil.fs.dirs.DownloadDir}/${filename}`, + }, +}).fetch('GET', dataUri); +``` + +#### Tier 3: expo-sharing Fallback + +- Current expo-sharing implementation +- Always available as ultimate fallback + +### iOS Tier Implementation + +#### Tier 1: react-native-blob-util iOS + +```typescript +// iOS native file operations +await RNBlobUtil.ios.openDocument(filePath); +``` + +#### Tier 2: expo-sharing (Current) + +- Existing working implementation +- Native iOS share sheet + +### Cross-Platform Detection + +```typescript +const getOptimalDownloadStrategy = () => { + if (Platform.OS === 'ios') { + return 'react-native-blob-util-ios'; + } + + // Android device detection + const androidVersion = Platform.Version; + const manufacturer = DeviceInfo.getManufacturer(); + + if (androidVersion >= 24 && !isProblematicDevice(manufacturer)) { + return 'enhanced-saf'; + } + + return 'react-native-blob-util-android'; +}; +``` + +## Testing Strategy + +### Device Compatibility Matrix + +| Platform | Version | Manufacturer | Test Status | Expected Tier | +| -------- | ------- | ---------------- | ----------- | ------------- | +| Android | 7.0 | Huawei | 🔴 Failing | Tier 2 | +| Android | Latest | Motorola Edge 50 | ✅ Working | Tier 1 | +| Android | 8.0+ | Samsung | 📋 To Test | Tier 1 | +| Android | 7.0+ | Generic | 📋 To Test | Tier 2 | +| iOS | 13+ | All | 📋 To Test | Tier 1 | + +### Test Scenarios + +#### Scenario 1: Primary Method Success + +- **Expected**: File saves successfully using Tier 1 +- **Verify**: File appears in expected location +- **Platforms**: Both iOS and Android + +#### Scenario 2: Primary Method Failure → Fallback + +- **Expected**: Graceful fallback to Tier 2 +- **Verify**: User receives clear feedback about fallback +- **Platforms**: Primarily older Android devices + +#### Scenario 3: All Methods Fail → Ultimate Fallback + +- **Expected**: expo-sharing dialog appears +- **Verify**: User can still save file manually +- **Platforms**: Edge cases and very old devices + +#### Scenario 4: Rollback Testing + +- **Expected**: Original implementation works when flag is set +- **Verify**: Can instantly revert to current behavior +- **Platforms**: All platforms + +### Testing Checklist + +- [ ] Test on Huawei Android 7.0 device (primary issue) +- [ ] Test on Motorola Edge 50 (currently working) +- [ ] Test on Samsung Android devices +- [ ] Test on iOS devices (iPhone/iPad) +- [ ] Test rollback flag functionality +- [ ] Test each tier individually +- [ ] Test error scenarios and fallback paths +- [ ] Verify file integrity after save +- [ ] Test user experience and feedback messages + +## Deployment Plan + +### Phase 1: Development & Backup (Week 1) + +- [ ] Create backup of current implementation +- [ ] Install react-native-blob-util dependency +- [ ] Implement enhanced multi-tier system +- [ ] Add configuration flags for rollback +- [ ] Initial testing on available devices + +### Phase 2: Testing & Validation (Week 2) + +- [ ] Comprehensive device testing +- [ ] User experience validation +- [ ] Performance impact assessment +- [ ] Error handling verification +- [ ] Rollback mechanism testing + +### Phase 3: Gradual Rollout (Week 3) + +- [ ] Deploy with original implementation as default +- [ ] Enable new implementation for beta users +- [ ] Monitor error rates and user feedback +- [ ] A/B test between implementations + +### Phase 4: Full Deployment (Week 4) + +- [ ] Enable new implementation by default +- [ ] Monitor production metrics +- [ ] Address any edge cases discovered +- [ ] Document final implementation + +### Phase 5: Cleanup (Week 5) + +- [ ] Remove rollback flags if stable +- [ ] Clean up original implementation backup +- [ ] Update documentation +- [ ] Archive this implementation plan + +## Risk Mitigation + +### High-Priority Risks + +1. **New implementation breaks working devices** + - **Mitigation**: Backup + runtime flag for instant rollback +2. **react-native-blob-util compatibility issues** + - **Mitigation**: Keep expo-sharing as ultimate fallback +3. **iOS functionality regression** + - **Mitigation**: Maintain current expo-sharing as iOS fallback + +### Medium-Priority Risks + +1. **Performance impact of tier detection** + - **Mitigation**: Cache device detection results +2. **User confusion during fallbacks** + - **Mitigation**: Clear, consistent messaging across tiers + +### Low-Priority Risks + +1. **Increased bundle size** + - **Mitigation**: Monitor bundle impact, tree-shake unused features + +## Success Criteria + +### Primary Success Metrics + +- [ ] ✅ File download works on Huawei Android 7.0 device +- [ ] ✅ Existing functionality maintained on all working devices +- [ ] ✅ iOS compatibility preserved +- [ ] ✅ Zero regression on currently working devices + +### Secondary Success Metrics + +- [ ] ✅ Improved error handling and user feedback +- [ ] ✅ Better device compatibility across Android manufacturers +- [ ] ✅ Graceful degradation on unsupported devices +- [ ] ✅ Maintainable code with clear fallback paths + +### Quality Metrics + +- [ ] ✅ All tests pass on target devices +- [ ] ✅ Code review approval +- [ ] ✅ Performance impact within acceptable bounds +- [ ] ✅ Documentation updated and complete + +## Implementation Checklist + +### Pre-Implementation + +- [ ] Review and approve this plan +- [ ] Identify test devices for validation +- [ ] Set up development environment +- [ ] Create feature branch + +### Development Phase + +- [ ] Create backup file (`useFileDownload.native.original.ts`) +- [ ] Install react-native-blob-util dependency +- [ ] Implement multi-tier fallback system +- [ ] Add configuration flags +- [ ] Update mobile app permissions if needed +- [ ] Add comprehensive error handling +- [ ] Implement device detection logic + +### Testing Phase + +- [ ] Unit tests for tier selection logic +- [ ] Integration tests for each tier +- [ ] Manual testing on target devices +- [ ] Rollback mechanism validation +- [ ] Performance impact assessment + +### Deployment Phase + +- [ ] Code review and approval +- [ ] Merge to feature branch +- [ ] Deploy to staging environment +- [ ] Beta user testing +- [ ] Production deployment with feature flag +- [ ] Monitor metrics and user feedback + +### Post-Deployment + +- [ ] Enable new implementation by default +- [ ] Monitor for issues and user feedback +- [ ] Address any edge cases discovered +- [ ] Clean up temporary code and flags +- [ ] Update project documentation + +--- + +## Notes + +### Development Guidelines + +- Follow existing code patterns and architecture +- Maintain backwards compatibility +- Use TypeScript for type safety +- Follow React Native best practices +- Ensure cross-platform compatibility + +### Security Considerations + +- Validate file permissions on all platforms +- Sanitize file names and paths +- Ensure user data privacy +- Follow platform security guidelines + +### Performance Considerations + +- Minimize bundle size impact +- Cache device detection results +- Avoid blocking UI during file operations +- Implement proper loading states + +## Solution to test + +Given the number of failed attempts, I think the most realistic path forward is to stop fighting SAF on Android 7 entirely and instead create a non-SAF backup flow specifically for old devices — but not using cache or sharing, since both had UX or security pitfalls. + +**We can make this hook robust and future-proof by adding:** + +- Android API detection to decide whether to use SAF or fallback. +- Copy-to-clipboard fallback for Android < 26 (broken SAF). +- iOS version detection to decide between UIDocumentPicker (iOS 11+) and fallback for older iOS. + +The below code may ned to be reworked according to our specifci situation, it's untested. + +IMPORTANT: +We dont' want to show a QR code to users, but simply their key to copy to the clipboard manually. We can use the ClickToCopyContent.native.tsx components for this. + +```typescript + +import { useCallback } from 'react'; +import * as FileSystem from 'expo-file-system'; +import * as Sharing from 'expo-sharing'; +import { Alert, Platform, Clipboard } from 'react-native'; +import DeviceInfo from 'react-native-device-info'; +import { t } from '@lingui/core/macro'; +import QRCode from 'react-native-qrcode-svg'; +import React from 'react'; + +// Helper component for fallback display +const KeyBackupFallback = ({ keyData }: { keyData: string }) => { + return ( + <> + <Alert + title={t`Manual Backup Required`} + message={t`Your device cannot save this file directly. Please scan the QR code or copy the text below.`} + /> + <QRCode value={keyData} size={250} /> + <Text selectable>{keyData}</Text> + <Button title={t`Copy to Clipboard`} onPress={() => Clipboard.setString(keyData)} /> + </> + ); +}; + +export const useFileDownloadAdapter = () => { + const androidVersion = Platform.OS === 'android' + ? parseInt(DeviceInfo.getSystemVersion().split('.')[0], 10) + : null; + + const iosVersion = Platform.OS === 'ios' + ? parseInt(DeviceInfo.getSystemVersion().split('.')[0], 10) + : null; + + const downloadKeyFile = useCallback(async (keyData: string, filename: string): Promise<void> => { + try { + const tempFileUri = FileSystem.cacheDirectory + filename; + await FileSystem.writeAsStringAsync(tempFileUri, keyData); + + if (Platform.OS === 'android') { + if (androidVersion && androidVersion < 8) { + // Fallback for Android < API 26 + Alert.alert( + t`Manual Backup Required`, + t`Saving files is not supported on this Android version. We’ll show you a QR code and direct copy option.` + ); + // Render fallback UI (QR + Copy) + // This can be a modal/screen you navigate to + return; + } + + const { StorageAccessFramework } = FileSystem; + if (!StorageAccessFramework) { + throw new Error('File saving not supported on this device'); + } + + const permissions = await StorageAccessFramework.requestDirectoryPermissionsAsync(); + if (!permissions.granted) { + await FileSystem.deleteAsync(tempFileUri, { idempotent: true }); + throw new Error('canceled'); + } + + const base64Content = await FileSystem.readAsStringAsync(tempFileUri, { + encoding: FileSystem.EncodingType.Base64 + }); + + const fileUri = await StorageAccessFramework.createFileAsync( + permissions.directoryUri, + filename, + 'application/octet-stream' + ); + + if (!fileUri) { + await FileSystem.deleteAsync(tempFileUri, { idempotent: true }); + throw new Error('Failed to create file'); + } + + await FileSystem.writeAsStringAsync(fileUri, base64Content, { + encoding: FileSystem.EncodingType.Base64 + }); + + await FileSystem.deleteAsync(tempFileUri, { idempotent: true }); + + Alert.alert(t`Key Saved`, t`Private key saved to selected folder`, [{ text: t`OK` }]); + + } else if (Platform.OS === 'ios') { + if (iosVersion && iosVersion < 11) { + // Fallback for iOS < 11 + Alert.alert( + t`Manual Backup Required`, + t`Your iOS version does not support saving to Files. We’ll show you a QR code and direct copy option.` + ); + // Render fallback UI + return; + } + + await Sharing.shareAsync(tempFileUri, { + mimeType: 'application/octet-stream', + dialogTitle: t`Save Private Key`, + }); + + Alert.alert( + t`Key Ready`, + t`Use "Save to Files" to store your private key`, + [{ text: t`OK` }] + ); + } + + } catch (error: any) { + if (error.message?.includes('canceled') || error.message?.includes('cancelled')) { + throw new Error('canceled'); + } + throw new Error(`Failed to save key file: ${error.message}`); + } + }, [androidVersion, iosVersion]); + + const showError = useCallback((message: string) => { + Alert.alert(t`Backup Failed`, message, [{ text: t`OK` }]); + }, []); + + return { downloadKeyFile, showError }; +}; + +``` + +--- + +_Last Updated: 2025-08-11_ +_Created by: Claude Code Assistant_ diff --git a/.agents/bugs/.solved/SOLVED_fast-refresh-export-incompatibility.md b/.agents/bugs/.solved/SOLVED_fast-refresh-export-incompatibility.md new file mode 100644 index 000000000..9c0d1f3f9 --- /dev/null +++ b/.agents/bugs/.solved/SOLVED_fast-refresh-export-incompatibility.md @@ -0,0 +1,44 @@ +--- +type: bug +title: 'SOLVED: Fast Refresh Export Incompatibility' +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# SOLVED: Fast Refresh Export Incompatibility + +## Problem + +Vite dev server was showing Fast Refresh failures with errors like: + +``` +Could not Fast Refresh ("useMessageDB" export is incompatible) +Could not Fast Refresh ("useRegistrationContext" export is incompatible) +``` + +## Root Cause + +Files were exporting both React components AND hooks from the same module. Fast Refresh requires files to export either components OR non-component values, but not both. + +## Solution + +Separated hooks into dedicated files: + +1. Created `/src/components/context/useMessageDB.ts` for the `useMessageDB` hook +2. Created `/src/components/context/useRegistrationContext.ts` for the `useRegistrationContext` hook +3. Updated 39 import statements across the codebase to use the new hook files + +## Files Changed + +- Created 2 new hook files +- Modified `MessageDB.tsx` and `RegistrationPersister.tsx` to only export components +- Updated imports in 39 files under `/src/components/` and `/src/hooks/` + +## Result + +Fast Refresh now works correctly, preserving component state during hot module replacement. + +--- + +_Fixed: 2025-09-06_ diff --git a/.agents/bugs/.solved/SOLVED_modal-navmenu-zindex-stacking.md b/.agents/bugs/.solved/SOLVED_modal-navmenu-zindex-stacking.md new file mode 100644 index 000000000..8cb7e9e8c --- /dev/null +++ b/.agents/bugs/.solved/SOLVED_modal-navmenu-zindex-stacking.md @@ -0,0 +1,294 @@ +--- +type: bug +title: 'SOLVED: Modal NavMenu Z-Index Stacking Issue' +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# SOLVED: Modal NavMenu Z-Index Stacking Issue + + +**Issue**: NavMenu elements appearing above modal overlays +**Root Cause**: CSS stacking contexts created by responsive layout transforms +**Solution**: New Modal component with direct rendering and higher z-index + +**Last Updated**: 2025-01-20 + +## Problem Summary + +After implementing the responsive mobile layout system, NavMenu elements (logo and space icons) were appearing **above** modal overlays, breaking the user experience as modal content was partially obscured. + +## Root Cause + +1. **Responsive layout transforms**: The new responsive layout created CSS stacking contexts via `transform: translateX()` for sidebar animations +2. **Portal rendering limitation**: Original Modal used `createPortal(..., document.body)` with `z-[2000]` +3. **Stacking context trap**: The transforms trapped the modal's z-index, preventing it from appearing above NavMenu elements + +## Solution Applied + +### 1. Created New Modal Component + +**Old Modal (Portal-based)**: + +```tsx +return createPortal( + <div className="fixed inset-0 z-[2000] flex items-center justify-center bg-overlay backdrop-blur"> + {/* modal content */} + </div>, + document.body // Portal rendering - problem source +); +``` + +**New Modal (Direct rendering)**: + +```tsx +return ( + <div className="fixed inset-0 z-[9999] flex items-center justify-center bg-overlay backdrop-blur"> + {/* modal content */} + </div> + // Direct rendering - solution +); +``` + +### 2. Key Changes + +| Aspect | Old | New | +| ---------------- | ------------------------------- | ------------------------------- | +| Rendering | `createPortal` to document.body | Direct component tree rendering | +| Z-Index | `z-[2000]` | `z-[9999]` | +| Stacking Context | Trapped by transforms | Natural hierarchy | + +### 3. Complex Modals at AppWithSearch Level + +For UserSettingsModal, SpaceEditor, and ChannelEditor: + +```tsx +<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-overlay backdrop-blur"> + <UserSettingsModal {...props} /> + <div className="fixed inset-0 -z-10" onClick={onClose} /> +</div> +``` + +## Implementation Details + +1. **Renamed files**: + - `Modal.tsx` → `Modal-OLD.tsx` (preserved for reference) + - `SimpleModal.tsx` → `Modal.tsx` (new implementation) + +2. **Updated all modal imports** to use new Modal component + +3. **Moved complex modals** to AppWithSearch level using modal context + +## Why This Works + +1. **Direct rendering** avoids portal-related stacking context complications +2. **Higher z-index** (`z-[9999]`) ensures modals appear above NavMenu (`z-index: 999`) +3. **AppWithSearch level rendering** places complex modals above Layout component where stacking contexts exist + +## Additional Improvements + +- Enhanced animation system (fade + scale) +- Universal close buttons with responsive sizing +- Proper closing animations with state management +- Consistent modal behavior across all types + +## Files Modified + +- `src/components/Modal.tsx` (new implementation) +- `src/components/Modal-OLD.tsx` (old implementation preserved) +- `src/components/AppWithSearch.tsx` +- All modal components updated to use new system + +## Result + +✅ All modals now appear correctly above NavMenu elements +✅ No z-index conflicts or stacking context issues +✅ Smooth animations and consistent behavior +✅ Backward compatibility maintained + +--- + +_Last updated: 2025-01-20_ + +--- + +# New Modal Component Implementation + +## Summary + +After implementing a new responsive mobile layout, we discovered that the original Modal component had z-index issues where NavMenu elements were appearing above modal overlays. We solved this by creating a new Modal component that uses direct rendering instead of React portals and a higher z-index value. The old component is preserved as `Modal-OLD.tsx` for reference. + +## Detailed Recap + +### Background: Mobile Layout Implementation + +We recently implemented a comprehensive responsive mobile layout system that included: + +- ResponsiveLayoutProvider context for managing mobile/desktop states +- CSS transforms for sidebar animations (`transform: translateX()`) +- New stacking contexts created by the responsive layout components + +### The Problem: NavMenu Z-Index Issues + +After implementing the responsive layout, we noticed that NavMenu elements (logo and space icons) were appearing **above** modal overlays. This broke the user experience as modal content was partially obscured. + +**Root Cause Analysis:** + +- The original Modal component used `createPortal(... document.body)` for rendering +- It had a z-index of `z-[2000]` +- The responsive layout implementation created new CSS stacking contexts via transforms +- These stacking contexts trapped the modal's z-index, preventing it from appearing above NavMenu elements + +### The Solution: New Modal Component + +We created a new Modal component that fundamentally changed the rendering approach: + +#### Key Changes from Old to New: + +**1. Rendering Method:** + +- **Old:** `createPortal(... document.body)` - rendered outside component tree +- **New:** Direct rendering in component tree - avoids portal stacking context issues + +**2. Z-Index:** + +- **Old:** `z-[2000]` - insufficient to overcome stacking context barriers +- **New:** `z-[9999]` - high enough to appear above all UI elements + +**3. Component Structure:** + +```tsx +// Old Modal (Modal-OLD.tsx) +return createPortal( + <div className="fixed inset-0 z-[2000] flex items-center justify-center bg-overlay backdrop-blur"> + {/* modal content */} + </div>, + document.body // Portal rendering - problem source +); + +// New Modal (Modal.tsx) +return ( + <div className="fixed inset-0 z-[9999] flex items-center justify-center bg-overlay backdrop-blur"> + {/* modal content */} + </div> + // Direct rendering - solution +); +``` + +**4. Animation System Enhancement:** + +- **Updated animations:** Changed from `createBox` to `modalOpen` keyframes for consistency +- **New animation pattern:** Fade + subtle scale (opacity: 0, transform: scale(0.95) → opacity: 1, transform: scale(1)) +- **Timing:** 300ms ease-out for smooth, professional animations +- **Closing animations:** All modals now have proper closing animations with state management + +**5. Universal Close Button Implementation:** + +- **Circular close buttons:** Added to all modal types (simple, small, complex) +- **Consistent positioning:** Top-right corner with backdrop blur and subtle background +- **Responsive sizing:** 32px on desktop, 28px on mobile for complex modals; 28px/24px for small modals +- **Hover effects:** Scale and color transitions for better UX +- **Z-index management:** Proper layering to avoid content overlap + +**6. Features Preserved:** + +- Title rendering with proper styling +- `hideClose` prop support (used by JoinSpaceModal) +- Click-outside-to-close functionality +- All CSS styling from Modal.scss + +### Implementation Strategy + +#### Phase 1: Testing the Solution + +1. Created a simple test modal to verify the fix worked +2. Confirmed that `z-[9999]` + direct rendering solved the NavMenu issue + +#### Phase 2: Modal Categories + +We identified two types of modals that needed different approaches: + +**Category 1: Standard Modals (using Modal wrapper)** + +- CreateSpaceModal, JoinSpaceModal, KickUserModal, NewDirectMessageModal +- Image viewer modal in Message component +- **Solution:** Replace `import Modal` with new Modal component + +**Category 2: Custom Modals (using direct wrapper)** + +- UserSettingsModal, SpaceEditor, ChannelEditor +- **Solution:** Render at AppWithSearch level with simple backdrop wrapper: + +```tsx +<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-overlay backdrop-blur"> + <UserSettingsModal {...props} /> + <div className="fixed inset-0 -z-10" onClick={onClose} /> +</div> +``` + +#### Phase 3: Conversion Process + +1. **Enhanced SimpleModal** with all original Modal features +2. **Moved problematic modals** (SpaceEditor, ChannelEditor) to AppWithSearch level using modal context +3. **Updated all modal imports** from SimpleModal to Modal +4. **Renamed files:** `Modal.tsx` → `Modal-OLD.tsx`, `SimpleModal.tsx` → `Modal.tsx` + +### Why the New Solution Works + +**1. Stacking Context Avoidance:** + +- Direct rendering keeps modals in the natural component tree hierarchy +- Avoids portal-related stacking context complications + +**2. Higher Z-Index:** + +- `z-[9999]` ensures modals appear above all UI elements +- Much higher than NavMenu's `z-index: 999` + +**3. Consistent Rendering Level:** + +- UserSettingsModal, SpaceEditor, ChannelEditor render at AppWithSearch level +- This is above the Layout component where stacking context issues occur + +### Files Modified During Implementation + +#### Renamed Files: + +- `src/components/Modal.tsx` → `src/components/Modal-OLD.tsx` +- `src/components/SimpleModal.tsx` → `src/components/Modal.tsx` + +#### Updated Components: + +- `src/components/modals/CreateSpaceModal.tsx` +- `src/components/modals/JoinSpaceModal.tsx` ✅ **Recently updated with responsive layout** +- `src/components/modals/KickUserModal.tsx` +- `src/components/modals/NewDirectMessageModal.tsx` ✅ **Recently updated with responsive buttons** +- `src/components/message/Message.tsx` +- `src/components/AppWithSearch.tsx` +- `src/components/space/ChannelList.tsx` +- `src/components/space/ChannelGroup.tsx` + +#### CSS Changes: + +- Updated `src/components/Modal.scss` with new `modalOpen` animation keyframes +- Enhanced `src/styles/_modal_common.scss` with: + - Universal close button styles (`.modal-complex-close-button`, `.modal-small-close-button`) + - New `.modal-input-display` class for read-only content that looks like inputs + - Responsive typography and spacing improvements +- Removed temporary `invisible-dismissal-high` class from `src/styles/_base.scss` +- Added responsive button patterns to modal SCSS files + +### Result + +✅ **All modals now appear above NavMenu elements** +✅ **Consistent animation system** across all modal types (fade + subtle scale) +✅ **Universal close buttons** with professional styling and responsive behavior +✅ **Enhanced UX** with proper closing animations and hover effects +✅ **Responsive design** improvements for mobile and desktop +✅ **New utility classes** for flexible modal content styling +✅ **Clean codebase** with old component safely preserved as backup + +The modal system is now fully modernized with consistent animations, professional close buttons, and enhanced responsive behavior while maintaining complete backward compatibility. + +For guidelines on the new modal system see .claude\docs\modals.md diff --git a/.agents/bugs/.solved/SOLVED_newdirectmessage-modal-url-to-state-conversion.md b/.agents/bugs/.solved/SOLVED_newdirectmessage-modal-url-to-state-conversion.md new file mode 100644 index 000000000..9bc71cbb7 --- /dev/null +++ b/.agents/bugs/.solved/SOLVED_newdirectmessage-modal-url-to-state-conversion.md @@ -0,0 +1,549 @@ +--- +type: bug +title: 'NewDirectMessage Modal: URL-to-State Conversion' +status: done +created: 2025-01-19T00:00:00.000Z +updated: '2026-01-09' +--- + +# NewDirectMessage Modal: URL-to-State Conversion + + +**Issue**: Modal reopening/flickering when closing from existing conversations +**Solution**: Converted from URL-based to state-based modal management + +**Last Updated**: 2025-01-20 + +## Resolution Summary + +The modal had two separate issues that were both resolved: + +1. **Original Issue (URL-based modal flickering)**: Successfully fixed by converting from URL-based (`/messages/new` route) to state-based modal management using React Context. + +2. **Secondary Issue (React hooks violation)**: After the URL-to-state conversion, another developer introduced a bug by incorrectly using the `useRegistration` hook inside a regular function (`lookupUser`), violating React's Rules of Hooks. This was fixed by converting the hook call to a raw API call. + +**Final Status**: Both issues are now resolved. The modal uses state-based management (no URL changes) and properly handles user registration lookups without hook violations. + +## Original Problem + +The NewDirectMessage modal had a reopening issue when: + +1. User was on an existing conversation (e.g., `/messages/QmV5xWMo5CYSxgAAy6emKFZZPCPKwCsBZKZxXD3mCUZF2n`) +2. Clicked to open modal → URL changed to `/messages/new` +3. Closed modal → `navigate(-1)` caused brief reopen before landing on original conversation + +This didn't happen from EmptyDirectMessage (`/messages/`) because no route conflict occurred. + +## Root Cause Analysis + +The modal visibility was controlled by URL route `/messages/new`: + +- Route change triggered React re-renders +- Modal's 300ms close animation conflicted with navigation timing +- `navigate(-1)` caused race conditions during route transitions + +## Solution Implemented + +### 1. Removed URL-Based Modal Control + +**File**: `src/App.tsx` + +```diff +- <Route +- path="/messages/new" +- element={ +- <AppWithSearch newDirectMessage ... /> +- } +- /> +``` + +### 2. Added State-Based Modal Management + +**File**: `src/components/AppWithSearch.tsx` + +```diff ++ interface ModalContextType { ++ isNewDirectMessageOpen: boolean; ++ openNewDirectMessage: () => void; ++ closeNewDirectMessage: () => void; ++ } + ++ const [isNewDirectMessageOpen, setIsNewDirectMessageOpen] = useState(false); + ++ const modalContextValue = { ++ isNewDirectMessageOpen, ++ openNewDirectMessage: () => setIsNewDirectMessageOpen(true), ++ closeNewDirectMessage: () => setIsNewDirectMessageOpen(false), ++ }; +``` + +### 3. Updated Modal Triggers + +**File**: `src/components/direct/DirectMessageContactsList.tsx` + +```diff +- <Link to="/messages/new"> +- <FontAwesomeIcon ... /> +- </Link> + ++ <FontAwesomeIcon ++ onClick={openNewDirectMessage} ++ ... ++ /> + +- <Link to="/messages/new"> +- <Button>+ Add a friend</Button> +- </Link> + ++ <Button onClick={openNewDirectMessage}> ++ + Add a friend ++ </Button> +``` + +### 4. Updated Layout Modal Control + +**File**: `src/components/Layout.tsx` + +```diff +- const Layout: React.FunctionComponent<{ +- newDirectMessage?: boolean; +- }> = (props) => { +- let navigate = useNavigate(); + ++ const Layout: React.FunctionComponent<{ ++ // removed newDirectMessage prop ++ }> = (props) => { ++ const { isNewDirectMessageOpen, closeNewDirectMessage } = useModalContext(); + +- {props.newDirectMessage && ( +- <NewDirectMessageModal +- visible={props.newDirectMessage ?? false} +- onClose={() => navigate(-1)} +- /> +- )} + ++ {isNewDirectMessageOpen && ( ++ <NewDirectMessageModal ++ visible={isNewDirectMessageOpen} ++ onClose={closeNewDirectMessage} ++ /> ++ )} +``` + +### 5. Updated Modal Navigation + +**File**: `src/components/modals/NewDirectMessageModal.tsx` + +```diff ++ import { useModalContext } from '../AppWithSearch'; ++ const { closeNewDirectMessage } = useModalContext(); + + onClick={() => { + if (!!address) { ++ closeNewDirectMessage(); + navigate('/messages/' + address); + } + }} +``` + +## Files Modified + +1. `src/App.tsx` - Removed `/messages/new` route +2. `src/components/AppWithSearch.tsx` - Added modal state to context +3. `src/components/Layout.tsx` - Updated to use context state +4. `src/components/direct/DirectMessageContactsList.tsx` - Replaced Links with buttons +5. `src/components/modals/NewDirectMessageModal.tsx` - Added context integration + +## Expected Benefits + +- ✅ No URL changes when opening/closing modal +- ✅ No re-render conflicts during navigation +- ✅ Smoother user experience without flicker +- ✅ Maintains all existing functionality + +## Potential Issue Discovered + +After implementation, testing locally shows constant "User does not exist" errors. This might be: + +1. **Expected**: Local development without backend API +2. **Regression**: Previous setup allowed local testing with different browsers/profiles + +## Rollback Instructions (If Needed) + +### Simplest Rollback (Recommended) + +Since commit `b04c61534831fe8e8a1c6a9ba74cb6208a478e73` only modified our modal files: + +```bash +# Simple revert of the entire commit +git revert b04c61534831fe8e8a1c6a9ba74cb6208a478e73 + +# Or if you want to undo without creating a revert commit +git revert b04c61534831fe8e8a1c6a9ba74cb6208a478e73 --no-commit +git reset HEAD # if you want to review changes first +``` + +### Alternative: File-Specific Rollback + +```bash +git checkout HEAD~1 -- src/App.tsx src/components/AppWithSearch.tsx src/components/Layout.tsx src/components/direct/DirectMessageContactsList.tsx src/components/modals/NewDirectMessageModal.tsx +``` + +### Manual Rollback Steps + +1. **Restore `/messages/new` route in App.tsx**: + +```tsx +<Route + path="/messages/new" + element={ + <AppWithSearch + newDirectMessage + kickUserAddress={kickUserAddress} + setKickUserAddress={setKickUserAddress} + > + <DirectMessages ... /> + </AppWithSearch> + } +/> +``` + +2. **Remove modal state from AppWithSearch.tsx**: + - Remove `isNewDirectMessageOpen` from interface and state + - Remove modal methods from context + - Restore `newDirectMessage` prop + +3. **Restore Layout.tsx**: + - Add back `newDirectMessage?: boolean` prop + - Restore `navigate(-1)` onClose handler + - Remove useModalContext usage + +4. **Restore DirectMessageContactsList.tsx**: + - Change buttons back to `<Link to="/messages/new">` + - Remove useModalContext usage + +5. **Restore NewDirectMessageModal.tsx**: + - Remove useModalContext import and usage + - Remove closeNewDirectMessage() call from submit + +## Testing Verification Needed + +1. **Modal behavior**: Open/close from different conversation states +2. **User lookup**: Verify API calls work same as before +3. **Local testing**: Check if different browsers/profiles still work for adding users +4. **Navigation**: Ensure back button, submit button, close button all work correctly + +## Alternative Solutions (If Rollback Needed) + +1. **Delay navigation**: Add setTimeout to match modal animation +2. **Prevent modal during navigation**: Add navigating state flag +3. **Custom history management**: Better handling of navigate(-1) + +--- + +**Note**: Keep `/invite/` route URL-based as it has legitimate reasons for shareable URLs. + +--- + +## UPDATE after human review + +The dev who worked on this modal and fixed the issue: fixed version src\components\modals\NewDirectMessageModal.tsx + +_Dev comment: you can't use hooks in functions in a component, I had to convert the registration hook to the underlying raw api call._ + +I did some testing though and looks like the version right after their first edit (and before mine) was already showing the issue: + +This is what I see: + +- current version: working +- before my edits to the modal and after the dev edits: NOT working +- before all edits: working + +Please compare all the version to see what chnages and identify the real issue: + +VERSION 3: current src\components\modals\NewDirectMessageModal.tsx (working) + +VERSION 2: before my edits to the modal and after the dev edits (NOT working) + +```tsx +import * as React from 'react'; +import Modal from '../Modal'; +import Input from '../Input'; +import Button from '../Button'; +import './NewDirectMessageModal.scss'; +import { t } from '@lingui/core/macro'; +import { base58btc } from 'multiformats/bases/base58'; +import { usePasskeysContext } from '@quilibrium/quilibrium-js-sdk-channels'; +import { useConversations, useRegistration } from '../../hooks'; +import { useNavigate } from 'react-router'; + +type NewDirectMessageModalProps = { + visible: boolean; + onClose: () => void; +}; + +const NewDirectMessageModal: React.FunctionComponent< + NewDirectMessageModalProps +> = (props) => { + let [address, setAddress] = React.useState<string>(''); + let [error, setError] = React.useState<string | null>(null); + let [buttonText, setButtonText] = React.useState<string>(t`Send`); + let navigate = useNavigate(); + + const { data: conversations } = useConversations({ type: 'direct' }); + const conversationsList = [ + ...conversations.pages.flatMap((c: any) => c.conversations), + ]; + const { currentPasskeyInfo } = usePasskeysContext(); + const ownAddress = currentPasskeyInfo?.address; + + const lookupUser = async (): Promise<boolean> => { + setButtonText(t`Looking up user...`); + try { + const { data: registration } = await useRegistration({ address }); + return registration.registered; + } catch { + setError(t`User does not exist.`); + return false; + } finally { + setButtonText(t`Send`); + } + }; + + const resetState = () => { + setError(null); + setButtonText(t`Send`); + }; + + React.useEffect(() => { + resetState(); + + if (!address) return; + + // check if address is the same as own address + if (address === ownAddress) { + setError(t`You cannot send a direct message to yourself.`); + return; + } + + // check if address is exactly 46 characters long + if (address.length !== 46) { + setError(t`Addresses must be exactly 46 characters long.`); + return; + } + + // check if address starts with Qm + if (!address.startsWith('Qm')) { + setError(t`Addresses start with "Qm".`); + return; + } + + // check if conversation already exists + if (conversationsList.find((c: any) => c.address === address)) { + setButtonText(t`Go to conversation`); + return; + } + + lookupUser().then((isRegistered: boolean) => { + setError(isRegistered ? null : t`User does not exist.`); + }); + + try { + base58btc.baseDecode(address); + } catch { + setError( + t`Invalid address format. Addresses must use valid alphanumeric characters.` + ); + return; + } + }, [address, ownAddress]); + + return ( + <Modal + visible={props.visible} + onClose={props.onClose} + title={t`New Direct Message`} + > + <div className="modal-new-direct-message w-full max-w-[500px] mx-auto"> + <div className="mb-4 text-sm text-subtle text-left max-sm:text-center"> + {t`Enter a user's address to start messaging them.`} + </div> + <div> + <Input + className={`w-full !text-xs sm:!text-sm ${error ? 'error' : ''}`} + onChange={(e) => setAddress(e.target.value.trim())} + placeholder={t`User address here`} + /> + </div> + {error && <div className="modal-new-direct-message-error">{error}</div>} + <React.Suspense + fallback={ + <div className="modal-new-direct-message-actions"> + <Button + className="w-full sm:max-w-32 sm:inline-block" + type="primary" + disabled={!address} + onClick={() => {}} + > + {buttonText} + </Button> + </div> + } + > + <div className="modal-new-direct-message-actions"> + <Button + className="w-full sm:inline-block" + type="primary" + disabled={!address || !!error} + onClick={() => { + if (!!address) { + navigate('/messages/' + address); + } + }} + > + {buttonText} + </Button> + </div> + </React.Suspense> + </div> + </Modal> + ); +}; + +export default NewDirectMessageModal; +``` + +VERSION 1: before all edits: working + +```tsx +import * as React from 'react'; +import Modal from '../Modal'; +import Input from '../Input'; +import Button from '../Button'; +import { useConversations } from '../../hooks'; +import { AddressLookup } from '../AddressLookup'; +import './NewDirectMessageModal.scss'; +import { t } from '@lingui/core/macro'; +import { base58btc } from 'multiformats/bases/base58'; +import { usePasskeysContext } from '@quilibrium/quilibrium-js-sdk-channels'; + +type NewDirectMessageModalProps = { + visible: boolean; + onClose: () => void; +}; + +const NewDirectMessageModal: React.FunctionComponent< + NewDirectMessageModalProps +> = (props) => { + let [address, setAddress] = React.useState<string>(''); + let [error, setError] = React.useState<string | null>(null); + + const { data: conversations } = useConversations({ type: 'direct' }); + + const conversationsList = [ + ...conversations.pages.flatMap((c: any) => c.conversations), + ]; + + const { currentPasskeyInfo } = usePasskeysContext(); + const ownAddress = currentPasskeyInfo?.address; + + const lookupUser = (value: string) => { + setAddress(value.trim()); + }; + + React.useEffect(() => { + setError(null); + + if (!address) return; + + // check if address is the same as own address + if (address === ownAddress) { + setError(t`You cannot send a direct message to yourself.`); + return; + } + + // check if address is exactly 46 characters long + if (address.length !== 46) { + setError(t`Addresses must be exactly 46 characters long.`); + return; + } + + // check if address starts with Qm + if (!address.startsWith('Qm')) { + setError(t`Addresses start with "Qm".`); + return; + } + + // check if conversation already exists + if (conversationsList.find((c: any) => c.address === address)) { + setError(t`You already have a conversation with this user.`); + return; + } + + try { + base58btc.baseDecode(address); + } catch { + setError( + t`Invalid address format. Addresses must use valid alphanumeric characters.` + ); + return; + } + }, [address, ownAddress]); + + return ( + <Modal + visible={props.visible} + onClose={props.onClose} + title={t`New Direct Message`} + > + <div className="modal-new-direct-message w-full max-w-[500px] mx-auto"> + <div className="mb-4 text-sm text-subtle text-left max-sm:text-center"> + {t`Enter a user's address to start messaging them.`} + </div> + <div> + <Input + className="w-full !text-xs sm:!text-sm" + onChange={(e) => lookupUser(e.target.value)} + placeholder={t`User address here`} + /> + {error && ( + <div className="modal-new-direct-message-error">{error}</div> + )} + </div> + <React.Suspense + fallback={ + <div className="modal-new-direct-message-actions"> + <Button + className="w-full sm:max-w-32 sm:inline-block" + type="primary" + disabled={!address} + onClick={() => {}} + > + {t`Send`} + </Button> + </div> + } + > + {address.length === 46 && !error && ( + <AddressLookup address={address} /> + )} + </React.Suspense> + {(address.length !== 46 || error) && ( + <div className="modal-new-direct-message-actions"> + <Button + className="w-full sm:max-w-32 sm:inline-block" + type="primary" + disabled={true} + onClick={() => {}} + > + {t`Send`} + </Button> + </div> + )} + </div> + </Modal> + ); +}; + +export default NewDirectMessageModal; +``` diff --git a/.agents/bugs/.solved/SOLVED_react-hooks-violation-conditional-return.md b/.agents/bugs/.solved/SOLVED_react-hooks-violation-conditional-return.md new file mode 100644 index 000000000..fae4b48b9 --- /dev/null +++ b/.agents/bugs/.solved/SOLVED_react-hooks-violation-conditional-return.md @@ -0,0 +1,136 @@ +--- +type: bug +title: 'React Hooks Violation: Conditional Return Before Hooks' +status: done +created: 2025-01-20T00:00:00.000Z +updated: '2026-01-09' +--- + +# React Hooks Violation: Conditional Return Before Hooks + + +**Issue**: React hooks called conditionally due to early return statement +**Files Affected**: src/App.tsx, src/App-prod-new.tsx + +**Severity**: High - Can cause React errors and unpredictable behavior + +## Problem Description + +React's Rules of Hooks require that hooks must be called in the exact same order on every render. The App component violated this rule by having a conditional return statement before two `useEffect` hooks, causing them to be skipped when the condition was met. + +## The Violation + +```tsx +const App = () => { + // Hooks 1-5: Always called + const { currentPasskeyInfo, passkeyRegistrationComplete } = usePasskeysContext(); + const [user, setUser] = useState(...); + const [init, setInit] = useState(false); + const [landing, setLanding] = useState(false); + const [kickUserAddress, setKickUserAddress] = useState<string>(); + + // PROBLEM: Early return before remaining hooks + const isElementsPage = window.location.pathname === '/elements'; + if (isElementsPage) return <Elements />; // ❌ Violation! + + // Hooks 6-7: Only called when NOT on /elements page + useEffect(() => { /* WASM initialization */ }, [init]); + useEffect(() => { /* User setup */ }, [currentPasskeyInfo, ...]); +``` + +## Impact + +### What the Hooks Do + +1. **First useEffect (WASM Initialization)**: + - Initializes WebAssembly module for channel functionality + - Runs once on app startup + - Critical for core app features + - Sets landing page state after 500ms delay + +2. **Second useEffect (User Setup)**: + - Creates user object after passkey authentication + - Sets display name, avatar, online status + - Runs when authentication state changes + +### Consequences of the Violation + +When visiting `/elements` page: + +- ❌ WASM module never initializes +- ❌ User authentication state never sets up +- ❌ React's hook order tracking gets corrupted + +This can cause: + +- State corruption between renders +- Memory leaks +- React development mode warnings/errors +- Unpredictable behavior in production +- Potential crashes when navigating between pages + +## Solution + +Move all hooks before any conditional returns: + +```tsx +const App = () => { + // All hooks first + const { currentPasskeyInfo, passkeyRegistrationComplete } = usePasskeysContext(); + const [user, setUser] = useState(...); + const [init, setInit] = useState(false); + const [landing, setLanding] = useState(false); + const [kickUserAddress, setKickUserAddress] = useState<string>(); + + // All useEffect hooks before conditionals + useEffect(() => { + if (!init) { + setInit(true); + setTimeout(() => setLanding(true), 500); + fetch('/channelwasm_bg.wasm').then(async (r) => { + channel_raw.initSync(await r.arrayBuffer()); + }); + } + }, [init]); + + useEffect(() => { + if (currentPasskeyInfo && currentPasskeyInfo.completedOnboarding && !user) { + setUser({...}); + } + }, [currentPasskeyInfo, passkeyRegistrationComplete, setUser, user]); + + // NOW it's safe to have conditional returns + const isElementsPage = window.location.pathname === '/elements'; + if (isElementsPage) return <Elements />; + + return (...); +}; +``` + +## Files Status + +| File | Status | Notes | +| -------------------- | ------------ | ------------------------------------- | +| src/App.tsx | ✅ Fixed | Hooks moved before conditional return | +| src/App-prod.tsx | ✅ Safe | No violation (no Elements page check) | +| src/App-prod-new.tsx | ❌ Needs Fix | Has the same violation | + +## Prevention + +To prevent this in the future: + +1. Always call all hooks at the top of components +2. Never put conditional returns before hooks +3. Use ESLint rule `react-hooks/rules-of-hooks` +4. Consider using conditional rendering instead of early returns: + ```tsx + return isElementsPage ? <Elements /> : <MainApp />; + ``` + +## Related Issues + +This was discovered while investigating React hooks violations after fixing the NewDirectMessage modal hook issue (see `newdirectmessage-modal-url-to-state-conversion.md`). + +--- + +_Last updated: 2025-01-20_ diff --git a/.agents/bugs/.solved/SOLVED_right-sidebar-overlay.md b/.agents/bugs/.solved/SOLVED_right-sidebar-overlay.md new file mode 100644 index 000000000..11be4dee7 --- /dev/null +++ b/.agents/bugs/.solved/SOLVED_right-sidebar-overlay.md @@ -0,0 +1,298 @@ +--- +type: bug +title: 'SOLVED: Right Sidebar Overlay Issue - Navbar Still Visible' +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# SOLVED: Right Sidebar Overlay Issue - Navbar Still Visible + +## Problem Summary + +**Primary Issue**: When the right sidebar (users panel) was opened on mobile/tablet screens (below 1024px), the navbar space icons remained visible on top of the overlay backdrop, creating a broken UX where the navbar appeared to "float" above the overlay. + +**Secondary Issues Found**: During debugging, additional issues were discovered and resolved: + +- Desktop sidebar became completely invisible +- Mobile sidebar lost its slide animation +- Sidebar content persisted when navigating between pages + +## Root Cause Analysis + +### The Core Problem: Stacking Context Isolation + +The issue was **NOT** a simple z-index problem. Despite using the same z-index values (`z-[9999]`) that successfully fixed similar modal issues, the right sidebar overlay remained below the navbar (`z-index: 999`). + +**Root Cause**: The right sidebar overlay was rendered inside the `Container` component, which has `position: fixed`. This created a new stacking context that isolated the overlay from the navbar's stacking context. + +### Component Hierarchy Analysis + +**Working Modals** (rendered at AppWithSearch level): + +``` +AppWithSearch (z-[9999]) +├── Modal overlays (z-[9999]) ✅ Works - appears above navbar +└── Layout + ├── NavMenu (z-index: 999) + └── Container (position: fixed) + └── Content components +``` + +**Broken Sidebar Overlay** (rendered inside Container): + +``` +AppWithSearch +└── Layout + ├── NavMenu (z-index: 999) + └── Container (position: fixed) <- Creates stacking context + └── DirectMessage/Channel components + └── Sidebar overlay (z-[9999]) ❌ Isolated from navbar +``` + +### Technical Details + +**Container Component** (`src/components/ui/Container.tsx`): + +```css +.container-unit { + position: fixed; /* This creates the stacking context isolation */ + width: calc(100vw - 72px); + height: calc(100vh - 14px); + left: 72px; + top: 14px; + /* ... */ +} +``` + +The `position: fixed` property creates a new stacking context, which isolates all child elements (including the sidebar overlay) from elements outside the container (like the navbar). + +## Solution Implementation + +### Strategy: Multi-Level Rendering + +The solution involved moving sidebar components to different levels based on screen size: + +1. **Mobile Overlay & Content**: Moved to `AppWithSearch` level (above Container stacking context) +2. **Desktop Sidebar**: Kept at component level (normal layout flow) +3. **Context Management**: Extended modal context to manage sidebar state and content + +### Implementation Details + +#### 1. Extended Modal Context (`AppWithSearch.tsx`) + +```tsx +interface ModalContextType { + // ... existing modal methods + showRightSidebar: boolean; + setShowRightSidebar: (show: boolean) => void; + rightSidebarContent: React.ReactNode; + setRightSidebarContent: (content: React.ReactNode) => void; +} +``` + +#### 2. Mobile Overlay & Sidebar at AppWithSearch Level + +```tsx +// AppWithSearch.tsx - Mobile overlay (above navbar) +{ + showRightSidebar && ( + <div + className="fixed inset-0 bg-mobile-overlay z-[9999] lg:hidden" + onClick={() => setShowRightSidebar(false)} + /> + ); +} + +// AppWithSearch.tsx - Mobile sidebar content (above overlay) +{ + rightSidebarContent && ( + <div + className={ + 'w-[260px] bg-mobile-sidebar mobile-sidebar-right overflow-scroll ' + + 'transition-transform duration-300 ease-in-out ' + + (showRightSidebar ? 'translate-x-0' : 'translate-x-full') + + ' fixed top-0 right-0 h-full z-[10000] lg:hidden' + } + > + {rightSidebarContent} + </div> + ); +} +``` + +#### 3. Desktop Sidebar at Component Level + +```tsx +// DirectMessage.tsx & Channel.tsx - Desktop sidebar +<div + className={ + 'w-[260px] bg-mobile-sidebar mobile-sidebar-right overflow-scroll ' + + 'transition-transform duration-300 ease-in-out ' + + (showUsers + ? 'translate-x-0 fixed top-0 right-0 h-full z-[10000] lg:relative lg:top-auto lg:right-auto lg:h-auto lg:z-auto' + : 'translate-x-full fixed top-0 right-0 h-full z-[10000] lg:relative lg:top-auto lg:right-auto lg:h-auto lg:z-auto') + + ' hidden lg:block' + } +> + {/* Desktop sidebar content */} +</div> +``` + +#### 4. Content Management via useEffect + +```tsx +// DirectMessage.tsx & Channel.tsx - Set mobile sidebar content +React.useEffect(() => { + const sidebarContent = ( + <div className="flex flex-col">{/* Sidebar content JSX */}</div> + ); + setRightSidebarContent(sidebarContent); +}, [members, user.currentPasskeyInfo, setRightSidebarContent]); + +// Cleanup on unmount +React.useEffect(() => { + return () => { + setRightSidebarContent(null); + }; +}, [setRightSidebarContent]); +``` + +### Final Z-Index Hierarchy + +``` +AppWithSearch Level: +├── Mobile Sidebar Content: z-[10000] ✅ +├── Mobile Overlay: z-[9999] ✅ +└── Layout Level: + ├── NavMenu: z-index: 999 ✅ + └── Container: position: fixed (creates stacking context) + └── Desktop Sidebar: z-[10000] lg:relative ✅ +``` + +## Issues Resolved + +### 1. ✅ Primary Issue: Navbar Above Overlay + +- **Problem**: Navbar space icons visible above overlay +- **Solution**: Moved overlay to AppWithSearch level with `z-[9999]` +- **Result**: Overlay now appears above navbar on mobile + +### 2. ✅ Secondary Issue: Desktop Sidebar Invisible + +- **Problem**: Desktop sidebar completely hidden after mobile fix +- **Solution**: Added desktop-only sidebar rendering with `hidden lg:block` +- **Result**: Desktop sidebar visible and properly positioned + +### 3. ✅ Secondary Issue: Mobile Animation Lost + +- **Problem**: Mobile sidebar appeared instantly without slide animation +- **Solution**: Restored conditional `translate-x-0` vs `translate-x-full` +- **Result**: Smooth slide-in/out animation on mobile + +### 4. ✅ Secondary Issue: Content Persistence + +- **Problem**: Sidebar content persisted when navigating between pages +- **Solution**: Added cleanup effects to clear content on component unmount +- **Result**: Clean navigation without content artifacts + +## Technical Lessons Learned + +### 1. Stacking Context Isolation + +- `position: fixed` creates stacking contexts that isolate z-index values +- Elements in different stacking contexts cannot be compared by z-index alone +- Solution: Move elements to the same stacking context level + +### 2. Responsive Design Complexity + +- Mobile and desktop may require different rendering strategies +- Don't assume the same solution works for both responsive states +- Test both breakpoints independently + +### 3. Component Hierarchy Matters + +- Where a component is rendered in the tree affects its stacking behavior +- Moving components higher in the tree can resolve stacking issues +- Consider component placement as part of the solution + +### 4. Context for Cross-Component State + +- Complex UI states may need to be managed at higher component levels +- React context is useful for sharing state across component boundaries +- Cleanup is important to prevent state leaks between page navigations + +## Files Modified + +### Primary Changes + +- `src/components/AppWithSearch.tsx` - Mobile overlay and sidebar rendering +- `src/components/direct/DirectMessage.tsx` - Desktop sidebar and content management +- `src/components/space/Channel.tsx` - Desktop sidebar and content management + +### Supporting Files + +- `src/components/ui/Container.tsx` - Identified stacking context source +- `src/components/navbar/NavMenu.scss` - Confirmed navbar z-index +- `src/styles/_components.scss` - Confirmed overlay styling + +## Testing Results + +### Mobile (< 1024px) + +- ✅ Overlay appears above navbar +- ✅ Sidebar slides in from right smoothly +- ✅ Clicking overlay closes sidebar +- ✅ Sidebar content clears when navigating + +### Desktop (>= 1024px) + +- ✅ Sidebar visible when toggled +- ✅ Sidebar positioned correctly in layout +- ✅ No overlay interference +- ✅ Sidebar content clears when navigating + +### Cross-Browser Compatibility + +- ✅ Works across different screen sizes (768px-1024px breakpoint) +- ✅ Consistent behavior in different browsers +- ✅ No performance issues with z-index changes + +## Future Considerations + +### 1. Stacking Context Audit + +Consider auditing other components for potential stacking context issues, especially: + +- Elements with `position: fixed` or `position: sticky` +- Elements with `transform` properties +- Elements with `opacity` less than 1 + +### 2. Responsive Design Patterns + +Establish patterns for components that need different behavior on mobile vs desktop: + +- Consider mobile-first design approach +- Use responsive utility classes consistently +- Test both breakpoints during development + +### 3. Z-Index Management + +Consider implementing a z-index scale/system to prevent future conflicts: + +- Document z-index values and their purposes +- Use CSS custom properties for important z-index values +- Regular audit of z-index usage across the application + +## Related Documentation + +- `.agents/docs/new-modal-component.md` - Original modal z-index fix +- `.agents/docs/responsive-layout.md` - Responsive design patterns +- `.agents/docs/modals.md` - Modal system guidelines + +--- + +**Date Solved**: 2025-01-16 +**Severity**: High (Mobile UX breaking) +**Time to Resolution**: Multiple debugging sessions +**Root Cause**: Stacking context isolation from Container component diff --git a/.agents/bugs/.solved/SOLVED_search-results-page-refresh-and-focus-stealing.md b/.agents/bugs/.solved/SOLVED_search-results-page-refresh-and-focus-stealing.md new file mode 100644 index 000000000..1b7ee0154 --- /dev/null +++ b/.agents/bugs/.solved/SOLVED_search-results-page-refresh-and-focus-stealing.md @@ -0,0 +1,463 @@ +--- +type: bug +status: done +created: 2025-09-09T00:00:00.000Z +updated: '2026-01-09' +--- + + +**Priority**: High + +**Session Duration**: Extended debugging session + +## Problem Statement + +When users type 3+ characters in the search input and search results appear, two critical issues occur: + +### Primary Issues + +1. **Page Refresh on Search Results Appearance** + - Occurs immediately when user types the 3rd character + - Search results panel appears but page refreshes instantly + - Happens consistently, not dependent on user input after results appear + - Not related to Enter key presses or form submissions + +2. **Focus Stealing from Search Input** + - Search input loses focus when results appear + - Input border remains accent color but cursor disappears + - User must click in search input again to continue typing + - Affects both typing flow and user experience + +## Root Cause Analysis + +**CONFIRMED ROOT CAUSE**: The issue is caused by **data-fetching hooks in SearchResultItem components** making simultaneous API/database calls when search results mount. + +### Exact Culprit Code + +**Location**: `/src/components/search/SearchResultItem.tsx` + +**Problematic Hooks**: + +1. `useSearchResultDisplayDM` - Makes async database calls via `messageDB.getConversation()` +2. `useSearchResultDisplaySpace` - Makes React Query API calls via `useUserInfo()` and `useSpace()` + +**What Happens**: + +1. User types 3rd character → `setShowResults(query.trim().length >= minQueryLength)` in `useGlobalSearchState.ts:31` +2. SearchResults component renders with `isOpen={true}` +3. Multiple SearchResultItem components mount simultaneously (typically 8-10 results) +4. Each SearchResultItem calls data-fetching hooks immediately on mount +5. **Cascade of simultaneous async operations overwhelms the system** +6. This somehow triggers browser form submission behavior → page refresh +7. Async state updates interfere with input focus management → focus stealing + +### Technical Evidence + +**Isolation Test Results**: + +- ✅ **Search without results panel**: No issues when `setShowResults(false)` always +- ✅ **Results panel with simple content**: No issues with basic `<div>` elements +- ❌ **Results panel with SearchResultItem**: Issues reproduce immediately +- ❌ **SearchResultItem without data hooks**: No issues when hooks disabled +- ❌ **SearchResultItem with data hooks**: Issues reproduce consistently + +This confirms the data-fetching hooks are the exact root cause. + +## Code Architecture Analysis + +### Current Search Result Display Architecture + +```typescript +// SearchResultItem.tsx +const { channelName, icon } = useSearchResultDisplayDM({ result }); // DATABASE CALLS +const { displayName, channelName } = useSearchResultDisplaySpace({ result }); // API CALLS +``` + +### Hook Details + +**useSearchResultDisplayDM** (`/src/hooks/business/search/useSearchResultDisplayDM.ts`): + +- Makes `messageDB.getConversation()` database calls in useEffect +- Updates multiple state variables (`setIcon`, `setDisplayName`, `setIsLoading`) +- Designed for individual component use, not bulk operations + +**useSearchResultDisplaySpace** (`/src/hooks/business/search/useSearchResultDisplaySpace.ts`): + +- Uses React Query hooks (`useUserInfo`, `useSpace`) for API calls +- Makes simultaneous network requests for user and space data +- Each search result triggers separate API calls + +### The Fundamental Problem + +**Anti-Pattern**: Multiple components making expensive async operations simultaneously on mount. + +When 8-10 SearchResultItem components mount at once: + +- 8-10 database calls via `useSearchResultDisplayDM` +- 16-20 API calls via `useSearchResultDisplaySpace` (user + space per result) +- Multiple React state updates happening concurrently +- Browser overwhelmed by async operation cascade + +## Failed Solution Attempts + +### Attempt 1: Fix DropdownPanel useEffect Cleanup + +**Approach**: Fixed useEffect cleanup function in DropdownPanel.tsx +**Result**: ❌ FAILED - No impact on the issue +**Reason**: The issue was not in DropdownPanel event listeners + +### Attempt 2: Prevent Enter Key Form Submission + +**Approach**: Always prevent Enter key in `useKeyboardNavigation.ts` +**Result**: ❌ FAILED - No impact on the issue +**Reason**: Issue occurs when panel appears, not from keyboard input + +### Attempt 3: Remove SearchResultItem tabIndex + +**Approach**: Changed `tabIndex={0}` to `tabIndex={-1}` to prevent focus stealing +**Result**: ❌ FAILED - No impact on the issue +**Reason**: Focus stealing was caused by async operations, not focusable elements + +### Attempt 4: Defensive Async Operations + +**Approach**: Added defensive checks, timeouts, and requestAnimationFrame to database calls +**Result**: ❌ FAILED - Issue persisted +**Reason**: Any async operation in the hooks still caused the problem + +### Attempt 5: Staggered Loading with useState + +**Approach**: Used `useState` and `useEffect` to delay API calls with increasing timeouts +**Result**: ❌ FAILED - Issue persisted +**Reason**: The state changes themselves (`setShouldLoadData(true)`) triggered page refresh + +### Attempt 6: Conditional Hook Execution + +**Approach**: Added `enabled` flag to hooks to disable API calls +**Result**: ❌ FAILED - Couldn't implement due to React Rules of Hooks +**Reason**: Hooks cannot be called conditionally + +### Attempt 7: Complete Hook Elimination + +**Approach**: Removed all data-fetching hooks, show truncated IDs instead +**Result**: ✅ FIXES PAGE REFRESH - ❌ BREAKS USER EXPERIENCE +**Reason**: Users see cryptographic keys instead of readable names + +## Current State + + +Proper display names have been restored: + +- ✅ Real channel names (e.g., "#general", "#dev-chat") +- ✅ Real user names (e.g., "John Smith", "Alice Johnson") +- ✅ Proper DM display names + +**Consequence**: The original issues have returned: + +- ❌ Page refresh when search results appear (after 3+ characters typed) +- ❌ Focus stealing from search input (cursor disappears, requires click to continue) + +**Code Status**: All data-fetching hooks restored to original state: + +- `useSearchResultDisplayDM` - Making database calls for DM user info +- `useSearchResultDisplaySpace` - Making API calls for user and space info + +This confirms the root cause analysis - the display hooks are indeed the culprit causing both issues. + +## Required Real Solution + +**Must Have Requirements**: + +1. ✅ No page refresh when search results appear +2. ✅ No focus stealing from search input +3. ✅ **Real channel names and user display names** (not IDs) +4. ✅ Fast search results appearance +5. ✅ Full search functionality + +## Potential Approaches to Investigate + +### Option 1: Batch API Strategy + +- Pre-load all user and space data in a global cache +- Search results use cached data instead of individual API calls +- Challenge: Cache invalidation and memory usage + +### Option 2: Server-Side Search Enhancement + +- Modify search API to include display names in results +- Eliminate need for client-side data fetching +- Challenge: Requires backend changes + +### Option 3: Virtualized Loading + +- Only load display names for visible search results +- Use intersection observer for lazy loading +- Challenge: Complex implementation, may still have timing issues + +### Option 4: Search Results Refactor + +- Move from individual SearchResultItem hooks to bulk data loading +- Single API call for all search result metadata +- Challenge: Requires architectural changes to hook system + +## Files Involved + +- `/src/components/search/SearchResultItem.tsx` - Main component with problematic hooks +- `/src/hooks/business/search/useSearchResultDisplayDM.ts` - Database call hook +- `/src/hooks/business/search/useSearchResultDisplaySpace.ts` - API call hook +- `/src/components/search/SearchResults.tsx` - Results container +- `/src/hooks/business/search/useGlobalSearchState.ts` - Controls when results show + +## Conclusion + +This is a **fundamental architecture issue** where the current search result display pattern (individual async hooks per component) is incompatible with bulk search result rendering. + +**Current Trade-off Choice**: + +- ✅ **Proper user experience** (real display names) +- ❌ **Technical issues** (page refresh and focus stealing) + +This demonstrates that any real solution requires either: + +1. **Changing how display data is fetched** (bulk/cached instead of individual) +2. **Changing when display data is fetched** (pre-loaded instead of on-demand) +3. **Changing what display data is available** (server-side inclusion) + +**Immediate Impact**: Users can now see proper channel and user names in search results, but must deal with page refresh and focus issues until architectural solution is implemented. + +**Next Steps**: Architectural decision needed on which approach to pursue for maintaining user-friendly display names while eliminating performance issues. + +--- + +## SOLUTION IMPLEMENTED ✅ + +**Date Resolved**: 2025-09-09 +**Approach Used**: Batch API Strategy (Option 1) + +### Implementation Summary + +The solution completely eliminates the cascading async operation problem by implementing a **batch data loading architecture** that replaces individual component hooks with a centralized batch hook. + +### Key Changes Made + +#### 1. Created Batch Search Results Display Hook + +**File**: `/src/hooks/business/search/useBatchSearchResultsDisplay.ts` + +**Purpose**: Replace individual `useSearchResultDisplayDM` and `useSearchResultDisplaySpace` hooks with a single batch loading hook. + +**Key Features**: + +- Uses React Query's `useQueries` for efficient batch API calls +- Extracts unique user IDs and space IDs from all search results +- Makes single batch call for all users and spaces instead of individual calls per result +- Creates lookup maps for efficient data access +- Implements proper error handling and retry logic +- Uses cache-friendly configuration to prevent excessive API calls + +**Core Implementation**: + +```typescript +// Extract unique identifiers for batch operations +const { uniqueUserIds, uniqueSpaceIds } = useMemo(() => { + const userIds = new Set<string>(); + const spaceIds = new Set<string>(); + + results.forEach((result) => { + const isDM = result.message.spaceId === result.message.channelId; + if (isDM) { + if (result.message.content.senderId !== currentPasskeyInfo?.address) { + userIds.add(result.message.content.senderId); + } + } else { + userIds.add(result.message.content.senderId); + spaceIds.add(result.message.spaceId); + } + }); + + return { + uniqueUserIds: Array.from(userIds), + uniqueSpaceIds: Array.from(spaceIds), + }; +}, [results, currentPasskeyInfo]); + +// Batch fetch user info for all unique users +const userInfoQueries = useQueries({ + queries: uniqueUserIds.map((address) => ({ + queryKey: buildUserInfoKey({ address }), + queryFn: buildUserInfoFetcher({ messageDB, address }), + refetchOnMount: false, // Use cached data when available + staleTime: 5 * 60 * 1000, // 5 minutes + retry: 1, // Limit retries to prevent cascading failures + })), +}); +``` + +#### 2. Enhanced Focus Management + +**File**: `/src/hooks/business/search/useSearchFocusManager.ts` + +**Purpose**: Prevent focus stealing during async operations with specialized focus management. + +**Key Features**: + +- Uses `requestAnimationFrame` for proper timing of focus restoration +- Tracks user interaction to prevent unwanted focus changes +- Implements `preventFocusSteal` wrapper for async operations +- Provides `maintainFocus` for proactive focus maintenance + +**Core Implementation**: + +```typescript +const preventFocusSteal = useCallback( + (callback: () => void) => { + // Store current focus before async operation + const currentFocus = document.activeElement as HTMLElement; + const shouldRestoreFocus = currentFocus === searchInputRef?.current; + + // Execute the callback + callback(); + + // Schedule focus restoration if needed + if (shouldRestoreFocus && searchInputRef?.current) { + focusTimeoutRef.current = setTimeout(() => { + if (searchInputRef?.current && !isUserInteracting.current) { + searchInputRef.current.focus(); + } + }, 50); + } + }, + [searchInputRef] +); +``` + +#### 3. Updated SearchResults Component + +**File**: `/src/components/search/SearchResults.tsx` + +**Changes**: + +- Added `useBatchSearchResultsDisplay` hook call +- Pass batch-loaded display data to individual SearchResultItem components +- Eliminated individual async hook calls per component + +**Implementation**: + +```typescript +// Batch load display data for all search results +const { resultsData } = useBatchSearchResultsDisplay({ + results, +}); + +// Pass display data to each result item +{results.map((result, index) => ( + <SearchResultItem + key={`${result.message.messageId}-${index}`} + result={result} + displayData={resultsData.get(result.message.messageId)} + // ... other props + /> +))} +``` + +#### 4. Refactored SearchResultItem Components + +**File**: `/src/components/search/SearchResultItem.tsx` + +**Changes**: + +- Removed individual `useSearchResultDisplayDM` and `useSearchResultDisplaySpace` hook calls +- Accept `displayData` prop from batch hook +- Use pre-loaded display data instead of triggering async operations + +**Before** (Problematic): + +```typescript +// Each component triggered individual async operations +const { channelName, icon } = useSearchResultDisplayDM({ result }); +const { displayName, channelName } = useSearchResultDisplaySpace({ result }); +``` + +**After** (Optimized): + +```typescript +// Use pre-loaded batch data +const channelName = + displayData?.channelName || + (displayData?.isLoading ? 'Loading...' : 'Unknown'); +const displayName = + displayData?.displayName || + (displayData?.isLoading ? 'Loading...' : 'Unknown User'); +``` + +#### 5. Enhanced SearchBar with Focus Management + +**File**: `/src/components/search/SearchBar.tsx` + +**Changes**: + +- Added `useSearchFocusManager` hook integration +- Wrapped input change handler with `preventFocusSteal` +- Added input ref for direct focus management + +### Architecture Changes + +#### Before (Problematic Pattern): + +``` +SearchResults renders 10 SearchResultItem components + └── Each SearchResultItem calls: + ├── useSearchResultDisplayDM (database call) + └── useSearchResultDisplaySpace (2 API calls) + +Result: 10 + 20 = 30 simultaneous async operations → CASCADE OVERLOAD +``` + +#### After (Optimized Pattern): + +``` +SearchResults calls useBatchSearchResultsDisplay once + ├── Extracts unique IDs: 5 users, 3 spaces + ├── Makes 5 user API calls + 3 space API calls = 8 total + └── Creates lookup map for all results + +SearchResultItem components use pre-loaded data from lookup map + +Result: 8 batched async operations → EFFICIENT LOADING +``` + +### Performance Improvements + +1. **Reduced API Calls**: From 20-30 individual calls to 5-10 batch calls +2. **Eliminated Cascade Effect**: No simultaneous mounting of async operations +3. **Improved Caching**: React Query cache reuse across components +4. **Better Error Handling**: Centralized retry logic and error states +5. **Focus Stability**: Proactive focus management prevents stealing + +### Results Achieved + +✅ **No page refresh** when search results appear +✅ **No focus stealing** from search input +✅ **Real display names** maintained (channels, users, DMs) +✅ **Improved performance** with batch loading +✅ **Better error handling** and loading states +✅ **Maintained all existing functionality** + +### Files Modified + +- `/src/hooks/business/search/useBatchSearchResultsDisplay.ts` (NEW) +- `/src/hooks/business/search/useSearchFocusManager.ts` (NEW) +- `/src/hooks/business/search/index.ts` (UPDATED - exports) +- `/src/components/search/SearchResults.tsx` (UPDATED - batch integration) +- `/src/components/search/SearchResultItem.tsx` (UPDATED - use batch data) +- `/src/components/search/SearchBar.tsx` (UPDATED - focus management) +- `/src/components/search/GlobalSearch.tsx` (UPDATED - props) + +### Testing Recommendations + +1. **Volume Test**: Search terms returning 10+ results +2. **Rapid Typing Test**: Fast typing without focus loss +3. **Network Performance**: Monitor reduced API calls in dev tools +4. **Focus Behavior**: Verify cursor stays visible while typing +5. **Display Names**: Confirm real names appear, not IDs or "Loading..." + +--- diff --git a/.agents/bugs/.solved/SOLVED_tooltip-flickering-top-left-on-fast-mouse-movement.md b/.agents/bugs/.solved/SOLVED_tooltip-flickering-top-left-on-fast-mouse-movement.md new file mode 100644 index 000000000..3f84247f9 --- /dev/null +++ b/.agents/bugs/.solved/SOLVED_tooltip-flickering-top-left-on-fast-mouse-movement.md @@ -0,0 +1,75 @@ +--- +type: bug +title: Tooltip Flickering at Top-Left on Fast Mouse Movement +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Tooltip Flickering at Top-Left on Fast Mouse Movement + +> **AI-Generated**: May contain errors. Verify before use. + +## Status: SOLVED + +## Problem + +When moving the mouse quickly over tooltips, they briefly flash at the top-left corner (0,0) before appearing in the correct position. + +- **Scope**: App-wide (NavMenu, Message Actions, Header Icons, etc.) +- **Cause**: `opacity: 1 !important` in CSS bypassed react-tooltip's fade-in transition, making the brief (0,0) positioning visible + +## Solution + +Two changes in `ReactTooltip.tsx` and `ReactTooltip.scss`: + +### 1. Add `delayShow={50}` (ReactTooltip.tsx:197) + +```tsx +<Tooltip + // ... other props + delayShow={50} +/> +``` + +Gives Floating UI time to calculate position before tooltip appears. + +### 2. Let react-tooltip handle opacity transition (ReactTooltip.scss) + +**Removed** from mixin: +```scss +opacity: 1 !important; // This was causing the flicker +``` + +**Added** at bottom of file: +```scss +.react-tooltip { + --rt-opacity: 1; // Override default 0.9 to ensure full opacity +} +``` + +The key insight: react-tooltip defaults to `--rt-opacity: 0.9` (90% opaque). Its built-in fade-in transition masks the (0,0) positioning, but we need to override the CSS variable for full opacity. + +## What Didn't Work + +| Attempt | Result | +|---------|--------| +| Remove duplicate anchor IDs | No effect (but was a valid fix) | +| CSS transition-delay | Position updates before visibility | +| Shared tooltip with data attributes | Tooltips stopped appearing | +| TooltipAnchor + TooltipRenderer components | Marginal improvement only | +| CSS selectors targeting position (0,0) | Selectors didn't match style format | + +## Files Changed + +- [ReactTooltip.tsx](../../src/components/ui/ReactTooltip.tsx) - Added `delayShow={50}` +- [ReactTooltip.scss](../../src/components/ui/ReactTooltip.scss) - Removed forced opacity, added `--rt-opacity: 1` + +## References + +- [react-tooltip Issue #1010](https://github.com/ReactTooltip/react-tooltip/issues/1010) + +--- + + +_Solved: 2025-12-15 - Fix: delayShow + let react-tooltip handle opacity transition + override --rt-opacity to 1_ diff --git a/.agents/bugs/.solved/action-queue-plaintext-private-keys.md b/.agents/bugs/.solved/action-queue-plaintext-private-keys.md new file mode 100644 index 000000000..50659dfa1 --- /dev/null +++ b/.agents/bugs/.solved/action-queue-plaintext-private-keys.md @@ -0,0 +1,897 @@ +--- +type: bug +title: Action Queue Bypasses SDK Encryption for Identity Keys +status: done +created: 2025-12-20T00:00:00.000Z +updated: '2026-01-09' +--- + +# Action Queue Bypasses SDK Encryption for Identity Keys + +> **AI-Generated**: May contain errors. Verify before use. + +> **📍 Reorganized**: This report has been split and moved to: +> - [006-plaintext-private-keys-bug.md](../reports/action-queue/006-plaintext-private-keys-bug.md) - Detailed bug description +> - [007-plaintext-private-keys-fix.md](../reports/action-queue/007-plaintext-private-keys-fix.md) - Actionable fix + +**Severity**: Critical +**Type**: Security Regression + + +**Discovered During**: DM offline registration persistence analysis + +**TL;DR**: The SDK encrypts DeviceKeyset and UserKeyset at IndexedDB id=2 using AES-GCM. The action queue stores the **exact same keys** in plaintext, bypassing this encryption. + +--- + +## 🔧 FIX: Encrypt Action Queue Context + +> **Effort**: ~50 lines of code | **Risk**: Low | **Pattern**: Already exists in codebase + +### What to Do + +Encrypt the `context` field in `QueueTask` before storing to IndexedDB. The encryption pattern already exists in `ConfigService.ts`. + +### Files to Modify + +1. **`src/services/ActionQueueService.ts`** - Add encrypt/decrypt around context +2. **`src/types/actionQueue.ts`** - Update interface to `EncryptedQueueTask` + +### Implementation + +```typescript +// src/utils/encryption.ts (new shared utility) + +import { UserKeyset } from '@quilibrium/quilibrium-js-sdk-channels'; + +// Cache the derived AES key for the tab lifecycle (performance optimization) +// Safe because userKeyset is already in React state for the entire tab lifecycle +let cachedAesKey: CryptoKey | null = null; + +export async function getOrDeriveAesKey(userKey: UserKeyset): Promise<CryptoKey> { + if (cachedAesKey) return cachedAesKey; + + const derived = await crypto.subtle.digest( + 'SHA-512', + Buffer.from(new Uint8Array(userKey.user_key.private_key)) + ); + cachedAesKey = await crypto.subtle.importKey( + 'raw', + derived.slice(0, 32), + { name: 'AES-GCM' }, + false, + ['encrypt', 'decrypt'] + ); + return cachedAesKey; +} + +// Call on tab close or if user somehow logs out +export function clearCachedAesKey(): void { + cachedAesKey = null; +} + +export async function encryptContext( + context: Record<string, unknown>, + aesKey: CryptoKey +): Promise<{ encryptedContext: string; iv: string }> { + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encrypted = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv }, + aesKey, + Buffer.from(JSON.stringify(context), 'utf-8') + ); + return { + encryptedContext: Buffer.from(encrypted).toString('hex'), + iv: Buffer.from(iv).toString('hex'), + }; +} + +export async function decryptContext( + encryptedContext: string, + iv: string, + aesKey: CryptoKey +): Promise<Record<string, unknown>> { + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: Buffer.from(iv, 'hex') }, + aesKey, + Buffer.from(encryptedContext, 'hex') + ); + return JSON.parse(Buffer.from(decrypted).toString('utf-8')); +} +``` + +### Performance Note + +The AES key is cached in memory for the tab lifecycle. This is safe because: +- The source `userKeyset` is already stored in React state (`MessageDB.tsx`) for the entire tab +- An attacker who can read the cached AES key can also read the source private key +- Key derivation (~1ms) only happens once per tab, not per action + +### Updated Interface + +```typescript +// src/types/actionQueue.ts + +export interface QueueTask { + id?: number; + taskType: ActionType; + encryptedContext: string; // AES-GCM encrypted (was: context) + iv: string; // 12-byte IV (new) + key: string; // Unchanged (for queue grouping) + status: TaskStatus; + retryCount: number; + maxRetries: number; + nextRetryAt: number; + createdAt: number; + processedAt?: number; + processingStartedAt?: number; + error?: string; +} +``` + +### Integration Points + +**Key insight**: `ActionQueueService.enqueue()` only receives `(type, context, key)` - it doesn't have direct access to `keyset`. However, tasks that contain private keys (DM tasks, config save) already include `keyset` in their context. + +**Complete task types by security requirement:** + +| Task Type | Has `keyset` in context? | Contains private keys? | Needs encryption? | +|-----------|--------------------------|------------------------|-------------------| +| **DM Tasks (Double Ratchet)** | | | | +| `send-dm` | ✅ Yes (`keyset`) | ✅ **YES** - DeviceKeyset + UserKeyset | **CRITICAL** | +| `reaction-dm` | ✅ Yes (via `dmActionContext`) | ✅ **YES** - DeviceKeyset + UserKeyset | **CRITICAL** | +| `delete-dm` | ✅ Yes (via `dmActionContext`) | ✅ **YES** - DeviceKeyset + UserKeyset | **CRITICAL** | +| `edit-dm` | ✅ Yes (via `dmActionContext`) | ✅ **YES** - DeviceKeyset + UserKeyset | **CRITICAL** | +| **Config/Settings** | | | | +| `save-user-config` | ✅ Yes (`keyset`) | ✅ **YES** - Full keyset | **CRITICAL** | +| **Moderation (Space)** | | | | +| `kick-user` | ✅ Yes (`user_keyset`, `device_keyset`) | ✅ **YES** - Both keysets | **CRITICAL** | +| `mute-user` | ❌ No (`currentPasskeyInfo`) | ❌ No (just address) | Optional | +| `unmute-user` | ❌ No (`currentPasskeyInfo`) | ❌ No (just address) | Optional | +| **Space Tasks (Triple Ratchet)** | | | | +| `send-channel-message` | ❌ No | ❌ No (signed message) | Optional | +| `reaction` | ❌ No (`currentPasskeyInfo`) | ❌ No (just address) | Optional | +| `pin-message` | ❌ No (`currentPasskeyInfo`) | ❌ No | Optional | +| `unpin-message` | ❌ No (`currentPasskeyInfo`) | ❌ No | Optional | +| `edit-message` | ❌ No (`currentPasskeyInfo`) | ❌ No | Optional | +| `delete-message` | ❌ No (`currentPasskeyInfo`) | ❌ No | Optional | +| `update-space` | ❌ No (just `spaceId`, `space`) | ❌ No | Optional | + +**Summary**: 6 task types contain private keys and require encryption: +- `send-dm`, `reaction-dm`, `delete-dm`, `edit-dm` (DM actions) +- `save-user-config` (config sync) +- `kick-user` (moderation with key-based auth) + +**Implementation approach** - extract `keyset` from context when available: + +1. **On enqueue** (`ActionQueueService.enqueue`): + ```typescript + async enqueue(type: ActionType, context: Record<string, unknown>, key: string) { + // Extract userKeyset from context - different field names for different task types: + // - DM tasks, save-user-config: context.keyset.userKeyset + // - kick-user: context.user_keyset (direct UserKeyset) + const keyset = context.keyset as { userKeyset: UserKeyset } | undefined; + const userKeyset = keyset?.userKeyset ?? (context.user_keyset as UserKeyset | undefined); + + let taskToStore: QueueTask; + + if (userKeyset) { + // Tasks with private keys - MUST encrypt + const aesKey = await getOrDeriveAesKey(userKeyset); + const { encryptedContext, iv } = await encryptContext(context, aesKey); + taskToStore = { ...task, encryptedContext, iv, context: undefined }; + } else { + // Space tasks (mute/unmute, reactions, pins, etc.) - no private keys, store as-is + taskToStore = { ...task, context, encryptedContext: undefined, iv: undefined }; + } + + // ... store taskToStore + } + ``` + +2. **Add `setUserKeyset()` method** (follows existing `setHandlers()` pattern): + ```typescript + // ActionQueueService.ts + private userKeyset: UserKeyset | null = null; + + /** + * Set the user's keyset for encrypting/decrypting queue context. + * Call this after passkey auth completes (same place as MessageDB.setKeyset). + */ + setUserKeyset(keyset: UserKeyset): void { + this.userKeyset = keyset; + // Also cache the derived AES key for performance + getOrDeriveAesKey(keyset).catch(() => {}); + } + + clearUserKeyset(): void { + this.userKeyset = null; + clearCachedAesKey(); + } + ``` + + **Why this is needed**: On `processTask`, the context is encrypted - we can't read `keyset` from it to decrypt! The `setUserKeyset()` approach: + - Follows existing patterns (`setHandlers()`, `setIsOnlineCallback()`) + - Single source of truth for the keyset + - Works for both enqueue and process + - Integrates with AES key caching + +3. **On process** (`ActionQueueService.processTask`): + ```typescript + async processTask(task: QueueTask) { + let context: Record<string, unknown>; + + if (task.encryptedContext && task.iv) { + // Encrypted task - need to decrypt + if (!this.userKeyset) { + throw new Error('ActionQueueService: userKeyset not set, cannot decrypt task'); + } + const aesKey = await getOrDeriveAesKey(this.userKeyset); + context = await decryptContext(task.encryptedContext, task.iv, aesKey); + } else { + // Unencrypted Space task + context = task.context!; + } + + // ... pass context to handler + } + ``` + +4. **Wire up in context** (where keyset is set after auth): + ```typescript + // In RegistrationPersister.tsx or MessageDB context, after passkey auth: + setKeyset({ deviceKeyset, userKeyset }); // existing + actionQueueService.setUserKeyset(userKeyset); // new - add this + ``` + +### Updated Interface (supports both encrypted and unencrypted) + +```typescript +// src/types/actionQueue.ts + +export interface QueueTask { + id?: number; + taskType: ActionType; + + // For encrypted tasks (DM, config) - mutually exclusive with context + encryptedContext?: string; // AES-GCM encrypted + iv?: string; // 12-byte IV + + // For unencrypted tasks (Space) - mutually exclusive with encryptedContext + context?: Record<string, unknown>; + + key: string; + status: TaskStatus; + retryCount: number; + maxRetries: number; + nextRetryAt: number; + createdAt: number; + processedAt?: number; + processingStartedAt?: number; + error?: string; +} +``` + +### Why This Works + +- **Keys already in memory**: After app open, `userKeyset` is in React state for the entire tab lifecycle +- **No UX change**: No additional prompts or auth required +- **Consistent pattern**: Matches `ConfigService.ts` encryption approach +- **True protection for passkey users**: AES key derived from hardware-backed Ed448 +- **No performance impact**: Key derived once per tab, cached for all subsequent operations +- **Selective encryption**: Only encrypts tasks that actually contain private keys + +--- + +## Symptoms + +The action queue (`action_queue` IndexedDB store) persists sensitive cryptographic material in plaintext, including: + +1. **User's private keys** (DeviceKeyset, UserKeyset) +2. **Counterparty registration data** (public keys, inbox addresses) +3. **Message content** (signed but unencrypted at rest) +4. **User identity** (display name, profile picture URL) + +This data is stored in plaintext and persists until the queued action completes. + +## Evidence + +### 1. SDK Type Definitions Confirm Private Keys + +From `node_modules/@quilibrium/quilibrium-js-sdk-channels/dist/channel/channel.d.ts`: + +```typescript +export type Ed448Keypair = { + type: 'ed448'; + public_key: number[]; + private_key: number[]; // <-- PRIVATE KEY +}; + +export type X448Keypair = { + type: 'x448'; + public_key: number[]; + private_key: number[]; // <-- PRIVATE KEY +}; + +export type DeviceKeyset = { + identity_key: X448Keypair; // Contains private_key + pre_key: X448Keypair; // Contains private_key + inbox_keyset: InboxKeyset; // Contains multiple private_keys +}; + +export type InboxKeyset = { + inbox_address: string; + inbox_key: Ed448Keypair; // Contains private_key + inbox_encryption_key: X448Keypair; // Contains private_key +}; + +export type UserKeyset = { + user_key: Ed448Keypair; // Contains private_key + peer_key: X448Keypair; // Contains private_key +}; +``` + +### 2. Action Queue Handler Receives Full Keysets + +From `src/services/ActionQueueHandlers.ts:504-511`: + +```typescript +const signedMessage = context.signedMessage as Message; +const messageId = context.messageId as string; +const self = context.self as secureChannel.UserRegistration; +const counterparty = context.counterparty as secureChannel.UserRegistration; +const keyset = context.keyset as { + deviceKeyset: secureChannel.DeviceKeyset; // Contains 4+ private keys + userKeyset: secureChannel.UserKeyset; // Contains 2 private keys +}; +``` + +### 3. Context Stored as Plaintext in IndexedDB + +From `src/types/actionQueue.ts:34-41`: + +```typescript +export interface QueueTask { + id?: number; + taskType: ActionType; + + // Plaintext context - browser sandbox provides sufficient isolation + // See "Why No Encryption-at-Rest?" in task doc + context: Record<string, unknown>; // <-- Stored as plaintext JSON + + // ... +} +``` + +### 4. Multiple Action Types Affected + +The following action types store private keys in context: + +| Action Type | Private Keys Stored | Location | +|-------------|---------------------|----------| +| `send-dm` | DeviceKeyset + UserKeyset | `ActionQueueHandlers.ts:501-620` | +| `reaction-dm` | DeviceKeyset + UserKeyset | `ActionQueueHandlers.ts:897-940` | +| `delete-dm` | DeviceKeyset + UserKeyset | `ActionQueueHandlers.ts:941-990` | +| `edit-dm` | DeviceKeyset + UserKeyset | `ActionQueueHandlers.ts:991-1040` | +| `save-user-config` | keyset (full) | `ActionQueueHandlers.ts:62-66` | + +## Root Cause + +The action queue was designed with the assumption that browser sandbox provides sufficient isolation (per comment in `actionQueue.ts`). However: + +1. **IndexedDB is not encrypted** - data is stored as plaintext JSON +2. **Electron apps have file system access** - IndexedDB files are in `%APPDATA%` (Windows) or `~/Library/Application Support/` (macOS) +3. **No TTL or cleanup** - queued actions with private keys can persist indefinitely if they fail +4. **Private keys are the crown jewels** - they enable: + - Impersonation of the user + - Decryption of past and future messages + - Signing messages as the user + +## Threat Model + +| Adversary | Access Method | Impact | +|-----------|---------------|--------| +| Malware on device | Direct IndexedDB access | **Critical** - full key extraction | +| Physical access | Copy IndexedDB files | **Critical** - offline key extraction | +| Malicious browser extension | IndexedDB API | **Critical** - runtime key extraction | +| Forensic analysis | Device backup/image | **Critical** - historical key recovery | +| Cloud backup sync | iCloud/Google Drive backup | **High** - cloud-based key extraction | + +## Comparison with Other Stores + +| Store | Contains | Encrypted | Risk | +|-------|----------|-----------|------| +| `space_keys` | Space private keys | No | High (but required for decryption) | +| `action_queue` | User's device/user private keys | No | **Critical** (identity theft) | +| `encryption_states` | Double Ratchet state | No | Medium (session keys) | +| Proposed `user_registrations` | Counterparty public keys | No (proposed) | Medium (social graph) | + +The `action_queue` is arguably the **most sensitive** store because it contains the user's identity keys, not just session or space keys. + +## Potential Solutions + +### Option 1: Encrypt Context at Rest (Recommended) + +Encrypt the `context` field using AES-GCM with a key derived from the user's private key. + +**Good news**: This pattern already exists in the codebase! The `ConfigService.ts` already implements this exact approach for config sync: + +```typescript +// From src/services/ConfigService.ts - EXISTING PATTERN + +// 1. Key derivation (already implemented) +const derived = await crypto.subtle.digest( + 'SHA-512', + Buffer.from(new Uint8Array(userKey.user_key.private_key)) +); +const aesKey = derived.slice(0, 32); // AES-256 key + +// 2. AES-GCM encryption (already implemented) +const iv = crypto.getRandomValues(new Uint8Array(12)); +const encrypted = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + subtleKey, + Buffer.from(JSON.stringify(context), 'utf-8') +); +``` + +**Updated interface:** + +```typescript +interface EncryptedQueueTask { + id?: number; + taskType: ActionType; + encryptedContext: string; // AES-GCM encrypted (hex) + iv: string; // 12-byte IV (hex) + key: string; // Key grouping (unencrypted for queue processing) + // ... other metadata fields remain unencrypted +} +``` + +**Implementation approach:** + +1. Extract encryption helpers from `ConfigService.ts` into shared utility +2. Encrypt `context` on queue insertion +3. Decrypt `context` on queue processing +4. Keys are already in memory after passkey auth - **no extra prompts needed** + +**Pros**: +- Protects against all offline attacks +- Uses existing, tested encryption code +- No UX changes (keys in memory after passkey auth) +- Consistent with config sync security model + +**Cons**: +- Adds ~50 lines of code +- Slightly slower queue operations (negligible) + +### Option 2: Avoid Storing Private Keys in Queue + +Redesign DM actions to not store full keysets: + +1. Store only a reference to the keyset (e.g., keyset ID) +2. Retrieve keyset from passkey at processing time +3. Fail gracefully if keyset unavailable + +**Pros**: Minimal private key exposure +**Cons**: Requires passkey presence at queue processing time, may break offline-first design + +### Option 3: TTL + Secure Deletion + +Add aggressive cleanup for queued actions containing keys: + +1. Short TTL (e.g., 24 hours) for DM actions +2. Secure overwrite on completion/failure +3. Clear on app close if possible + +**Pros**: Limits exposure window +**Cons**: Doesn't protect against immediate extraction, may lose messages + +### Option 4: Accept the Risk (Current Approach) + +Document that browser sandbox is the security boundary and accept the risk. + +**Pros**: No implementation work +**Cons**: Unacceptable for a privacy-focused app, violates user expectations + +## Questions for Lead Dev + +1. **Is browser sandbox considered sufficient?** The current code comments suggest this was an intentional design decision. Is this still the accepted threat model? + +2. **Should action queue match passkey security model?** The user's identity is protected by passkey. Should queued private keys have equivalent protection? + +3. **What about Electron?** In Electron, IndexedDB is file-based with no additional encryption. Is this acceptable for desktop app? + +4. **Consistency concern**: If we require encryption for `user_registrations` (public key metadata), shouldn't `action_queue` (private keys) have at least equivalent protection? + +## Pre-existing Issues (Before Action Queue) + +Analysis of the `develop` branch (original implementation before action queue) reveals that **similar security issues already existed**: + +### 1. `space_keys` Store (Schema v2, develop branch) + +Stores Space private keys in plaintext: + +```typescript +// From develop:src/db/messages.ts +async saveSpaceKey(key: { + spaceId: string; + keyId: string; + address?: string; + publicKey: string; + privateKey: string; // <-- PLAINTEXT PRIVATE KEY +}): Promise<void> +``` + +**Risk**: Anyone with IndexedDB access can decrypt all Space messages. + +### 2. `user_config` Store (Schema v2, develop branch) + +Also stores Space keys in plaintext as part of user configuration: + +```typescript +// From develop:src/db/messages.ts +export type UserConfig = { + address: string; + spaceIds: string[]; + // ... + spaceKeys?: { + spaceId: string; + encryptionState: { /* ... */ }; + keys: { + keyId: string; + address?: string; + publicKey: string; + privateKey: string; // <-- PLAINTEXT PRIVATE KEY + spaceId: string; + }[]; + }[]; +}; +``` + +**Risk**: Duplicate storage of Space private keys. + +### 3. `encryption_states` Store (Schema v1, develop branch) + +Stores Double Ratchet session state in plaintext: + +```typescript +export interface EncryptionState { + state: string; // <-- Serialized ratchet state (contains session keys) + timestamp: number; + conversationId: string; + inboxId: string; + sentAccept?: boolean; +} +``` + +**Risk**: Session key extraction enables decryption of messages within that session. + +### Summary: Action Queue DOES Introduce a New Problem + +The action queue **bypasses existing encryption** that was designed to protect identity keys: + +| Store | Contains | Encrypted? | Introduced | +|-------|----------|------------|------------| +| `IndexedDB KeyDB id=2` | DeviceKeyset + UserKeyset | ✅ **Yes (AES-GCM)** | develop (SDK) | +| `action_queue` | DeviceKeyset + UserKeyset | ❌ **No (plaintext)** | cross-platform | + +The SDK specifically encrypts `{identity, device}` at IndexedDB id=2 using AES-GCM derived from the user's Ed448 key. The action queue stores the **exact same data** in plaintext. + +**Key difference from other unencrypted stores**: + +| Store | Key Type | Blast Radius | +|-------|----------|--------------| +| `space_keys` | Derived (per-space) | One space | +| `encryption_states` | Derived (per-session) | One conversation | +| `action_queue` | **Identity** | **Complete impersonation** | + +### Conclusion + +**This IS a bug introduced by the action queue**, not a pre-existing pattern: + +1. The SDK encrypts identity keys at id=2 ✅ +2. The action queue duplicates these keys in plaintext ❌ +3. This bypasses the SDK's security model + +**Recommended fix**: Encrypt the action queue context using the same pattern the SDK already uses. + +## Proof: Pre-Action-Queue Architecture + +Analysis of both `develop` branch and `cross-platform` branch (commit `5a2034d0`, December 17, 2025 - just before action queue) confirms that **identity keys were NEVER persisted to disk** before the action queue. + +### Branch Comparison + +| Branch | `action_queue` store | Queue mechanism | Keys persisted? | +|--------|---------------------|-----------------|-----------------| +| `develop` | ❌ None | `useRef` (memory) | ❌ No | +| `cross-platform` @ 5a2034d0 | ❌ None | `useRef` (memory) | ❌ No | +| `cross-platform` @ HEAD | ✅ IndexedDB | `action_queue` store | ⚠️ **Yes (plaintext)** | + +Both `develop` and pre-action-queue `cross-platform` use **identical** memory-only queue implementations. + +### Old Architecture: Memory-Only Queue + +From `src/components/context/WebsocketProvider.tsx` (identical in both `develop` and pre-action-queue `cross-platform`): + +```typescript +// MEMORY-ONLY queue using React useRef +const outboundQueue = useRef<OutboundMessage[]>([]); + +// OutboundMessage is a CLOSURE that captures keyset +type OutboundMessage = () => Promise<string[]>; + +const enqueueOutbound = (message: OutboundMessage) => { + outboundQueue.current = [...outboundQueue.current, message]; + processQueue(); +}; +``` + +### Key Difference: Closure vs Serialization + +| Aspect | Before Action Queue | After Action Queue | +|--------|---------------------|-------------------| +| Queue Storage | `useRef` (memory only) | IndexedDB (`action_queue` store) | +| Queue Persistence | ❌ Lost on page refresh | ✅ Survives page refresh | +| Private Keys | Captured in closure (memory) | Serialized to `context` field (plaintext JSON) | +| Offline Support | ❌ None (queue lost on refresh) | ✅ Full (but keys exposed) | +| Key Exposure Risk | **Low** (volatile, memory-only) | **Critical** (persistent, extractable) | + +### Why This Matters + +**Before action queue:** +- Keysets passed to `submitMessage()` were captured in JavaScript closures +- Closures lived only in memory (`useRef`) +- Page refresh = queue lost, **keys never written to disk** +- No IndexedDB storage of private keys whatsoever + +**After action queue:** +- Keysets must be serialized to JSON for IndexedDB persistence +- Context (including full keysets) persisted to `action_queue` store +- Keys survive page refresh (the feature goal) +- But now **extractable from disk, backups, and forensic analysis** + +### Conclusion + +The action queue **introduced** plaintext identity key storage to enable offline support. This was not a pre-existing issue - it's a direct consequence of the persistence requirement. + +The fix should encrypt the `context` field using the same AES-GCM pattern the SDK uses for `KeyDB id=2`. + +--- + +## Critical Finding: SDK Already Encrypts These Keys + +**The DeviceKeyset and UserKeyset ARE encrypted by the SDK** - but the action queue stores them in plaintext, bypassing the SDK's security model. + +### How Keys Are Properly Stored (SDK) + +From `src/components/context/RegistrationPersister.tsx:129-149`: + +```typescript +// 1. Derive AES key from user's Ed448 private key +const key = await passkey.createKeyFromBuffer(user_key); + +// 2. Encrypt the identity + device keysets (INNER encryption) +const inner = await passkey.encrypt( + Buffer.from( + JSON.stringify({ + identity: senderIdent, // UserKeyset with private keys + device: senderDevice, // DeviceKeyset with private keys + }), + 'utf-8' + ), + key // ← Key derived from Ed448, which is in hardware for passkey users +); + +// 3. Store as encrypted envelope at IndexedDB KeyDB id=2 +const envelope = Buffer.from( + JSON.stringify({ + iv: [...inner.iv], + ciphertext: [...new Uint8Array(inner.ciphertext)], + }), + 'utf-8' +); +await passkey.encryptDataSaveKey(2, envelope); // Adds OUTER encryption layer +``` + +### Double-Encryption Structure at id=2 + +The `encryptDataSaveKey` function adds an **outer encryption layer** where the key is stored with the ciphertext. But the **inner layer** is encrypted with a key derived from Ed448: + +``` +IndexedDB KeyDB id=2: +┌─────────────────────────────────────────────────────────────────┐ +│ OUTER LAYER (encryptDataSaveKey) │ +│ keys: [AES key #1] ← Stored here, anyone can decrypt outer │ +│ encrypted: { │ +│ iv: [...], │ +│ ciphertext: [ │ +│ ┌─────────────────────────────────────────────────────────┐ │ +│ │ INNER LAYER (passkey.encrypt with Ed448-derived key) │ │ +│ │ { │ │ +│ │ iv: [...], │ │ +│ │ ciphertext: [DeviceKeyset + UserKeyset] │ │ +│ │ } │ │ +│ │ ↑ To decrypt this, you need the Ed448 private key │ │ +│ └─────────────────────────────────────────────────────────┘ │ +│ ] │ +│ } │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**For true passkey users**: The Ed448 key is in hardware (WebAuthn) or PRF-encrypted. Even if you extract the outer layer from IndexedDB, you CANNOT decrypt the inner layer without biometric authentication. + +**For fallback mode users**: The Ed448 key at id=1 is also stored with its encryption key, so both layers are breakable. + +### SDK Key Storage Model + +| Location | Contents | True Passkey Mode | Fallback Mode | +|----------|----------|-------------------|---------------| +| WebAuthn passkey | Ed448 root private key | ✅ Hardware-backed | N/A | +| `localStorage['quorum-master']` | Ed448 key (Chrome PRF) | ✅ PRF-encrypted | N/A | +| `IndexedDB KeyDB id=1` | Ed448 key (fallback) | N/A | ⚠️ Key with ciphertext | +| `IndexedDB KeyDB id=2` | DeviceKeyset + UserKeyset | ✅ **Inner layer protected by hardware** | ⚠️ Both layers breakable | + +--- + +## Important Nuance: Fallback Mode Security + +The app doesn't require authentication on every open. This is because of **fallback mode** used in Electron and browsers without passkey support. + +### How Authentication Works + +**True Passkey Mode (Safari with LargeBlob, Chrome with PRF):** +``` +App opens → Biometric prompt → Hardware returns Ed448 key → Session starts +``` +Keys are truly protected by hardware - extraction requires biometric. + +**Fallback Mode (Electron, Firefox, older browsers):** +``` +App opens → Read IndexedDB KeyDB id=1 → Decrypt with stored key → Session starts + (no biometric prompt!) +``` + +### The Fallback Mode Problem + +From `quilibrium-js-sdk-channels/src/passkeys/types.ts:65-71`: + +```typescript +export async function encryptDataSaveKey(id: number, data: Buffer) { + var keys = await makeKeys(); // Generate AES key + var encrypted = await encrypt(data, keys); + callOnStore((store: IDBObjectStore) => { + store.put({ id: id, keys: keys, encrypted: encrypted }); + // ↑ KEY STORED WITH CIPHERTEXT! + }); +} +``` + +In fallback mode, IndexedDB KeyDB id=1 contains: +```typescript +{ + id: 1, + keys: CryptoKey, // ← AES key stored IN SAME RECORD + encrypted: { + iv: Uint8Array, + ciphertext: ArrayBuffer // ← Ed448 private key + } +} +``` + +**The encryption key is stored alongside the ciphertext** - this is security by obscurity, not true protection. + +### Impact on Severity Assessment + +| User Type | Existing Protection | Action Queue Impact | +|-----------|--------------------|--------------------| +| **True passkey** (Safari, Chrome PRF) | ✅ Hardware-backed, requires biometric | **Critical regression** - bypasses hardware protection | +| **Fallback mode** (Electron, Firefox) | ⚠️ Obscured but accessible without auth | **Adds redundant exposure** - keys now in 2 places | + +### Why Action Queue Is Still a Problem + +Even in fallback mode where keys are already technically accessible: + +1. **Redundant exposure**: Keys exist in KeyDB id=1/id=2 (obscured) AND action_queue (plaintext) +2. **Easier extraction**: Plaintext JSON is trivially readable vs needing to understand encryption structure +3. **Forensic persistence**: Failed queue tasks may persist indefinitely +4. **Backup exposure**: Cloud backups capture plaintext more easily than encrypted blobs + +### Revised Severity + +- **For true passkey users**: Action queue is a **Critical** security regression +- **For fallback mode users**: Action queue is a **High** security issue (adds plaintext exposure to already-weak protection) + +The fix recommendation remains the same: encrypt the action queue context using AES-GCM derived from the user's key. This provides: +- True protection for passkey users +- Consistent obscurity for fallback users (matching existing pattern) + +### The Action Queue Bypass + +The action queue stores the **exact same data** that's encrypted at id=2, but in **plaintext**: + +```typescript +// ActionQueueHandlers.ts - PLAINTEXT storage of encrypted data +const keyset = context.keyset as { + deviceKeyset: secureChannel.DeviceKeyset, // Same as encrypted id=2 + userKeyset: secureChannel.UserKeyset, // Same as encrypted id=2 +}; +``` + +| Storage | Data | Encrypted? | +|---------|------|-----------| +| `IndexedDB KeyDB id=2` | DeviceKeyset + UserKeyset | ✅ Yes | +| `action_queue` context | DeviceKeyset + UserKeyset | ❌ **NO - PLAINTEXT** | + +**This is a security regression** - the action queue bypasses the SDK's encryption that was specifically designed to protect these keys. + +--- + +## Pre-existing Unencrypted Stores (Context) + +While the action queue bypass is new, some other stores were already unencrypted in the `develop` branch. However, these contain **derived keys** (Space keys, session keys), not identity keys. + +| Store | Contains | Encrypted? | Risk Level | +|-------|----------|------------|------------| +| `space_keys` | Space private keys | ❌ No | High (space-specific) | +| `user_config` (local) | Space keys, bookmarks | ❌ No | High (space-specific) | +| `encryption_states` | Session ratchet state | ❌ No | Medium (session-specific) | +| **`action_queue`** | **Identity keys** | ❌ No | **Critical (full identity)** | + +### Key Hierarchy & Impact + +``` +Identity Keys (DeviceKeyset, UserKeyset) + ↓ compromised = complete identity theft + ↓ +Space Keys (per-space) + ↓ compromised = access to one space + ↓ +Session Keys (per-DM conversation) + ↓ compromised = access to one conversation +``` + +The action queue is the **only store** that exposes identity-level keys in plaintext. All other unencrypted stores contain derived keys with limited blast radius. + +### Implementation Leverage + +The encryption code in `ConfigService.ts` can be extracted into a shared utility: + +```typescript +// Proposed: src/utils/encryption.ts + +export async function deriveAesKey(userPrivateKey: number[]): Promise<CryptoKey>; +export async function encryptData(data: unknown, key: CryptoKey): Promise<{encrypted: string, iv: string}>; +export async function decryptData(encrypted: string, iv: string, key: CryptoKey): Promise<unknown>; +``` + +This would enable consistent encryption across: +- Action queue context +- User registrations cache (proposed) +- Potentially other stores (space_keys, encryption_states) + +## Related + +- **Task**: [DM Offline Registration Persistence](../tasks/dm-offline-registration-persistence.md) - discovered this issue during analysis +- **Doc**: [Config Sync System](../docs/config-sync-system.md) - existing encryption implementation +- **Doc**: [Passkey Authentication Flow](../reports/onboarding-flow/passkey-authentication-flow-analysis-2025-11-23.md) - key storage model +- **Store**: `space_keys` - similar unencrypted storage of space private keys +- **Store**: `encryption_states` - Double Ratchet session state (also unencrypted) + +## Impact Summary + +**If exploited, an attacker can:** +- Impersonate the user completely +- Decrypt all past DM conversations +- Sign messages as the user +- Access all Spaces the user is a member of +- Map the user's entire social graph + +This is not a minor metadata leak - it's a complete identity compromise. + +--- + + +_Updated: 2025-12-20_ - Added `setUserKeyset()` approach for decryption (solves chicken-and-egg problem) +_Severity: Critical (passkey users) / High (fallback mode users)_ +_Type: Security Regression_ diff --git a/.agents/bugs/.solved/auto-jump-unread-virtuoso-scroll-conflict.md b/.agents/bugs/.solved/auto-jump-unread-virtuoso-scroll-conflict.md new file mode 100644 index 000000000..09df81e22 --- /dev/null +++ b/.agents/bugs/.solved/auto-jump-unread-virtuoso-scroll-conflict.md @@ -0,0 +1,406 @@ +--- +type: bug +title: >- + Bug: Auto-Jump to First Unread Message - Blocked by Virtuoso Scroll + Positioning +status: done +created: 2025-11-11T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Bug: Auto-Jump to First Unread Message - Blocked by Virtuoso Scroll Positioning + + +**Priority**: Medium (UX enhancement, not critical bug) +**Type**: Feature Request / Technical Limitation +**Component**: MessageList, Virtuoso, Message Loading +**Affects**: Channel navigation UX + +**Last Updated**: 2025-11-12 +**Resolved**: 2025-11-12 + +--- + +## Resolution (2025-11-12) + +**Successfully implemented by reusing the hash navigation pattern.** + +### Key Insight +The hash navigation feature (implemented 2025-11-12) already solved the Virtuoso scroll positioning problem. Instead of fighting with `initialTopMostItemIndex` and scroll timing, we: + +1. **Load data FIRST** using `loadMessagesAround()` utility +2. **Update React Query cache** with centered message set +3. **Scroll AFTER data is loaded** using `scrollToMessage()` helper +4. **Use programmatic prop** (`scrollToMessageId`) instead of URL hash + +This is the exact same pattern as hash navigation, which already works reliably with Virtuoso. + +### Implementation +- Added `scrollToMessageId` prop to MessageList (MessageList.tsx:69,330-356) +- Added auto-jump logic to Channel.tsx using `getFirstUnreadMessage()` (Channel.tsx:344-422) +- Reused `loadMessagesAround()`, `scrollToMessage()`, and pagination control flags +- No flash highlight (unread line indicator is sufficient) + +### Why This Works +The previous attempts failed because they tried to position the viewport **during** or **immediately after** component mount, when Virtuoso's internal state wasn't ready. The hash navigation pattern waits for: +- Data to load fully +- React Query cache to update +- MessageList to re-render with new data +- **Then** scrolls programmatically + +By following the proven hash navigation timing, we bypass all the Virtuoso initialization issues. + +### Documentation +See `.agents/docs/features/messages/auto-jump-first-unread.md` for full implementation details. + +--- + +## Historical Context + +Multiple attempts to implement auto-jump to first unread message failed due to fundamental conflicts with React Virtuoso's scroll positioning system. While the bidirectional message loading implementation works correctly, positioning the viewport at the first unread message after async data loads was not achievable until we discovered the hash navigation pattern. + +--- + +## Desired Feature + +When a user enters a channel with unread messages, automatically position the viewport at the **first** unread message (with context messages above) instead of always loading from the bottom. + +### Expected Behavior + +1. User B enters channel where User A posted 20 new messages +2. System loads ~40 messages before first unread + all unread messages (40-60 total) +3. Viewport positions at first unread message +4. User sees first unread with context above for scrolling up +5. User scrolls down to see remaining unreads + +### Current Behavior + +1. System loads 100 most recent messages from bottom +2. User lands at bottom of channel +3. User must manually scroll up to find first unread message + +--- + +## Implementation History + +### Attempt 1: WIP Commit a63f609f - Message Sync Bug + +**Problem**: Initial implementation broke message synchronization. When User A posted multiple messages, User B only saw the first one, requiring multiple refreshes. + +**Root Cause**: Attempted to set initial cursor to first unread timestamp, but `getMessages()` is designed for unidirectional pagination, not "jump to middle" behavior. + +**Result**: Reverted due to critical functionality break. + +**Reference**: `.agents/bugs/.archived/auto-jump-unread-breaks-message-sync.md` + +### Attempt 2: Bidirectional Loading + Virtuoso Scroll (2025-11-11) + +**Implementation**: + +- ✅ Created `loadMessagesAround()` function - loads 40 before + 40 after target (desktop) +- ✅ Integrated with message fetcher for initial load +- ✅ Platform-aware limits (40/40 desktop, 25/25 mobile) +- ✅ Parallel query execution for performance +- ❌ Failed to position viewport at first unread + +**5 Different Scroll Positioning Approaches Tried:** + +1. **`initialTopMostItemIndex`** - Only works on component mount (before data loads) +2. **Conditional `alignToBottom`** - Viewport still went to bottom +3. **`scrollToIndex()` in useEffect** - Called successfully but had no effect +4. **Disable `followOutput`** - No observable change +5. **Force remount with key change** - Caused "messy behavior" per user + +**Console Evidence**: All systems reported correct data loading and index calculations, but viewport was always at/near bottom. + +**Result**: Reverted all changes after user confirmation that even hash navigation improvements caused issues. + +--- + +## Technical Analysis + +### What Works ✅ + +**Bidirectional Message Loading**: + +```typescript +async function loadMessagesAround({ + messageDB, + spaceId, + channelId, + targetTimestamp, +}) { + const [olderMessages, newerMessages] = await Promise.all([ + messageDB.getMessages({ + cursor: targetTimestamp, + direction: 'backward', + limit: 40, + }), + messageDB.getMessages({ + cursor: targetTimestamp, + direction: 'forward', + limit: 40, + }), + ]); + return { messages: [...olderMessages.messages, ...newerMessages.messages] }; +} +``` + +- Loads messages around any target timestamp +- Works for both unreads and hash navigation +- Proper cursor management for pagination +- Confirmed working via console logs + +**First Unread Detection**: + +```typescript +const firstUnread = await messageDB.getFirstUnreadMessage({ + spaceId, + channelId, + afterTimestamp: lastReadTimestamp, +}); +// Returns: { messageId: string, timestamp: number } +``` + +- Database method exists and works +- Correctly identifies first unread message +- Uses existing indexed queries + +### What Doesn't Work ❌ + +**Virtuoso Scroll Positioning After Async Load**: + +The core issue: Virtuoso's scroll positioning props (`initialTopMostItemIndex`) only work when the component **first mounts**. By the time async data loads, it's too late. + +Attempts to scroll programmatically after data loads (`scrollToIndex()`) were either ignored or overridden by Virtuoso's internal state management. + +### Root Cause + +**Timing Mismatch**: + +1. Virtuoso mounts → `initialTopMostItemIndex` applied (messageList is empty, defaults to bottom) +2. Data loads asynchronously → messageList updates with 55 messages +3. Component tries to reposition → Virtuoso ignores, already has internal scroll state + +**Conflicting Props**: `alignToBottom`, `followOutput`, and `initialTopMostItemIndex` have complex interactions not fully documented. Disabling them individually or in combination didn't resolve the issue. + +--- + +## Related Issues + +### Hash Navigation Bug (Documented but Not Fixed) + +**Issue**: Clicking links to old messages (pinned messages, search results, notifications) fails if message is older than the 100 most recent. + +**Current Behavior**: + +1. System loads 100 recent messages +2. Looks for target message ID in array +3. If not found → no scroll, user sees wrong content + +**Example Failures**: + +- Admin pins announcement from 2 weeks ago → link doesn't work +- Search for message from last month → click lands at bottom +- Active channels (>100 msgs/day) → almost guaranteed failure + +**Why Not Fixed**: Attempted fix using bidirectional loading for hash navigation also caused issues and was reverted per user request. + +**Current State**: Known limitation, hash navigation only works for messages within most recent 100. + +--- + +## Current State + +### Files Status + +**src/hooks/queries/messages/buildMessagesFetcher.ts**: + +```typescript +// Simple, clean baseline - loads 100 messages from bottom +const buildMessagesFetcher = ({ messageDB, spaceId, channelId }) => + wrapPaginatedFetcher(async ({ pageParam: cursor }) => { + const response = await messageDB.getMessages({ + spaceId, + channelId, + cursor: cursor?.cursor, + direction: cursor?.direction, + }); + return response; + }); +``` + +**src/components/message/MessageList.tsx**: + +- Original hash navigation code intact (works for recent messages) +- No scroll positioning modifications +- Stable, predictable behavior + +**src/db/messages.ts**: + +- `getFirstUnreadMessage()` method exists (from WIP commit) +- Not currently used but available for future attempts +- No harm in keeping it + +### Behavior + +✅ **Normal channel entry**: Loads 100 recent messages, positions at bottom +✅ **Hash navigation**: Works if target message in recent 100 +✅ **No bugs**: Clean, stable, predictable +⚠️ **No auto-jump**: Users must manually scroll to find unreads +⚠️ **Hash limitation**: Old message links may not work + +--- + +## Lessons Learned + +### Technical Insights + +1. **Virtuoso Design**: React Virtuoso is optimized for end-reached (bottom) or start-reached (top) patterns, not mid-list initial positioning with async data. + +2. **Scroll Timing**: The gap between component mount and data load is incompatible with Virtuoso's initialization pattern. + +3. **Working Pattern**: The existing hash navigation works because: + - Messages load from bottom first + - Hash scroll happens AFTER messages are in DOM + - Uses `scrollToMessage()` which directly manipulates scroll position + - Only works if message is already in loaded set + +4. **Bidirectional Loading**: The implementation is technically sound and could be reused if the scroll positioning problem is solved. + +### Attempted Solutions Summary + +| Approach | Description | Result | +| --------------------------- | --------------------------- | ------------------------------ | +| `initialTopMostItemIndex` | Set initial scroll position | ❌ Only works on mount | +| Conditional `alignToBottom` | Disable bottom alignment | ❌ Still scrolls to bottom | +| `scrollToIndex()` useEffect | Scroll after data loads | ❌ Calls succeed but no effect | +| Disable `followOutput` | Prevent auto-follow | ❌ No change | +| Remount with key | Force re-mount | ❌ Messy behavior | + +--- + +## Potential Future Approaches + +### Option A: Different Virtualization Library + +Consider alternatives to react-virtuoso: + +- `react-window` - More low-level control over scroll +- `@tanstack/virtual` - Headless virtual, custom scroll logic +- Custom virtualization - Full control but complex + +**Tradeoff**: Major refactor required, may introduce new issues. + +### Option B: Load Strategy Change + +Instead of bidirectional loading around unread: + +1. Load from first unread **downward** only (forward direction) +2. Use `startReached` callback to load older on scroll up +3. First unread naturally becomes "top" of loaded set + +**Tradeoff**: Different pagination UX, may feel awkward. + +### Option C: Accept Limitation + +Keep current behavior, add UI hints: + +- Show unread count badge +- Add "Jump to first unread" button +- Visual indicator of unread position + +**Tradeoff**: No automatic positioning, but explicit user control. + +### Option D: Hybrid Approach + +For channels with few unreads (<50): + +- Load all unreads in initial 100 message fetch +- User sees unreads near bottom naturally + +For channels with many unreads: + +- Load from bottom as normal +- Show prominent "N unread messages" banner +- Clicking banner scrolls to first unread (may require pagination) + +**Tradeoff**: Different behavior based on unread count. + +--- + +## Related Files + +### Core Implementation (Reverted) + +- `src/hooks/queries/messages/buildMessagesFetcher.ts` - Message loading +- `src/components/message/MessageList.tsx` - Virtuoso scroll logic + +### Supporting Code (Working) + +- `src/db/messages.ts:getFirstUnreadMessage()` - DB query +- `src/hooks/business/messages/useMessageHighlight.ts` - Hash scroll mechanism +- `src/utils/platform.ts` - Platform detection + +### Documentation + +- `.agents/tasks/auto-jump-first-unread.md` - Original task (outdated) +- `.agents/bugs/.archived/auto-jump-unread-breaks-message-sync.md` - First attempt + +--- + +## ~~Recommendation~~ (OBSOLETE - Feature Implemented) + +~~**Do not attempt to implement this feature** without either:~~ + +~~1. **Solving the Virtuoso problem**:~~ + ~~- Research Virtuoso GitHub issues/discussions~~ + ~~- Create minimal reproduction case~~ + ~~- Contact Virtuoso maintainers if needed~~ + +~~2. **Changing virtualization library**:~~ + ~~- Major refactor with significant risk~~ + ~~- Would need full testing across platforms~~ + +~~3. **Alternative UX approach**:~~ + ~~- Manual "jump to unread" button~~ + ~~- Different behavior that works with Virtuoso's design~~ + +~~The bidirectional loading code is solid and can be reused, but the viewport positioning problem is a blocker.~~ + +**UPDATE**: Feature successfully implemented using hash navigation pattern. The key was not to solve Virtuoso's initialization timing, but to work around it by loading data first, then scrolling programmatically after re-render. + +--- + +## ~~Questions for Future Investigation~~ (ANSWERED) + +1. ~~Does Virtuoso have any undocumented props/APIs for mid-list initial positioning?~~ + - **Answer**: Not needed. The solution was to avoid initial positioning entirely. + +2. ~~Can we delay Virtuoso mount until data is loaded? (May break suspense/loading states)~~ + - **Answer**: Not needed. Load data first, mount with initial data, then scroll. + +3. ~~Is there a way to "trick" Virtuoso into thinking it's at the start when positioned at middle?~~ + - **Answer**: Not needed. Let Virtuoso initialize normally at bottom, then scroll programmatically. + +4. ~~Would using `scrollerRef` to directly manipulate DOM scroll work?~~ + - **Answer**: No, but using Virtuoso's `scrollToIndex()` via the scroll helper works perfectly. + +5. ~~Do other apps using Virtuoso have this problem? How do they solve it?~~ + - **Answer**: They likely use the same pattern: load data, mount, then scroll programmatically. + +--- + +**Priority Justification**: ~~Medium priority because:~~ + +- ~~✅ Not a bug - existing functionality works~~ +- ~~✅ UX enhancement, not critical feature~~ +- ~~✅ Workarounds exist (manual scrolling)~~ +- ~~❌ High implementation complexity vs benefit~~ +- ~~❌ Multiple failed attempts demonstrate technical barriers~~ + +**Actual Effort**: ~2 hours (once we discovered the hash navigation pattern could be reused) + +--- + +_This bug report consolidates all attempts and learnings, and documents the successful resolution. The key lesson: when facing seemingly impossible technical barriers, look for proven patterns elsewhere in the codebase that already solve similar problems._ diff --git a/.agents/bugs/.solved/channel-group-save-race-condition.md b/.agents/bugs/.solved/channel-group-save-race-condition.md new file mode 100644 index 000000000..93ea1c07f --- /dev/null +++ b/.agents/bugs/.solved/channel-group-save-race-condition.md @@ -0,0 +1,34 @@ +--- +type: bug +title: Channel/Group Save Race Condition +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Channel/Group Save Race Condition + +## Issue +Channel and Group settings changes were non-deterministic - sometimes visible only after refresh, sometimes not saving at all. + +## Root Cause +1. `updateSpace()` calls in `useChannelManagement` and `useGroupManagement` hooks were not awaited +2. Modal used `saveWithTimeout` which closed after fixed 3 seconds regardless of actual save completion +3. This created a race condition where modal could close before save/cache invalidation completed + +## Solution +1. Added `await` before all `updateSpace()` calls in both hooks +2. Changed from `saveWithTimeout` to `saveUntilComplete` in both editors +3. Added 10-second maxTimeout as failsafe (instead of default 30s) + +## Files Modified +- `/src/hooks/business/channels/useChannelManagement.ts` - Added await to updateSpace calls +- `/src/hooks/business/channels/useGroupManagement.ts` - Added await to updateSpace calls +- `/src/components/space/ChannelEditor.tsx` - Changed to saveUntilComplete with 10s timeout +- `/src/components/space/GroupEditor.tsx` - Changed to saveUntilComplete with 10s timeout + +## Result +Modal now closes only when save actually completes, ensuring UI shows updated data immediately. + +--- +*Fixed: 2025-01-17* diff --git a/.agents/bugs/.solved/config-save-stale-cache-allowsync.md b/.agents/bugs/.solved/config-save-stale-cache-allowsync.md new file mode 100644 index 000000000..9e403fc0f --- /dev/null +++ b/.agents/bugs/.solved/config-save-stale-cache-allowsync.md @@ -0,0 +1,207 @@ +--- +type: bug +title: Config Save Missing React Query Cache Update Causes Stale allowSync +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Config Save Missing React Query Cache Update Causes Stale allowSync + +> **⚠️ AI-Generated**: May contain errors. Verify before use. +> **Reviewed by**: feature-analyzer agent + +## Symptoms + +When a user disables sync (`allowSync: false`) in UserSettingsModal and then immediately performs a folder operation (or other config-dependent operation), the console still shows: + +``` +[ConfigService] Posting settings to server... {address: '...', timestamp: ...} +``` + +This occurs even though sync was just disabled. The user's `allowSync: false` preference is not being respected by subsequent operations. + +**Affected operations** (operations using `useConfig` hook + `save-user-config` action): + +| Operation | File | Impact | +|-----------|------|--------| +| Folder create/edit | `src/hooks/business/folders/useFolderManagement.ts` | Overwrites allowSync | +| Folder delete | `src/hooks/business/folders/useDeleteFolder.ts` | Overwrites allowSync | +| Folder drag & drop | `src/hooks/business/folders/useFolderDragAndDrop.ts` | Overwrites allowSync | +| Space reordering | `src/hooks/business/spaces/useSpaceDragAndDrop.ts` | Overwrites allowSync | + +**NOT affected** (use direct IndexedDB access, not React Query cache): +- `src/hooks/business/bookmarks/useBookmarks.ts` - calls `messageDB.getUserConfig()` directly +- `src/hooks/business/mentions/useMentionNotificationSettings.ts` - calls `messageDB.getUserConfig()` directly + +## Root Cause + +**Missing React Query cache update in `ConfigService.saveConfig()`** + +When `saveConfig()` saves the config to IndexedDB, it does NOT update the React Query cache. Compare: + +### `getConfig()` - CORRECT (lines 349-353) +```typescript +await this.messageDB.saveUserConfig({ + ...config, + timestamp: savedConfig.timestamp, +}); +// ... +await this.queryClient.setQueryData( + buildConfigKey({ userAddress: config.address! }), + () => config +); +``` + +### `saveConfig()` - MISSING CACHE UPDATE (lines 494-496) +```typescript +console.log('[ConfigService] Saving config to local DB...'); +await this.messageDB.saveUserConfig(config); +console.log('[ConfigService] Config saved to local DB'); +// ❌ NO queryClient.setQueryData() call! +``` + +**The flow that causes the bug:** + +1. User disables sync → `allowSync: false` in newConfig +2. `useUserSettings` enqueues `save-user-config` with `{ config: newConfig }` +3. `useUserSettings` does NOT update React Query cache (also missing optimistic update) +4. Action queue processes → `saveConfig()` saves to IndexedDB with `allowSync: false` +5. `saveConfig()` does NOT update React Query cache +6. User does folder operation → `useConfig()` reads from React Query cache +7. Cache still has `allowSync: true` (stale!) +8. Folder operation creates newConfig spreading from stale cache → `allowSync: true` +9. Folder operation queues save → posts to server despite user wanting sync disabled + +## Historical Context + +**This bug existed in the `develop` branch**, but was latent: + +### In `develop` branch (stable first iteration): +- `saveConfig` was called **directly** (synchronously), not via action queue +- Located in `MessageDB.tsx` (lines 5220-5221): + ```typescript + await messageDB.saveUserConfig(config); + // ❌ NO cache update here either + ``` +- Fewer operations modified config (no folders) +- Operations were synchronous → less chance of race conditions + +### In current branch: +- Action queue introduced → **async** saves +- Folder operations added → **more** operations reading config +- Multiple rapid operations → **higher chance** to hit stale cache +- The latent bug became a **visible, reproducible issue** + +**Evidence from develop:** +- `getConfig()` in develop DID update the cache (lines 5126-5132) +- `saveConfig()` in develop did NOT update the cache (lines 5220-5221) +- Same asymmetry as current branch + +## Solution + +Add React Query cache update to `saveConfig()` after saving to IndexedDB: + +**File**: `src/services/ConfigService.ts` + +**Location**: After line 495 (`await this.messageDB.saveUserConfig(config);`) + +**Change**: +```typescript +console.log('[ConfigService] Saving config to local DB...'); +await this.messageDB.saveUserConfig(config); +console.log('[ConfigService] Config saved to local DB'); + +// Update React Query cache to prevent stale reads +this.queryClient.setQueryData( + buildConfigKey({ userAddress: config.address! }), + config +); +``` + +### Secondary fix (optional - NOT implemented) + +> **Decision**: Not implemented. The primary fix is sufficient since `saveConfig()` now updates the cache immediately after saving. The action queue processes fast enough that users won't hit the race condition. Adding optimistic updates in multiple places increases maintenance burden without meaningful UX improvement. + +Add optimistic cache update in `useUserSettings.ts` for immediate UI consistency: + +**File**: `src/hooks/business/user/useUserSettings.ts` + +**Location**: After creating `newConfig` (line 152), before enqueueing + +**Change**: +```typescript +const newConfig = { + ...existingConfig.current!, + allowSync, + nonRepudiable: nonRepudiable, + name: displayName, + profile_image: profileImageUrl, +}; + +// Optimistic cache update (matches folder operations pattern) +queryClient.setQueryData( + buildConfigKey({ userAddress: currentPasskeyInfo.address }), + newConfig +); + +await actionQueueService.enqueue( + 'save-user-config', + { config: newConfig }, + `config:${currentPasskeyInfo.address}` +); +``` + +## Verification + +### Test Case 1: Disable sync then folder operation +1. Open UserSettingsModal +2. Toggle sync OFF (ensure it was ON before) +3. Save settings +4. Immediately create/edit a folder +5. **Expected**: Console should NOT show "Posting settings to server" +6. **Current (bug)**: Console shows "Posting settings to server" + +### Test Case 2: Verify allowSync persists +1. Disable sync in UserSettingsModal +2. Perform multiple folder operations +3. Refresh the page +4. Open UserSettingsModal +5. **Expected**: Sync toggle should still be OFF +6. **Current (bug)**: Sync toggle may be ON (overwritten by folder operations) + +### Test Case 3: Other config values +1. Change any config value in UserSettingsModal +2. Immediately do folder operation +3. Verify config value persists after refresh + +## Prevention + +1. **Pattern enforcement**: Any method that saves to IndexedDB should also update React Query cache +2. **Code review checklist**: Check for cache/DB synchronization in config-related changes +3. **Consider abstraction**: Create a unified `persistConfig()` method that handles both DB and cache + +## Impact Assessment + +- **Severity**: Medium-High +- **User Impact**: User privacy preference (sync disabled) can be silently overwritten +- **Data Impact**: Config posted to server when user explicitly disabled sync +- **Scope**: All config-modifying operations, not just folders + +## Related Files + +- `src/services/ConfigService.ts:494-496` - Missing cache update +- `src/hooks/business/user/useUserSettings.ts:146-157` - Missing optimistic update +- `src/hooks/queries/config/useConfig.ts` - React Query hook reading stale cache +- `src/hooks/queries/config/buildConfigFetcher.ts` - Fetcher reading from IndexedDB + +## Related Documentation + +- `.agents/docs/features/action-queue.md` - Action queue system +- `.agents/reports/action-queue/008-endpoint-dependencies.md` - Endpoint mapping + +--- + + +_Bug existed in: develop branch (latent), cross-platform_action-queue branch (visible)_ diff --git a/.agents/bugs/.solved/deleted-messages-reappear-via-sync.md b/.agents/bugs/.solved/deleted-messages-reappear-via-sync.md new file mode 100644 index 000000000..4d6adab3a --- /dev/null +++ b/.agents/bugs/.solved/deleted-messages-reappear-via-sync.md @@ -0,0 +1,164 @@ +--- +type: bug +title: Deleted Messages Reappear After Peer Sync +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-18T00:00:00.000Z +--- + +# Deleted Messages Reappear After Peer Sync + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms + +When a user deletes a message: +1. The message is correctly removed from the UI (optimistic delete works) +2. The message is correctly deleted from local IndexedDB +3. The delete is correctly broadcast to other peers +4. **BUT** when another peer syncs their message history to you, the deleted message reappears +5. The message comes back with full content (not as a ghost/empty message) +6. Refreshing doesn't help - the message persists because it's now back in IndexedDB + +## Pre-existing Issue - CONFIRMED + +**Status: Pre-existing** - This bug exists in all three branches: `develop`, `cross-platform`, and current `cross-platform_action-queue`. + +### Verification Results + +| Branch | sync-messages Location | saveMessage else branch | Tombstone Check | +|--------|------------------------|------------------------|-----------------| +| `develop` | `MessageDB.tsx:1685` | Saves all post messages | ❌ None | +| `cross-platform` | `MessageService.ts:3144` | `MessageService.ts:557-565` | ❌ None | +| `cross-platform_action-queue` | `MessageService.ts:~3070` | `MessageService.ts:~576` | ❌ None | + +### The Code Path Is Identical in All Branches + +1. Receive `sync-messages` control envelope +2. For each message, call `saveMessage()` +3. `saveMessage()` for `post` type saves to IndexedDB (no tombstone check) +4. Call `refetchQueries()` to reload UI from IndexedDB + +### Why Wasn't This Noticed Before? + +1. **Single-peer testing**: If testing alone, no one syncs TO you, so deleted messages stay deleted +2. **Space owner testing**: Space owners send syncs OUT but may not receive syncs FROM others in test scenarios +3. **Infrequent sync triggers**: Sync happens on specific events (join, sync-request), not constantly +4. **Delete permission scope in develop**: Only message authors could delete (no `message:delete` role permission) → fewer deletes overall +5. **Small test datasets**: With few messages, the race window between delete and incoming sync is smaller + +### What Made It Visible Now? + +- Added `message:delete` role permission → more people can delete → more deletes happening +- Action queue makes deletes more reliable and visible +- More active multi-peer testing +- Delete confirmation modal draws attention to the action + +## Root Cause + +### The Sync Mechanism + +When peers sync messages via `sync-messages` control envelope: +1. Peer A has 454 messages in their local DB (including message X) +2. User on Peer B deletes message X +3. Message X is removed from Peer B's IndexedDB +4. Delete broadcast is sent to network +5. **Shortly after**, Peer A sends `sync-messages` with all 454 messages +6. `saveMessage()` re-saves message X to Peer B's IndexedDB (no tombstone check) +7. `refetchQueries()` reloads from IndexedDB → message X reappears + +### Code Flow (from console logs) + +``` +[useMessageActions:handleDelete] Queuing delete-message: {messageId: '09e5...'} +[useMessageActions:handleDelete] Delete queued successfully +[ActionQueue:deleteMessage] Executing delete: {...} +[ActionQueue:deleteMessage] Delete submitted successfully + +// Immediately after: +[MessageService:sync-messages] Received sync with 454 messages +[MessageService:sync-messages] Processing message: {messageId: '...', type: 'post', ...} +[MessageService:saveMessage] Saving post message to IndexedDB: {...} +// ... 454 times, including deleted messages +``` + +### Missing Component: Tombstone Tracking + +The system has **no mechanism** to remember which messages were deleted. When `saveMessage()` is called during sync: + +```typescript +// MessageService.ts - saveMessage() else branch (line ~576) +} else { + // This saves ANY post message, including ones we deleted + await messageDB.saveMessage( + { ...decryptedContent, channelId, spaceId }, + 0, spaceId, conversationType, + updatedUserProfile.user_icon!, + updatedUserProfile.display_name! + ); +} +``` + +There's no check like: +```typescript +// Missing check: +if (await messageDB.isMessageDeleted(message.messageId)) { + return; // Don't re-add deleted messages +} +``` + +## Affected Files + +| File | Lines | Issue | +|------|-------|-------| +| [MessageService.ts](src/services/MessageService.ts) | ~3070-3140 | `sync-messages` handler saves all messages without checking tombstones | +| [MessageService.ts](src/services/MessageService.ts) | ~576-590 | `saveMessage()` doesn't check if message was previously deleted | +| [messages.ts](src/db/messages.ts) | N/A | No `deleted_messages` table or tombstone tracking | + +## Solution - IMPLEMENTED + +### Tombstone Tracking (Done) + +**Step 1**: Added `deleted_messages` table to IndexedDB (DB_VERSION 7) +- File: [messages.ts](src/db/messages.ts) +- Added `DeletedMessageRecord` interface +- Created `deleted_messages` object store with indices + +**Step 2**: Track deletions in `deleteMessage()` (channel messages only) +- File: [messages.ts](src/db/messages.ts) +- Modified `deleteMessage()` to save tombstone before deleting +- **DMs excluded**: Tombstones are only created for channel messages (`spaceId !== channelId`) +- DMs don't need tombstones because they have no sync mechanism that could resurrect deleted messages + +**Step 3**: Check tombstones during sync in `saveMessage()` +- File: [MessageService.ts](src/services/MessageService.ts) +- Added `isMessageDeleted()` check before saving post messages + +### Future: Tombstone Cleanup (Not Implemented) + +To prevent unbounded growth of tombstones: +- Add periodic cleanup of tombstones older than 30 days +- Or limit to last N tombstones per channel +- Or sync tombstones between peers (more complex) + +## Testing + +**To reproduce:** +1. Have two clients connected to the same space +2. On Client A: Send 3 messages +3. Wait for Client B to receive them +4. On Client B: Delete all 3 messages +5. Observe: Messages may reappear after a few seconds when Client A syncs + +**To verify fix:** +1. Delete a message +2. Wait for sync from other peers +3. Message should NOT reappear +4. Check IndexedDB `deleted_messages` table contains the tombstone + +## Related Issues + +- [ghost-message-after-delete.md](.agents/bugs/ghost-message-after-delete.md) - Different bug where `remove-message` type was misclassified (already fixed with type guard) + +--- diff --git a/.agents/bugs/.solved/dm-mark-all-read-no-immediate-ui-update.md b/.agents/bugs/.solved/dm-mark-all-read-no-immediate-ui-update.md new file mode 100644 index 000000000..0d1e264fa --- /dev/null +++ b/.agents/bugs/.solved/dm-mark-all-read-no-immediate-ui-update.md @@ -0,0 +1,235 @@ +--- +type: bug +title: DM "Mark All as Read" Context Menu - UI Not Updating Immediately +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# DM "Mark All as Read" Context Menu - UI Not Updating Immediately + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms + +When using the "Mark All as Read" option from the DM SpaceIcon context menu: +1. The NavMenu DM bubble indicator updates correctly (disappears) +2. The `DirectMessageContact` unread indicators (dot + bold text) do NOT update +3. A page refresh is required to see the updated read state in the contacts list + +## Technical Analysis + +### Data Flow Architecture + +``` +NavMenu.tsx + └── useDirectMessageUnreadCount() → fetches from DB directly → works ✅ + +DirectMessageContactsList.tsx + └── useConversationPolling() + └── useConversations({ type: 'direct' }) → useSuspenseInfiniteQuery + └── Returns { pages: [{ conversations: [...] }] } + └── Stored in React Query cache with key ['Conversations', 'direct'] + └── processedConversations = conversations.pages.flatMap(...) + └── conversationsWithPreviews + └── enhancedConversations (useMemo) + └── filteredConversations (useMemo) + └── DirectMessageContact unread={...} ❌ NOT UPDATING +``` + +### Key Files Involved + +1. **NavMenu.tsx** (`src/components/navbar/NavMenu.tsx`) + - Contains `handleMarkAllDmsRead` function + - Calls `messageDB.saveReadTime()` for each unread conversation + - Attempts optimistic updates via `queryClient.setQueryData()` + +2. **useDirectMessageUnreadCount.ts** (`src/hooks/business/messages/useDirectMessageUnreadCount.ts`) + - Query key: `['unread-counts', 'direct-messages', userAddress]` + - Now fetches directly from DB (was previously using conversations cache) + - Works correctly after optimistic update + +3. **useConversations.ts** (`src/hooks/queries/conversations/useConversations.ts`) + - Uses `useSuspenseInfiniteQuery` + - Query key: `['Conversations', 'direct']` + - Returns paginated data: `{ pages: [...], pageParams: [...] }` + +4. **useConversationPolling.ts** (`src/hooks/business/conversations/useConversationPolling.ts`) + - Wraps `useConversations` + - Processes data: `conversations.pages.flatMap(c => c.conversations)` + - Polls every 2 seconds via `refetchConversations()` + +5. **DirectMessageContactsList.tsx** (`src/components/direct/DirectMessageContactsList.tsx`) + - Uses `useConversationPolling()` for conversation data + - Passes `unread={(c.lastReadTimestamp ?? 0) < c.timestamp}` to each contact + - Multiple `useMemo` layers derive the final displayed data + +### Debugging Findings + +#### Console Logs Show: +``` +[DM Mark Read] setQueryData callback, oldData: {pages: Array(1), pageParams: Array(1)} +[DM Mark Read] New data: {pages: Array(1), pageParams: Array(1)} +``` + +The optimistic update IS being applied to the cache, but the component doesn't re-render. + +#### IndexedDB Transaction Timing: +- `saveReadTime()` resolves on `request.onsuccess` +- But IndexedDB transactions commit asynchronously +- First query after save still returns stale data +- Second query (after ~50-100ms) returns correct data + +#### React Query Cache Behavior: +- `setQueryData()` updates the cache correctly +- `useSuspenseInfiniteQuery` doesn't always trigger re-render on cache update +- `invalidateQueries({ refetchType: 'none' })` was tried but didn't help +- The 2-second polling in `useConversationPolling` eventually picks up the change + +### Attempted Solutions (All Failed for DirectMessageContact) + +1. **Cache Invalidation + Refetch** + ```typescript + await queryClient.refetchQueries({ queryKey: ['Conversations', 'direct'] }); + ``` + - First refetch returns stale data from IndexedDB + - Second refetch returns correct data (too late) + +2. **Delay Before Refetch** + ```typescript + await new Promise(resolve => setTimeout(resolve, 50)); + await queryClient.invalidateQueries({ queryKey: ['Conversations', 'direct'] }); + ``` + - Still returns stale data + +3. **Optimistic Update with setQueryData** + ```typescript + queryClient.setQueryData(['Conversations', 'direct'], (oldData) => { + // Update lastReadTimestamp for all conversations + return { ...oldData, pages: updatedPages }; + }); + ``` + - Cache IS updated (confirmed via logs) + - Component does NOT re-render + +4. **Invalidate with refetchType: 'none'** + ```typescript + queryClient.invalidateQueries({ + queryKey: ['Conversations', 'direct'], + refetchType: 'none', + }); + ``` + - Should notify subscribers without refetching + - Did not trigger re-render + +## Root Cause + +The issue is a combination of: + +1. **useSuspenseInfiniteQuery behavior**: This query type may not trigger re-renders when cache is updated via `setQueryData()` in the same way regular queries do. + +2. **Multiple derived state layers**: The data flows through multiple `useMemo` transformations: + - `useConversations` → `useConversationPolling` → `conversationsWithPreviews` → `enhancedConversations` → `filteredConversations` + - Each layer may have stale closure references + +3. **IndexedDB async transaction commit**: The DB transaction commits asynchronously, so immediate reads after writes return stale data. + +4. **Polling masks the issue**: The 2-second polling interval in `useConversationPolling` eventually updates the UI, making the issue intermittent in testing. + +## Potential Solutions + +### Option A: Force Component Re-render +Trigger a state change that forces the entire `DirectMessageContactsList` to re-render with fresh data. + +### Option B: Use React Query's `onSuccess` Callback +Instead of optimistic updates, wait for the mutations to complete and use `onSuccess` to invalidate. + +### Option C: Lift State Up +Create a React Context that holds the "marked as read" state, update it immediately, and use it in `DirectMessageContact` to override the query-derived unread state. + +### Option D: Direct Subscription Pattern +Have `DirectMessageContactsList` subscribe to a "mark all read" event and immediately update its local state. + +### Option E: Fix useSuspenseInfiniteQuery Re-render +Research why `useSuspenseInfiniteQuery` doesn't re-render on `setQueryData` and find the correct pattern for infinite queries. + +### Option F: Remove Suspense Query +Convert `useConversations` to use regular `useInfiniteQuery` instead of `useSuspenseInfiniteQuery`, which may have better cache update reactivity. + +## Files to Modify + +- `src/components/navbar/NavMenu.tsx:279-324` - Current handler implementation +- `src/hooks/queries/conversations/useConversations.ts` - Query configuration +- `src/hooks/business/conversations/useConversationPolling.ts` - Data processing +- `src/components/direct/DirectMessageContactsList.tsx:395-410` - Unread prop derivation + +## Related Code + +### Current handleMarkAllDmsRead (NavMenu.tsx) +```typescript +const handleMarkAllDmsRead = React.useCallback(async () => { + const now = Date.now(); + const { conversations } = await messageDB.getConversations({ type: 'direct' }); + + // Optimistic updates (work for NavMenu, not for DirectMessageContact) + queryClient.setQueryData(['unread-counts', 'direct-messages', user.currentPasskeyInfo?.address], 0); + queryClient.setQueryData(['Conversations', 'direct'], (oldData) => { + // ... update lastReadTimestamp + }); + + // Save to DB + for (const conv of conversations) { + if ((conv.lastReadTimestamp ?? 0) < conv.timestamp) { + await messageDB.saveReadTime({ + conversationId: conv.conversationId, + lastMessageTimestamp: now, + }); + } + } +}, [messageDB, queryClient, user.currentPasskeyInfo?.address]); +``` + +### Unread Prop in DirectMessageContactsList.tsx +```typescript +<DirectMessageContact + unread={(c.lastReadTimestamp ?? 0) < c.timestamp && !mutedSet.has(c.conversationId)} + // ... +/> +``` + +## Solution Implemented + +**Option C: Lift State Up with React Context** was implemented. + +### New Files Created +- `src/context/DmReadStateContext.tsx` - Context that holds `markAllReadTimestamp` + +### Files Modified +1. **App.tsx** - Added `DmReadStateProvider` to component tree +2. **NavMenu.tsx** - Simplified `handleMarkAllDmsRead` to call `markAllAsRead()` from context +3. **DirectMessageContactsList.tsx** - Uses `markAllReadTimestamp` to override unread calculation +4. **useDirectMessageUnreadCount.ts** - Returns 0 when `markAllReadTimestamp` is set + +### How It Works +1. When user clicks "Mark All as Read", `markAllAsRead()` sets `markAllReadTimestamp` to current time +2. Both `DirectMessageContactsList` and `useDirectMessageUnreadCount` check this timestamp +3. If `markAllReadTimestamp` exists, conversations with `lastReadTimestamp < markAllReadTimestamp` are considered read +4. UI updates immediately via React's state-driven re-render +5. DB writes happen in background; the 2-second polling eventually syncs the cached data + +### Why This Works +- Context state changes trigger immediate re-renders in all consuming components +- No dependency on React Query cache update behavior with `useSuspenseInfiniteQuery` +- Simple, idiomatic React pattern that works alongside existing caching + +## Prevention + +- When implementing optimistic updates with `useSuspenseInfiniteQuery`, test that `setQueryData` actually triggers re-renders +- Consider using React Context for immediate UI updates when cache-based optimistic updates fail +- Add integration tests for cache update → UI update flows + +--- + + +_Solved: 2026-01-06_ diff --git a/.agents/bugs/.solved/dm-offline-navigation-empty.md b/.agents/bugs/.solved/dm-offline-navigation-empty.md new file mode 100644 index 000000000..e69d66808 --- /dev/null +++ b/.agents/bugs/.solved/dm-offline-navigation-empty.md @@ -0,0 +1,230 @@ +--- +type: bug +title: DM Offline Navigation Shows Empty View +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# DM Offline Navigation Shows Empty View + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Status: RESOLVED + +## Symptoms + +When the browser is set to "Network > Offline" in DevTools: +- Navigating to a different DM conversation results in an empty view +- The DM conversation list loads correctly (uses IndexedDB) +- Clicking on any conversation shows nothing - component never renders + +## Root Cause + +`DirectMessage.tsx` requires **network API calls** via `useRegistration()` hook that cannot complete offline: + +```typescript +// DirectMessage.tsx:92-95 +const { data: registration } = useRegistration({ address: address! }); +const { data: self } = useRegistration({ + address: user.currentPasskeyInfo!.address, +}); +``` + +The data flow: +1. User clicks DM conversation → `navigate()` fires → URL changes +2. `DirectMessage.tsx` renders → calls `useRegistration({ address })` +3. `useRegistration` → `useSuspenseQuery` → `apiClient.getUser(address)` +4. Network call fails (offline) → query suspends indefinitely +5. Component never renders → user sees empty view + +**Key insight**: Unlike space queries (which use IndexedDB), registration data is **only available via network API**. There is no local cache. + +## Files Involved + +| File | Role | +|------|------| +| [DirectMessage.tsx:92-95](src/components/direct/DirectMessage.tsx#L92-L95) | Two `useRegistration()` calls that block rendering | +| [useRegistration.ts](src/hooks/queries/registration/useRegistration.ts) | `useSuspenseQuery` with network-dependent fetcher | +| [buildRegistrationFetcher.ts](src/hooks/queries/registration/buildRegistrationFetcher.ts) | `apiClient.getUser()` network call | + +## Why This Differs from Space Navigation + +Space navigation was fixed by adding `networkMode: 'always'` because space queries use **IndexedDB** (local storage). Registration queries use **network API calls** - they genuinely need network connectivity. + +| Feature | Data Source | Offline Fix | +|---------|-------------|-------------| +| Space navigation | IndexedDB | `networkMode: 'always'` ✅ Fixed | +| DM navigation | Network API | Requires fallback solution | + +--- + +## Solution: Non-Suspense Query with Fallback UI (Recommended) + +Switch from `useSuspenseQuery` to `useQuery` so the component renders immediately even when registration data is unavailable. Use data from `useConversation` (already cached in IndexedDB) as fallback. + +**Why this is the best approach:** +- **Minimal code changes** - Just switch query type and add fallback logic +- **No schema migration** - No new IndexedDB tables needed +- **No cache invalidation complexity** - No need to track when registration data changes +- **Data already exists** - `useConversation` already caches displayName/icon in IndexedDB +- **Handles the real use case** - Users reading existing conversations offline + +### Implementation + +**Step 1: Create non-suspense version of useRegistration** + +```typescript +// src/hooks/queries/registration/useRegistrationOptional.ts +import { useQuery } from '@tanstack/react-query'; +import { buildRegistrationFetcher } from './buildRegistrationFetcher'; +import { buildRegistrationKey } from './buildRegistrationKey'; +import { useApiClient } from '../../../components/context/useApiClient'; + +const useRegistrationOptional = ({ address }: { address: string }) => { + const { apiClient } = useApiClient(); + + return useQuery({ + queryKey: buildRegistrationKey({ address }), + queryFn: buildRegistrationFetcher({ apiClient, address }), + networkMode: 'always', // Allow query to run offline (will fail gracefully) + staleTime: Infinity, // Don't refetch if we have data + gcTime: Infinity, // Keep in cache forever + retry: false, // Don't retry failed requests when offline + }); +}; + +export { useRegistrationOptional }; +``` + +**Step 2: Update DirectMessage.tsx to use fallback data** + +```typescript +// DirectMessage.tsx - Replace suspense queries with optional queries +const { data: registration } = useRegistrationOptional({ address: address! }); +const { data: self } = useRegistrationOptional({ + address: user.currentPasskeyInfo!.address, +}); +const { data: conversation } = useConversation({ + conversationId: conversationId, +}); + +// Build members with fallback from conversation data (IndexedDB) +const members = useMemo(() => { + const m = {} as { [address: string]: { displayName?: string; userIcon?: string; address: string } }; + + // Priority: conversation data (IndexedDB) > registration data (network) > defaults + if (conversation?.conversation) { + m[address!] = { + displayName: conversation.conversation.displayName ?? t`Unknown User`, + userIcon: conversation.conversation.icon ?? DefaultImages.UNKNOWN_USER, + address: address!, + }; + } else if (registration?.registration) { + m[registration.registration.user_address] = { + displayName: registration.registration.display_name ?? t`Unknown User`, + userIcon: registration.registration.pfp_url ?? DefaultImages.UNKNOWN_USER, + address: registration.registration.user_address, + }; + } else { + // Offline fallback - use address as identifier + m[address!] = { + displayName: t`Unknown User`, + userIcon: DefaultImages.UNKNOWN_USER, + address: address!, + }; + } + + // Self data - use passkey context as primary source (always available) + m[user.currentPasskeyInfo!.address] = { + address: user.currentPasskeyInfo!.address, + userIcon: user.currentPasskeyInfo!.pfpUrl, + displayName: user.currentPasskeyInfo!.displayName, + }; + + return m; +}, [registration, conversation, address, user.currentPasskeyInfo]); +``` + +### What Works Offline with This Approach + +| Feature | Works Offline? | Notes | +|---------|---------------|-------| +| View conversation | ✅ Yes | Uses IndexedDB messages | +| See message history | ✅ Yes | Already cached locally | +| See user display name | ✅ Partial | Falls back to conversation cache or "Unknown User" | +| See user avatar | ✅ Partial | Falls back to conversation cache or default avatar | +| Send messages | ⚠️ Queued | Action queue handles offline messages | +| See sent messages | ✅ Yes | Optimistic updates work | + +### Files to Modify + +| File | Change | +|------|--------| +| `src/hooks/queries/registration/useRegistrationOptional.ts` | **New file** - non-suspense version | +| `src/hooks/index.ts` | Export new hook | +| `src/components/direct/DirectMessage.tsx` | Use `useRegistrationOptional`, update `members` logic | + +--- + +## Alternative Solutions + +### Alternative A: Cache Registration in IndexedDB + +Store registration data locally when fetched online, fall back to cached data when offline. + +**Pros:** +- Full offline support for previously-visited conversations +- Consistent with space navigation behavior + +**Cons:** +- Requires IndexedDB schema change and migration +- Needs cache invalidation strategy (registration data can change) +- More complex implementation +- Overkill for the actual use case + +**Implementation outline:** +1. Add `registrations` table to IndexedDB schema +2. When `apiClient.getUser()` succeeds, cache result in IndexedDB +3. Modify `buildRegistrationFetcher` to check IndexedDB first when offline +4. Add `networkMode: 'always'` to `useRegistration` + +### Alternative B: Use React Query's Stale Data + +```typescript +useSuspenseQuery({ + queryKey: buildRegistrationKey({ address }), + queryFn: buildRegistrationFetcher({ apiClient, address }), + staleTime: Infinity, // Don't refetch if we have data + gcTime: Infinity, // Keep data in cache forever + networkMode: 'always', // Allow query to run offline +}); +``` + +**Pros:** +- Minimal code change +- Works for conversations visited in current session + +**Cons:** +- Only works if user visited that DM conversation while online in same session +- Data lost on page refresh/app restart +- Not a true offline solution + +--- + +## Related Documentation + +- [Offline Navigation Issues](offline-navigation-issues.md) - Parent bug tracking both issues (Issue 1 resolved) +- [Action Queue Feature](../docs/features/action-queue.md) - Offline action handling (works correctly) + +## Prevention Guidelines + +For future network-dependent queries: + +1. **Consider offline use case** during design +2. **Cache critical data in IndexedDB** for offline access +3. **Document data source** clearly in hook comments (network vs local) +4. **Test offline scenarios** as part of feature development + +--- diff --git a/.agents/bugs/.solved/dm-send-fails-address-undefined.md b/.agents/bugs/.solved/dm-send-fails-address-undefined.md new file mode 100644 index 000000000..29bea904d --- /dev/null +++ b/.agents/bugs/.solved/dm-send-fails-address-undefined.md @@ -0,0 +1,152 @@ +--- +type: bug +title: DM Send Fails with "Cannot read properties of undefined (reading 'address')" +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# DM Send Fails with "Cannot read properties of undefined (reading 'address')" + +> **✅ FIXED**: 2025-12-18 + +## Symptoms + +1. **DM messages fail to send** - the "Sending..." indicator persists indefinitely (now shows "Failed to send. Retry" after fix to onFailure callback) +2. **Error message**: `Cannot read properties of undefined (reading 'address')` +3. **Channel/Space messages work fine** - only DMs are affected +4. **Retry sometimes works** - clicking "Retry" on a failed message often succeeds +5. **Error occurs after all encryption sessions are created** - the 16 sessions all have valid `receiving_inbox` addresses, but error happens when processing session 0 + +## Root Cause (FOUND) + +The error was in `SpaceService.sendHubMessage()` at line 1166: +```typescript +async sendHubMessage(spaceId: string, message: string) { + const hubKey = await this.messageDB.getSpaceKey(spaceId, 'hub'); + const envelope = await secureChannel.SealHubEnvelope( + hubKey.address!, // <-- ERROR: hubKey is undefined for DMs! + ... + ); +} +``` + +**The sendDm handler was incorrectly using `sendHubMessage`** which: +1. Tries to get a "hub" key for the DM address (which doesn't exist) +2. `hubKey` returns `undefined` +3. Accessing `hubKey.address` throws: "Cannot read properties of undefined (reading 'address')" + +**Why retry worked**: The retry path in `MessageService.retryDirectMessage()` uses `enqueueOutbound()` to send `{ type: 'direct', ...session.sealed_message }` messages directly through WebSocket - NOT through `sendHubMessage`. + +## The Fix + +### 1. Added `sendDirectMessages` method to MessageService +```typescript +// src/services/MessageService.ts:163-175 +/** + * Send direct message(s) via WebSocket. + * Used by ActionQueueHandlers for DM sending. + */ +sendDirectMessages(messages: string[]): Promise<void> { + return new Promise((resolve) => { + this.enqueueOutbound(async () => { + resolve(); + return messages; + }); + }); +} +``` + +### 2. Updated sendDm handler to use WebSocket instead of sendHubMessage +```typescript +// src/services/ActionQueueHandlers.ts:698-755 +// Collect messages to send (listen + direct for each session) +const outboundMessages: string[] = []; + +for (const session of sessions) { + // ... save encryption state ... + + // Collect messages: listen subscription + direct message + outboundMessages.push( + JSON.stringify({ + type: 'listen', + inbox_addresses: [session.receiving_inbox.inbox_address], + }) + ); + outboundMessages.push( + JSON.stringify({ type: 'direct', ...session.sealed_message }) + ); +} + +// Send all messages via WebSocket +await this.deps.messageService.sendDirectMessages(outboundMessages); +``` + +## Key Difference: Spaces vs DMs + +| Aspect | Spaces/Channels | Direct Messages | +|--------|----------------|-----------------| +| Encryption | Triple Ratchet | Double Ratchet | +| Message type | `{ type: 'group', ... }` | `{ type: 'direct', ... }` | +| Routing | Via hub (SealHubEnvelope) | Direct inbox-to-inbox | +| Hub key | Required (per-space) | Not applicable | +| Send method | `sendHubMessage()` | `sendDirectMessages()` | + +## Files Modified + +- `src/services/MessageService.ts` - Added `sendDirectMessages()` method +- `src/services/ActionQueueHandlers.ts` - Updated sendDm to use WebSocket +- `src/components/message/Message.tsx` - Fixed icon name (`circle-question` → `question-circle`) + +## Previous Changes (retained) + +### 1. Added `onFailure` callback to TaskHandler interface +```typescript +// src/services/ActionQueueHandlers.ts:41-42 +onFailure?: (context: Record<string, unknown>, error: Error) => void; +``` + +### 2. ActionQueueService calls onFailure on failure +```typescript +// src/services/ActionQueueService.ts:219-231 +handler.onFailure?.(task.context, err); +``` + +### 3. Both sendChannelMessage and sendDm have onFailure callbacks +```typescript +onFailure: (context, error) => { + this.deps.messageService.updateMessageStatus( + this.deps.queryClient, + address, address, messageId, + 'failed', + this.sanitizeError(error) + ); +} +``` + +### 4. Added sanitizeError helper +```typescript +private sanitizeError(error: Error): string { + const msg = error.message.toLowerCase(); + if (msg.includes('network') || msg.includes('fetch') || msg.includes('offline')) { + return t`Network error`; + } + if (msg.includes('encrypt') || msg.includes('ratchet') || msg.includes('key')) { + return t`Encryption error`; + } + if (msg.includes('no target inboxes')) { + return t`Recipient has no devices`; + } + return t`Send failed`; +} +``` + +## Related + +- Feature doc: `.agents/docs/features/messages/message-sending-indicator.md` +- Action Queue doc: `.agents/docs/features/action-queue.md` + +--- + + +_Fixed: 2025-12-18_ diff --git a/.agents/bugs/.solved/dm-unknown-user-identity-not-revealed.md b/.agents/bugs/.solved/dm-unknown-user-identity-not-revealed.md new file mode 100644 index 000000000..dfd4ac9f1 --- /dev/null +++ b/.agents/bugs/.solved/dm-unknown-user-identity-not-revealed.md @@ -0,0 +1,243 @@ +--- +type: bug +title: DM Shows "Unknown User" After Conversation Starts +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# DM Shows "Unknown User" After Conversation Starts + +> **✅ FIXED**: 2025-12-18 + +## Symptoms + +1. **Non-deterministic behavior** - Sometimes DM recipients show as "Unknown User" even after conversation is established +2. **Both parties affected** - Both sender and recipient may see each other as "Unknown User" +3. **Observed after starting conversation** - Initially user shows as account address (expected), but after messages are exchanged they may show as "Unknown User" instead of revealing identity +4. **Avatar also affected** - Default avatar (?) shown instead of user's actual avatar + +### Expected Behavior +- Before first message: Recipient shows as "Unknown User" with default avatar +- After recipient REPLIES: Sender should see recipient's real displayName and avatar +- Vice versa: Recipient should see sender's real displayName and avatar after receiving their first message + +### Actual Behavior (observed after recent changes) +- Before first message: Recipient shows as **account address** (truncated) instead of "Unknown User" +- After conversation starts, one or both parties may still see "Unknown User" or account address +- Identity is not revealed even when it should be +- This behavior was NOT present before the work done in this branch + +## Environment + +- **Branch**: cross-platform_action-queue +- **Observed**: 2025-12-18 +- **Confirmed regression**: Introduced in commit `94d51813` (2025-10-13) "Translate and compile - fix some ts errors" + +## Root Cause Found + +### Issue 1: Changed Fallback Pattern (Address shown instead of "Unknown User") + +**Develop branch** used `mapSenderToUser()` with built-in fallback: +```typescript +const mapSenderToUser = (senderId: string) => { + return members[senderId] || { + displayName: 'Unknown User', // Always "Unknown User" if not in members + userIcon: '/unknown.png', + }; +}; +// Usage: mapSenderToUser(address).displayName +``` + +**Current branch** uses `otherUser` with address fallback: +```typescript +const otherUser = members[address!] || { + displayName: t`Unknown User`, + userIcon: DefaultImages.UNKNOWN_USER, + address: address!, +}; +// Usage: otherUser.displayName ?? otherUser.address ← Falls back to ADDRESS! +``` + +The problem is **line 650, 770, etc.** in DirectMessage.tsx: +```typescript +{otherUser.displayName ?? otherUser.address} +``` + +When `conversation.conversation.displayName` is `undefined` (not the string "Unknown User"), the `??` operator falls back to showing the **address** instead of "Unknown User". + +### Why displayName becomes undefined + +In the current branch, `NewDirectMessageModal` explicitly saves the conversation with `displayName: t"Unknown User"`. However, the issue occurs when: + +1. User navigates to DM before the conversation is saved +2. Or conversation exists but `displayName` field is missing/undefined +3. The `members` map uses `conversation.conversation.displayName` directly without fallback + +## Initial Analysis + +### How DM Identity Revelation Should Work + +1. **Sender initiates DM** → Creates conversation with `displayName: "Unknown User"` in IndexedDB +2. **Recipient receives message** → `envelope.display_name` contains sender's real name +3. **`updatedUserProfile` is set** in MessageService.ts when `envelope.user_address != self_address` +4. **`addOrUpdateConversation()` called** with `updatedUserProfile` → Updates React Query cache +5. **UI renders** with revealed identity + +### Potential Issue Areas + +#### 1. React Query Cache Only (No Persistence) +The `addOrUpdateConversation` function in [MessageDB.tsx:315-379](src/components/context/MessageDB.tsx#L315-L379) only updates React Query cache, NOT IndexedDB: +```typescript +const addOrUpdateConversation = ( + queryClient: QueryClient, + address: string, + timestamp: number, + lastReadTimestamp: number, + updatedUserProfile?: Partial<secureChannel.UserProfile> +) => { + // Only updates queryClient.setQueryData() - NO messageDB.saveConversation() +}; +``` + +This means: +- Identity revealed in memory but not persisted +- On app restart/refresh, conversation loaded from IndexedDB with old "Unknown User" value +- **However**, this doesn't explain why it fails DURING a session + +#### 2. Conditional Profile Setting +In [MessageService.ts:2001-2006](src/services/MessageService.ts#L2001-L2006): +```typescript +if (envelope.user_address != self_address) { + updatedUserProfile = { + user_address: envelope.user_address, + user_icon: envelope.user_icon, + display_name: envelope.display_name, + }; +} +``` + +The condition `envelope.user_address != self_address` should be correct, but need to verify: +- Is `self_address` always properly set? +- Could there be a race condition where `self_address` is undefined? + +#### 3. Multiple Code Paths +DM messages can be received through different paths: +- **New session** (first message): Lines ~1956-2067 +- **Existing session** (subsequent messages): Lines ~2079-3240 +- **ConfirmDoubleRatchetSenderSession** (reply confirmation): Lines ~2103-2117 + +Each path has its own `updatedUserProfile` handling - inconsistency could cause issues. + +#### 4. Fallback to Existing Values +When `updatedUserProfile` is undefined, code falls back to existing conversation values: +```typescript +updatedUserProfile ?? { + user_icon: conversation?.conversation?.icon ?? DefaultImages.UNKNOWN_USER, + display_name: conversation?.conversation?.displayName ?? t`Unknown User`, +} +``` + +If `updatedUserProfile` isn't set correctly, the fallback perpetuates "Unknown User". + +## Files Involved + +- [src/services/MessageService.ts](src/services/MessageService.ts) - DM message handling, profile extraction +- [src/components/context/MessageDB.tsx](src/components/context/MessageDB.tsx) - `addOrUpdateConversation` function +- [src/components/direct/DirectMessage.tsx](src/components/direct/DirectMessage.tsx) - DM UI, members map creation +- [src/hooks/business/conversations/useDirectMessageData.ts](src/hooks/business/conversations/useDirectMessageData.ts) - DM data hook +- [src/components/modals/NewDirectMessageModal.tsx](src/components/modals/NewDirectMessageModal.tsx) - Initial conversation creation + +## Reproduction Steps + +1. User A initiates new DM with User B (who User A has never messaged) +2. User A sends a message +3. User B receives and replies +4. **Observe**: Does User A see User B's real name/avatar? +5. **Observe**: Does User B see User A's real name/avatar? + +## Investigation TODO + +- [ ] Add debug logging to trace `updatedUserProfile` through message receive paths +- [ ] Verify `self_address` is always set when messages are received +- [ ] Check if `envelope.display_name` is actually populated in incoming messages +- [ ] Test with fresh conversation (no existing IndexedDB data) +- [ ] Check React Query cache state before/after message receive +- [ ] Verify `invalidateConversation` is triggering UI re-render + +## Partial Fix Applied (Minor Issue) + +Fixed the regression where address was shown instead of "Unknown User": + +### Files Modified + +- **src/components/direct/DirectMessage.tsx:167-168** - Added `?? t"Unknown User"` and `?? DefaultImages.UNKNOWN_USER` fallbacks +- **src/hooks/business/conversations/useDirectMessageData.ts:51-52** - Same fix applied + +### Code Change + +```typescript +// Before (buggy): +displayName: conversation.conversation!.displayName, +userIcon: conversation.conversation!.icon, + +// After (fixed): +displayName: conversation.conversation!.displayName ?? t`Unknown User`, +userIcon: conversation.conversation!.icon ?? DefaultImages.UNKNOWN_USER, +``` + +This ensures that when `displayName` is `undefined`, we show "Unknown User" instead of the address. + +## Main Issue - Root Cause Found & Fixed + +**Debug output revealed**: +``` +display_name: undefined, user_icon: 'missing' +``` + +The sender's `display_name` and `user_icon` were NOT being included in the encrypted envelope. + +### Root Cause + +The Double Ratchet encryption functions accept optional `sender_name` and `sender_photo` parameters, but we were passing `undefined, undefined`: + +```typescript +// ActionQueueHandlers.ts - BEFORE (broken) +await secureChannel.NewDoubleRatchetSenderSession( + keyset.deviceKeyset, + self.user_address, + targetDevice, + JSON.stringify(messageToEncrypt), + undefined, // sender_name - NOT PASSED! + undefined // sender_photo - NOT PASSED! +) +``` + +### Fix Applied + +1. **MessageService.ts** - Pass `currentPasskeyInfo.displayName` and `currentPasskeyInfo.pfpUrl` in the ActionQueue context +2. **ActionQueueHandlers.ts** - Extract and pass these values to all three Double Ratchet encryption functions: + - `NewDoubleRatchetSenderSession` + - `DoubleRatchetInboxEncryptForceSenderInit` + - `DoubleRatchetInboxEncrypt` + +```typescript +// ActionQueueHandlers.ts - AFTER (fixed) +await secureChannel.NewDoubleRatchetSenderSession( + keyset.deviceKeyset, + self.user_address, + targetDevice, + JSON.stringify(messageToEncrypt), + senderDisplayName, // Now passed! + senderUserIcon // Now passed! +) +``` + +## Related + +- [dm-send-fails-address-undefined.md](dm-send-fails-address-undefined.md) - Recent DM sending fix (different issue) + +--- + + +_Fixed: 2025-12-18_ diff --git a/.agents/bugs/.solved/folder-click-expand-not-working.md b/.agents/bugs/.solved/folder-click-expand-not-working.md new file mode 100644 index 000000000..a6b2832bc --- /dev/null +++ b/.agents/bugs/.solved/folder-click-expand-not-working.md @@ -0,0 +1,117 @@ +--- +type: bug +title: Folder Click to Expand/Collapse Not Working +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Folder Click to Expand/Collapse Not Working + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Status: ✅ SOLVED + +## Symptoms + +1. **Click on folder icon does nothing** - No expand/collapse behavior +2. **No tooltip on folder hover** - Folder tooltips don't appear (space tooltips inside folders work fine) +3. **Context menu doesn't open** - Right-click on folder produces no menu +4. **Console shows no click logs** - Even with extensive debug logging, pointer events are not firing + +**What DID work:** +- Folders render correctly +- SpaceButton clicks work perfectly (navigation works) +- Space tooltips open to the right correctly + +## Root Cause + +**The `.expanded-nav-buttons-container` CSS class was creating a full-viewport overlay that captured all pointer events.** + +```scss +// THE PROBLEM - in ExpandableNavMenu.scss +.expanded-nav-buttons-container { + position: fixed; + bottom: 0; + left: 0; + width: $nav-header-width; + height: 100vh; // <-- THIS WAS THE BUG! + // ... +} +``` + +This container (meant to hold the bottom navigation buttons like settings, create space, etc.) was: +- `position: fixed` - taken out of normal flow +- `height: 100vh` - covering the entire viewport height +- Sitting on top of the folder elements in z-order + +Even though the container was visually transparent and only contained buttons at the bottom, it was intercepting all pointer events for the entire nav column. + +## Why SpaceButton Worked But FolderContainer Didn't + +**This was a red herring.** SpaceButton appeared to work because the spaces rendered higher in the DOM/z-order or because testing happened in areas not covered by the overlay. The actual issue had nothing to do with dnd-kit, event handlers, or component implementation differences. + +## Investigation Process + +1. Added debug logging to FolderContainer - `handleClick` never fired +2. Added native DOM event listeners - `pointerdown` never fired on folder elements +3. Verified CSS `pointer-events: auto` was set correctly on folder elements +4. Added document-level `pointerdown` listener with capture phase: + ```javascript + document.addEventListener('pointerdown', (e) => console.log('CLICK TARGET:', e.target), true); + ``` +5. **This revealed the click was hitting `.expanded-nav-buttons-container` instead of the folder** + +## Solution + +Modified `src/components/navbar/ExpandableNavMenu.scss`: + +```scss +.expanded-nav-buttons-container { + position: fixed; + bottom: 0; + left: 0; + width: $nav-header-width; + // height: 100vh; // REMOVED - was blocking clicks on folders above + padding-bottom: $s-5; + gap: $s-2; + pointer-events: none; // Allow clicks to pass through the container + + // Re-enable pointer events on actual buttons + > * { + pointer-events: auto; + } +} +``` + +Changes: +1. Removed `height: 100vh` - container now only takes the height of its content +2. Added `pointer-events: none` to the container - clicks pass through +3. Added `pointer-events: auto` to children - actual buttons still receive clicks + +## Lessons Learned + +1. **When clicks don't work, check for overlays first** - Use document-level event listeners with capture phase to find what's actually receiving the event +2. **CSS layout issues can masquerade as JavaScript event problems** - The symptoms suggested dnd-kit was the culprit, but it was pure CSS +3. **`position: fixed` with large dimensions is dangerous** - Always consider what the element might be covering +4. **The `pointer-events` CSS property is powerful for overlay scenarios** - `none` on container + `auto` on children is a common pattern + +## Files Modified + +- `src/components/navbar/ExpandableNavMenu.scss` - Fixed the overlay issue + +## Debug Code to Remove + +The following debug code was added to FolderContainer.tsx during investigation and should be cleaned up: + +- `DEBUG_DISABLE_DND` flag +- `debugRef` and native event listener setup +- Console.log statements for useSortable result and handleClick + +--- + + +_Solved: 2025-12-06_ +_Total debugging time: ~2.5 hours_ +_Root cause: CSS overlay blocking pointer events_ diff --git a/.agents/bugs/.solved/folder-editor-modal-race-condition.md b/.agents/bugs/.solved/folder-editor-modal-race-condition.md new file mode 100644 index 000000000..b9e9db2c9 --- /dev/null +++ b/.agents/bugs/.solved/folder-editor-modal-race-condition.md @@ -0,0 +1,123 @@ +--- +type: bug +title: FolderEditorModal Race Condition on Auto-Open After Folder Creation +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# FolderEditorModal Race Condition on Auto-Open After Folder Creation + +> **✅ SOLVED** - Implemented Option 1: await saveConfig before opening modal + +## Solution + +**Files changed:** +- `src/hooks/business/folders/useFolderDragAndDrop.ts` - Made `handleDragEnd` async, added `await` before `saveConfig()` +- `src/components/navbar/NavMenu.tsx` - Re-enabled `onFolderCreated: openFolderEditor` callback + +**The fix:** +```typescript +// Before: saveConfig was not awaited, modal opened before save completed +saveConfig({ config: newConfig, keyset }); + +// After: await ensures save completes before modal opens +await saveConfig({ config: newConfig, keyset }); +``` + +The 100ms delay (`FOLDER_MODAL_OPEN_DELAY_MS`) after save completion allows React Query state to settle. + +--- + +## Original Issue + +## Symptoms + +When automatically opening the FolderEditorModal immediately after creating a folder via drag-and-drop: + +1. Modal initially finds the folder correctly (`found: true, items: Array(11)`) +2. Config gets overwritten from another source (`found: false, items: Array(12)`) - items count increases but folder ID disappears +3. Modal's `saveChanges` runs with `isEditMode: false` +4. A NEW empty folder is created instead of editing the existing one +5. The original folder with spaces becomes invisible/orphaned + +**Console log pattern observed:** +``` +[FolderManagement] Looking for folder {folderId: 'd7fad7af-...', found: true, itemCount: 11, ...} +[FolderManagement] Looking for folder {folderId: 'd7fad7af-...', found: false, itemCount: 12, ...} +[FolderManagement] Creating new folder in modal - this is unexpected +``` + +## Root Cause + +Race condition between multiple config update sources: + +1. **Drag handler** (`useFolderDragAndDrop.ts`) creates folder and does optimistic React Query cache update +2. **Modal opens** after 100ms timeout via `onFolderCreated` callback +3. **Config sync** (likely from websocket or server response) overwrites the cache with stale/different data +4. **Modal's `useFolderManagement` hook** re-renders with the new config that doesn't contain the folder ID +5. **Save operation** runs with `isEditMode: false`, creating a duplicate empty folder + +The items array growing (11 → 12 → 13) suggests configs are being merged or accumulated rather than replaced cleanly. + +## Affected Files + +- `src/hooks/business/folders/useFolderDragAndDrop.ts:576-582` - `onFolderCreated` callback with setTimeout +- `src/hooks/business/folders/useFolderManagement.ts` - Hook that depends on config having the folder +- `src/components/navbar/NavMenu.tsx:104-108` - Where callback is wired up (currently disabled) +- `src/services/ConfigService.ts` - Config sync logic that may be overwriting cache + +## Current Workaround + +The `onFolderCreated` callback is disabled in `NavMenu.tsx`: +```typescript +const folderDrag = useFolderDragAndDrop({ + config, + // TODO: Auto-open modal on folder creation causes issues - disabled for now + // onFolderCreated: openFolderEditor, +}); +``` + +Users can right-click the folder and select "Edit" from the context menu to customize it after creation. + +## Potential Solutions + +### Option 1: Ensure Config Persistence Before Opening Modal +Wait for `saveConfig` to complete and config to be persisted/synced before opening modal: +```typescript +await saveConfig({ config: newConfig, keyset }); +// Wait for React Query to settle +await queryClient.invalidateQueries({ queryKey: buildConfigKey(...) }); +// Then open modal +onFolderCreated?.(folderId); +``` + +### Option 2: Pass Folder Data Directly to Modal +Instead of having modal look up folder by ID, pass the folder object directly: +```typescript +onFolderCreated?.({ id: folder.id, name: folder.name, spaceIds: folder.spaceIds, ... }); +``` + +### Option 3: Use Optimistic UI with Rollback +Track pending folder creation state and prevent config overwrites until creation is confirmed. + +### Option 4: Debounce/Lock Config Updates +Implement a lock mechanism during folder creation flow to prevent concurrent config updates from interfering. + +## Investigation Needed + +1. Identify exactly what triggers the config update that overwrites the optimistic cache update +2. Check if `ConfigService.getConfig` is being called and overwriting with stale server data +3. Verify if websocket messages trigger config refetches +4. Determine if the issue is in React Query cache management or the sync logic + +## Prevention + +- Consider implementing a config version/timestamp check to prevent stale overwrites +- Add integration tests for folder creation + immediate edit flow +- Document the config update flow and potential race conditions + +--- + + +_Solved: 2025-12-10_ diff --git a/.agents/bugs/.solved/ghost-message-after-delete.md b/.agents/bugs/.solved/ghost-message-after-delete.md new file mode 100644 index 000000000..e7b7721dd --- /dev/null +++ b/.agents/bugs/.solved/ghost-message-after-delete.md @@ -0,0 +1,148 @@ +--- +type: bug +title: Ghost Message Appears After Deleting a Message +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Ghost Message Appears After Deleting a Message + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms + +When a user deletes a message: +1. The message is correctly removed from the UI (optimistic delete works) +2. Immediately after, a new empty/ghost message appears at the bottom of the channel +3. The ghost message shows the same author as the deleted message but has no content +4. Refreshing the page makes the ghost message disappear +5. In some cases, these empty messages persist and resist deletion + +## Root Cause + +**UPDATE**: After verification by exploration agent, the root cause analysis has been refined. + +### Original Theory (Partially Correct) + +The original theory was that `submitChannelMessage()` at [MessageService.ts:3307-3310](src/services/MessageService.ts#L3307-L3310) didn't exclude `remove-message` from the `isPostMessage` check, causing delete messages to be treated as post messages and cascade into `send-channel-message` handler which re-adds them to cache. + +### Verified Root Cause + +The `isPostMessage` check at [MessageService.ts:3307-3310](src/services/MessageService.ts#L3307-L3310) does NOT exclude `remove-message`: + +```typescript +// Current code (buggy) +const isPostMessage = + typeof pendingMessage === 'string' || + (!isEditMessage && !isPinMessage && !isUpdateProfileMessage); +``` + +This means `{type: 'remove-message', ...}` passes `isPostMessage = true` and enters the post-message code path. + +### The Flow (Without Fix) + +1. **User clicks delete** in `useMessageActions.ts`: + - Optimistic update removes message from React Query cache ✓ + - Message deleted from IndexedDB ✓ + - `delete-message` action queued to ActionQueue + +2. **ActionQueueHandlers.deleteMessage** executes: + - Calls `messageService.submitChannelMessage()` with `{type: 'remove-message', senderId, removeMessageId}` + +3. **submitChannelMessage()** misclassifies the delete message: + - `isPostMessage = true` (because `remove-message` isn't in the exclude list) + - Code enters the post-message handling block + - Creates a NEW Message object with `content: {type: 'remove-message', ...}` + - Calls `addMessage()` - this correctly handles `remove-message` by filtering (not adding) + - **BUT THEN** enqueues to `send-channel-message` handler (line 3458-3468) + +4. **send-channel-message handler** in [ActionQueueHandlers.ts:443-478](src/services/ActionQueueHandlers.ts#L443-L478): + - Encrypts and sends the message + - **Then blindly re-adds the message to cache if not present** (line 461-477) + - This re-add does NOT check message type - adds ANY message including `remove-message` + +5. **Ghost message appears**: + - The Message object with `content.type: 'remove-message'` is now in the React Query cache + - The renderer doesn't handle `remove-message` type specially + - It renders as an empty message (no `text` field exists) + +### Why Two Action Queue Tasks? + +This creates an unintended cascade of two tasks: +1. `delete-message` → calls `submitChannelMessage()` +2. `send-channel-message` → encrypts, sends, and **re-adds ghost to cache** + +This double-task pattern is NOT intentional - it's a side effect of the misclassification bug. + +## Affected Files + +| File | Lines | Issue | +|------|-------|-------| +| [MessageService.ts](src/services/MessageService.ts) | 3307-3310 | `isPostMessage` check doesn't exclude `remove-message`, `reaction`, `mute` | +| [ActionQueueHandlers.ts](src/services/ActionQueueHandlers.ts) | 461-477 | `send-channel-message` re-adds any message to cache without type checking | + +## Solution + +### Option A: Fix at send-channel-message handler (SIMPLEST) - DONE + +Add a type guard in `send-channel-message` handler to only re-add post messages to cache: + +```typescript +// At ActionQueueHandlers.ts line ~461 +// Only re-add post messages to cache +if (signedMessage.content.type === 'post') { + // ... existing re-add logic +} +``` + +**Pros**: Single line change, minimal risk +**Cons**: Doesn't fix the architectural issue (delete still goes through post-message path) + +### Option B: Fix at submitChannelMessage (PROPER) - NOT DONE + +Exclude `remove-message` from `isPostMessage` check AND add proper handler: + +**Step 1**: Update `isPostMessage` check: +```typescript +const isRemoveMessage = + typeof pendingMessage === 'object' && + (pendingMessage as any).type === 'remove-message'; + +const isPostMessage = + typeof pendingMessage === 'string' || + (!isEditMessage && !isPinMessage && !isUpdateProfileMessage && !isRemoveMessage); +``` + +**Step 2**: Add handler in `enqueueOutbound` block for `remove-message` (similar to edit/pin handlers) + +**Pros**: Clean architecture, proper separation of concerns +**Cons**: More code to write, needs handlers for remove-message, reaction, mute + +### Implementation Priority + +1. **Option A first** - quick fix to stop ghost messages +2. **Option B second** - proper architecture (can be done in follow-up) + +## Prevention + +1. **Type-safe message routing**: Consider using TypeScript discriminated unions to ensure message types are handled explicitly +2. **Explicit handler mapping**: Instead of relying on "isPostMessage" fallback, explicitly route each message type to its handler +3. **Cache modification guards**: Any code that adds messages to React Query cache should validate the message type is displayable + +## Testing + +To reproduce: +1. Send a message in a channel +2. Delete the message +3. Observe ghost message appearing at bottom of channel +4. Refresh page - ghost message disappears + +To verify fix: +1. Delete a message - no ghost should appear +2. Add a reaction - no ghost should appear +3. Mute a user - no ghost should appear +4. Check ActionQueue stats - only one task should be created per action + +--- diff --git a/.agents/bugs/.solved/icon-color-not-saving-issue.md b/.agents/bugs/.solved/icon-color-not-saving-issue.md new file mode 100644 index 000000000..30f99edc4 --- /dev/null +++ b/.agents/bugs/.solved/icon-color-not-saving-issue.md @@ -0,0 +1,115 @@ +--- +type: bug +title: Icon Color Not Saving Issue +status: done +created: 2025-01-15T00:00:00.000Z +updated: '2026-01-09' +--- + +# Icon Color Not Saving Issue + + +**Impact**: High - Icon colors were not persisting for channels and groups + +## Problem Description + +Users could select icon colors in the IconPicker component, but the colors would not save properly. When reopening the editor, the icon color would revert to the default, and changes were not reflected in the channel/group lists. + +## Root Cause + +The fundamental issue was that **color changes alone did not trigger the save callback**. The IconPicker component had two separate handlers: + +1. `handleColorChange` - Updated internal state only +2. `handleIconClick` - Called `onIconSelect` to notify parent component + +When users changed only the color (without selecting a different icon), the parent component was never notified of the change, so the data was never updated. + +## Investigation Process + +### Initial Symptoms +- Icon colors not displaying correctly in channel lists +- Colors reverting to default when reopening editors +- Intermittent behavior - "sometimes works, sometimes doesn't" + +### Red Herrings & Over-Engineering Attempts + +1. **React Key Conflicts**: Found duplicate keys in ChannelGroup causing render issues, but this was secondary +2. **CSS Color System**: Suspected missing color classes, created complex debugging system +3. **Force Re-render Mechanisms**: Added React keys to force component recreation +4. **Save Button Logic**: Over-complicated GroupEditor save logic +5. **Color Function Debugging**: Added extensive logging to `getIconColorHex()` +6. **State Management**: Suspected async state update issues + +### Debug Process +- Added console logs throughout the component chain +- Tracked data flow from IconPicker → Editor → Save → Display +- Discovered that `handleColorChange` was called but `handleIconChange` was not +- Realized color selection never triggered the parent callback + +## Actual Solution + +**Simple 3-line fix** in `IconPicker.web.tsx`: + +```typescript +const handleColorChange = (color: IconColor) => { + setSelectedColor(color); + // Immediately notify parent of color change with current icon + if (selectedIcon) { + onIconSelect(selectedIcon, color); + } +}; +``` + +## Over-Engineering to Clean Up + +The following additions were made during debugging but are likely unnecessary: + +### 1. Complex React Keys +- **Location**: `ChannelGroup.tsx` Icon components +- **Added**: Unique keys with icon/color combinations +- **Status**: Probably helpful for performance, keep + +### 2. Enhanced Color Debugging +- **Location**: `IconPicker/types.ts` - `getIconColorHex()` +- **Added**: Console warnings and debug logs +- **Status**: Remove debug logs, keep error handling + +### 3. Hover Titles for Debugging +- **Location**: All Icon components +- **Added**: `title="Icon: name (color)"` attributes +- **Status**: Keep for user experience + +### 4. Force Refresh CSS +- **Location**: All Icon components +- **Added**: `pointerEvents: 'none'` and enhanced styling +- **Status**: `pointerEvents: 'none'` is needed, keep + +### 5. Over-Simplified Save Logic +- **Location**: `useGroupManagement.ts` +- **Added**: Always-enabled save button for existing groups +- **Status**: This is actually an improvement, keep + +## Lessons Learned + +1. **Start with the data flow**: Track callbacks before assuming rendering issues +2. **Simple console logs**: Basic "function called" logs often reveal more than complex state inspection +3. **Question assumptions**: Color changes "should" trigger saves, but the code didn't implement this +4. **Avoid over-engineering**: The real fix was trivial compared to the debugging additions + +## Recommended Cleanup Actions + +1. Remove excessive debug console logs +2. Simplify color calculation functions +3. Keep improved save button logic (it's better UX) +4. Keep React keys (they prevent real issues) +5. Remove hover debug titles or make them user-friendly + +## Prevention + +- Add unit tests for IconPicker callback behavior +- Document component callback contracts clearly +- Consider using React DevTools for debugging component communication + +--- + +*This bug exemplifies how debugging complex UI interactions can lead to over-engineering when the root cause is a simple missing callback.* diff --git a/.agents/bugs/.solved/kick-user-ux-improvements.md b/.agents/bugs/.solved/kick-user-ux-improvements.md new file mode 100644 index 000000000..d51c25b7f --- /dev/null +++ b/.agents/bugs/.solved/kick-user-ux-improvements.md @@ -0,0 +1,105 @@ +--- +type: bug +title: Kick User UX Improvements +status: done +created: 2025-01-30T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +related_issues: + - '#62' +--- + +# Kick User UX Improvements + +Added to GitHub Issues: https://github.com/QuilibriumNetwork/quorum-desktop/issues/62 + +## Status: Partially Implemented - UI Blocking Issue Remains + +## Problem Statement + +The current kick user experience has poor UX during the 3-5 second operation: + +- App appears frozen during kick operation +- No visual feedback indicating progress +- User unsure if operation is working +- Modal doesn't auto-close after success +- Success message appears in chat but user might miss it + +## Implemented Solution (2025-09-16) + +### ✅ Successfully Added: Modal Save Overlay System + +**1. KickUserModal.tsx Changes:** + +- Added `useModalSaveState` hook with `saveUntilComplete` pattern +- Added `ModalSaveOverlay` component with "Kicking..." message +- Modal close mechanisms disabled during kick operation (ESC, backdrop clicks) +- Auto-closes modal after kick completion +- Minimum 3-second overlay display time for proper user feedback + +**2. Files Modified:** +- `src/components/modals/KickUserModal.tsx` - Integrated modal save overlay system +- `src/hooks/business/user/useUserKicking.ts` - Exposed `kickUserFromSpace` function + +**3. Current Flow:** +1. "Kick!" → "Click again to confirm" +2. "Kicking..." overlay appears with spinner +3. Kick operation executes (3-5 seconds) +4. Modal auto-closes after minimum 3 seconds +5. "User has been kicked" message appears in chat (~5 seconds later) + +## ⚠️ Remaining Issue: Complete UI Blocking + +### Problem Analysis + +**Root Cause:** The entire UI freezes for ~5-8 seconds during kick operations due to `enqueueOutbound` queue processing heavy cryptographic operations on the main thread: + +- Key generation (`ch.js_generate_x448()`) +- Digital signing (`ch.js_sign_ed448()`) +- Encryption operations (`ch.js_encrypt_inbox_message()`) +- Network API calls +- IndexedDB operations + +**Impact:** +- Admin cannot navigate, send messages, or interact with UI during kick +- Unlike Discord/modern apps which keep UI responsive during user operations +- Affects overall user experience despite improved modal feedback + +## Potential Solutions for UI Blocking + +### 1. Web Workers (Optimal Solution) +- Move heavy cryptographic operations to background threads +- Keep main UI thread responsive during kick operations +- Requires refactoring MessageDB crypto operations + +### 2. Chunked Processing +- Break crypto operations into smaller chunks with `setTimeout` delays +- Allow UI to remain interactive between chunks +- Less optimal but easier to implement + +### 3. Progressive Enhancement +- Show detailed progress steps: "Preparing..." → "Generating keys..." → "Updating manifest..." +- Better user feedback even if UI remains blocked +- Minimal implementation effort + +### 4. Targeted Operation Blocking +- Only disable conflicting operations (space management, concurrent kicks) +- Allow navigation, messaging, and reading to continue +- More complex state management required + +## Related Issues + +### UserProfile "Kick User" Button State +After a user is kicked, the UserProfile still shows an enabled "Kick User" button. Should show disabled state with "Kicked!" text. + +**Challenge:** Detecting kicked users efficiently without performance issues when spaces have 1K-10K members. Requires optimization of member lookup logic. + +## Priority: Medium-High + +- Functional kick system works correctly +- UI blocking affects admin productivity during member management +- Modern chat applications don't exhibit this behavior + +--- + + +**Affects**: Admin user experience during kick operations diff --git a/.agents/bugs/.solved/mention-dropdown-not-showing-on-empty-query.md b/.agents/bugs/.solved/mention-dropdown-not-showing-on-empty-query.md new file mode 100644 index 000000000..2b15e06f1 --- /dev/null +++ b/.agents/bugs/.solved/mention-dropdown-not-showing-on-empty-query.md @@ -0,0 +1,107 @@ +--- +type: bug +title: "Mention Dropdown Not Showing When Typing Just @ or #" +status: done +priority: medium +ai_generated: true +reviewed_by: null +created: 2026-01-11 +updated: 2026-01-11 +--- + +# Bug: Mention Dropdown Not Showing When Typing Just `@` or `#` + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms + +The mention dropdown does not appear when typing just `@` or `#` (empty query), even though the hook correctly returns `showDropdown: true` with valid options. + +**Expected behavior**: Dropdown should appear immediately when typing `@` showing alphabetical users (Tier 1). + +**Actual behavior**: Dropdown does not appear. However, if you type `@a` (dropdown shows), then delete `a`, the dropdown remains visible for just `@`. + +## Root Cause + +The issue was related to a race condition between state updates in the filtering logic. When typing just `@`: +1. The `showDropdown` state was being set correctly +2. But `filteredOptions` wasn't being populated synchronously for empty queries +3. The return statement `showDropdown && filteredOptions.length > 0` would return `false` on first render + +The key insight was that the filtering for empty queries needed to happen immediately, not through the debounced path. + +## Solution + +Fixed by implementing tiered filtering in `useMentionInput.ts` that handles empty queries synchronously: + +1. **Added `sortByRelevance` helper** - Centralized sorting logic for consistent behavior +2. **Added `filterUsersForTier2` and `filterRolesForTier2`** - Bypass `minQueryLength` for short queries +3. **Updated `filterMentions` with explicit tier logic**: + - Tier 1 (empty query): Immediately returns alphabetical users (first 10) + - Tier 2 (1-2 chars): Shows @everyone + matching roles + matching users + - Tier 3 (3+ chars): Existing full search behavior + +Key change in `useMentionInput.ts`: +```typescript +const filterMentions = useCallback((query: string, mentionType: '@' | '#' = '@') => { + if (mentionType === '#') { + options = filterChannelGroups(query); + } else { + if (!query || query.length === 0) { + // TIER 1: Empty query - show alphabetical users (first 10) + const alphabeticalUsers = sortByRelevance(users, '', u => u.displayName || ''); + options = alphabeticalUsers + .slice(0, 10) + .map(u => ({ type: 'user' as const, data: u })); + } else if (query.length < minQueryLength) { + // TIER 2: 1-2 chars + // ... + } else { + // TIER 3: 3+ chars + // ... + } + } + setFilteredOptions(options); + // ... +}, [/* dependencies */]); +``` + +## Prevention + +- When implementing dropdown/autocomplete features, ensure that the filtering logic handles empty queries explicitly +- Avoid relying on debounced updates for immediate visual feedback +- Test the "just typed trigger character" case (`@`, `#`, `/`) separately from "typed trigger + characters" + +--- + +## Debug Findings (Historical) + +### Hook State was Correct + +Console logs added to `useMentionInput.ts` showed: + +``` +[useMentionInput] useEffect triggered { textValue: '@', cursorPosition: 1, usersCount: 4 } +[useMentionInput] Detection result { mentionPosition: 0, mentionChar: '@' } +[useMentionInput] Valid mention detected { query: '', mentionChar: '@' } +[useMentionInput] Empty query - calling filterMentions immediately +[useMentionInput] filterMentions called { query: '', mentionType: '@', usersCount: 4 } +[useMentionInput] Setting filteredOptions { optionsCount: 4, options: [...] } +[useMentionInput] Return values { showDropdown: true, filteredOptionsLength: 4, finalShowDropdown: true } +``` + +### Key Observation + +> "if I type @a, then the dropdown shows, if I go back and delete 'a' and leave only @, the dropdown still shows" + +This confirmed: +- The dropdown CAN render for empty query (just `@`) +- Something about the INITIAL `@` keystroke was different from the delete-to-`@` case +- The fix needed to ensure filtering happens synchronously on the initial keystroke + +--- + +## Updates + +**2026-01-11 - Claude**: Initial bug report created after failed fix attempt. +**2026-01-11 - Claude**: Bug resolved by implementing tiered filtering in `useMentionInput.ts`. Moved to .solved folder. diff --git a/.agents/bugs/.solved/message-scroll-jank-on-send.md b/.agents/bugs/.solved/message-scroll-jank-on-send.md new file mode 100644 index 000000000..fda2bcef9 --- /dev/null +++ b/.agents/bugs/.solved/message-scroll-jank-on-send.md @@ -0,0 +1,113 @@ +--- +type: bug +title: 'Bug: Message Scroll Issues - Send Jank & Delete Auto-Scroll' +status: done +created: 2026-01-05T00:00:00.000Z +updated: '2026-01-09' +--- + +# Bug: Message Scroll Issues - Send Jank & Delete Auto-Scroll + + +**Priority**: Medium (UX issue) +**Type**: Bug +**Component**: Channel, DirectMessage, MessageList, Virtuoso +**Affects**: Message sending and deletion UX in channels and DMs + +**Resolved**: 2026-01-05 + +--- + +## Summary + +Two related scroll issues caused by Virtuoso's `followOutput` callback: + +1. **Send Message Jank**: When sending a message, the UI exhibited scroll jank where the message briefly appeared behind the MessageComposer before scrolling to the correct position. + +2. **Delete Auto-Scroll**: When deleting a message, the page auto-scrolled to the last message instead of staying at the current position. + +--- + +## Issue 1: Send Message Scroll Jank + +### Root Cause +Two independent scroll mechanisms were fighting each other: + +1. **Virtuoso's `followOutput`** - fires automatically when new items added, returns `'smooth'` +2. **Manual `scrollToBottom()`** - fired 100ms later with `behavior: 'auto'` (instant) + +The instant scroll interrupted the smooth scroll mid-animation. + +### Solution +- Removed manual `scrollToBottom()` calls from Channel.tsx and DirectMessage.tsx +- Added `scroll-padding-bottom: $s-16` to `.message-list` in `_chat.scss` to account for sticky composer +- Let Virtuoso's `followOutput` handle all scrolling + +--- + +## Issue 2: Delete Message Auto-Scroll + +### Root Cause +When a message is deleted via optimistic update (`queryClient.setQueryData`), Virtuoso detects the list change and calls `followOutput` BEFORE our deletion handling code runs. + +**Timeline (from debug logs):** +``` +T+0ms (9052): followOutput fires - deletionInProgress: false ← SCROLL HAPPENS +T+89ms (9141): setDeletionInProgress(true) called ← TOO LATE! +``` + +The flow was: +1. User clicks delete → `handleDelete` in `useMessageActions.ts` +2. `queryClient.setQueryData` removes message from cache (optimistic update) +3. Virtuoso sees list changed → calls `followOutput` immediately +4. `handleSubmitMessage` runs → sets deletion flag (TOO LATE!) + +### Solution +Added `onBeforeDelete` callback that fires BEFORE the optimistic update: + +1. **useMessageActions.ts** - Added `onBeforeDelete` option, called before `queryClient.setQueryData` +2. **Message.tsx** - Added `onBeforeDelete` prop, passed to `useMessageActions` +3. **MessageList.tsx** - Uses a ref (`deletionInProgressRef`) for synchronous state, passes callback to Message: + ```typescript + onBeforeDelete={() => { + deletionInProgressRef.current = true; + setTimeout(() => { + deletionInProgressRef.current = false; + }, 500); + }} + ``` + +**Why a ref instead of state?** +React state updates are async and batched. By the time Virtuoso calls `followOutput`, a state update wouldn't have propagated yet. A ref updates synchronously, so the flag is `true` when `followOutput` checks it. + +--- + +## Files Changed + +| File | Changes | +|------|---------| +| `src/hooks/business/messages/useMessageActions.ts` | Added `onBeforeDelete` callback option, called before optimistic update | +| `src/components/message/Message.tsx` | Added `onBeforeDelete` prop, passed to `useMessageActions` | +| `src/components/message/MessageList.tsx` | Added `deletionInProgressRef`, `setDeletionInProgress` method, `onBeforeDelete` callback to Message | +| `src/components/space/Channel.tsx` | Removed manual scroll calls, removed `isDeletionInProgress` state | +| `src/components/direct/DirectMessage.tsx` | Same cleanup as Channel.tsx | +| `src/styles/_chat.scss` | Added `scroll-padding-bottom: $s-16` to `.message-list`, spacer to `$s-6` | + +--- + +## Key Lessons + +1. **Optimistic updates fire Virtuoso callbacks immediately** - Any code that runs after `setQueryData` is too late to prevent scroll behavior +2. **Use refs for synchronous state** - When you need a value to be immediately available (not waiting for React render cycle), use a ref +3. **Trace timing with `performance.now()`** - For race conditions, add timestamps to debug logs to understand the exact sequence of events +4. **`followOutput` fires on both add AND remove** - Virtuoso calls this callback whenever the list changes, not just when items are added + +--- + +## Related Issues + +- [auto-jump-unread-virtuoso-scroll-conflict.md](auto-jump-unread-virtuoso-scroll-conflict.md) - Similar Virtuoso scroll timing issues + +--- + +*Last updated: 2026-01-05* diff --git a/.agents/bugs/.solved/mobiledrawer-virtuoso-double-scroller.md b/.agents/bugs/.solved/mobiledrawer-virtuoso-double-scroller.md new file mode 100644 index 000000000..5d6fdceca --- /dev/null +++ b/.agents/bugs/.solved/mobiledrawer-virtuoso-double-scroller.md @@ -0,0 +1,175 @@ +--- +type: bug +title: MobileDrawer Virtuoso Double Scroller CSS Issues +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# MobileDrawer Virtuoso Double Scroller CSS Issues + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms + +### Primary Issue: Double Scrolling +When using Virtuoso components inside MobileDrawer for displaying lists (SearchResults, PinnedMessagesPanel), two scrollbars appear: +1. **Inner Virtuoso scroller**: Manages individual list items (EXPECTED) +2. **Outer MobileDrawer scroller**: Scrolls the entire panel content (PROBLEMATIC) + +This creates a confusing UX where users can scroll at two different levels, causing: +- Difficulty reaching items at the bottom of lists +- Last items in lists being cut off or not completely visible +- Inconsistent scrolling behavior across different panels + +### Component-Specific Behavior +Current status of affected components: + +**NotificationPanel**: ✅ Perfect behavior +- Uses standard map() iteration, no Virtuoso +- Single scroller, proper height, boxed layout working + +**PinnedMessagesPanel**: ⚠️ Intermittent issues +- Sometimes shows double scrollers when adaptive height is used +- Works correctly with static height (`350px` desktop, calculated mobile) + +**SearchResults**: ❌ Multiple issues +- Double scroller appears frequently +- Search results don't show at all in mobile drawer (only search input visible) +- Complex CSS media query conflicts with Virtuoso height calculations + +## Root Cause + +### 1. CSS Height Conflicts +The core issue stems from conflicting height definitions between: +- **DropdownPanel maxHeight prop**: `Math.min(window.innerHeight * 0.8, 600)` +- **Virtuoso inline style height**: `style={{ height: calculatedHeight }}` +- **CSS media query overrides**: `max-height: none !important` in mobile styles + +### 2. Virtuoso DOM Structure Interference +Virtuoso creates its own internal DOM wrapper structure that doesn't respond well to standard CSS spacing approaches: +```html +<div class="mobile-drawer__item-list"> + <div> <!-- Virtuoso wrapper --> + <div> <!-- Virtuoso scroller --> + <div class="mobile-drawer__item-box">Item 1</div> + <div class="mobile-drawer__item-box">Item 2</div> + </div> + </div> +</div> +``` + +Standard CSS approaches that failed: +- `gap` property on container +- `margin-bottom` on items +- `padding` adjustments + +### 3. Media Query Complexity +SearchResults.scss contains complex CSS that attempts to handle different behaviors for mobile vs desktop: + +```scss +.search-results-list { + @media (max-width: 768px) { + max-height: none !important; // Conflicts with Virtuoso + overflow-y: visible; // Causes double scrolling + } +} +``` + +### 4. Conditional Rendering Structure +Different approaches used across components create inconsistent behavior: + +**Working (NotificationPanel)**: +```tsx +{isTouchDevice() ? ( + <div className="mobile-drawer__item-list"> + {items.map(item => ( + <div className="mobile-drawer__item-box">{item}</div> + ))} + </div> +) : ( + <Container>{items.map(...)}</Container> +)} +``` + +**Problematic (SearchResults)**: +```tsx +{isTouch ? ( + <div className="mobile-drawer__item-list"> + <Virtuoso style={{ height: calculated }} data={results} /> + </div> +) : ( + <Virtuoso style={{ height: maxHeight }} data={results} /> +)} +``` + +## Solution + +### Implemented Fixes + +#### 1. Spacing Solution for Virtuoso Items +After extensive testing, found the only working CSS approach: +```scss +.mobile-drawer__item-list { + padding: $s-3; + + & > div .mobile-drawer__item-box { + margin-top: $s-3; + } + + & > div:first-child .mobile-drawer__item-box { + margin-top: 0; + } +} +``` + +#### 2. Simplified Height Management +- Use static heights instead of complex adaptive calculations +- Avoid CSS `max-height: none !important` overrides +- Let DropdownPanel control overall height, Virtuoso handle internal scrolling + +#### 3. Component-Specific Approaches +**For SearchResults**: Simplify CSS, remove conflicting media queries +**For PinnedMessagesPanel**: Use proven static height approach +**For NotificationPanel**: Keep current working implementation + +### Current Status After Fixes +- **NotificationPanel**: ✅ Working perfectly +- **PinnedMessagesPanel**: ✅ Working perfectly +- **SearchResults**: ❌ Needs investigation - results not displaying at all + +## Prevention + +### Best Practices for Future Virtuoso + MobileDrawer Integration + +1. **Height Management**: + - Use static heights when possible + - Avoid `max-height: none !important` in mobile styles + - Let parent container (DropdownPanel) control overall dimensions + +2. **Spacing**: + - Use the proven margin-top approach for Virtuoso item spacing + - Don't rely on CSS gap or standard margin/padding patterns + - Target Virtuoso's internal wrapper structure specifically + +3. **Conditional Rendering**: + - Keep mobile/desktop logic simple and consistent + - Avoid complex CSS overrides within conditional branches + - Test both paths thoroughly + +4. **Testing Protocol**: + - Test all three panel types when making changes + - Verify both desktop dropdown and mobile drawer behaviors + - Check for double scrollers on various viewport sizes + - Ensure last items in lists are fully visible + +### Code Review Checklist +When modifying MobileDrawer + Virtuoso components: +- [ ] No conflicting height definitions +- [ ] Single scroller per panel +- [ ] Consistent spacing approach +- [ ] Mobile and desktop paths both functional +- [ ] Last list items fully visible + +--- diff --git a/.agents/bugs/.solved/new-messages-separator-intersection-observer-issues.md b/.agents/bugs/.solved/new-messages-separator-intersection-observer-issues.md new file mode 100644 index 000000000..2071dfaa6 --- /dev/null +++ b/.agents/bugs/.solved/new-messages-separator-intersection-observer-issues.md @@ -0,0 +1,356 @@ +--- +type: bug +title: New Messages Separator - Intersection Observer Dismissal Issues +status: done +created: 2025-11-12T00:00:00.000Z +updated: 2025-11-12T00:00:00.000Z +--- + +# New Messages Separator - Intersection Observer Dismissal Issues + + +**Priority**: High +**Component**: MessageList, NewMessagesSeparator + +**Resolved**: 2025-11-12 +**Solution**: Replaced Intersection Observer with Virtuoso's `rangeChanged` callback + +--- + +## Problem Summary + +The "New Messages" separator is not being dismissed when scrolled out of viewport, despite implementing an Intersection Observer. The separator persists indefinitely unless the page is refreshed. + +--- + +## Root Causes Discovered + +### 1. **Initial Issue: `innerRef` Prop Error** +The separator component was using `innerRef` prop which is not valid for DOM elements. FlexRow was passing it to a `<div>`, causing React warnings and preventing ref attachment. + +**Solution Applied:** +- Changed NewMessagesSeparator to use `React.forwardRef` +- Changed prop from `innerRef` to standard `ref` + +### 2. **Critical Issue: Intersection Observer Root Element** +The Intersection Observer was observing relative to the **browser viewport** instead of the **MessageList scroll container**. + +**Context:** +- MessageList uses Virtuoso for virtualized scrolling +- Messages scroll inside a container, not the browser window +- Default Intersection Observer observes viewport, not nested scroll containers + +**Solution Applied:** +```typescript +// Get the Virtuoso scroll container element +const virtuosoElement = (virtuoso.current as any).element; + +// Pass as root to observe within scroll container +root: virtuosoElement, +``` + +### 3. **Timeout Clearing Issue** +Initial implementation used a 500ms `setTimeout` to delay observer setup, but: +- Component re-renders multiple times during auto-jump +- Each re-render triggers useEffect +- Timer gets cleared before it can fire +- Observer never gets set up + +**Attempted Solutions:** +- Added `observerSetupRef` to prevent multiple setups +- Removed timeout delay, set up observer immediately + +### 4. **Component Re-render Cascade** +MessageList re-renders frequently during: +- Auto-jump scroll animation +- Message loading +- State updates from parent (Channel.tsx) + +This causes the useEffect to retrigger, potentially cleaning up the observer prematurely. + +--- + +## Current Implementation Status + +**What Works:** +- ✅ Separator appears on auto-jump +- ✅ Unread count displays correctly +- ✅ Count stays fixed (doesn't decrease as messages are read) +- ✅ Ref forwarding implemented correctly +- ✅ Observer setup reaches completion (logs confirm) + +**What Doesn't Work:** +- ❌ Separator never dismisses when scrolled out of view +- ❌ No intersection events fire when scrolling + +**Console Logs Show:** +``` +[NewMessagesSeparator] Setting up observer immediately... +[NewMessagesSeparator] Observer setup complete +// But then... no intersection events when scrolling +``` + +--- + +## Alternative Approaches to Consider + +### Option A: Use Virtuoso's Built-in Scroll Events ⭐ **RECOMMENDED** +Instead of Intersection Observer, leverage Virtuoso's scroll tracking: + +```typescript +// In MessageList.tsx +<Virtuoso + rangeChanged={(range) => { + // Check if first unread message is in visible range + const firstUnreadIndex = messageList.findIndex( + m => m.messageId === newMessagesSeparator?.firstUnreadMessageId + ); + + if (firstUnreadIndex !== -1) { + const isVisible = firstUnreadIndex >= range.startIndex && + firstUnreadIndex <= range.endIndex; + + if (!isVisible && separatorWasVisible) { + onDismissSeparator(); + } + if (isVisible) { + setSeparatorWasVisible(true); + } + } + }} +/> +``` + +**Pros:** +- ✅ Works natively with Virtuoso's virtualization +- ✅ No DOM observation needed +- ✅ Reliable - not affected by ref timing issues +- ✅ Simpler mental model + +**Cons:** +- ⚠️ Couples dismissal logic to Virtuoso API +- ⚠️ Need to track separator visibility state + +### Option B: Use Virtuoso's `itemsRendered` Callback +```typescript +<Virtuoso + itemsRendered={(items) => { + const firstUnreadIndex = messageList.findIndex( + m => m.messageId === newMessagesSeparator?.firstUnreadMessageId + ); + + const isRendered = items.some(item => item.originalIndex === firstUnreadIndex); + + if (!isRendered && separatorWasVisible) { + onDismissSeparator(); + } + }} +/> +``` + +**Pros:** +- ✅ Even simpler than rangeChanged +- ✅ Directly tracks if separator message is rendered +- ✅ No manual range calculations + +**Cons:** +- ⚠️ Tied to Virtuoso's rendering lifecycle + +### Option C: Time-Based Dismissal (Fallback) +```typescript +useEffect(() => { + if (!newMessagesSeparator) return; + + const timer = setTimeout(() => { + onDismissSeparator(); + }, 10000); // 10 seconds + + return () => clearTimeout(timer); +}, [newMessagesSeparator]); +``` + +**Pros:** +- ✅ Dead simple +- ✅ No observer complexity +- ✅ Works reliably + +**Cons:** +- ❌ Poor UX - dismisses even if user is reading +- ❌ Arbitrary timing +- ❌ Can't "rediscover" separator if user scrolls back + +### Option D: Manual "Dismiss" Button +```typescript +<NewMessagesSeparator + count={...} + onDismiss={onDismissSeparator} +/> +``` + +**Pros:** +- ✅ User has explicit control +- ✅ Zero complexity +- ✅ 100% reliable + +**Cons:** +- ❌ Adds UI clutter +- ❌ Requires user action +- ❌ Less elegant than auto-dismiss + +### Option E: Hybrid Approach +Combine Virtuoso scroll tracking with a minimum visibility time: + +```typescript +const [separatorVisibleSince, setSeparatorVisibleSince] = useState<number | null>(null); + +// In Virtuoso rangeChanged +if (isVisible && !separatorVisibleSince) { + setSeparatorVisibleSince(Date.now()); +} else if (!isVisible && separatorVisibleSince) { + const visibleDuration = Date.now() - separatorVisibleSince; + if (visibleDuration > 2000) { // Visible for at least 2 seconds + onDismissSeparator(); + } +} +``` + +**Pros:** +- ✅ Prevents accidental dismissal during fast scrolling +- ✅ Ensures user sees separator +- ✅ Still auto-dismisses naturally + +**Cons:** +- ⚠️ More complex logic +- ⚠️ Requires state management + +--- + +## Recommendation + +**Use Option A (Virtuoso's `rangeChanged`)** because: + +1. **Native Integration**: Works with Virtuoso's virtualization system +2. **Reliability**: No ref timing issues or Intersection Observer quirks +3. **Performance**: Already computed by Virtuoso internally +4. **Maintainability**: Clear cause-and-effect logic +5. **Proven Pattern**: Similar to how "Jump to Present" button works + +### Proposed Implementation + +```typescript +// In MessageList.tsx +const [separatorWasVisible, setSeparatorWasVisible] = useState(false); + +<Virtuoso + // ... existing props + rangeChanged={(range) => { + if (!newMessagesSeparator) { + setSeparatorWasVisible(false); + return; + } + + const firstUnreadIndex = messageList.findIndex( + m => m.messageId === newMessagesSeparator.firstUnreadMessageId + ); + + if (firstUnreadIndex === -1) return; + + const isVisible = firstUnreadIndex >= range.startIndex && + firstUnreadIndex <= range.endIndex; + + if (isVisible && !separatorWasVisible) { + // First time separator becomes visible + setSeparatorWasVisible(true); + } else if (!isVisible && separatorWasVisible) { + // Separator scrolled out of view - dismiss it + onDismissSeparator?.(); + setSeparatorWasVisible(false); + } + }} +/> +``` + +**Benefits of This Approach:** +- Removes all Intersection Observer complexity +- Removes ref management issues +- Works with Virtuoso's internal state +- ~30 lines of simple, readable code +- No timing issues or race conditions + +--- + +## Current Code Issues + +### File: `MessageList.tsx` (lines 406-493) + +**Problems:** +1. Intersection Observer may not work reliably with Virtuoso's virtualization +2. Complex ref management and timing coordination +3. Multiple useEffect dependencies causing re-renders +4. Observer cleanup timing is fragile + +### File: `NewMessagesSeparator.tsx` + +**Status:** ✅ Working correctly after forwardRef refactor + +--- + +## Testing Checklist + +When implementing the fix, verify: + +- [ ] Separator appears on auto-jump ✅ +- [ ] Count displays correctly ✅ +- [ ] Separator dismisses when scrolled up past it +- [ ] Separator dismisses when scrolled down past it +- [ ] No console errors ✅ +- [ ] Works on fast scrolling +- [ ] Works on slow scrolling +- [ ] Separator reappears on channel revisit with unreads ✅ +- [ ] No performance issues with large message lists +- [ ] Works after message arrives (separator stays visible) + +--- + +## Related Files + +- `src/components/message/MessageList.tsx:406-493` - Intersection Observer logic +- `src/components/message/NewMessagesSeparator.tsx:25-54` - Separator component +- `src/components/space/Channel.tsx:124-128,391-394,424-427` - State management + +--- + +## Documentation + +- [new-messages-separator.md](../docs/features/messages/new-messages-separator.md) - Feature spec +- [auto-jump-first-unread.md](../docs/features/messages/auto-jump-first-unread.md) - Related feature + +--- + +## Final Solution Implemented + +✅ **Option A (Virtuoso `rangeChanged`)** has been implemented and is working perfectly. + +**Implementation Details:** +- File: `MessageList.tsx:415-446` +- Uses `handleRangeChanged` callback with Virtuoso +- Tracks separator visibility via `separatorWasVisible` state +- ~30 lines of simple, reliable code + +**Cleanup Completed:** +- ✅ Removed all Intersection Observer code +- ✅ Removed `separatorRef` (no longer needed) +- ✅ Simplified NewMessagesSeparator (removed forwardRef) +- ✅ Removed all debug logging +- ✅ Updated comments to reflect new approach + +**Testing Results:** +- ✅ Separator dismisses when scrolled up past it +- ✅ Separator dismisses when scrolled down past it +- ✅ No console errors +- ✅ Works reliably with Virtuoso's virtualization +- ✅ Performance is excellent + +--- + +*Last updated: 2025-11-12* diff --git a/.agents/bugs/.solved/offline-navigation-issues.md b/.agents/bugs/.solved/offline-navigation-issues.md new file mode 100644 index 000000000..fc629622f --- /dev/null +++ b/.agents/bugs/.solved/offline-navigation-issues.md @@ -0,0 +1,163 @@ +--- +type: bug +title: Offline Navigation Issues +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-19T00:00:00.000Z +--- + +# Offline Navigation Issues + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Status: RESOLVED (Issue 1) / OPEN (Issue 2) + +## Symptoms + +When the browser is set to "Network > Offline" in DevTools: + +1. **~~Space Navigation Fails Silently~~** - **FIXED**: Clicking space icons in NavMenu now works offline +2. **DM Navigation Shows Empty**: Navigating to a different DM conversation results in an empty view (still open) + +Both issues occurred while the user was already on a space or DM conversation (where queued actions work correctly). + +--- + +## Issue 1: Space Navigation - RESOLVED + +### Root Cause + +**React Query v5's default `networkMode: 'online'`** was blocking IndexedDB queries when offline. + +Despite all space-related queries using **local IndexedDB** (not network), React Query v5 has a default behavior where queries are **paused** when `navigator.onLine` returns `false`. This caused: + +1. User clicks space icon → `navigate()` fires → URL changes +2. `Space.tsx` renders → calls `useSpace({ spaceId })` +3. `useSuspenseQuery` checks `navigator.onLine` → returns `false` +4. Query is **paused** (fetcher never called) → component suspends indefinitely +5. User sees nothing happen + +**Key insight**: The IndexedDB fetcher was never even called - React Query blocked it before execution. + +### Debugging Evidence + +``` +[DEBUG] Space component rendering +[DEBUG] Space params: {spaceId: '...', channelId: '...'} +[DEBUG] useSpace called, spaceId: ... messageDB exists: true +// ❌ buildSpaceFetcher NEVER called - query paused by networkMode +``` + +After fix: +``` +[DEBUG] useSpace called, spaceId: ... messageDB exists: true +[DEBUG] buildSpaceFetcher called for: ... +[DEBUG] buildSpaceFetcher got response: ... +[DEBUG] Space data loaded: ... // ✅ Works! +``` + +### Solution + +Added `networkMode: 'always'` to all IndexedDB-based queries to tell React Query these queries don't depend on network connectivity: + +```typescript +return useSuspenseQuery({ + queryKey: buildSpaceKey({ spaceId }), + queryFn: buildSpaceFetcher({ messageDB, spaceId }), + refetchOnMount: true, + networkMode: 'always', // This query uses IndexedDB, not network +}); +``` + +### Files Modified + +| File | Change | +|------|--------| +| [useSpace.ts](src/hooks/queries/space/useSpace.ts) | Added `networkMode: 'always'` | +| [useSpaceMembers.ts](src/hooks/queries/spaceMembers/useSpaceMembers.ts) | Added `networkMode: 'always'` | +| [useMessages.ts](src/hooks/queries/messages/useMessages.ts) | Added `networkMode: 'always'` | +| [useSpaceOwner.ts](src/hooks/queries/spaceOwner/useSpaceOwner.ts) | Added `networkMode: 'always'` | +| [useConfig.ts](src/hooks/queries/config/useConfig.ts) | Added `networkMode: 'always'` | +| [useSpaces.ts](src/hooks/queries/spaces/useSpaces.ts) | Added `networkMode: 'always'` | +| [useConversation.ts](src/hooks/queries/conversation/useConversation.ts) | Added `networkMode: 'always'` | +| [useMutedUsers.ts](src/hooks/queries/mutedUsers/useMutedUsers.ts) | Added `networkMode: 'always'` | + +--- + +## Issue 2: DM Navigation Shows Empty - STILL OPEN + +### Root Cause (Confirmed) + +`useRegistration()` makes a **network API call** and uses `useSuspenseQuery`: + +``` +DirectMessage.tsx + → useRegistration({ address }) // Line 92-98 + → useSuspenseQuery // Line 10 of useRegistration.ts + → apiClient.getUser(address) // Network call! +``` + +When offline: +- The API call fails (network unavailable) +- `useSuspenseQuery` suspends waiting for network +- The component never renders, showing empty/loading state + +Unlike space queries (IndexedDB), registration data is **only available via network API**. + +### Files Involved + +- [DirectMessage.tsx:92-98](src/components/direct/DirectMessage.tsx#L92-L98) - Two `useRegistration()` calls +- [useRegistration.ts:10-13](src/hooks/queries/registration/useRegistration.ts#L10-L13) - `useSuspenseQuery` with `refetchOnMount: true` +- [buildRegistrationFetcher.ts:6](src/hooks/queries/registration/buildRegistrationFetcher.ts#L6) - `apiClient.getUser()` network call + +### Potential Solutions + +**Option A: Cache registration in IndexedDB** +- Store registration data locally when fetched online +- Fall back to cached data when offline +- Requires schema change and cache invalidation strategy + +**Option B: Use React Query's stale data** +```typescript +useSuspenseQuery({ + queryKey: buildRegistrationKey({ address }), + queryFn: buildRegistrationFetcher({ apiClient, address }), + staleTime: Infinity, // Don't refetch if we have data + gcTime: Infinity, // Keep data in cache forever +}); +``` +- Only works if user visited that DM conversation before while online + +**Option C: Use non-suspense query with loading state** +```typescript +const { data: registration, isLoading, error } = useQuery({ + // ...same config but not useSuspenseQuery +}); +// Show placeholder UI when offline/loading +``` + +--- + +## Prevention Guidelines + +For future development: + +1. **Always add `networkMode: 'always'` to IndexedDB queries** - React Query v5 defaults to `'online'` which pauses queries when offline + +2. **Audit query data sources** - Clearly distinguish between: + - Local queries (IndexedDB) → `networkMode: 'always'` + - Network queries (API) → Consider offline fallback strategy + +3. **Consider caching network data locally** - For critical paths like registration, cache data in IndexedDB for offline access + +4. **Test offline scenarios** - Add offline navigation to testing checklist + +--- + +## Related Documentation + +- [React Query v5 Network Mode](https://tanstack.com/query/latest/docs/framework/react/guides/network-mode) +- [Action Queue Feature](../.agents/docs/features/action-queue.md) - Handles offline actions correctly + +--- diff --git a/.agents/bugs/.solved/space-creation-config-save-race-condition.md b/.agents/bugs/.solved/space-creation-config-save-race-condition.md new file mode 100644 index 000000000..2d03e209f --- /dev/null +++ b/.agents/bugs/.solved/space-creation-config-save-race-condition.md @@ -0,0 +1,187 @@ +--- +type: bug +title: Space Creation Config Save Race Condition +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Space Creation Config Save Race Condition + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms + +When creating a new space, the space does not appear in the navigation menu after creation completes. This happens on both localhost and staging with sync enabled. A page refresh is required to see the newly created space. + +### Observable Behavior +1. User creates a new space successfully +2. Space is saved to database +3. Space does NOT appear in left navigation bar +4. User refreshes page → space now appears +5. Console shows sync succeeded with no errors + +### Debug Evidence +From console logs during space creation: +``` +[DEBUG] Before filtering: {spaceIds: 15, spaceKeys: 16, items: 12} +``` + +The new space (`QmW9Hfzqqc96Z5zNf9HbGUm178Qrqoz38ezfKs4LbSvjJu`) is in `spaceIds` (15 spaces) but NOT in `spaceKeys` (only has keys for 14 other spaces + 2 orphaned spaces). + +After filtering logic runs, the new space gets removed because it lacks encryption keys, causing it to disappear from the nav menu. + +--- + +## Root Cause + +### The Race Condition + +In `SpaceService.ts` (and the original `MessageDB.tsx` on develop), space creation follows this sequence: + +```typescript +// WRONG ORDER (line 447-468 in SpaceService.ts): +await saveConfig({ // 1. Save config (with new space in spaceIds) + config: { + ...config, + spaceIds: [...config.spaceIds, spaceAddress] // ← Space added to config + }, + keyset, +}); + +await messageDB.saveEncryptionState({ // 2. Save encryption state AFTER + state: JSON.stringify(session), + conversationId: spaceAddress + '/' + spaceAddress, + ... +}); +``` + +**What happens:** +1. `saveConfig()` is called with the new space in `spaceIds` +2. Inside `saveConfig()` (ConfigService.ts:392-411), it fetches encryption states from the database +3. **The new space doesn't have encryption state yet** because it hasn't been saved +4. The bidirectional filtering logic (added 2025-12-12 for server validation) removes spaces without encryption +5. New space gets filtered out of `spaceIds` before sync +6. User sees no space in nav menu + +### Why It Worked Before + +The original `develop` branch had the SAME ordering bug (confirmed via git history - see `MessageDB.tsx` line 2938-2958), BUT it didn't cause visible issues because: + +- **No filtering existed** in develop's `saveConfig` +- Spaces without encryption were still synced to server +- Server didn't validate `spaceIds ⟷ spaceKeys` consistency + +### Why It Fails Now + +On 2025-12-12, we added bidirectional filtering to fix folder operations failing with `400 - invalid config missing data`: + +```typescript +// ConfigService.ts:413-442 +// Filter spaceIds to only include spaces WITH encryption keys +const validSpaceIds = new Set(config.spaceKeys.map(sk => sk.spaceId)); +config.spaceIds = config.spaceIds.filter(id => validSpaceIds.has(id)); + +// Filter spaceKeys to only include keys for spaces IN spaceIds +const finalSpaceIds = new Set(config.spaceIds); +config.spaceKeys = config.spaceKeys.filter(sk => finalSpaceIds.has(sk.spaceId)); +``` + +This filtering is **necessary** for server validation (server rejects configs where `spaceIds.length !== spaceKeys.length`), but it exposes the ordering bug in space creation. + +--- + +## Solution + +**Swap the order**: Save encryption state BEFORE saving config. + +### Implementation (SpaceService.ts:431-471) + +```typescript +// CORRECT ORDER: +// 1. Save encryption state FIRST +await this.messageDB.saveEncryptionState({ + state: JSON.stringify(session), + timestamp: ts, + conversationId: spaceAddress + '/' + spaceAddress, + inboxId: inboxAddress, +}, true); + +// 2. Save config AFTER (now encryption exists when filtering runs) +const config = await this.messageDB.getUserConfig({ + address: registration.user_address, +}); +await this.saveConfig({ + config: { + ...config, + spaceIds: [...config.spaceIds, spaceAddress], + }, + keyset, +}); +``` + +### Why This Works + +When `saveConfig()` runs: +1. It fetches encryption states from database (line 398-407) +2. **New space NOW has encryption state** (saved in step 1) +3. Filtering logic keeps the space (has encryption ✓) +4. Config syncs with space included +5. Space appears in nav menu immediately + +### Files Changed +- `src/services/SpaceService.ts:431-471` - Moved `saveEncryptionState` before config save + +--- + +## Prevention + +### Rule: Always Save Encryption Before Config + +Any code that adds a space to `spaceIds` must ensure encryption state exists first: + +```typescript +// ✅ CORRECT pattern: +await saveEncryptionState({ ... }); // 1. Encryption first +await saveConfig({ + spaceIds: [...spaceIds, newSpace] // 2. Then add to config +}); + +// ❌ WRONG pattern: +await saveConfig({ + spaceIds: [...spaceIds, newSpace] // Config first +}); +await saveEncryptionState({ ... }); // Encryption after +``` + +### Where to Check + +Search for any code that modifies `spaceIds` or `items`: +```bash +git grep "spaceIds.*push\|spaceIds.*=.*\[" src/services/ +``` + +Verify encryption is saved BEFORE `saveConfig` is called. + +### Test During Development + +When creating/joining spaces, check console for debug logs: +``` +[DEBUG] Before filtering: {spaceIds: X, spaceKeys: Y} +``` + +If `spaceIds > spaceKeys` after a space operation, encryption is being saved too late. + +--- + +## Related Issues + +- **Folder operations failing with 400** - Fixed 2025-12-12 by adding bidirectional filtering +- **Server validation** - Server requires perfect `spaceIds ⟷ spaceKeys` consistency +- **Original develop branch** - Had same bug but was hidden by lack of filtering + +--- + + +_Fixed: 2025-12-12_ diff --git a/.agents/bugs/.solved/triple-ratchet-state-save-destroys-invite-fields.md b/.agents/bugs/.solved/triple-ratchet-state-save-destroys-invite-fields.md new file mode 100644 index 000000000..4e248c575 --- /dev/null +++ b/.agents/bugs/.solved/triple-ratchet-state-save-destroys-invite-fields.md @@ -0,0 +1,198 @@ +--- +type: bug +title: Triple Ratchet State Save Destroys Template/Evals Fields +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Triple Ratchet State Save Destroys Template/Evals Fields + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms + +When attempting to send a private invite to an existing conversation (without generating a public invite first), users receive the error: + +``` +Error: Encryption state is missing required template data. Please generate a public invite link first. +``` + +This error occurs at `InvitationService.ts:74` when `constructInviteLink()` checks for `sets[0].template`. + +**Expected behavior**: Private invites should work at any time, regardless of whether messages have been sent in the space. + +**Actual behavior**: Private invites only work if NO messages have been sent in the space after creation. Once a message is sent, the `template` and `evals` fields are lost. + +## Root Cause + +### The Bug + +When `encryptAndSendToSpace()` saves the encryption state after `TripleRatchetEncrypt`, it **only saves the ratchet state**, discarding the `template` and `evals` fields required for private invite generation. + +**Location**: `src/services/MessageService.ts:216-227` + +```typescript +const saveState = async () => { + await this.messageDB.saveEncryptionState( + { + state: JSON.stringify({ state: result.ratchet_state }), // ❌ BUG: Only saves ratchet state + timestamp: Date.now(), + inboxId: spaceId, + conversationId: spaceId + '/' + spaceId, + sentAccept: false, + }, + true + ); +}; +``` + +### Why This Matters + +The encryption state for spaces has THREE components: + +| Field | Purpose | Needed For | +|-------|---------|------------| +| `state` | Triple Ratchet state for encryption | Message encryption/decryption | +| `template` | DKG ratchet template | Private invite generation | +| `evals` | Polynomial evaluations (~10,000 secrets) | Private invite generation (consumes one per invite) | + +When only `{ state: result.ratchet_state }` is saved, `template` and `evals` are permanently lost. + +### Compare With Correct Pattern + +The receive-side code in the same file correctly preserves all fields: + +**Location**: `src/services/MessageService.ts:2390-2393` + +```typescript +newState = JSON.stringify({ + ...keys, // ✅ CORRECT: Preserves template/evals + state: JSON.stringify(ratchet), // Updates only ratchet_state +}); +``` + +### This Is a Regression + +**develop branch**: Does NOT save encryption state after `TripleRatchetEncrypt` for regular space messages (see `MessageDB.tsx:4828-4855`). This inadvertently preserved template/evals by not overwriting them. + +**cross-platform branch**: Added state saving (which IS needed for ratchet synchronization), but the implementation loses template/evals. + +### Additional Bug Locations + +The same bug pattern was introduced in three more locations by commit `a37bb283`: + +| Location | Context | +|----------|---------| +| `MessageService.ts:3593-3608` | Edit message path | +| `MessageService.ts:3740-3755` | Pin message path | +| `MessageService.ts:3854-3869` | Retry message path | + +All use the same incorrect pattern: `JSON.stringify({ state: result.ratchet_state })`. + +## Solution + +### Fix 1: Preserve existing fields when saving state + +Update `encryptAndSendToSpace()` to preserve template/evals: + +```typescript +// In encryptAndSendToSpace() - MessageService.ts:188-246 +const response = await this.messageDB.getEncryptionStates({ + conversationId: spaceId + '/' + spaceId, +}); +const sets = response.map((e) => JSON.parse(e.state)); + +// ... encryption logic ... + +const saveState = async () => { + await this.messageDB.saveEncryptionState( + { + state: JSON.stringify({ + ...sets[0], // ✅ Preserve template, evals, and any other fields + state: result.ratchet_state, // Update only the ratchet state + }), + timestamp: Date.now(), + inboxId: response[0]?.inboxId || spaceId, + conversationId: spaceId + '/' + spaceId, + sentAccept: false, + }, + true + ); +}; +``` + +### Fix 2: Apply same fix to edit/pin/retry paths + +Update the three locations from commit `a37bb283` with the same pattern. + +### Files to Modify + +| File | Lines | Context | +|------|-------|---------| +| `src/services/MessageService.ts` | 216-227 | `encryptAndSendToSpace()` | +| `src/services/MessageService.ts` | 3593-3608 | Edit message path | +| `src/services/MessageService.ts` | 3740-3755 | Pin message path | +| `src/services/MessageService.ts` | 3854-3869 | Retry message path | + +## Prevention + +1. **Pattern documentation**: Add a comment explaining the full structure of space encryption states +2. **Type safety**: Consider creating a TypeScript interface for the complete encryption state structure +3. **Unit test**: Add test verifying template/evals are preserved after message send + +## Verification + +After fix: +1. Create a new space +2. Send a message in the space +3. Go to Space Settings → Invites +4. Select an existing conversation +5. Click "Send Invite" +6. ✅ Should succeed (currently fails with "missing template data" error) + +## Open Questions + +Before implementing the fix, verify: + +1. **Does `...sets[0]` spreading work correctly?** + - `sets[0]` should contain `{ state, template, evals }` + - After spread + override: `{ ...sets[0], state: newRatchetState }` should preserve `template` and `evals` + - Need to confirm this doesn't introduce any nested object reference issues + +2. **Are there edge cases where template/evals should NOT be preserved?** + - After `generateNewInviteLink()` - a new session is created with fresh template/evals + - After `kickUser()` - a rekey happens + - These operations already save the full session object, so this fix shouldn't interfere + +3. **Does this pattern match what the codebase already does?** + - ✅ YES - Line 2390-2393 uses `{ ...keys, state: JSON.stringify(ratchet) }` for control messages + - The fix aligns with the established pattern + +## Risk Assessment + +| Risk | Level | Mitigation | +|------|-------|------------| +| Breaking existing message encryption | Low | The fix only adds fields, doesn't change ratchet_state handling | +| Breaking state saving for Action Queue | Low | The state field is still saved correctly | +| Stale template/evals after rekey | Low | Rekey operations save full session, not just ratchet_state | + +## Related Documentation + +- [Invite System Analysis](.agents/docs/features/invite-system-analysis.md) - Full invite system documentation +- [Encryption State Structure](src/services/InvitationService.ts:55-105) - How template/evals are used +- [Space Message Comparison Audit](.agents/reports/action-queue/004-space-message-code-comparison-audit.md) - Original analysis that led to a37bb283 + +## Commit History + +| Commit | Description | Impact | +|--------|-------------|--------| +| `a37bb283` | Added state saving for edit/pin paths | Introduced the bug pattern | +| `b5013cf6` | Extracted `encryptAndSendToSpace()` | Consolidated the bug into shared helper | + +--- + + +_Branch: cross-platform_ +_Introduced by: Service extraction refactor + commit a37bb283_ diff --git a/.agents/bugs/.solved/user-kick-role-permission-non-functional.md b/.agents/bugs/.solved/user-kick-role-permission-non-functional.md new file mode 100644 index 000000000..6427c6f37 --- /dev/null +++ b/.agents/bugs/.solved/user-kick-role-permission-non-functional.md @@ -0,0 +1,128 @@ +--- +type: bug +title: 'user:kick Role Permission is Non-Functional for Non-Owners' +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# user:kick Role Permission is Non-Functional for Non-Owners + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms + +The `user:kick` permission can be assigned to roles in Space Settings, but **moderators (non-owners) cannot actually kick users** even when they have a role with this permission. + +- The "Kick User" permission appears in the role settings dropdown +- Users with this permission see the Kick button in the UI +- **Clicking the button fails** because the underlying operation requires cryptographic keys that only the space owner possesses + +## Root Cause + +The `kickUser()` function in [SpaceService.ts:663-730](src/services/SpaceService.ts#L663-L730) requires the **space owner's ED448 private key** to: + +1. Sign the new space registration (line 725-730) +2. Sign the space manifest update (line 794-806) +3. Sign the sync envelope for broadcasting the kick (line 951-979) + +When a non-owner calls `kickUser()`: + +```typescript +// Line 686 - This returns undefined for non-owners +const ownerKey = await this.messageDB.getSpaceKey(spaceId, 'owner'); + +// Lines 707, 715, 727 - These crash because ownerKey is undefined +...hexToSpreadArray(ownerKey.publicKey)... +...ownerKey.privateKey... +``` + +**The owner key is only stored when creating a space** ([SpaceService.ts:370-377](src/services/SpaceService.ts#L370-L377)). When users join via invite, they only receive: +- `hub` key +- `config` key +- `inbox` key + +They **never receive the `owner` key** because sharing it would compromise space security. + +## Comparison: message:delete Works, user:kick Does Not + +| Permission | How it works | Non-owner support | +|------------|--------------|-------------------| +| `message:delete` | Delete messages are **regular encrypted messages** that receiving clients validate by checking sender's roles | ✅ Works - validated on receive side | +| `user:kick` | Kick requires **owner signature** on space registration + manifest + sync envelope | ❌ Broken - requires owner private key | + +The `message:delete` permission works because: +1. The sender broadcasts a delete message (no special key needed) +2. **Receiving clients** validate if sender has `message:delete` role ([MessageService.ts:839-848](src/services/MessageService.ts#L839-L848)) + +The `user:kick` permission fails because: +1. Kicking requires signing with the owner's ED448 private key +2. Only the space owner has this key +3. There's no delegation mechanism for moderators + +## Solution + +**Remove the `user:kick` permission from the role settings UI** until a proper delegation mechanism is implemented. + +### Immediate Fix (Remove Broken Feature) + +1. **Remove from role permission options** in [Roles.tsx:168-171](src/components/modals/SpaceSettingsModal/Roles.tsx#L168-L171): + ```typescript + // Remove this option: + { + value: 'user:kick', + label: t`Kick Users`, + }, + ``` + +2. **Remove from Permission type** (optional, for cleanup): + - File: [quorumApi.ts:3](src/api/quorumApi.ts#L3) + - Remove `'user:kick'` from the union type + +3. **Remove canKickUser checks** that reference role permissions: + - [channelPermissions.ts:113-124](src/utils/channelPermissions.ts#L113-L124) - Simplify to only check `isSpaceOwner` + - [permissions.ts:53](src/utils/permissions.ts#L53) - Remove from owner's implicit permissions (owner already can kick via protocol) + +### Future Implementation (If Delegated Kicking is Desired) + +A proper implementation would require one of: + +**Option A: Owner Delegation via Signed Authorization** +- Moderator requests kick +- Owner client receives request and signs it +- Broadcast the owner-signed kick +- Requires owner to be online + +**Option B: Multi-Signature Protocol Change** +- Modify protocol to accept moderator signatures for kicks +- Receiving clients would validate both moderator role AND moderator signature +- Requires protocol-level changes to the Quilibrium SDK + +Both options are significant architectural changes and should be tracked as a separate feature task if needed. + +## Files Affected + +**UI (to remove broken feature):** +- `src/components/modals/SpaceSettingsModal/Roles.tsx:168-171` - Permission dropdown + +**Types (optional cleanup):** +- `src/api/quorumApi.ts:3` - Permission type definition + +**Permission logic (simplification):** +- `src/utils/channelPermissions.ts:113-124` - canKickUser function +- `src/utils/permissions.ts:53` - getPermissions function + +## Prevention + +When adding permission-based features: +1. **Verify the underlying operation can actually be performed by non-owners** +2. Check if the operation requires cryptographic keys that are owner-only +3. Add receiving-side validation if the permission is broadcast-based +4. Don't expose UI for features that will fail at runtime + +--- + + +_Severity: Medium (broken feature, no security impact)_ +_Type: Feature that was implemented incompletely_ diff --git a/.agents/bugs/.solved/usersettingsmodal-scalability-performance.md b/.agents/bugs/.solved/usersettingsmodal-scalability-performance.md new file mode 100644 index 000000000..f0cfc4612 --- /dev/null +++ b/.agents/bugs/.solved/usersettingsmodal-scalability-performance.md @@ -0,0 +1,213 @@ +--- +type: bug +title: >- + Performance Issue: UserSettingsModal Save Times Scale Poorly with Number of + Spaces +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +related_issues: + - '#65' +--- + +# Performance Issue: UserSettingsModal Save Times Scale Poorly with Number of Spaces + +Added to Github Issues: https://github.com/QuilibriumNetwork/quorum-desktop/issues/65 + +## Problem Summary + +The UserSettingsModal save operation has fundamental scalability issues that make it unusable for users with many spaces. While recent optimizations improved the situation, the core architectural problems remain and will cause severe performance degradation as users join more spaces. + +## Current Performance Issues + +### 1. Database Queries Scale Linearly O(n) + +Every profile save executes database queries proportional to the number of spaces: + +- Each space requires 2 database calls: `getSpaceKeys()` + `getEncryptionStates()` +- User with 30 spaces = 60 database queries per save +- Even with parallel execution, this creates significant I/O overhead + +### 2. Payload Size Grows with Space Count + +The encrypted user config includes ALL space keys and encryption states: + +- More spaces = larger JSON payload +- Larger payload = slower encryption + network transmission +- Exponential growth in memory usage + +### 3. API Bottleneck Gets Worse + +The `postUserSettings` API call currently takes 7-8 seconds (as noted in commit f4000e58). This scales poorly: + +- Current (4 spaces): ~8 seconds total +- Projected (30 spaces): ~25-30 seconds total + +### 4. All-or-Nothing Sync Model + +Currently, ANY profile change forces a complete re-sync of ALL space data, regardless of what actually changed. + +## Code Location + +**File:** `src/components/context/MessageDB.tsx:5252-5269` + +```typescript +const spaces = await messageDB.getSpaces(); + +// Fetch all space keys and encryption states in parallel +const spaceKeysPromises = spaces.map(async (space) => { + const [keys, encryptionState] = await Promise.all([ + messageDB.getSpaceKeys(space.spaceId), + messageDB.getEncryptionStates({ + conversationId: space.spaceId + '/' + space.spaceId, + }), + ]); + return { + spaceId: space.spaceId, + encryptionState: encryptionState[0], + keys: keys, + }; +}); + +config.spaceKeys = await Promise.all(spaceKeysPromises); +``` + +## Performance Timeline + +This issue has existed since the very beginning of the project: + +- **January 19, 2025** (commit 43f3fa4d): Initial public commit - sequential spaceKeys processing included from day one +- **September 9, 2025** (commit f4000e58): Parallel processing implemented - partial improvement + +## Impact Assessment + +### Current Impact (4-6 spaces) + +- Save times: ~8 seconds +- Acceptable but noticeable delay + +### Projected Impact (10+ spaces) + +- Save times: 12-20+ seconds +- Unusable user experience +- Potential timeouts and failures + +### Power User Impact (20+ spaces) + +- Save times: 25-30+ seconds +- Application appears frozen +- Users may abandon profile updates + +## Proposed Solutions + +### 1. Lazy Loading (Quick Win) 🟡 + +Only sync spaces that have changed since last sync: + +```typescript +const changedSpaces = spaces.filter( + (space) => space.lastModified > config.lastSyncTimestamp +); +``` + +**Pros:** Easy to implement, immediate improvement +**Cons:** Still processes all spaces on first sync + +### 2. Incremental Sync (Better) 🟠 + +Separate user profile from space data syncing: + +```typescript +// Only sync what actually changed +if (profileChanged) { + await postUserProfile(profileData); +} +if (spaceKeysChanged) { + await postUserSpaceKeys(onlyChangedSpaces); +} +``` + +**Pros:** Scales better, more targeted syncing +**Cons:** Requires API changes, more complex state tracking + +### 3. Background Sync (Best) 🟢 + +Save profile immediately, sync spaces asynchronously: + +```typescript +// Instant user feedback +await saveProfileLocally(config); + +// Background sync without blocking UI +backgroundSync.queue(() => syncSpacesToServer(config)); +``` + +**Pros:** Best UX, true scalability +**Cons:** Most complex, requires offline-first architecture + +### 4. Hybrid Approach 🔵 + +Combine multiple strategies: + +- Instant local save + background sync for spaces +- Lazy loading for changed spaces only +- Batch API calls to reduce round trips + +## Acceptance Criteria + +### Immediate (Next Release) + +- [ ] Profile saves complete in <2 seconds regardless of space count +- [ ] Users receive immediate feedback when saving +- [ ] No UI blocking during save operations + +### Long-term (Future Releases) + +- [ ] Support for 50+ spaces without performance degradation +- [ ] Offline-capable profile editing +- [ ] Incremental sync with conflict resolution + +## Testing Strategy + +### Performance Testing + +- [ ] Test with 5, 10, 20, 50 spaces +- [ ] Measure save times across different scenarios +- [ ] Monitor memory usage during large syncs + +### User Experience Testing + +- [ ] Test with slow network connections +- [ ] Verify save state indicators work correctly +- [ ] Test error handling for failed syncs + +## Priority + +**High** - This will become a critical blocker as the platform grows and users join more spaces. The current architecture makes the application unusable for power users. + +## Related Issues + +- Performance optimization in commit f4000e58 +- User settings architecture +- API scalability concerns + +## Tyler's comment + +we need to make a background sync queue. This will be useful for all sorts of actions, including this one. + +The importance is to essentially capture the action (and any context) needing to be done and add it to an offline queue, then once it's there the user UI is freed back up for the user to go about as they wish while the activity they submitted happens in the background. There are things to consider, e.g. saving user profile should automatically update first in the local database and then submit (if they have sync enabled) to the remote sync API endpoint. + +This queue should be generic enough to be used for almost any actions. + +E.g. send a message, save profile, etc. + +How this would be done is you'd have a list of valid actions/task types, 'send-message'. You'd save a list of these activities in the DB with a data schema something like: + +ID, auto incremented +task type; send-message/delete-message/update-profile/etc. +context; data that goes with each task, message content, message ID, profile info +And then the Queue works through each task in the order they are submitted (top down). It removes tasks after successful completion and sends a toast to the user. If it fails, it toasts the user with the error. + +There may be a library for this such that we don't have to re-invent the wheel on this. + +--- diff --git a/.agents/bugs/.solved/websocket-processqueue-stuck-blocking-outbound.md b/.agents/bugs/.solved/websocket-processqueue-stuck-blocking-outbound.md new file mode 100644 index 000000000..c179f6fa4 --- /dev/null +++ b/.agents/bugs/.solved/websocket-processqueue-stuck-blocking-outbound.md @@ -0,0 +1,253 @@ +--- +type: bug +title: WebSocket processQueue Stuck - Blocking Outbound Messages +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-19T00:00:00.000Z +--- + +# WebSocket processQueue Stuck - Blocking Outbound Messages + +> **AI-Generated**: May contain errors. Verify before use. + +## Symptoms + +- Space profile updates (avatar, display name) never sync to server +- Outbound messages queue up but never execute +- Console shows repeated `[WebSocket] processQueue early return - processing: true handler: true` +- `processingRef.current` lock never releases +- Actions using `enqueueOutbound` silently fail (no error, just never execute) + +## Root Cause + +The `WebSocketProvider.processQueue()` function in `src/components/context/WebsocketProvider.tsx` can get stuck indefinitely: + +1. **Processing lock acquired** (line ~81): `processingRef.current = true` +2. **Inbound messages grouped and processed** (lines ~88-118): Messages are wrapped in Promises and processed via `Promise.allSettled(allPromises)` +3. **Handler blocks/hangs**: If `handlerRef.current!(message)` at line ~108 never returns (hangs indefinitely), the Promise wrapping it never settles +4. **Promise.allSettled waits forever**: Line ~118 `await Promise.allSettled(allPromises)` blocks indefinitely waiting for the hung promise +5. **Execution never reaches finally block**: Since `Promise.allSettled` never completes, the finally block (line ~160-163) that releases the lock is never reached +6. **Outbound queue starves**: Lines 125-157 (outbound processing) never execute because the function is stuck at `Promise.allSettled` + +**Key Issue**: There's no timeout or recovery mechanism for hung handlers. A single slow/stuck inbound message blocks ALL outbound messages indefinitely. + +**Note**: The issue is NOT that errors prevent the finally block from executing (errors are caught). The issue is that if a handler **never returns** (infinite loop, deadlock, unresolved Promise), `Promise.allSettled` waits forever and execution never reaches the finally block. + +**Code Location**: `src/components/context/WebsocketProvider.tsx:74-164` + +```typescript +const processQueue = async () => { + if (processingRef.current || !handlerRef.current) { + return; // <-- All subsequent calls return here while stuck + } + + processingRef.current = true; // <-- Lock acquired (line 81) + try { + // Inbound messages wrapped in promises (lines 99-117) + for (const [_, messages] of inboxMap) { + allPromises.push( + new Promise(async (resolve) => { + for (const message of messages) { + await handlerRef.current!(message); // <-- If this hangs, promise never settles + } + resolve(); + }) + ); + } + await Promise.allSettled(allPromises); // <-- Waits forever if any promise never settles (line 118) + // ... outbound processing (lines 125-157, never reached if above hangs) ... + } finally { + processingRef.current = false; // <-- Never reached if Promise.allSettled hangs (line 161) + } +}; +``` + +## Impact + +### Affected Features +- **DM sending** (via ActionQueue's `sendDirectMessages`) - CRITICAL +- **DM listen subscriptions** (after processing inbound DM) +- **Space profile updates** (`update-profile` message type) - discovered during investigation +- **Message retries** (both Space and DM retry flows) +- **Any action using `enqueueOutbound`**: + - Edit messages + - Pin/unpin messages + - Profile updates + +### Relationship to Action Queue +The Action Queue (`ActionQueueService`) is **not the cause** but is affected: +- Action Queue handlers call `MessageService` methods +- `MessageService.submitChannelMessage()` uses `this.enqueueOutbound()` for certain message types +- `enqueueOutbound` → `WebSocketProvider.enqueueOutbound()` → stuck `processQueue` + +Per action-queue.md documentation (lines 354-367): +``` +ActionQueueService → MessageService/ConfigService → WebSocketProvider +(Persistence layer) (Business logic) (Transport layer) +``` + +The bug is in the transport layer, affecting both Action Queue path and legacy paths. + +## Pre-existing Bug Verification + +### Bug Exists in `develop` and `cross-platform` Branches + +The WebsocketProvider.tsx has the **identical single-lock architecture** in all branches: + +**`develop` branch:** +```bash +$ git show develop:src/components/context/WebsocketProvider.tsx | grep -n "processingRef" +33: const processingRef = useRef(false); # Single lock +59: if (processingRef.current || !handlerRef.current) { +63: processingRef.current = true; +115: processingRef.current = false; +``` + +**`cross-platform` branch:** +```bash +$ git show cross-platform:src/components/context/WebsocketProvider.tsx | grep -n "processingRef" +35: const processingRef = useRef(false); # Single lock +75: if (processingRef.current || !handlerRef.current) { +79: processingRef.current = true; +139: processingRef.current = false; +``` + +**Conclusion**: The bug has existed since the initial implementation (`develop` branch, initial public commit). It is NOT caused by the Action Queue. + +### Why Action Queue Makes the Bug More Visible + +| Branch | `enqueueOutbound` calls | Key Difference | +|--------|------------------------|----------------| +| `cross-platform` | 7 calls | All triggered by user actions (edit, pin, send message) | +| `action-queue` | 6 calls + **new `sendDirectMessages()`** | ActionQueue calls `sendDirectMessages()` for ALL DM sends | + +**The critical difference**: In `cross-platform`, DM sending used a different code path. With Action Queue: +1. ALL DM sends go through `ActionQueueHandlers.sendDmHandler()` +2. Which calls `MessageService.sendDirectMessages()` (line 171) +3. Which calls `this.enqueueOutbound()` + +**Before Action Queue**: DM sending didn't use `enqueueOutbound` as heavily, so the bug was less likely to trigger. + +**With Action Queue**: Every single DM send now depends on `enqueueOutbound` executing, making the blocking bug affect a core feature (DM communication) rather than just edge cases (edit, pin, profile update). + +## Discovery Context + +Found while debugging why Space avatar/display name changes weren't persisting: +1. User saves profile in Space Settings modal +2. `useSpaceProfile.onSave()` calls `submitChannelMessage()` with `update-profile` type +3. Message correctly enqueued to outbound queue +4. `processQueue` already stuck processing inbound messages +5. Outbound callback never executes +6. Profile change never sent to server + +## Solution + +### Chosen: Separate Inbound/Outbound Processing (Option 2) + +After analysis by feature-analyzer agent, **Option 2 is the safest and recommended fix**. + +#### Why Option 2 (Separate Locks) + +| Criteria | Option 1 (Timeout) | Option 2 (Separate) | Option 3 (Bypass) | +|----------|-------------------|---------------------|-------------------| +| Data loss risk | **HIGH** - timeout mid-encryption corrupts state | **NONE** | **MEDIUM** - ordering issues | +| Behavior change | Yes - may skip messages | **No** - same logic, just parallel | Yes - breaks ordering | +| Code complexity | Medium | **Low** - just split function | Low but fragile | +| Fixes bug completely | Partially (still blocks during timeout) | **Yes** | Partially | + +#### Verification Results (feature-analyzer) + +**All claims confirmed:** +1. **Inbound and outbound are independent** - Only producer-consumer relationship (inbound can enqueue outbound via `MessageService.ts:2037`, but doesn't wait for it) +2. **No hidden dependencies** - They share no state requiring synchronized access +3. **WebSocket API supports concurrent sends** - `send()` is thread-safe +4. **Ordering preserved** - Outbound FIFO maintained, inbound already parallel by inbox + +#### Implementation + +```typescript +// Separate processing locks +const inboundProcessingRef = useRef(false); +const outboundProcessingRef = useRef(false); + +const processInbound = async () => { + if (inboundProcessingRef.current || !handlerRef.current) return; + inboundProcessingRef.current = true; + try { + // Lines 86-123 (existing inbound logic) + } finally { + inboundProcessingRef.current = false; + } +}; + +const processOutbound = async () => { + if (outboundProcessingRef.current) return; + outboundProcessingRef.current = true; + try { + // Lines 125-157 (existing outbound logic) + } finally { + outboundProcessingRef.current = false; + } +}; +``` + +#### Call Site Updates + +| Location | Current | After Fix | +|----------|---------|-----------| +| `ws.onopen` (line 173) | `processQueue()` | `processInbound(); processOutbound();` | +| `ws.onmessage` (line 185) | `processQueue()` | `processInbound();` (only inbound needed) | +| `setInterval` (line 204) | `processQueue()` | `processInbound(); processOutbound();` | +| `enqueueOutbound` (line 215) | `processQueue()` | `processOutbound();` (only outbound needed) | +| `setMessageHandler` (line 220) | `processQueue()` | `processInbound();` (only inbound needed) | + +### Rejected Options + +#### Option 1: Add Timeout (REJECTED - Data Loss Risk) +```typescript +await processWithTimeout(() => handlerRef.current!(message), 30000); +``` +**Risk**: If handler times out mid-encryption-state-update (e.g., `MessageService.ts:2037`), state may be corrupted. The handler could still be running after timeout, causing race conditions. + +#### Option 3: Non-blocking Outbound (REJECTED - Breaks Guarantees) +```typescript +if (wsRef.current?.readyState === WebSocket.OPEN) { + wsRef.current.send(message); +} +``` +**Risk**: Critical messages jump the queue, breaking ordering guarantees. Inconsistent behavior - some messages queued, some not. + +## Prevention + +1. ~~**Add handler timeouts**~~ - Not recommended (data loss risk) +2. **Separate concerns**: Inbound and outbound processing should not share a single lock +3. **Add monitoring**: Log when processing takes >5s for debugging +4. **Architecture principle**: Independent operations should have independent locks + +## Related Files + +- `src/components/context/WebsocketProvider.tsx:74-164` - Bug location +- `src/services/MessageService.ts:171` - `sendDirectMessages()` uses `enqueueOutbound` (ActionQueue path) +- `src/services/MessageService.ts:1563` - `submitMessage()` uses `enqueueOutbound` for DMs +- `src/services/MessageService.ts:2037` - DM listen subscription uses `enqueueOutbound` +- `src/services/MessageService.ts:3513` - `submitChannelMessage()` uses `enqueueOutbound` for edit/pin/update-profile +- `src/services/MessageService.ts:3941` - `retryFailedChannelMessage()` uses `enqueueOutbound` +- `src/services/MessageService.ts:4088` - `retryFailedDirectMessage()` uses `enqueueOutbound` +- `src/hooks/business/spaces/useSpaceProfile.ts` - Affected feature (profile updates) +- `.agents/docs/features/action-queue.md` - Documents WebSocket queue relationship + +## Debug Logs Added (To Remove) + +During investigation, debug logs were added to these files: +- `src/components/context/WebsocketProvider.tsx` - processQueue flow logging +- `src/services/MessageService.ts` - submitChannelMessage logging +- `src/hooks/business/spaces/useSpaceProfile.ts` - onSave flow logging + +These should be removed after the fix is implemented. + +--- + + +_Updated: 2025-12-19_ (corrected blocking mechanism, added all affected features, added feature-analyzer verification, chose Option 2) +_Discovered while: Debugging Space profile avatar not updating_ +_Status: Ready for implementation_ diff --git a/.agents/bugs/config-sync-space-loss-race-condition.md b/.agents/bugs/config-sync-space-loss-race-condition.md new file mode 100644 index 000000000..290733cc1 --- /dev/null +++ b/.agents/bugs/config-sync-space-loss-race-condition.md @@ -0,0 +1,229 @@ +--- +type: bug +title: Config Sync Space Loss Race Condition +status: open +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-13T00:00:00.000Z +--- + +# Config Sync Space Loss Race Condition + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms + +- User has spaces A, B, C on Device A with sync enabled +- User imports profile on Device B +- Only spaces A and B appear on Device B (space C missing) +- After some time, space C also disappears from Device A's nav menu +- Reported on one profile so far - may affect more users + +## Root Cause + +The `saveConfig()` function in `ConfigService.ts` **overwrites the entire remote config** based on local state, filtering out any spaces that lack complete encryption data locally. This creates a destructive race condition during cross-device sync. + +### The Filtering Logic (ConfigService.ts:391-432) + +```typescript +// Line 391: Get spaces from LOCAL DB only +const spaces = await this.messageDB.getSpaces(); + +// Lines 408-410: Filter out spaces without encryption state +config.spaceKeys = allSpaceKeys.filter(sk => sk.encryptionState !== undefined); + +// Lines 414-415: Remove spaces from spaceIds if not in spaceKeys +const validSpaceIds = new Set(config.spaceKeys.map(sk => sk.spaceId)); +config.spaceIds = config.spaceIds.filter(id => validSpaceIds.has(id)); + +// Lines 416-427: Remove spaces from nav items too +if (config.items) { + config.items = config.items.filter(item => { + if (item.type === 'space') { + return validSpaceIds.has(item.id); // ← SPACE PERMANENTLY REMOVED + } else { + item.spaceIds = item.spaceIds.filter(id => validSpaceIds.has(id)); + return item.spaceIds.length > 0; + } + }); +} +``` + +### Race Condition Sequence + +1. **Device A**: Has spaces A, B, C → saves config with all three +2. **Device B**: Imports profile, `getConfig()` runs + - Space sync loop (lines 136-293) processes A, B, C + - Space C fails mid-sync (network error, manifest unavailable, hub registration fails) + - Device B has spaces A, B locally; C partially synced or missing +3. **Device B**: Any action triggers `saveConfig()`: + - Reads local DB → only finds A, B with valid encryption states + - Filters out C (missing or incomplete encryption state) + - Uploads config with `{spaceIds: [A, B]}` → **C is gone from remote** +4. **Device A**: Calls `getConfig()` later + - Remote config (newer timestamp) only has A, B + - Local config updated to match → **C disappears from nav menu** + +### Why Space Sync Can Fail Silently + +In `getConfig()` lines 136-293, the space import has multiple failure points wrapped in try-catch: + +```typescript +for (const space of config.spaceKeys ?? []) { + const existingSpace = await this.messageDB.getSpace(space.spaceId); + if (!existingSpace) { + try { + // Lines 140-143: Key validation (can fail) + // Lines 156-165: Manifest fetch (can fail - logged as warning) + // Lines 206-210: Save encryption state + // Lines 212-256: Hub registration (can fail) + // Lines 265-275: Save inbox key + } catch (e) { + console.error(t`Could not add Space`, e); // Silent continue! + } + } +} +``` + +If any step fails, the space is **not fully initialized** but the loop continues. The space may exist in memory (in `config.spaceIds`) but not have a valid `encryptionState` in the database. + +## Affected Code + +| File | Lines | Issue | +|------|-------|-------| +| [ConfigService.ts](src/services/ConfigService.ts#L391-L432) | 391-432 | `saveConfig()` filters spaces without encryption state | +| [ConfigService.ts](src/services/ConfigService.ts#L136-L293) | 136-293 | `getConfig()` space sync can fail silently mid-loop | + +### Triggers for `saveConfig()` (all can cause loss) + +- `useUserSettings.ts:146` - User saves profile/settings +- `useSpaceDragAndDrop.ts:62` - Dragging spaces in nav +- `useFolderManagement.ts:141` - Creating/managing folders +- `useFolderDragAndDrop.ts:588` - Dragging folders +- `useDeleteFolder.ts:61` - Deleting folders +- `SpaceService.ts:449,467` - Creating spaces +- `SpaceService.ts:649` - Deleting spaces +- `InvitationService.ts:818` - Joining spaces via invite +- `EncryptionService.ts:237` - Fixing encryption states +- `MessageService.ts:2554` - Handling kick messages + +## Mitigation Implemented + +### Space Recovery Tool (2025-12-13) + +Added user-facing recovery tool in User Settings → Privacy/Security → Data Recovery. + +**Location**: [useSpaceRecovery.ts](../../src/hooks/business/user/useSpaceRecovery.ts) + +**What it does**: +- Scans local IndexedDB for spaces not in navigation menu +- Validates encryption state (must exist, must be valid JSON) +- Skips bloated encryption states (>500KB) - these cause sync failures +- Adds recoverable spaces back to nav menu and syncs + +**Limitations**: +- Only recovers spaces with valid local encryption data +- Cannot recover spaces with bloated states (see separate bug) +- Cannot recover spaces that were never synced to this device + +**Task doc**: [space-recovery-tool.md](../tasks/space-recovery-tool.md) + +### Diagnostic Logging (2025-12-13) + +Added warning log to detect when spaces are filtered out during sync: + +```typescript +// ConfigService.ts:412-419 +const spacesWithoutEncryption = allSpaceKeys.filter(sk => sk.encryptionState === undefined); +if (spacesWithoutEncryption.length > 0) { + console.warn( + `[ConfigService] ${spacesWithoutEncryption.length} space(s) filtered from sync (missing encryption state):`, + spacesWithoutEncryption.map(sk => sk.spaceId) + ); +} +``` + +This helps detect if the bug is occurring in production. If users report space loss, check browser console for this warning. + +## Why Full Fix Is Complex + +The filtering logic **cannot simply be removed** - it was added to fix a different bug where folder operations failed with `400 - invalid config missing data`. The server requires `spaceIds.length === spaceKeys.length` (bidirectional consistency). + +See: [.agents/bugs/.solved/space-creation-config-save-race-condition.md](.agents/bugs/.solved/space-creation-config-save-race-condition.md) + +## Solution Options + +**Not yet implemented.** Requires architectural decision: + +### Option A: Merge Instead of Replace (Recommended) +Before `saveConfig()` overwrites remote, merge local and remote space lists: +1. Fetch current remote config, decrypt it +2. Merge `spaceKeys`: local takes precedence for conflicts, but preserve remote-only spaces +3. Only remove a space if explicitly deleted (add `deletedSpaceIds` tombstone array like bookmarks have) + + +### Option B: Delay saveConfig Until Sync Complete +Add a sync state flag to prevent `saveConfig()` during active space sync: +1. Set `isSyncingSpaces = true` at start of `getConfig()` space loop +2. Block `saveConfig()` while flag is set (queue the save) +3. Only allow save after all spaces have valid encryption states + + +### Option C: Mark Incomplete Spaces +Track spaces that failed to sync and exclude them from filtering: +1. Add `pendingSyncSpaces: string[]` to local state +2. When space sync fails, add to pending list +3. In `saveConfig()`, preserve `pendingSyncSpaces` in remote config +4. Retry sync for pending spaces periodically + + +### Option D: Block Sync on Significant Space Loss (Considered, Not Implemented) +Before uploading, check if we'd lose many spaces compared to local DB: +```typescript +if (newSpaceCount < dbSpaceCount * 0.5) { + console.error('Aborting sync: would lose too many spaces'); + return; // Save locally only +} +``` + +**Problem**: Blocks ALL sync (profile, bookmarks, settings) not just space data. Too aggressive. + +## Prevention + +Once fixed, prevent regression with: + +1. **Integration test**: Simulate partial space sync failure, verify spaces preserved +2. **Sync state tracking**: Add UI indicator when spaces are syncing +3. **Conflict detection**: Warn user if remote config has spaces not present locally + +## Comparison with Bookmark Sync + +Bookmarks already handle this correctly with tombstone tracking: + +```typescript +// ConfigService.ts - Bookmark merge preserves deletions +config.deletedBookmarkIds?: string[]; // Tombstone tracking + +private mergeBookmarks(local, remote, deletedIds) { + // Uses additive merge with explicit deletion tracking +} +``` + +Spaces need similar treatment but currently lack any merge or tombstone logic. + +## Impact + +- **Severity**: High - causes permanent data loss (spaces removed from nav) +- **Affected users**: Anyone with `allowSync: true` using multiple devices +- **Recovery**: Space data may still exist in local IndexedDB but orphaned from nav +- **Detection**: Silent - no error message shown to user + +## Related Documentation + +- [config-sync-system.md](.agents/docs/architecture/config-sync-system.md) - Config sync architecture +- [user-config-sync.md](.agents/docs/features/user-config-sync.md) - Profile sync feature (different issue) + +--- + + +_Status: Mitigated (recovery tool available, diagnostic logging added)_ diff --git a/.agents/bugs/directmessage-invite-loading-performance.md b/.agents/bugs/directmessage-invite-loading-performance.md new file mode 100644 index 000000000..e62330398 --- /dev/null +++ b/.agents/bugs/directmessage-invite-loading-performance.md @@ -0,0 +1,98 @@ +--- +type: bug +title: DirectMessage Invite Loading Performance Issue +status: open +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# DirectMessage Invite Loading Performance Issue + +## Problem + +Space invites in DirectMessage conversations take a considerable time to load, creating poor UX when multiple invites are present in a conversation. Each invite triggers expensive cryptographic operations without any caching or optimization. + +## Root Cause Analysis + +### Cryptographic Operations Bottleneck + +Each invite link triggers a chain of expensive operations in `src/components/context/MessageDB.tsx:4498-4579`: + +1. **API call** to `getSpaceManifest(spaceId)` +2. **First decryption**: `ch.js_decrypt_inbox_message()` to decrypt space manifest +3. **Public key generation**: `ch.js_get_pubkey_x448()` +4. **Conditional second API call** to `getSpaceInviteEval()` +5. **Second decryption**: Another `ch.js_decrypt_inbox_message()` call +6. **Buffer operations**: Multiple hex/base64 conversions and JSON parsing + +### No Caching Mechanism + +Critical performance issues: +- Each invite is processed independently via `useInviteProcessing` hook +- No caching of decrypted space data +- No memoization for identical invite links +- Duplicate invites trigger full crypto chain every time + +### Multiplicative Problem + +In conversations with multiple invites: +- Each invite triggers the complete cryptographic processing chain +- No batching or rate limiting +- Parallel processing can overwhelm crypto operations +- Edge case becomes significant UX problem + +## Impact + +- **User Experience**: Slow loading of invite cards +- **Resource Usage**: Unnecessary crypto operations and API calls +- **Scalability**: Problem compounds with more invites per conversation + +## Proposed Solutions + +### 1. Implement Invite Cache (Quick Win) +```typescript +// In MessageDB context +const inviteCache = new Map<string, { space: Space; timestamp: number }>(); + +// In processInviteLink, check cache first +const cacheKey = `${spaceId}-${configKey}`; +const cached = inviteCache.get(cacheKey); +if (cached && Date.now() - cached.timestamp < 300000) { // 5 min TTL + return cached.space; +} +``` + +### 2. Batch Processing +- Queue multiple invite processing requests +- Process in batches with rate limiting +- Prevent overwhelming crypto operations + +### 3. Progressive Loading +- Show skeleton/loading state immediately +- Staggered timing for multiple invites (100ms delays) +- Prioritize visible invites first + +### 4. Lazy Loading +- Only process invites when entering viewport +- Use intersection observer for trigger +- Ideal for conversations with many invites + +### 5. Optimize Crypto Operations +- Move heavy crypto work to Web Workers +- Implement request deduplication +- Consider pre-warming commonly accessed invites + +## Priority + +**Medium-High** - While an edge case, it significantly impacts UX when encountered and the solution (caching) is straightforward to implement. + +## Files Involved + +- `src/components/message/InviteLink.tsx` - Invite component rendering +- `src/hooks/business/invites/useInviteProcessing.ts` - Invite processing hook +- `src/components/context/MessageDB.tsx:4498-4579` - Core crypto operations +- `src/components/direct/DirectMessage.tsx` - DirectMessage conversation view + +--- + +*Analysis by: Claude Code investigation* diff --git a/.agents/bugs/encryption-state-evals-bloat.md b/.agents/bugs/encryption-state-evals-bloat.md new file mode 100644 index 000000000..7ff92c5cf --- /dev/null +++ b/.agents/bugs/encryption-state-evals-bloat.md @@ -0,0 +1,58 @@ +--- +type: bug +title: Encryption State Evals Causing Config Sync Bloat +status: open +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-09T00:00:00.000Z +related_issues: + - '#108' +--- + +# Encryption State Evals Causing Config Sync Bloat + +https://github.com/QuilibriumNetwork/quorum-desktop/issues/108 + +## Problem + +When a user **creates** a space, the encryption state stores ~10,000 polynomial evaluations (`evals`) for private invite generation. Each eval is ~200 bytes, resulting in **~2MB per created space**. + +A test user who created 2+ spaces hit the API config sync limit (`invalid config missing data` 400 error) because the total payload exceeded the server limit (~4MB). + +Users who **join** spaces are not affected (they get 0 evals, ~12KB per space). + +**Important**: Only **private invites** consume evals. Public invites don't use evals at all. In practice, most spaces will use public invites (especially large communities), making the 10K pre-allocation largely unnecessary. + +## Root Cause + +In `SpaceService.createSpace()`, the SDK is called without a `total` parameter, defaulting to ~10,000 evals: + +```typescript +// src/services/SpaceService.ts:343-347 +const session = await secureChannel.EstablishTripleRatchetSessionForSpace( + keyset.userKeyset, + keyset.deviceKeyset, + registration + // No 'total' parameter → defaults to ~10,000 +); +``` + +**This is a pre-existing issue in the `develop` branch** (same pattern at `MessageDB.tsx:2860`), not something introduced by feature branches. + +## Workaround + +**Disable config sync**: Users who hit this issue can disable "Allow Sync" in Privacy settings. This prevents the API call that fails, while still allowing local space creation. + +The bloated encryption states remain in local IndexedDB (no size limit) and spaces work normally. The tradeoff is no cross-device sync until the SDK issue is fixed. + +## Proposed Solution + +**On-demand eval generation**: Can the SDK generate evals incrementally when needed, rather than all upfront at space creation? + +This would allow: +- Spaces to start with 0 or minimal evals (~12KB like joiners) +- Evals generated only when creating private invites +- No arbitrary limit that's either too small (runs out) or too large (bloats sync) + +Note: Consumed evals are already removed from state when private invites are sent (`InvitationService.ts:95-102`). The issue is the initial 10K allocation. + +--- diff --git a/.agents/bugs/expired-invite-card-validation-timing.md b/.agents/bugs/expired-invite-card-validation-timing.md new file mode 100644 index 000000000..fe6101a91 --- /dev/null +++ b/.agents/bugs/expired-invite-card-validation-timing.md @@ -0,0 +1,250 @@ +--- +type: bug +title: Expired Invite Card Validation Timing Issue +status: open +created: 2026-01-09T00:00:00.000Z +updated: 2025-11-09T00:00:00.000Z +--- + +# Expired Invite Card Validation Timing Issue + +## Problem + +Expired or invalid invite cards display with an active "Join" button, only showing an error message **after** the user clicks the button. This creates a confusing UX where users are presented with a seemingly valid invite that fails when they attempt to join. + +**Visual Issue:** +1. Card renders with space details (icon, name, description) +2. "Join" button appears active and clickable +3. User clicks "Join" +4. Error callout appears: "The invite link has expired or is invalid." +5. Button becomes disabled + +**Expected Behavior:** +- Error should be detected and displayed immediately when the card renders +- Button should be disabled from the start if invite is invalid/expired +- User should not be able to attempt joining an expired invite + +## Root Cause Analysis + +### Two-Stage Validation Problem + +The invite validation happens in **two separate stages**: + +**Stage 1: `processInviteLink()` (Initial Display)** +- Location: `src/services/InvitationService.ts:508-558` +- Purpose: Decrypt invite link and retrieve space metadata +- **Does NOT validate expiration** ❌ +- Only checks: + - Link format validity + - Space manifest decryption + - Required fields present +- **Always succeeds** for properly formatted links, even if expired + +**Stage 2: `joinInviteLink()` (On Join Click)** +- Location: `src/services/InvitationService.ts:626-636` +- Purpose: Actually join the space +- Calls `getSpaceInviteEval()` which **does** check expiration ✅ +- Returns 404 if expired → Throws error +- **This is when expiration is discovered** + +### Caching Amplifies the Problem + +Module-level cache in `useInviteProcessing.ts` stores successful `processInviteLink()` results: + +```typescript +// Lines 10, 37 +const inviteCache = new Map<string, { space?: Space; error?: string }>(); +inviteCache.set(inviteLink, { space: spaceData }); +``` + +**Timeline:** +1. **Weeks ago:** Invite was valid + - `processInviteLink()` succeeds → Space data cached +2. **Today:** Invite has expired + - Component loads → Reads cache → Shows space with "Join" button + - No re-validation occurs + - User clicks "Join" → First time expiration is checked → Error + +### Regression from Recent Changes + +This issue may have been introduced or exacerbated by commit `7d3dfa94`: +- Added invite link support to MessageMarkdownRenderer +- Implemented caching to prevent skeleton flashing on remount +- Cache optimization may have inadvertently prevented expiration detection + +## Impact + +**User Experience:** +- Confusing: Button appears clickable but doesn't work +- Frustrating: Users waste time clicking invalid invites +- Trust: Makes the app feel broken or unreliable + +**Occurrence:** +- Common in old DM conversations with expired invites +- Affects public invite links (not private direct invites) +- Worse when invites were valid initially but expired later + +## Investigation History + +### Attempted Fixes (2025-11-09) + +**Attempt 1: Stale-While-Revalidate Pattern** +- Removed cache skip logic in `useInviteProcessing.ts` +- Made hook always call API to re-validate +- **Result:** No change - `processInviteLink()` doesn't validate expiration + +**Attempt 2: Add Expiration Check to `processInviteLink()`** +- Added `getSpaceInviteEval()` call to `processInviteLink()` +- Location: `InvitationService.ts:557-575` +- **Result:** No change observed by user +- **Issue:** May need to investigate why this didn't work + +**Attempt 3: Disable Button on Error** +- Added `|| !!displayError` to button disabled state +- Location: `InviteLink.tsx:114` +- **Result:** Button disables after error, but error still only shows on click + +## Current Code State + +**Modified Files (Need Review):** +1. `src/hooks/business/invites/useInviteProcessing.ts` - Removed cache skip +2. `src/services/InvitationService.ts` - Added expiration validation (lines 557-575) +3. `src/components/message/InviteLink.tsx` - Disable button on error +4. `src/hooks/business/invites/useInviteUI.ts` - "Invite sent" text for senders +5. `src/components/message/MessageMarkdownRenderer.tsx` - Pass sender context +6. `src/components/message/Message.tsx` - Pass sender context + +**Changes to Keep:** +- Button text behavior (showing "Invite sent" for senders) - Good UX improvement +- Sender detection logic - Useful feature + +**Changes to Revert:** +- Cache skip removal (may cause performance issues) +- Expiration validation in `processInviteLink()` (didn't work as expected) +- Button error disable logic (symptom fix, not root cause) + +## Investigation Findings (2025-11-09) + +### API Behavior Analysis + +Through console logging and testing, discovered the following behavior: + +1. **`getSpaceInviteEval()` API Returns:** + - During `processInviteLink()`: Returns **200 with valid encrypted data** ✅ + - During `joinInviteLink()`: Returns **200 with error text** (e.g., "Decryption failed") ❌ + +2. **Error Pattern:** + ``` + Failed to join space: SyntaxError: Unexpected token 'D', "Decryption"... is not valid JSON + at JSON.parse (<anonymous>) + at InvitationService.joinInviteLink (InvitationService.ts:707:18) + ``` + +3. **Console Logs Show:** + ``` + [processInviteLink] getSpaceInviteEval succeeded: {data: '{"ciphertext":"..."}', status: 200} + [useInviteProcessing] processInviteLink succeeded + ``` + Then on Join click: + ``` + Failed to join space: SyntaxError: Unexpected token 'D', "Decryption"... is not valid JSON + ``` + +### Root Cause Confirmed + +**The issue is NOT client-side validation timing.** The API behavior is inconsistent: + +1. **Initial processing** (`processInviteLink`): API returns valid encrypted JSON → Space displays with Join button +2. **Join attempt** (`joinInviteLink`): API returns plain text error → JSON.parse fails + +### Why This Happens + +Likely causes (from related bug reports): + +1. **Single-use invite evals**: First call to `getSpaceInviteEval()` during `processInviteLink` might consume the eval +2. **Intermittent expiration**: Related to `.agents/bugs/public-invite-link-intermittent-expiration.md` +3. **API state changes**: Space membership/ratchet ID changes between calls +4. **Network/timing issues**: Related to `.agents/bugs/joinspacemodal-invalid-json-network-error.md` + +### Why Client-Side Fix Won't Work + +**Cannot add expiration validation to `processInviteLink()`** because: +- Calling `getSpaceInviteEval()` during processing might **consume** the invite eval +- The same invite eval needs to be available for `joinInviteLink()` to actually join +- API returns 200 with valid data during processing, so no way to detect it will fail later + +### Current Behavior is Actually Correct + +The existing error handling already: +- Shows "The invite link has expired or is invalid" message after Join fails ✅ +- Disables the Join button after error ✅ +- Prevents repeated clicking ✅ + +**The user complaint is about seeing the Join button at all**, but this is unavoidable with current API behavior. + +### Next Steps + +This requires **server-side/API investigation**, not client-side fixes: + +1. **Understand invite eval lifecycle:** + - Are invite evals single-use or multi-use? + - Why does first call succeed but second fails? + +2. **API consistency:** + - Should expired invites return 404 instead of 200? + - Should error messages be in response body with error status codes? + +3. **Alternative API patterns:** + - Provide a "validate only" endpoint that doesn't consume the eval + - Include expiration timestamp in manifest so client can check without eval + - Return consistent error codes (404) instead of 200 with error text + +## Proposed Solution (After Investigation) + +### Option A: Fix Validation in `processInviteLink()` +- Debug why current expiration check doesn't work +- Ensure `getSpaceInviteEval()` is actually called +- Verify error is properly propagated to UI + +### Option B: Separate Validation Hook +```typescript +// New hook: useInviteExpiration +const useInviteExpiration = (inviteLink, space) => { + const [isExpired, setIsExpired] = useState(false); + + useEffect(() => { + // Call getSpaceInviteEval separately from processInviteLink + // Update isExpired state + }, [inviteLink]); + + return isExpired; +}; +``` + +### Option C: Cache with TTL +- Add timestamp to cache entries +- Revalidate after X minutes +- Clear cache for specific invites when join fails + +## Priority + +**High** - User-facing bug that creates confusing UX and makes the app appear broken. Common occurrence in old conversations. + +## Files Involved + +- `src/services/InvitationService.ts` - Invite validation logic +- `src/hooks/business/invites/useInviteProcessing.ts` - Processing and caching +- `src/components/message/InviteLink.tsx` - UI component +- `src/hooks/business/invites/useInviteJoining.ts` - Join action handler +- `src/hooks/business/invites/useInviteUI.ts` - Button state logic + +## Related Issues + +- May be related to caching performance improvements in commit `7d3dfa94` +- Similar to `directmessage-invite-loading-performance.md` (caching tradeoffs) + +--- + + +*Status: Blocked - Requires Server-Side Investigation* +*Conclusion: Cannot be fixed client-side due to API behavior. Invite evals appear valid during processing but fail during join. Likely single-use or intermittent API issue.* diff --git a/.agents/bugs/joinspacemodal-invalid-json-network-error.md b/.agents/bugs/joinspacemodal-invalid-json-network-error.md new file mode 100644 index 000000000..72dd53a0a --- /dev/null +++ b/.agents/bugs/joinspacemodal-invalid-json-network-error.md @@ -0,0 +1,93 @@ +--- +type: bug +title: "JoinSpaceModal \"Invalid JSON\" Error Due to Network Issues" +status: open +created: 2026-01-09 +updated: 2025-01-08 +--- + +# JoinSpaceModal "Invalid JSON" Error Due to Network Issues + + +**Priority**: Low +**Component**: Various +**Discovered**: 2025-08-03 + +**Issue Opened** + +## Bug Description + +Users encountering "invalid json" error messages in the JoinSpaceModal when trying to join spaces via invite links. The error appears to be related to network/connection issues rather than actual invalid invite links. + +## Root Cause + +The error occurs due to: + +1. **Network issues** causing incomplete/corrupted API responses during invite validation +2. **No retry logic** in the invite validation flow +3. **Generic error handling** that displays raw JavaScript JSON parsing errors to users +4. **False solution pattern** where regenerating public links appears to "fix" the issue (but actually just retries the network request) + +## Technical Details + +### Error Flow + +1. User enters invite link in JoinSpaceModal +2. `useInviteValidation` calls `apiClient.getSpaceManifest()` +3. Network issues cause corrupted/incomplete JSON response +4. `JSON.parse(manifest.data.space_manifest)` fails with native browser error +5. Error bubbles up to `useSpaceJoining` catch block +6. Raw error message (e.g., "invalid json", "Unexpected token") displayed to user + +### Key Code Locations + +- `src/hooks/business/spaces/useInviteValidation.ts:79` - JSON.parse of space manifest +- `src/hooks/business/spaces/useInviteValidation.ts:99-101` - JSON.parse of decrypted data +- `src/hooks/business/spaces/useSpaceJoining.ts:24-27` - Generic error handling that exposes raw errors +- `src/components/context/MessageDB.tsx` - Multiple JSON.parse calls in joinInviteLink function + +### Problematic Error Handling + +```typescript +// useSpaceJoining.ts:24-27 +} catch (e: any) { + console.error(e); + const errorMessage = e.message || e.toString() || 'Failed to join space'; + setJoinError(errorMessage); // Raw browser error shown to user + return false; +} +``` + +## Current Workaround + +Users regenerate public invite links when encountering the error, which appears to solve it but is not optimal as it: + +- Invalidates existing invite links unnecessarily +- Doesn't address the underlying network issue +- Creates confusion about whether links are actually invalid + +## Proposed Solution + +1. **Add retry logic** for network failures during invite validation +2. **Detect JSON parsing errors** specifically and retry API calls +3. **Improve error messages** to distinguish between network issues and invalid invites +4. **Add user-friendly error handling** instead of showing raw JavaScript errors + +### Implementation Ideas + +- Wrap `getSpaceManifest()` calls with retry logic +- Catch `SyntaxError` from JSON.parse and retry +- Show "Connection issue, retrying..." instead of "invalid json" +- Only show "Invalid invite link" for actual validation failures + +## Impact + +- **User Experience**: Confusing error messages and unnecessary link regeneration +- **Reliability**: Network hiccups cause permanent-seeming failures +- **Support**: Users may think invite system is broken when it's just network issues + +## Priority + +Medium-High - Affects user onboarding experience and creates confusion about invite system reliability. + +--- diff --git a/.agents/bugs/kick-user-button-state-after-kick.md b/.agents/bugs/kick-user-button-state-after-kick.md new file mode 100644 index 000000000..02172086b --- /dev/null +++ b/.agents/bugs/kick-user-button-state-after-kick.md @@ -0,0 +1,203 @@ +--- +type: bug +title: Kick User Button Remains Enabled After User is Kicked +status: open +created: 2025-09-16T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +related_issues: + - '#74' +--- + +# Kick User Button Remains Enabled After User is Kicked + +https://github.com/QuilibriumNetwork/quorum-desktop/issues/74 + +## Bug Description + +After a user is successfully kicked from a space, the "Kick User" button in their UserProfile component remains enabled and functional. This creates confusion and allows admins to attempt kicking the same user multiple times. + +## Expected Behavior + +Once a user has been kicked: +- The "Kick User" button should be disabled +- Button text should change to "Kicked!" to indicate the user's status +- Button should remain disabled permanently (until page refresh/space re-entry) + +## Current Behavior + +- "Kick User" button stays enabled after successful kick +- Admin can click the button again (though subsequent kicks may fail) +- No visual indication that the user has already been kicked +- Confusing UX for space administrators + +## Reproduction Steps + +1. Open a space where you have kick permissions +2. Right-click on a user → View Profile +3. Click "Kick User" → "Click again to confirm" +4. Wait for kick operation to complete and modal to close +5. Right-click on the same user again → View Profile +6. **Bug**: "Kick User" button is still enabled and clickable + +## Root Cause Analysis + +The UserProfile component doesn't check if a user has been kicked. It only checks: +- Current permissions (`hasKickPermission`) +- Whether user can be kicked (`canKickThisUser`) +- But not whether user has **already been kicked** + +## Technical Challenge: Performance at Scale + +### The Problem +Checking kick status requires determining if a user is still a space member: +- `useSpaceMembers()` returns all space members (could be 1K-10K users) +- Every UserProfile component would need to search this array +- With 100 visible users → 100 × 10K = 1M operations for linear searches +- Causes performance issues and excessive re-renders + +### Current Data Sources +- `useSpaceMembers({ spaceId })` - Contains current space members +- Kicked users are **absent** from this list +- Checking recent kick messages only works for recently kicked users (~30 seconds) + +## Potential Solutions + +### 1. Optimized Member Set Hook (Recommended) + +Create a shared, memoized Set for O(1) lookups: + +```tsx +// High-level hook that creates Set once and shares across components +const useSpaceMembersSet = (spaceId: string) => { + const { data: spaceMembers } = useSpaceMembers({ spaceId }); + + return useMemo(() => { + if (!spaceMembers) return new Set(); + return new Set(spaceMembers.map(m => m.user_address)); + }, [spaceMembers]); +}; + +// UserProfile usage +const useIsUserKicked = (userAddress: string, spaceId: string) => { + const memberAddressesSet = useSpaceMembersSet(spaceId); + return !memberAddressesSet.has(userAddress); // O(1) lookup +}; +``` + +**Pros**: Fast O(1) lookups, shared across components +**Cons**: Still creates Set per component instance + +### 2. Parent Component Optimization + +Pass kick status down from parent (Channel.tsx): + +```tsx +// In Channel.tsx +const memberAddressesSet = useSpaceMembersSet(spaceId); + +// Pass to all UserProfile instances +<UserProfile + user={user} + isUserKicked={!memberAddressesSet.has(user.address)} +/> +``` + +**Pros**: Single Set creation, maximum performance +**Cons**: Requires prop drilling, component coupling + +### 3. React Query Custom Cache + +Create dedicated query for member status: + +```tsx +const useUserMemberStatus = (userAddress: string, spaceId: string) => { + return useQuery({ + queryKey: ['UserMemberStatus', spaceId, userAddress], + queryFn: async () => { + const members = await messageDB.getSpaceMembers(spaceId); + return members.some(m => m.user_address === userAddress); + }, + staleTime: 30000, // Cache for 30 seconds + }); +}; +``` + +**Pros**: Leverages React Query caching, automatic invalidation +**Cons**: Additional queries per user, cache complexity + +### 4. Context Provider Solution + +Create space-wide context for member status: + +```tsx +const SpaceMemberContext = createContext(); + +// Provider at space level +<SpaceMemberProvider spaceId={spaceId}> + <Channel /> {/* All UserProfile components can access context */} +</SpaceMemberProvider> + +// Hook usage +const useIsSpaceMember = (userAddress: string) => { + const { memberSet } = useContext(SpaceMemberContext); + return memberSet.has(userAddress); +}; +``` + +**Pros**: True sharing, clean separation of concerns +**Cons**: Additional context complexity, provider setup + +### 5. Lazy Loading Approach + +Only check kick status when UserProfile is actually opened: + +```tsx +const UserProfile = ({ user, spaceId }) => { + const [isOpen, setIsOpen] = useState(false); + const { data: isKicked } = useQuery({ + queryKey: ['IsUserKicked', user.address, spaceId], + queryFn: () => checkIfUserKicked(user.address, spaceId), + enabled: isOpen, // Only run when modal is open + }); + + // Button state based on kick status + const buttonText = isKicked ? 'Kicked!' : 'Kick User'; + const buttonDisabled = isKicked; +}; +``` + +**Pros**: Minimal performance impact, only checks when needed +**Cons**: Delayed feedback, still requires efficient checking + +## Recommended Implementation + +**Use Solution #2 (Parent Component Optimization)** for best performance: + +1. Create `useSpaceMembersSet` hook in Channel.tsx +2. Pass kick status as props to UserProfile components +3. Update UserProfile button rendering logic + +This provides: +- ✅ O(1) lookup performance +- ✅ Single Set creation for all users +- ✅ Immediate visual feedback +- ✅ Minimal code complexity + +## Files to Modify + +- `src/components/user/UserProfile.tsx` - Add kicked state handling +- `src/components/space/Channel.tsx` - Add member set logic +- `src/hooks/queries/spaceMembers/` - Add optimized member set hook +- Create `useIsUserKicked` utility hook + +## Priority: Medium + +- Functional but confusing user experience +- Important for admin workflows +- Performance considerations prevent simple solution +- Affects spaces with many members most severely + +--- + + +**Affects**: Space administrators managing large member lists diff --git a/.agents/bugs/markdown-line-break-inconsistency.md b/.agents/bugs/markdown-line-break-inconsistency.md new file mode 100644 index 000000000..a5a073f6c --- /dev/null +++ b/.agents/bugs/markdown-line-break-inconsistency.md @@ -0,0 +1,76 @@ +--- +type: bug +title: Markdown Line Break Inconsistency +status: open +created: 2026-01-09T00:00:00.000Z +updated: 2025-01-21T00:00:00.000Z +--- + +# Markdown Line Break Inconsistency + +**Date:** 2025-01-21 +**Status:** Identified +**Priority:** Medium + +## Description + +There's an inconsistency in how line breaks are handled between plain text messages and markdown-enabled messages in the MessageMarkdownRenderer. + +## Current Behavior + +**Plain text with single line breaks** (renders correctly): +``` +test1 +test2 +test3 +test4 +``` +↳ Renders with each item on a separate line + +**Markdown links with single line breaks** (renders incorrectly): +``` +[test1](https://example.com) +[test2](https://example.com) +[test3](https://example.com) +[test4](https://example.com) +``` +↳ Renders all links on the same line + +**Mixed content** (renders incorrectly): +``` +test1 +[test2](https://example.com) +test3 +[test4](https://example.com) +``` +↳ Renders everything on one line + +## Root Cause + +When `shouldUseMarkdown()` detects markdown patterns, it switches to `MessageMarkdownRenderer` which uses ReactMarkdown with remarkGfm. This follows strict markdown rules where single line breaks are treated as soft breaks (spaces), while plain text rendering preserves line breaks as-is. + +## Expected Behavior + +Both plain text and markdown content should handle single line breaks consistently - either both should preserve them as hard breaks or both should treat them as soft breaks. + +## Workarounds + +Users can: +1. Use double line breaks between items +2. Use list syntax with `-` or `*` +3. Add two spaces at the end of each line + +## Potential Solutions + +1. **Install remark-breaks plugin** - Makes ReactMarkdown treat single line breaks as hard breaks +2. **Custom preprocessing** - Convert single line breaks to double line breaks in markdown content +3. **Unified handling** - Make plain text follow markdown rules instead + +## Files Affected + +- `src/components/message/MessageMarkdownRenderer.tsx` +- `src/hooks/business/messages/useMessageFormatting.ts` +- `src/components/message/Message.tsx` + +--- +*Last updated: 2025-01-21* diff --git a/.agents/bugs/message-hash-navigation-conflict.md b/.agents/bugs/message-hash-navigation-conflict.md new file mode 100644 index 000000000..45da76293 --- /dev/null +++ b/.agents/bugs/message-hash-navigation-conflict.md @@ -0,0 +1,202 @@ +--- +type: bug +title: "Message Hash Navigation Conflict Bug" +status: open +created: 2026-01-09 +updated: 2025-08-03 +--- + +# Message Hash Navigation Conflict Bug + + +**Priority**: Low +**Component**: MessageList.tsx +**Discovered**: 2025-08-03 +**Reporter**: During Message.tsx business logic extraction + +**Issue Opened** + +## Summary + +When navigating to a message via hash link (e.g., pasted message link), subsequent message deletion operations cause the page to scroll back to the originally visited message and re-trigger the yellow highlight flash. + +## Steps to Reproduce + +1. Copy a message link and paste it in browser (or click shared link) +2. Page correctly scrolls to target message with yellow flash effect +3. Scroll to a different location in the chat +4. Delete ANY message in the chat +5. **Bug**: Page scrolls back to the originally visited message from step 1 +6. **Bug**: Yellow highlight flash effect re-triggers + +## Expected Behavior + +- Hash navigation should work once and not interfere with subsequent interactions +- After navigating via hash link, message deletion should not cause re-navigation +- User should be able to scroll freely after initial hash navigation + +## Current Test Results (Post-Fix) + +### Confirmed Working + +- ✅ **Reply action**: No re-navigation (correctly scrolls to last message sent) +- ✅ **Emoji reactions**: No re-navigation issue +- ✅ **Copy link**: No re-navigation issue + +### Still Problematic + +- ❌ **Delete action**: Still causes re-navigation to previously visited hash message + - Scroll to previous message? **YES** + - Flashing effect: **YES** + +## Technical Analysis + +### Root Cause + +Located in `MessageList.tsx` lines 134-168, specifically the `useEffect` dependency array: + +```typescript +useEffect(() => { + // Hash navigation logic +}, [init, messageList, location.hash]); +``` + +The `messageList` dependency causes the hash navigation logic to re-run whenever: + +- Messages are deleted (`messageList` changes) +- Reactions are added/removed (`messageList` changes) +- New messages arrive (`messageList` changes) + +### Current Hash Logic Flow + +1. User clicks hash link → `location.hash = "#msg-123"` +2. MessageList scrolls to message 123, shows highlight +3. After 1 second, hash is cleared via `history.replaceState` +4. User performs message operation → `messageList` changes +5. useEffect re-runs due to `messageList` dependency +6. **Problem**: During the 1-second window, hash still exists, causing re-navigation + +### Attempted Fix + +Added `hasProcessedHash` flag to prevent re-navigation, but issue persists. The conflict may be more complex involving: + +- Timing of hash clearing vs message operations +- Virtuoso component internal state management +- React Router hash handling + +## Environment + +- **Pre-existing bug**: Confirmed to exist before Message.tsx refactoring +- **Affects**: Message deletion operations after hash navigation (other operations fixed) +- **Browser**: All browsers +- **Component**: MessageList.tsx, Message.tsx interaction + +## Final Resolution + +### Git Bisect Investigation + +- Used git bisect to identify commit `210d4f6` as the first bad commit that introduced the bug +- Multiple fix attempts created regressions (infinite scrolling, continuous flashing) +- **Problematic fix attempt**: Commit `3cbc08b` ("Improve message hash navigation with state-based highlighting") introduced worse regressions including infinite scrolling loops +- Reverted to stable baseline commit `46b28e3` before applying final fix + +### Applied Fix + +**File**: `MessageList.tsx` line 168 +**Change**: Removed `messageList` and `hasProcessedHash` from useEffect dependency array + +```typescript +// Before (problematic): +}, [init, messageList, location.hash, hasProcessedHash]); + +// After (fixed): +}, [init, location.hash]); // Removed messageList and hasProcessedHash to prevent re-navigation on message changes +``` + +### Root Cause Analysis + +The `messageList` dependency caused the hash navigation logic to re-run on every message operation: + +- ✅ Message deletions changed `messageList` → triggered re-navigation (PARTIALLY FIXED) +- ✅ Emoji reactions changed `messageList` → triggered re-navigation (FIXED) +- ✅ New messages changed `messageList` → triggered re-navigation (FIXED) + +The fix ensures hash navigation only responds to actual hash changes (`location.hash`), not message list updates. + +### Current Status After Testing + +**Partial Success**: Fixed emoji reactions and other operations, but message deletion still causes re-navigation. + +**Scope Reduced**: Issue now isolated to message deletion operations only: + +- ✅ Emoji reactions: Fixed +- ✅ Reply operations: Fixed +- ✅ Copy link: Fixed +- ❌ Message deletion: Still problematic + +**Decision**: Keeping as low-priority open bug due to: + +- Rare user workflow (visit via hash + delete message immediately) +- Issue scope significantly reduced (only affects deletions) +- Not critical to core functionality + +### Fix Results Summary + +- ✅ **Major improvement**: Fixed 75% of original problem scenarios +- ✅ **No regressions**: Avoided infinite scrolling and continuous flashing issues +- ❌ **Remaining issue**: Message deletion still triggers re-navigation + +## Potential Solutions + +### Option 1: Remove messageList Dependency + +Only run hash navigation on `location.hash` changes, not messageList changes: + +```typescript +}, [init, location.hash]); // Remove messageList dependency +``` + +### Option 2: Immediate Hash Clearing + +Clear hash immediately after capturing target, not after 1 second: + +```typescript +// Clear hash immediately after navigation starts +history.replaceState( + null, + '', + window.location.pathname + window.location.search +); +``` + +### Option 3: Separate Hash State Management + +Use separate state for hash processing instead of relying on browser location.hash + +### Option 4: Virtuoso-specific Solution + +Investigate if Virtuoso has built-in hash navigation support or scroll state management that conflicts. + +## Related Files + +- `src/components/message/MessageList.tsx` (lines 134-168) +- `src/components/message/Message.tsx` (isHashTarget logic) +- `src/components/message/Message.scss` (message-highlighted animation) + +## Test Scenarios + +When fixed, ensure these scenarios work: + +1. ✅ Hash navigation works initially +2. ✅ User can scroll freely after hash navigation +3. ✅ Message operations don't trigger re-navigation +4. ✅ New hash links work correctly +5. ✅ Multiple hash navigations in sequence work +6. ✅ Hash highlighting shows and clears correctly + +--- + +_Created: 2025-08-03_ +_Updated: 2025-08-03_ +_Priority: Low (UX issue, rare workflow, not blocking functionality)_ +_Status: Open for future investigation when higher priority items are resolved_ diff --git a/.agents/bugs/messagedb-cross-platform-storage-issue.md b/.agents/bugs/messagedb-cross-platform-storage-issue.md new file mode 100644 index 000000000..c32e8c277 --- /dev/null +++ b/.agents/bugs/messagedb-cross-platform-storage-issue.md @@ -0,0 +1,96 @@ +--- +type: bug +title: 'MessageDB Context: IndexedDB Platform Compatibility Issue' +status: open +created: 2026-01-09T00:00:00.000Z +updated: 2025-08-21T00:00:00.000Z +--- + +# MessageDB Context: IndexedDB Platform Compatibility Issue + +**Status:** Medium Priority - Requires platform-specific implementation +**Affects:** React Native/Expo Go builds +**Culprit Commit:** `a51ea3f663e43957a6b1f477eabe5ae1100c3616` + +## Issue Description + +Mobile app crashes on startup with error: + +``` +TypeError: window.addEventListener is not a function (it is undefined) +``` + +## Root Cause Analysis + +**Actual Problem**: Commit a51ea3f6 added `useMessageDB` import to `useOnboardingFlowLogic.ts`. The MessageDB context uses IndexedDB APIs that don't exist in React Native. + +**Import Chain**: + +1. `useOnboardingFlowLogic.ts` imports `useMessageDB` context (✅ legitimate need) +2. `MessageDB.tsx` imports `src/db/messages.ts` +3. `messages.ts` calls `indexedDB.open()` (❌ web-only API) + +**Evidence from Commit**: + +```typescript +// Added in useOnboardingFlowLogic.ts: +import { useMessageDB } from '../../../components/context/MessageDB'; + +// MessageDB context uses: +const { getConfig } = useMessageDB(); // This loads IndexedDB code +``` + +## Technical Details + +**MessageDB Context Complexity**: + +- **Size**: 5,346 lines combining storage + business logic +- **Storage**: Uses IndexedDB (`src/db/messages.ts`) for browser-based local database +- **Functions**: Message encryption/decryption, user configs, space management, real-time sync +- **Problem**: IndexedDB doesn't exist in React Native - needs AsyncStorage equivalent + +**Required Platform Support**: + +- **Web**: IndexedDB for local message storage +- **Mobile**: AsyncStorage equivalent for React Native +- **Business Logic**: Config management, message handling (90% shared) + +## Implementation Plan + +**Phase 1: Extract Storage Layer** + +- Create storage adapter interface for database operations +- Implement `platform/storage/useIndexedDBAdapter.web.ts` - wraps existing IndexedDB calls +- Implement `platform/storage/useAsyncStorageAdapter.native.ts` - AsyncStorage equivalent +- Abstract all `indexedDB.open()` calls behind common interface + +**Phase 2: Update MessageDB Context** + +- Inject storage adapter into MessageDB context constructor +- Replace direct IndexedDB calls with adapter methods +- Ensure all crypto operations remain platform-agnostic +- Test all storage operations (messages, configs, encrypted data) work identically + +**Phase 3: Platform Resolution** + +- Create `MessageDB.native.tsx` that uses AsyncStorage adapter +- Keep existing `MessageDB.tsx` using IndexedDB adapter for web +- Update Metro bundler configuration for proper platform resolution +- Verify mobile app launches and onboarding flow works + +**Complexity Assessment**: Medium-High but manageable + +- ✅ Single point of failure: only `messages.ts` calls IndexedDB directly +- ✅ Well-defined MessageDB interface makes abstraction cleaner +- ✅ Adapter pattern already proven successful in codebase +- ⚠️ Large codebase with complex crypto operations requires careful testing + +## Impact + +- **Current**: Completely blocks all mobile development/testing +- **Solution**: Enables full cross-platform MessageDB functionality +- **Risk**: Medium - crypto/storage complexity but isolated to storage layer + +--- + +_Created: 2025-08-16_ diff --git a/.agents/bugs/modal-gesture-handling-technical-debt.md b/.agents/bugs/modal-gesture-handling-technical-debt.md new file mode 100644 index 000000000..3ce03a3cd --- /dev/null +++ b/.agents/bugs/modal-gesture-handling-technical-debt.md @@ -0,0 +1,188 @@ +--- +type: bug +title: Modal Gesture Handling Technical Debt +status: open +created: 2026-01-09T00:00:00.000Z +updated: 2025-08-10T00:00:00.000Z +--- + +# Modal Gesture Handling Technical Debt + +**Status:** TECHNICAL_DEBT +**Priority:** Medium +**Component:** `src/components/primitives/Modal/Modal.native.tsx` +**Date Reported:** 2025-08-10 + +**Issue Opened** + +## Problem Description + +The Modal component's swipe-to-close gesture implementation uses outdated, non-standard approaches that violate modern React Native best practices for 2024/2025. + +## Current Implementation Issues + +### 1. **Legacy PanResponder Usage** + +```typescript +// @ts-ignore - PanResponder exists at runtime but not in types +const { PanResponder } = require('react-native'); +``` + +**Problems:** + +- Uses `require()` instead of ES6 imports +- Requires `@ts-ignore` to bypass TypeScript errors +- PanResponder runs on JavaScript thread (poor performance) +- Not exported by React Native 0.79+ TypeScript definitions + +### 2. **Mixed Animation Libraries** + +- Uses both `react-native` Animated and `react-native-reanimated` +- Inconsistent animation approach across the component +- Some code paths reference non-existent reanimated functions + +### 3. **TypeScript Violations** + +- Uses `any` types for gesture event parameters +- Suppresses TypeScript errors with `@ts-ignore` +- Uses type assertions like `(translateY as any).setValue()` + +### 4. **Performance Anti-Patterns** + +- Gesture handling runs on JavaScript thread instead of UI thread +- Manual `setValue()` calls instead of native driver animations +- No proper gesture state management + +## Modern Best Practices (2024/2025) + +The component should use: + +### ✅ **React Native Gesture Handler 2.x** + +```typescript +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; + +const panGesture = Gesture.Pan() + .onChange((event) => { + translateY.value = event.translationY; + }) + .onEnd((event) => { + // Handle gesture end + }); +``` + +### ✅ **React Native Reanimated 3.x** + +```typescript +import Animated, { + useSharedValue, + useAnimatedStyle, +} from 'react-native-reanimated'; + +const translateY = useSharedValue(0); +const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateY: translateY.value }], +})); +``` + +### ✅ **Proper TypeScript Integration** + +- Full type safety with proper gesture event types +- No `@ts-ignore` suppressions needed +- Native ES6 imports + +### ✅ **Performance Benefits** + +- Gestures run on UI thread (60fps performance) +- Native driver animations +- Proper worklet functions for smooth animations + +## Root Cause Analysis + +1. **Package Version Mismatch**: React Native 0.79.5 TypeScript definitions don't export PanResponder +2. **Migration Incomplete**: Project has both old and new gesture libraries but incomplete migration +3. **Documentation Gap**: No clear guidance on which gesture approach to use + +## Recommended Solution + +### Phase 1: Immediate Fix (Technical Debt) + +- [ ] Keep current working implementation +- [ ] Document the technical debt clearly +- [ ] Add TODO comments for future refactoring + +### Phase 2: Proper Migration (Future Sprint) + +- [ ] Audit all gesture dependencies and versions +- [ ] Create gesture handling guidelines for the project +- [ ] Migrate Modal to modern Gesture Handler 2.x + Reanimated 3.x +- [ ] Add proper TypeScript types +- [ ] Performance testing and validation + +## Impact Assessment + +**Current State:** + +- ✅ **Functionality**: Works correctly for users +- ❌ **Maintainability**: Hard to maintain and extend +- ❌ **Performance**: Suboptimal (JS thread instead of UI thread) +- ❌ **Type Safety**: TypeScript violations and suppressions +- ❌ **Best Practices**: Uses deprecated patterns + +**Business Risk:** Low (functionality works, but technical debt accumulates) +**Developer Experience:** Medium impact (confusing code patterns) + +## Dependencies + +- `react-native-gesture-handler: ~2.24.0` (installed but not properly used) +- `react-native-reanimated: ~3.17.4` (installed but causes conflicts) +- `react-native: 0.79.5` (TypeScript definition gaps) + +## Related Issues + +This affects other components that may need gesture handling: + +- Input components with swipe actions +- Card components with drag gestures +- Navigation components with pan gestures + +## Migration Attempts & Findings + +### Failed Modern Approach (August 2025) + +**Attempted Solution:** + +- Implemented modern Gesture.Pan() + GestureDetector approach +- Added 'react-native-reanimated/plugin' to babel.config.js +- Wrapped app with GestureHandlerRootView +- Used proper useSharedValue and useAnimatedStyle patterns + +**Results:** + +- ❌ GestureDetector completely non-functional (no events fired) +- ❌ No gesture logs despite proper setup and debugging +- ❌ Babel plugin caused build issues and conflicts +- ❌ Modern approach incompatible with current React Native 0.79.5 + Expo SDK 53 setup + +**Key Discovery:** +The modern gesture handling approach (Gesture Handler 2.x + Reanimated 3.x) appears to have compatibility issues with the current stack configuration that cannot be resolved through configuration alone. + +### Root Cause Investigation + +1. **New Architecture Requirement**: Modern gesture handling may require React Native's New Architecture to be fully enabled +2. **Expo Compatibility**: Expo SDK 53 may have limitations with latest gesture handler features +3. **Version Conflicts**: React Native 0.79.5 may be too old for modern gesture patterns despite having the required packages + +### Verified Working Solution + +- **Cherry-picked from commit b455d33**: PanResponder implementation with `@ts-ignore` workaround +- **Status**: Functional but uses deprecated patterns +- **Performance**: Adequate for current needs despite JavaScript thread limitations + +## Notes + +The project should establish consistent gesture handling patterns before adding more gesture-based components to prevent this technical debt from spreading. + +**Important:** Do not attempt to migrate to modern gesture handling without first upgrading the entire React Native + Expo stack, as the modern approach is confirmed non-functional with the current setup. + +--- diff --git a/.agents/bugs/pinned-messages-panel-clicks-and-message-list-disappearing.md b/.agents/bugs/pinned-messages-panel-clicks-and-message-list-disappearing.md new file mode 100644 index 000000000..6baf8797a --- /dev/null +++ b/.agents/bugs/pinned-messages-panel-clicks-and-message-list-disappearing.md @@ -0,0 +1,126 @@ +--- +type: bug +title: Pinned Messages Panel Button Clicks Bug +status: open +created: 2026-01-09T00:00:00.000Z +updated: 2025-01-08T00:00:00.000Z +--- + +# Pinned Messages Panel Button Clicks Bug + +## Date + +2025-01-08 + +## Bug Description + +**Primary Issue:** Pinned messages panel buttons (jump to message, unpin message) were completely unresponsive to clicks. + +**Related Issue:** During debugging, discovered an intermittent bug where the entire main message list disappears after performing pin/unpin/jump operations. This is non-deterministic and appears to be a separate underlying system issue. + +## Symptoms + +1. **Button Click Issue (FIXED):** + - Jump to message (arrow) button: No response when clicked + - Unpin message (X) button: No response when clicked + - Buttons appeared normal, showed hover states, but click events never fired + - No console errors related to button clicks + +2. **Message List Disappearing Issue (RELATED DISCOVERY):** + - After pinning, unpinning, or jumping to messages, the entire message list occasionally disappears + - Non-deterministic - happens "sometimes" but not always + - Requires page refresh to restore messages + - Pinned messages panel continues to work normally + - **Note:** This appears to be a separate system-level issue, not directly caused by the button click bug + +## Root Cause Analysis + +### Button Click Issue (SOLVED) + +The problem was in `DropdownPanel.tsx` click-outside detection logic. After layout changes, the `handleClickOutside` function was incorrectly identifying button clicks as "outside" clicks and preventing them from working. + +**Technical Details:** + +- Tooltip components render DOM elements outside the normal panel DOM tree +- The `panelRef.current.contains(event.target)` check returned `false` for button clicks +- SVG icons (`<path>` elements) inside buttons had no identifying classes/IDs +- The click-outside handler was closing the panel before button clicks could be processed + +### Message List Disappearing Issue (SEPARATE INVESTIGATION NEEDED) + +During debugging, discovered this appears to be a deeper system issue with WebAssembly bindings and database operations. + +**Error Message Found:** + +``` +WebsocketProvider.tsx:132 Error processing outbound: TypeError: Cannot read properties of undefined (reading '__wbindgen_add_to_stack_pointer') + at async MessageDB.tsx:2303:26 + at async processQueue (WebsocketProvider.tsx:127:30) +``` + +**Technical Analysis:** + +- `__wbindgen_add_to_stack_pointer` indicates WASM (WebAssembly) binding failure +- Error occurs in `requestSync` function at MessageDB.tsx:2303 (`secureChannel.SealHubEnvelope` operation) +- Suggests race condition in WASM memory management or SecureChannel instance corruption +- Operations queue in WebsocketProvider may be processing conflicting operations +- **This is a separate issue from the button click bug and requires its own investigation** + +## Solution Implemented + +### Fixed: Button Click Issue + +Updated `DropdownPanel.tsx` click-outside detection to properly handle button elements: + +1. **Added DOM tree traversal:** Walk up parent elements to find button-related classes +2. **Enhanced element detection:** Check for `jump-button`, `unpin-button`, `btn-unstyled` classes and `BUTTON` tags +3. **SVG element handling:** Proper handling of `SVGAnimatedString` vs regular string `className` properties + +**Key Code Changes:** + +```typescript +// Walk up the DOM tree to find button-related elements +for (let i = 0; i < 5 && currentElement && !isTooltipElement; i++) { + const elementId = currentElement.id || ''; + const elementClassName = + typeof currentElement.className === 'string' + ? currentElement.className + : currentElement.className?.baseVal || ''; + + isTooltipElement = + elementId.includes('jump-') || + elementId.includes('unpin-') || + elementClassName.includes('jump-button') || + elementClassName.includes('unpin-button') || + elementClassName.includes('btn-unstyled') || + currentElement.tagName === 'BUTTON'; + + currentElement = currentElement.parentElement; +} +``` + +## Status + +- ✅ **Button clicks:** FIXED - Both jump to message and unpin message buttons work correctly +- ❓ **Message list disappearing:** SEPARATE ISSUE - Requires dedicated investigation (not currently reproducing) + +## Solution Summary + +This bug was successfully resolved by fixing the DropdownPanel click-outside detection logic. The button functionality now works reliably. + +The message list disappearing issue discovered during debugging appears to be a separate system-level problem related to WASM bindings and should be tracked as its own bug report if it continues to occur. + +## Files Modified + +- `src/components/ui/DropdownPanel.tsx` - Fixed click-outside detection logic +- `src/components/message/PinnedMessagesPanel.tsx` - Restored to working state (debugging logs removed) +- `src/components/space/Channel.tsx` - Layout restructuring (related to initial bug trigger) + +## Testing Notes + +- Button functionality now works consistently across all scenarios +- Jump to message properly navigates and highlights target messages +- Unpin message successfully removes messages from pinned list +- Panel opening/closing behavior works as expected + +--- diff --git a/.agents/bugs/public-invite-link-intermittent-expiration.md b/.agents/bugs/public-invite-link-intermittent-expiration.md new file mode 100644 index 000000000..a90776562 --- /dev/null +++ b/.agents/bugs/public-invite-link-intermittent-expiration.md @@ -0,0 +1,89 @@ +--- +type: bug +title: Public Invite Link Intermittent Expiration Bug +status: open +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Public Invite Link Intermittent Expiration Bug + +**Reported:** September 22, 2025 +**Status:** Investigating +**Severity:** Medium +**Component:** Space Invite System + +## Bug Description + +Public invite links intermittently show "expired" or "invalid" errors after initially working correctly. This occurs when the same public invite URL works for the first 1-2 people but fails for subsequent users (typically 3rd+ person) who attempt to join. + +## Reproduction Steps + +1. Create a space +2. Generate a public invite link +3. Send the public invite link to multiple people (5+ people) +4. Have them attempt to join in relatively quick succession + +**Expected Result:** All users should be able to join using the same public invite link + +**Actual Result:** First 1-2 users join successfully, but subsequent users get "expired" or "invalid link" errors + +## Reproduction Reliability + +- **Non-deterministic**: Does not happen every time +- **Timing sensitive**: More likely to occur when multiple people attempt to join within a short timeframe +- **Count dependent**: Usually fails starting with the 3rd person, rarely on 1st or 2nd + +## Workaround + +Generate a new public invite link when users report "expired" errors. The new link consistently works for all users. + +## Investigation Notes + +### Key Observations + +1. **Not capacity related**: Occurs with only 2-3 total joins on fresh public links +2. **Not generation timing**: Immediate usage after generation works fine +3. **Membership related**: Appears connected to changes in space membership during concurrent joins +4. **Server-side component**: Error occurs during invite validation/joining process + +### Technical Areas Investigated + +- **Public invite link generation** (`generateNewInviteLink` in MessageDB.tsx) +- **Invite validation process** (`useInviteValidation.ts`) +- **Server-side invite eval system** (`postSpaceInviteEvals`/`getSpaceInviteEval`) +- **Ratchet ID sequencing** and space membership management + +### Potential Root Causes + +Several theories under investigation: + +1. **Race condition** between concurrent join attempts affecting cryptographic state +2. **ID sequencing issues** where ratchet IDs become misaligned with actual space state +3. **Server-side eval management** inconsistencies during concurrent access +4. **Membership change conflicts** during the join process + +### Code Areas of Interest + +- `MessageDB.tsx` lines 3758-3818 (space_evals generation) +- `MessageDB.tsx` lines 4674-4703 (invite eval retrieval and validation) +- Ratchet ID calculation logic (idCounter usage) +- Space membership update synchronization + +## Impact + +- **User Experience**: Users unable to join spaces, requiring regeneration of invite links +- **Adoption**: May discourage space sharing due to unreliable invite links +- **Support Load**: Users may report "broken" invite links when they are working for others + +## Next Steps + +- [ ] Add logging to track ratchet ID sequences during joins +- [ ] Monitor server-side invite eval consumption patterns +- [ ] Test with controlled timing of concurrent joins +- [ ] Investigate space membership synchronization during joins +- [ ] Consider implementing join attempt queuing or locking mechanisms + +--- + +**Note:** This bug report captures current understanding. Root cause analysis is ongoing and initial theories may require revision as more evidence is gathered. diff --git a/.agents/bugs/safari-passkey-session-loss-random-logout.md b/.agents/bugs/safari-passkey-session-loss-random-logout.md new file mode 100644 index 000000000..a9d0ad314 --- /dev/null +++ b/.agents/bugs/safari-passkey-session-loss-random-logout.md @@ -0,0 +1,154 @@ +--- +type: bug +title: Safari Passkey Session Loss - Random Logout on Browser Restart +status: open +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Safari Passkey Session Loss - Random Logout on Browser Restart + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms + +Ref: https://t.me/c/1967251104/146703/198329 + +User on Safari (macOS Tahoe 26.2, Safari 26.2) experience random session loss where: + +1. **User is logged in** to Quorum web app with valid passkey authentication +2. **User closes Safari** (normal quit, not clearing data) +3. **User reopens Safari** and navigates to Quorum +4. **App shows login screen** claiming "no passkeys" exist for this website +5. **Passkey still exists** in macOS Passwords.app/Keychain Access +6. **Only workaround**: Clear browser history/cache and re-import existing key +7. **Result**: All local data (messages, DMs, spaces) is lost + +### What User Sees + +The Safari WebAuthn dialog displays: + +> **Sign In** +> "You don't have any passwords or passkeys saved for this website. You may be able to use one of the options below to sign in." +> - Scan QR Code +> - Use Security key + +This dialog indicates Safari/WebAuthn cannot find a credential for the current domain, even though the user confirms a passkey exists in Passwords.app for their Quorum account. + +### Key Observations from User Report + +- Affects account created via **"Import Existing Key"** flow (Ed448 key from 25 December 2024) +- Passkey IS visible in macOS Passwords.app for the correct domain +- User exported key and confirmed it matches original (same address) +- **No pattern** - happens randomly, worked fine for days then suddenly failed +- **Cross-device sync also not working** between Quorum Mobile Beta and Safari web app +- User has NOT enabled automatic history clearing in Safari preferences +- Has happened "lots of times during 2025" - **recurring issue** + +### Environment + +- **OS**: macOS Tahoe 26.2 +- **Browser**: Safari Version 26.2 (21623.1.14.11.9) +- **Account type**: Imported key file (.key), NOT originally created with passkey + +## Root Cause + +**Under Investigation** - Multiple potential causes identified: + +### Hypothesis 1: localStorage vs Passkey Credential Mismatch + +Per the [passkey authentication flow analysis](..\reports\onboarding-flow\passkey-authentication-flow-analysis-2025-11-23.md): + +- Login state is determined by `localStorage['passkeys-list']` existence +- If localStorage is cleared but passkey credential remains in Keychain, the app shows login screen +- Safari may be clearing IndexedDB/localStorage while preserving Keychain data + +**Evidence supporting this**: +- User reports passkey exists in Passwords.app but app says "no passkeys" +- App uses `localStorage['passkeys-list']` for "quick lookup" of credentials +- If this localStorage entry is lost, app doesn't know a passkey exists + +### Hypothesis 2: Safari Storage Partitioning/ITP + +Safari's Intelligent Tracking Prevention (ITP) has aggressive storage policies: +- May cap IndexedDB/localStorage for sites not visited frequently +- May partition storage differently after updates +- Storage may expire if not accessed within 7 days of ITP rules + +### Hypothesis 3: Imported Key Flow Inconsistency + +Per documentation, when importing a key file: +1. App goes through two-prompt passkey creation flow +2. Private key is stored in passkey credential (or IndexedDB fallback) +3. `localStorage['passkeys-list']` stores lookup info + +**Potential issue**: If any step fails silently or data becomes inconsistent, the session could appear "lost" despite passkey existing in Keychain. + +### Hypothesis 4: Config Sync Failure + +Per [config-sync-system.md](..\docs\config-sync-system.md): +- User reported sync not working between Mobile Beta and Safari +- If `allowSync` is enabled but sync fails, could cause state inconsistencies +- Timestamp-based conflict resolution could reject local config in edge cases + +## Solution + +**Not yet implemented** - Pending investigation + +### Proposed Investigation Steps + +1. **Add diagnostic logging** to passkey authentication flow: + - Log when `localStorage['passkeys-list']` is accessed + - Log when localStorage entry is not found vs when passkey credential fails + - Log Safari storage events/permissions + +2. **Check for Safari-specific storage behavior**: + - Test with Safari's "Prevent cross-site tracking" disabled + - Test with different website data retention settings + - Verify IndexedDB persistence across browser restarts + +3. **Add recovery mechanism**: + - If passkey exists in Keychain but localStorage is missing, offer "Recover Existing Passkey" flow + - Scan for existing credentials before showing "no passkeys" message + +4. **Cross-reference with config sync**: + - Ensure local session state survives when sync fails + - Verify timestamp handling doesn't inadvertently invalidate local config + +### Files to Investigate + +- `src/components/modals/PasskeyModal.tsx` - Modal UI and flow logic +- `src/components/context/PasskeysContext.tsx` - React context for passkey state +- `src/hooks/business/user/useAuthenticationFlow.ts` - Auth state management +- `src/components/onboarding/Login.tsx` - Login screen logic +- `src/services/ConfigService.ts` - Config sync that may affect session + +## Prevention + +Once root cause is identified: + +1. **Robust session persistence**: Don't rely solely on localStorage for session state +2. **Graceful recovery**: Detect orphaned passkeys and offer recovery flow +3. **Safari-specific handling**: Account for ITP and storage partitioning behaviors +4. **Better error messaging**: If passkey exists but can't be used, explain why +5. **Diagnostic mode**: Add way for users to export debug info for support + +## Related Issues + +- Cross-device sync not working (mentioned by same user) +- May be related to "Orphaned passkeys" issue documented in passkey flow analysis +- Similar to "Prompt #1 Succeeds, Prompt #2 Fails" edge case + +## User Impact + +- **Severity**: High - Complete data loss (all messages, DMs, spaces gone) +- **Frequency**: Random but recurring - happened "lots of times during 2025" +- **Workaround**: Clear cache and re-import key file (loses all local data) +- **Affected Users**: Safari users who imported existing key files + +--- + + +_Reported by: User "Oumlaut" via Telegram support chat_ +_Environment: macOS Tahoe 26.2, Safari 26.2_ diff --git a/.agents/bugs/space-owner-privacy-limitation.md b/.agents/bugs/space-owner-privacy-limitation.md new file mode 100644 index 000000000..1cacca7c4 --- /dev/null +++ b/.agents/bugs/space-owner-privacy-limitation.md @@ -0,0 +1,90 @@ +--- +type: bug +title: Space Owner Privacy Limitation +status: open +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +related_issues: + - '#111' +--- + +# Space Owner Privacy Limitation + +https://github.com/QuilibriumNetwork/quorum-desktop/issues/111 + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Summary + +Space owner identity cannot be exposed in the `Space` type for privacy reasons. This prevents receiving-side clients from verifying space ownership, requiring space owners to manually assign themselves moderation roles. As a consequence, users with certain permissions can perform actions against the space owner that would otherwise be protected. + +## Root Cause + +In a privacy-focused decentralized system, exposing `Space.ownerAddress` would reveal the space owner's identity to all members. This limitation is an **design decision** for privacy. + +Without `ownerAddress`, receiving-side validation cannot check: +```typescript +// IMPOSSIBLE - field doesn't exist for privacy +if (decryptedContent.content.senderId === space.ownerAddress) { + // Protect space owner... +} +``` + +## Impact Table + +| Issue | Criticality | Impact | Recovery | +|-------|-------------|--------|----------| +| **Users with `message:delete` can delete space owner's messages** | **CRITICAL** | Permanent message loss, destructive | ❌ None | +| **Users with `user:mute` can mute space owner** | MEDIUM | Owner temporarily silenced | ✅ Owner can unmute self | +| **Space owner must self-assign roles for moderation** | LOW | Owner needs manual role setup for delete/pin/mute | N/A | +| **Space owner must join manager roles for read-only channels** | LOW | Cannot post without role setup | N/A | +| **`user:kick` permission** | NONE | Protocol-level ED448 verification protects owner | N/A | + +## Most Critical: `message:delete` Vulnerability + +Users with `message:delete` role permission can delete **any** message including the space owner's messages. This is: +- **Destructive**: Deleted messages cannot be recovered +- **No protection possible**: Receiving-side cannot identify owner to protect +- **Trust-dependent**: Space owners must carefully vet who gets delete permission + +## Current Workarounds + +1. **Space owners must create and join roles** with the permissions they need (delete, pin, mute) +2. **Careful permission delegation**: Only assign `message:delete` to highly trusted users +3. **Owner self-unmute**: For `user:mute`, owner can unmute themselves if they have the permission + +## Why `user:kick` is Unaffected + +Kick is the **only** space owner action verified at the protocol level: +- Uses ED448 signature from `owner_public_keys` +- Cannot be delegated or spoofed +- Receiving clients verify cryptographically + +## Potential Solutions (Privacy-Preserving) + +### 1. Protected Addresses List +Space manifest includes optional `protectedAddresses: string[]`. Only owner-key signatures can delete messages from protected addresses. Owner adds themselves (and optionally others) to this list. +- **Privacy**: ⚠️ Reveals "special" addresses exist | **Protection**: ✅ Full | **Effort**: Medium + +### 2. Owner-Signed Certificates +Owner issues blind certificates `{ address, spaceId, immunity: true }` signed with owner key. Receiving clients verify certificate before honoring destructive actions. Certificates don't reveal who issued them. +- **Privacy**: ✅ Good | **Protection**: ✅ Full | **Effort**: High +- **SDK**: ✅ Can use existing `js_sign_ed448`/`js_verify_ed448` functions + +### 3. Zero-Knowledge Proofs +Owner generates ZK proof proving knowledge of owner private key without revealing identity. Attached to messages needing protection. Cryptographically perfect but requires ZK library integration. +- **Privacy**: ✅ Perfect | **Protection**: ✅ Perfect | **Effort**: Very High +- **SDK**: ⚠️ Requires SDK enhancement - ZK proofs exist at Quilibrium protocol level but not exposed in `quilibrium-js-sdk-channels` + +## Related + +- [space-owner-delete-permissions-bug.md](.archived/space-owner-delete-permissions-bug.md) - Proposes `ownerAddress` solution (rejected for privacy) +- [message-delete-owner-vulnerability.md](.archived/message-delete-owner-vulnerability.md) - Details the delete vulnerability (archived) +- [security.md](../docs/features/security.md) - Security architecture noting no `isSpaceOwner` bypass +- [space-roles-system.md](../docs/space-permissions/space-roles-system.md) - Documents owner must join roles + +--- + + +_Status: Design Limitation (accepted trade-off for privacy)_ diff --git a/.agents/bugs/userprofile-modal-positioning-viewport-boundary.md b/.agents/bugs/userprofile-modal-positioning-viewport-boundary.md new file mode 100644 index 000000000..352444548 --- /dev/null +++ b/.agents/bugs/userprofile-modal-positioning-viewport-boundary.md @@ -0,0 +1,143 @@ +--- +type: bug +title: UserProfile Modal Positioning - Viewport Boundary Detection +status: open +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# UserProfile Modal Positioning - Viewport Boundary Detection + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Symptoms + +When clicking on a user avatar in a message near the bottom of the screen, the UserProfile modal opens downward and gets cut off by the viewport boundary. The modal should flip to open upward when there isn't enough space below, similar to how MessageActionsMenu handles this scenario. + +**Expected behavior**: Modal opens with top aligned to avatar. If insufficient space below, modal flips so bottom aligns with avatar. + +**Actual behavior**: Modal always opens downward from the avatar position, getting cut off when near the bottom of the screen. + +## Root Cause + +The positioning logic in `useUserProfileModal.ts` calculates the modal position **before** the modal is rendered to the DOM. This means: + +1. The hook determines position based on `elementRect.top` without knowing actual modal height +2. There's no mechanism to measure the rendered modal and adjust position +3. The React component lifecycle makes "measure-then-position" difficult with the current hook architecture + +**Contrast with MessageActionsMenu (which works correctly)**: +- MessageActionsMenu handles its own positioning internally using a callback ref pattern +- It renders initially hidden (`visibility: hidden`), measures actual height, then positions and shows +- The positioning logic is inside the component, not in an external hook + +## Attempted Solutions (All Failed) + +### 1. Callback Ref Pattern in Hook +**Approach**: Added `setModalRef` callback ref to `useUserProfileModal.ts` that would measure height and adjust position. + +**Code attempted**: +```typescript +const setModalRef = useCallback((node: HTMLDivElement | null) => { + if (node && pendingPosition) { + const actualHeight = node.getBoundingClientRect().height; + const adjustedTop = calculateVerticalPosition(pendingPosition.top, actualHeight); + setModalPosition({ ...pendingPosition, top: adjustedTop }); + setIsPositioned(true); + } +}, [pendingPosition]); +``` + +**Result**: Callback ref was never called. Modal stopped appearing entirely. + +**Why it failed**: The ref callback timing didn't work with how the modal was conditionally rendered. + +### 2. useLayoutEffect in Hook +**Approach**: Used `useLayoutEffect` with a ref to measure after render but before paint. + +**Result**: No change in behavior. Modal still positioned incorrectly. + +**Why it failed**: The element wasn't in the DOM when the effect ran, or the ref wasn't properly attached. + +### 3. requestAnimationFrame in Callback Ref +**Approach**: Added `requestAnimationFrame` inside callback ref to wait for browser to complete render. + +**Code attempted**: +```typescript +const setModalRef = useCallback((node: HTMLDivElement | null) => { + if (node) { + requestAnimationFrame(() => { + const actualHeight = node.getBoundingClientRect().height; + // ... position adjustment + }); + } +}, [pendingPosition]); +``` + +**Result**: No console logs appeared, modal didn't open. + +**Why it failed**: The callback ref itself wasn't being invoked. + +### 4. useLayoutEffect in Channel.tsx +**Approach**: Moved positioning logic to the parent component (Channel.tsx) with useLayoutEffect. + +**Result**: No change. Modal still positioned top-down. + +**Why it failed**: The hook's state management and the parent's effect weren't synchronized properly. + +### 5. Hidden Visibility Pattern +**Approach**: Render modal with `visibility: hidden`, measure, then set to `visible`. + +**Result**: Modal remained hidden or positioning wasn't updated before becoming visible. + +**Why it failed**: State updates and re-renders weren't happening in the expected order. + +## Solution + +**Recommended approach**: Refactor UserProfile to handle its own positioning internally, similar to MessageActionsMenu.tsx pattern. + +**Key changes needed**: + +1. **Move positioning logic inside UserProfile component** instead of external hook +2. **Use callback ref pattern** on the modal container div +3. **Render initially with `visibility: hidden`** +4. **Measure actual height in callback ref**, calculate adjusted position, then show + +**Reference implementation** - MessageActionsMenu.tsx:41-54: +```typescript +const [adjustedPosition, setAdjustedPosition] = useState<{ x: number; y: number } | null>(null); + +const setMenuRef = useCallback( + (node: HTMLDivElement | null) => { + menuRef.current = node; + if (node) { + const actualHeight = node.getBoundingClientRect().height; + setAdjustedPosition(calculatePosition(position.x, position.y, actualHeight)); + } + }, + [position.x, position.y] +); + +// In JSX: +// visibility: adjustedPosition ? 'visible' : 'hidden' +``` + +## Prevention + +When implementing viewport-aware positioning for modals/popups: +- Keep positioning logic inside the component that renders the element +- Use callback refs for DOM measurement, not useEffect/useLayoutEffect +- Render hidden first, measure, then show +- Avoid external hooks that calculate position before render + +## Files Involved + +- `src/hooks/business/ui/useUserProfileModal.ts` - Hook that currently handles positioning +- `src/components/user/UserProfile.tsx` - The modal component +- `src/components/user/UserProfile.scss` - Modal styling (width: 330px) +- `src/utils/modalPositioning.ts` - Shared positioning utilities +- `src/constants/ui.ts` - Modal dimension constants +- `src/components/message/MessageActionsMenu.tsx` - Reference implementation that works + +--- diff --git a/.agents/docs/component-management-guide.md b/.agents/docs/component-management-guide.md new file mode 100644 index 000000000..af0788ee9 --- /dev/null +++ b/.agents/docs/component-management-guide.md @@ -0,0 +1,537 @@ +--- +type: doc +title: Component Management & Development Guide +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-08-14T00:00:00.000Z +--- + +# Component Management & Development Guide + +**READY FOR OFFICIAL DOCS: _Last review: 2025-08-14 10:45 UTC_** + +This guide helps developers manage existing components and create new ones in our cross-platform architecture. + +## Architecture Awareness + +### The Golden Rule + +**This is a shared codebase that builds three separate apps: web (browser), desktop (Electron), and mobile (React Native).** We maximize code reuse by sharing business logic, components, and primitives between all platforms. Every component decision must consider all build targets. + +### What We Have + +- **Primitives Collection**: `src/components/primitives/` - Cross-platform UI building blocks +- **Theming System**: `src/components/primitives/theme/colors.ts` - Mirrors web CSS variables as hex colors for native app +- **Dev Playground**: Test primitives on both web (`/playground`) and mobile (React Native via Expo) +- **Platform Files**: `.web.tsx` for browser, `.native.tsx` for React Native +- **Mobile Testing**: `/mobile` workspace with test screens for real device testing +- **Components Audit**: `/src/dev` audit of all components (WIP) accessible via `/dev/audit` in forntend + +## Quick Decision Framework + +### 1. Should I Create a New Component? + +Ask yourself: + +- **Is this interactive?** (buttons, inputs, modals) → Use existing primitive or create new one +- **Is this layout-related?** (flex patterns, containers) → Use existing primitive +- **Is this business logic?** (user profiles, chat messages) → Create regular component using primitives +- **Is this highly specialized?** (charts, animations) → Regular component with custom code + +### 2. Platform-Specific vs Shared? + +**Most components are platform-specific** (only very simple components can be truly shared). Components can be shared when: + +- They only use primitives +- The logic flow is identical between web and native +- They have minimal complexity (e.g., AccentColorSwitcher, KickUserModal, LeaveSpaceModal) + +Create platform-specific components for: + +- Deep OS integration needs +- Platform-specific gestures/interactions +- Performance-critical sections + +### 3. Primitive vs Regular Component? + +**Create a primitive when:** + +- Multiple components need the same UI pattern +- You're using raw HTML elements (`<div>`, `<button>`, `<input>`) +- Cross-platform consistency matters +- It's a basic building block (not business logic) + +**Create a regular component when:** + +- It contains business logic or data fetching +- It combines multiple primitives +- It's specific to one feature/page + +### Business Logic Extraction Rule + +**When creating business components, always extract logic into custom hooks** in `src/hooks/`. This keeps the component focused on UI rendering while making the logic reusable and testable across all platforms. + +```tsx +// ❌ Bad - Logic mixed with UI +function UserProfile({ userId }) { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetchUser(userId) + .then(setUser) + .finally(() => setLoading(false)); + }, [userId]); + + return ( + <Container> + {loading ? <Text>Loading...</Text> : <Text>{user.name}</Text>} + </Container> + ); +} + +// ✅ Good - Logic extracted to hook +function UserProfile({ userId }) { + const { user, loading } = useUserProfile(userId); + + return ( + <Container> + {loading ? <Text>Loading...</Text> : <Text>{user.name}</Text>} + </Container> + ); +} +``` + +The hook goes in `src/hooks/` following existing categories and index structure. + +## Using Existing Primitives + +### Available Primitives + +```tsx +// Layout +import { FlexRow, FlexBetween, FlexCenter, FlexColumn } from '../primitives'; +import { Container, ResponsiveContainer } from '../primitives'; +import { ModalContainer, OverlayBackdrop } from '../primitives'; + +// Interaction +import { Button, Input, TextArea, Select } from '../primitives'; +import { Modal, Switch, RadioGroup } from '../primitives'; + +// Display +import { Text, Icon, ColorSwatch, Tooltip } from '../primitives'; +``` + +### Developer Guidelines by Approach + +**🏆 PREFERRED - Use Primitives Where They Add Value:** + +```tsx +function UserCard({ user }) { + return ( + <Container className="p-4 bg-surface-0 rounded-lg"> + <FlexBetween> + <Text variant="strong">{user.name}</Text> + <Button size="small" onClick={onEdit}> + Edit + </Button> + </FlexBetween> + </Container> + ); +} +// Good: Interactive elements + layout patterns benefit from primitives +``` + +**👌 PRAGMATIC - Mixed Approach (Common and Acceptable):** + +```tsx +function ComplexComponent() { + return ( + <Container className="p-4"> + {/* Use primitives for interactive/theme elements */} + <Text variant="strong">Settings</Text> + <Button onClick={onSave}>Save</Button> + + {/* Raw HTML for specialized needs */} + <div className="complex-animation-container"> + <span className="text-subtle">Loading animation...</span> + </div> + </Container> + ); +} +// Good: Primitives where they add value, raw HTML where needed +``` + +**⚠️ AVOID - Raw HTML When Primitives Would Help:** + +```tsx +function ComponentThatShouldUsePrimitives() { + return ( + <div className="p-4 bg-surface-0 rounded-lg"> + <div className="flex items-center justify-between"> + <span className="font-bold text-strong">{user.name}</span> + <button className="btn-small" onClick={onEdit}> + Edit + </button> + </div> + </div> + ); +} +// Bad: Missing consistency benefits of Button primitive and FlexBetween layout +``` + +### When to Use Primitives + +Follow the guidelines in [when-to-use-primitives.md](./when-to-use-primitives.md): + +- **Always**: Interactive elements (Button, Input, Modal) +- **Usually**: Layout containers with theme colors +- **Sometimes**: Text elements needing semantic colors +- **Almost Never**: Complex animations, third-party wrappers, performance-critical sections + +## Creating New Primitives + +### Before You Start + +**Rarely needed** - we have most primitives already. Only create new ones if: + +- Pattern appears in 3+ different places +- No existing primitive fits the need +- Cross-platform consistency is critical + +### Primitive Creation Rules + +Based on [primitive-styling-guide.md](./primitive-styling-guide.md): + +#### 1. **File Structure** + +``` +src/components/primitives/MyPrimitive/ +├── MyPrimitive.web.tsx # Web implementation +├── MyPrimitive.native.tsx # Mobile implementation +├── MyPrimitive.scss # Web styles (if needed) +├── types.ts # Shared TypeScript types +└── index.ts # Platform resolution +``` + +#### 2. **Styling Consistency** + +**Form fields must use semantic color variables:** + +```scss +// Web (.scss) +background-color: var(--color-field-bg); +border: 1px solid var(--color-field-border); +&:focus { + border-color: var(--color-field-border-focus); +} +``` + +```typescript +// Mobile (.native.tsx) +backgroundColor: colors.field.bg, +borderColor: colors.field.border, +// Focus: colors.field.borderFocus +``` + +#### 3. **Design System Integration** + +Always use existing tokens: + +- **Colors**: `bg-surface-0`, `text-strong`, `bg-accent` +- **Spacing**: `p-4`, `m-2`, `space-y-4` +- **Typography**: `text-base`, `font-medium` + +## Styling Best Practices + +### Web Styling Hierarchy + +From [component-development-guide.md](./component-development-guide.md#web-styling-hierarchy): + +1. **Tailwind Utilities First** + +```tsx +<div className="flex items-center justify-between p-4 bg-surface-0 rounded-lg"> +``` + +2. **@apply for Reusable Patterns** + +```scss +.card-base { + @apply bg-surface-0 rounded-lg shadow-sm border border-default; + @apply p-4 transition-shadow duration-200; +} +``` + +3. **Raw CSS Only for Complex Needs** + +```scss +.complex-animation { + @apply card-base; + animation: complexBounce 0.5s cubic-bezier(0.68, -0.55, 0.265, 1.55); +} +``` + +### Mobile Considerations + +- Use density-independent pixels (dp) not CSS units +- Minimum 44dp touch targets +- Use StyleSheet.create() for performance +- Test on both iOS and Android + +## Testing with Playground + +### Web Testing (Primary) + +1. Run `yarn dev` +2. Navigate to `/playground` (web playground for primitives) +3. Test your component with different props/states +4. Verify responsive behavior and theme switching + +### Mobile Testing (When Needed) + +1. Run `yarn mobile` to start the mobile test playground +2. Use Expo Go app to test on real device +3. Navigate through test screens in the mobile app to test your primitive + +### Testing Checklist + +- [ ] Component renders correctly on web +- [ ] All interactions work (click, hover, focus) +- [ ] Responsive behavior at different screen sizes +- [ ] Dark/light theme both work +- [ ] Mobile touch targets are adequate (if testing mobile) +- [ ] No console errors or layout shifts + +## Platform Files & Resolution + +### File Extensions + +- **`.web.tsx`** - Web implementation (uses CSS: px, rem, em, %) +- **`.native.tsx`** - React Native implementation (uses density-independent pixels: dp) +- **`.ios.tsx`** - iOS-specific code (optional, takes priority over .native.tsx) +- **`.android.tsx`** - Android-specific code (optional, takes priority over .native.tsx) +- **`.scss`** - Web styles only + +### Platform Resolution + +```typescript +// This import automatically resolves to the correct platform file: +import { Button } from '../primitives/Button'; + +// Bundler selects: +// - Button.web.tsx on web/desktop +// - Button.native.tsx on React Native +// - Button.ios.tsx on iOS (if exists, takes priority) +// - Button.android.tsx on Android (if exists, takes priority) +``` + +## Responsive & Platform Utilities + +We have two complementary systems for responsive behavior that work together to create sophisticated UX patterns. + +### Touch Device Detection + +**Centralized Detection** (now in `src/utils/platform.ts`): + +```typescript +import { isTouchDevice } from '../utils/platform'; + +// Comprehensive touch detection +const isTouch = isTouchDevice(); // Uses 3-layer detection for maximum compatibility +``` + +**Detection Logic:** + +```typescript +'ontouchstart' in window || // DOM touch event support + navigator.maxTouchPoints > 0 || // Modern touch points API + (navigator as any).msMaxTouchPoints > 0; // Legacy IE/Edge support +``` + +**Usage:** Input interaction patterns (hover vs tap), tooltip behavior, gesture handling + +### Screen Size Detection (ResponsiveLayout) + +**Purpose:** Viewport-based layout decisions and sidebar state management + +```typescript +import { useResponsiveLayoutContext } from '../context/ResponsiveLayoutProvider'; + +function MyComponent() { + const { isMobile, isTablet, isDesktop, leftSidebarOpen, toggleLeftSidebar } = useResponsiveLayoutContext(); + + return ( + <div> + {isMobile && <MobileNav />} + {isDesktop && <DesktopSidebar />} + </div> + ); +} +``` + +**Breakpoints:** + +- `isMobile`: < 768px (viewport width) +- `isTablet`: 768px - 1024px (viewport width) +- `isDesktop`: ≥ 1024px (viewport width) + +**Usage:** Layout structure, component visibility, sidebar behavior + +**Note:** This is viewport-based, not touch-based. A desktop with touchscreen at 1920px will be `isDesktop: true`. + +### Platform Detection Utility + +**Purpose:** Runtime environment detection (web/native/electron) + +```typescript +import { + isWeb, + isNative, + isElectron, + getPlatform, + isTouchDevice, +} from '../utils/platform'; + +// Platform environment checks +if (isWeb()) { + // Web browser code +} +if (isNative() || isMobile()) { + // Both are aliases + // React Native code +} +if (isElectron()) { + // Electron desktop app code +} + +// Get platform string +const platform = getPlatform(); // Returns: 'web' | 'mobile' | 'electron' + +// Combined platform features +const features = { + hasFileSystem: isElectron(), + hasNativeNotifications: isElectron() || isMobile(), + hasCamera: isMobile(), + hasDeepLinking: isMobile() || isElectron(), + hasPushNotifications: isMobile(), + hasTouch: isTouchDevice(), +}; +``` + +**Usage:** Platform-specific code paths, feature availability checks + +### System Integration: Creating Sophisticated UX Patterns + +The two detection systems work together to create **three distinct interaction modes**: + +```typescript +import { useResponsiveLayoutContext } from '../context/ResponsiveLayoutProvider'; +import { isTouchDevice } from '../utils/platform'; + +function useInteractionMode() { + const { isMobile } = useResponsiveLayoutContext(); // Screen size + const isTouch = isTouchDevice(); // Touch capability + + const useMobileDrawer = isMobile; // Phone: Drawer UI + const useDesktopTap = !isMobile && isTouch; // Tablet: Tap UI + const useDesktopHover = !isMobile && !isTouch; // Desktop: Hover UI + + return { useMobileDrawer, useDesktopTap, useDesktopHover }; +} +``` + +**Interaction Mode Matrix:** + +| Device Type | Screen Size | Touch | Result | UI Pattern | +| ------------ | ----------- | ----- | ----------------- | ------------------- | +| Phone | < 768px | ✓ | `useMobileDrawer` | Long-press → drawer | +| Tablet | ≥ 768px | ✓ | `useDesktopTap` | Tap → show/hide | +| Desktop | ≥ 1024px | ✗ | `useDesktopHover` | Hover → show | +| Touch Laptop | ≥ 1024px | ✓ | `useDesktopTap` | Tap → show/hide | + +### Understanding the Different "Mobile" Concepts + +**Be precise about which detection you need:** + +1. **Viewport Mobile** (`useResponsiveLayout`'s `isMobile`) + - **What:** Screen width < 768px + - **When:** Layout decisions, component sizing + - **Example:** A desktop browser resized to 600px is "mobile" + +2. **Platform Mobile** (`utils/platform`'s `isMobile()`) + - **What:** React Native runtime environment + - **When:** Platform-specific code paths + - **Example:** Mobile Safari returns `false` (it's "web" platform) + +3. **Touch Device** (`isTouchDevice()`) + - **What:** Touch input capability + - **When:** Interaction behavior (hover vs tap) + - **Example:** Surface Pro at 1920px is touch-enabled desktop + +**✅ Systems Work Together:** No conflicts, complementary purposes, well-architected integration patterns. + +## Troubleshooting + +### Common Issues + +**Import errors with primitives** + +```tsx +// ❌ Wrong +import Button from '../primitives/Button'; + +// ✅ Correct +import { Button } from '../primitives/Button'; +``` + +**Styling not working** + +- Check if you're using semantic CSS variables correctly +- Verify Tailwind classes are applied properly +- Use browser DevTools to inspect computed styles + +**Mobile testing issues** + +- Use `--tunnel` flag for Expo from WSL2 +- Test primitives on mobile: `yarn mobile` +- Ensure touch targets are minimum 44dp + +**Performance problems** + +- Avoid deep nesting of primitives +- Use raw HTML for simple static content +- Profile with React DevTools + +### Getting Help + +1. Check existing primitives first - don't reinvent +2. Review [when-to-use-primitives.md](./when-to-use-primitives.md) for guidance +3. Test with web playground (`/playground`) and mobile testing (`yarn mobile`) before implementing in main app +4. Follow existing patterns from similar components + +## Best Practices Summary + +### ✅ DO: + +- Think mobile-first for every component +- Use existing primitives for UI consistency +- Follow the styling hierarchy (Tailwind → @apply → raw CSS) +- Test with both web playground and mobile testing before shipping +- Use semantic color variables for consistency +- Keep business logic separate from UI primitives + +### ❌ DON'T: + +- Use raw HTML in business components (use primitives) +- Create primitives for one-off use cases +- Break existing component APIs +- Force primitives where they don't add value +- Ignore cross-platform testing +- Hardcode colors/spacing (use design tokens) + +--- + +_Created: 2025-07-31_ +_Updated: 2025-08-14 10:45 UTC_ +_This guide focuses on practical decision-making for component development in our cross-platform architecture._ diff --git a/.agents/docs/config-sync-system.md b/.agents/docs/config-sync-system.md new file mode 100644 index 000000000..c88f619ea --- /dev/null +++ b/.agents/docs/config-sync-system.md @@ -0,0 +1,367 @@ +--- +type: doc +title: Config Sync System +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-12T00:00:00.000Z +--- + +# Config Sync System + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Overview + +The Config Sync System enables cross-device synchronization of user configuration data. When enabled via the Privacy settings toggle (`allowSync`), user data is encrypted, signed, and uploaded to the Quorum API, allowing users to restore their configuration on new devices. + +**Key File**: `src/services/ConfigService.ts` + +## What Gets Synced + +The `UserConfig` object contains all user-specific data that syncs across devices: + +```typescript +export type UserConfig = { + address: string; // User's wallet address (key) + timestamp?: number; // Last sync timestamp (conflict resolution) + allowSync?: boolean; // Privacy toggle - enables/disables sync + + // Space membership + spaceIds: string[]; // Legacy flat list of space IDs + items?: NavItem[]; // Ordered list with folder support + + // Encryption keys (for E2E encryption recovery) + spaceKeys?: { + spaceId: string; + encryptionState: EncryptionState; // Triple Ratchet session state (Space encryption) + keys: SpaceKey[]; // config, hub, inbox keys + }[]; + + // User preferences + notificationSettings?: { + [spaceId: string]: NotificationSettings; + }; + + // Bookmarks (personal saved messages) + bookmarks?: Bookmark[]; + deletedBookmarkIds?: string[]; // Tombstones for deletion sync + + nonRepudiable?: boolean; // Message signing preference +}; +``` + +## Architecture + +### Data Flow Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ SAVE CONFIG FLOW │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ User Action (create space, add bookmark, change setting) │ +│ ↓ │ +│ ConfigService.saveConfig() │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 1. Collect and filter current data │ │ +│ │ - Fetch all spaces from IndexedDB │ │ +│ │ - Fetch space keys and encryption states │ │ +│ │ - Filter spaces without encryption states │ │ +│ │ - Filter spaceIds and items to match spaceKeys (consistency) │ │ +│ │ - Fetch bookmarks │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 2. Encrypt (if allowSync=true) │ │ +│ │ - Derive AES key: SHA-512(user_private_key)[0:32] │ │ +│ │ - Generate random 12-byte IV │ │ +│ │ - AES-GCM encrypt JSON config │ │ +│ │ - Append IV to ciphertext (hex encoded) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 3. Sign │ │ +│ │ - Message: ciphertext || timestamp (8 bytes, big-endian) │ │ +│ │ - Ed448 sign with user private key │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 4. Upload │ │ +│ │ POST /api/settings/{address} │ │ +│ │ { │ │ +│ │ user_address: string, │ │ +│ │ user_public_key: string (hex), │ │ +│ │ user_config: string (ciphertext+iv, hex), │ │ +│ │ timestamp: number, │ │ +│ │ signature: string (hex) │ │ +│ │ } │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ Save to local IndexedDB (always, regardless of sync) │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────┐ +│ GET CONFIG FLOW │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ App startup / Device switch │ +│ ↓ │ +│ ConfigService.getConfig() │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 1. Fetch from API and local │ │ +│ │ - GET /api/settings/{address} → remote config │ │ +│ │ - messageDB.getUserConfig() → local config │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 2. Compare timestamps │ │ +│ │ - If remote.timestamp < local.timestamp → use local │ │ +│ │ - If remote.timestamp == local.timestamp → use local │ │ +│ │ - If remote.timestamp > local.timestamp → decrypt remote │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 3. Verify signature │ │ +│ │ - Ed448 verify(public_key, ciphertext || timestamp, sig) │ │ +│ │ - If invalid → reject remote, use local │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 4. Decrypt │ │ +│ │ - Extract IV from last 24 chars (12 bytes hex) │ │ +│ │ - Derive AES key from user private key │ │ +│ │ - AES-GCM decrypt → UserConfig JSON │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ ↓ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 5. Merge and apply │ │ +│ │ - Sync new spaces (create inbox, register with hub) │ │ +│ │ - Merge bookmarks (last-write-wins + tombstones) │ │ +│ │ - Save merged config to local IndexedDB │ │ +│ │ - Update React Query cache │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## Cryptographic Details + +### Key Derivation + +The encryption key is derived from the user's Ed448 private key: + +```typescript +// Derive 64-byte hash from private key +const derived = await crypto.subtle.digest( + 'SHA-512', + Buffer.from(new Uint8Array(userKey.user_key.private_key)) +); + +// Use first 32 bytes as AES-256 key +const subtleKey = await window.crypto.subtle.importKey( + 'raw', + derived.slice(0, 32), + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt'] // or ['decrypt'] +); +``` + +### Encryption (AES-GCM) + +```typescript +// Generate random IV +const iv = crypto.getRandomValues(new Uint8Array(12)); + +// Encrypt config JSON +const encrypted = await window.crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + subtleKey, + Buffer.from(JSON.stringify(config), 'utf-8') +); + +// Concatenate ciphertext + IV (both hex encoded) +const ciphertext = Buffer.from(encrypted).toString('hex') + + Buffer.from(iv).toString('hex'); +``` + +### Signing (Ed448) + +The signature covers both the ciphertext and timestamp to prevent replay attacks: + +```typescript +const message = Buffer.from( + new Uint8Array([ + ...new Uint8Array(Buffer.from(ciphertext, 'utf-8')), + ...int64ToBytes(timestamp), // 8 bytes, big-endian + ]) +); + +const signature = ch.js_sign_ed448( + privateKeyBase64, + message.toString('base64') +); +``` + +## Conflict Resolution + +### Timestamp-Based Resolution + +The system uses a simple **last-write-wins** strategy based on timestamps: + +1. Compare `remote.timestamp` vs `local.timestamp` +2. Higher timestamp wins +3. Equal timestamps → prefer local (no network needed) + +### Bookmark Merge Strategy + +Bookmarks use a more sophisticated merge with tombstone tracking: + +```typescript +mergeBookmarks(local, remote, deletedIds): + 1. Filter out any bookmark in deletedIds (tombstones) + 2. For bookmarks pointing to same messageId, keep newer one + 3. Combine remaining local and remote bookmarks + 4. Sort by createdAt descending +``` + +**Why tombstones?** Without deletion tracking, a deleted bookmark would "resurrect" when syncing with a device that still has it. + +## Triggers: When Sync Happens + +Config is saved (and potentially synced) when: + +| Action | Files Modified | +|--------|----------------| +| Create space | `SpaceService.ts` → adds to `spaceIds` + `items` | +| Join space via invite | `InvitationService.ts` → adds to `spaceIds` + `items` | +| Create/delete folder | Various → modifies `items` array | +| Reorder spaces/folders | Drag handlers → modifies `items` array | +| Add/remove bookmark | `useBookmarks.ts` → modifies `bookmarks` + `deletedBookmarkIds` | +| Change notification settings | Settings UI → modifies `notificationSettings` | +| Toggle privacy settings | Privacy UI → modifies `allowSync`, `nonRepudiable` | + +## Safety Mechanisms + +### Encryption State Filtering + +Spaces without complete encryption data are filtered out during sync to prevent server validation errors. This happens in two scenarios: + +#### 1. Missing Encryption States + +Spaces that haven't completed encryption setup are excluded: + +```typescript +// Filter out spaces with undefined encryptionState +config.spaceKeys = allSpaceKeys.filter(sk => sk.encryptionState !== undefined); +``` + +#### 2. Bidirectional Config Consistency Check (Added 2025-12-12) + +We perform **two-way filtering** to ensure perfect consistency between `spaceKeys` and `spaceIds`: + +```typescript +// Step 1: Filter spaceIds/items to only include spaces WITH encryption keys +const validSpaceIds = new Set(config.spaceKeys.map(sk => sk.spaceId)); +config.spaceIds = config.spaceIds.filter(id => validSpaceIds.has(id)); + +if (config.items) { + config.items = config.items.filter(item => { + if (item.type === 'space') { + return validSpaceIds.has(item.id); + } else { + // For folders, filter out spaces without encryption keys + item.spaceIds = item.spaceIds.filter(id => validSpaceIds.has(id)); + // Remove empty folders + return item.spaceIds.length > 0; + } + }); +} + +// Step 2: Filter spaceKeys to only include keys for spaces IN the final spaceIds +const finalSpaceIds = new Set(config.spaceIds); +config.spaceKeys = config.spaceKeys.filter(sk => finalSpaceIds.has(sk.spaceId)); +``` + +**Why bidirectional filtering?** The server validates that `spaceIds` and `spaceKeys` must be perfectly in sync: +- Every space in `spaceIds` must have keys in `spaceKeys` (prevents "missing encryption" errors) +- Every key in `spaceKeys` must reference a space in `spaceIds` (prevents "orphaned keys" errors) + +Mismatches result in `400 - invalid config missing data` errors. + +**Impact**: +- Spaces without complete encryption won't appear in the nav bar +- They remain in the local database (not deleted) +- Once encryption completes, they'll automatically appear on next sync + +**Related bug**: Fixed folder operations failing with `400 - invalid config missing data` on staging (2025-12-12) + +### Signature Verification + +Remote configs with invalid signatures are rejected: + +```typescript +if (!ch.js_verify_ed448(publicKey, message, signature)) { + console.warn('received config with invalid signature!'); + return storedConfig; // Fall back to local +} +``` + +### Error Recovery for Bookmarks + +If bookmark sync fails partway through, the system attempts to restore original local bookmarks: + +```typescript +try { + // Apply differential changes... +} catch (error) { + // Attempt to restore original bookmarks + for (const bookmark of localBookmarks) { + await this.messageDB.addBookmark(bookmark); + } +} +``` + +## Privacy Control + +The `allowSync` toggle in Privacy settings controls whether config syncs to the server: + +- **`allowSync: true`**: Full encryption → sign → upload flow +- **`allowSync: false`**: Only saves to local IndexedDB + +When disabled, user data stays entirely on the local device. Switching devices requires manual setup. + +## Size Limits + +The API has an implicit size limit on the config payload. Based on observed failures: + +- **Typical config size**: 10KB - 500KB +- **Maximum observed working**: ~1MB +- **Failure threshold**: ~21MB (caused by bloated encryption states) + +The 100KB per-encryption-state filter keeps total payload well under limits. + +## Related Documentation + +- [Data Management Architecture](data-management-architecture-guide.md) - Overall data architecture +- [Bookmarks Feature](features/messages/bookmarks.md) - Detailed bookmark sync implementation +- [Bloated Encryption States Bug](../bugs/bloated-encryption-states-sync-failure.md) - Known issue with large states + +## File Reference + +| File | Purpose | +|------|---------| +| `src/services/ConfigService.ts` | Main sync implementation | +| `src/db/messages.ts:50-75` | UserConfig type definition | +| `src/utils.ts:17-18` | getDefaultUserConfig() | +| `src/api/baseTypes.ts` | API client methods | +| `src/hooks/queries/config/` | React Query integration | + +--- + + +*Updated: 2025-12-12* - Added spaceIds/items filtering for server validation consistency diff --git a/.agents/docs/cross-platform-components-guide.md b/.agents/docs/cross-platform-components-guide.md new file mode 100644 index 000000000..88ff85021 --- /dev/null +++ b/.agents/docs/cross-platform-components-guide.md @@ -0,0 +1,1664 @@ +--- +type: doc +title: 'Complete Guide: Cross-Platform React Components for Web + Native' +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-08-14T00:00:00.000Z +--- + +# Complete Guide: Cross-Platform React Components for Web + Native + +**Auto-reviewed and corrected against .agents/docs/component-management-guide.md - still needs human review : _Last review: 2025-08-14 10:45 UTC_** + +This guide provides architectural patterns and practical examples specific to this Quilibrium desktop/mobile app. All examples use our actual primitives, utilities, and file structure. + +## Table of Contents + +1. [Introduction](#introduction) +2. [Architectural Foundations](#architectural-foundations) +3. [Decision Framework: One vs Two Components](#decision-framework-one-vs-two-components) +4. [Logic Classification & Extraction](#logic-classification--extraction) +5. [Implementation Patterns](#implementation-patterns) +6. [Best Practices](#best-practices) +7. [Testing Strategies](#testing-strategies) +8. [Common Patterns & Examples](#common-patterns--examples) +9. [Performance Considerations](#performance-considerations) +10. [Migration Strategies](#migration-strategies) + +--- + +## Introduction + +Building cross-platform React applications for both web and React Native requires careful architectural decisions about **how to structure components** and **where to place business logic**. This guide provides a comprehensive framework for creating maintainable, scalable, and testable cross-platform components. + +### Key Principles + +**🎯 Core Goal**: Write platform-specific code at the primitive level. Our `src/components/primitives/` collection handles platform differences so business components don't need to. + +**📊 Code Sharing Reality**: We achieve ~90% code sharing by extracting business logic to hooks in `src/hooks/` and using shared primitives for UI consistency. + +**🏗️ Architecture Philosophy**: Three platform targets (web, desktop Electron, mobile React Native) share the same component architecture and business logic while adapting UI automatically through primitives. + +--- + +## Architectural Foundations + +### Layer 1: Primitive Components (Platform-Specific) + +```tsx +// Our actual primitive structure in src/components/primitives/ +Button/ +├── Button.web.tsx // Uses HTML <button> with Tailwind CSS +├── Button.native.tsx // Uses React Native <Pressable> with StyleSheet +├── types.ts // Shared TypeScript interface +└── index.ts // Platform resolution +``` + +### Layer 2: Business Logic (Shared) + +```tsx +// Our actual hooks structure in src/hooks/ +useSpaces(); // 100% shared across platforms +useMessages(); // 100% shared across platforms +useUserProfile(); // 100% shared across platforms +validateMessageContent(); // Pure functions in src/utils/ +``` + +### Layer 3: Layout Components (Platform-Aware) + +```tsx +// Our component patterns in src/components/ +SpaceHeader.tsx; // Single component using primitives + responsive detection +MessageComposer.web.tsx; // Desktop-specific layout with hover interactions +MessageComposer.native.tsx; // Mobile-specific layout with touch gestures +``` + +--- + +## Decision Framework: One vs Two Components + +This framework aligns with the practical guidance in [Component Management Guide](./component-management-guide.md). + +**Related Documentation:** +- [Primitives Overview](./features/primitives/INDEX.md) - Complete primitives documentation +- [API Reference](./features/primitives/API-REFERENCE.md) - Quick prop lookup +- [Quick Reference](../AGENTS.md) - Fast lookup for common patterns + +### Use Single Shared Component When: + +**✅ Criteria:** + +- Component only uses our primitives (no raw HTML) +- Layout differences can be handled with responsive detection +- Business logic is already extracted to hooks in `src/hooks/` +- Component complexity stays manageable (~100 lines or less) + +**📝 Example Structure:** + +```tsx +// UserProfile.tsx - Single component using our primitives +import { FlexColumn, Container, Text, Button } from '../primitives'; +import { useResponsiveLayoutContext } from '../context/ResponsiveLayoutProvider'; +import { useUserProfile } from '../hooks/useUserProfile'; + +export function UserProfile({ userId }) { + const { isMobile } = useResponsiveLayoutContext(); + + // ✅ Business logic extracted to hook + const { user, updateUser, isLoading } = useUserProfile(userId); + + // UI state stays in component + const [isEditing, setIsEditing] = useState(false); + + if (isLoading) return <Text>Loading...</Text>; + + return ( + <Container className={isMobile ? 'p-4' : 'p-6'}> + <FlexColumn gap={isMobile ? 'md' : 'lg'}> + <Text variant="heading" size={isMobile ? 'lg' : 'xl'}> + {user.name} + </Text> + + {isMobile ? ( + <MobileProfileLayout user={user} onEdit={() => setIsEditing(true)} /> + ) : ( + <DesktopProfileLayout user={user} onEdit={() => setIsEditing(true)} /> + )} + </FlexColumn> + </Container> + ); +} +``` + +### Use Separate Platform Components When: + +**✅ Criteria:** + +- Deep OS integration needs (file system, notifications, camera) +- Platform-specific gestures/interactions (swipe vs hover) +- Performance-critical sections requiring native optimization +- Would require excessive conditional rendering (>50% of component) +- Component exceeds ~150 lines with conditional logic + +**📝 Example Structure:** + +```tsx +// hooks/useSpaceHeader.ts - Shared business logic +export function useSpaceHeader(spaceId: string) { + const [space, setSpace] = useState(null); + const [memberCount, setMemberCount] = useState(0); + + // All business logic here - 100% shared + useEffect(() => { + Promise.all([fetchSpace(spaceId), fetchMemberCount(spaceId)]).then( + ([spaceData, count]) => { + setSpace(spaceData); + setMemberCount(count); + } + ); + }, [spaceId]); + + return { space, memberCount, updateSpace }; +} + +// SpaceHeader.web.tsx - Desktop layout using our primitives +import { FlexBetween, Text, Button, Container } from '../primitives'; + +export function SpaceHeader({ spaceId }) { + const { space, memberCount } = useSpaceHeader(spaceId); + + // ✅ Only UI logic specific to desktop + const [showDropdown, setShowDropdown] = useState(false); + + return ( + <Container className="space-header-desktop"> + <FlexBetween> + <Text variant="heading" size="xl"> + {space?.name} + </Text> + <Button variant="subtle" onClick={() => setShowDropdown(!showDropdown)}> + Settings + </Button> + </FlexBetween> + </Container> + ); +} + +// SpaceHeader.native.tsx - Mobile layout using our primitives +import { FlexColumn, FlexRow, Text, Button } from '../primitives'; + +export function SpaceHeader({ spaceId }) { + const { space, memberCount } = useSpaceHeader(spaceId); // Same hook! + + // ✅ Only UI logic specific to mobile + const [showActions, setShowActions] = useState(false); + + return ( + <FlexColumn gap="sm" style={styles.mobileHeader}> + <Text variant="heading" size="lg"> + {space?.name} + </Text> + <FlexRow gap="xs"> + <Text variant="subtle">{memberCount} members</Text> + <Button size="small" onPress={() => setShowActions(!showActions)}> + ⋯ + </Button> + </FlexRow> + </FlexColumn> + ); +} +``` + +--- + +## Logic Classification & Extraction + +### Business Logic: ALWAYS Extract to Hooks + +Following our [Component Management Guide](./component-management-guide.md#business-logic-extraction-rule), ALL business logic must be extracted to hooks in `src/hooks/`. + +**🔄 Must Extract to `src/hooks/`:** + +- **Data fetching**: API calls, WebSocket connections, caching +- **Business rules**: User permissions, message validation, space management +- **State management**: User profiles, space data, message history +- **Side effects**: Analytics tracking, notifications, file uploads + +**📝 Example from Our Codebase:** + +```tsx +// ❌ Business logic mixed in component +export function SpaceView({ spaceId }) { + const [messages, setMessages] = useState([]); + const [members, setMembers] = useState([]); + + // ❌ Extract this to src/hooks/ + useEffect(() => { + fetchSpaceMessages(spaceId).then(setMessages); + fetchSpaceMembers(spaceId).then(setMembers); + }, [spaceId]); + + const canManageSpace = (userId) => { + // ❌ Extract this business rule + return members.find((m) => m.id === userId)?.role === 'admin'; + }; + + return <div>...</div>; +} + +// ✅ Business logic extracted to our hooks structure +// src/hooks/useSpaceData.ts +export function useSpaceData(spaceId: string) { + const [messages, setMessages] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchSpaceMessages(spaceId) + .then(setMessages) + .finally(() => setIsLoading(false)); + }, [spaceId]); + + return { messages, isLoading }; +} + +// src/hooks/useSpacePermissions.ts +export function useSpacePermissions(spaceId: string, userId: string) { + const { members } = useSpaceMembers(spaceId); + + const canManageSpace = useCallback(() => { + return members.find((m) => m.id === userId)?.role === 'admin'; + }, [members, userId]); + + const canDeleteMessages = useCallback(() => { + const userMember = members.find((m) => m.id === userId); + return userMember?.role === 'admin' || userMember?.role === 'moderator'; + }, [members, userId]); + + return { canManageSpace, canDeleteMessages }; +} + +// ✅ Clean component using our primitives and extracted logic +import { FlexColumn, Text, Container } from '../primitives'; + +export function SpaceView({ spaceId }) { + const { messages, isLoading } = useSpaceData(spaceId); + const { canManageSpace } = useSpacePermissions(spaceId, currentUser.id); + + // Only UI logic remains + if (isLoading) return <Text>Loading...</Text>; + + return ( + <Container> + <FlexColumn gap="md"> + <SpaceMessageList messages={messages} canManage={canManageSpace()} /> + </FlexColumn> + </Container> + ); +} +``` + +### UI Logic: Keep in Components + +The [Component Management Guide](./component-management-guide.md#business-logic-extraction-rule) covers this in detail. Key points: + +**✅ Keep in Components:** + +- **UI state**: Modal visibility, input focus, hover states +- **Event handling**: Click handlers, form submissions +- **UI calculations**: Show/hide logic, formatting for display +- **Render logic**: Conditional rendering based on UI state + +**See the Component Management Guide for complete examples and implementation details.** + +### Pure Business Functions: Maximum Extractability + +Pure functions go in `src/utils/` and should contain no React dependencies: + +```tsx +// src/utils/messageValidation.ts - Pure functions +export function validateMessageContent(content: string): ValidationResult { + if (!content.trim()) { + return { isValid: false, error: 'Message cannot be empty' }; + } + + if (content.length > MESSAGE_MAX_LENGTH) { + return { + isValid: false, + error: `Message too long (max ${MESSAGE_MAX_LENGTH})`, + }; + } + + return { isValid: true }; +} + +export function calculateSpacePermissions( + user: User, + space: Space +): SpacePermissions { + if (user.globalRole === 'admin') return ALL_PERMISSIONS; + + const spaceMember = space.members.find((m) => m.userId === user.id); + return getRolePermissions(spaceMember?.role || 'member'); +} + +// src/hooks/useMessageValidation.ts - Hook uses pure functions +export function useMessageValidation(spaceId: string) { + const { space } = useSpaceData(spaceId); + + const validate = useCallback( + (content: string) => { + return validateMessageContent(content); // Uses pure function + }, + [space] + ); + + return { validate }; +} +``` + +--- + +## Implementation Patterns + +### Pattern 1: Responsive Single Component + +**Use Case**: Similar layouts with minor differences + +```tsx +// Component.tsx - Single file with platform detection +import { useResponsiveLayout } from '../hooks/useResponsiveLayout'; + +export function UserCard({ userId }) { + const { isMobile, isTablet } = useResponsiveLayout(); + + // Shared business logic + const { user, updateUser, isLoading } = useUser(userId); + + // UI state specific to this component + const [isExpanded, setIsExpanded] = useState(false); + const [activeTab, setActiveTab] = useState('profile'); + + if (isLoading) return <LoadingSpinner />; + + return ( + <Card className={isMobile ? 'user-card-mobile' : 'user-card-desktop'}> + <FlexRow gap={isMobile ? 'sm' : 'md'}> + <Avatar src={user.avatar} size={isMobile ? 'md' : 'lg'} /> + + <FlexColumn flex={1}> + <Text size={isMobile ? 'lg' : 'xl'} weight="bold"> + {user.name} + </Text> + + {isMobile ? ( + // Mobile: Collapsible details + <> + <Button + variant="subtle" + onClick={() => setIsExpanded(!isExpanded)} + > + {isExpanded ? 'Show Less' : 'Show More'} + </Button> + {isExpanded && <UserDetails user={user} />} + </> + ) : ( + // Desktop: Always visible details + <UserDetails user={user} /> + )} + </FlexColumn> + </FlexRow> + </Card> + ); +} +``` + +### Pattern 2: Platform-Specific Components with Shared Logic + +**Use Case**: Fundamentally different layouts or interactions + +```tsx +// hooks/useChannelChat.ts - 100% shared business logic +export function useChannelChat(channelId: string) { + const [messages, setMessages] = useState([]); + const [members, setMembers] = useState([]); + const [typing, setTyping] = useState([]); + + // All business logic: data fetching, WebSocket, etc. + useEffect(() => { + const ws = new WebSocket(`ws://api.com/channels/${channelId}`); + ws.onmessage = handleMessage; + return () => ws.close(); + }, [channelId]); + + const sendMessage = useCallback( + async (content: string) => { + const message = await api.sendMessage(channelId, content); + setMessages((prev) => [...prev, message]); + }, + [channelId] + ); + + return { messages, members, typing, sendMessage }; +} + +// ChannelChat.web.tsx - Desktop: Sidebar layout +export function ChannelChat({ channelId }) { + const chat = useChannelChat(channelId); // Shared logic + + // Desktop-specific UI state + const [showMembersList, setShowMembersList] = useState(true); + const [selectedMessage, setSelectedMessage] = useState(null); + + return ( + <FlexRow height="100vh"> + {/* Desktop: Always visible sidebar */} + <ChannelSidebar + members={chat.members} + visible={showMembersList} + onToggle={setShowMembersList} + /> + + <FlexColumn flex={1}> + <MessageList + messages={chat.messages} + onMessageSelect={setSelectedMessage} + selectedMessage={selectedMessage} + /> + + <MessageInput onSend={chat.sendMessage} typingUsers={chat.typing} /> + </FlexColumn> + </FlexRow> + ); +} + +// ChannelChat.native.tsx - Mobile: Stack navigation +export function ChannelChat({ channelId, navigation }) { + const chat = useChannelChat(channelId); // Same shared logic + + // Mobile-specific UI state + const [showActionSheet, setShowActionSheet] = useState(false); + + useLayoutEffect(() => { + navigation.setOptions({ + title: `# ${chat.channelName}`, + headerRight: () => ( + <IconButton + name="users" + onPress={() => + navigation.navigate('ChannelMembers', { + members: chat.members, + }) + } + /> + ), + }); + }, [navigation, chat.channelName, chat.members]); + + return ( + <View style={styles.container}> + <MessageList + messages={chat.messages} + onLongPress={(message) => setShowActionSheet(message)} + /> + + <MessageInput onSend={chat.sendMessage} typingUsers={chat.typing} /> + + {showActionSheet && ( + <ActionSheet + message={showActionSheet} + onClose={() => setShowActionSheet(false)} + /> + )} + </View> + ); +} +``` + +### Pattern 3: Hook Composition for Complex Logic + +<cite index="32-1">One powerful aspect of custom hooks is that they can be composed to create more complex logic. By combining multiple custom hooks, you can build sophisticated business logic while keeping your UI components clean and focused.</cite> + +```tsx +// Individual focused hooks +export function useChannelData(channelId: string) { + // Data fetching logic +} + +export function useChannelPermissions(channelId: string, userId: string) { + // Permission checking logic +} + +export function useChannelMessaging(channelId: string) { + // Message sending/receiving logic +} + +export function useChannelPresence(channelId: string) { + // User presence and typing indicators +} + +// Composed master hook +export function useChannel(channelId: string) { + const data = useChannelData(channelId); + const permissions = useChannelPermissions(channelId, currentUser.id); + const messaging = useChannelMessaging(channelId); + const presence = useChannelPresence(channelId); + + return { + ...data, + ...permissions, + ...messaging, + ...presence, + }; +} + +// Components can use either approach: +export function SimpleComponent({ channelId }) { + // Option A: Composed hook (simpler) + const channel = useChannel(channelId); + + return <ChannelView {...channel} />; +} + +export function AdvancedComponent({ channelId }) { + // Option B: Individual hooks (more control) + const { messages } = useChannelData(channelId); + const { canDelete } = useChannelPermissions(channelId, currentUser.id); + + return <AdvancedChannelView messages={messages} canDelete={canDelete} />; +} +``` + +--- + +## Best Practices + +### 1. Platform Detection Strategy + +We use our centralized platform utilities in `src/utils/platform.ts` for all platform detection needs: + +```tsx +// Our actual platform utilities +import { + isWeb, + isMobile, + isElectron, + getPlatform, + isTouchDevice, +} from '../utils/platform'; +import { useResponsiveLayoutContext } from '../context/ResponsiveLayoutProvider'; + +// Platform environment detection +if (isWeb()) { + // Web browser code +} +if (isMobile()) { + // React Native environment + // Mobile app code +} +if (isElectron()) { + // Electron desktop app code +} + +// Screen size detection (viewport-based) +const { + isMobile: isSmallScreen, + isTablet, + isDesktop, +} = useResponsiveLayoutContext(); + +// Touch capability detection +const hasTouch = isTouchDevice(); + +// Combined platform features +const platform = getPlatform(); // 'web' | 'mobile' | 'electron' +``` + +### 2. Custom Hook Naming and Structure + +<cite index="38-1">A custom Hook is a JavaScript function whose name starts with "use" and that may call other Hooks.</cite> + +```tsx +// ✅ Good: Focused, single-responsibility hooks +export function useUserProfile(userId: string) { + /* ... */ +} +export function useUserPermissions(userId: string) { + /* ... */ +} +export function useUserPreferences(userId: string) { + /* ... */ +} + +// ❌ Bad: Kitchen sink hook +export function useUserEverything(userId: string) { + /* ... */ +} + +// ✅ Good: Clear naming convention +export function useChannelData(channelId: string) { + /* ... */ +} +export function useChannelActions(channelId: string) { + /* ... */ +} +export function useChannelRealtime(channelId: string) { + /* ... */ +} +``` + +### 3. Dependency Injection for Testing + +<cite index="29-1">We adjust the function to accept a new "dependencies" parameter with the relevant service functions. Then we create a custom hook that "injects" these dependencies.</cite> + +```tsx +// Pure business function with dependency injection +export async function sendMessage( + channelId: string, + content: string, + dependencies: { + api: ApiService; + analytics: AnalyticsService; + notifications: NotificationService; + } +) { + const { api, analytics, notifications } = dependencies; + + try { + const message = await api.sendMessage(channelId, content); + analytics.track('message_sent', { + channelId, + messageLength: content.length, + }); + return message; + } catch (error) { + notifications.showError('Failed to send message'); + throw error; + } +} + +// Hook provides dependencies +export function useChannelMessaging(channelId: string) { + const sendMessageWithDeps = useCallback( + async (content: string) => { + return sendMessage(channelId, content, { + api: apiService, + analytics: analyticsService, + notifications: notificationService, + }); + }, + [channelId] + ); + + return { sendMessage: sendMessageWithDeps }; +} + +// Testing becomes easy +describe('sendMessage', () => { + it('should send message and track analytics', async () => { + const mockApi = { sendMessage: jest.fn().mockResolvedValue({ id: '123' }) }; + const mockAnalytics = { track: jest.fn() }; + const mockNotifications = { showError: jest.fn() }; + + await sendMessage('channel-1', 'Hello', { + api: mockApi, + analytics: mockAnalytics, + notifications: mockNotifications, + }); + + expect(mockApi.sendMessage).toHaveBeenCalledWith('channel-1', 'Hello'); + expect(mockAnalytics.track).toHaveBeenCalledWith('message_sent', { + channelId: 'channel-1', + messageLength: 5, + }); + }); +}); +``` + +### 4. Performance Optimization + +<cite index="36-1">Use useMemo and useCallback hooks to memoize expensive computations and callback functions, respectively. This helps avoid recalculating values or recreating functions unnecessarily.</cite> + +```tsx +export function useChannelData(channelId: string) { + const [messages, setMessages] = useState([]); + const [members, setMembers] = useState([]); + + // Memoize expensive calculations + const messagesByDate = useMemo(() => { + return groupMessagesByDate(messages); // Expensive operation + }, [messages]); + + const onlineMembers = useMemo(() => { + return members.filter((member) => member.isOnline); + }, [members]); + + // Memoize callback functions + const addMessage = useCallback((message: Message) => { + setMessages((prev) => [...prev, message]); + }, []); + + const updateMemberStatus = useCallback( + (userId: string, isOnline: boolean) => { + setMembers((prev) => + prev.map((member) => + member.id === userId ? { ...member, isOnline } : member + ) + ); + }, + [] + ); + + return { + messages, + messagesByDate, + members, + onlineMembers, + addMessage, + updateMemberStatus, + }; +} +``` + +### 5. Error Boundaries and Error Handling + +```tsx +// Error handling in hooks +export function useChannelData(channelId: string) { + const [messages, setMessages] = useState([]); + const [error, setError] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + const fetchData = async () => { + try { + setError(null); + setIsLoading(true); + + const data = await api.fetchChannelMessages(channelId); + + if (!cancelled) { + setMessages(data.messages); + } + } catch (err) { + if (!cancelled) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + }; + + fetchData(); + + return () => { + cancelled = true; + }; + }, [channelId]); + + return { messages, error, isLoading }; +} + +// Error boundary for components +export class ChannelErrorBoundary extends React.Component { + constructor(props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error) { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( + <ErrorFallback + error={this.state.error} + onRetry={() => this.setState({ hasError: false, error: null })} + /> + ); + } + + return this.props.children; + } +} +``` + +--- + +## Testing Strategies + +### Testing with Our Playground System + +**Web Testing (Primary):** + +1. Run `yarn dev` to start development server +2. Navigate to `/playground` for primitive testing +3. Test components with different props, themes, and screen sizes +4. Verify responsive behavior and theme switching + +**Mobile Testing (When Needed):** + +1. Run `yarn mobile` to start mobile test playground +2. Use Expo Go app to test on real device +3. Navigate through test screens to verify mobile behavior +4. Test touch interactions and native platform integration + +### 1. Testing Pure Business Functions + +```tsx +// src/utils/__tests__/messageValidation.test.ts +import { validateMessageContent } from '../messageValidation'; + +describe('validateMessageContent', () => { + it('should validate normal messages', () => { + const result = validateMessageContent('Hello world'); + expect(result.isValid).toBe(true); + }); + + it('should reject empty messages', () => { + const result = validateMessageContent(' '); + expect(result.isValid).toBe(false); + expect(result.error).toBe('Message cannot be empty'); + }); + + it('should reject messages that are too long', () => { + const longMessage = 'a'.repeat(1001); // Assuming 1000 char limit + const result = validateMessageContent(longMessage); + expect(result.isValid).toBe(false); + expect(result.error).toContain('too long'); + }); +}); +``` + +### 2. Testing Custom Hooks + +```tsx +// src/hooks/__tests__/useSpaceData.test.ts +import { renderHook, waitFor } from '@testing-library/react'; +import { useSpaceData } from '../useSpaceData'; + +// Mock our API service +jest.mock('../../services/api', () => ({ + fetchSpaceMessages: jest.fn(), + fetchSpaceMembers: jest.fn(), +})); + +describe('useSpaceData', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should fetch space data on mount', async () => { + const mockMessages = [{ id: '1', content: 'Hello space!' }]; + const mockMembers = [{ id: 'user1', name: 'Alice' }]; + + api.fetchSpaceMessages.mockResolvedValue({ messages: mockMessages }); + api.fetchSpaceMembers.mockResolvedValue({ members: mockMembers }); + + const { result } = renderHook(() => useSpaceData('space-123')); + + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.messages).toEqual(mockMessages); + expect(result.current.members).toEqual(mockMembers); + expect(result.current.error).toBeNull(); + }); + + it('should handle errors gracefully', async () => { + const error = new Error('Network error'); + api.fetchSpaceMessages.mockRejectedValue(error); + + const { result } = renderHook(() => useSpaceData('space-123')); + + await waitFor(() => { + expect(result.current.isLoading).toBe(false); + }); + + expect(result.current.error).toBe('Network error'); + expect(result.current.messages).toEqual([]); + }); +}); +``` + +### 3. Testing Platform-Specific Components + +```tsx +// src/components/__tests__/SpaceChat.test.tsx +import { render, screen, fireEvent } from '@testing-library/react'; +import { SpaceChat } from '../SpaceChat'; + +// Mock our hooks +jest.mock('../../hooks/useSpaceChat', () => ({ + useSpaceChat: () => ({ + messages: [{ id: '1', content: 'Hello space!', author: { name: 'Alice' } }], + sendMessage: jest.fn(), + members: [{ id: 'user1', name: 'Alice' }], + }), +})); + +// Mock our primitives +jest.mock('../primitives', () => ({ + FlexColumn: ({ children, ...props }) => ( + <div data-testid="flex-column" {...props}> + {children} + </div> + ), + Text: ({ children, ...props }) => <span {...props}>{children}</span>, + Button: ({ children, onClick, ...props }) => ( + <button onClick={onClick} {...props}> + {children} + </button> + ), + Input: ({ value, onChange, placeholder, ...props }) => ( + <input + value={value} + onChange={(e) => onChange?.(e.target.value)} + placeholder={placeholder} + {...props} + /> + ), +})); + +describe('SpaceChat', () => { + it('should render messages using our primitives', () => { + render(<SpaceChat spaceId="space-123" />); + + expect(screen.getByText('Hello space!')).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect(screen.getByTestId('flex-column')).toBeInTheDocument(); + }); + + it('should handle message sending with our Input primitive', () => { + const mockSendMessage = jest.fn(); + + render(<SpaceChat spaceId="space-123" />); + + const input = screen.getByPlaceholderText('Type a message...'); + const sendButton = screen.getByRole('button', { name: /send/i }); + + fireEvent.change(input, { target: { value: 'New space message' } }); + fireEvent.click(sendButton); + + expect(mockSendMessage).toHaveBeenCalledWith('New space message'); + }); +}); +``` + +--- + +## Common Patterns & Examples + +### 1. Space Header Pattern (Real Example) + +This is the actual pattern used in our app - see the complete implementation in `src/components/SpaceHeader/`. + +```tsx +// src/hooks/useSpaceHeader.ts - Shared business logic +export function useSpaceHeader(spaceId: string) { + const [space, setSpace] = useState(null); + const [memberCount, setMemberCount] = useState(0); + const [userRole, setUserRole] = useState('member'); + + useEffect(() => { + Promise.all([ + fetchSpace(spaceId), + fetchSpaceMemberCount(spaceId), + fetchUserSpaceRole(spaceId, currentUser.id), + ]).then(([spaceData, count, role]) => { + setSpace(spaceData); + setMemberCount(count); + setUserRole(role); + }); + }, [spaceId]); + + const canManageSpace = userRole === 'admin' || userRole === 'moderator'; + + return { space, memberCount, userRole, canManageSpace }; +} + +// SpaceHeader.web.tsx - Desktop: Uses our primitives +import { Container, FlexBetween, Text, Button } from '../primitives'; + +export function SpaceHeader({ spaceId }) { + const { space, memberCount, canManageSpace } = useSpaceHeader(spaceId); + + return ( + <Container className="space-header-desktop bg-surface-0 border-b border-default"> + <FlexBetween className="p-4"> + <FlexColumn gap="xs"> + <Text variant="heading" size="xl" className="text-strong"> + {space?.name} + </Text> + <Text variant="subtle" size="sm"> + {memberCount} members + </Text> + </FlexColumn> + + {canManageSpace && ( + <Button variant="subtle" onClick={() => openSpaceSettings(spaceId)}> + Settings + </Button> + )} + </FlexBetween> + </Container> + ); +} + +// SpaceHeader.native.tsx - Mobile: Uses our primitives with StyleSheet +import { FlexColumn, FlexRow, Text, Button } from '../primitives'; +import { StyleSheet } from 'react-native'; + +export function SpaceHeader({ spaceId }) { + const { space, memberCount, canManageSpace } = useSpaceHeader(spaceId); + + return ( + <FlexColumn style={styles.container}> + <FlexRow style={styles.titleSection}> + <FlexColumn style={styles.titleContainer}> + <Text variant="heading" size="lg" style={styles.spaceName}> + {space?.name} + </Text> + <Text variant="subtle" size="sm" style={styles.memberCount}> + {memberCount} members + </Text> + </FlexColumn> + + {canManageSpace && ( + <Button + variant="subtle" + size="small" + onPress={() => openSpaceSettings(spaceId)} + > + ⋯ + </Button> + )} + </FlexRow> + </FlexColumn> + ); +} + +// Using our theming system from src/components/primitives/theme/colors.ts +const styles = StyleSheet.create({ + container: { + backgroundColor: colors.surface[0], + borderBottomWidth: 1, + borderBottomColor: colors.border.default, + }, + titleSection: { + padding: 16, + alignItems: 'center', + }, + titleContainer: { + flex: 1, + }, +}); +``` + +### 2. Message Component with Actions + +```tsx +// hooks/useMessage.ts - Shared message logic +export function useMessage(messageId: string) { + const [message, setMessage] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + fetchMessage(messageId) + .then(setMessage) + .finally(() => setIsLoading(false)); + }, [messageId]); + + const updateMessage = useCallback( + async (newContent: string) => { + const updated = await api.updateMessage(messageId, newContent); + setMessage(updated); + return updated; + }, + [messageId] + ); + + const deleteMessage = useCallback(async () => { + await api.deleteMessage(messageId); + setMessage(null); + }, [messageId]); + + return { message, isLoading, updateMessage, deleteMessage }; +} + +// hooks/useMessageActions.ts - Message actions logic +export function useMessageActions(messageId: string) { + const { canEdit, canDelete } = useMessagePermissions(messageId); + + const actions = useMemo(() => { + const availableActions = []; + + availableActions.push({ id: 'reply', label: 'Reply', icon: 'reply' }); + availableActions.push({ + id: 'react', + label: 'Add Reaction', + icon: 'smile', + }); + + if (canEdit) { + availableActions.push({ id: 'edit', label: 'Edit', icon: 'edit' }); + } + + if (canDelete) { + availableActions.push({ + id: 'delete', + label: 'Delete', + icon: 'trash', + danger: true, + }); + } + + availableActions.push({ id: 'copy', label: 'Copy Link', icon: 'link' }); + + return availableActions; + }, [canEdit, canDelete]); + + return { actions }; +} + +// Message.tsx - Shared component with platform detection +export function Message({ messageId }) { + const { isMobile } = useResponsiveLayout(); + const { message, isLoading } = useMessage(messageId); + const { actions } = useMessageActions(messageId); + + // UI state + const [showActions, setShowActions] = useState(false); + const [isHovered, setIsHovered] = useState(false); + + if (isLoading) return <MessageSkeleton />; + if (!message) return null; + + const shouldShowActions = isMobile ? showActions : isHovered; + + return ( + <FlexRow + gap="md" + className="message group" + onMouseEnter={() => !isMobile && setIsHovered(true)} + onMouseLeave={() => !isMobile && setIsHovered(false)} + onLongPress={() => isMobile && setShowActions(true)} + > + <Avatar src={message.author.avatar} size="md" /> + + <FlexColumn flex={1} gap="xs"> + <FlexRow gap="sm" align="center"> + <Text weight="semibold" size="sm"> + {message.author.name} + </Text> + <Text size="xs" color="subtle"> + {formatTimestamp(message.createdAt)} + </Text> + </FlexRow> + + <Text>{message.content}</Text> + + {message.reactions?.length > 0 && ( + <MessageReactions reactions={message.reactions} /> + )} + </FlexColumn> + + {/* Actions appear on hover (desktop) or long press (mobile) */} + {shouldShowActions && ( + <MessageActionsMenu + actions={actions} + onAction={(actionId) => { + handleAction(actionId, message); + setShowActions(false); + }} + onClose={() => setShowActions(false)} + positioning={isMobile ? 'bottom-sheet' : 'tooltip'} + /> + )} + </FlexRow> + ); +} +``` + +### 3. Form with Validation + +```tsx +// business/validation.ts - Pure validation functions +export interface ValidationRule { + required?: boolean; + minLength?: number; + maxLength?: number; + pattern?: RegExp; + custom?: (value: any) => boolean; +} + +export function validateField( + value: any, + rules: ValidationRule +): ValidationResult { + if (rules.required && (!value || value.toString().trim() === '')) { + return { valid: false, error: 'This field is required' }; + } + + if (rules.minLength && value.length < rules.minLength) { + return { valid: false, error: `Minimum length is ${rules.minLength}` }; + } + + if (rules.maxLength && value.length > rules.maxLength) { + return { valid: false, error: `Maximum length is ${rules.maxLength}` }; + } + + if (rules.pattern && !rules.pattern.test(value)) { + return { valid: false, error: 'Invalid format' }; + } + + if (rules.custom && !rules.custom(value)) { + return { valid: false, error: 'Invalid value' }; + } + + return { valid: true }; +} + +// hooks/useForm.ts - Form state management +export function useForm<T>( + initialValues: T, + validationRules: Record<keyof T, ValidationRule> +) { + const [values, setValues] = useState(initialValues); + const [errors, setErrors] = useState<Record<keyof T, string>>({}); + const [touched, setTouched] = useState<Record<keyof T, boolean>>({}); + + const validateForm = useCallback(() => { + const newErrors = {} as Record<keyof T, string>; + let isValid = true; + + Object.keys(validationRules).forEach((field) => { + const fieldKey = field as keyof T; + const result = validateField(values[fieldKey], validationRules[fieldKey]); + + if (!result.valid) { + newErrors[fieldKey] = result.error; + isValid = false; + } + }); + + setErrors(newErrors); + return isValid; + }, [values, validationRules]); + + const setValue = useCallback((field: keyof T, value: any) => { + setValues((prev) => ({ ...prev, [field]: value })); + setTouched((prev) => ({ ...prev, [field]: true })); + }, []); + + const handleSubmit = useCallback( + (onSubmit: (values: T) => void) => { + return (e?: React.FormEvent) => { + e?.preventDefault(); + + if (validateForm()) { + onSubmit(values); + } + }; + }, + [values, validateForm] + ); + + return { + values, + errors, + touched, + setValue, + validateForm, + handleSubmit, + isValid: Object.keys(errors).length === 0, + }; +} + +// CreateChannelForm.tsx - Form using shared logic +export function CreateChannelForm({ onSubmit, onCancel }) { + const form = useForm( + { name: '', description: '', isPrivate: false }, + { + name: { required: true, minLength: 1, maxLength: 50 }, + description: { maxLength: 500 }, + } + ); + + return ( + <form onSubmit={form.handleSubmit(onSubmit)}> + <FlexColumn gap="md"> + <InputField + label="Channel Name" + value={form.values.name} + onChange={(value) => form.setValue('name', value)} + error={form.touched.name ? form.errors.name : undefined} + placeholder="Enter channel name" + required + /> + + <TextAreaField + label="Description" + value={form.values.description} + onChange={(value) => form.setValue('description', value)} + error={form.touched.description ? form.errors.description : undefined} + placeholder="Optional description" + rows={3} + /> + + <SwitchField + label="Private Channel" + description="Only invited members can see this channel" + checked={form.values.isPrivate} + onChange={(checked) => form.setValue('isPrivate', checked)} + /> + + <FlexRow gap="sm" justify="end"> + <Button variant="subtle" onClick={onCancel}> + Cancel + </Button> + <Button + type="submit" + disabled={!form.isValid} + loading={form.isSubmitting} + > + Create Channel + </Button> + </FlexRow> + </FlexColumn> + </form> + ); +} +``` + +--- + +## Performance Considerations + +### 1. Memoization Strategies + +```tsx +// Heavy computation memoization +export function useChannelAnalytics(channelId: string) { + const { messages } = useChannelData(channelId); + + // Expensive calculations - memoize them + const analytics = useMemo(() => { + const messageCount = messages.length; + const userActivity = calculateUserActivity(messages); // Expensive + const timeDistribution = calculateTimeDistribution(messages); // Expensive + const popularEmojis = extractPopularEmojis(messages); // Expensive + + return { + messageCount, + userActivity, + timeDistribution, + popularEmojis, + }; + }, [messages]); + + return analytics; +} + +// Callback memoization to prevent child re-renders +export function MessageList({ messages, onMessageAction }) { + // Memoize the callback to prevent MessageItem re-renders + const handleMessageAction = useCallback( + (messageId: string, action: string) => { + onMessageAction(messageId, action); + }, + [onMessageAction] + ); + + return ( + <VirtualList + data={messages} + renderItem={({ item: message }) => ( + <MessageItem + key={message.id} + message={message} + onAction={handleMessageAction} // Stable reference + /> + )} + /> + ); +} +``` + +### 2. Virtual Scrolling for Large Lists + +```tsx +// hooks/useVirtualizedMessages.ts +export function useVirtualizedMessages(channelId: string) { + const [messages, setMessages] = useState([]); + const [hasMore, setHasMore] = useState(true); + const [isLoading, setIsLoading] = useState(false); + + const loadMoreMessages = useCallback(async () => { + if (isLoading || !hasMore) return; + + setIsLoading(true); + try { + const response = await api.fetchMessages(channelId, { + before: messages[0]?.id, + limit: 50, + }); + + setMessages((prev) => [...response.messages, ...prev]); + setHasMore(response.hasMore); + } finally { + setIsLoading(false); + } + }, [channelId, messages, isLoading, hasMore]); + + return { messages, hasMore, isLoading, loadMoreMessages }; +} + +// MessageList with virtualization +export function MessageList({ channelId }) { + const { messages, hasMore, loadMoreMessages } = + useVirtualizedMessages(channelId); + + return ( + <VirtualList + data={messages} + renderItem={({ item, index }) => ( + <MessageItem + message={item} + isFirstMessage={index === 0} + isLastMessage={index === messages.length - 1} + /> + )} + onEndReached={loadMoreMessages} + onEndReachedThreshold={0.1} + ListFooterComponent={hasMore ? <LoadingSpinner /> : null} + getItemLayout={(data, index) => ({ + length: 60, // Estimated message height + offset: 60 * index, + index, + })} + /> + ); +} +``` + +### 3. Code Splitting and Lazy Loading + +```tsx +// Lazy load heavy components +const UserSettingsModal = lazy(() => import('./UserSettingsModal')); +const EmojiPicker = lazy(() => import('./EmojiPicker')); +const FileUploadDialog = lazy(() => import('./FileUploadDialog')); + +export function ChatInterface() { + const [showSettings, setShowSettings] = useState(false); + const [showEmojiPicker, setShowEmojiPicker] = useState(false); + + return ( + <div> + <ChatMessages /> + <MessageInput onEmojiClick={() => setShowEmojiPicker(true)} /> + + {/* Lazy load modals only when needed */} + <Suspense fallback={<ModalSkeleton />}> + {showSettings && ( + <UserSettingsModal + visible={showSettings} + onClose={() => setShowSettings(false)} + /> + )} + + {showEmojiPicker && ( + <EmojiPicker + visible={showEmojiPicker} + onClose={() => setShowEmojiPicker(false)} + /> + )} + </Suspense> + </div> + ); +} +``` + +--- + +## Migration Strategies + +### 1. Incremental Migration Approach + +```tsx +// Phase 1: Extract business logic to hooks +// Before: Monolithic component +export function Channel({ channelId }) { + const [messages, setMessages] = useState([]); + const [members, setMembers] = useState([]); + + useEffect(() => { + // 100+ lines of business logic + }, [channelId]); + + const handleSendMessage = async (content) => { + // 50+ lines of business logic + }; + + return <div>{/* 200+ lines of JSX */}</div>; +} + +// After Phase 1: Logic extracted to hooks +export function Channel({ channelId }) { + // ✅ Business logic extracted + const { messages, members } = useChannelData(channelId); + const { sendMessage } = useChannelMessaging(channelId); + + return <div>{/* Same JSX, but now using shared logic */}</div>; +} + +// Phase 2: Create platform-specific versions if needed +// Channel.web.tsx +export function Channel({ channelId }) { + const channelLogic = useChannel(channelId); + + return <DesktopChannelLayout {...channelLogic} />; +} + +// Channel.native.tsx +export function Channel({ channelId }) { + const channelLogic = useChannel(channelId); // Same logic! + + return <MobileChannelLayout {...channelLogic} />; +} +``` + +### 2. Feature Flag Driven Migration + +```tsx +// Gradual rollout using feature flags +export function UserProfile({ userId }) { + const { useNewArchitecture } = useFeatureFlags(); + + if (useNewArchitecture) { + // New: Uses extracted hooks and shared logic + return <UserProfileV2 userId={userId} />; + } + + // Old: Legacy implementation + return <UserProfileV1 userId={userId} />; +} + +// Test both versions in production +export function UserProfileV2({ userId }) { + const { user, updateUser } = useUser(userId); + const { permissions } = useUserPermissions(userId); + + return ( + <UserProfileLayout + user={user} + permissions={permissions} + onUpdate={updateUser} + /> + ); +} +``` + +### 3. Component Wrapper Strategy + +```tsx +// Temporary wrapper during migration +export function LegacyChannelWrapper({ channelId }) { + // Extract logic gradually + const { messages } = useChannelData(channelId); + + // Keep using legacy component with new data + return ( + <LegacyChannel + channelId={channelId} + messages={messages} // New: From hook + // Other props passed through from legacy usage + /> + ); +} + +// Eventually replace entirely +export function Channel({ channelId }) { + const channelLogic = useChannel(channelId); + + return <NewChannelLayout {...channelLogic} />; +} +``` + +--- + +## Conclusion + +Creating effective cross-platform React components requires careful consideration of **architecture**, **logic separation**, and **platform-specific requirements**. The key principles are: + +1. **Write platform-specific code at the lowest level possible** - in primitives +2. **Extract all business logic to shared hooks** for maximum code reuse +3. **Keep UI logic in components** where it belongs +4. **Use the decision framework** to choose between shared vs separate components +5. **Test business logic independently** from UI components +6. **Optimize performance** with proper memoization and virtualization + +By following these patterns and best practices, you can achieve <cite index="27-1">87% code reuse across platforms</cite> while maintaining native user experiences on both web and mobile. + +The investment in proper architecture pays dividends in **maintainability**, **testability**, and **developer productivity** as your cross-platform application grows in complexity. + +--- + +## Additional Resources + +- **[Component Management Guide](./component-management-guide.md)** - Practical decisions for component creation and management +- **[Primitive Styling Guide](./primitive-styling-guide.md)** - Detailed styling guidelines for primitives +- **[When to Use Primitives](./when-to-use-primitives.md)** - Decision framework for primitive usage + +--- + +_This guide provides architectural patterns specific to the Quilibrium desktop/mobile app. Continue refining these patterns based on user feedback and platform requirements._ diff --git a/.agents/docs/cross-platform-repository-implementation.md b/.agents/docs/cross-platform-repository-implementation.md new file mode 100644 index 000000000..107345a2c --- /dev/null +++ b/.agents/docs/cross-platform-repository-implementation.md @@ -0,0 +1,530 @@ +--- +type: doc +title: Cross-Platform Repository Implementation +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-08-07T00:00:00.000Z +--- + +# Cross-Platform Repository Implementation + +This document explains the implemented cross-platform repository structure for the Quorum desktop application, detailing what changed from the previous structure and how the build configurations were modified to support both development and production environments. + +## What Changed: Before vs After + +### Previous Structure (Single Platform) + +``` +quorum-desktop/ +├── src/ # Mixed web/shared code +├── index.html # Web entry at root +├── main.jsx # Web entry point at root +├── vite.config.js # Single config at root +├── electron/ # Electron wrapper at root +├── public/ # Web assets +├── package.json # Web dependencies +└── node_modules/ # Dependencies +``` + +### New Structure (Cross-Platform Ready) + +``` +quorum-desktop/ +├── src/ # SHARED CODE (90% of app) +│ ├── components/ +│ │ ├── primitives/ # Cross-platform UI components +│ │ │ ├── Button/ +│ │ │ │ ├── Button.web.tsx # Web implementation +│ │ │ │ ├── Button.native.tsx # Mobile implementation +│ │ │ │ ├── types.ts # Shared types +│ │ │ │ └── index.ts # Platform-aware exports +│ │ │ └── ... +│ │ ├── Router/ # Platform-specific routing +│ │ │ ├── Router.web.tsx # React Router (web) +│ │ │ ├── Router.native.tsx # React Navigation (mobile) +│ │ │ └── index.ts +│ │ └── ... (existing components) +│ ├── hooks/ # 100% shared business logic +│ ├── api/ # 100% shared API layer +│ ├── utils/ +│ │ └── platform.ts # NEW: Platform detection utilities +│ └── ... (existing structure) +│ +├── web/ # WEB-SPECIFIC FILES +│ ├── index.html # Web HTML entry (moved from root) +│ ├── main.tsx # Web React entry (moved from root) +│ ├── vite.config.ts # Web build config (moved from root) +│ └── electron/ # Electron wrapper (moved from root) +│ ├── main.cjs +│ └── preload.cjs +│ +├── mobile/ # MOBILE TEST PLAYGROUND +│ ├── App.tsx # React Native entry point with test screens +│ ├── screens/ # Primitive component test screens +│ ├── app.json # Expo/RN configuration +│ ├── metro.config.js # Metro bundler config +│ └── babel.config.js # Babel config for RN +│ +├── index.html # BUILD-ONLY: Root HTML for flat output +├── public/ # SHARED assets (unchanged location) +├── dist/ # PLATFORM-SPECIFIC BUILDS +│ ├── web/ # Complete web distribution +│ └── mobile/ # Future mobile distribution +├── package.json # SHARED dependencies & scripts +├── yarn.lock # SHARED lock file +└── node_modules/ # SHARED dependencies +``` + +## Key Architectural Changes + +### 1. Platform Detection System + +**New file**: `src/utils/platform.ts` + +```typescript +export function isWeb(): boolean { + return ( + typeof window !== 'undefined' && typeof window.document !== 'undefined' + ); +} + +export function isMobile(): boolean { + return ( + typeof navigator !== 'undefined' && navigator.product === 'ReactNative' + ); +} + +export function isElectron(): boolean { + if ( + typeof navigator !== 'undefined' && + navigator.userAgent.includes('Electron') + ) { + return true; + } + return false; +} + +export function isNative(): boolean { + return isMobile(); // Alias for backward compatibility +} +``` + +**Usage in App.tsx**: + +```typescript +// Before: Hardcoded electron detection +const isElectron = navigator.userAgent.includes('Electron'); + +// After: Platform-aware detection +import { isWeb, isElectron } from './utils/platform'; +// ... +{isWeb() && isElectron() && <CustomTitlebar />} +``` + +### 2. Platform-Specific Router Abstraction + +**New structure**: `src/components/Router/` + +- `Router.web.tsx` - React Router implementation +- `Router.native.tsx` - React Navigation placeholder +- `index.ts` - Platform-aware exports + +**Web Router** (`Router.web.tsx`): + +```typescript +import { Routes, Route } from 'react-router'; +// Existing routing logic extracted from App.tsx +export function Router() { + return ( + <Routes> + {/* Existing routes unchanged */} + </Routes> + ); +} +``` + +### 3. Cross-Platform Primitive Components + +Enhanced primitive components with platform-specific implementations: + +- `Button.web.tsx` - Web implementation with CSS +- `Button.native.tsx` - React Native implementation +- Shared types and interfaces + +## Build Configuration Changes + +The most critical changes were made to support both development and production builds with the new structure. + +### The Core Challenge + +**Problem**: Different requirements for dev vs build contexts: + +- **Dev server**: Needs to access `node_modules` from project root +- **Build**: Needs to avoid nested `dist/web/web/index.html` structure + +### ESolution: Environment-Specific Entry Points + +**New Vite Configuration** (`web/vite.config.ts`): + +```typescript +import { defineConfig } from 'vite'; +// ... other imports + +export default defineConfig(({ command }) => ({ + root: resolve(__dirname, '..'), // Project root for dependency resolution + publicDir: 'public', + base: '/', + build: { + target: 'es2022', + outDir: 'dist/web', + emptyOutDir: true, + rollupOptions: { + external: (id) => { + if (process.env.NODE_ENV === 'production' && id.includes('/dev/')) { + return true; + } + return false; + }, + // KEY SOLUTION: Environment-specific entry points + input: + command === 'build' + ? resolve(__dirname, '..', 'index.html') // Build: use root HTML (flat output) + : resolve(__dirname, 'index.html'), // Dev: use web/index.html + }, + }, + // ... rest of config +})); +``` + +### Why This Works + +1. **Development (`yarn dev`)**: + - Uses `web/index.html` as entry point + - Vite root is project root → can access `node_modules` + - References `/web/main.tsx` in HTML + +2. **Production (`yarn build`)**: + - Uses `index.html` at project root as entry point + - Avoids nested `dist/web/web/` structure + - Creates clean `dist/web/index.html` output + +### Dual HTML Strategy + +**Development HTML** (`web/index.html`): + +```html +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/png" href="./quorumicon-blue.png" /> + <!-- ... other head content ... --> + <script src="./handleredirect.js"></script> + </head> + <body> + <div id="root"></div> + <script type="module" src="./main.tsx"></script> + <!-- Dev path --> + </body> +</html> +``` + +**Build HTML** (`index.html` at root): + +```html +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <link rel="icon" type="image/png" href="./quorumicon-blue.png" /> + <!-- ... other head content ... --> + <script src="./handleredirect.js"></script> + </head> + <body> + <div id="root"></div> + <script type="module" src="./web/main.tsx"></script> + <!-- Build path --> + </body> +</html> +``` + +## Updated Package.json Scripts + +```json +{ + "scripts": { + // Web Development (uses web/vite.config.ts) + "dev": "vite --config web/vite.config.ts", + "build": "vite build --config web/vite.config.ts", + "build:preview": "yarn build && yarn preview --port 3000 --config web/vite.config.ts", + "preview": "vite preview --config web/vite.config.ts", + + // Electron (uses new paths) + "electron:dev": "NODE_ENV=development electron web/electron/main.cjs", + "electron:build": "yarn build && electron-builder", + + // Mobile (placeholder - not active yet) + "mobile:dev": "echo 'Mobile development not yet active. Run: cd mobile && expo start'", + "mobile:android": "echo 'Mobile development not yet active. Run: cd mobile && expo start --android'", + "mobile:ios": "echo 'Mobile development not yet active. Run: cd mobile && expo start --ios'", + + // Existing scripts unchanged + "lint": "eslint .", + "format": "prettier --write .", + "lingui:extract": "lingui extract", + "lingui:compile": "lingui compile" + } +} +``` + +## Build Output Structure + +### Correct Cross-Platform Build Structure + +``` +dist/ +└── web/ # Complete web distribution + ├── index.html ✅ Correct location + ├── assets/ ✅ JS/CSS bundles + │ ├── index-[hash].js + │ ├── index-[hash].css + │ └── messages-[hash].js (i18n chunks) + ├── apple/ ✅ Emoji assets + ├── quorumicon-blue.png ✅ Public assets + ├── handleredirect.js ✅ Static scripts + └── channelwasm_bg.wasm ✅ WebAssembly module +``` + +**Future mobile builds will create**: + +``` +dist/ +├── web/ # Web distribution +└── mobile/ # Mobile distribution + ├── android/ # Android APK/AAB + └── ios/ # iOS IPA +``` + +## Dependency Management + +### Yarn Workspaces Implementation + +The project uses **Yarn Workspaces** for proper monorepo dependency management, which was essential to resolve React version conflicts and ensure both platforms work correctly. + +**Root package.json configuration**: + +```json +{ + "workspaces": ["mobile"], + "dependencies": { + "react": "^19.0.0", + "react-dom": "^19.0.0" + } +} +``` + +**Key benefits of Yarn Workspaces**: + +- ✅ Eliminates multiple React instances that caused Metro bundler errors +- ✅ Hoists shared dependencies to root `node_modules/` +- ✅ Allows mobile-specific dependencies in `mobile/package.json` +- ✅ Resolves version conflicts between web and mobile platforms + +### Metro Configuration for Workspaces + +**mobile/metro.config.js**: + +```javascript +const config = getDefaultConfig(__dirname); +const projectRoot = __dirname; +const monorepoRoot = path.resolve(projectRoot, '..'); + +// Watch shared source folders +config.watchFolders = [path.resolve(monorepoRoot, 'src')]; + +// Configure resolver for workspace +config.resolver = { + ...config.resolver, + // Use hoisted dependencies from workspace root + nodeModulesPaths: [path.resolve(monorepoRoot, 'node_modules')], + platforms: ['native', 'android', 'ios'], + sourceExts: [...config.resolver.sourceExts, 'mjs', 'cjs'], + // Prioritize platform-specific files for React Native + resolverMainFields: ['react-native', 'main'], +}; + +// Support symlinks (used by Yarn workspaces) +config.resolver.symlinks = true; +``` + +### Vite Configuration for Cross-Platform + +**web/vite.config.ts** simplified configuration relies on platform file resolution: + +```typescript +export default defineConfig({ + resolve: { + // Platform-specific resolution - prioritize .web files over .native files + extensions: [ + '.web.tsx', + '.web.ts', + '.web.jsx', + '.web.js', + '.tsx', + '.ts', + '.jsx', + '.js', + ], + // Deduplicate React instances (critical for monorepo) + dedupe: ['react', 'react-dom'], + }, + optimizeDeps: { + include: ['@quilibrium/quilibrium-js-sdk-channels'], // Force pre-bundling for WSL compatibility + }, +}); +``` + +**Key Simplification**: The explicit React Native exclusions were removed because Vite's platform file resolution automatically selects `.web.tsx` files over `.native.tsx` files, preventing React Native from being included in web builds. + +### Platform-Specific File Resolution Strategy + +Critical fix for theme provider imports that caused build failures: + +**Theme Provider Hybrid Resolution**: + +```typescript +// src/components/primitives/theme/index.ts +// Vite will resolve ThemeProvider.web.tsx for web, Metro will resolve ThemeProvider.native.tsx for mobile +export { useTheme, ThemeProvider } from './ThemeProvider'; + +// src/components/primitives/theme/ThemeProvider.ts +// For React Native, export from .native file explicitly since Metro resolution isn't always reliable +export { useTheme, ThemeProvider } from './ThemeProvider.native'; +``` + +This hybrid approach ensures: + +- **Vite (Web)**: Uses platform resolution to find `.web.tsx` files +- **Metro (Mobile)**: Uses explicit `.native` exports for reliable resolution + +### Single Shared Dependencies + +- ✅ One `node_modules/` folder at root (via Yarn Workspaces) +- ✅ One `package.json` for shared dependencies +- ✅ One `yarn.lock` file +- ✅ Web and mobile share 90%+ of dependencies +- ✅ Mobile can have additional dependencies in `mobile/package.json` + +### Bundler Intelligence + +- **Vite (Web)**: Only bundles web-compatible dependencies, excludes React Native +- **Metro (Mobile)**: Only bundles mobile-compatible dependencies from workspace +- Platform-specific dependencies are automatically filtered by each bundler + +## Development Workflow + +### Starting Development + +```bash +# Web development (unchanged experience) +yarn dev +# → Opens http://localhost:5173 + +# Electron development +yarn electron:dev +# → Opens Electron window + +# Production build +yarn build +# → Creates dist/web/ with correct structure + +# Preview production build +yarn build:preview +# → Serves from dist/web/ on http://localhost:3000 +``` + +### Key Benefits Achieved + +1. **Zero Disruption**: Existing web developers see no workflow changes +2. **Clean Separation**: Platform-specific code isolated in `web/` and `mobile/` +3. **Maximum Sharing**: 90%+ codebase shared between platforms +4. **Proper Build Structure**: Each platform gets clean `dist/platform/` output +5. **Both Environments Work**: Dev server and production builds both functional +6. **Future-Proof**: Ready for mobile development without further restructuring + +## Technical Implementation Details + +### Platform File Resolution + +The build system automatically resolves platform-specific files: + +- `Button.web.tsx` → Used in web builds +- `Button.native.tsx` → Used in mobile builds (when implemented) +- `Button.tsx` → Fallback for shared logic + +### Shared Asset Access + +Assets in `public/` are accessible to both platforms: + +```typescript +// Works in both web and mobile +<img src="/quorumicon-blue.png" alt="Quorum" /> +``` + +### Environment-Specific Builds + +The Vite configuration intelligently handles different build contexts without requiring separate config files or build hacks. + +## Critical Fixes and Troubleshooting + +### Issues Resolved During Implementation + +**1. Metro Bundler "Cannot read property 'S' of undefined" Error** + +- **Root Cause**: Multiple React instances and version conflicts +- **Solution**: Implemented Yarn Workspaces to deduplicate dependencies +- **Fix**: Hoisted React to workspace root, configured Metro to use workspace node_modules + +**2. "useCrossPlatformTheme is not a function" Mobile Runtime Error** + +- **Root Cause**: Inconsistent theme hook naming across primitives +- **Solution**: Updated all primitive components to use unified `useTheme` import +- **Files Fixed**: Button, Text, Icon, Tooltip, ModalContainer, OverlayBackdrop, ResponsiveContainer + +**3. Vite Parsing React Native Flow Syntax Errors** + +- **Root Cause**: Vite attempting to parse React Native files for web builds +- **Solution**: Simplified to rely on platform file resolution (`.web.tsx` prioritized over `.native.tsx`) +- **Avoided**: Using react-native-web dependency (per lead developer preference) + +**4. "Element type is invalid" Mobile Error After Theme Fixes** + +- **Root Cause**: Metro bundler not reliably resolving platform-specific files +- **Solution**: Implemented hybrid resolution strategy with explicit .native exports +- **Implementation**: ThemeProvider.ts for Metro, index.ts for Vite platform resolution + +### Commands for Testing Cross-Platform Setup + +```bash +# Test web build +yarn dev +# → Should load without React Native errors + +# Test web production build +yarn build +# → Should exclude all React Native dependencies + +# Test mobile build (in mobile/ directory) +cd mobile && yarn expo start +# → Should resolve shared dependencies from workspace root + +# Verify workspace setup +yarn workspaces info +# → Should show mobile workspace properly configured +``` + +--- + +**Implementation Status**: ✅ Web Platform Complete and Production Ready +**Mobile Status**: 🚧 Test playground implemented, full mobile app development pending +**Next Phase**: Mobile application development using established cross-platform architecture diff --git a/.agents/docs/cryptographic-architecture.md b/.agents/docs/cryptographic-architecture.md new file mode 100644 index 000000000..731f72d25 --- /dev/null +++ b/.agents/docs/cryptographic-architecture.md @@ -0,0 +1,367 @@ +--- +type: doc +title: Cryptographic Architecture +status: done +created: 2025-12-20T00:00:00.000Z +updated: 2026-01-02T00:00:00.000Z +--- + +# Cryptographic Architecture + +> **AI-Generated**: May contain errors. Verify before use. +> Verified by agent 01-02-2026 + +This document explains the cryptographic protocols and key management used in Quorum. It focuses on the **mental model** needed to understand how encryption and signing work, rather than implementation details. + + +**Last Updated**: 2026-01-02 + +--- + +## Table of Contents + +1. [Key Hierarchy](#key-hierarchy) +2. [Double Ratchet vs Triple Ratchet](#double-ratchet-vs-triple-ratchet) +3. [Message Signing vs Encryption](#message-signing-vs-encryption) +4. [Key Storage Locations](#key-storage-locations) +5. [Key Compromise Impact](#key-compromise-impact) +6. [SDK Functions Reference](#sdk-functions-reference) + +--- + +## Key Hierarchy + +Quorum uses a hierarchical key structure with different keys for different purposes: + +``` +UserKeyset (Master Identity) + │ + ├── DeviceKeyset (Per-Device) + │ │ + │ └── Double Ratchet Sessions (Per-DM Contact) + │ + └── Space Inbox Keys (Per-Space) + │ + └── Triple Ratchet Sessions (Per-Space) +``` + +### Key Types + +| Key Type | Algorithm | Purpose | Scope | +|----------|-----------|---------|-------| +| **UserKeyset** | Ed448 | Master identity, cross-device | Per-user, all devices | +| **DeviceKeyset** | X448 + Ed448 | Device operations, DM encryption | Per-device | +| **Space Hub Key** | Ed448 | Hub broadcast envelope sealing/unsealing | Per-space, per-user | +| **Space Inbox Key** | Ed448 | Message signing within a Space | Per-space, per-user | +| **Space Config Key** | X448 | Sync envelope encryption (optional layer) | Per-space, per-user | +| **Ratchet State** | Symmetric (derived) | Message encryption state | Per-conversation | + +### What Each Key Does + +- **UserKeyset**: Your master identity. Used to prove you are "you" across all your devices. The public key is your address. + +- **DeviceKeyset**: Your device's capability to encrypt/decrypt DMs. Each device has its own keyset. When you add a new device, a new DeviceKeyset is generated. + +- **Space Hub Key**: Generated when you join a Space. Used for sealing/unsealing hub broadcast envelopes that reach all Space members. + +- **Space Inbox Key**: Generated when you join a Space. Used for signing messages to prove authorship within that Space. Separate from your UserKeyset for privacy (Spaces don't know your master identity). + +- **Space Config Key**: Optional X448 key for additional encryption layer on sync envelopes. Provides forward secrecy for peer-to-peer sync messages. + +- **Ratchet State**: Not a key itself, but the current state of a session. Contains derived symmetric keys that evolve with each message (forward secrecy). + +--- + +## Double Ratchet vs Triple Ratchet + +Quorum uses two different ratchet protocols depending on the conversation type: + +| Protocol | Used For | Why | +|----------|----------|-----| +| **Double Ratchet** | DMs (1:1 conversations) | Optimal for two-party communication | +| **Triple Ratchet** | Spaces (group conversations) | Efficient for multi-party groups | + +### Double Ratchet (DMs) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Double Ratchet (DM) │ +├─────────────────────────────────────────────────────────────┤ +│ At Encrypt Time: │ +│ ✅ Requires: deviceKeyset (private key) │ +│ ✅ Requires: counterparty's registration (public keys) │ +│ ✅ Requires: existing session state (if continuing) │ +│ │ +│ Session: Per-inbox (each of your devices has separate │ +│ sessions with each of their devices) │ +├─────────────────────────────────────────────────────────────┤ +│ SDK Functions: │ +│ - DoubleRatchetInboxEncrypt() │ +│ - DoubleRatchetInboxEncryptForceSenderInit() │ +│ - NewDoubleRatchetSenderSession() │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key Point**: Double Ratchet requires the private key at encryption time because it performs key agreement with the counterparty for each message chain. + +### Triple Ratchet (Spaces) + +``` +┌─────────────────────────────────────────────────────────────┐ +│ Triple Ratchet (Space) │ +├─────────────────────────────────────────────────────────────┤ +│ At Encrypt Time: │ +│ ❌ Does NOT require: any private key │ +│ ✅ Requires: ratchet_state (from IndexedDB) │ +│ │ +│ Session: Per-space (all members share the same session │ +│ state, established when joining the Space) │ +├─────────────────────────────────────────────────────────────┤ +│ SDK Function: │ +│ - TripleRatchetEncrypt() │ +└─────────────────────────────────────────────────────────────┘ +``` + +**Key Point**: Triple Ratchet does NOT require private keys at encryption time. The session was established when you joined the Space, and the `ratchet_state` contains all the symmetric keys needed for encryption. + +### Why This Matters + +This distinction is crucial for security analysis: + +1. **Code that handles DM encryption MUST have access to private keys** - there's no way around this +2. **Code that handles Space encryption should NOT need private keys** - if it does, something is wrong +3. **Private keys stored in plaintext are a security issue** - but only for code paths that actually need them + +--- + +## Message Signing vs Encryption + +**Signing** and **encryption** are separate operations with different purposes: + +### Signing (Authorship Proof) + +| Aspect | DMs | Spaces | +|--------|-----|--------| +| **Key Used** | UserKeyset | Space Inbox Key | +| **Key Location** | `KeyDB id=2` (encrypted) | `space_keys` store | +| **Optional?** | Yes (`skipSigning` param) | Depends on `isRepudiable` flag | + +**What signing does**: +- Creates Ed448 signature over the `messageId` +- Proves the message came from the claimed sender +- Enables non-repudiation (sender can't deny sending) + +**The `isRepudiable` flag** (Space setting): +- `false`: All messages MUST be signed (non-repudiable) +- `true`: Users can toggle signing off in the composer (repudiable/anonymous) + +### Encryption (Content Protection) + +| Aspect | DMs | Spaces | +|--------|-----|--------| +| **Protocol** | Double Ratchet | Triple Ratchet | +| **Key Used** | DeviceKeyset + session | Ratchet state only | +| **Optional?** | Never - always encrypted | Never - always encrypted | + +**What encryption does**: +- Protects message content from eavesdroppers +- Provides forward secrecy (past messages safe if keys compromised) +- Prevents tampering (authenticated encryption) + +### The Complete Message Flow + +``` +1. User types message +2. Generate messageId (SHA-256 of nonce + content) +3. [Optional] Sign messageId with Ed448 +4. Encrypt message with ratchet protocol +5. Send encrypted envelope to network +``` + +--- + +## Key Storage Locations + +All keys are stored in IndexedDB, but with different levels of protection: + +| Store | Contents | Encrypted? | Notes | +|-------|----------|------------|-------| +| **KeyDB id=2** | UserKeyset, DeviceKeyset | ✅ AES-GCM | SDK-managed, passkey-derived key | +| **space_keys** | Space Hub/Inbox/Config Keys | ❌ Plaintext | keyId: `hub`, `inbox`, or `config` | +| **encryption_states** | Ratchet session states | ❌ Plaintext | Contains symmetric keys | +| **user_config** | User preferences | ❌ Plaintext | May contain `spaceKeys` backup | + +### The SDK Encryption Pattern + +The SDK uses a double-layer encryption for identity keys at `KeyDB id=2`: + +``` +IndexedDB KeyDB id=2: +┌─────────────────────────────────────────────────────────────┐ +│ OUTER LAYER (encryptDataSaveKey) │ +│ keys: [AES key #1] ← Stored here, anyone can decrypt outer │ +│ encrypted: { │ +│ ┌─────────────────────────────────────────────────────┐ │ +│ │ INNER LAYER (passkey.encrypt) │ │ +│ │ iv: [...] │ │ +│ │ ciphertext: { │ │ +│ │ identity: UserKeyset ← Protected by Ed448 key │ │ +│ │ device: DeviceKeyset ← Protected by Ed448 key │ │ +│ │ } │ │ +│ └─────────────────────────────────────────────────────┘ │ +│ } │ +└─────────────────────────────────────────────────────────────┘ +``` + +- **Outer layer**: Convenience encryption, key stored alongside ciphertext +- **Inner layer**: Real protection, key derived from user's Ed448 private key (passkey-backed) + +--- + +## Key Compromise Impact + +Understanding what happens when different keys are compromised: + +| Key Compromised | Impact | Severity | +|-----------------|--------|----------| +| **UserKeyset** | Full identity theft, can impersonate across all devices | 🚨 **Critical** | +| **DeviceKeyset** | Can impersonate from that device, decrypt DMs | 🚨 **Critical** | +| **Space Inbox Key** | Can sign messages as you in that Space | ⚠️ **High** | +| **Ratchet State** | Can decrypt messages in that session | ⚠️ **High** | + +### Forward Secrecy Protection + +Even if keys are compromised: +- **Past messages remain safe** - ratchet advances make old keys unrecoverable +- **Future messages compromised** - until key rotation/new session +- **Other conversations unaffected** - separate session states + +--- + +## SDK Functions Reference + +### Double Ratchet (DMs) + +```typescript +// Encrypt to an existing session +secureChannel.DoubleRatchetInboxEncrypt( + deviceKeyset, // Your device's private key (required!) + [sessionState], // Existing session + messageJSON, // Message to encrypt + selfRegistration, // Your registration + displayName, + userIcon +) + +// Force new session initialization +secureChannel.DoubleRatchetInboxEncryptForceSenderInit( + deviceKeyset, // Your device's private key (required!) + [sessionState], + messageJSON, + selfRegistration, + displayName, + userIcon +) + +// Create brand new session +secureChannel.NewDoubleRatchetSenderSession( + deviceKeyset, // Your device's private key (required!) + selfAddress, + targetInbox, + deviceRegistration, + messageJSON, + selfRegistration, + displayName, + userIcon +) +``` + +### Triple Ratchet (Spaces) + +```typescript +// Encrypt for Space - NO private key needed! +secureChannel.TripleRatchetEncrypt( + JSON.stringify({ + ratchet_state: state, // From IndexedDB encryption_states + message: messageBytes + }) +) +``` + +### Signing + +```typescript +// Sign a message ID +channel_raw.js_sign_ed448( + privateKeyBase64, // From UserKeyset or Space Inbox Key + messageIdBase64 // SHA-256 hash of message +) + +// Verify a signature +channel_raw.js_verify_ed448( + publicKeyBase64, + messageIdBase64, + signatureBase64 +) +``` + +### Envelope Sealing (Space Messages) + +Two envelope types are used for Space communication: + +**Hub Envelope** - Broadcast to all Space members: + +```typescript +// Seal a hub broadcast envelope +secureChannel.SealHubEnvelope( + hubAddress, // Space hub address + hubKey, // Ed448 hub key { type, public_key, private_key } + messageJSON, // Message payload + configKey? // Optional X448 config key for additional encryption +) + +// Unseal a hub broadcast envelope +secureChannel.UnsealHubEnvelope( + hubKey, // Ed448 hub key + envelope, // Received envelope + configKey? // Optional X448 config key +) +``` + +**Sync Envelope** - Directed to specific peer: + +```typescript +// Seal a sync envelope (directed message) +secureChannel.SealSyncEnvelope( + targetInbox, // Recipient's inbox address + hubAddress, // Space hub address + hubKey, // Ed448 hub key + inboxKey, // Ed448 inbox key + messageJSON, // Message payload + configKey? // Optional X448 config key +) + +// Unseal a sync envelope +secureChannel.UnsealSyncEnvelope( + hubKey, // Ed448 hub key + envelope, // Received envelope + configKey? // Optional X448 config key +) +``` + +**When to use each:** +- **Hub Envelope**: Regular Space messages (broadcast to all members) +- **Sync Envelope**: Peer-to-peer sync messages (`sync-request`, `sync-info`, `sync-initiate`, `sync-manifest`, `sync-delta`) + +--- + +## Related Documentation + +- [Security Architecture](features/security.md) - Application security (XSS, permissions, etc.) +- [Data Management Architecture](data-management-architecture-guide.md) - Storage patterns +- [Action Queue](features/action-queue.md) - Background task processing + +--- + + +_Last Updated: 2026-01-02_ diff --git a/.agents/docs/data-management-architecture-guide.md b/.agents/docs/data-management-architecture-guide.md new file mode 100644 index 000000000..372ae7379 --- /dev/null +++ b/.agents/docs/data-management-architecture-guide.md @@ -0,0 +1,1012 @@ +--- +type: doc +title: Quorum Data Management Architecture +status: done +created: 2025-01-20T00:00:00.000Z +updated: 2026-01-02T00:00:00.000Z +--- + +# Quorum Data Management Architecture + +A comprehensive guide to data storage, management, and flow patterns in the Quorum desktop application. + + +**Last Updated**: 2025-12-18 + +## Table of Contents + +1. [Overview](#overview) +2. [Data Storage Architecture](#data-storage-architecture) +3. [Database Schema & Structure](#database-schema--structure) +4. [Message System](#message-system) +5. [User Data Management](#user-data-management) +6. [Space/Channel System](#spacechannel-system) +7. [Real-time Communication](#real-time-communication) +8. [Data Flow Patterns](#data-flow-patterns) +9. [Security & Encryption](#security--encryption) +10. [Configuration Management](#configuration-management) +11. [Search Implementation](#search-implementation) +12. [State Management](#state-management) + +--- + +## Overview + +Quorum uses a sophisticated multi-layer data architecture that combines local persistence, real-time communication, and end-to-end encryption. The application employs IndexedDB for primary data storage, localStorage for preferences, and a comprehensive caching system powered by TanStack Query. + +**Architectural Update: Service-Oriented Design** +Following recent refactoring, core MessageDB functionalities have been extracted into dedicated services located in `src/services/`. The `MessageDB` class now primarily acts as an orchestration layer, providing a unified interface to these specialized services, which handle specific business logic and data interactions. This enhances modularity, maintainability, and testability. + +### Key Components + +- **MessageDB Orchestrator**: Coordinates interactions with specialized services and IndexedDB (`src/db/messages.ts`, `src/components/context/MessageDB.tsx`) +- **Specialized Services**: Encapsulate business logic for specific domains (`src/services/MessageService.ts`, `src/services/SpaceService.ts`, `src/services/EncryptionService.ts`, `src/services/SyncService.ts`, `src/services/InvitationService.ts`, `src/services/ConfigService.ts`, `src/services/SearchService.ts`, `src/services/NotificationService.ts`, `src/services/ActionQueueService.ts`, `src/services/ActionQueueHandlers.ts`) +- **Context Providers**: Data management contexts (`src/components/context/`) +- **Query System**: TanStack Query hooks (`src/hooks/queries/`) +- **API Layer**: RESTful client (`src/api/`) +- **WebSocket Provider**: Real-time communication (`src/components/context/WebsocketProvider.tsx`) + +--- + +## Data Storage Architecture + +### 1. IndexedDB (Primary Storage) + +**Location**: `src/db/messages.ts` - `MessageDB` class + +`src/db/messages.ts` now primarily handles low-level IndexedDB operations, providing a robust and efficient persistent storage layer. Higher-level business logic and data manipulation are delegated to specialized services within `src/services/`. + +```typescript +class MessageDB { + private db: IDBDatabase | null = null; + private readonly DB_NAME = 'quorum_db'; + private readonly DB_VERSION = 6; + private searchIndices: Map<string, MiniSearch<SearchableMessage>> = new Map(); +} +``` + +**Object Stores**: + +- `messages` - All chat messages with full content +- `conversations` - Direct message conversations +- `encryption_states` - Encryption state management +- `spaces` - Space/server data +- `space_keys` - Encryption keys for spaces +- `space_members` - Space membership data +- `user_config` - User configuration and preferences +- `user_info` - User profile information +- `inbox_mapping` - Inbox address mappings +- `latest_states` - Latest encryption states +- `conversation_users` - Users in conversations +- `bookmarks` - User bookmarked messages +- `action_queue` - Persistent background task queue (see [Action Queue](features/action-queue.md)) + +### 2. localStorage (Preferences & Settings) + +**Usage Locations**: + +- `src/components/context/ThemeProvider.tsx` - Theme preferences +- `src/components/ui/AccentColorSwitcher.tsx` - Accent color selection +- `src/i18n/i18n.ts` - Language preferences +- Various components for temporary state + +**Stored Data**: + +```typescript +// Theme and visual preferences +localStorage.setItem('theme', 'dark|light|system'); +localStorage.setItem('accent-color', 'blue|red|green|...'); +localStorage.setItem('locale', 'en|es|fr|...'); + +// User-specific temporary data +localStorage.setItem(`userStatus_${address}`, status); +``` + +### 3. Memory Storage (React State) + +**Context Providers**: Manage in-memory state and provide data access patterns + +- **MessageDB Context** (`src/components/context/MessageDB.tsx`): Provides access to the specialized services (e.g., MessageService, SpaceService) for interacting with application data and business logic. +- **WebSocket Context** (`src/components/context/WebsocketProvider.tsx`) +- **Registration Context** (`src/components/context/RegistrationPersister.tsx`) +- **Theme Context** (`src/components/context/ThemeProvider.tsx`) +- **QuorumApi Context** (`src/components/context/QuorumApiContext.tsx`) +- **Responsive Layout Context** (`src/components/context/ResponsiveLayoutProvider.tsx`) +- **Sidebar Context** (`src/components/context/SidebarProvider.tsx`) +- **Mobile Context** (`src/components/context/MobileProvider.tsx`) +- **Modal Providers** (`src/components/context/ModalProvider.tsx`, `ConfirmationModalProvider.tsx`, `ImageModalProvider.tsx`, `EditHistoryModalProvider.tsx`) +- **Action Queue Context** (`src/components/context/ActionQueueContext.tsx`) - Queue stats and online/offline state + +### 4. Server Integration + +**API Client**: `src/api/baseTypes.ts` and `src/api/quorumApi.ts` + +```typescript +class QuorumApiClient { + baseUrl: string; // https://api.quorummessenger.com + webSocketUrl: string; // wss://api.quorummessenger.com/ws +} +``` + +--- + +## Database Schema & Structure + +### IndexedDB Schema (Version 6) + +#### Messages Store + +```typescript +{ + keyPath: 'messageId', + indexes: { + 'by_conversation_time': ['spaceId', 'channelId', 'createdDate'] + } +} +``` + +#### Conversations Store + +```typescript +{ + keyPath: ['conversationId'], + indexes: { + 'by_type_time': ['type', 'timestamp'] + } +} +``` + +#### Encryption States Store + +```typescript +{ + keyPath: ['conversationId', 'inboxId']; +} +``` + +#### Spaces Store + +```typescript +{ + keyPath: 'spaceId'; +} +``` + +#### Space Members Store + +```typescript +{ + keyPath: ['spaceId', 'user_address'], + indexes: { + 'by_address': ['user_address'] + } +} +``` + +#### User Config Store + +```typescript +{ + keyPath: 'address'; +} +``` + +### Database Operations + +**Message Retrieval**: + +```typescript +async getMessages({ + spaceId, + channelId, + cursor, + direction = 'backward', + limit = 100 +}): Promise<{ + messages: Message[]; + nextCursor: number | null; + prevCursor: number | null; +}> +``` + +**Space Management**: + +```typescript +async getSpace(spaceId: string): Promise<Space | undefined> +async saveSpace(space: Space): Promise<void> +async deleteSpace(spaceId: string): Promise<void> +``` + +--- + +## Message System + +### Message Types + +**Core Message Interface** (`src/api/quorumApi.ts`): + +```typescript +export type Message = { + channelId: string; + spaceId: string; + messageId: string; + digestAlgorithm: string; + nonce: string; + createdDate: number; + modifiedDate: number; + lastModifiedHash: string; + content: + | PostMessage + | EventMessage + | EmbedMessage + | ReactionMessage + | RemoveReactionMessage + | RemoveMessage + | JoinMessage + | LeaveMessage + | KickMessage + | UpdateProfileMessage + | StickerMessage + | PinMessage + | DeleteConversationMessage + | EditMessage; + reactions: Reaction[]; + mentions: Mentions; + publicKey?: string; + signature?: string; +}; +``` + +**Message Content Types**: + +- `PostMessage` - Regular text messages +- `EventMessage` - System events +- `EmbedMessage` - Rich content embeds +- `ReactionMessage` - Emoji reactions +- `StickerMessage` - Sticker/image messages +- `JoinMessage` - User join events +- `LeaveMessage` - User leave events +- `KickMessage` - User kick events +- `UpdateProfileMessage` - Profile updates +- `PinMessage` - Message pin/unpin actions +- `DeleteConversationMessage` - Conversation deletion events +- `EditMessage` - Message edit events + +### Message Flow + +1. **Creation**: User composes message in UI +2. **Encryption**: Message encrypted using Quilibrium SDK +3. **Local Storage**: Saved to IndexedDB immediately +4. **Network Send**: Transmitted via WebSocket +5. **Server Processing**: Server validates and distributes +6. **Recipient Delivery**: Real-time delivery to other clients +7. **Decryption**: Recipients decrypt using their keys + +### Message Submission + +**Location**: Handled by `MessageService` via `MessageDB Context` + +Message submission logic is now encapsulated within the `MessageService` (`src/services/MessageService.ts`), which is exposed through the `MessageDB Context`. This service handles the encryption, local storage, and network transmission of messages. + +### Encryption & Decryption + +**Encrypted Message Structure**: + +```typescript +interface EncryptedMessage { + encryptedContent: string; + inboxAddress: string; + timestamp: number; +} + +interface DecryptionResult { + decryptedMessage: Message; + newState: any; +} +``` + +--- + +## User Data Management + +### Authentication System + +**Passkey-Based Authentication**: Uses Web Authentication API through Quilibrium SDK + +**User Registration Flow**: + +1. Passkey creation/selection +2. User registration with server +3. Local keyset generation +4. Profile configuration + +**Registration Context** (`src/components/context/RegistrationPersister.tsx`): + +```typescript +type RegistrationContextValue = { + keyset: { + userKeyset: secureChannel.UserKeyset; + deviceKeyset: secureChannel.DeviceKeyset; + }; +}; +``` + +### User Configuration + +**User Config Structure**: + +```typescript +// NavItem represents either a standalone space or a folder containing spaces +export type NavItem = + | { type: 'space'; id: string } + | { + type: 'folder'; + id: string; + name: string; + spaceIds: string[]; + icon?: IconName; + color?: FolderColor; + createdDate: number; + modifiedDate: number; + }; + +export type UserConfig = { + address: string; + spaceIds: string[]; // Kept for backwards compatibility + items?: NavItem[]; // Single source of truth for ordering & folders + timestamp?: number; + nonRepudiable?: boolean; + allowSync?: boolean; + spaceKeys?: { + spaceId: string; + encryptionState: { + conversationId: string; + inboxId: string; + state: string; + timestamp: number; + }; + keys: { + keyId: string; + address?: string; + publicKey: string; + privateKey: string; + spaceId: string; + }[]; + }[]; + notificationSettings?: { + [spaceId: string]: NotificationSettings; + }; + bookmarks?: Bookmark[]; + deletedBookmarkIds?: string[]; +}; +``` + +### Profile Management + +**User Profile Data**: + +- Display name +- Profile picture URL +- Online status +- User preferences +- Space memberships + +**Profile Updates**: Managed through `UpdateProfileMessage` type and synchronized across all connected clients. + +--- + +## Space/Channel System + +### Hierarchical Structure + +**Space → Groups → Channels**: + +```typescript +export type Space = { + spaceId: string; + spaceName: string; + description?: string; + vanityUrl: string; + inviteUrl: string; + iconUrl: string; + bannerUrl: string; + defaultChannelId: string; + hubAddress: string; + createdDate: number; + modifiedDate: number; + isRepudiable: boolean; + isPublic: boolean; + saveEditHistory?: boolean; + groups: Group[]; + roles: Role[]; + emojis: Emoji[]; + stickers: Sticker[]; +}; + +export type Group = { + groupName: string; + channels: Channel[]; + icon?: string; + iconColor?: string; + iconVariant?: 'outline' | 'filled'; +}; + +export type Channel = { + channelId: string; + spaceId: string; + channelName: string; + channelTopic: string; + channelKey?: string; + createdDate: number; + modifiedDate: number; + mentionCount?: number; + mentions?: string; + isReadOnly?: boolean; + managerRoleIds?: string[]; // Roles that can manage read-only channels + isPinned?: boolean; // Pinned to top of group + pinnedAt?: number; // Timestamp for stack ordering + icon?: string; // Custom icon name + iconColor?: string; // Custom icon color + iconVariant?: 'outline' | 'filled'; +}; +``` + +### Permission System + +**Role-Based Access Control**: + +```typescript +export type Role = { + roleId: string; + displayName: string; + roleTag: string; + color: string; + members: string[]; + permissions: Permission[]; + isPublic?: boolean; // Whether the role is visible to other users +}; + +export type Permission = 'message:delete' | 'message:pin' | 'user:kick' | 'mention:everyone'; +``` + +### Space Operations + +**Space Creation** (`src/services/SpaceService.ts` via `MessageDB Context`): + +Space creation logic is now encapsulated within the `SpaceService` (`src/services/SpaceService.ts`), which is exposed through the `MessageDB Context`. This service handles key generation, API registration, initial channel setup, and data persistence for new spaces. + +**Space Membership**: Managed through `space_members` object store with efficient indexing for membership queries. + +### Space Data Synchronization + +**Location**: `src/services/SyncService.ts` + +Quorum uses a **peer-to-peer hash-based delta sync protocol** to synchronize Space data (messages, members, peer maps) between devices. This runs separately from user config sync (see [Config Sync System](config-sync-system.md)). + +**New Protocol (v2) - Hash-Based Delta Sync:** + +The sync protocol uses manifest hashes to efficiently identify what data needs to be exchanged: + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ SPACE SYNC PROTOCOL │ +├─────────────────────────────────────────────────────────────────┤ +│ 1. sync-request (broadcast) │ +│ → Includes SyncSummary with manifestHash │ +│ → Other peers compare hash to detect differences │ +│ │ +│ 2. sync-info (response) │ +│ → Peers with different/more data respond │ +│ → Includes their SyncSummary for comparison │ +│ │ +│ 3. sync-initiate (to best candidate) │ +│ → Sends full manifest (message digests) │ +│ → Triggers bidirectional sync │ +│ │ +│ 4. sync-manifest (response) │ +│ → Peer responds with their manifest │ +│ → Both sides compute deltas │ +│ │ +│ 5. sync-delta (chunked) │ +│ → Contains only missing/updated messages │ +│ → Includes member and peer map deltas │ +│ → Marked with isFinal flag for last chunk │ +└─────────────────────────────────────────────────────────────────┘ +``` + +**Key Types** (from `@quilibrium/quorum-shared`): + +- `SyncSummary` - Compact hash + counts for quick comparison +- `SyncManifest` - Per-message digests for precise diff computation +- `SyncDeltaPayload` - Efficient delta with new/updated/deleted items + +**Storage Adapter**: The `IndexedDBAdapter` (`src/adapters/indexedDbAdapter.ts`) wraps MessageDB to provide a platform-agnostic storage interface, enabling shared sync logic across desktop and other platforms. + +--- + +## Real-time Communication + +### WebSocket Provider + +**Location**: `src/components/context/WebsocketProvider.tsx` + +**Key Features**: + +- Persistent connection management +- Message queuing for reliability +- Automatic reconnection +- Error handling and retry logic + +```typescript +interface WebSocketContextValue { + connected: boolean; + setMessageHandler: (handler: MessageHandler) => void; + enqueueOutbound: (message: OutboundMessage) => void; + setResubscribe: (resubscribe: () => Promise<void>) => void; +} +``` + +### Message Processing + +**Inbound Message Flow**: + +1. Receive encrypted message via WebSocket +2. Queue message for processing +3. Decrypt message content +4. Update local IndexedDB +5. Invalidate React Query cache +6. Trigger UI updates + +**Outbound Message Flow**: + +1. Compose message in UI +2. Encrypt message content +3. Store locally first +4. Queue for WebSocket transmission +5. Send when connection available +6. Handle acknowledgments + +### Queue Management + +**Message Queuing System**: + +```typescript +const messageQueue = useRef<EncryptedMessage[]>([]); +const outboundQueue = useRef<OutboundMessage[]>([]); + +const processQueue = async () => { + // Process inbound messages by inbox + let inboxMap = new Map<string, EncryptedMessage[]>(); + + // Batch process messages per inbox + for (const [_, messages] of inboxMap) { + for (const message of messages) { + await handlerRef.current!(message); + } + } + + // Process outbound messages + while ((outbound = dequeueOutbound())) { + const messages = await outbound(); + // Send via WebSocket + } +}; +``` + +--- + +## Data Flow Patterns + +### Query Management (TanStack Query) + +**Query Organization**: Structured query hooks in `src/hooks/queries/` + +**Key Query Types**: + +- **Messages**: `useMessages`, `useInvalidateMessages` +- **Spaces**: `useSpaces`, `useSpace`, `useSpaceMembers` +- **Conversations**: `useConversations`, `useConversation` +- **User Data**: `useRegistration`, `useUserInfo` +- **Configuration**: `useConfig` + +**Query Key Patterns**: + +```typescript +// Message queries +buildMessagesKey({ spaceId, channelId, cursor }); + +// Space queries +buildSpaceKey({ spaceId }); +buildSpaceMembersKey({ spaceId }); + +// Conversation queries +buildConversationKey({ conversationId }); +``` + +### Caching Strategy + +**Multi-Layer Caching**: + +1. **React Query Cache** (Memory) + - Fast access to frequently used data + - Automatic background updates + - Optimistic updates + +2. **IndexedDB Cache** (Persistent) + - Long-term message storage + - Offline capability + - Large data sets + +3. **Search Index Cache** (Memory) + - MiniSearch indices for full-text search + - Context-specific search scopes + +### Data Invalidation + +**Cache Invalidation Patterns**: + +```typescript +// Invalidate specific queries +const invalidateMessages = useInvalidateMessages(); +invalidateMessages({ spaceId, channelId }); + +// Invalidate related data +const invalidateConversation = useInvalidateConversation(); +invalidateConversation({ conversationId }); +``` + +### Optimistic Updates + +**Pattern**: Update UI immediately, then sync with server + +```typescript +// Update local cache first +queryClient.setQueryData(messagesKey, (old) => [...old, newMessage]); + +// Then send to server +await submitMessage(...); + +// Handle errors by reverting if needed +``` + +### Background Action Queue + +For operations that benefit from persistence, retry logic, and offline support, the application uses a background action queue. This system: +- Provides instant UI feedback via optimistic updates +- Persists tasks to IndexedDB for crash recovery +- Processes tasks asynchronously with exponential backoff retry +- Handles offline gracefully (queues accumulate, process when online) + +See [Action Queue](features/action-queue.md) for detailed architecture and implementation. + +--- + +## Security & Encryption + +### End-to-End Encryption + +**Quilibrium SDK Integration**: `@quilibrium/quilibrium-js-sdk-channels` + +**Key Components**: + +- User keysets for identity +- Device keysets for sessions +- Per-space encryption keys +- Message-level encryption + +### Key Management + +**Keyset Structure**: + +```typescript +interface KeysetInfo { + userKeyset: secureChannel.UserKeyset; + deviceKeyset: secureChannel.DeviceKeyset; +} +``` + +**Key Storage**: + +- User keys: Stored in `user_config` object store +- Space keys: Stored in `space_keys` object store +- Device keys: Generated per session + +### Encryption States + +**State Tracking**: + +```typescript +interface EncryptionState { + state: string; + timestamp: number; + conversationId: string; + inboxId: string; + sentAccept?: boolean; +} +``` + +**State Management**: Tracks encryption state for each conversation to ensure proper message ordering and key rotation. + +### Security Practices + +- **Local Storage**: Only non-sensitive preference data +- **IndexedDB**: Encrypted message content stored locally +- **Memory**: Decrypted content only held temporarily +- **Network**: All communication over HTTPS/WSS + +--- + +## Configuration Management + +### Application Configuration + +**Location**: `src/config/` + +**Configuration Files**: + +- `config.ts` - Main configuration entry point +- `config.quorum.ts` - Production Quorum API endpoints +- `config.local.ts` - Local development endpoints + +**Production Config**: + +```typescript +export const getQuorumApiConfig = function () { + return { + quorumApiUrl: 'https://api.quorummessenger.com', + quorumWsUrl: 'wss://api.quorummessenger.com/ws', + apiVersion: 'v1', + langId: 'en-US', + }; +}; +``` + +### User Preferences + +**Theme Management** (`src/components/context/ThemeProvider.tsx`): + +- Light/dark/system theme selection +- Accent color customization +- Automatic system theme detection + +**Internationalization** (`src/i18n/`): + +- Multi-language support via Lingui +- Dynamic locale switching +- Persistent language preferences + +--- + +## Search Implementation + +### Search Architecture + +**Location**: `src/services/searchService.ts` + +**Search Service Features**: + +- Full-text search using MiniSearch +- Context-aware search (space/DM scoped) +- Debounced queries for performance +- Result caching and pagination + +```typescript +export class SearchService { + private messageDB: MessageDB; + private searchCache: Map<string, CachedSearchResult> = new Map(); + private debounceTimers: Map<string, NodeJS.Timeout> = new Map(); + private readonly CACHE_TTL = 5 * 60 * 1000; // 5 minutes +} +``` + +### Search Index Management + +**Searchable Message Structure**: + +```typescript +interface SearchableMessage { + id: string; + messageId: string; + spaceId: string; + channelId: string; + content: string; + senderId: string; + createdDate: number; + type: string; +} +``` + +**Index Initialization**: + +```typescript +// Per-context search indices +private searchIndices: Map<string, MiniSearch<SearchableMessage>> = new Map(); + +// Initialize index for space/conversation +await this.messageDB.initializeSearchIndices(); +``` + +### Search Context + +**Context Types**: + +```typescript +interface SearchContext { + type: 'space' | 'dm'; + spaceId?: string; + channelId?: string; + conversationId?: string; +} +``` + +**Search Execution**: + +- Scope search to current context +- Return ranked results with highlighting +- Cache results for performance + +--- + +## State Management + +### Context Provider Architecture + +**Primary Contexts**: + +1. **MessageDB Context** - Orchestrates access to specialized services for core data operations and business logic. +2. **WebSocket Context** - Real-time communication +3. **Registration Context** - User authentication +4. **Theme Context** - UI preferences +5. **Responsive Layout Context** - UI state management +6. **QuorumApi Context** - API client access +7. **Sidebar Context** - Sidebar state management +8. **Mobile Context** - Mobile-specific state +9. **Modal Providers** - Various modal management contexts + +### State Synchronization + +**Cross-Component State Flow**: + +1. User action triggers state change +2. Context provider updates internal state +3. React Query cache updated/invalidated +4. IndexedDB persistence (if needed) +5. WebSocket message sent (if needed) +6. UI components re-render automatically + +### Error Handling + +**Error Boundary Implementation**: + +```typescript +class ErrorBoundary extends React.Component { + static getDerivedStateFromError(error: any) { + return { hasError: true }; + } + + componentDidCatch(error: any, info: any) { + // Log error for debugging + console.log(error, info); + } +} +``` + +**Network Error Handling**: + +- Automatic retry with exponential backoff +- Offline state detection +- Queue management for failed operations +- User notifications for persistent errors + +--- + +## Performance Considerations + +### Database Optimization + +**Indexing Strategy**: + +- Composite indexes for common query patterns +- Time-based ordering for message pagination +- User-based indexes for membership queries + +**Query Optimization**: + +- Cursor-based pagination for large datasets +- Lazy loading of message content +- Efficient range queries using IDBKeyRange + +### Caching Strategy + +**Cache Layers**: + +1. **Browser Cache** - Static assets +2. **React Query** - API responses and computed data +3. **IndexedDB** - Persistent application data +4. **Search Index** - Pre-computed search indices + +### Network Optimization + +**WebSocket Management**: + +- Connection pooling and reuse +- Message batching for efficiency +- Compression for large payloads +- Heartbeat mechanism for connection health + +--- + +## Development Guidelines + +### Data Access Patterns + +**Recommended Patterns**: + +1. Use React Query hooks for server state +2. Use Context providers for cross-component state +3. Use IndexedDB directly only through MessageDB class +4. Implement optimistic updates for better UX + +### Error Handling + +**Best Practices**: + +- Always handle async operation failures +- Provide meaningful user feedback +- Log errors for debugging +- Implement retry mechanisms for transient failures + +### Security Considerations + +**Development Guidelines**: + +- Never store sensitive data in localStorage +- Always encrypt data before network transmission +- Validate all user inputs +- Use Content Security Policy headers +- Implement proper authentication flows + +--- + +## File Reference Index + +### Core Data Management Files + +- **`src/db/messages.ts`** - Low-level IndexedDB interface and schema management. +- **`src/components/context/MessageDB.tsx`** - MessageDB React context, providing access to specialized services. +- **`src/services/`** - Directory containing specialized services for business logic: + - `MessageService.ts` - Handles message-related operations. + - `SpaceService.ts` - Manages space creation, membership, and operations. + - `EncryptionService.ts` - Encapsulates encryption/decryption logic. + - `SyncService.ts` - Manages Space data synchronization using hash-based delta protocol. + - `InvitationService.ts` - Handles invitation-related logic. + - `ConfigService.ts` - Manages user and application configuration. + - `SearchService.ts` - Implements full-text search functionality. + - `NotificationService.ts` - Manages application notifications. + - `ActionQueueService.ts` - Persistent background task queue with retry logic. + - `ActionQueueHandlers.ts` - Task handlers for each action type. +- **`src/adapters/`** - Storage adapters for cross-platform compatibility: + - `indexedDbAdapter.ts` - Wraps MessageDB to conform to `@quilibrium/quorum-shared` StorageAdapter interface. +- **`src/components/context/WebsocketProvider.tsx`** - WebSocket management +- **`src/components/context/RegistrationPersister.tsx`** - User authentication +- **`src/api/baseTypes.ts`** - API client implementation +- **`src/api/quorumApi.ts`** - Type definitions and API endpoints + +### Query Management Files + +- **`src/hooks/queries/`** - TanStack Query hooks directory +- **`src/hooks/queries/messages/`** - Message-related queries +- **`src/hooks/queries/spaces/`** - Space-related queries +- **`src/hooks/queries/conversations/`** - Conversation queries +- **`src/hooks/queries/search/`** - Search queries + +### Configuration Files + +- **`src/config/config.ts`** - Main configuration +- **`src/config/config.quorum.ts`** - Production endpoints +- **`src/components/context/ThemeProvider.tsx`** - Theme management +- **`src/i18n/`** - Internationalization + +--- + +_Last updated: 2026-01-02_ diff --git a/.agents/docs/development/unused-dependencies-analysis.md b/.agents/docs/development/unused-dependencies-analysis.md new file mode 100644 index 000000000..1c33bbb13 --- /dev/null +++ b/.agents/docs/development/unused-dependencies-analysis.md @@ -0,0 +1,78 @@ +--- +type: doc +title: Unused Dependencies Analysis +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Unused Dependencies Analysis + +## Safe to Remove (Verified) + +These dependencies were analyzed and confirmed to be unused: + +### Main Dependencies + +- `electron-is-dev` - Not used in source code +- `expo-media-library` - Not used in source code +- `expo-status-bar` - Not used in source code +- `@react-navigation/bottom-tabs` - Not used (project doesn't use React Navigation) +- `@react-navigation/native` - Not used (project doesn't use React Navigation) +- `@react-navigation/stack` - Not used (project doesn't use React Navigation) +- `react-native-document-picker` - Not used in source code + +### Dev Dependencies + +- `@vitejs/plugin-basic-ssl` - Not used in Vite config +- `autoprefixer` - Not used (no PostCSS config referencing it) +- `postcss` - Not used (no PostCSS config) +- `unenv` - Not used in Vite config + +## DO NOT REMOVE (Required) + +### Build-time Dependencies + +- `emoji-datasource-apple` - Used in Vite config for static copy +- All browserify polyfills (`crypto-browserify`, `buffer`, etc.) - Required for Vite build +- All React Native platform dependencies - Used for platform-specific builds + +### Dynamic/Platform Dependencies + +- `expo-*` packages in general - May be used in mobile builds +- `react-native-*` packages - May be used in .native.tsx files +- Polyfill packages - Required for web compatibility + +## Missing Dependencies (Need to Add) + +- `@lingui/core` - Used throughout the app but not in package.json +- `@gorhom/bottom-sheet` - Used in Modal.native.gorhom.tsx + +## Commands to Clean Up + +```bash +# Remove confirmed unused dependencies +yarn remove electron-is-dev expo-media-library expo-status-bar +yarn remove @react-navigation/bottom-tabs @react-navigation/native @react-navigation/stack +yarn remove react-native-document-picker + +# Remove unused dev dependencies +yarn remove @vitejs/plugin-basic-ssl autoprefixer postcss unenv + +# Add missing dependencies +yarn add @lingui/core @gorhom/bottom-sheet +``` + +## Verification Steps + +After removal, verify the app still works: + +1. `yarn build` - Web build should succeed +2. `cd mobile && yarn install && expo start` - Mobile build should succeed +3. `yarn electron:build` - Electron build should succeed +4. Test key backup functionality on both platforms + +--- + +_Analysis Date: 2025-01-11_ +_Tool Used: depcheck + manual verification_ diff --git a/.agents/docs/expo-dev-testing-guide.md b/.agents/docs/expo-dev-testing-guide.md new file mode 100644 index 000000000..a5ee32ad5 --- /dev/null +++ b/.agents/docs/expo-dev-testing-guide.md @@ -0,0 +1,190 @@ +--- +type: doc +title: Expo Dev Testing Guide +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-09-03T00:00:00.000Z +--- + +# Expo Dev Testing Guide + +Quick setup guide for testing Quorum mobile app with Expo Dev Client. + +## Prerequisites + +### Required Tools + +- **Node.js** 18+ +- **Yarn** (NOT npm!) +- **Git** +- **Android Studio** (all platforms) or **Xcode** (macOS only) + +### Android Studio Setup & Verification + +After installing Android Studio, verify your environment: + +#### 1. Check Environment Variables + +```bash +# Windows (PowerShell) +echo $env:ANDROID_HOME +echo $env:Path + +# macOS/Linux +echo $ANDROID_HOME +echo $PATH +``` + +#### 2. Set Missing Variables (if needed) + +```bash +# Windows (run as Administrator) +setx ANDROID_HOME "%LOCALAPPDATA%\Android\Sdk" +setx PATH "%PATH%;%ANDROID_HOME%\platform-tools;%ANDROID_HOME%\tools" + +# macOS/Linux (add to ~/.bashrc or ~/.zshrc) +export ANDROID_HOME=$HOME/Android/Sdk +export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/tools +``` + +#### 3. Verify Android Tools + +```bash +# These commands should work after setup +adb --version +emulator -version +``` + +### Install Expo Tools + +```bash +yarn global add expo-cli eas-cli +``` + +## Setup Steps + +### 1. Clone & Install + +```bash +git clone https://github.com/quilibrium/quorum-desktop.git +cd quorum-desktop +yarn install # MUST use yarn, not npm +``` + +### 2. First-Time Device Setup + +#### Android + +```bash +cd mobile +yarn expo run:android # Builds and installs dev client (up to 30 min first time) +``` + +#### iOS (macOS only) + +```bash +cd mobile +yarn expo run:ios # Builds and installs dev client (up to 30 min first time) +``` + +## Running the App + +**IMPORTANT**: The emulator must be started manually BEFORE running the dev server. + +### Step-by-step Workflow + +1. **Start the emulator first** (choose one): + + ```bash + # Android emulator + emulator @AVD_NAME + + # iOS simulator (macOS only) + open -a Simulator + ``` + +2. **Start the dev server** (from project root): + + ```bash + # Start dev server + yarn mobile + + # With tunnel (for remote devices) + yarn mobile:tunnel + + # Clear cache if issues + yarn mobile:clear + + # Platform-specific + yarn mobile:android + yarn mobile:ios + ``` + +3. **The app should open automatically** in the running emulator + +**Note**: If you run a full build (`yarn expo run:android` or `yarn expo run:ios`), the emulator will be started automatically during the build process. + +## Device Requirements + +### Physical Android Device + +1. Enable Developer Options & USB Debugging +2. Connect via USB +3. Trust computer when prompted + +### Android Emulator + +```bash +# List available emulators +emulator -list-avds + +# Start specific emulator (replace AVD_NAME with your emulator name) +emulator @AVD_NAME + +# Or start default emulator +emulator @Pixel_3a_API_30_x86 +``` + +### iOS Simulator (macOS only) + +```bash +# List available simulators +xcrun simctl list devices + +# Open simulator app +open -a Simulator + +# Boot specific device (optional) +xcrun simctl boot "iPhone 14" +``` + +## Quick Troubleshooting + +| Issue | Solution | +| --------------------- | --------------------------------------------------- | +| Metro bundler crash | `yarn mobile:clear` | +| Build fails | Clean build: `cd mobile/android && ./gradlew clean` | +| Network issues | Use tunnel: `yarn mobile:tunnel` | +| Android SDK not found | Set `ANDROID_HOME` environment variable | + +## Testing Commands + +All from project root: + +- `yarn mobile` - Start standard dev server +- `yarn mobile:tunnel` - Use tunnel for remote access +- `yarn mobile:clear` - Clear cache and restart +- `yarn mobile:android` - Open on Android +- `yarn mobile:ios` - Open on iOS +- `yarn mobile:build` - Build Android APK + +## Important Notes + +- **ALWAYS use yarn**, never npm +- Shake device or press `Cmd+D`/`Cmd+M` for dev menu +- First build takes up to 30 minutes +- Both devices must be on same network (unless using tunnel) + +--- + +_Last updated: 2025-09-03 15:45 UTC_ diff --git a/.agents/docs/features/action-queue.md b/.agents/docs/features/action-queue.md new file mode 100644 index 000000000..c54a49631 --- /dev/null +++ b/.agents/docs/features/action-queue.md @@ -0,0 +1,769 @@ +--- +type: doc +title: Action Queue +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-04T00:00:00.000Z +--- + +# Action Queue + +> **AI-Generated**: May contain errors. Verify before use. + +## Overview + +The Action Queue is a persistent background task processing system that handles user operations asynchronously. It solves **two main problems**: + +### Problem 1: UI Freezing (Blocking Operations) + +**Before**: Heavy operations (encryption, API calls) blocked the main thread, causing the UI to freeze for seconds during saves. + +**After**: The main thread stays responsive. Operations complete instantly from the user's perspective: + +1. **Validate input** - instant +2. **Update UI immediately** (optimistic update) - instant +3. **Queue action to IndexedDB** - instant (~1ms) +4. **Return to user** - instant + +Heavy work (crypto, network calls) happens in the background processor, not blocking the UI. + +**Examples of improved UX:** +- Settings modals close immediately after clicking Save (previously froze for seconds) +- Folder operations (create, edit, delete, reorder) are instant +- Space settings save without delay +- Message sending feels instantaneous + +### Problem 2: Offline Support + +Actions are persisted to IndexedDB and survive crashes/refreshes. When connectivity is restored, queued actions are automatically processed with exponential backoff retry. + +### Summary of Benefits + +- **Instant UI feedback** through optimistic updates +- **No more UI freezing** - heavy operations run in background +- **Crash recovery** via IndexedDB persistence +- **Offline support** by queuing actions until connectivity is restored +- **Automatic retries** with exponential backoff for transient failures +- **Visibility** through offline banner counter ("n actions queued") + +## Offline Support Summary + +Not all actions can be queued for offline use. This table shows what works offline: + +### Actions That Work Offline (Queued) + +| Action | Category | Notes | +|--------|----------|-------| +| Send space message | Space | ✅ Fully queued, survives refresh | +| Reactions (space) | Space | ✅ Fully queued, survives refresh | +| Pin/unpin message | Space | ✅ Fully queued, survives refresh | +| Edit message (space) | Space | ✅ Fully queued, survives refresh | +| Delete message (space) | Space | ✅ Fully queued, survives refresh | +| Save user config | Global | ✅ Fully queued, survives refresh | +| Update space settings | Global | ✅ Fully queued, survives refresh | +| Kick/mute/unmute user | Moderation | ✅ Fully queued, survives refresh | +| Channel mute/unmute | Global | ✅ Fully queued, survives refresh (via save-user-config) | +| DM conversation mute/unmute | Global | ✅ Fully queued, survives refresh (via save-user-config) | +| DM favorites | Global | ✅ Fully queued, survives refresh (via save-user-config) | +| Send DM | DM | ⚠️ Queued only when offline with existing sessions (see below) | +| Reactions (DM) | DM | ⚠️ Queued only when offline with existing sessions (see below) | +| Edit/delete DM | DM | ⚠️ Queued only when offline with existing sessions (see below) | + +### DM Offline-Only Routing + +**DM actions use a hybrid routing strategy** implemented in [MessageService.ts](src/services/MessageService.ts), [useMessageActions.ts](src/hooks/business/messages/useMessageActions.ts), and [MessageEditTextarea.tsx](src/components/message/MessageEditTextarea.tsx): + +| Scenario | Path | Why | +|----------|------|-----| +| **Online** | Legacy path | Handles new devices, creates new sessions, cleans stale states | +| **Offline + existing sessions** | Action Queue | Persisted, crash-resilient, works with cached encryption states | +| **Offline + new conversation** | Legacy path | Fails immediately (expected - can't create sessions offline) | + +**Implementation** checks `navigator.onLine` at enqueue time: + +```typescript +const isOnline = navigator.onLine; +if (ENABLE_DM_ACTION_QUEUE && hasEstablishedSessions && !isOnline) { + // Use Action Queue (offline only) +} else { + // Use legacy path (online or new conversation) +} +``` + +**Why offline-only?** The Action Queue doesn't store `counterparty.device_registrations` (for security - see [007-plaintext-private-keys-fix.md](../../reports/action-queue/007-plaintext-private-keys-fix.md)). This means it cannot create new sessions for counterparty's new devices. By routing through legacy path when online, new devices are always handled correctly. + +See [010-dm-registration-inbox-mismatch-fix.md](../../reports/action-queue/010-dm-registration-inbox-mismatch-fix.md) for the full analysis. + +### Why Space Messages Work Fully Offline But DM Messages Don't + +**Space messages** work fully offline because all encryption data is stored in IndexedDB: +- Encryption keys are persisted locally when you join a space +- You can send messages offline, close the app, reopen it, and they'll still be queued +- Messages are sent automatically when you come back online + +**DM messages** have additional constraints: +- **Encryption states** are stored in IndexedDB (work offline for established sessions) +- **BUT** the Action Queue can't create *new* sessions (requires counterparty registration data) +- **AND** stale encryption states are cleaned up on DM page load (see [DirectMessage.tsx:198-245](src/components/direct/DirectMessage.tsx#L198-L245)) + +| Scenario | Space Message | DM Message | +|----------|---------------|------------| +| Online: send message | ✅ Via Action Queue | ✅ Via Legacy path | +| Offline: existing conversation | ✅ Via Action Queue | ✅ Via Action Queue | +| Offline: new conversation | ✅ Via Action Queue | ❌ Fails (expected) | +| App restart with pending | ✅ Resumes | ✅ Resumes (if sessions exist) | + +**Why the difference?** Space encryption uses static keys. DM encryption uses Double Ratchet with per-device sessions that require counterparty registration data to create. + +### Actions That Require Online (Not Queued) + +| Action | Reason | UI Behavior | +|--------|--------|-------------| +| Create space | Server generates space ID | Warning callout + disabled button | +| Join space | Requires server handshake | N/A | +| Start new DM conversation | Needs counterparty registration | Fails with error | +| Delete conversation | Not yet integrated (see [Potential Future Actions](#potential-future-actions)) | N/A | +| Delete space | Not yet integrated (see [Potential Future Actions](#potential-future-actions)) | N/A | + +### Why Some Actions Can't Be Queued + +1. **Server-generated IDs**: Space creation requires the server to generate the `spaceId`. The client can't create this locally. + +2. **New DM sessions require registration data**: Creating a new Double Ratchet session requires the counterparty's device registration (public keys, inbox addresses). The Action Queue deliberately doesn't store this data for security reasons. New conversations must be started online. + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────┐ +│ USER ACTION │ +│ (Send message, save config, etc.) │ +└─────────────────────┬───────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ MAIN THREAD (Stays Responsive) │ +│ 1. Validate input │ +│ 2. Update UI immediately (optimistic) │ +│ 3. Queue action to IndexedDB │ +│ 4. Return to user (instant!) │ +└─────────────────────┬───────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ PERSISTENT QUEUE (IndexedDB) │ +│ - Task context stored WITHOUT private keys │ +│ - Survives crashes/refreshes │ +│ - Status tracking (pending/processing/failed/completed) │ +└─────────────────────┬───────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ BACKGROUND PROCESSOR │ +│ 1. Keyset gate (waits for auth to complete) │ +│ 2. Status-based gating (multi-tab safety) │ +│ 3. Get next batch of pending tasks │ +│ 4. Execute task (crypto, API calls, WebSocket sends) │ +│ 5. Update status, handle retries │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Key Components + +| File | Purpose | +|------|---------| +| [ActionQueueService.ts](src/services/ActionQueueService.ts) | Core queue service - enqueuing, processing, retry logic | +| [ActionQueueHandlers.ts](src/services/ActionQueueHandlers.ts) | Task handlers for each action type | +| [ActionQueueContext.tsx](src/components/context/ActionQueueContext.tsx) | React context for queue state | +| [OfflineBanner.tsx](src/components/ui/OfflineBanner.tsx) | Offline status indicator with queue count | +| [actionQueue.ts](src/types/actionQueue.ts) | Type definitions | + +### Service Initialization + +Services are initialized in order to avoid circular dependencies: + +```typescript +// 1. Create MessageDB (no deps) +const messageDB = new MessageDB(); + +// 2. Create ActionQueueService (only needs messageDB) +const actionQueueService = new ActionQueueService(messageDB); + +// 3. Create other services +const messageService = new MessageService({ messageDB, actionQueueService }); + +// 4. Create handlers (needs all services) +const handlers = new ActionQueueHandlers({ messageDB, messageService, ... }); + +// 5. Wire handlers back to queue service +actionQueueService.setHandlers(handlers); + +// 6. Start processing +actionQueueService.start(); + +// 7. After passkey auth completes (in MessageDB.tsx): +actionQueueService.setUserKeyset({ deviceKeyset, userKeyset }); +// Queue now starts processing tasks that require keys +``` + +**Important**: The queue waits for `setUserKeyset()` before processing tasks that require cryptographic keys. This ensures keys are never stored in IndexedDB - they're pulled from memory at processing time. + +--- + +## Space vs DM: Two Different Encryption Systems + +The action queue handles two fundamentally different messaging systems with different encryption protocols. + +### Encryption Protocol Comparison + +| Aspect | Space Messages | DM Messages | +|--------|----------------|-------------| +| **Protocol** | Triple Ratchet | Double Ratchet | +| **Encryption States** | 1 per space | N per device inbox | +| **Recipients** | All space members via Hub | Specific device inboxes | +| **Transport** | `sendHubMessage()` | `sendDirectMessages()` (WebSocket) | +| **Identity** | Always known (space members) | Hidden until reply | +| **ID Structure** | `spaceId` ≠ `channelId` | `spaceId` === `channelId` (both = address) | + +### DM Detection + +Code detects DMs using this pattern: +```typescript +const isDM = spaceId === channelId; +``` + +For DMs, the counterparty's wallet address is used for both `spaceId` and `channelId`. + +--- + +## Supported Action Types + +For a summary of which actions work offline, see [Offline Support Summary](#offline-support-summary) above. + +| Action Type | Category | Encryption | Source Location | +|-------------|----------|------------|-----------------| +| `send-channel-message` | Space | Triple Ratchet | `MessageService.ts` | +| `reaction` | Space | Triple Ratchet | `useMessageActions.ts` | +| `pin-message` | Space | Triple Ratchet | `usePinnedMessages.ts` | +| `unpin-message` | Space | Triple Ratchet | `usePinnedMessages.ts` | +| `edit-message` | Space | Triple Ratchet | `MessageEditTextarea.tsx` | +| `delete-message` | Space | Triple Ratchet | `useMessageActions.ts` | +| `send-dm` | DM | Double Ratchet | `MessageService.ts` | +| `reaction-dm` | DM | Double Ratchet | `useMessageActions.ts` | +| `delete-dm` | DM | Double Ratchet | `useMessageActions.ts` | +| `edit-dm` | DM | Double Ratchet | `MessageEditTextarea.tsx` | +| `save-user-config` | Global | None | `useUserSettings.ts`, `useFolderManagement.ts`, `useFolderDragAndDrop.ts`, `useDeleteFolder.ts`, `useSpaceDragAndDrop.ts`, `useChannelMute.ts`, `useDMMute.ts`, `useDMFavorites.ts` | +| `update-space` | Global | None | `useSpaceManagement.ts` | +| `kick-user` | Moderation | None | `useUserKicking.ts` | +| `mute-user` | Moderation | None | `useUserMuting.ts` | +| `unmute-user` | Moderation | None | `useUserMuting.ts` | + +> **Note**: The `save-user-config` action type handles many UI operations because user configuration (stored in IndexedDB and synced to server) includes: +> - User profile settings (display name, avatar, preferences) +> - Folder structure (create, edit, delete, reorder folders) +> - Space organization (drag spaces into/out of folders, reorder spaces) +> - Channel mute settings (mute/unmute channels, show/hide muted channels toggle) +> - DM conversation mute settings (mute/unmute individual DM conversations) +> - DM favorites (add/remove favorite conversations) +> +> All these operations use the same optimistic update + queue pattern, which is why folder, space organization, channel mute, and DM favorite operations are instant and non-blocking. + +--- + +## Handler Architecture + +### Space Message Handler Flow + +``` +User clicks Send in Space channel + │ + ▼ +MessageService.submitChannelMessage() + ├─► Generate messageId (nonce) + ├─► Sign message with Ed448 ← HAPPENS ONCE + ├─► Add to React Query cache (sendStatus: 'sending') + └─► actionQueueService.enqueue('send-channel-message', context) + │ + ▼ +ActionQueueHandlers.sendChannelMessage.execute() + ├─► Check space/channel still exists + ├─► Get Triple Ratchet encryption state + ├─► Encrypt with TripleRatchetEncrypt() + ├─► Send via sendHubMessage() + ├─► Save updated encryption state + └─► Update message status to 'sent' +``` + +### DM Message Handler Flow + +DM messages use **offline-only routing** - Action Queue is only used when offline: + +``` +User clicks Send in DM conversation + │ + ▼ +MessageService.submitMessage() (for DMs) + ├─► Generate messageId (nonce) + ├─► Sign message with Ed448 ← HAPPENS ONCE + ├─► Add to React Query cache (sendStatus: 'sending') + ├─► Check: hasEstablishedSessions && !navigator.onLine? + │ + ├─► [If ONLINE] → Legacy path (handles new devices) + │ └─► Full Double Ratchet flow with stale cleanup + │ + └─► [If OFFLINE + sessions exist] → Action Queue + └─► actionQueueService.enqueue('send-dm', context) + │ + ▼ + ActionQueueHandlers.sendDm.execute() + ├─► Get encryption states from IndexedDB + ├─► For each existing session: + │ └─► Encrypt with DoubleRatchetInboxEncrypt() + ├─► Save updated encryption states + ├─► Send all messages via sendDirectMessages() + └─► Update message status to 'sent' +``` + +**Why offline-only?** The Action Queue only stores `selfUserAddress` (not full registration). It cannot create new sessions for counterparty's new devices. Online routing through legacy path ensures new devices are always handled. + +### DM Secondary Actions (Reactions, Deletes, Edits) + +DM secondary actions (reactions, deletes, edits) also use **offline-only routing**: + +``` +User adds reaction in DM + │ + ▼ +useMessageActions.handleReaction() + ├─► Optimistic UI update (React Query cache) + ├─► Persist to IndexedDB + ├─► Check isDM = spaceId === channelId → true + ├─► Check: navigator.onLine? + │ + ├─► [If ONLINE] → Legacy path via onSubmitMessage() + │ + └─► [If OFFLINE] → buildDmActionContext() + enqueue + └─► actionQueueService.enqueue('reaction-dm', context) + │ + ▼ + ActionQueueHandlers.reactionDm.execute() + └─► encryptAndSendDm(address, reactionMessage, ...) + ├─► Double Ratchet encryption per existing inbox + └─► sendDirectMessages() +``` + +The same pattern applies to `edit-dm` in [MessageEditTextarea.tsx](src/components/message/MessageEditTextarea.tsx) and `delete-dm` in [useMessageActions.ts](src/hooks/business/messages/useMessageActions.ts). + +### Context Requirements by Action Type + +#### Space Actions Context +```typescript +{ + spaceId: string; + channelId: string; + signedMessage?: Message; // For send-channel-message + reactionMessage?: object; // For reaction + editMessage?: object; // For edit-message + deleteMessage?: object; // For delete-message + currentPasskeyInfo: object; // User identity +} +``` + +#### DM Actions Context +```typescript +{ + address: string; // Counterparty wallet address + signedMessage?: Message; // For send-dm + reactionMessage?: object; // For reaction-dm + editMessage?: object; // For edit-dm + deleteMessage?: object; // For delete-dm + messageId?: string; // For edit-dm (to check if still exists) + selfUserAddress: string; // Sender's user address (only field needed from self) + senderDisplayName?: string; // For identity revelation + senderUserIcon?: string; // For identity revelation + // NOTE: keyset NOT stored (security - pulled from memory at processing time) + // NOTE: counterparty NOT stored (security - uses existing encryption states) + // See: 007-plaintext-private-keys-fix.md, 009-dm-offline-registration-persistence-fix.md +} +``` + +--- + +## Deduplication Keys + +Each action type uses a unique deduplication key to prevent duplicate actions: + +| Action Type | Dedupe Key Format | +|-------------|-------------------| +| `send-channel-message` | `send:${spaceId}:${channelId}:${messageId}` | +| `send-dm` | `send-dm:${address}:${messageId}` | +| `reaction` | `reaction:${spaceId}:${channelId}:${messageId}:${emoji}:${userAddress}` | +| `reaction-dm` | `reaction-dm:${address}:${messageId}:${emoji}` | +| `delete-message` | `delete:${spaceId}:${channelId}:${messageId}` | +| `delete-dm` | `delete-dm:${address}:${messageId}` | +| `edit-message` | `edit:${spaceId}:${channelId}:${messageId}` | +| `edit-dm` | `edit-dm:${address}:${messageId}` | + +--- + +## Legacy Path vs Action Queue + +DM actions use **offline-only routing**: Legacy path when online, Action Queue when offline. + +### When Each Path Is Used + +| Condition | Path | Why | +|-----------|------|-----| +| Online + any DM action | Legacy | Handles new devices, creates new sessions | +| Offline + existing sessions | Action Queue | Persisted, crash-resilient | +| Offline + new conversation | Legacy | Fails (expected - can't create sessions) | +| `dmContext` unavailable | Legacy | Fallback for race conditions | +| `ENABLE_DM_ACTION_QUEUE = false` | Legacy | Feature disabled | + +### Path Comparison + +| Aspect | Action Queue Path | Legacy Path | +|--------|-------------------|-------------| +| **Visibility** | Shows in offline banner | No visibility | +| **Deduplication** | Yes (dedupe key) | No | +| **Retry Logic** | Exponential backoff | WebSocket reconnection | +| **Offline Support** | Yes (existing sessions) | Limited (WebSocket queue) | +| **New device support** | ❌ No | ✅ Yes | +| **Stale cleanup** | Relies on DM page load | ✅ Built-in | + +**Key insight**: The legacy path handles more edge cases (new devices, stale cleanup). Action Queue provides better offline resilience for established sessions. + +--- + +## Data Flow Patterns + +### Optimistic UI Pattern + +All queue-integrated actions follow this pattern: + +```typescript +// 1. Optimistic UI update (INSTANT) +queryClient.setQueryData(queryKey, (oldData) => ({ ...oldData, /* changes */ })); + +// 2. Persist to IndexedDB (DURABLE) +await messageDB.updateMessage(updatedMessage); + +// 3. Queue server sync (EVENTUAL) +await actionQueueService.enqueue('action-type', context, dedupKey); +``` + +> **Important**: Use `setQueryData` for optimistic updates, not `invalidateQueries`. The latter may delay/skip refetches, causing offline actions to not appear in the UI. + +### Sign Once, Encrypt on Retry + +For messages, **signing** happens before queueing, **encryption** happens in the handler: + +``` +submitChannelMessage() / submitMessage() + 1. Generate messageId (nonce) + 2. Sign message with Ed448 ← HAPPENS ONCE + 3. Add to cache (sendStatus: 'sending') + 4. Queue with signed message + +ActionQueue Handler (retryable) + 1. Encrypt with Triple/Double Ratchet ← CAN RETRY SAFELY + 2. Send via WebSocket/Hub + 3. Update cache status to 'sent' +``` + +This separation ensures retries don't create duplicate messages - the same messageId/signature is preserved across retries. + +--- + +## Relationship to WebSocket Queue + +The Action Queue works **in series** with the existing WebSocket queue: + +``` +ActionQueueService → MessageService/ConfigService → WebSocketProvider +(Persistence layer) (Business logic) (Transport layer) +``` + +| Queue | Purpose | Storage | Lifetime | +|-------|---------|---------|----------| +| **ActionQueue** | Persistence, retry, crash recovery | IndexedDB | Survives refresh | +| **WebSocket queue** | Buffer during disconnect | Memory | Lost on refresh | + +--- + +## Multi-Tab Safety + +The queue uses status-based gating to prevent duplicate processing: + +1. Re-fetch task status before processing +2. Skip if status is not `pending` (another tab grabbed it) +3. Check `processingStartedAt` timestamp for grace period (30s) +4. Mark as `processing` with timestamp before executing + +--- + +## UI Integration + +### OfflineBanner + +Displays **only when offline**: +- "You're offline" message with queued action count +- Dismissible X button (reappears on refresh or next offline event) +- Pushes layout down via `body.offline-banner-visible` class + +When online, queue processing is silent - no "Syncing..." banner. + +### useActionQueue Hook + +```typescript +const { isOnline, stats, refreshStats } = useActionQueue(); + +// stats: { pending, processing, failed, completed, total } +``` + +### Events + +| Event | Purpose | +|-------|---------| +| `quorum:queue-updated` | Queue state changed (debounced 500ms) | +| `quorum:session-expired` | Auth error (401) encountered | + +--- + +## IndexedDB Schema + +```typescript +interface QueueTask { + id?: number; // Auto-generated + taskType: ActionType; + context: Record<string, unknown>; + key: string; // Grouping/dedup key + status: TaskStatus; // 'pending' | 'processing' | 'completed' | 'failed' + retryCount: number; + maxRetries: number; + nextRetryAt: number; + createdAt: number; + processedAt?: number; + processingStartedAt?: number; // For crash recovery & multi-tab gating + error?: string; +} +``` + +**Indexes**: `status`, `taskType`, `key`, `nextRetryAt` + +--- + +## Configuration + +| Setting | Value | Description | +|---------|-------|-------------| +| `maxRetries` | 3 | Maximum retry attempts | +| `baseRetryDelayMs` | 2000 | Initial retry delay | +| `maxRetryDelayMs` | 300000 | Maximum retry delay (5 min) | +| `processIntervalMs` | 1000 | Queue polling interval | +| `batchSize` | 10 | Tasks per processing cycle | +| `multiTabGraceMs` | 30000 | Multi-tab coordination grace period | +| `MAX_QUEUE_SIZE` | 1000 | Maximum queue size | +| `MAX_TASK_AGE_MS` | 259200000 | Task retention (3 days) | + +### Exponential Backoff + +`min(baseRetryDelayMs * 2^retryCount, maxRetryDelayMs)` → 2s, 4s, 8s... + +--- + +## Error Handling + +### Error Classification + +Each handler defines `isPermanentError(error)`: + +**Permanent (No Retry)**: Validation errors, 400, 403, 404 + +**Retryable**: Network failures, timeouts, 5xx errors + +**Auth Errors**: 401 triggers `quorum:session-expired` event, task fails immediately + +### Idempotent Handlers + +Some handlers are safe to retry by design: +- **Reactions**: Re-adding same reaction is a no-op +- **Delete**: Already deleted = success +- **Kick**: Checks if user already left + +--- + +## Toast Feedback + +| Action | Success | Failure | +|--------|---------|---------| +| Messages | None (inline indicator) | None | +| Config saves | None | "Failed to save settings" | +| Space updates | None | "Failed to save space settings" | +| Moderation | None | "Failed to [action] user" | +| Reactions | None | None | +| Pin/Unpin | None | "Failed to pin/unpin message" | +| DM secondary actions | None | "Failed to edit/delete message" | + +--- + +## Debugging + +```javascript +// Check queue stats +await window.__actionQueue.getStats() + +// View all tasks +await window.__messageDB.getAllQueueTasks() + +// Force process queue +window.__actionQueue.processQueue() +``` + +--- + +## Technical Decisions + +### No Web Worker + +The bottleneck is network latency (~80% of operation time), not CPU. Moving crypto to a worker would add security concerns (private keys) with minimal benefit. + +### No Debouncing + +With non-blocking queued saves, multiple rapid operations don't freeze the UI. Sequential processing handles them without user impact. + +### Keys Not Stored in Queue (Security) + +**Private keys are never stored in IndexedDB.** Instead of storing keysets in the task context, handlers pull keys from memory at processing time via `actionQueueService.getUserKeyset()`. + +This approach: +- **Avoids keys on disk** - keys exist only in memory after passkey auth +- **Auth gate** - queue waits for `setUserKeyset()` before processing key-requiring tasks +- **App restart handling** - queue persists, keys don't; after re-auth, queue resumes + +See [007-plaintext-private-keys-fix.md](../../reports/action-queue/007-plaintext-private-keys-fix.md) for implementation details. + +### Sequential Processing + +Tasks are processed one at a time, not in parallel. This is a deliberate choice: + +**Why not parallel?** Actions to the **same target** share mutable state: +- Space messages: Same Triple Ratchet encryption state +- DM messages: Same Double Ratchet encryption states +- User config: Same config object (folders, settings, profile) + +Parallel processing would cause race conditions: +``` +Action A reads config → Action B reads config → A writes → B writes (overwrites A!) +``` + +**Could we parallelize across different targets?** Yes, in theory: +- Space A message + Space B message → Safe (different encryption states) +- Space message + DM message + user config → Safe (independent) + +But the complexity (lock groups, per-target semaphores, more failure modes) isn't justified by the benefit. The "slowness" users perceive is network/crypto latency, not queue ordering. + +**Current approach**: Sequential is simple, safe, and correct. Grouped parallelism is a potential future optimization if throughput becomes a real pain point. + +### Legacy Path as Primary for DMs + +DM actions use **offline-only routing**: the legacy path is now the primary path when online. This design decision: +- **Handles new devices** - legacy path creates new Double Ratchet sessions +- **Cleans stale states** - legacy path has built-in stale encryption state cleanup +- **Reserves Action Queue for offline** - where its persistence benefits matter most + +The Action Queue is only used for DMs when `navigator.onLine === false` and established sessions exist. + +--- + +## Component Hierarchy for DM Context + +For DM actions to use the Action Queue path (when offline), `dmContext` must be passed through the component hierarchy: + +``` +DirectMessage.tsx + ├─► Cleans stale encryption states on page load (Layer 3 fix) + └─► Constructs dmContext: { self: self.registration, counterparty: registration.registration } + │ + ▼ +MessageList.tsx (dmContext prop) + │ + ▼ +Message.tsx (dmContext prop) + ├─► useMessageActions({ ..., dmContext }) → for reactions, deletes + └─► MessageEditTextarea (dmContext prop) → for edits +``` + +**Note**: When online, all DM actions use the legacy path regardless of `dmContext` availability. The hierarchy matters primarily for offline scenarios. + +--- + +## Potential Future Actions + +These actions could be integrated with the action queue but are not yet: + +| Action Type | Current Location | Notes | +|-------------|------------------|-------| +| `delete-conversation` | `MessageService.deleteConversation()` | Multiple DB operations + Double Ratchet encryption | +| `delete-space` | `SpaceService.deleteSpace()` | Multiple DB operations + Hub envelope + API calls | + +### Recommended Integration Approach + +Both actions involve crypto (similar to `kick-user` which is already queued). Integration would follow this pattern: + +``` +User clicks Delete + │ + ├─► Immediately: Delete local data (messages, keys, states) + ├─► Immediately: Update React Query cache (item disappears) + └─► Queue: 'delete-conversation-notify' or 'delete-space-notify' + │ + ▼ (when online) + Send notification to counterparty/server +``` + +This provides: +- **Instant feedback** - conversation/space disappears immediately +- **Offline support** - works even when disconnected +- **Best-effort notification** - counterparty gets notified when possible + +The counterparty notification is already "best effort" (wrapped in try/catch), so queuing it is safe. + +--- + +## Testing + +Unit tests for the Action Queue are in `src/dev/tests/services/`: + +```bash +# Run all Action Queue tests (98 tests) +yarn vitest src/dev/tests/services/ActionQueue --run +``` + +| Test File | Tests | Coverage | +|-----------|-------|----------| +| `ActionQueueService.unit.test.ts` | 42 | Queue mechanics, retry logic, multi-tab safety | +| `ActionQueueHandlers.unit.test.ts` | 56 | All 15 handlers, context contracts, error classification | + +Tests verify control flow and contracts, not real encryption (SDK is mocked). See [README](src/dev/tests/README.md) for details. + +--- + +## Related Documentation + +### Feature Documentation +- [Offline Support](offline-support.md) - Comprehensive offline capabilities including navigation and data viewing +- [Feature Flag](../../src/config/features.ts) - `ENABLE_DM_ACTION_QUEUE` controls DM routing + +### Reports & Fixes +- [Keyset Security Fix (007)](../../reports/action-queue/007-plaintext-private-keys-fix.md) - Private keys not stored in queue +- [Offline-Only Routing Fix (009)](../../reports/action-queue/009-dm-offline-registration-persistence-fix.md) - Action Queue used only when offline +- [Inbox Mismatch Fix (010)](../../reports/action-queue/010-dm-registration-inbox-mismatch-fix.md) - Stale encryption state cleanup + offline-only routing + +### Audits +- [DM Code Comparison Audit (003)](../../reports/action-queue/003-DM-message-code-comparison-audit.md) - Code analysis and verification +- [Space Message Audit (004)](../../reports/action-queue/004-space-message-code-comparison-audit.md) - Space message code comparison + +### Full Report Index +- [Action Queue Report Index](../../reports/action-queue/INDEX.md) - All Action Queue related bugs and reports + +--- diff --git a/.agents/docs/features/avatar-initials-system.md b/.agents/docs/features/avatar-initials-system.md new file mode 100644 index 000000000..43bd23883 --- /dev/null +++ b/.agents/docs/features/avatar-initials-system.md @@ -0,0 +1,624 @@ +--- +type: doc +title: Avatar & Initials System +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-10-26T00:00:00.000Z +--- + +# Avatar & Initials System + + +**Platforms**: Web + Mobile +**Components**: `UserAvatar`, `UserInitials`, `SpaceAvatar` +**Pattern**: Deterministic fallback rendering + +--- + +## Overview + +The avatar system provides visual identity for users and spaces through images with intelligent fallback to colored initials. When no image is available, the system automatically generates initials with deterministic colors, ensuring consistent visual identity across all users and devices. + +**Key Principle**: Initials and colors are **generated on-the-fly on each device**, NOT stored in the database. All users see identical results due to deterministic algorithms. + +--- + +## Architecture + +### Component Hierarchy + +``` +UserInitials (Generic) +├── Renders 1-2 character initials +├── Gradient background with deterministic colors +├── Cross-platform (.web.tsx + .native.tsx) +└── Reused by domain-specific wrappers: + ├── UserAvatar (User profiles) + └── SpaceAvatar (Space icons) +``` + +### Pattern: Avatar Components + +Both `UserAvatar` and `SpaceAvatar` follow the same pattern: + +1. Check if valid image exists +2. If image exists → render image +3. If no image → render `UserInitials` with deterministic color + +This ensures **DRY** (Don't Repeat Yourself) - initials rendering logic exists in only one place. + +--- + +## Components + +### UserInitials (Generic Component) + +**Location**: `src/components/user/UserInitials/` + +**Purpose**: Generic component that renders colored initials for any name. + +**Files**: +- `UserInitials.web.tsx` - Web implementation (HTML/CSS gradients) +- `UserInitials.native.tsx` - Mobile implementation (React Native LinearGradient) +- `UserInitials.types.ts` - TypeScript interface +- `UserInitials.scss` - Styling + +**Interface**: +```typescript +export interface UserInitialsProps { + name: string; // Display name (for initials) + backgroundColor: string; // Pre-calculated background color + size?: number; // Size in dp/px (default: 40) + + // Web-specific + className?: string; + id?: string; + onClick?: (event: React.MouseEvent) => void; + + // Mobile-specific + testID?: string; + onPress?: () => void; +} +``` + +**Key Features**: +- Extracts 1-2 character initials from name +- Renders gradient background (lighter top, darker bottom) +- Cross-platform compatible +- Accepts pre-calculated background color (for performance) + +--- + +### UserAvatar (User Profile Images) + +**Location**: `src/components/user/UserAvatar/` + +**Purpose**: Shows user profile images with automatic fallback to initials. + +**Files**: +- `UserAvatar.web.tsx` - Web implementation +- `UserAvatar.native.tsx` - Mobile implementation + +**Usage**: +```typescript +<UserAvatar + userIcon={user.pfpUrl} // Optional image URL + displayName={user.displayName} // For initials fallback + address={user.address} // User identifier + size={40} // Size in pixels +/> +``` + +**Logic Flow**: +```typescript +const hasValidImage = userIcon && !userIcon.includes(DefaultImages.UNKNOWN_USER); + +if (hasValidImage) { + // Render circular image + return <div style={{ backgroundImage: `url(${userIcon})` }} />; +} + +// Fallback to initials +const backgroundColor = getColorFromDisplayName(displayName); +return <UserInitials name={displayName} backgroundColor={backgroundColor} />; +``` + +--- + +### SpaceAvatar (Space Icon Images) + +**Location**: `src/components/space/SpaceAvatar/` + +**Purpose**: Shows space icons with automatic fallback to initials. + +**Files**: +- `SpaceAvatar.web.tsx` - Web implementation +- `SpaceAvatar.native.tsx` - Mobile implementation +- `SpaceAvatar.types.ts` - TypeScript interface +- `index.ts` - Exports + +**Usage**: +```typescript +<SpaceAvatar + iconUrl={space.iconUrl} // Optional image URL + iconData={space.iconData} // Optional base64 data + spaceName={space.spaceName} // For initials fallback + size={40} // Size in pixels +/> +``` + +**Logic Flow** (same pattern as UserAvatar): +```typescript +const hasValidImage = (iconUrl || iconData) && + !iconUrl?.includes(DefaultImages.UNKNOWN_USER) && + iconData !== null; + +if (hasValidImage) { + // Render circular image + const imageSource = iconData || iconUrl; + return <div style={{ backgroundImage: `url(${imageSource})` }} />; +} + +// Fallback to initials +const backgroundColor = getColorFromDisplayName(spaceName); +return <UserInitials name={spaceName} backgroundColor={backgroundColor} />; +``` + +--- + +## Deterministic Color Generation + +### How It Works + +**File**: `src/utils/avatar.ts` + +**Function**: `getColorFromDisplayName(displayName: string): string` + +**Algorithm**: DJB2 Hash + +```typescript +// 1. Normalize input (case-insensitive, trimmed) +const normalized = displayName.toLowerCase().trim(); + +// 2. Calculate hash using DJB2 algorithm +let hash = 5381; +for (let i = 0; i < normalized.length; i++) { + hash = ((hash << 5) + hash) + normalized.charCodeAt(i); // hash * 33 + c +} + +// 3. Map to color palette (32 pre-desaturated colors) +return colors[(hash >>> 0) % colors.length]; +``` + +**Color Palette**: 32 pre-desaturated colors (25% less saturation for subtle appearance): +- Blues (#5f8eeb, #4970e0, #42aad9, #378dc0) +- Greens (#40b589, #357671, #47b0a8, #3d948e) +- Purples (#9673ea, #8858e1, #7579e6, #6559da) +- Pinks/Reds (#e4649f, #d14882, #e85c76, #d63e5c) +- Oranges/Yellows (#eba03f, #ce8336, #ec814a, #dc6738) +- And more... + +**Key Properties**: +- ✅ **Deterministic**: Same name → Same color (always) +- ✅ **Consistent**: All users see identical colors +- ✅ **Fast**: O(n) where n = name length +- ✅ **Privacy-Preserving**: Derived from public display name, not private address +- ✅ **Zero Storage**: No need to store color preference + +### Examples + +```typescript +getColorFromDisplayName("Alice") → #5f8eeb (blue-500) +getColorFromDisplayName("alice") → #5f8eeb (same!) +getColorFromDisplayName("Bob") → #e4649f (pink-500) +getColorFromDisplayName("My Cool Space") → #4970e0 (blue-600) +getColorFromDisplayName("Team Chat") → #d14882 (pink-600) +``` + +--- + +## Initials Extraction + +### How It Works + +**File**: `src/utils/avatar.ts` + +**Function**: `getInitials(displayName: string): string` + +**Rules**: +1. **Special case**: "Unknown User" → "?" +2. **Emoji detection**: If starts with emoji → return emoji only +3. **Standard names**: First letter of first 2 words, uppercase + +### Examples + +| Input | Output | Logic | +|-------|--------|-------| +| `"Alice"` | `"A"` | Single word → 1 letter | +| `"Alice Smith"` | `"AS"` | Two words → 2 letters | +| `"Alice Bob Smith"` | `"AB"` | Three+ words → first 2 words | +| `"My Cool Space"` | `"MC"` | Two words → 2 letters | +| `"😊 Fun Space"` | `"😊"` | Starts with emoji → emoji only | +| `"Unknown User"` | `"?"` | Special case | + +### Emoji Detection + +Uses **simple Unicode range checks** for common emoji: +- Modern emojis (0x1F600-0x1F64F, 0x1F300-0x1F5FF, etc.) +- Older Unicode emojis (0x2600-0x26FF, 0x2700-0x27BF) +- Regional indicators / flags (0x1F1E0-0x1F1FF) + +**Performance**: O(1) constant-time checks, extremely fast even for thousands of users. + +**Note**: Handles ~99% of emoji usage without over-engineering. For complex emoji (skin tones, ZWJ sequences), consider using `emoji-regex` library. + +--- + +## Gradient Backgrounds + +Both web and mobile implementations use **gradient backgrounds** for visual depth: + +### Web Implementation +```typescript +// UserInitials.web.tsx +const gradientStart = lightenColor(backgroundColor, 10); +const gradientEnd = darkenColor(backgroundColor, 10); + +style={{ + background: `linear-gradient(135deg, ${gradientStart} 0%, ${gradientEnd} 100%)` +}} +``` + +### Mobile Implementation +```typescript +// UserInitials.native.tsx +import LinearGradient from 'expo-linear-gradient'; + +const gradientStart = lightenColor(backgroundColor, 10); +const gradientEnd = darkenColor(backgroundColor, 10); + +<LinearGradient + colors={[gradientStart, gradientEnd]} + start={{ x: 0, y: 0 }} + end={{ x: 1, y: 1 }} +/> +``` + +**Helper Functions** (`src/utils/avatar.ts`): +- `lightenColor(hex: string, percent: number)` - Increases lightness in HSL space +- `darkenColor(hex: string, percent: number)` - Decreases lightness in HSL space + +--- + +## Database Storage + +### What IS Stored + +**Users**: +```json +{ + "address": "0x1234...", + "displayName": "Alice Smith", + "pfpUrl": "https://example.com/avatar.png" // or null +} +``` + +**Spaces**: +```json +{ + "spaceId": "abc123", + "spaceName": "My Cool Space", + "iconUrl": "https://example.com/icon.png", // or null + "iconData": null // or base64 data URI +} +``` + +### What is NOT Stored + +- ❌ Initials ("AS", "MC") +- ❌ Background colors (#5f8eeb, #4970e0) +- ❌ Gradient colors +- ❌ Any generated images + +### Migration from Old System + +**Before**: Spaces without images stored `DefaultImages.UNKNOWN_USER` fallback. + +**After**: Spaces without images store `null`, triggering initials display. + +**Backward Compatibility**: Both `UserAvatar` and `SpaceAvatar` check for `DefaultImages.UNKNOWN_USER` and treat it as "no image": + +```typescript +const hasValidImage = iconUrl && !iconUrl.includes(DefaultImages.UNKNOWN_USER); +``` + +--- + +## Integration Examples + +### In Space Creation + +**File**: `src/hooks/business/spaces/useSpaceCreation.ts` + +```typescript +const iconData = fileData && currentFile + ? 'data:' + currentFile.type + ';base64,' + Buffer.from(fileData).toString('base64') + : null; // ← Allow null for initials fallback + +await createSpaceAPI(name, iconData, ...); +``` + +**File**: `src/components/modals/CreateSpaceModal.tsx` + +```typescript +<Button + disabled={!canCreate} // ← No longer requires !fileData + onClick={() => createSpace(spaceName, fileData, currentFile)} +> + Create Space +</Button> +``` + +### In Navbar (SpaceIcon) + +**File**: `src/components/navbar/SpaceIcon.tsx` + +```typescript +const hasValidImage = backgroundImage && + props.iconUrl && + !props.iconUrl.includes(DefaultImages.UNKNOWN_USER); + +const backgroundColor = getColorFromDisplayName(props.spaceName); +const size = props.size === 'large' ? 48 : 40; + +{hasValidImage ? ( + <div className="space-icon" style={{ backgroundImage }} /> +) : ( + <UserInitials + name={props.spaceName} + backgroundColor={backgroundColor} + size={size} + /> +)} +``` + +### In Settings Modal + +**File**: `src/components/modals/SpaceSettingsModal/General.tsx` + +Space icons in settings show preview with fallback: +```typescript +<div + className="avatar-upload" + style={{ + backgroundImage: iconData || space?.iconUrl + ? `url(${iconData || space?.iconUrl})` + : undefined + }} +> + {!iconData && !space?.iconUrl && ( + <Icon name="image" size="2xl" /> + )} +</div> +``` + +--- + +## Benefits of This System + +### 1. Performance +- ✅ **Zero Network Requests**: Initials generated locally +- ✅ **Memoized Colors**: `useMemo` prevents recalculation +- ✅ **Fast Algorithms**: O(n) hash, O(1) emoji detection + +### 2. Consistency +- ✅ **Deterministic**: All users see identical initials/colors +- ✅ **Persistent**: Same identity across sessions/devices +- ✅ **Predictable**: Users learn their visual identity + +### 3. Privacy +- ✅ **No Fingerprinting**: Color derived from public name, not private address +- ✅ **No Tracking**: No server-side color assignment +- ✅ **User Control**: Changing display name changes color + +### 4. User Experience +- ✅ **Immediate Feedback**: No loading spinners for initials +- ✅ **Visual Distinction**: 32 colors provide variety +- ✅ **Graceful Degradation**: Works even if image upload fails +- ✅ **Lower Barrier**: Users/spaces can exist without images + +### 5. Maintainability +- ✅ **DRY**: Single `UserInitials` component reused everywhere +- ✅ **Pattern Consistency**: `UserAvatar` and `SpaceAvatar` follow same structure +- ✅ **Cross-Platform**: Shared logic, platform-specific rendering +- ✅ **Testable**: Pure functions for colors/initials + +--- + +## Edge Cases + +### Color Collisions + +**Issue**: With 32 colors and many users/spaces, some will share colors. + +**Mitigation**: +- Gradient backgrounds provide visual variation +- Initials provide primary distinction +- Birthday paradox: ~50% chance of collision after 22 items + +**Trade-off**: Deterministic colors > random unique colors (consistency wins) + +### Emoji Names + +**Supported**: +- ✅ Single emoji: "😊" → "😊" +- ✅ Emoji + name: "😊 Fun" → "😊" + +**Not Fully Supported** (edge cases): +- ⚠️ Skin tone modifiers (👋🏽) - displays base emoji +- ⚠️ ZWJ sequences (👨‍👩‍👧) - may display first component +- ⚠️ Multi-emoji names ("😊😎") - displays first emoji + +**Solution**: For 99% of use cases, current implementation works. For complex emoji handling, consider `emoji-regex` library. + +### Very Long Names + +**Issue**: "This Is A Very Long Space Name With Many Words" + +**Solution**: `getInitials()` only uses first 2 words → "TI" + +**Rendering**: CSS `overflow: hidden` prevents text overflow in initials circle. + +### Unknown User + +**Special Case**: Display name "Unknown User" shows "?" instead of "UU". + +**Reason**: "Unknown User" is system default for missing profiles, "?" is more appropriate. + +--- + +## Cross-Platform Compatibility + +### Web Platform + +**Rendering**: HTML `<div>` with CSS gradients +```css +background: linear-gradient(135deg, #color1 0%, #color2 100%); +border-radius: 50%; +``` + +**Click Handling**: `onClick` prop + +**Styling**: Tailwind classes + SCSS modules + +### Mobile Platform + +**Rendering**: React Native `<View>` with Expo `LinearGradient` +```typescript +<LinearGradient colors={[color1, color2]}> + <Text>{initials}</Text> +</LinearGradient> +``` + +**Touch Handling**: `onPress` prop + +**Styling**: React Native `StyleSheet.create()` + +### Shared Logic + +**100% Shared** (same code for both platforms): +- `getInitials()` - Initials extraction +- `getColorFromDisplayName()` - Color generation +- `lightenColor()` / `darkenColor()` - Gradient calculation +- Business logic in hooks + +**Platform-Specific** (different implementations): +- Rendering layer (HTML vs React Native components) +- Event handling (onClick vs onPress) +- Styling (CSS vs StyleSheet) + +--- + +## Future Enhancements + +### Potential Features + +1. **Custom Colors** + - Allow users to override deterministic color + - Store color preference in database + - Fallback to deterministic if no preference + +2. **Custom Icons** (for spaces) + - Icon picker (like channels/groups) + - Font Awesome, Material Icons, etc. + - Store icon name in database + +3. **Badges/Overlays** + - Status indicators (online, busy, away) + - Notification badges + - Role badges (admin, moderator) + +4. **Animations** + - Hover effects (web) + - Pulse animations for notifications + - Smooth transitions between image/initials + +5. **Advanced Emoji Support** + - Use `emoji-regex` library + - Handle skin tones, ZWJ sequences + - Multi-emoji names + +--- + +## Testing Checklist + +### Unit Tests + +- [ ] `getInitials()` generates correct initials for various names +- [ ] `getColorFromDisplayName()` returns consistent colors +- [ ] `getColorFromDisplayName()` is case-insensitive +- [ ] `lightenColor()` / `darkenColor()` work correctly +- [ ] Emoji detection handles common emoji ranges + +### Integration Tests + +- [ ] UserAvatar shows image when pfpUrl provided +- [ ] UserAvatar shows initials when pfpUrl is null +- [ ] SpaceAvatar shows image when iconUrl/iconData provided +- [ ] SpaceAvatar shows initials when iconUrl/iconData is null +- [ ] Colors consistent across component re-renders (memoization) + +### Cross-Platform Tests + +- [ ] Web: Initials render with CSS gradients +- [ ] Mobile: Initials render with LinearGradient +- [ ] Web: Click handlers work on initials +- [ ] Mobile: Touch handlers work on initials +- [ ] Both platforms show identical colors for same names + +### Manual Tests + +- [ ] Create user without profile picture → shows initials +- [ ] Create space without icon → shows initials +- [ ] Same user/space shows same color across app +- [ ] Initials update when display name changes +- [ ] Edge cases: emoji names, very long names, special chars + +--- + +## Related Files + +### Components +- `src/components/user/UserInitials/` - Generic initials renderer +- `src/components/user/UserAvatar/` - User profile avatars +- `src/components/space/SpaceAvatar/` - Space icon avatars +- `src/components/navbar/SpaceIcon.tsx` - Navbar space icons with initials fallback + +### Utilities +- `src/utils/avatar.ts` - Color generation and initials extraction +- `src/utils/DefaultImages.ts` - Default image constants + +### Hooks +- `src/hooks/business/spaces/useSpaceCreation.ts` - Space creation with optional images +- `src/hooks/business/ui/useImageLoading.ts` - Image loading for avatars + +### Modals +- `src/components/modals/CreateSpaceModal.tsx` - Create space without image requirement +- `src/components/modals/SpaceSettingsModal/General.tsx` - Space icon preview + +--- + +## Documentation + +### Related Docs +- [Cross-Platform Components Guide](./../cross-platform-components-guide.md) - General component patterns +- [Client-Side Image Compression](./messages/client-side-image-compression.md) - Avatar image processing +- [Primitives API Reference](./primitives/API-REFERENCE.md) - Select component avatar support + +### Tasks +- [Space Initials Fallback Task](./../../tasks/space-initials-fallback.md) - Original implementation task + +--- + +_Last updated: 2025-10-26_ +_Verified: 2025-12-09 - File paths confirmed current_ diff --git a/.agents/docs/features/channel-space-mute-system.md b/.agents/docs/features/channel-space-mute-system.md new file mode 100644 index 000000000..3078fa1c3 --- /dev/null +++ b/.agents/docs/features/channel-space-mute-system.md @@ -0,0 +1,392 @@ +--- +type: doc +title: Channel and Space Mute System +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-27T00:00:00.000Z +--- + +# Channel and Space Mute System + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Overview + +The Channel and Space Mute System provides users with granular control over notifications at both the channel and space level. Users can mute individual channels to suppress notifications while keeping them visible (with reduced opacity), or mute entire spaces to disable all notifications from that space. The system integrates with the existing notification infrastructure (mentions and replies) and persists preferences across sessions and devices via the Action Queue pattern. + +## Architecture + +### Data Model + +Mute settings are stored in the `UserConfig` type within IndexedDB: + +**File**: [messages.ts](src/db/messages.ts) + +```typescript +export type UserConfig = { + address: string; + spaceIds: string[]; + // ... other fields ... + + // Channel mute settings: maps spaceId to array of muted channelIds + mutedChannels?: { + [spaceId: string]: string[]; + }; + + // Global UI preference for showing muted channels (default: true = visible with 50% opacity) + showMutedChannels?: boolean; + + // Per-space notification settings (includes space-level muting) + notificationSettings?: { + [spaceId: string]: NotificationSettings; + }; +}; +``` + +**Space-level muting** uses the `isMuted` field in `NotificationSettings`: + +**File**: [notifications.ts](src/types/notifications.ts) + +```typescript +export interface NotificationSettings { + spaceId: string; + enabledNotificationTypes: NotificationTypeId[]; + isMuted?: boolean; // When true, suppresses ALL notifications for this space +} +``` + +### Key Components + +| Component | File Path | Purpose | +|-----------|-----------|---------| +| Main Hook | [useChannelMute.ts](src/hooks/business/channels/useChannelMute.ts) | Provides mute/unmute functions and state | +| Utility Functions | [channelUtils.ts](src/utils/channelUtils.ts) | Helper functions for mute checking | +| Channel Item UI | [ChannelItem.tsx](src/components/space/ChannelItem.tsx) | Context menu for channel muting | +| Space Context Menu | [NavMenu.tsx](src/components/navbar/NavMenu.tsx) | Space-level mute toggle | +| Account Settings | [Account.tsx](src/components/modals/SpaceSettingsModal/Account.tsx) | Settings toggles for mute preferences | +| CSS Styling | [ChannelGroup.scss](src/components/space/ChannelGroup.scss) | Visual treatment for muted channels | + +## Core Hook: useChannelMute + +**File**: [useChannelMute.ts](src/hooks/business/channels/useChannelMute.ts) + +The `useChannelMute` hook provides all mute-related functionality: + +```typescript +interface UseChannelMuteReturn { + // Channel-level muting + isChannelMuted: (channelId: string) => boolean; + getMutedChannelIds: () => string[]; + muteChannel: (channelId: string) => Promise<void>; + unmuteChannel: (channelId: string) => Promise<void>; + toggleMute: (channelId: string) => Promise<void>; + + // UI preference + showMutedChannels: boolean; + toggleShowMutedChannels: () => Promise<void>; + + // Space-level muting + isSpaceMuted: boolean; + muteSpace: () => Promise<void>; + unmuteSpace: () => Promise<void>; + toggleSpaceMute: () => Promise<void>; +} +``` + +### Implementation Details + +- Uses **Action Queue Service** for offline support and crash recovery +- **Optimistically updates** React Query cache for instant UI feedback +- **Invalidates notification queries** upon mute/unmute for immediate count updates +- Uses dedup key pattern: `config:${userAddress}` to collapse rapid toggles +- Persists to IndexedDB via `actionQueueService.enqueue('save-user-config', ...)` + +### Usage Example + +```typescript +const { + isChannelMuted, + toggleMute, + isSpaceMuted, + toggleSpaceMute, + showMutedChannels, + toggleShowMutedChannels, +} = useChannelMute({ spaceId }); + +// Check if channel is muted +if (isChannelMuted('channel-123')) { + // Channel is muted +} + +// Toggle channel mute +await toggleMute('channel-123'); + +// Toggle entire space mute +await toggleSpaceMute(); +``` + +## Utility Functions + +**File**: [channelUtils.ts](src/utils/channelUtils.ts) + +```typescript +/** + * Check if a channel is muted for a specific space + */ +export function isChannelMuted( + spaceId: string, + channelId: string, + mutedChannels?: UserConfig['mutedChannels'] +): boolean; + +/** + * Get all muted channel IDs for a specific space + */ +export function getMutedChannelsForSpace( + spaceId: string, + mutedChannels?: UserConfig['mutedChannels'] +): string[]; +``` + +## Notification Integration + +All notification hooks check for muted channels and spaces, skipping them to suppress notifications. + +### Space-Level Mute Check (O(1) Early Return) + +All hooks perform an early check for space-level muting: + +```typescript +// Check if entire space is muted (takes precedence over individual settings) +if (settings?.isMuted) { + return {}; // or [] for arrays - Space is muted - no notifications +} +``` + +### Channel-Level Mute Filtering + +After the space check, hooks filter out individually muted channels: + +```typescript +// Get muted channels to exclude from counts +const mutedChannelIds = getMutedChannelsForSpace(spaceId, config?.mutedChannels); + +// Process each channel (excluding muted ones) +for (const channelId of channelIds) { + if (mutedChannelIds.includes(channelId)) { + continue; // Skip muted channels + } + // ... process notifications for this channel ... +} +``` + +### Affected Hooks + +| Hook | File | Returns | Stale Time | +|------|------|---------|------------| +| useChannelMentionCounts | [useChannelMentionCounts.ts](src/hooks/business/mentions/useChannelMentionCounts.ts) | `Record<string, number>` | 90s | +| useReplyNotificationCounts | [useReplyNotificationCounts.ts](src/hooks/business/replies/useReplyNotificationCounts.ts) | `Record<string, number>` | 30s | +| useAllMentions | [useAllMentions.ts](src/hooks/business/mentions/useAllMentions.ts) | `MentionNotification[]` | 30s | +| useAllReplies | [useAllReplies.ts](src/hooks/business/replies/useAllReplies.ts) | `ReplyNotification[]` | 30s | +| useSpaceMentionCounts | [useSpaceMentionCounts.ts](src/hooks/business/mentions/useSpaceMentionCounts.ts) | `Record<string, number>` | 90s | +| useSpaceReplyCounts | [useSpaceReplyCounts.ts](src/hooks/business/replies/useSpaceReplyCounts.ts) | `Record<string, number>` | 90s | + +## UI Integration + +### Channel Context Menu + +**File**: [ChannelItem.tsx](src/components/space/ChannelItem.tsx) + +Right-click or long-press on a channel opens a context menu with mute options: + +```typescript +// Build context menu items +contextMenuItems.push({ + id: 'toggle-mute', + icon: isMuted ? 'bell' : 'bell-off', + label: isMuted ? t`Unmute Channel` : t`Mute Channel`, + onClick: async () => { + if (onToggleMute) { + await onToggleMute(channel.channelId); + } + }, +}); +``` + +- Available to all users (not just space owners) +- Icon toggles: `bell` (muted, click to unmute) ↔ `bell-off` (unmuted, click to mute) + +### Space Context Menu + +**File**: [NavMenu.tsx](src/components/navbar/NavMenu.tsx) + +Right-click on a space icon shows space-level options: + +```typescript +const items: MenuItem[] = [ + { + id: 'account', + icon: 'user', + label: t`My Account`, + onClick: () => openSpaceEditor(spaceContextMenu.spaceId!, 'account'), + }, + { + id: 'toggle-muted-channels', + icon: showMutedChannels ? 'eye-off' : 'eye', + label: showMutedChannels ? t`Hide Muted Channels` : t`Show Muted Channels`, + onClick: () => toggleShowMutedChannels(), + }, + { + id: 'toggle-space-mute', + icon: isSpaceMuted ? 'bell' : 'bell-off', + label: isSpaceMuted ? t`Unmute Space` : t`Mute Space`, + onClick: () => toggleSpaceMute(), + }, +]; + +// Separator before owner/leave options +if (spaceContextMenu.isOwner) { + items.push( + { id: 'settings', icon: 'settings', label: t`Space Settings`, separator: true, ... }, + { id: 'invites', icon: 'user-plus', label: t`Invite Members`, ... }, + { id: 'roles', icon: 'shield', label: t`Manage Roles`, ... } + ); +} else { + items.push({ + id: 'leave', icon: 'logout', label: t`Leave Space`, danger: true, separator: true, ... + }); +} +``` + +### Account Settings + +**File**: [Account.tsx](src/components/modals/SpaceSettingsModal/Account.tsx) + +The Account tab in Space Settings provides toggles for mute preferences: + +```tsx +{/* Notifications Section */} +<div className="text-subtitle-2"> + <Trans>Notifications</Trans> +</div> + +{/* Notification type selector */} +<Select + value={selectedMentionTypes} + onChange={setSelectedMentionTypes} + multiple={true} + options={[ + { value: 'mention-you', label: t`@you` }, + { value: 'mention-everyone', label: t`@everyone` }, + { value: 'mention-roles', label: t`@roles` }, + { value: 'reply', label: t`Replies` }, + ]} +/> + +{/* Mute this Space toggle */} +<FlexRow className="items-center justify-between pt-4"> + <div className="text-label-strong"> + <Trans>Mute this Space</Trans> + </div> + <Switch + value={isSpaceMuted} + onChange={toggleSpaceMute} + accessibilityLabel={t`Mute all notifications from this space`} + /> +</FlexRow> + +{/* Hide muted channels toggle */} +<FlexRow className="items-center justify-between pt-4"> + <div className="text-label-strong"> + <Trans>Hide muted channels</Trans> + </div> + <Switch + value={!showMutedChannels} + onChange={handleShowMutedToggle} + accessibilityLabel={t`Hide muted channels in list`} + /> +</FlexRow> +``` + +## Visual Treatment + +**File**: [ChannelGroup.scss](src/components/space/ChannelGroup.scss) + +Muted channels display at 50% opacity when visible: + +```scss +.channel-item-muted { + opacity: 0.5; +} +``` + +Applied conditionally in ChannelItem: + +```typescript +const mutedClassName = isMuted ? 'channel-item-muted' : ''; +``` + +### Visibility Behavior + +| `showMutedChannels` | Muted Channel Behavior | +|---------------------|------------------------| +| `true` (default) | Displayed at 50% opacity | +| `false` | Hidden from channel list | + +## Data Flow + +1. **User mutes channel** via ChannelItem context menu +2. **useChannelMute.muteChannel()** is called +3. Action is queued: `actionQueueService.enqueue('save-user-config', ...)` +4. React Query cache updated optimistically +5. Notification queries invalidated: + - `['mention-counts', 'channel', spaceId]` + - `['reply-counts', 'channel', spaceId]` + - `['mention-notifications', spaceId]` + - `['reply-notifications', spaceId]` +6. Config saved to IndexedDB (with offline support) +7. Syncs across devices via existing sync mechanism + +## Mute Hierarchy + +The mute system follows a clear precedence: + +1. **Space mute** (`notificationSettings[spaceId].isMuted`) - Highest priority + - When enabled, ALL channels in the space are muted + - New channels added to a muted space are automatically muted + - O(1) check at the start of all notification hooks + +2. **Channel mute** (`mutedChannels[spaceId][]`) - Per-channel override + - Individual channels can be muted/unmuted independently + - When space is unmuted, individual channel preferences are preserved + +## Technical Decisions + +### Action Queue Pattern +Uses the same offline-first pattern as folder operations for crash recovery and eventual consistency. Config saves are queued with dedup keys to prevent duplicate writes during rapid toggling. + +### Optimistic Updates +React Query cache is updated immediately for instant UI feedback, while the actual persistence happens asynchronously in the background. + +### Early Exit Optimization +Space-level mute checks happen first (O(1)) to avoid unnecessary channel iteration when the entire space is muted. Notification hooks also use early-exit thresholds (10 items) since UI shows "9+" for counts > 9. + +### Separation of Concerns +- `isMuted` in `NotificationSettings` represents **user intent** to mute a space +- `mutedChannels` represents **individual channel preferences** +- `showMutedChannels` is a **UI preference** for visibility + +## Known Limitations + +- **No per-space showMutedChannels**: The hide/show preference is global, not per-space +- **No notification history**: When a channel is unmuted, previous notifications don't reappear +- **Sync conflicts**: Uses last-write-wins; rapid muting on multiple devices may cause brief inconsistencies + +## Related Documentation + +- [Mention Notification System](mention-notification-system.md) +- [Reply Notification System](reply-notification-system.md) +- [Config Sync System](../config-sync-system.md) + +--- diff --git a/.agents/docs/features/cross-platform-theming.md b/.agents/docs/features/cross-platform-theming.md new file mode 100644 index 000000000..847ff7d62 --- /dev/null +++ b/.agents/docs/features/cross-platform-theming.md @@ -0,0 +1,399 @@ +--- +type: doc +title: Cross-Platform Theming System +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-08-04T00:00:00.000Z +--- + +# Cross-Platform Theming System + +This document describes our comprehensive cross-platform theming architecture that provides consistent theme switching and color management across web and React Native platforms. + +## Overview + +Our theming system is built with a shared-first approach, providing: + +- **Unified Theme API**: Same interface for web and mobile +- **System Theme Detection**: Automatic light/dark detection on both platforms +- **Dynamic Accent Colors**: Full accent color switching with persistence +- **Shared Component Support**: Single ThemeRadioGroup works everywhere +- **Color Consistency**: Mobile colors mirror web CSS variables exactly + +## Architecture + +### File Structure + +``` +src/components/primitives/theme/ +├── ThemeProvider.ts # Shared types/interfaces +├── ThemeProvider.web.tsx # Web implementation (matchMedia API) +├── ThemeProvider.native.tsx # React Native (useColorScheme hook) +├── colors.ts # Shared color definitions +└── index.ts # Platform resolution +``` + +### Platform Resolution + +The system automatically selects the correct provider: + +- **Web**: Uses `ThemeProvider.web.tsx` with `window.matchMedia()` +- **React Native**: Uses `ThemeProvider.native.tsx` with `useColorScheme()` + +Both platforms export the same API through `src/components/primitives/theme/index.ts`. + +## Theme Types + +```typescript +export type Theme = 'light' | 'dark' | 'system'; +export type AccentColor = + | 'blue' + | 'purple' + | 'fuchsia' + | 'orange' + | 'green' + | 'yellow'; +``` + +### Theme Resolution + +| Theme Value | Web Behavior | Mobile Behavior | +| ----------- | ------------------------------------------------- | ------------------------------------ | +| `'light'` | Force light theme | Force light theme | +| `'dark'` | Force dark theme | Force dark theme | +| `'system'` | Uses `matchMedia('(prefers-color-scheme: dark)')` | Uses React Native `useColorScheme()` | + +## Theme Provider Interfaces + +### Unified Interface + +```typescript +interface ThemeContextType { + theme: Theme; // Current setting ('light'|'dark'|'system') + setTheme: (value: Theme) => void | Promise<void>; + resolvedTheme: 'light' | 'dark'; // Actual applied theme + accent: AccentColor; // Current accent color + setAccent: (value: AccentColor) => void | Promise<void>; +} +``` + +### Extended Mobile Interface + +```typescript +interface PrimitivesThemeContextType extends ThemeContextType { + colors: ColorPalette; // Pre-calculated colors with dynamic accent + getColor: (path: string) => string; +} +``` + +## Related Documentation + +- [Primitives Styling Guide](./primitives/05-primitive-styling-guide.md) - Styling system and color usage +- [API Reference](./primitives/API-REFERENCE.md) - Theme integration in primitives +- [Quick Reference](../../AGENTS.md) - Theme system overview +- [Cross-Platform Guide](../cross-platform-components-guide.md) - Component architecture + +## Color System + +### Color Mirroring + +Mobile colors **exactly mirror** web CSS variables: + +```scss +// Web CSS (_colors.scss) +--surface-0: #fefeff; +--surface-3: #e6e6eb; +--color-text-main: #363636; +``` + +```typescript +// Mobile colors.ts +surface: { + '0': '#fefeff', // Matches --surface-0 + '3': '#e6e6eb', // Matches --surface-3 +}, +text: { + main: '#363636', // Matches --color-text-main +} +``` + +### Mobile-Specific Field Colors + +Mobile has additional field-specific colors optimized for React Native: + +```typescript +field: { + bg: '#eeeef3', // Input background (surface-2) + border: '#cdccd3', // Input border (surface-6) + borderFocus: '#0287f2', // Focus state (dynamic accent) + text: '#363636', // Input text + placeholder: '#818181', // Placeholder text + optionTextSelected: '#0287f2', // Select dropdown selected text (dynamic accent) +} +``` + +### Dynamic Accent Color System + +The `getColors()` function dynamically applies accent colors to form fields: + +```typescript +export const getColors = ( + theme: 'light' | 'dark' = 'light', + accent: AccentColor = 'blue' +) => { + const baseColors = { + ...themeColors[theme], + accent: accentColors[accent], + ...commonColors, + }; + const accentDefault = accentColors[accent].DEFAULT; + + return { + ...baseColors, + field: { + ...baseColors.field, + borderFocus: accentDefault, // Input/TextArea focus borders + optionTextSelected: accentDefault, // Select dropdown selected items + }, + }; +}; +``` + +## Usage Patterns + +### Shared Components + +Components can be truly cross-platform: + +```typescript +// ThemeRadioGroup.tsx - Works on both platforms +import { useTheme, type Theme } from './primitives/theme'; + +const ThemeRadioGroup = () => { + const { theme, setTheme } = useTheme(); + + return ( + <RadioGroup + options={[ + { value: 'light', label: t`Light`, icon: 'sun' }, + { value: 'dark', label: t`Dark`, icon: 'moon' }, + { value: 'system', label: t`System`, icon: 'desktop' }, + ]} + value={theme} + onChange={setTheme} + /> + ); +}; +``` + +```typescript +// AccentColorSwitcher.tsx - Also works on both platforms +import { useTheme, type AccentColor } from './primitives/theme'; + +const AccentColorSwitcher = () => { + const { accent, setAccent } = useTheme(); + + return ( + <ColorSwitcher + colors={['blue', 'purple', 'fuchsia', 'orange', 'green', 'yellow']} + value={accent} + onChange={setAccent} + /> + ); +}; +``` + +### Platform-Specific Usage + +**Web:** + +```typescript +// Automatic CSS class application +// theme='system' + user prefers dark → html.dark class applied +``` + +**React Native:** + +```typescript +// Pre-calculated colors with dynamic accent available +const theme = useTheme(); +const backgroundColor = theme.colors.bg.app; +const focusBorderColor = theme.colors.field.borderFocus; // Uses current accent +const selectedTextColor = theme.colors.field.optionTextSelected; // Uses current accent +``` + +## System Theme Detection + +### Web Implementation + +```typescript +// Uses matchMedia API +const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + +// Listens for changes +mediaQuery.addEventListener('change', onSystemChange); +``` + +### Mobile Implementation + +```typescript +// Uses React Native hook + AsyncStorage persistence +const systemColorScheme = useColorScheme(); // 'light' | 'dark' | null + +// Load persisted values on mount +useEffect(() => { + const loadPersistedValues = async () => { + const savedTheme = (await AsyncStorage.getItem('theme')) as Theme | null; + const savedAccent = (await AsyncStorage.getItem( + 'accent-color' + )) as AccentColor | null; + + if (savedTheme) setThemeState(savedTheme); + if (savedAccent) setAccentState(savedAccent); + }; + loadPersistedValues(); +}, []); + +// Automatic re-render on system changes +useEffect(() => { + const actualTheme = + theme === 'system' + ? systemColorScheme === 'dark' + ? 'dark' + : 'light' + : theme; + setResolvedTheme(actualTheme); +}, [theme, systemColorScheme]); +``` + +## Development Guidelines + +### Adding New Colors + +1. **Add to web CSS first** (`_colors.scss`) +2. **Mirror exactly in mobile** (`colors.ts`) +3. **Update both light and dark themes** +4. **Consider dynamic accent support** in `getColors()` +5. **Test on both platforms** + +### Adding New Accent Colors + +1. **Define in `accentColors` object** with full shade palette +2. **Update `AccentColor` type** to include new option +3. **Add to AccentColorSwitcher** component options +4. **Test field focus states** with new accent + +### Theme Provider Updates + +When updating theme providers: + +1. **Maintain interface compatibility** between web and native +2. **Update shared types** in `ThemeProvider.ts` +3. **Test ThemeRadioGroup** on both platforms +4. **Verify system theme detection** + +### Component Best Practices + +**✅ Do:** + +```typescript +// Use pre-resolved colors from provider +const theme = useTheme(); +const colors = theme.colors; // Already resolved with dynamic accent + +// Use semantic color names +backgroundColor: colors.bg.app; +color: colors.text.main; +borderColor: colors.field.borderFocus; // Uses current accent automatically +``` + +**❌ Don't:** + +```typescript +// Don't resolve themes in components +const actualTheme = theme.theme === 'system' ? 'light' : theme.theme; +const colors = getColors(actualTheme); // Provider already did this + +// Don't use hardcoded accent colors +borderColor: '#0287f2'; // Use colors.field.borderFocus instead + +// Don't use hardcoded surface values +backgroundColor: '#ffffff'; // Use colors.bg.app instead +``` + +### Avoiding Conflicts + +1. **Never modify colors.ts without updating CSS** - They must stay in sync +2. **Don't resolve 'system' theme in components** - Let providers handle it +3. **Use theme.colors, not getColors()** - Colors are pre-calculated +4. **Test both platforms** when making theme changes +5. **Keep platform providers in sync** - Same API, different implementation + +## Testing + +### Manual Testing Checklist + +**Both Platforms:** + +- [ ] Light theme renders correctly +- [ ] Dark theme renders correctly +- [ ] System theme matches OS preference +- [ ] Theme switching is immediate +- [ ] System changes are detected automatically +- [ ] Accent color switching works (blue, purple, fuchsia, orange, green, yellow) +- [ ] Accent colors persist between sessions +- [ ] Field focus borders use current accent color +- [ ] Select dropdown selected items use current accent color + +**Mobile Specific:** + +- [ ] Field colors have proper contrast against card backgrounds +- [ ] Touch targets work in all themes +- [ ] StatusBar adapts to theme +- [ ] AsyncStorage persistence works for theme and accent + +**Web Specific:** + +- [ ] CSS classes applied correctly +- [ ] LocalStorage persistence works for theme and accent +- [ ] Media query changes detected + +## Troubleshooting + +### Common Issues + +**"Cannot read property 'border' of undefined"** + +- Component is calling `getColors()` with 'system' theme +- Solution: Use `theme.colors` instead of resolving manually + +**Colors don't match between platforms** + +- CSS variables and colors.ts are out of sync +- Solution: Compare and update both files to match + +**System theme not working** + +- Check platform-specific detection is working +- Web: Verify `matchMedia` support +- Mobile: Verify `useColorScheme` import + +**Theme switching not working** + +- Components using different ThemeProvider contexts +- Solution: Ensure all imports use `./primitives/theme` + +**Accent colors not updating form fields** + +- Field colors using hardcoded values instead of dynamic accent +- Solution: Ensure `getColors()` overrides `borderFocus` and `optionTextSelected` + +**Field backgrounds not visible** + +- Card backgrounds same color as field backgrounds +- Solution: Use different surface levels (e.g., card=surface-0, field=surface-2) + +--- + + +_Verified: 2025-12-09 - File structure confirmed current_ diff --git a/.agents/docs/features/delete-confirmation-system.md b/.agents/docs/features/delete-confirmation-system.md new file mode 100644 index 000000000..495ffcf5a --- /dev/null +++ b/.agents/docs/features/delete-confirmation-system.md @@ -0,0 +1,193 @@ +--- +type: doc +title: Delete Confirmation System +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Delete Confirmation System + +## Overview + +The delete confirmation system provides unified protection for all delete operations across the application. It implements smart escalation logic, content previews, and consistent UX patterns to prevent accidental deletions while maintaining good user experience. + +## Architecture + +### Core Components + +**useConfirmation Hook** (`src/hooks/ui/useConfirmation.ts`) +- Manages confirmation state and logic +- Supports inline double-click and modal confirmations +- Handles smart escalation and blocking conditions +- Includes Shift+click bypass for desktop users + +**ConfirmationModal** (`src/components/modals/ConfirmationModal.tsx`) +- Cross-platform modal component with web/native variants +- Scrollable content preview area using `ScrollContainer` +- PROTIP text for Shift+click bypass (desktop only) +- Configurable buttons, variants, and sizing + +**Preview Components** +- `MessagePreview.tsx` - Shows message content, author, timestamp +- `RolePreview.tsx` - Displays role name, member count, permissions +- `ChannelPreview.tsx` - Shows channel name and message count + +**Layout Integration** +- `ConfirmationModalProvider.tsx` - Context provider for layout-level modal rendering +- Integrated into main Layout to fix z-index stacking issues + +## Confirmation Patterns + +### Pattern A: Inline Double-Click +- **Use case**: Large buttons/links with low-medium risk +- **Behavior**: First click shows "Click again to confirm", second click executes +- **Timeout**: 5 seconds to reset confirmation state +- **Example**: Empty channel deletion, space deletion + +### Pattern B: Modal Confirmation +- **Use case**: High-risk operations requiring context +- **Features**: Title, message, content preview, action buttons +- **Shift bypass**: Hold Shift+click to skip modal (desktop only) +- **PROTIP**: Shows bypass hint on desktop +- **Example**: Message deletion, role deletion + +### Pattern C: Blocked Operations +- **Use case**: Operations that cannot proceed due to dependencies +- **Behavior**: Shows inline error message, prevents action +- **Example**: Group deletion when channels exist + +## Smart Escalation Logic + +The system automatically chooses the appropriate confirmation level: + +```typescript +// Channel Deletion +- Empty channel (0 messages) → Double-click confirmation +- Channel with messages → Modal with content preview + +// Group Deletion +- Empty group (0 channels) → Double-click confirmation +- Group with channels → Blocked with error message + +// Role Deletion +- Always → Modal with role details preview +``` + +## Implementation Example + +```typescript +// Basic usage +const deleteConfirmation = useConfirmation({ + type: 'modal', + enableShiftBypass: false, + modalConfig: { + title: t`Delete Role`, + message: t`Are you sure you want to delete this role?`, + preview: React.createElement(RolePreview, { role }), + confirmText: t`Delete`, + variant: 'danger' + } +}); + +// In component +const handleDelete = (e: React.MouseEvent) => { + deleteConfirmation.handleClick(e, () => { + // Perform actual deletion + performDelete(); + }); +}; + +// Render confirmation modal +{deleteConfirmation.showModal && deleteConfirmation.modalConfig && ( + <ConfirmationModal + visible={deleteConfirmation.showModal} + {...deleteConfirmation.modalConfig} + onCancel={() => deleteConfirmation.setShowModal(false)} + /> +)} +``` + +## Key Features + +### Content Previews +- **ScrollContainer**: Used for long content with `height="sm"` +- **Rich content**: Shows exactly what will be deleted +- **Consistent styling**: Matches app's design system + +### Cross-Platform Support +- **Desktop**: Full modal with Shift+click bypass +- **Mobile**: Streamlined modal without bypass features +- **Electron**: Desktop behavior with proper event handling + +### Layout-Level Rendering +- Modals render at layout level to fix z-index issues +- Provider pattern allows any component to show confirmations +- Proper stacking above navigation menus + +## Current Usage + +**Protected Operations:** +- Message deletion (modal with preview) +- Role deletion (modal with details) +- Channel deletion (smart escalation) +- Group deletion (blocking when has channels) +- Conversation deletion (modal with warning) +- Pin/Unpin operations (modal with preview) + +**Integration Points:** +- `useRoleManagement.ts` - Role deletion in SpaceSettingsModal +- `useMessageActions.ts` - Message deletion +- `usePinnedMessages.ts` - Pin/Unpin operations +- `useChannelManagement.ts` - Channel deletion +- `ConversationSettingsModal.tsx` - Conversation deletion + +## Configuration Options + +```typescript +interface UseConfirmationOptions { + type: 'inline' | 'modal'; + escalateWhen?: () => boolean; // Smart escalation trigger + blockedWhen?: () => boolean; // Block operation trigger + enableShiftBypass?: boolean; // Allow Shift+click bypass + doubleClickTimeout?: number; // Timeout for double-click (default: 5s) + modalConfig?: { + title: string; + message: string; + preview?: React.ReactNode; + confirmText?: string; + cancelText?: string; + variant?: 'danger' | 'warning' | 'info'; + }; + blockedError?: string; // Error message when blocked +} +``` + +## Files Structure + +``` +src/ +├── hooks/ui/ +│ └── useConfirmation.ts # Core confirmation logic +├── components/ +│ ├── modals/ +│ │ ├── ConfirmationModal.tsx # Web modal component +│ │ └── ConfirmationModal.native.tsx # Mobile modal component +│ ├── context/ +│ │ └── ConfirmationModalProvider.tsx # Context provider +│ ├── message/ +│ │ └── MessagePreview.tsx # Message preview component +│ ├── role/ +│ │ └── RolePreview.tsx # Role preview component +│ └── channel/ +│ └── ChannelPreview.tsx # Channel preview component +└── hooks/business/ + ├── spaces/useRoleManagement.ts # Role deletion usage + ├── messages/useMessageActions.ts # Message deletion usage + └── messages/usePinnedMessages.ts # Pin/Unpin usage +``` + +--- + + +*Verified: 2025-12-09 - File paths confirmed current* diff --git a/.agents/docs/features/desktop-notifications.md b/.agents/docs/features/desktop-notifications.md new file mode 100644 index 000000000..fb6342124 --- /dev/null +++ b/.agents/docs/features/desktop-notifications.md @@ -0,0 +1,194 @@ +--- +type: doc +title: Desktop Notifications Feature +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-04T00:00:00.000Z +--- + +# Desktop Notifications Feature + +## Overview + +The desktop notifications feature allows Quorum to notify users about new messages when the application is running in the background. This feature enhances user engagement by ensuring they don't miss important messages even when the app isn't actively focused. + +## Architecture + +### Core Components + +1. **NotificationService** (`src/services/NotificationService.ts`) + - Singleton service that manages all notification-related functionality + - Handles browser permission requests and status checks + - Provides methods for showing different types of notifications + - Includes Safari compatibility handling (icons not supported) + +2. **WebSocket Integration** (`src/components/context/WebsocketProvider.tsx`) + - Coordinates notification display after message batch processing + - Implements throttling to prevent notification spam (5-second cooldown) + - Retrieves filtered notification count from NotificationService after processing + +3. **MessageService Integration** (`src/services/MessageService.ts`) + - Increments notification count for DM posts from other users + - Increments notification count for space mentions and replies (based on user's per-space settings) + - Filters out sync messages, reactions, edits, and own messages + - Handles DM paths (Double Ratchet) and space paths (Triple Ratchet) + +4. **User Settings UI** (`src/components/modals/UserSettingsModal.tsx`) + - New "Notifications" settings category + - Toggle switch to enable/disable desktop notifications + - Handles permission requests and displays current status + - Shows appropriate messages for unsupported browsers or blocked permissions + +## Technical Implementation + +### NotificationService Class + +The `NotificationService` is implemented as a singleton to ensure consistent state management across the application. + +#### Key Methods: + +- `isNotificationSupported()`: Checks if the browser supports the Notification API +- `getPermissionStatus()`: Returns current permission status ('granted', 'denied', or 'default') +- `requestPermission()`: Requests notification permission from the user (must be triggered by user interaction) +- `showUnreadMessagesNotification(unreadCount)`: Shows a generic notification for new unread messages (legacy) +- `showContextualNotification(count, metadata)`: Shows a contextual notification with sender and type info +- `showNotification(options)`: Shows a custom notification with configurable options +- `shouldRequestPermission()`: Helper to check if permission should be requested +- `resetPendingNotificationCount()`: Resets the pending count and metadata at the start of each WebSocket batch +- `addPendingNotification(metadata)`: Called by MessageService for qualifying messages with context +- `getPendingNotificationData()`: Returns count and latest notification metadata for current batch + +#### NotificationMetadata Type: + +```typescript +type NotificationMetadata = { + type: 'dm' | 'mention' | 'reply'; + senderName: string; + spaceName?: string; + mentionType?: 'user' | 'role' | 'everyone'; + roleName?: string; +}; +``` + +#### Browser Compatibility: + +- **Safari**: Special handling to exclude icon property (not supported) +- **iOS Safari**: Fixed in commit `ab1acbb` to allow icons to be set (though they may not display) +- **Permission API**: Uses both promise-based and callback-based approaches for broader compatibility + +### WebSocket Integration + +The WebSocket provider coordinates with NotificationService and MessageService to notify users of new messages: + +1. **Batch Reset**: At the start of each WebSocket message batch, `resetPendingNotificationCount()` is called +2. **Message Processing**: MessageService processes each message and calls `incrementPendingNotificationCount()` for qualifying messages +3. **Notification Trigger**: After all messages are processed, the filtered count is retrieved and used to show notifications +4. **Throttling**: Notifications are throttled to one per 5 seconds to prevent spam +5. **Visibility Check**: Notifications only show when the app is in the background (handled by NotificationService) + +### Message Filtering + +The following messages trigger notifications: + +**DM Messages:** +- DM posts from other users +- New posts only (reactions, edits excluded) +- Messages from others (your own messages from other devices don't trigger) + +**Space Messages** (respects per-space settings): +- @you mentions (when `mention-you` enabled in Space Settings) +- @everyone mentions (when `mention-everyone` enabled) +- @role mentions (when `mention-roles` enabled and user has the role) +- Replies to your messages (when `reply` enabled) +- Muted spaces never trigger notifications + +### User Interface + +The User Settings modal includes a new "Notifications" category with: + +- **Toggle Switch**: Enable/disable desktop notifications +- **Permission Handling**: + - Automatically requests permission when user enables notifications + - Shows appropriate messages for different permission states + - Informs users how to change browser settings if needed +- **Browser Support Detection**: Displays warning if notifications aren't supported + +## Usage Flow + +1. **Initial State**: Notifications are disabled by default +2. **User Enables**: User navigates to Settings > Notifications and toggles the switch +3. **Permission Request**: Browser prompts user to allow/block notifications +4. **Notification Display**: When new messages arrive while app is backgrounded, a notification appears +5. **User Interaction**: Clicking the notification focuses the app window + +## Notification Behavior + +### Display Rules: + +- Only show when document is not visible or doesn't have focus +- Auto-dismiss after 5 seconds +- Use consistent "quorum-unread-messages" tag to prevent duplicate notifications +- Click action focuses the app window + +### Message Content: + +- **Title**: "Quorum" +- **Body** (contextual based on message type): + - DM: "New message from Alice" + - @you mention: "Bob mentioned you in Space Name" + - @everyone mention: "Bob mentioned @everyone in Space Name" + - @role mention: "Bob mentioned @Admins in Space Name" + - Reply: "Carol replied to your message in Space Name" + - Multiple notifications: "5 new notifications" +- **Icon**: Quorum logo (`/quorumicon-blue.png`) - except on Safari +- **Fallbacks**: "Someone", "a space", "a role" when data is unavailable + +## Internationalization + +All user-facing strings use Lingui for proper localization: + +- Notification permission messages +- Settings UI text +- Notification content + +## Security Considerations + +1. **Permission-based**: Requires explicit user permission +2. **Privacy-conscious**: Notifications include sender name and location, but never message content +3. **User control**: Can be disabled at any time through settings +4. **Browser security**: Leverages browser's built-in notification security model + +## Future Enhancements + +Potential improvements for the notification system: + +1. ~~**Rich notifications**: Include sender name or message preview (with privacy settings)~~ ✅ Implemented +2. **Sound settings**: Allow users to configure notification sounds +3. **Per-conversation settings**: Enable/disable notifications for specific conversations +4. **Service Worker integration**: Persist notifications even when app is closed +5. **Notification actions**: Add quick reply or mark as read buttons +6. **Custom notification schedules**: Do not disturb hours +7. **Global notification categories**: Toggle DMs, mentions, and replies separately in User Settings +8. **Enhanced multi-notification display**: Show "5 new messages including mention from Bob" instead of generic count + +## Testing Considerations + +When testing the notification feature: + +1. **Permission states**: Test all three permission states (granted, denied, default) +2. **Browser compatibility**: Test on Chrome, Firefox, Safari, and mobile browsers +3. **Background detection**: Ensure notifications only show when app is backgrounded +4. **Throttling**: Verify the 5-second cooldown between notifications +5. **User settings**: Confirm toggle state persists and reflects actual permission + +## Known Limitations + +1. **Safari**: Icon support is not available +2. **iOS**: Limited notification API support in mobile Safari +3. **Permission revocation**: Can't programmatically revoke permissions once granted +4. **Cross-origin**: Notifications require same-origin context + +--- + +_Last updated: 2026-01-04_ +_Verified: 2025-12-09 - File path corrected (NotificationService.ts)_ diff --git a/.agents/docs/features/dropdown-panels.md b/.agents/docs/features/dropdown-panels.md new file mode 100644 index 000000000..8c8bfd962 --- /dev/null +++ b/.agents/docs/features/dropdown-panels.md @@ -0,0 +1,53 @@ +--- +type: doc +title: Dropdown Panels +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Dropdown Panels + +## Overview + +The dropdown panel is a versatile UI component used throughout the Quorum desktop and web application to display various types of content, such as search results, pinned messages, and notifications. It is designed to be a flexible and reusable component that can be easily adapted to different use cases. + +## Implementation + +The core of this feature is the `DropdownPanel` component, located in `src/components/ui/DropdownPanel.tsx`. This component is responsible for rendering the panel and its content, and it provides a number of props to customize its appearance and behavior. + +### Desktop Implementation + +On desktop devices, the `DropdownPanel` component renders as a traditional dropdown panel that appears below the element that triggered it. It can be positioned in different ways, such as centered, right-aligned, or below the search bar. + +### Mobile (Touch) Implementation + +On touch devices, the `DropdownPanel` component uses the `MobileDrawer` component to render the content in a bottom sheet. This provides a more mobile-friendly experience, as it is easier to interact with a bottom sheet on a touch screen. + +The `DropdownPanel` component automatically detects whether the user is on a touch device and renders the appropriate component. This is done using the `isTouchDevice` utility function. + +The `MobileDrawer` component, located in `src/components/ui/MobileDrawer.tsx`, provides a swipe-to-close gesture and a backdrop that can be tapped to close the drawer. + +## Content Types + +The `DropdownPanel` component is used to display the following types of content: + +### Pinned Messages + +The `PinnedMessagesPanel` component uses the `DropdownPanel` to display a list of pinned messages in a channel. On desktop, this appears as a dropdown panel on the right side of the screen. On mobile, it appears as a bottom sheet. + +### Notifications + +The `NotificationPanel` component uses the `DropdownPanel` to display a list of notifications. Similar to the pinned messages panel, it appears as a dropdown on desktop and a bottom sheet on mobile. + +### Search Results + +The `SearchResults` component uses the `DropdownPanel` to display a list of search results. On desktop, the results appear in a dropdown below the search bar. On mobile, the search input and results are displayed in a bottom sheet. This provides a more immersive search experience on smaller screens. + +## Conclusion + +The `DropdownPanel` and `MobileDrawer` components work together to provide a flexible and responsive UI for displaying various types of content. By automatically switching between a dropdown panel and a bottom sheet based on the user's device, we can provide an optimal user experience across all platforms. + +--- + +_Verified: 2025-12-09 - File paths confirmed current_ diff --git a/.agents/docs/features/input-validation-reference.md b/.agents/docs/features/input-validation-reference.md new file mode 100644 index 000000000..c910c6045 --- /dev/null +++ b/.agents/docs/features/input-validation-reference.md @@ -0,0 +1,332 @@ +--- +type: doc +title: Input & Textarea Validation Reference +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2025-11-23T00:00:00.000Z +--- + +# Input & Textarea Validation Reference + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Overview + +This document provides a quick reference for all input and textarea validations implemented across the application. For detailed security analysis, see [security.md](./security.md). + +## Validation Types & Limits + +### Character Length Limits + +| Input Type | Limit | Threshold Display | Constant | Files | +|------------|-------|------------------|----------|-------| +| **Message Content** | 2500 chars | 80% (2000 chars) | `MAX_MESSAGE_LENGTH` | `validation.ts:47`<br>`useMessageValidation.ts`<br>`MessageComposer.tsx`<br>`MessageComposer.native.tsx` | +| **Display Names** | 40 chars | N/A | `MAX_NAME_LENGTH` | `validation.ts:35`<br>`useDisplayNameValidation.ts`<br>Onboarding, User Settings & Space Settings modals | +| **Space Names** | 40 chars | N/A | `MAX_NAME_LENGTH` | `validation.ts:35`<br>`useSpaceNameValidation.ts`<br>`CreateSpaceModal.tsx` | +| **Topics/Descriptions** | 80 chars | N/A | `MAX_TOPIC_LENGTH` | `validation.ts:41`<br>Channel & Space settings | +| **Mention Display Names** | 200 chars | N/A | Hardcoded | `MessageMarkdownRenderer.tsx:203` | + +### Content Security Validation + +| Validation Type | Rule | Blocked Patterns | Purpose | Files | +|-----------------|------|------------------|---------|-------| +| **XSS Prevention** | `DANGEROUS_HTML_PATTERN` | `<` + letter/`/`/`!`/`?` | Prevent HTML tag injection | `validation.ts:38`<br>`validateNameForXSS()` | +| **Mention Count Limit** | Max 20 mentions | N/A | Prevent notification spam | `mentionUtils.ts`<br>`useMessageComposer.ts` | +| **Token Breaking** | Auto-removal | `>>>` | Prevent token injection | `MessageMarkdownRenderer.tsx:203` | + +**Allowed Safe Patterns**: `<3` (heart), `>_<` (emoticon), `->`, `<-` (arrows), `<<`, `>>`, `<>`, quotes (`"`, `'`). + +**Blocked Dangerous Patterns**: `<script>`, `<img`, `</div>`, `<!--`, `<?xml` - anything that starts an HTML tag. + +### Reserved Name Validation (Display Names Only) + +Protects against impersonation and mention conflicts. **Only applies to user display names**, not space/channel/group names. + +| Validation Type | Names Protected | Strategy | Purpose | +|-----------------|-----------------|----------|---------| +| **Mention Conflict** | `everyone`, `here`, `mod`, `manager` | Exact match only (case insensitive) | Prevent conflict with @mentions | +| **Anti-Impersonation** | `admin`, `administrator`, `moderator`, `support` | Homoglyph + Word Boundary | Prevent staff impersonation | + +#### Homoglyph Protection (Anti-Impersonation Only) + +Characters mapped to letters: `0→o`, `1→i`, `3→e`, `4→a`, `5→s`, `7→t`, `@→a`, `$→s`, `!→i`, `|→l` + +**Examples - BLOCKED:** +| Input | Normalized | Reason | +|-------|------------|--------| +| `admin` | `admin` | Exact match | +| `ADM1N` | `admin` | Homoglyph (1→i) + case | +| `@dmin` | `admin` | Homoglyph (@→a) | +| `admin team` | `admin team` | Word boundary (space) | +| `moderator123` | `moderator123` | Word boundary (numbers) | +| `supp0rt 24/7` | `support 24/7` | Homoglyph + boundary | + +**Examples - ALLOWED:** +| Input | Reason | +|-------|--------| +| `sysadmin` | Embedded, no word boundary | +| `supporting` | Embedded, no word boundary | +| `padministrator` | Embedded, no word boundary | +| `everyone loves me` | "everyone" not exact match | +| `3very0ne` | No homoglyph check for "everyone" | +| `here we go` | "here" not exact match | +| `h3r3` | No homoglyph check for "here" | +| `mod team` | "mod" not exact match | +| `m0d` | No homoglyph check for "mod" | +| `manager position` | "manager" not exact match | +| `m4nager` | No homoglyph check for "manager" | + +#### Implementation Details +The `isImpersonationName()` function uses THREE checks to catch all variations: +1. **Original lowercase** with word boundaries → catches "admin123", "admin-team", "blah-administrator" +2. **Homoglyph-normalized** with word boundaries → catches "adm1n", "@dmin", "supp0rt" +3. **Starts/ends check** on normalized string → catches "m0derat0r123" where trailing digits become letters after normalization + +#### Implementation Files +- `validation.ts`: `HOMOGLYPH_MAP`, `IMPERSONATION_NAMES`, `MENTION_RESERVED_NAMES`, `normalizeHomoglyphs()`, `isImpersonationName()`, `isMentionReserved()`, `getReservedNameType()`, `isReservedName()` +- `useDisplayNameValidation.ts`: Uses `getReservedNameType()` for validation + +### Address & ID Validation + +| Type | Format | Validation | Files | +|------|--------|------------|-------| +| **IPFS Addresses** | `Qm[44 chars]` | Base58 + CID format | `validation.ts:109-154`<br>`isValidIPFSCID()` | +| **Channel IDs** | IPFS CID format | Same as addresses | `validation.ts:182`<br>`isValidChannelId()` | + +## Validation Implementation + +### Core Validation Files + +``` +src/utils/validation.ts +├── Constants: MAX_MESSAGE_LENGTH, MAX_NAME_LENGTH, MAX_TOPIC_LENGTH +├── XSS Functions: validateNameForXSS(), sanitizeNameForXSS() +├── Reserved Names: HOMOGLYPH_MAP, IMPERSONATION_NAMES, MENTION_RESERVED_NAMES +│ ├── normalizeHomoglyphs() - Convert lookalike chars to letters +│ ├── isImpersonationName() - Check with homoglyph + word boundary +│ ├── isMentionReserved() - Check exact "everyone"/"here" match +│ ├── getReservedNameType() - Returns 'mention' | 'impersonation' | null +│ └── isReservedName() - Simple boolean check +├── IPFS Functions: isValidIPFSCID(), isValidChannelId() +└── Error Helpers: getXSSValidationError() +``` + +### Validation Hooks + +``` +src/hooks/business/validation/ +├── useMessageValidation.ts → Message length + 80% threshold logic +├── useDisplayNameValidation.ts → Display name XSS + length + RESERVED NAME validation +├── useSpaceNameValidation.ts → Space name XSS + length (NO reserved name check) +└── index.ts → Exports all validation hooks +``` + +### UI Integration Points + +#### Message Composer +- **Files**: `MessageComposer.tsx`, `MessageComposer.native.tsx`, `useMessageComposer.ts` +- **Validations**: + - Character count with 80% threshold display (2000/2500 chars) + - Bold red counter when over limit + - Send button disabled when over limit + - Mention count validation (max 20) +- **UX**: Unified error display (counter | separator | error messages) + +#### User Settings & Onboarding +- **Files**: `Onboarding.tsx`, `UserSettingsModal.tsx`, `SpaceSettingsModal/Account.tsx`, `useOnboardingFlowLogic.ts`, `useSpaceProfile.ts` +- **Validations**: + - XSS protection (blocks `< > " '`) + - Length limit (40 chars) + - Reserved name validation: + - `everyone` - exact match only (mention conflict) + - `admin`, `administrator`, `moderator`, `support` - with homoglyph + word boundary (anti-impersonation) +- **UX**: Real-time validation with error messages + +#### Space Management +- **Files**: `CreateSpaceModal.tsx`, `SpaceSettingsModal.tsx`, `useSpaceCreation.ts` +- **Validations**: Space name XSS + length (40 chars) +- **UX**: Validation on blur and submit + +#### Channel & Topic Fields +- **Files**: Various channel and space settings components +- **Validations**: Topic/description length (80 chars) +- **UX**: Character count display near limit + +## Message Composer Character Limit Details + +### Implementation Flow +1. **Hook**: `useMessageValidation(message)` calculates validation state +2. **Logic**: 80% threshold = `Math.floor(2500 * 0.8) = 2000` characters +3. **Display**: Counter shows when `messageLength >= 2000` +4. **Styling**: + - Normal: `color: var(--color-text-subtle)` + - Over limit: `color: var(--color-text-danger)` + `font-weight: bold` +5. **Submission**: Blocked in `useMessageComposer.ts` when `isOverLimit = true` + +### Responsive Behavior +- **Desktop**: Counter | separator | errors (horizontal layout) +- **Mobile** (≤480px): Counter above errors (vertical stacking) +- **Send button**: Disabled + grayed out when over limit + +### Cross-Platform Consistency +- **Web**: CSS variables for colors (`--color-text-danger`, `--color-text-subtle`) +- **Native**: Theme colors (`theme.colors.text.danger`, `theme.colors.text.subtle`) +- **Logic**: Shared `useMessageValidation` hook ensures identical behavior + +## Security Integration + +### Layered Defense (from security.md) +1. **Input Validation** → This document's validations +2. **Placeholder Tokens** → Safe mention rendering +3. **React Auto-Escaping** → JSX attribute protection + +### Regex DoS Protection +- **Display name limits**: 200 chars max in mention tokens +- **Bounded quantifiers**: `{0,200}`, `{1,50}` instead of `*`, `+` +- **Token sanitization**: Remove `>>>` characters automatically + +### Rate Limiting +- **Mention extraction**: Max 20 mentions per message +- **Submission blocking**: Graceful error in MessageComposer + +## Quick Reference Commands + +### Check Current Validation State +```bash +# Search for validation usage +grep -r "MAX_MESSAGE_LENGTH\|MAX_NAME_LENGTH" src/ +grep -r "validateNameForXSS\|useMessageValidation" src/ +grep -r "isOverLimit\|shouldShowCounter" src/ +``` + +### Common Validation Patterns +```typescript +// Message validation +const messageValidation = useMessageValidation(message); +const isValid = !messageValidation.isOverLimit; + +// Name validation +const isValidName = validateNameForXSS(name) && name.length <= MAX_NAME_LENGTH; + +// Address validation +const isValidAddress = isValidIPFSCID(address); +``` + +## Recent Updates + +### 2025-11-23: Pattern-Based XSS Validation (Major Relaxation) +- **Change**: Replaced character-based blocking with pattern-based detection + - Old: `DANGEROUS_HTML_CHARS = /[<>"']/` (blocked all `<`, `>`, `"`, `'`) + - New: `DANGEROUS_HTML_PATTERN = /<[a-zA-Z\/!?]/` (only blocks HTML tag starts) +- **Reason**: + - Quotes and standalone angle brackets are safely handled by React's JSX auto-escaping + - HTML5 spec requires `<` immediately followed by letter for tag recognition + - Unicode lookalikes are NOT parsed as HTML tags by browsers +- **User Benefit**: Now allowed: + - Emoticons: `<3`, `>_<`, `>.<` + - Arrows: `->`, `<-`, `=>`, `<=` + - Decorative: `<<Name>>`, `>>quote` + - Quotes: `O'Brien`, `"The Legend"` +- **Still Blocked**: `<script>`, `<img`, `</div>`, `<!--`, `<?xml` +- **Security Safeguards**: + - Fixed `SearchService.highlightSearchTerms` with proper HTML and regex escaping + - Security analyst verified all attack vectors are covered +- **Files Modified**: + - `src/utils/validation.ts`: New `DANGEROUS_HTML_PATTERN`, updated functions + - `src/services/SearchService.ts`: Added `escapeHtml()` and `escapeRegex()` methods + +### 2025-11-21: Enhanced Reserved Name Validation with Anti-Impersonation +- **Issue**: Only "everyone" was blocked; impersonation attempts like "admin", "supp0rt" were allowed +- **Solution**: Two-tier validation system: + 1. **Mention Keywords** (`everyone`, `here`, `mod`, `manager`) - Simple exact match (case insensitive) for @mention conflicts + 2. **Anti-impersonation** (`admin`, `administrator`, `moderator`, `support`) - Homoglyph normalization + word boundary detection +- **Homoglyph Protection**: Maps lookalike characters (0→o, 1→i, 3→e, 4→a, 5→s, 7→t, @→a, $→s, !→i, |→l) - Only for anti-impersonation +- **Word Boundary**: Allows embedded words (e.g., "sysadmin", "supporting") but blocks separated words (e.g., "admin team", "moderator123") +- **Scope**: Only applies to user display names (not space/channel/group names) +- **Files Modified**: + - `src/utils/validation.ts`: Added `HOMOGLYPH_MAP`, `IMPERSONATION_NAMES`, `MENTION_RESERVED_NAMES`, `normalizeHomoglyphs()`, `isImpersonationName()`, `isMentionReserved()`, `getReservedNameType()`, `isReservedName()` + - `src/hooks/business/validation/useDisplayNameValidation.ts`: Updated to use `getReservedNameType()` with type-specific error messages + +### 2025-11-19: Space Settings Modal Validation Fix +- **Issue**: SpaceSettingsModal Account.tsx was missing proper display name validation +- **Fix**: Added `useDisplayNameValidation` hook to both Account.tsx component and useSpaceProfile.ts hook +- **Impact**: Now includes XSS protection, 40-character limit, and reserved name ("everyone") validation +- **Files Modified**: + - `src/components/modals/SpaceSettingsModal/Account.tsx`: Added proper validation hook usage + - `src/hooks/business/spaces/useSpaceProfile.ts`: Updated to use `useDisplayNameValidation` instead of basic empty check + +## Testing Scenarios + +### Character Limits +- [ ] Message exactly at 2000 chars → Counter appears +- [ ] Message exactly at 2500 chars → Send button disabled +- [ ] Message at 2501+ chars → Bold red counter + disabled send +- [ ] Display name at 40+ chars → Validation error +- [ ] Space name at 40+ chars → Validation error + +### XSS Prevention +- [ ] Name with `<script>` → Blocked with error message +- [ ] Name with `<img>` → Blocked with error message +- [ ] Name with `</div>` → Blocked with error message +- [ ] Name with `<!--` → Blocked with error message +- [ ] Name with `<3` → Allowed (heart emoticon) +- [ ] Name with `>_<` → Allowed (emoticon) +- [ ] Name with `->` or `<-` → Allowed (arrows) +- [ ] Name with `<<` or `>>` → Allowed (decorative) +- [ ] Name with `&` → Allowed (safe character) +- [ ] Name with `"` → Allowed (React auto-escapes) +- [ ] Name with `'` → Allowed (e.g., `O'Brien`) +- [ ] International chars → Allowed (émojis, 北京, etc.) + +### Address Validation +- [ ] Valid IPFS CID → Accepted +- [ ] Invalid format → Validation error +- [ ] Wrong length → Validation error + +### Reserved Name Validation (Display Names Only) +**Mention Keywords - Exact Match:** +- [ ] `everyone` → Blocked +- [ ] `Everyone` → Blocked (case insensitive) +- [ ] `EVERYONE` → Blocked +- [ ] `everyone loves me` → Allowed (not exact match) +- [ ] `3very0ne` → Allowed (no homoglyph check for everyone) +- [ ] `here` → Blocked +- [ ] `Here` → Blocked (case insensitive) +- [ ] `HERE` → Blocked +- [ ] `here we go` → Allowed (not exact match) +- [ ] `h3r3` → Allowed (no homoglyph check for here) +- [ ] `mod` → Blocked +- [ ] `Mod` → Blocked (case insensitive) +- [ ] `MOD` → Blocked +- [ ] `mod team` → Allowed (not exact match) +- [ ] `m0d` → Allowed (no homoglyph check for mod) +- [ ] `manager` → Blocked +- [ ] `Manager` → Blocked (case insensitive) +- [ ] `MANAGER` → Blocked +- [ ] `manager position` → Allowed (not exact match) +- [ ] `m4nager` → Allowed (no homoglyph check for manager) + +**Anti-Impersonation - Homoglyph + Word Boundary:** +- [ ] `admin` → Blocked +- [ ] `ADM1N` → Blocked (homoglyph 1→i) +- [ ] `@dmin` → Blocked (homoglyph @→a) +- [ ] `admin team` → Blocked (word boundary) +- [ ] `moderator123` → Blocked (word boundary) +- [ ] `supp0rt` → Blocked (homoglyph 0→o) +- [ ] `sysadmin` → Allowed (embedded) +- [ ] `supporting` → Allowed (embedded) +- [ ] `padministrator` → Allowed (embedded) + +## Related Documentation + +- **Security Details**: [security.md](./security.md) +- **Architecture**: Message validation in useMessageComposer hook integration +- **Task History**: `.agents/tasks/.done/` - Character limit implementation +- **Validation Constants**: `src/utils/validation.ts` + +--- + + +_Last Updated: 2025-11-23 (Pattern-based XSS validation; allows emoticons, arrows, quotes; fixed SearchService XSS)_ +_Verified: 2025-12-09 - File paths confirmed current_ diff --git a/.agents/docs/features/invite-system-analysis.md b/.agents/docs/features/invite-system-analysis.md new file mode 100644 index 000000000..a0e5a1101 --- /dev/null +++ b/.agents/docs/features/invite-system-analysis.md @@ -0,0 +1,357 @@ +--- +type: doc +title: Invite System Documentation +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Invite System Documentation + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Overview + +The Quorum desktop application features a sophisticated dual-key invite system that supports both private and public invite links for spaces. This document explains how the invite system works, its architecture, and important behavioral considerations. + +## Architecture Overview + +The invite system operates through several key components: + +### Core Components + +1. **SpaceSettingsModal.tsx** - Main UI for managing invites (lines 742-907) +2. **useInviteManagement.ts** - Hook managing invite state and logic +3. **useInviteValidation.ts** - Hook for validating invite links +4. **useSpaceJoining.ts** - Hook for joining spaces via invites +5. **InvitationService.ts** - Encapsulates core invite-related business logic and interacts with MessageDB for persistence. +6. **MessageDB Context** - Provides access to InvitationService and other services. +7. **InviteLink.tsx** - Component for displaying and processing invite links + +### Data Flow + +1. **Invite Creation**: Handled by `InvitationService` via `generateNewInviteLink()` +2. **Invite Sending**: Sent via `sendInviteToUser()` to direct message conversations (logic likely within `MessageService` or `InvitationService`) +3. **Invite Processing**: Links parsed and validated through `useInviteValidation` hook +4. **Space Joining**: Users join via `InvitationService`'s `joinInviteLink()` function + +## Invite Types and Behavior + +### Private vs Public: Key Differences + +| Aspect | Private Invite | Public Invite | +|--------|----------------|---------------| +| **Evals stored** | Locally on inviter's device | Uploaded to server | +| **Eval consumed when** | Link is **generated** | Link is **used to join** | +| **URL changes each generation** | Yes (unique `secret` each time) | No (stable until regenerated) | +| **One link = how many people** | 1 person per link | Unlimited (until server evals exhausted) | +| **Crypto material in URL** | Full (`template`, `secret`, `hubKey`) | Minimal (`configKey` only) | +| **How joiner gets crypto material** | Embedded in URL | Fetched from server via `getSpaceInviteEval()` | + +### Private Invites + +Private invites are sent directly to users via existing conversations or manual address entry. They use unique cryptographic keys for each space and remain private between the sender and recipient. + +**Characteristics:** +- **One link per person**: Each generated link contains a unique `secret` that can only be used once +- Sent through direct messages +- **Eval consumed on generation**: Each invite generation consumes one secret from the local `evals` array +- **Limited supply**: Spaces have a limited number of secrets available for private invites +- URL contains all crypto material needed to join (no server fetch required) + +### Public Invite Links + +Public invite links are shareable URLs that anyone can use to join a space. They use a different key system from private invites. + +**Characteristics:** +- **Same URL for everyone**: Share one link with unlimited people +- **Eval consumed on join**: When someone uses the link, the server provides one eval from the uploaded pool +- Can be shared anywhere (social media, websites, etc.) +- Regeneratable (invalidates previous public link by creating new keys) +- Switches system to public-only mode (private invites become public URL) + +## Critical System Behavior + +### ⚠️ System Switch Behavior + +When you generate a public invite link, the system switches from private-only to public-only mode. + +**Exact Behavior Flow:** + +1. **Phase 1 - Private Only Mode:** + - Send private invites via "existing conversations" or manual address + - Each invite generation consumes one secret from the space's finite `evals` array + - Generated invites remain valid until the space switches to public mode + - **Limited capacity**: Spaces can only generate a finite number of private invites + +2. **Phase 2 - The Switch:** + - Generate first public link → `space.inviteUrl` is set + - `constructInviteLink()` now returns the public URL immediately (line 57-58) + - **ALL subsequent "private" invites become the public URL** + +3. **Phase 3 - Public-Only Mode:** + - "Send invite to existing conversations" now sends the **same public URL** to everyone + - No more unique private invites possible while public link exists + - Can generate new public links (invalidates old one by creating new config key) + - **Cannot delete**: True deletion requires backend API endpoint that doesn't exist + +**🔥 Critical Code Evidence:** + +```typescript +// constructInviteLink() - InvitationService.ts:55-59 +async constructInviteLink(spaceId: string) { + const space = await this.messageDB.getSpace(spaceId); + if (space?.inviteUrl) { + return space.inviteUrl; // ← Returns public URL, skips private invite generation! + } + // ... private invite code never reached once inviteUrl exists +} +``` + +**LIMITATION:** Cannot return to private-only mode - deletion requires backend API support. + +### Evals (Polynomial Evaluations) + +Evals are cryptographic secrets used exclusively for invite generation. They are NOT used for message encryption. + +**Eval Allocation:** + +| Operation | Evals Generated | Notes | +|-----------|-----------------|-------| +| Space creation | ~10,000 (SDK default) | No `total` param passed | +| Generate public invite link | `members + 200` | Replaces previous session | +| Kick user (rekey) | `members + 200` | Replaces previous session | + +**Important**: When `generateNewInviteLink()` is called, it creates a NEW session that **replaces** the old encryption state. The original ~10K evals from space creation are discarded and replaced with ~200 evals. + +**Eval Consumption:** + +```typescript +// InvitationService.ts - Private invite consumes one eval +const index_secret_raw = sets[0].evals.shift(); // Removes from array +await this.messageDB.saveEncryptionState( + { ...response[0], state: JSON.stringify(sets[0]) }, // Saves updated state + true +); +``` + +### Known Issue: Config Sync Bloat + +See: `.agents/bugs/encryption-state-evals-bloat.md` + +Space creation allocates ~10K evals (~2MB per space) which causes config sync failures for users who create many spaces. The `generateNewInviteLink` and `kickUser` operations correctly use `members + 200`, suggesting the 10K default at creation may be unintentional. + +**Proposed improvement**: On-demand eval generation instead of upfront allocation. + +## Technical Architecture Details + +### Dual Key System Architecture + +**Two Separate Invite Systems:** + +1. **Original Space Keys** (Space Creation): + + ``` + Created: When space is first created + Keys: config_key, hub_key (from space creation) + Used by: constructInviteLink() when space.inviteUrl is null + Lifetime: Permanent until public links are enabled + ``` + +2. **Public Link Keys** (On-Demand Generation): + ``` + Created: When generateNewInviteLink() is called + Keys: New X448 key pairs + updated config + Used by: constructInviteLink() when space.inviteUrl exists + Lifetime: Until regenerated + ``` + +### Invite Link Structures + +**Private Invites (Original Keys + Consumed Secrets):** + +``` +https://[domain]/#spaceId={SPACE_ID}&configKey={ORIGINAL_CONFIG_PRIVATE_KEY}&template={TEMPLATE}&secret={CONSUMED_SECRET}&hubKey={HUB_PRIVATE_KEY} +``` + +**Note**: The `secret` parameter comes from `evals.shift()` - each private invite permanently consumes one secret from the space's finite pool. + +**Public Links (Generated Keys):** + +``` +https://[domain]/invite/#spaceId={SPACE_ID}&configKey={NEW_CONFIG_PRIVATE_KEY} +``` + +**Domain Resolution (as of September 22, 2025):** +- **Production** (`app.quorummessenger.com`): Uses `qm.one` for short links +- **Staging** (`test.quorummessenger.com`): Uses `test.quorummessenger.com` +- **Local Development** (`localhost`): Uses `localhost:port` with http protocol + +### Cryptographic Flow + +**Private Invites (constructInviteLink):** + +1. **Check**: Does `space.inviteUrl` exist? +2. **If NO**: Use original space creation keys +3. **Secret Consumption**: `sets[0].evals.shift()` permanently removes one secret from the finite pool +4. **Template Construction**: Uses existing encryption states and ratchets +5. **Link Generation**: Includes template, consumed secret, and hub keys +6. **State Update**: The `InvitationService` now handles saving the modified encryption state (with one less secret) back to the database. +7. **Validation**: Uses original config keys for decryption + +**Public Links (generateNewInviteLink):** + +1. **Key Generation**: Create new X448 key pair +2. **Key Storage**: Save new config keys to database +3. **Space Update**: Set `space.inviteUrl` with new link +4. **Manifest Creation**: Encrypt space data with new keys +5. **Validation**: Uses new config keys for decryption + +### Key Decision Logic + +```typescript +// The critical decision point in constructInviteLink(), now orchestrated by InvitationService +if (space?.inviteUrl) { + return space.inviteUrl; // PUBLIC SYSTEM +} else { + // PRIVATE SYSTEM - use original keys. These calls would be internal to a service. + const config_key = await messageDB.getSpaceKey(spaceId, 'config'); + const hub_key = await messageDB.getSpaceKey(spaceId, 'hub'); +} +``` + +### Database Operations + +- **Space Keys**: Multiple types stored ('hub', 'owner', 'config', 'space') +- **Key Evolution**: Original keys preserved, new keys added when public links enabled +- **Space State**: `inviteUrl` property determines which key system is active +- **Member Management**: Real-time updates via WebSocket sync +- **Message History**: Persistent storage in local MessageDB, managed by `MessageService`. + +## Recommendations + +### Security Improvements + +1. **Implement Banned User Tracking**: Create persistent ban list to prevent re-joining +2. **Enhanced Validation**: Add banned user checks in invite processing + + +### Code Quality + +1. **Centralized Validation**: Move invite validation logic to single location +2. **Type Safety**: Improve TypeScript definitions for invite-related types +3. **Error Handling**: More granular error categorization and handling +4. **Documentation**: Better code comments explaining the dual key system + +### Current Public Invite Link UI Flow + +**No Link State**: Shows warning text + "Generate Public Invite Link" button → Direct generation → Link appears with "Generate New Link" button + +**Link Exists State**: Shows copyable link field with "Generate New Link" button (modal confirm) → Operations show loading callouts → Success callouts (3s auto-dismiss) + +**Note**: Delete button was removed (2025-10-04) as true deletion requires backend API support that doesn't exist. + +## Summary + +The invite system uses a sophisticated **dual key architecture** that supports both private and public invite modes. Key characteristics: + +1. **Private invites consume finite secrets** - each generation permanently uses one secret from the `evals` array +2. **Public link mode is NOT reversible** - switching to public is permanent (deletion requires backend API) +3. **"Expiration" errors are validation failures** - often due to secret exhaustion or key mismatches +4. **No persistent user blocking** - kicked users can easily rejoin +5. **Regeneration invalidates old links** - creates new config key, orphaning old server data + +The system is cryptographically sound but switching to public mode is a one-way operation with current implementation. + +## Frequently Asked Questions + +### Can kicked users receive new invites? + +**Answer: YES** - There are no blocks preventing kicked users from receiving invites in existing conversations. + +The system allows selecting any existing conversation and does not check if users were previously kicked. Previously kicked users can still receive invite messages in their direct conversations. + +### Why do I get "Invite Link Expired" errors? + +**Answer:** This occurs due to cryptographic validation failures, not time-based expiration. + +**Common Causes:** +- **Secret exhaustion**: Space has run out of secrets in the `evals` array for private invites +- **Key system conflict**: Using private invite links after switching to public mode +- Links using old keys after public link generation +- Missing or corrupted space configuration + +The error message "expired or invalid" is misleading - invite links don't actually expire based on time, but private invites have limited capacity. + +### Can kicked users rejoin via public invite links? + +**Answer: YES** - Kicked users can immediately rejoin via public invite links. + +There is no persistent "ban list" or kicked user tracking. Public invite links bypass membership checks entirely. + +**Prevention methods:** +- Regenerate invite links after kicking users (creates new config key) +- Manually manage invite distribution +- ~~Switch to private-only invites~~ (not possible once in public mode) + +### Can I go back to private-only mode after enabling public links? + +**Answer: NO** - Cannot return to private-only mode with current implementation. + +True deletion requires a backend `DELETE /invite/evals` API endpoint that doesn't exist. You can only: +- **Regenerate** the public link (invalidates old one by creating new config key) +- **Cannot delete** server-side invite evals (remains in public mode permanently) + +--- + +## Environment-Specific Invite System (September 2025 Update) + +### Dynamic Domain Resolution + +The invite system now dynamically detects the environment and uses appropriate domains: + +**Implementation:** `src/utils/inviteDomain.ts` + +1. **Production Environment** (`app.quorummessenger.com`): + - Generates invite links with `qm.one` (short domain) + - Accepts both `qm.one` and `app.quorummessenger.com` links + - Maintains backward compatibility with all existing invites + +2. **Staging Environment** (`test.quorummessenger.com`): + - Generates invite links with `test.quorummessenger.com` + - Only accepts staging domain links (isolation from production) + - Prevents cross-environment invite confusion + +3. **Local Development** (`localhost:port`): + - Generates invite links with `http://localhost:port` + - Accepts all domains for comprehensive testing + - Supports common development ports (3000, 5173, etc.) + +### Key Benefits: +- **No hardcoded domains**: Automatically adapts to deployment environment +- **Staging isolation**: Test environment works independently +- **Local testing**: Developers can test invite flows without deployment +- **Production safety**: No changes to existing production behavior + +### Files Modified: +- `src/utils/inviteDomain.ts` - New utility for dynamic domain resolution +- `src/components/context/MessageDB.tsx` - Provides access to `InvitationService` which uses dynamic domain for invite generation +- `src/components/modals/JoinSpaceModal.tsx` - Uses dynamic domain for display +- `src/hooks/business/spaces/useInviteValidation.ts` - Dynamic validation prefixes + +## Duplicate Prevention (Fixed) + +**Issues Fixed**: Multiple join messages, redundant invites to existing members + +**Changes**: +- `InvitationService.joinInviteLink()` - Added membership check before saving member/sending join message +- `useInviteManagement.invite()` - Added membership validation with warning display +- `MessageDB` context - Provides access to `SpaceService` for `getSpaceMember()` for membership checks + +**Result**: Clean invite flow with no duplicate joins or redundant invite sending. + +--- + + +_Covers: SpaceEditor.tsx, useInviteManagement.ts, useInviteValidation.ts, useSpaceJoining.ts, InvitationService.ts, MessageDB Context, InviteLink.tsx, inviteDomain.ts_ diff --git a/.agents/docs/features/kick-user-system.md b/.agents/docs/features/kick-user-system.md new file mode 100644 index 000000000..99608ad67 --- /dev/null +++ b/.agents/docs/features/kick-user-system.md @@ -0,0 +1,471 @@ +--- +type: doc +title: Kick User System Documentation +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Kick User System Documentation + +## Overview + +The Kick User system allows **space owners** to remove users from spaces through a secure, cryptographically-verified process. Only the space owner can kick users - this is enforced at the protocol level via ED448 signature verification. The system has been refactored to use cross-platform primitives and extracted business logic for maintainability and mobile compatibility. + +## Architecture + +### Component Structure + +``` +UserProfile.tsx (Kick button) + ↓ +ModalProvider (useModals().openKickUser) + ↓ +KickUserModal (UI Component with user info display) + ↓ +useUserKicking (Business Logic Hook) + ↓ +SpaceService.kickUser (Core Implementation via MessageDB Context) + ↓ +IndexedDB + Server API +``` + +### Modal State Flow + +The kick modal uses the centralized modal state system in `useModalState.ts`: + +```tsx +// State interface +interface KickUserTarget { + address: string; + displayName: string; + userIcon?: string; +} + +// Opening the modal +openKickUser(target: KickUserTarget) → dispatch({ type: 'OPEN_KICK_USER', target }) + +// Modal renders when state.kickUser.isOpen && state.kickUser.target +``` + +## Components + +### KickUserModal (UI Component) + +**Location**: `src/components/modals/KickUserModal.tsx` + +**Purpose**: Cross-platform modal UI for kicking users with confirmation flow. + +**Key Features**: + +- ✅ **Cross-platform compatible** - Uses only primitives (Container, Text, FlexRow, Button, Modal, Spacer) +- ✅ **User identification** - Displays target user's avatar, name, and truncated address +- ✅ **Confirmation flow** - Requires two clicks to prevent accidental kicks +- ✅ **Loading states** - Shows disabled state and overlay during kick operation +- ✅ **Auto-reset** - Confirmation resets when modal closes +- ✅ **Cancel option** - Explicit cancel button alongside kick action + +**Props**: + +```tsx +interface KickUserModalProps { + visible: boolean; // Modal visibility + onClose: () => void; // Close handler + userName: string; // Display name of user to kick + userIcon?: string; // Avatar URL of user to kick + userAddress: string; // Address of user to kick +} +``` + +**Primitives Used**: + +- `Modal` - Base modal container +- `Container` - Layout containers with responsive props +- `Text` - Typography with variant styling +- `FlexRow` - Horizontal layout +- `Spacer` - Vertical spacing between sections +- `Button` - Action buttons (Cancel and Kick) +- `UserAvatar` - User avatar display component + +### useUserKicking (Business Logic Hook) + +**Location**: `src/hooks/business/user/useUserKicking.ts` + +**Purpose**: Encapsulates all kick user business logic, state management, and API interactions. + +**State Management**: + +```tsx +const [kicking, setKicking] = useState(false); // Loading state +const [confirmationStep, setConfirmationStep] = useState(0); // 0: initial, 1: confirm +const [confirmationTimeout, setConfirmationTimeout] = + useState<NodeJS.Timeout | null>(null); +``` + +**Dependencies**: + +- `useParams()` - Gets spaceId from URL +- `usePasskeysContext()` - Current user's passkey info +- `useRegistrationContext()` - User's keyset for cryptographic operations +- `useRegistration()` - User's registration data +- `useQueryClient()` - React Query cache management +- `useMessageDB()` - Provides access to specialized services (e.g., `SpaceService`) for database operations. + +**Key Functions**: + +#### `kickUserFromSpace(userAddress, onSuccess?)` + +- Validates required parameters (spaceId, registration, userAddress) +- Sets loading state +- Calls `SpaceService.kickUser()` (accessed via `useMessageDB()`) with cryptographic parameters +- Invalidates React Query cache for space members +- Executes success callback (typically closes modal) +- Handles errors and cleanup + +#### `handleKickClick(userAddress, onSuccess?)` + +- Implements two-click confirmation flow +- First click: Sets confirmationStep to 1, starts 5-second timeout +- Second click: Clears timeout, executes kick operation +- Auto-resets confirmation after 5 seconds + +#### `resetConfirmation()` + +- Resets confirmation state to 0 +- Clears any active timeout +- Called when modal closes + +**Return Values**: + +```tsx +{ + kicking: boolean; // Current loading state + confirmationStep: number; // Current confirmation step (0 or 1) + handleKickClick: (userAddress, onSuccess?) => void; + resetConfirmation: () => void; +} +``` + +## Core Kick User Logic Flow + +### 1. User Interaction Flow + +``` +User clicks "Kick" button in UserProfile popup + ↓ +openKickUser({ address, displayName, userIcon }) called via ModalProvider + ↓ +Modal shows with: + - User avatar, name, and truncated address + - "This user will be removed from the Space" message + - Cancel and Kick buttons + ↓ +First click on "Kick" → "Click again to confirm" + ↓ +5-second timeout starts (auto-resets) + ↓ +Second click: Execute kick operation + ↓ +Modal shows loading overlay ("Kicking...") + ↓ +Operation completes (~5 seconds) + ↓ +Modal closes, success message appears in chat +``` + +### 2. Technical Implementation Flow + +``` +UserProfile.tsx: openKickUser({ address, displayName, userIcon }) + ↓ +ModalProvider dispatches OPEN_KICK_USER action with target + ↓ +KickUserModal receives props: userName, userIcon, userAddress + ↓ +User confirms → handleKickClick(userAddress) + ↓ +kickUserFromSpace(userAddress) + ↓ +SpaceService.kickUser(spaceId, userAddress, userKeyset, deviceKeyset, registration) (via MessageDB Context) + ↓ +[Server Operations + Local Database Updates] + ↓ +queryClient.invalidateQueries(['SpaceMembers', spaceId]) + ↓ +Modal auto-closes on success +``` + +### 3. `SpaceService.kickUser` Implementation + +**Location**: `src/services/SpaceService.ts` + +**Core Operations**: The `SpaceService.kickUser` method, exposed via the `MessageDB Context`, orchestrates the following operations: + +#### Server-Side Operations: + +1. **Key Generation**: Creates new cryptographic keys (config, space, owner) +2. **Space Registration**: Posts updated space data to server via `apiClient.postSpace()` +3. **Member Notification**: Sends encrypted rekey notifications to remaining members +4. **Kick Notification**: Sends kick notification to the kicked user +5. **Manifest Update**: Updates space manifest excluding kicked user + +#### Local Database Operations: + +1. **Kick Message**: Saves kick confirmation message to local chat history (via `MessageService`) +2. **Encryption State**: Updates encryption state excluding kicked user from peer mappings (via `EncryptionService`) +3. **Member Filtering**: Creates `filteredMembers` list for future encryption sessions + +#### **Important**: + +The `kickUser` function does **NOT** remove the kicked user from the local IndexedDB space members table directly. The user remains visible in the UI until server synchronization updates the local database. + +## Data Flow and Caching + +### 1. Space Members Data Flow + +``` +IndexedDB (via SpaceService.getSpaceMembers) + ↓ +useSpaceMembers React Query Hook + ↓ +Channel.tsx (activeMembers, members, noRoleMembers) + ↓ +Right Sidebar Content (setRightSidebarContent) +``` + +### 2. Cache Invalidation Strategy + +After kick operation completes: + +```tsx +// Invalidate space members cache +await queryClient.invalidateQueries({ + queryKey: ['SpaceMembers', spaceId], +}); +``` + +**Result**: + +- ✅ React Query refetches data from IndexedDB +- ⚠️ **Kicked user still visible** because IndexedDB still contains their record +- ✅ User eventually disappears when server sync updates local database + +### 3. Why Users Remain Visible + +**By Design**: The kicked user remains in the local UI until server synchronization removes them from IndexedDB. This ensures UI consistency with actual server state and prevents optimistic updates that might not match reality. + +**Timeline**: + +1. **Immediate**: Kick operation completes, success message shows +2. **Cache refresh**: React Query refetches same data from IndexedDB +3. **Eventually**: Server sync removes user from local database +4. **Final**: User disappears from sidebar on next data refresh + +## Security and Cryptography + +### Required Parameters for Kick Operation + +```tsx +SpaceService.kickUser( + spaceId: string, // Target space ID + userAddress: string, // User to kick + userKeyset: secureChannel.UserKeyset, // Admin's user keyset + deviceKeyset: secureChannel.DeviceKeyset, // Admin's device keyset + registration: secureChannel.UserRegistration // Admin's registration +) +``` + +### Cryptographic Operations + +1. **Key Rotation**: New encryption keys generated excluding kicked user +2. **Rekey Notifications**: Encrypted messages sent to remaining members using the **old config key** (so recipients can decrypt with their current key) +3. **Kick Notification**: Encrypted with the **old config key** so the kicked user can decrypt the kick message +4. **Access Revocation**: Kicked user excluded from future encryption sessions +5. **Signature Verification**: All operations cryptographically signed + +#### Config Key Encryption Layer + +As of Dec 2025, hub/sync envelopes include an optional **config key parameter** for additional encryption: + +```typescript +await secureChannel.SealHubEnvelope( + hubKey.address, + { ... }, + // Config key for envelope encryption (X448) + oldConfigKey ? { + type: 'x448', + public_key: [...hexToSpreadArray(oldConfigKey.publicKey)], + private_key: [...hexToSpreadArray(oldConfigKey.privateKey)], + } : undefined +); +``` + +**Important**: During kick operations, the **old** config key is used for sealing rekey/kick messages. This ensures: +- Remaining members can decrypt rekey notifications with their current key +- The kicked user can decrypt their kick notification before losing access + +## Error Handling + +### Validation Checks + +- ✅ spaceId must be present (from URL params) +- ✅ userAddress must be provided +- ✅ registration data must be loaded +- ✅ User must be space owner (protocol enforces via ED448 key - only owner possesses the key to sign kick messages) + +### Error States + +- **Network failures**: Displayed in console, operation retried +- **Cryptographic errors**: Operation fails, error logged +- **Permission errors**: Operation rejected by server +- **Invalid parameters**: Early return, no operation attempted + +### UI Error Handling + +- **Loading state**: Button disabled during operation +- **Error recovery**: User can retry operation +- **Graceful degradation**: Modal can be closed even if operation fails + +## Performance Considerations + +### UI Responsiveness + +- **5-second operation**: Kick operation typically takes ~5 seconds +- **Non-blocking**: UI remains responsive (loading state shown) +- **Background operation**: Cryptographic work happens in background thread (via `enqueueOutbound`) + +### Performance Analysis (2025-12-17) + +**Test Configuration**: 3 total members, 1 member to notify + +#### Timing Breakdown + +| Operation | Time (ms) | % of Total | +|-----------|-----------|------------| +| **EstablishTripleRatchetSessionForSpace** | **5,086** | **~75%** | +| postSpaceManifest API | 431 | ~6% | +| postSpace API | 377 | ~6% | +| Peer ID mapping loop (getUser API calls) | 372 | ~5% | +| Rekey envelope loop | 114 | ~2% | +| Create signatures (space + owner) | 67 | ~1% | +| Create manifest + signature | 55 | <1% | +| Kick envelope creation | 51 | <1% | +| Get keys (space, owner, hub) | 5 | <1% | +| Generate config keypair | 10 | <1% | +| Generate ephemeral key + getSpace | 7 | <1% | +| Get and filter members | 1 | <1% | +| Get encryption states | 0 | <1% | +| Save config key | 2 | <1% | +| getSpace (initial) | 15 | <1% | + +#### Console Output + +``` +[KICK TIMING] Starting kick operation for user: QmNTPKPGXqwjFUjfCMMrTXXtxzwqCDVtN3oTGSFEuSXX2U +[KICK SERVICE] Starting kickUser for: QmNTPKPGXqwjFUjfCMMrTXXtxzwqCDVtN3oTGSFEuSXX2U +[KICK SERVICE] getSpace completed in 15ms +[KICK SERVICE] Starting enqueued outbound operation +[KICK TIMING] kickUser() completed in 16ms +[KICK TIMING] Kick operation completed for user: QmNTPKPGXqwjFUjfCMMrTXXtxzwqCDVtN3oTGSFEuSXX2U +[KICK TIMING] Cache invalidation completed in 3ms +[KICK TIMING] Total kick operation took 20ms +[KICK SERVICE] Get keys (space, owner, hub) completed in 5ms +[KICK SERVICE] Generate config keypair completed in 10ms +[KICK SERVICE] Save config key completed in 2ms +[KICK SERVICE] Create signatures (space + owner) completed in 67ms +[KICK SERVICE] postSpace API call completed in 377ms +[KICK SERVICE] Generate ephemeral key + getSpace completed in 7ms +[KICK SERVICE] Create manifest + signature completed in 55ms +[KICK SERVICE] postSpaceManifest API call completed in 431ms +[KICK SERVICE] Get and filter members (3 total, 1 to notify) completed in 1ms +[KICK SERVICE] Get encryption states completed in 0ms +[KICK SERVICE] EstablishTripleRatchetSessionForSpace completed in 5086ms +[KICK SERVICE] Starting peer ID mapping loop for 1 members +[KICK SERVICE] Peer ID mapping loop (getUser API calls) completed in 372ms +[KICK SERVICE] Starting rekey envelope loop for 1 members +[KICK SERVICE] Rekey envelope loop completed in 114ms +[KICK SERVICE] Kick envelope creation completed in 51ms +``` + +#### Key Finding + +**`EstablishTripleRatchetSessionForSpace` is the primary bottleneck**, consuming ~75% of the total operation time at 5,086ms. This is a cryptographic operation from `@quilibrium/quilibrium-js-sdk-channels` that establishes a new triple ratchet session with capacity for `filteredMembers.length + 200` members. The expensive part is the DKG (Distributed Key Generation) setup. + +#### Important Note on Async Behavior + +The `kickUser()` call in the hook returns after only ~16-20ms because `enqueueOutbound()` is fire-and-forget - it queues the work but doesn't wait for completion. The actual cryptographic work continues in the background, which is why the UI shows "Total kick operation took 20ms" while the service continues logging for several more seconds. + +### Cache Management + +- **Selective invalidation**: Only space members cache invalidated +- **Efficient queries**: React Query manages background refetching +- **Minimal re-renders**: Only affected components re-render + +### Memory Management + +- **Timeout cleanup**: Confirmation timeouts properly cleared +- **Effect cleanup**: useEffect cleanup prevents memory leaks +- **Cache cleanup**: Old query data garbage collected + +## Testing Considerations + +### Test Scenarios + +1. **Happy path**: Normal kick operation with confirmation +2. **Timeout test**: Confirmation resets after 5 seconds +3. **Modal close**: Confirmation resets when modal closes +4. **Network error**: Handles kick operation failures +5. **Invalid user**: Handles missing userAddress +6. **Permission error**: Handles insufficient privileges + +### Mock Requirements + +- Mock `useMessageDB()` to provide a mocked `SpaceService` for kick operations +- Mock `useQueryClient()` for cache invalidation +- Mock `useParams()` for spaceId +- Mock authentication contexts for user data + +## Integration Points + +### Dependencies + +- **Authentication**: Requires valid passkey and registration +- **Routing**: Needs spaceId from URL parameters +- **Database**: Integrates with `SpaceService` (via `MessageDB Context`) for operations +- **Caching**: Uses React Query for data management +- **UI**: Uses primitive components for cross-platform compatibility + +### External Systems + +- **Server API**: Posts space updates and notifications +- **WebSocket**: Receives real-time updates and sync messages +- **IndexedDB**: Stores local space member data +- **Cryptography**: Secure channel operations for encryption + +## Future Improvements + +### Potential Enhancements + +1. **Optimistic UI**: Immediately hide kicked user with rollback on failure +2. **Progress indication**: Show detailed progress during 5-second operation +3. **Batch operations**: Allow kicking multiple users simultaneously +4. **Audit logging**: Track kick operations for space administration + +> **Note**: Role-based kick delegation (different kick permissions for different admin roles) is not feasible at the protocol level - kick requires the space owner's ED448 private key to sign the message, which cannot be delegated. + +### Mobile Considerations + +- ✅ **Touch-friendly**: Button sizes appropriate for mobile +- ✅ **Responsive text**: Centers on mobile, left-aligned on desktop +- ✅ **Cross-platform**: Uses primitives compatible with React Native +- ✅ **Accessibility**: Proper ARIA labels and keyboard navigation + +--- + +**Last Updated**: 2026-01-02 +**Verified**: 2025-12-15 - Updated modal props and flow (user info display via ModalProvider) +**Performance Tested**: 2025-12-17 - Added detailed timing analysis + +**Cross-Platform**: ✅ Web + Mobile Compatible + +*2026-01-02: Added config key encryption layer documentation (from qm delta commit)* diff --git a/.agents/docs/features/mention-notification-system.md b/.agents/docs/features/mention-notification-system.md new file mode 100644 index 000000000..bd0249de0 --- /dev/null +++ b/.agents/docs/features/mention-notification-system.md @@ -0,0 +1,732 @@ +--- +type: doc +title: Unified Notification System (Mentions + Replies) +status: done +ai_generated: true +reviewed_by: null +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T09:00:00.000Z +related_docs: ["mention-pills-ui-system.md"] +--- + +# Unified Notification System (Mentions + Replies) + +## Overview + +The Unified Notification System provides real-time visual feedback for mentions and replies. The system tracks unread notifications across devices, provides contextual highlighting, and allows per-space notification preferences. + +### Supported Notification Types + +- **@you mentions**: Direct user mentions (`@<address>`) +- **@everyone mentions**: Channel-wide mentions (permission-based) +- **@role mentions**: Role-based mentions (`@moderators`, `@admins`) with user role checking +- **#channel mentions**: Channel references (`#<channelId>`) with clickable navigation +- **Replies**: Notifications when someone replies to your messages + +### Key Design + +The system uses a **unified notification type format** (`'mention-you'`, `'mention-everyone'`, `'mention-roles'`, `'reply'`) that allows mentions and replies to be: +- Displayed together in a single notification panel +- Filtered independently by type +- Configured per-space in user settings +- Counted separately but displayed in combined channel badges + +**Architecture**: Mentions and replies are **separate systems** (different data models, queries, and cache keys) that **share UI components** (NotificationPanel, NotificationItem) for a unified user experience. + +--- + +## Architecture + +### High-Level Flow + +``` +Message sent with @mention or @roleTag + ↓ +extractMentionsFromText() parses patterns + ↓ +MessageService saves message with mentions field + ↓ +React Query invalidates mention-counts cache + ↓ +useChannelMentionCounts() recalculates counts + ↓ +ChannelList renders bubbles with counts +``` + +### Read Tracking Flow + +``` +User opens channel + ↓ +useConversation() fetches lastReadTimestamp (React Query) + ↓ +Interval (2s) checks if new content to mark as read + ↓ +useUpdateReadTime() saves to DB, then invalidates cache + ↓ +React Query refetches → bubble updates automatically +``` + +**Key Design**: Channel-level read tracking (opening channel marks all messages as read). This matches industry standard (Discord, Slack) where apps auto-scroll to bottom. + +### Message Highlighting Flow + +``` +Mentioned message enters viewport (50% visible) + ↓ +useViewportMentionHighlight() detects via IntersectionObserver + ↓ +Check: isMentioned && isUnread (createdDate > lastRead)? + ↓ +highlightMessage(messageId, { duration: 61000, variant: 'mention' }) + ↓ +Message gets .message-highlighted-mention CSS class + ↓ +Flash-highlight animation (yellow fade, 61s - more subtle, longer duration) +``` + +This is a **self-highlighting** pattern where the Message component highlights itself. See `.agents/docs/features/messages/message-highlight-system.md` for the full architecture. + +--- + +## Core Components + +### Utilities + +**`src/utils/mentionUtils.ts`** +- `extractMentionsFromText(text, options)`: Parses mention patterns: + - User mentions: `@<address>` + - Role mentions: `@roleTag` + - Everyone mentions: `@everyone` + - Channel mentions: `#<channelId>` +- `hasWordBoundaries(text, match)`: Validates mentions have whitespace boundaries (used by both backend and frontend) +- `isMentioned(message, options)`: Checks if user is mentioned +- `isMentionedWithSettings(message, options)`: Checks mention with user settings and role membership + - Accepts: `['mention-you', 'mention-everyone', 'mention-roles']` + - Filters mentions based on enabled notification types and user's roles +- `getMentionType(message, options)`: Returns mention type for UI + +**`src/utils/channelUtils.ts`** +- `findChannelByName(channelName, channels)`: Locates channel by name (utility function) + +**`src/utils/notificationSettingsUtils.ts`** +- `getDefaultNotificationSettings(spaceId)`: Returns default settings + - Default: `['mention-you', 'mention-everyone', 'mention-roles', 'reply']` (all enabled) +- `saveNotificationSettings()`: Persists settings to IndexedDB user config +- `getNotificationSettings()`: Retrieves settings for a space + +### Hooks + +**`src/hooks/business/mentions/useChannelMentionCounts.ts`** +- React Query hook to calculate unread mention counts per channel +- Query key: `['mention-counts', 'channel', spaceId, userAddress, ...channelIds]` +- Stale time: 90 seconds, refetches on window focus +- Implements early-exit at 10 mentions (matches "9+" display threshold) +- Uses `getUnreadMentions()` for efficient database-level filtering +- Respects user's notification settings (filters by enabled types) +- Includes user role IDs for role mention filtering +- Returns: `{ [channelId]: mentionCount }` + +**`src/hooks/business/mentions/useSpaceMentionCounts.ts`** +- Space-level mention counts (aggregates across all channels) +- Used for space icon notification badges +- Implements early-exit at 10 mentions across all channels +- Uses `getUnreadMentions()` for efficient database-level filtering +- Includes user role lookup per space for role mention filtering +- Returns: `{ [spaceId]: mentionCount }` + +**`src/hooks/business/mentions/useAllMentions.ts`** +- Fetches all unread mentions across all channels in a space +- Supports filtering by mention type: `['mention-you', 'mention-everyone', 'mention-roles']` +- Accepts `userRoleIds` for role mention filtering +- Returns: `{ mentions: MentionNotification[], isLoading }` +- Used by NotificationPanel + +**`src/hooks/business/mentions/useMentionInput.ts`** +- Autocomplete hook for mention dropdown in message composer +- Supports user, role, and channel mentions via discriminated union (`MentionOption`) +- Separate dropdowns for `@` mentions (users/roles/@everyone) and `#` mentions (channels only) +- Filters and ranks suggestions based on query with channel limit of 25 results +- Returns: `{ filteredOptions, showDropdown, selectOption, handleKeyDown }` + +**`src/hooks/business/mentions/useMentionNotificationSettings.ts`** +- Manages per-space notification preferences +- Loads saved settings from IndexedDB user config +- Returns: `{ selectedTypes, setSelectedTypes, saveSettings, isLoading }` +- Used by SpaceSettingsModal and NotificationPanel + +**`src/hooks/business/replies/useReplyNotificationCounts.ts`** +- React Query hook to calculate unread reply counts per channel +- Query key: `['reply-counts', 'channel', spaceId, userAddress, ...channelIds]` +- Stale time: 90 seconds, refetches on window focus +- Implements early-exit at 10 replies (matches "9+" display threshold) +- Returns: `{ [channelId]: replyCount }` + +**`src/hooks/business/replies/useAllReplies.ts`** +- Fetches all unread replies across all channels in a space +- Returns: `{ replies: ReplyNotification[], isLoading }` +- Used by NotificationPanel + +**`src/hooks/business/messages/useMessageFormatting.ts`** +- Formats message content for display (token-based rendering) +- Processes user mentions, role mentions, @everyone, links, etc. +- Accepts `spaceRoles` for role mention validation and styling +- Returns styled mention tokens for Message component + +**`src/hooks/business/mentions/useViewportMentionHighlight.ts`** +- Auto-highlights mentions when entering viewport +- Uses IntersectionObserver API (50% visibility threshold) +- Integrates with existing `useMessageHighlight()` system +- Duration: 6 seconds + +**`src/hooks/business/conversations/useUpdateReadTime.ts`** +- React Query mutation for atomic read time updates +- DB write completes BEFORE cache invalidation (fixes race condition) +- Invalidates: conversation cache + mention count caches + reply count caches + +### UI Components + +**`src/components/message/MessageComposer.tsx`** +- Mention autocomplete dropdown with user, role, and channel suggestions +- Users format: `@<address>` +- Roles format: `@roleTag` (without brackets) +- Channels format: `#<channelId>` +- Displays role badges with colors and channel icons in dropdown +- Separate dropdowns for `@` and `#` triggers +- CSS: `MessageComposer.scss` includes role badge and channel styling + +**`src/components/space/ChannelList.tsx`** +- Calls `useChannelMentionCounts({ spaceId, channelIds, userRoleIds })` +- Calculates user's role IDs using `getUserRoles()` +- Merges counts into channel data +- Passes to ChannelGroup → ChannelItem + +**`src/components/space/ChannelItem.tsx`** +- Combines mention counts + reply counts for unified notification bubble +- Formula: `totalCount = mentionCount + replyCount` +- CSS class: `channel-mentions-bubble-you` +- Display: Shows combined count in accent-colored bubble + +**`src/components/message/Message.tsx`** +- Receives `lastReadTimestamp` and `spaceRoles` props +- Passes `spaceRoles` to `useMessageFormatting()` for role mention styling +- Calculates `isUnread = message.createdDate > lastReadTimestamp` +- Passes to `useViewportMentionHighlight()` + +**`src/components/message/MessageMarkdownRenderer.tsx`** +- Renders markdown-formatted messages +- Processes user mentions: `@<address>` format → styled span with display name lookup +- Processes role mentions: `@roleTag` → styled span +- Processes channel mentions: `#<channelId>` → clickable span with navigation and channel name lookup +- Only styles roles that exist in `message.mentions.roleIds` +- Only styles channels that exist in `message.mentions.channelIds` +- Display names always looked up from space data for security +- Accepts `roleMentions`, `channelMentions`, `spaceRoles`, and `spaceChannels` props +- CSS: `.message-name-mentions-you` for consistent styling across all mention types + +**`src/components/space/Channel.tsx`** +- Uses `useConversation()` for lastReadTimestamp +- Calculates user's role IDs using `getUserRoles()` +- Passes `userRoleIds` to NotificationPanel +- Implements interval-based read time updates (2s) +- Renders notification bell icon with NotificationPanel + +**`src/components/notifications/NotificationPanel.tsx`** +- Unified notification inbox panel (mentions + replies) +- Loads user's saved notification settings using `useMentionNotificationSettings()` +- Respects disabled notification types from Space Settings +- Fetches both `useAllMentions()` and `useAllReplies()` +- Combines and sorts by date (newest first) +- Multi-select filter by type: `['mention-you', 'mention-everyone', 'mention-roles', 'reply']` +- Mark all as read functionality +- Click-to-navigate using URL hash: `navigate(\`/spaces/${spaceId}/${channelId}#msg-${messageId}\`)` + +**`src/components/notifications/NotificationItem.tsx`** +- Individual notification item display +- Supports mention and reply notifications +- Matches SearchResults layout (channel - author - date - message) +- Message truncation (200 chars max) +- Shows appropriate icon (mention vs reply) + +**`src/components/modals/SpaceSettingsModal/Account.tsx`** +- Per-space notification preferences UI +- Multi-select for notification types: `@you`, `@everyone`, `@roles`, `Replies` +- Uses `useMentionNotificationSettings()` for load/save +- Saves to IndexedDB user config +- Syncs across devices + +### Services + +**`src/services/MessageService.ts`** +- Extracts mentions when creating messages +- Validates @everyone permission before extraction +- Passes `spaceRoles` to `extractMentionsFromText()` for role validation +- Populates `message.mentions.roleIds[]` for role mentions +- Invalidates both mention and reply counts when new notifications arrive + +**`src/db/messages.ts`** +- Stores `lastReadTimestamp` in conversations table +- `saveReadTime()`: Updates timestamp +- `getConversation()`: Retrieves timestamp for count calculation +- `getUnreadMentions()`: Efficient query for mention counting + - Fetches only messages after lastReadTimestamp with mentions + - Database-level filtering using IndexedDB cursors + - Supports limit parameter for early-exit optimization +- `getUnreadReplies()`: Efficient query for reply counting + +--- + +## Role Mention System + +### Overview + +Role mentions allow users to notify all members of a role (e.g., `@moderators`, `@admins`) with a single mention. + +### Format + +- **User mentions**: `@<address>` +- **Role mentions**: `@roleTag` (without brackets, e.g., `@moderators`) +- **@everyone**: `@everyone` (special case, permission-based) + +### Autocomplete + +**Component**: `MessageComposer.tsx` + `useMentionInput.ts` + +When typing `@`, the dropdown shows: +1. Matching users (with avatars) +2. Matching roles (with colored badges) + +**Implementation**: +- Discriminated union type: `MentionOption = { type: 'user', data: User } | { type: 'role', data: Role }` +- Filters roles by `displayName` and `roleTag` +- Sorts by relevance (exact match > starts with > contains) +- Selection inserts appropriate format (`@<address>` for users or `@roleTag` for roles) + +### Extraction + +**Function**: `extractMentionsFromText(text, { spaceRoles })` + +Parses message text and extracts: +- User mentions: `/@<([^>]+)>/g` → `mentions.memberIds[]` +- Role mentions: `/@([a-zA-Z0-9_-]+)(?!\w)/g` → `mentions.roleIds[]` +- @everyone: `/@everyone\b/i` → `mentions.everyone = true` + +**Validation**: +- Role tags are validated against `spaceRoles` array +- Only existing roles are extracted (prevents fake mentions) +- **Word boundary validation**: Mentions only extract when surrounded by whitespace (excludes all markdown syntax: code blocks, bold, italic, links, etc.) +- Case-insensitive matching for role tags + +### Notification Filtering + +**Function**: `isMentionedWithSettings(message, { userAddress, enabledTypes, userRoles })` + +Checks if user should receive notification: +1. **@you mentions**: `message.mentions.memberIds` includes `userAddress` +2. **@everyone mentions**: `message.mentions.everyone === true` +3. **@role mentions**: `message.mentions.roleIds` overlaps with `userRoles` + +User's roles are calculated using `getUserRoles(userAddress, space)` which: +- Checks space members for role assignments +- Returns array of role IDs user belongs to +- Cached per component for performance + +### Rendering + +**Web (Markdown)**: `MessageMarkdownRenderer.tsx` +- Processes `@roleTag` patterns in markdown text +- Validates against `message.mentions.roleIds` (only style if actually mentioned) +- Replaces with styled span: `<span class="message-name-mentions-you" title="{displayName}">@{roleTag}</span>` + +**Web (Token-based)**: `useMessageFormatting.ts` +- Detects `@roleTag` pattern during text token processing +- Validates against `spaceRoles` and `message.mentions.roleIds` +- Returns mention token with same styling as user mentions + +**CSS**: `.message-name-mentions-you` (shared with @everyone and @you) + +### Notification Counts + +**Channel Bubbles**: `useChannelMentionCounts.ts` +- Accepts `userRoleIds` parameter +- Passes to `isMentionedWithSettings()` for filtering +- Counts role mentions where user is a member + +**Space Bubbles**: `useSpaceMentionCounts.ts` +- Calculates `userRoleIds` per space using `getUserRoles()` +- Aggregates role mention counts across all channels +- Updates space icon notification badge + +### Settings + +**UI**: `SpaceSettingsModal/Account.tsx` + +Users can enable/disable role mention notifications per space: +- Toggle `@roles` option in notification settings +- Saved to IndexedDB: `user_config.notificationSettings[spaceId].enabledNotificationTypes` +- Respects setting in NotificationPanel filter and counts + +### Permissions + +**Note**: Role mentions have NO permission restrictions: +- Any user can mention any role +- All role members receive notifications (if enabled in settings) +- Deleted/invalid roles render as plain text (no styling) + +### Edge Cases + +- **Multiple roles**: User with multiple roles mentioned gets single notification per message +- **Deleted roles**: Role IDs in `mentions.roleIds` that don't exist in `space.roles` are ignored +- **Empty roles**: Roles with no members can be mentioned (0 notifications sent) +- **Code blocks**: `@roleTag` in code blocks doesn't trigger extraction +- **Self-mentions**: Users can mention their own roles (intentional) + +--- + +## Channel Mention System + +### Overview + +Channel mentions allow users to reference channels within messages using `#<channelId>` syntax. These mentions are rendered as clickable links that navigate to the referenced channel. + +### Format + +- **Channel mentions**: `#<channelId>` +- **Navigation**: Clicking navigates to `/spaces/{spaceId}/{channelId}` + +### Autocomplete + +**Component**: `MessageComposer.tsx` + `useMentionInput.ts` + +When typing `#`, the dropdown shows: +- Matching channels (with hashtag icons) +- Shows all channels immediately when `#` is typed (no minimum query length) +- Filtered by channel name as user types + +**Implementation**: +- Separate dropdown from `@` mentions (different trigger character) +- Discriminated union type includes: `{ type: 'channel', data: Channel }` +- Filters channels by `channelName` +- Sorts by relevance (exact match > starts with > contains) +- Limited to 25 results for better UX +- Selection inserts `#<channelId>` format + +### Extraction + +**Function**: `extractMentionsFromText(text, { spaceChannels })` + +Parses message text and extracts: +- Channel mentions: `/#<([^>]+)>/g` → `mentions.channelIds[]` + +**Validation**: +- Channel IDs are validated against `spaceChannels` array by ID +- Only existing channels are extracted (prevents fake mentions) +- **Word boundary validation**: Channel mentions only extract when surrounded by whitespace (excludes all markdown syntax) +- Exact ID matching for rename-safety + +### Rendering + +**Web (Markdown)**: `MessageMarkdownRenderer.tsx` +- Processes `#<channelId>` patterns in markdown text +- Validates against `message.mentions.channelIds` (only style if actually mentioned) +- Replaces with clickable span: `<span class="message-name-mentions-you" data-channel-id="{channelId}">#{channelName}</span>` +- Channel names looked up from space channels for security +- Uses same CSS class as other mentions for consistency + +**Web (Token-based)**: `useMessageFormatting.ts` +- Detects `#<channelId>` pattern during text token processing +- Validates against `spaceChannels` and `message.mentions.channelIds` +- Returns channel token with click handler for navigation + +### Navigation + +**Click Handler**: `onChannelClick(channelId: string)` +- Navigates to channel using React Router: `navigate(\`/spaces/${spaceId}/${channelId}\`)` +- Provides seamless channel-to-channel navigation from message content + +### Storage + +**Database**: `MessageService.ts` +- Channel mentions stored in `message.mentions.channelIds[]` +- Array of channel IDs that were mentioned in the message +- Used for validation during rendering (prevents styling fake mentions) + +### Edge Cases + +- **Deleted channels**: Channel IDs in `mentions.channelIds` that don't exist in space channels are ignored +- **Private channels**: All channels in space are mentionable (no permission restrictions) +- **Code blocks**: `#<channelId>` in code blocks doesn't trigger extraction +- **Cross-space channels**: Only channels within the same space can be mentioned + +--- + +## Mention Pills UI System + +For comprehensive documentation on the Mention Pills UI system (visual pills during message composition), see the dedicated documentation: + +**→ [Mention Pills UI System](./mention-pills-ui-system.md)** + +**Quick Overview**: The mention pills feature provides Discord/Slack-style visual pills (like `@John Doe`, `#general-discussion`) during message composition instead of raw IDs (`@<address>`, `#<channelId>`). This enhances the user experience while maintaining the robust ID-based storage system underneath. + +**Key Features**: +- Visual clarity with readable names in the composer +- Uses contentEditable with custom React hook (`useMentionPillEditor`) +- Lightweight custom solution (~5KB total) +- Event delegation pattern prevents memory leaks +- Feature flag (`ENABLE_MENTION_PILLS`) for backward compatibility + +See the full documentation for architecture details, implementation examples, integration patterns, and performance characteristics. + +--- + +## @everyone Mentions + +### Permission System + +**Who can use @everyone**: +- Space owners (automatic) +- Users with `mention:everyone` role permission + +**Permission files**: +- `src/utils/permissions.ts` - Space owners get permission automatically +- `src/api/quorumApi.ts` - `'mention:everyone'` added to Permission type +- `src/components/modals/SpaceSettingsModal/Roles.tsx` - UI for assigning permission + +### Processing + +**Extraction**: +```typescript +const canUseEveryone = hasPermission( + currentPasskeyInfo.address, + 'mention:everyone', + space, + isSpaceOwner +); + +mentions = extractMentionsFromText(messageText, { + allowEveryone: canUseEveryone, + spaceRoles +}); +``` + +**Rendering**: +- Only styled if `message.mentions.everyone === true` +- Non-authorized users' @everyone appears as plain text + +**Notification counting**: +- `isMentionedWithSettings()` checks `mentions.everyone` field +- All users receive notification if `'mention-everyone'` enabled in settings + +### Edge Cases + +- **Word boundary validation**: @everyone inside markdown syntax ignored (code blocks, bold, italic, links, etc.) +- Case insensitive: @everyone, @Everyone, @EVERYONE work +- Permission denial: Unauthorized @everyone doesn't trigger notifications + +--- + +## Key Design Decisions + +### 1. Unified Notification Type System +**Format**: `'mention-you' | 'mention-everyone' | 'mention-roles' | 'reply'` +**Benefits**: Consistent API, UI filtering, per-space configuration, no format transformations + +### 2. Separate Systems, Shared UI +**Separate**: Data models, queries, cache keys, hooks +**Shared**: NotificationPanel, NotificationItem, read tracking, highlighting +**Rationale**: Mentions are text-based, replies are relationship-based (semantic mismatch) + +### 3. URL Hash Navigation +**Pattern**: `navigate(\`#msg-${messageId}\`)` +**Benefits**: Works reliably, consistent across scenarios, reuses existing scroll/highlight logic + +### 4. Database-Level Filtering +**Implementation**: `getUnreadMentions()`, `getUnreadReplies()` +**Benefits**: Efficient IndexedDB cursor-based filtering, early-exit support + +### 5. React Query Architecture +**Cache Keys**: +- Mentions: `['mention-counts', ...]` and `['mention-notifications', ...]` +- Replies: `['reply-counts', ...]` and `['reply-notifications', ...]` +**Benefits**: Independent invalidation, prevents race conditions, no coupling + +### 6. Early-Exit Optimization +**Threshold**: Stop counting at 10 (UI shows "9+" beyond this) +**Benefits**: 3-5x performance improvement for channels with many notifications + +### 7. Role Mention Format +**Decision**: `@roleTag` without brackets (vs `@<roleTag>`) +**Rationale**: Avoids autocomplete conflict with `@<address>` pattern +**Benefits**: Clear distinction, simpler parsing, better UX + +### 8. Permission-Free Role Mentions +**Decision**: No permission check required to mention roles +**Rationale**: Notifications are informational, not permission-based +**Benefits**: Simpler implementation, matches user expectations + +--- + +## Performance Characteristics + +### Query Optimization +- Database-level filtering with IndexedDB cursors +- Early-exit at 10 notifications per type +- 90s stale time for counts, 30s for panel data +- Independent caching (mentions/replies don't invalidate each other) + +### Expected Performance +- Small channels (<100 messages): <10ms +- Medium channels (1k messages): <50ms +- Large channels (10k messages): <100ms +- Multiple channels (20 with notifications): <200ms total + +### Role Mention Performance +- User role lookup: O(n) where n = number of members (typically <1000) +- Role validation: O(m) where m = number of roles (typically <20) +- Cached per component to avoid repeated calculations + +--- + +## Cross-Device Synchronization + + +### How It Works + +``` +Device A: User views channel → lastReadTimestamp updated + ↓ +SyncService syncs to server + ↓ +Device B: Opens app → fetches latest state + ↓ +React Query refetches with synced timestamp + ↓ +Notifications update across devices +``` + +**Sync timing**: +- On app focus: `refetchOnWindowFocus: true` +- On app launch: Initial data load +- Background: 90s stale time + periodic sync + +**Configuration**: `allowSync` flag in user config + +--- + +## Troubleshooting + +### Bubbles Not Appearing +- Check mention format (`@<address>` for users, `@roleTag` for roles) +- Verify message has `mentions.memberIds[]` or `mentions.roleIds[]` populated +- Check user's role membership for role mentions +- Verify notification type enabled in settings + +### Bubbles Not Clearing +- Wait 2 seconds for debounce +- Check `lastReadTimestamp` in DB +- Verify query invalidation in React Query DevTools + +### Performance Issues +- Use React Query DevTools to identify slow queries +- Check IndexedDB performance in browser tools +- Verify 90s stale time reducing query frequency + +### Role Mentions Not Working +- Verify role exists in `space.roles[]` +- Check user has the role in space members +- Confirm `@roles` enabled in notification settings +- Check `message.mentions.roleIds[]` populated correctly + +--- + +## File Reference + +``` +src/ +├── utils/ +│ ├── mentionUtils.ts # Mention detection/extraction +│ ├── channelUtils.ts # Channel lookup utilities +│ ├── notificationSettingsUtils.ts # Settings helpers +│ └── permissions.ts # Role permission utilities +├── types/ +│ └── notifications.ts # NotificationTypeId, NotificationSettings +├── hooks/ +│ ├── business/ +│ │ ├── mentions/ +│ │ │ ├── useChannelMentionCounts.ts # Channel-level counts +│ │ │ ├── useSpaceMentionCounts.ts # Space-level counts +│ │ │ ├── useAllMentions.ts # Fetch all mentions +│ │ │ ├── useMentionInput.ts # Autocomplete hook +│ │ │ ├── useMentionNotificationSettings.ts # Settings hook +│ │ ├── replies/ +│ │ │ ├── useReplyNotificationCounts.ts # Reply counts +│ │ │ └── useAllReplies.ts # Fetch all replies +│ │ ├── messages/ +│ │ │ ├── useMessageFormatting.ts # Token-based rendering +│ │ │ └── useViewportMentionHighlight.ts # Viewport trigger +│ │ └── conversations/ +│ │ └── useUpdateReadTime.ts # Read time mutation +│ └── queries/ +│ └── conversation/ +│ └── useConversation.ts # Read time query +├── components/ +│ ├── space/ +│ │ ├── Channel.tsx # Read tracking + bell icon +│ │ ├── ChannelList.tsx # Count integration +│ │ └── ChannelItem.tsx # Combined bubble +│ ├── message/ +│ │ ├── Message.tsx # Highlight trigger +│ │ ├── MessageComposer.tsx # Mention autocomplete +│ │ ├── MessageList.tsx # Hash detection +│ │ └── MessageMarkdownRenderer.tsx # Markdown rendering +│ ├── notifications/ +│ │ ├── NotificationPanel.tsx # Unified panel +│ │ └── NotificationItem.tsx # Individual item +│ └── modals/ +│ └── SpaceSettingsModal/ +│ └── Account.tsx # Settings UI +├── services/ +│ └── MessageService.ts # Extraction + metadata +└── db/ + └── messages.ts # Read time + queries +``` + +## Mention Interactivity + +### CSS Architecture + +Mentions use semantic CSS classes to control interactivity: + +**Base styling**: `.message-name-mentions-you` +- Font weight, colors, padding, border radius + +**Interactive mentions**: `.message-name-mentions-you.interactive` +- Pointer cursor, hover effects +- Used in: Regular message display, markdown rendering + +**Non-interactive mentions**: `.message-name-mentions-you.non-interactive` +- Default cursor, no hover effects +- Used in: Preview contexts (delete/pin modals, pinned lists, notification panel) + +### Implementation + +All mention components use `tokenData.isInteractive` flag to determine CSS class: +- MessagePreview: Respects `disableMentionInteractivity` prop +- NotificationItem: Always non-interactive +- Message.tsx: Interactive when not in preview context +- MessageMarkdownRenderer: Always interactive + +--- + +## Related Documentation + +- **[Reply Notification System](../../tasks/.done/reply-notification-system.md)** - Reply implementation details +- **[Role Mention Notifications](../../tasks/.done/role-mention-notifications.md)** - Role mention implementation task +- **[Notification Settings](../../tasks/.done/mention-notification-settings-phase4.md)** - Per-space configuration +- **[Data Management Architecture](../data-management-architecture-guide.md)** - Sync service integration + +--- + +*Last updated: 2026-01-09* +*Reviewed by Claude Code: 2026-01-09* +*Verified: 2026-01-09 - Removed references to unsupported enhanced mention formats* diff --git a/.agents/docs/features/mention-pills-ui-system.md b/.agents/docs/features/mention-pills-ui-system.md new file mode 100644 index 000000000..419d4c1b1 --- /dev/null +++ b/.agents/docs/features/mention-pills-ui-system.md @@ -0,0 +1,371 @@ +--- +type: doc +title: Mention Pills UI System +status: done +ai_generated: true +reviewed_by: null +created: 2026-01-09T09:00:00.000Z +updated: 2026-01-09T09:00:00.000Z +related_docs: ["mention-notification-system.md"] +related_tasks: [".done/mention-pills-in-message-textarea.md", ".done/mention-pills-abstraction-refactor.md"] +--- + +# Mention Pills UI System + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Overview + +The Mention Pills UI system provides a Discord/Slack-style visual experience during message composition, showing readable mention pills (like `@John Doe`, `#general-discussion`) instead of raw IDs (`@<QmAbc123...>`, `#<ch-def456>`) in the composer. This enhances the user experience while maintaining the robust ID-based storage system underneath. + +**Key Benefits**: +- **Visual Clarity**: Users see readable names while typing, eliminating confusion with cryptic IDs +- **Technical Robustness**: Maintains rename-safe ID storage with zero breaking changes +- **Lightweight**: Custom solution (~2KB) vs heavy rich text editors (~75-100KB) +- **Memory Safe**: Event delegation pattern prevents memory leaks in long-running Electron app + +**Feature Flag**: `ENABLE_MENTION_PILLS` in `src/config/features.ts` +- When enabled: Uses contentEditable with visual pills +- When disabled: Falls back to standard textarea with text-based mentions + +## Architecture + +**Dual-Mode System**: +``` +ENABLE_MENTION_PILLS = true: + User types "@joh" + ↓ + Autocomplete shows options + ↓ + User selects "@John Doe" + ↓ + Pill inserted in contentEditable: <span data-mention-type="user">@John Doe</span> + ↓ + On save: extractStorageText() → "@<QmAbc123...>" + ↓ + Stored in message with IDs + +ENABLE_MENTION_PILLS = false: + Standard textarea with text-based mentions (original behavior) +``` + +**Key Components**: +- `src/utils/mentionPillDom.ts` - Pure utility functions (184 lines) +- `src/hooks/business/mentions/useMentionPillEditor.ts` - React hook (305 lines) +- `src/components/message/MessageComposer.tsx` - New message composition +- `src/components/message/MessageEditTextarea.tsx` - Message editing + +## Core Utilities (`mentionPillDom.ts`) + +Pure utility functions with zero React dependencies for DOM manipulation: + +**Constants**: +```typescript +export const MENTION_PILL_CLASSES = { + user: 'message-mentions-user', + role: 'message-mentions-role', + channel: 'message-mentions-channel', + everyone: 'message-mentions-everyone', +} as const; +``` + +**Key Functions**: + +1. **`extractPillDataFromOption(option: MentionOption): PillData`** + - Converts autocomplete option to pill data + - Handles all 4 mention types (users, roles, channels, @everyone) + +2. **`createPillElement(pillData: PillData, onClick?: () => void): HTMLSpanElement`** + - Creates pill DOM element with proper CSS classes + - Sets data attributes: `data-mention-type`, `data-mention-address`, `data-mention-display-name` + - Marks as non-editable: `contentEditable="false"` + - Optional click handler for removal (used with event delegation) + +3. **`extractStorageTextFromEditor(editorElement: HTMLElement): string`** + - Walks DOM tree and converts pills to storage format + - Users: `@<address>` + - Roles: `@roleTag` (no brackets) + - Channels: `#<channelId>` + - Everyone: `@everyone` + +4. **`getCursorPositionInElement(editorElement: HTMLElement): number`** + - Gets cursor position (character offset) in contentEditable + - Uses Selection API and Range API + +**Type Definitions**: +```typescript +export type MentionPillType = 'user' | 'role' | 'channel' | 'everyone'; + +export interface PillData { + type: MentionPillType; + displayName: string; + address: string; // user address, roleTag, channelId, or 'everyone' +} +``` + +## React Hook (`useMentionPillEditor`) + +Encapsulates all pill management logic for contentEditable editors. + +**Hook Interface**: +```typescript +interface UseMentionPillEditorOptions { + onTextChange: (text: string) => void; // Receives storage format text +} + +interface UseMentionPillEditorReturn { + editorRef: React.RefObject<HTMLDivElement>; + extractVisualText: () => string; // What user sees (for mention detection) + extractStorageText: () => string; // Storage format with IDs + getCursorPosition: () => number; // Character offset from start + insertPill: (option: MentionOption, mentionStart: number, mentionEnd: number) => void; +} +``` + +**Usage Example**: +```typescript +const pillEditor = useMentionPillEditor({ + onTextChange: onChange, +}); + +const { editorRef, insertPill, extractVisualText, getCursorPosition } = pillEditor; + +// In JSX: +<div + ref={editorRef} + contentEditable + onInput={() => { + const text = pillEditor.extractStorageText(); + onTextChange(text); + }} +/> +``` + +**Key Features**: + +1. **Event Delegation for Memory Safety**: + - Single click listener on parent contentEditable element + - Prevents memory leaks from per-pill listeners + - Critical for long-running Electron app where users may compose hundreds of messages per session + ```typescript + useEffect(() => { + const handlePillClick = (e: MouseEvent) => { + const target = e.target as HTMLElement; + if (target.dataset?.mentionType) { + target.remove(); + const newText = extractStorageTextFromEditor(editorRef.current!); + onTextChange(newText); + } + }; + + editor.addEventListener('click', handlePillClick); + return () => editor.removeEventListener('click', handlePillClick); + }, [onTextChange]); + ``` + +2. **Complex DOM Walking Algorithm** (battle-tested, ~190 lines): + - Preserves existing pills while inserting new ones + - Two-phase approach: clone content before mention → insert pill → clone content after mention + - Handles text nodes and element nodes separately + - Character-based position tracking to maintain cursor accuracy + +3. **Automatic Cleanup**: + - useEffect return function removes event listeners + - Prevents memory leaks when component unmounts + +## Integration in MessageComposer + +**Before Refactoring** (~270 lines of duplicated code): +```typescript +const editorRef = useRef<HTMLDivElement>(null); +const extractVisualText = useCallback(() => { /* ... */ }, []); +const extractTextFromEditor = useCallback(() => { /* ... */ }, []); +const getCursorPosition = useCallback(() => { /* ... */ }, []); +const insertPill = useCallback((option, start, end) => { /* ... */ }, []); +``` + +**After Refactoring** (~10 lines): +```typescript +const pillEditor = useMentionPillEditor({ + onTextChange: onChange, +}); + +const { editorRef, extractVisualText, extractStorageText, getCursorPosition, insertPill } = pillEditor; +``` + +**Integration with Mention Autocomplete**: +```typescript +const handleMentionSelect = useCallback( + (option: MentionOption, mentionStart: number, mentionEnd: number) => { + if (ENABLE_MENTION_PILLS) { + insertPill(option, mentionStart, mentionEnd); // Uses hook's insertPill + } else { + // Original text-based insertion for textarea + } + }, + [insertPill] +); +``` + +**Text Extraction**: +```typescript +const handleEditorInput = useCallback(() => { + const newText = extractStorageText(); // Converts pills to IDs + onChange(newText); // Parent receives storage format + setCursorPosition(getCursorPosition()); // For autocomplete +}, [extractStorageText, onChange, getCursorPosition]); +``` + +## Integration in MessageEditTextarea + +Similar integration to MessageComposer, with one key difference: + +**Edit-Specific: Double Validation**: +- Keeps `parseMentionsAndCreatePills()` function (edit-specific with double validation) +- Layer 1: Lookup real display name from users/roles/channels +- Layer 2: Verify mention exists in `message.mentions` (prevents spoofing) +- Only creates pills if both validations pass + +**Pill Creation on Edit**: +```typescript +useEffect(() => { + if (ENABLE_MENTION_PILLS && editorRef.current && !initialized) { + // Parse stored text and create pills with double validation + const fragment = parseMentionsAndCreatePills(editText); + editorRef.current.innerHTML = ''; + editorRef.current.appendChild(fragment); + setInitialized(true); + } +}, [editText, initialized, parseMentionsAndCreatePills]); +``` + +## Storage Format Conversion + +Pills are converted to legacy storage format (ID-based) to maintain compatibility: + +**User Mentions**: +- Visual: `@John Doe` (pill with `data-mention-address="QmAbc123..."`) +- Storage: `@<QmAbc123...>` + +**Role Mentions**: +- Visual: `@moderators` (pill with `data-mention-address="moderators"`) +- Storage: `@moderators` (no brackets) + +**Channel Mentions**: +- Visual: `#general-discussion` (pill with `data-mention-address="ch-def456"`) +- Storage: `#<ch-def456>` + +**@everyone Mentions**: +- Visual: `@everyone` (pill with `data-mention-address="everyone"`) +- Storage: `@everyone` + +**Extraction Process**: +```typescript +// extractStorageTextFromEditor walks DOM tree +if (el.dataset?.mentionType && el.dataset?.mentionAddress) { + const prefix = el.dataset.mentionType === 'channel' ? '#' : '@'; + + if (el.dataset.mentionType === 'role') { + text += `@${el.dataset.mentionAddress}`; // @roleTag + } else if (el.dataset.mentionType === 'everyone') { + text += '@everyone'; + } else { + text += `${prefix}<${el.dataset.mentionAddress}>`; // @<addr> or #<id> + } +} +``` + +## Pill Styling + +Pills use the same CSS classes as rendered mentions in Message.tsx for consistent appearance: + +**CSS Classes** (defined in `MessageComposer.scss`): +- `.message-mentions-user` - Blue background, user mentions +- `.message-mentions-role` - Purple background, role mentions +- `.message-mentions-channel` - Green background, channel mentions +- `.message-mentions-everyone` - Orange/red background, @everyone mentions +- `.message-composer-pill` - Base pill styles (non-editable, inline-block, padding, border-radius) + +**Pill Structure**: +```html +<span + class="message-mentions-user message-composer-pill" + contenteditable="false" + data-mention-type="user" + data-mention-address="QmAbc123..." + data-mention-display-name="John Doe" +> + @John Doe +</span> +``` + +## Code Refactoring History + +The mention pills implementation went through two phases: + +**Phase 1: Initial Implementation** +- Task: [mention-pills-in-message-textarea.md](../../tasks/.done/mention-pills-in-message-textarea.md) +- Implemented pills in both MessageComposer and MessageEditTextarea +- Result: ~270 lines of duplicated code across 2 components + +**Phase 2: Code Abstraction** +- Task: [mention-pills-abstraction-refactor.md](../../tasks/.done/mention-pills-abstraction-refactor.md) +- Following "Rule of Three" principle, extracted shared utilities and hooks +- Created `mentionPillDom.ts` (pure functions) and `useMentionPillEditor.ts` (React hook) +- Refactored both components to use shared hook +- Fixed memory leak with event delegation pattern +- Net savings: ~270 lines of code eliminated + +**Benefits of Refactoring**: +- Single source of truth for all pill logic +- Bug fixes only need to be made once +- Better testability (pure functions separated from React) +- Memory leak prevention +- Future components can reuse the same hook + +## Performance Characteristics + +**Memory Usage**: +- Event delegation: 1 listener per editor (not per pill) +- Pills removed from DOM are automatically garbage collected +- No memory leaks from orphaned event listeners + +**DOM Operations**: +- Pill insertion: O(n) where n = number of existing nodes (preserves all content) +- Text extraction: O(n) where n = number of DOM nodes +- Cursor position: O(n) where n = number of characters before cursor + +**Bundle Size**: +- Utilities: ~2KB (pure functions) +- Hook: ~3KB (React integration) +- Total: ~5KB for complete pill system (vs ~75-100KB for rich text editors) + +## Known Limitations + +1. **Web-Only Implementation**: Current implementation uses DOM APIs (contentEditable, Selection API) + - Mobile will require separate implementation using React Native TextInput + - Storage format remains the same (cross-platform compatible) + +2. **Paste Behavior**: Pasting text with mentions doesn't auto-parse into pills + - Future enhancement: Parse mentions from pasted text + - Current: Paste inserts plain text, user can mention manually + +3. **Markdown Toolbar**: Pills are non-editable, markdown formatting doesn't apply to pill content + - Pills remain as-is when text is bolded/italicized around them + - This is expected behavior (pills should maintain visual consistency) + +## Related Documentation + +- **Mention System**: [mention-notification-system.md](./mention-notification-system.md) - Notification system, autocomplete, extraction, rendering + +- **Implementation Tasks**: + - [mention-pills-in-message-textarea.md](../../tasks/.done/mention-pills-in-message-textarea.md) - Initial implementation + - [mention-pills-abstraction-refactor.md](../../tasks/.done/mention-pills-abstraction-refactor.md) - Code refactoring + +- **Research & Design**: + - [mention-pills-research.md](../../reports/mention-pills-research.md) - Industry research, POC validation, technical insights + +- **Related Systems**: + - Mention autocomplete: `useMentionInput.ts` (autocomplete dropdown) + - Mention extraction: `mentionUtils.ts` (parsing and validation) + - Mention rendering: `Message.tsx` (display in message list) diff --git a/.agents/docs/features/messages/auto-jump-first-unread.md b/.agents/docs/features/messages/auto-jump-first-unread.md new file mode 100644 index 000000000..58124e7ef --- /dev/null +++ b/.agents/docs/features/messages/auto-jump-first-unread.md @@ -0,0 +1,256 @@ +--- +type: doc +title: Auto-Jump to First Unread Message +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-11-13T00:00:00.000Z +--- + +# Auto-Jump to First Unread Message + +## Overview + +### Problem +When entering a channel or direct message conversation with unread messages, users always landed at the bottom (most recent messages), forcing them to manually scroll up to find where they left off. + +### Solution +Automatically jump to the first unread message when entering a channel or DM with unreads. The system loads messages around the first unread, providing context above (for scrolling up) and below (for scrolling down). + +**Key Behavior:** +- Loads 40 messages before + first unread + 40 messages after +- Scrolls to first unread message (81-message context window) +- Unread indicator line shows which messages are unread +- User can scroll both directions with bidirectional pagination + +--- + +## How It Works + +### Entry Flow +``` +1. User opens channel or DM with unreads +2. System queries getFirstUnreadMessage(afterTimestamp: lastReadTimestamp) +3. If first unread not in current 100 messages: + → Load messages around first unread (40 before + target + 40 after) + → Update React Query cache + → Scroll to first unread +4. If first unread already loaded: + → Just scroll to it +5. User sees first unread with context in both directions +``` + +### Edge Cases Handled + +**Hash Navigation Priority:** +If URL contains `#msg-{messageId}`, hash navigation takes precedence over auto-jump. + +**No Unreads:** +If `lastReadTimestamp === 0` (no read history), normal behavior applies (land at bottom). + +**First Unread Already Loaded:** +If first unread is within the initial 100 messages, skip loading and just scroll to it. + +--- + +## Implementation + +### Core Logic + +**Channels** (Channel.tsx:344-422) and **Direct Messages** (DirectMessage.tsx:338-454) share identical implementation: + +```typescript +useEffect(() => { + // Skip if hash navigation in progress + if (window.location.hash.startsWith('#msg-')) return; + + // Skip if no unreads + if (lastReadTimestamp === 0) return; + + const jumpToFirstUnread = async () => { + // Get first unread message ID + const firstUnread = await messageDB.getFirstUnreadMessage({ + spaceId, channelId, afterTimestamp: lastReadTimestamp + }); + + if (!firstUnread) return; + + // Check if already loaded + const isAlreadyLoaded = messageList.some(m => m.messageId === firstUnread.messageId); + + if (isAlreadyLoaded) { + setScrollToMessageId(firstUnread.messageId); + return; + } + + // Load messages around first unread + const { messages, prevCursor, nextCursor } = await loadMessagesAround({ + messageDB, spaceId, channelId, + targetMessageId: firstUnread.messageId, + beforeLimit: 40, afterLimit: 40 + }); + + // Update cache and scroll + queryClient.setQueryData(buildMessagesKey({ spaceId, channelId }), { + pages: [{ messages, prevCursor, nextCursor }], + pageParams: [undefined] + }); + + setScrollToMessageId(firstUnread.messageId); + }; + + const timer = setTimeout(() => jumpToFirstUnread(), 100); + return () => clearTimeout(timer); +}, [channelId, spaceId, lastReadTimestamp, messageDB, messageList, queryClient]); +``` + +### MessageList Integration (MessageList.tsx:330-356) + +```typescript +// Handle programmatic scrollToMessageId +useEffect(() => { + if (!init || messageList.length === 0 || !scrollToMessageId) return; + + if (!hasProcessedScrollTo) { + const index = messageList.findIndex(m => m.messageId === scrollToMessageId); + + if (index !== -1) { + setHasProcessedScrollTo(true); + setHasJumpedToOldMessage(true); // Disable auto-scroll during pagination + + // Scroll to the message (no highlight - unread line is shown via lastReadTimestamp) + setTimeout(() => { + scrollToMessage(scrollToMessageId, virtuoso.current, messageList); + }, 200); + } + } +}, [init, messageList, scrollToMessageId, hasProcessedScrollTo, scrollToMessage]); +``` + +### New Props + +**MessageList.tsx:** +- `scrollToMessageId?: string` - Triggers programmatic scroll without URL hash + +**Channel.tsx & DirectMessage.tsx:** +- `scrollToMessageId` state - Holds target message ID for auto-jump + +--- + +## Shared Infrastructure + +This feature **reuses** the bidirectional loading pattern from hash navigation: + +- **`loadMessagesAround()`** utility (see [hash-navigation-to-old-messages.md](./hash-navigation-to-old-messages.md)) +- **`getFirstUnreadMessage()`** database query (MessageDB.ts:1560-1603) +- **`scrollToMessage()`** scroll helper (useMessageHighlight.ts) +- **`hasJumpedToOldMessage`** flag for disabling auto-scroll during pagination + +For details on bidirectional loading, React Query cache updates, and pagination behavior, see the hash navigation documentation. + +--- + +## User Experience + +### Visual Indicators + +**Unread Line:** +Messages after `lastReadTimestamp` show a visual indicator (handled by Message component). No flash highlight is used - the unread line provides persistent visual feedback. + +**Scroll Position:** +First unread message is positioned with ~40 messages of context above, allowing users to scroll up for additional context or down to read new messages. + +### Pagination Behavior + +After auto-jumping: +- **Scroll UP** → Loads older messages (backward pagination) +- **Scroll DOWN** → Loads newer messages (forward pagination) +- **Auto-scroll disabled** until reaching present (`hasNextPage === false`) +- **"Jump to Present" button** appears when scrolled away from bottom + +This is identical to hash navigation behavior. + +--- + +## Testing Scenarios + +### 1. Standard Auto-Jump +- **Setup**: Channel or DM has 200 messages, user last read at message 100 +- **Expected**: Jump to message 101 (first unread) with messages 61-141 loaded +- **Verify**: Can scroll up to see older messages, down to see newer + +### 2. Already Loaded Unread +- **Setup**: First unread is within most recent 100 messages +- **Expected**: No reload, just scroll to first unread +- **Verify**: Fast, no loading indicator + +### 3. Hash Navigation Priority +- **Setup**: Open channel or DM with `#msg-abc123` and also has unreads +- **Expected**: Hash navigation takes priority, auto-jump is skipped +- **Verify**: Lands on hash message, not first unread + +### 4. No Unreads +- **Setup**: User has no read history (`lastReadTimestamp === 0`) or all caught up +- **Expected**: Normal behavior, land at bottom +- **Verify**: Latest messages shown + +### 5. Bidirectional Scrolling +- **Setup**: Auto-jumped to first unread in middle of channel or DM history +- **Expected**: Can scroll up/down, auto-scroll disabled, "Jump to Present" button appears +- **Verify**: Scrolling works smoothly, auto-scroll resumes at present + +--- + +## Performance + +**Initial Load:** +- Best case: ~100-200ms (first unread in recent 100) +- Average case: ~500ms-1s (database query + cache update) +- Worst case: ~2-3s (slow device, large channel/DM history) + +**Message Count:** +- Loads 81 messages (40 + 1 + 40) +- Same as hash navigation +- Sufficient context without memory overhead + +--- + +## Code References + +**Modified Files:** +- `src/components/message/MessageList.tsx:69,111,330-356,360-362` - Added `scrollToMessageId` prop and scroll logic +- `src/components/space/Channel.tsx:119-122,344-422,920` - Auto-jump logic and state (Channels) +- `src/components/direct/DirectMessage.tsx:66,82,338-460,719` - Auto-jump logic and state (Direct Messages) +- `src/hooks/business/conversations/useDirectMessagesList.ts:14,36,113` - Added `hasNextPage` support for DMs + +**Shared Utilities:** +- `src/hooks/queries/messages/loadMessagesAround.ts` - Bidirectional loading (see hash navigation doc) +- `src/db/messages.ts:1560-1603` - `getFirstUnreadMessage()` query +- `src/hooks/business/messages/useMessageHighlight.ts` - `scrollToMessage()` helper + +--- + +## Related Documentation + +- [hash-navigation-to-old-messages.md](./hash-navigation-to-old-messages.md) - Shared bidirectional loading infrastructure +- `.agents/bugs/auto-jump-unread-virtuoso-scroll-conflict.md` - Previous failed attempts and lessons learned + +--- + +## Design Decisions + +**Why No Flash Highlight?** +The unread line indicator (provided by `lastReadTimestamp`) already shows which messages are unread. Adding a flash highlight would be redundant and potentially confusing. + +**Why 40 Messages Before/After?** +Same as hash navigation. Provides sufficient context for users to understand conversation flow while keeping memory usage reasonable. + +**Why Skip When Hash Present?** +Hash navigation is explicit user intent (clicked a link). Auto-jump is convenience feature. Explicit intent wins. + +**Why Reuse Hash Navigation Pattern?** +The hash navigation pattern was proven to work with Virtuoso's scroll system. Previous attempts to implement auto-jump failed due to scroll timing issues. Reusing the working pattern ensures reliability. + +--- + +*Last updated: 2025-11-13* +*Verified: 2025-12-09 - File paths and architecture confirmed current* diff --git a/.agents/docs/features/messages/bookmarks.md b/.agents/docs/features/messages/bookmarks.md new file mode 100644 index 000000000..3fdee9276 --- /dev/null +++ b/.agents/docs/features/messages/bookmarks.md @@ -0,0 +1,464 @@ +--- +type: doc +title: Bookmarks Feature +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-02T00:00:00.000Z +--- + +# Bookmarks Feature + +> **⚠️ AI-Generated**: May contain errors. Verify before use. +> **Reviewed by**: feature-analyzer and security-analyst agents + +## Overview + +The Bookmarks feature allows users to privately save important messages from both Spaces (Channels) and Direct Messages for later reference. Unlike pinned messages which are shared across all space members, bookmarks are **personal and private** to each user. + +**Key Value Proposition**: Provides a personal reference system for important information across all conversations, increasing user productivity without requiring special permissions or affecting other users. + +## Architecture + +### Data Flow + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ LOCAL OPERATIONS │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ Message → MessageActions → useMessageActions → useBookmarks → MessageDB │ +│ ↑ ↓ │ +│ BookmarksPanel ← BookmarkItem ← filteredBookmarks ← React Query ← IndexedDB │ +└─────────────────────────────────────────────────────────────────────────────┘ + ↓ +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CROSS-DEVICE SYNC │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ useBookmarks → removeBookmark → Track deletion → Update UserConfig │ +│ ↓ │ +│ ConfigService.saveConfig() ← Collect bookmarks ← MessageDB.getBookmarks() │ +│ ↓ │ +│ AES-GCM Encrypt → Ed448 Sign → POST /api/settings/{address} │ +│ ↓ │ +│ ConfigService.getConfig() → Decrypt → Verify → mergeBookmarks() → Apply │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +### Key Components + +**Data Layer**: +- `src/api/quorumApi.ts:242+` - `Bookmark` type definition and `BOOKMARKS_CONFIG` +- `src/db/messages.ts:50-51` - UserConfig type with sync fields (`bookmarks?`, `deletedBookmarkIds?`) +- `src/db/messages.ts:205-218` - `getMessageById()` for context-free message resolution +- `src/db/messages.ts:1577-1696` - CRUD operations in MessageDB class + +**Business Logic**: +- `src/hooks/business/bookmarks/useBookmarks.ts` - Main business hook with React Query integration +- `src/hooks/queries/bookmarks/useResolvedBookmark.ts` - Message resolution for hybrid MessagePreview rendering +- `src/hooks/queries/bookmarks/` - React Query hooks for data fetching and caching +- `src/hooks/business/messages/useMessageActions.ts` - Action integration and context handling + +**Sync Layer**: +- `src/services/ConfigService.ts:377-379` - Bookmark collection before encryption +- `src/services/ConfigService.ts:289-325` - Differential sync with error recovery +- `src/services/ConfigService.ts:453-490` - Merge algorithm with deduplication +- `src/utils.ts:17-18` - Default UserConfig with empty bookmark arrays + +**UI Components**: +- `src/components/bookmarks/BookmarksPanel.tsx` - Dropdown panel with filtering and virtualization +- `src/components/bookmarks/BookmarkItem.tsx` - Individual bookmark item with actions +- `src/components/bookmarks/BookmarksPanel.scss` - Responsive styling + +**Integration Points**: +- `src/components/message/MessageActions.tsx` - Desktop hover actions +- `src/components/message/MessageActionsDrawer.tsx` - Mobile touch drawer +- `src/components/space/Channel.tsx` - Channel header bookmark button +- `src/components/direct/DirectMessage.tsx` - DM header bookmark button +- `src/components/message/Message.tsx` - Visual bookmark indicators in message headers + +### Data Structure + +```typescript +export type Bookmark = { + bookmarkId: string; // crypto.randomUUID() + messageId: string; // Reference to original message + spaceId?: string; // For space messages (undefined for DMs) + channelId?: string; // For channel messages (undefined for DMs) + conversationId?: string; // For DM messages (undefined for channels) + sourceType: 'channel' | 'dm'; + createdAt: number; // Timestamp for sorting + + cachedPreview: { + senderName: string; // Display name at bookmark time + textSnippet: string; // First ~150 chars, plain text (empty for media-only) + messageDate: number; // Original message timestamp + sourceName: string; // "Space Name > #channel" or empty for DMs + + // Media content info for visual rendering + contentType: 'text' | 'image' | 'sticker'; + imageUrl?: string; // For embed messages (image URL) + thumbnailUrl?: string; // For embed messages (smaller preview) + stickerId?: string; // For sticker messages (resolve at render time) + }; +}; +``` + +**Configuration**: +```typescript +const BOOKMARKS_CONFIG = { + MAX_BOOKMARKS: 200, // Maximum bookmarks per user + PREVIEW_SNIPPET_LENGTH: 150, // Character limit for cached text +} as const; +``` + +**UserConfig Sync Fields**: +```typescript +export type UserConfig = { + // ... existing fields (spaceIds, spaceKeys, notificationSettings, etc.) + bookmarks?: Bookmark[]; // User's bookmarks for cross-device sync + deletedBookmarkIds?: string[]; // Tombstones for deletion sync +}; +``` + +### Database Schema + +**IndexedDB Object Store**: `bookmarks` +- **keyPath**: `bookmarkId` +- **Indices**: + - `by_message` on `messageId` - O(1) isBookmarked checks + - `by_created` on `createdAt` - Chronological sorting + +All bookmarks are loaded into memory with a memoized Set for O(1) lookup during message rendering. + +## Cross-Device Sync + +Bookmark sync leverages the existing **UserConfig sync infrastructure** used for spaceKeys and notificationSettings. + +### Sync Flow + +When `allowSync` is enabled in Privacy settings: +1. User creates/deletes bookmark locally +2. `ConfigService.saveConfig()` collects all bookmarks +3. AES-GCM encrypts UserConfig (including bookmarks) +4. Ed448 signs encrypted payload +5. POST `/api/settings/{address}` syncs to server + +When user switches devices: +1. `ConfigService.getConfig()` fetches from server +2. Verifies Ed448 signature and decrypts AES-GCM +3. `mergeBookmarks()` combines local + remote using differential sync +4. Only changed bookmarks are updated in IndexedDB +5. Tombstones reset after successful sync + +### Conflict Resolution + +The merge algorithm uses **last-write-wins with tombstone tracking and messageId deduplication**: + +```typescript +private mergeBookmarks( + local: Bookmark[], + remote: Bookmark[], + deletedIds: string[] +): Bookmark[] { + const bookmarkMap = new Map<string, Bookmark>(); + const messageIdToBookmarkId = new Map<string, string>(); + + const addBookmark = (bookmark: Bookmark) => { + if (deletedIds.includes(bookmark.bookmarkId)) return; + + const existingBookmarkId = messageIdToBookmarkId.get(bookmark.messageId); + const existing = existingBookmarkId ? bookmarkMap.get(existingBookmarkId) : undefined; + + if (!existing || bookmark.createdAt > existing.createdAt) { + if (existingBookmarkId) { + bookmarkMap.delete(existingBookmarkId); + } + bookmarkMap.set(bookmark.bookmarkId, bookmark); + messageIdToBookmarkId.set(bookmark.messageId, bookmark.bookmarkId); + } + }; + + local.forEach(addBookmark); + remote.forEach(addBookmark); + + return Array.from(bookmarkMap.values()) + .sort((a, b) => b.createdAt - a.createdAt); +} +``` + +### Deletion Tracking + +When a bookmark is deleted, the deletion is tracked in `deletedBookmarkIds` for cross-device sync: + +```typescript +// In removeBookmark mutation +mutationFn: async (bookmarkId: string) => { + await messageDB.removeBookmark(bookmarkId); + + const config = await messageDB.getUserConfig({ address: userAddress }); + if (config) { + config.deletedBookmarkIds = config.deletedBookmarkIds || []; + config.deletedBookmarkIds.push(bookmarkId); + await messageDB.saveUserConfig(config); + } +} +``` + +### Differential Sync + +Instead of replacing all bookmarks, the system calculates and applies only necessary changes: + +```typescript +const localMap = new Map(localBookmarks.map(b => [b.bookmarkId, b])); +const mergedMap = new Map(mergedBookmarks.map(b => [b.bookmarkId, b])); + +const toDelete = localBookmarks.filter(b => !mergedMap.has(b.bookmarkId)); +const toAdd = mergedBookmarks.filter(b => !localMap.has(b.bookmarkId)); +const toUpdate = mergedBookmarks.filter(b => { + const existing = localMap.get(b.bookmarkId); + return existing && existing.createdAt !== b.createdAt; +}); + +// Apply only necessary changes +for (const bookmark of toDelete) { + await this.messageDB.removeBookmark(bookmark.bookmarkId); +} +for (const bookmark of [...toAdd, ...toUpdate]) { + await this.messageDB.addBookmark(bookmark); +} +``` + +### Error Recovery + +The sync includes transaction safety with rollback capability. If sync fails partway through, the system attempts to restore the original local bookmarks to prevent data loss. + +### Security & Privacy + +- **Encryption**: AES-GCM with user-derived keys (SHA-512 of user private key) +- **Signing**: Ed448 signature for integrity verification +- **Privacy Control**: Only syncs when `allowSync=true` in Privacy settings +- **User Control**: Disable sync anytime via Privacy settings toggle +- **Limit Enforcement**: 200 bookmark limit enforced with defense-in-depth (UI + database-layer atomic validation to prevent client-side bypass, see [messages.ts:1656-1679](../../../src/db/messages.ts#L1656-L1679)) + +## Usage Examples + +### Basic Bookmark Operations + +```typescript +const { isBookmarked, toggleBookmark, canAddBookmark } = useBookmarks(userAddress); + +// Check if message is bookmarked (O(1)) +const bookmarked = isBookmarked(message.messageId); + +// Toggle bookmark with context +const handleToggle = () => { + toggleBookmark( + message, + 'channel', + { + spaceId: 'space-123', + channelId: 'channel-456', + conversationId: undefined + }, + 'User Display Name', + 'Space Name > #channel-name' + ); +}; +``` + +### Opening Bookmarks Panel + +```typescript +const [activePanel, setActivePanel] = useState<ActivePanel>(null); + +<Button + onClick={() => setActivePanel('bookmarks')} + iconName="bookmark" + iconOnly +/> + +<BookmarksPanel + isOpen={activePanel === 'bookmarks'} + onClose={() => setActivePanel(null)} + userAddress={userAddress} +/> +``` + +### Context-Aware Filtering + +```typescript +const { + filterBySourceType, // 'all' | 'channel' | 'dm' + filterByConversation, // Filter DMs by conversationId + filterByCurrentSpace, // Filter by spaceId + optional channelId +} = useBookmarks(userAddress); + +// Dynamic filter options based on current route +const filterOptions = useMemo(() => { + const options = [{ value: 'all', label: 'All Bookmarks' }]; + + if (searchContext.type === 'dm' && searchContext.conversationId) { + options.push( + { value: `conversation:${searchContext.conversationId}`, label: 'This conversation' }, + { value: 'dms', label: 'All DMs' }, + { value: 'spaces', label: 'All Spaces' } + ); + } else if (searchContext.type === 'space' && searchContext.spaceId) { + options.push( + { value: `currentSpace:${searchContext.spaceId}`, label: 'This Space' }, + { value: 'spaces', label: 'All Spaces' }, + { value: 'dms', label: 'All DMs' } + ); + } else { + options.push( + { value: 'dms', label: 'All DMs' }, + { value: 'spaces', label: 'All Spaces' } + ); + } + + return options; +}, [searchContext]); +``` + +### Cross-Context Navigation + +Navigation uses hash-based highlighting (cross-component communication via URL state): + +```typescript +const handleJumpToMessage = (bookmark: Bookmark) => { + // Navigate with hash - destination MessageList handles scroll, Message detects hash for highlighting + if (bookmark.sourceType === 'channel') { + navigate(`/spaces/${bookmark.spaceId}/${bookmark.channelId}#msg-${bookmark.messageId}`); + } else { + const dmAddress = bookmark.conversationId?.split('/')[0]; + navigate(`/messages/${dmAddress}#msg-${bookmark.messageId}`); + } + + // Clean up hash after highlight animation completes (8s matches CSS animation) + setTimeout(() => { + history.replaceState(null, '', window.location.pathname + window.location.search); + }, 8000); +}; +``` + +See `.agents/docs/features/messages/message-highlight-system.md` for the full highlighting architecture. + +## Technical Decisions + +### Database Integration +Bookmark CRUD is integrated into the existing `MessageDB` class rather than a separate `BookmarkDB`. This allows bookmarks to benefit from shared DB connection and transaction patterns. + +### Sync via ConfigService +Bookmark sync is integrated into ConfigService rather than a separate BookmarkService because: +- UserConfig is the natural sync boundary for user-private data +- Leverages existing AES-GCM encryption and Ed448 signing infrastructure +- Provides atomic consistency with other user settings +- Avoids duplicating complex crypto code + +### Differential Sync +The system calculates and applies only changed bookmarks rather than replacing all. This provides 20-40x faster sync (~10ms vs ~400ms for 200 bookmarks) and eliminates UI flickering. + +### Conflict Resolution Strategy +Last-write-wins with tombstone tracking is simple, deterministic, and sufficient for bookmarks (which don't require collaborative editing). Operational Transform or CRDTs would be over-engineered for this use case. + +### MessageId Deduplication +The merge algorithm prevents multiple bookmarks pointing to the same message, ensuring a clean UI when the same message is bookmarked on different devices. + +### Tombstone Reset Timing +`deletedBookmarkIds` resets only after successful sync to prevent resurrection of deleted bookmarks during network failures. + +### Context-Aware Filtering +Filter options dynamically reorder based on current route (DM vs Space context), surfacing the most relevant filters first. + +### Performance Architecture +All bookmarks are loaded into a memoized Set for O(1) status checking. With a 200 bookmark limit, memory cost is negligible compared to the query cost of database lookups per message. + +### Visual Indicators +Filled bookmark icons appear in message headers when bookmarked, providing immediate visual feedback without cluttering the UI. + +## Performance Characteristics + +| Operation | Complexity | Implementation | +|-----------|------------|----------------| +| `isBookmarked()` check | O(1) | Memoized Set lookup | +| Panel rendering | O(visible) | Virtuoso virtualization | +| Message resolution | O(uncached) | React Query cache | +| Filter by space | O(n) | In-memory filtering | +| Add/remove bookmark | O(1) + invalidate | Optimistic updates | +| Cross-device sync | O(changes) | Differential sync | +| Merge bookmarks | O(n) | Map-based deduplication | + +Race conditions from rapid clicking are prevented via pending state tracking that debounces toggle operations. + +## Integration Patterns + +### Message Actions +- Desktop: Hover reveals bookmark button after copy message, before separator +- Mobile: Drawer action between copy and edit +- Icon changes based on bookmark state; no permission checking required + +### Panel State Management +- Follows existing `activePanel` state pattern for mutual exclusion +- Each header manages its own panel state (no global context needed) +- Mobile: Automatic drawer conversion via `isTouchDevice()` detection + +### Styling +- Uses `@extend` patterns from shared dropdown styles +- Responsive design with mobile breakpoint at 639px +- Consistent with NotificationPanel and PinnedMessagesPanel patterns + +### Hybrid Message Preview (Like PinnedMessagesPanel) +BookmarkItem uses a hybrid rendering approach: + +1. **Full MessagePreview** (preferred): If message exists in local IndexedDB, render with `MessagePreview` component (same as PinnedMessagesPanel) - supports full markdown, mentions, images, stickers +2. **Cached Preview** (fallback): If message not found locally (cross-device sync, unloaded channel), render using `cachedPreview` data stored in the bookmark + +Resolution flow: +``` +useResolvedBookmark(bookmark) → messageDB.getMessageById(messageId) + ↓ +┌─────────────────────────────────────────┐ +│ Message found in local IndexedDB? │ +├────────────┬────────────────────────────┤ +│ YES │ NO │ +│ ↓ │ ↓ │ +│ MessagePreview │ CachedPreview │ +│ (full render) │ (fallback) │ +└────────────┴────────────────────────────┘ +``` + +### Media Content Support (Cached Fallback) +When using cached preview fallback: +- Images (embeds): Displayed as thumbnails (200x120 max) +- Stickers: Displayed visually (80x80 max) with sticker lookup at render time +- Fallback text: `[Image]` or `[Sticker]` shown if media URL unavailable + +## Known Limitations + +### Bookmark Count Badge +Header icons don't show bookmark count. This is by design to avoid header clutter. + +### Cross-Device Message Availability +When viewing bookmarks on a different device, messages may not exist in local IndexedDB (not synced). In this case, the cached preview is shown instead of full MessagePreview. This is expected behavior - the cached preview ensures bookmarks always display something useful. + +### Deleted Message Handling +When a message is deleted, any bookmark pointing to it is automatically removed (cascade deletion). This prevents orphaned bookmarks with stale cached data. + +### Offline Sync +Sync requires network connectivity. Local changes are preserved but won't sync until online. Tombstones accumulate during offline periods and reset on successful sync. + +## Related Documentation + +- [Message Preview Rendering](message-preview-rendering.md) - Overview of all preview rendering systems +- [Markdown Stripping](markdown-stripping.md) - Text processing utilities +- [Markdown Renderer](markdown-renderer.md) - Full message rendering (dual system) +- `src/components/message/PinnedMessagesPanel.tsx` - Similar panel structure and navigation +- `src/components/notifications/NotificationPanel.tsx` - Filter patterns and UI reference +- `src/components/ui/DropdownPanel.tsx` - Container component architecture +- `src/services/ConfigService.ts` - Sync infrastructure patterns +- [Message Bookmarking Feature Task](../../tasks/message-bookmarking-feature.md) - Original implementation specification + +--- + + +*Verified: 2025-12-09 - File paths confirmed current* diff --git a/.agents/docs/features/messages/client-side-image-compression.md b/.agents/docs/features/messages/client-side-image-compression.md new file mode 100644 index 000000000..a5f6762bd --- /dev/null +++ b/.agents/docs/features/messages/client-side-image-compression.md @@ -0,0 +1,241 @@ +--- +type: doc +title: Client-Side Image Compression & Thumbnail System +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-09-21T00:00:00.000Z +--- + +# Client-Side Image Compression & Thumbnail System + +## Overview + +Unified image compression system with smart thumbnail generation for bandwidth optimization. Automatically processes all uploaded images with appropriate compression and generates thumbnails for large images to minimize data usage while preserving quality when needed. + +## Architecture + +### Core Files + +``` +src/utils/imageProcessing/ +├── compressor.ts # Main compression engine (web) +├── gifUtils.ts # GIF frame extraction utilities +├── types.ts # Shared interfaces +├── index.ts # Public API exports +├── config.ts # Unified configuration system +├── errors.ts # Centralized error messages +├── gifProcessor.ts # Consolidated GIF handling +└── unifiedProcessor.ts # Single processor for all image types +``` + +### Integration Points + +**Updated Hooks (with compression & thumbnails):** +- `src/hooks/business/messages/useMessageComposer.ts` - Message attachments with dual URLs +- `src/hooks/business/user/useWebFileUpload.ts` - User avatars +- `src/hooks/business/ui/useSpaceFileUploads.ts` - Space icons/banners +- `src/hooks/business/ui/useCustomAssets.ts` - Emojis/stickers with GIF support +- `src/hooks/business/user/useProfileImage.ts` - Legacy avatar uploads +- `src/hooks/business/ui/useFileUpload.ts` - Legacy file uploads + +**Enhanced Components:** +- `src/components/message/Message.tsx` - Smart thumbnail/full image display +- `src/components/message/MessageComposer.tsx` - Dual image preview +- `src/components/primitives/FileUpload/FileUpload.web.tsx` - Built-in avatar compression + +## Compression Targets & GIF Handling + +| Use Case | Input Limit | Static Output | Animated GIF Limit | Display Size | +|----------|-------------|---------------|-------------------|--------------| +| **User Avatars** | 25MB | 123×123px | N/A (static only) | 82×82px | +| **Space Icons** | 25MB | 123×123px | N/A (static only) | 82×82px | +| **Space Banners** | 25MB | 450×253px | N/A (static only) | 300×120px | +| **Message Attachments** | 25MB | 300px + 1200px | 2MB (animation preserved) | 300×300px max | +| **Custom Emojis** | 5MB | 36×36px | 100KB (animation preserved) | 24×24px | +| **Custom Stickers** | 25MB | 400px width (600px max height) | 750KB (animation preserved) | 300px max width (600px max height) | + +### Smart Thumbnail System (Message Attachments) + +**For Static Images > 300px:** +- **Thumbnail**: 300×300px max for instant display +- **Full Image**: 1200×1200px max for modal view +- **Bandwidth Savings**: ~90% (loads thumbnail first, full image on click) + +**For All GIFs (New Approach):** +- **Display**: Always constrained to 300px max width via CSS +- **Modal**: Never opens - GIFs animate in-place only +- **Large GIFs (>500KB)**: Static first frame thumbnail with play button +- **Click Behavior**: Animate full GIF in-place at 300px max width +- **Small GIFs (≤500KB)**: Auto-play directly at 300px max width +- **Bandwidth Savings**: ~90% for large GIFs (thumbnail first) + +### GIF Animation Preservation Strategy + +- **Message GIFs**: 2MB limit, always animate in-place at 300px max width +- **Sticker GIFs**: 750KB limit, always animate in-place at 300px max width +- **Emoji GIFs**: 100KB limit, perfect for tiny animations +- **Modal Behavior**: GIFs never open in modal - only static images do +- **Smart Processing**: Never convert animated GIFs to static images + +## Usage + +### Message Attachments with Thumbnails +```typescript +import { processAttachmentImage } from '../../../utils/imageProcessing'; + +// Process message attachment - returns thumbnail + full image if needed +const result = await processAttachmentImage(file); + +if (result.thumbnail) { + // Large image/GIF - has thumbnail + const thumbnailBuffer = await result.thumbnail.file.arrayBuffer(); + const fullBuffer = await result.full.file.arrayBuffer(); + + const embedMessage: EmbedMessage = { + type: 'embed', + thumbnailUrl: `data:${result.thumbnail.file.type};base64,${Buffer.from(thumbnailBuffer).toString('base64')}`, + imageUrl: `data:${result.full.file.type};base64,${Buffer.from(fullBuffer).toString('base64')}`, + isLargeGif: result.isLargeGif + }; +} else { + // Small image - single version + const buffer = await result.full.file.arrayBuffer(); + const embedMessage: EmbedMessage = { + type: 'embed', + imageUrl: `data:${result.full.file.type};base64,${Buffer.from(buffer).toString('base64')}` + }; +} +``` + +### Unified Configuration-Driven Processing +```typescript +import { processImage, IMAGE_CONFIGS } from '../../../utils/imageProcessing'; + +// Direct config-based processing (new approach) +const result = await processImage(file, 'emoji'); +const stickerResult = await processImage(file, 'sticker'); + +// Or use convenient type-specific processors +const emojiResult = await processEmojiImage(file); // 36px, 100KB GIF limit +const stickerResult = await processStickerImage(file); // 400px width, 750KB GIF limit +const avatarResult = await processAvatarImage(file); // 123px, no GIFs +``` + +### Error Handling with Centralized Messages +```typescript +import { processImage, IMAGE_ERRORS } from '../../../utils/imageProcessing'; + +try { + const result = await processImage(file, 'emoji'); +} catch (error) { + // Standardized error messages for better UX + if (error.message.includes('100KB')) { + // Handle emoji GIF size limit + } else if (error.message.includes('5MB')) { + // Handle general file size limit + } +} +``` + +## File Size Limits + +### Input Limits (What Users Can Upload) +- **Static images**: 25MB (automatically compressed) +- **Message GIFs**: 2MB hard limit (animation preserved) +- **Sticker GIFs**: 750KB hard limit (animation preserved) +- **Emoji GIFs**: 100KB hard limit (animation preserved) +- **Emojis (static)**: 5MB (compressed to 36×36px) + +### Output Results (After Processing) +- **Static images**: 50KB - 500KB typical range +- **Thumbnails**: 20KB - 100KB typical range +- **Animated GIFs**: Original file size preserved (within limits) +- **Compression ratios**: 5-20x smaller for static images + +## Error Handling + +### Error Messages +- `"File cannot be larger than 25MB"` - Static image input limit +- `"GIF files cannot be larger than 2MB"` - Message GIF limit +- `"Animated sticker GIFs cannot be larger than 750KB"` - Sticker GIF limit +- `"Animated emoji GIFs cannot be larger than 100KB"` - Emoji GIF limit +- `"Unable to process image. Please use a smaller image."` - Processing failure + +### Loading States +- Processing indicators for thumbnail generation +- Progress tracking for dual-image processing +- Specific indicators for GIF frame extraction + +## Platform Support + +### Web (✅ Implemented) +- Uses **compressorjs** library for static compression +- **Canvas API** for GIF frame extraction +- Handles all formats: JPEG, PNG, GIF, WebP, HEIC +- **PNG transparency**: Preserved for files ≤750KB, converted to JPEG for larger files +- **GIF animation**: Always preserved within size limits + +### Mobile (📱 Planned) +- Will use **expo-image-manipulator** for static compression +- **React Native Image** for GIF frame extraction +- Same compression targets and behavior +- See: `.agents/tasks/mobile-dev/mobile-image-compression.md` + +## Development Notes + +### Adding New Image Types +1. Add configuration to `IMAGE_CONFIGS` in `config.ts` +2. Define compression target (dimensions, ratio, quality) +3. Set GIF size limits and animation preservation settings +4. Export type-specific processor function if needed +5. Update hooks to use new processor + +### Configuration System Benefits +- **Single source of truth**: All compression settings in one place +- **Type safety**: TypeScript ensures valid configurations +- **Consistency**: Same logic applied across all image types +- **Maintainability**: Easy to adjust compression targets +- **Error handling**: Centralized, translatable error messages +- **GIF Display Control**: `gifMaxDisplayWidth` setting controls max width across all GIF types + +### Testing Thumbnail System +```javascript +// Check thumbnail generation in browser console +const result = await processAttachmentImage(file); +console.log(`Has thumbnail: ${!!result.thumbnail}`); +if (result.thumbnail) { + console.log(`Thumbnail: ${(result.thumbnail.file.size / 1024).toFixed(2)}KB`); + console.log(`Full image: ${(result.full.file.size / 1024).toFixed(2)}KB`); + console.log(`Bandwidth savings: ${((1 - result.thumbnail.file.size / result.full.file.size) * 100).toFixed(1)}%`); +} +``` + +### Testing GIF Processing +```javascript +// Check GIF handling in browser console +const gifFile = document.querySelector('input[type="file"]').files[0]; +console.log(`GIF size: ${(gifFile.size / 1024).toFixed(2)}KB`); +console.log(`Type: ${gifFile.type}`); + +// Test processing +const result = await processAttachmentImage(gifFile); +console.log(`Animation preserved: ${!result.thumbnail || result.isLargeGif}`); +``` + +### Performance Optimizations +- **Configuration-driven processing** - eliminates code duplication +- **Centralized GIF validation** - single validation path for all image types +- **Smart thumbnail generation** - only when needed for large images/GIFs +- **Skip compression** for files already within limits +- **Memory efficient** GIF frame extraction +- **Progressive enhancement** - works without thumbnails for old messages + +### Bandwidth Impact +- **90% reduction** for large static images (300px vs 1200px) +- **95%+ reduction** for large GIFs (40KB thumbnail vs 2MB animation) +- **Zero overhead** for small images/GIFs (no unnecessary thumbnails) + +--- + + +*Verified: 2025-12-09 - All file paths and architecture confirmed current* diff --git a/.agents/docs/features/messages/dm-conversation-list-previews.md b/.agents/docs/features/messages/dm-conversation-list-previews.md new file mode 100644 index 000000000..3c728b565 --- /dev/null +++ b/.agents/docs/features/messages/dm-conversation-list-previews.md @@ -0,0 +1,99 @@ +--- +type: doc +title: DM Conversation List Previews +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# DM Conversation List Previews + +## Overview + +Shows message previews and timestamps in the DM conversation list, similar to Signal/Telegram. Provides quick context about recent messages without opening the conversation. + +## Implementation + +### Display Format + +``` +[Avatar] Jennifer........................3:45 PM + Hey, did you see the latest update? + This is the second line of preview... +``` + +- **Line 1:** Avatar (44px) + Display Name + Timestamp (right-aligned) +- **Lines 2-3:** Message preview (up to 2 lines with ellipsis) + +### Date Format + +- Today: "3:45 PM" +- Yesterday: "Yesterday" +- Older: "11 Nov", "6 Dec" + +### Colors + +- **Normal state:** `text-muted` for preview/timestamp +- **Active state:** `bg-sidebar-active-accent` (accent color @ 20% opacity), `text-subtle` for preview/timestamp +- **Hover state:** `bg-sidebar-hover` + +## Architecture + +**Hybrid approach:** Store `lastMessageId` in DB, compute preview on-demand in UI + +### Key Files + +- `src/api/quorumApi.ts:84` - Added `lastMessageId` to Conversation type +- `src/db/messages.ts:657` - Track last message when saving +- `src/utils/messagePreview.ts` - Generate plain text previews +- `src/utils/dateFormatting.ts:49` - Compact time formatter +- `src/hooks/business/conversations/useConversationPreviews.ts` - React Query hook +- `src/components/direct/DirectMessageContact.tsx` - Two-line layout +- `src/components/direct/DirectMessageContactsList.tsx` - Integrate previews +- `src/styles/_colors.scss:51` - `--color-bg-sidebar-active-accent` variable + +### Message Preview Logic + +`generateMessagePreview()` returns an object: `{ text: string, icon?: string }` + +**Shows:** +- Text messages: Markdown stripped, up to 100 chars +- Images: `{ text: "Photo", icon: "image" }` (renders with Icon component) +- Future: Videos (`video` icon), Attachments (`paperclip` icon) - commented out + +**Hides:** System messages (edits, reactions, profile updates) - returns empty to fall back to previous content + +**Special:** Deleted messages show `Message deleted` (no icon) + +## Performance + +- **IndexedDB Query:** O(1) direct key lookup per conversation +- **Caching:** React Query with 30s staleTime, 5min gcTime +- **Batching:** Processes in chunks of 10 to avoid overwhelming IndexedDB +- **Optimization:** Stable query key prevents unnecessary refetches + +**Performance Profile:** +- 50 conversations: ~50-100ms initial, 0ms cached +- 100 conversations: ~100-200ms initial, 0ms cached + +## Cache Invalidation + +`useInvalidateConversation` hook invalidates `['conversation-previews']` query key when: +- New message arrives +- Conversation updates + +## i18n Support + +Uses Lingui macro syntax for automatic extraction: +- `t\`Photo\`` (with `image` icon) +- `t\`Message deleted\`` +- `t\`Yesterday\`` + +Future translations ready: +- `t\`Video\`` (with `video` icon) +- `t\`Attachment\`` (with `paperclip` icon) + +--- + +**Updated:** 2025-01-14 +**Verified:** 2025-12-09 - File paths confirmed current diff --git a/.agents/docs/features/messages/emoji-picker-react-customization.md b/.agents/docs/features/messages/emoji-picker-react-customization.md new file mode 100644 index 000000000..c75d50f4d --- /dev/null +++ b/.agents/docs/features/messages/emoji-picker-react-customization.md @@ -0,0 +1,106 @@ +--- +type: doc +title: Emoji Picker React - Styling & Customization +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-09T00:00:00.000Z +--- + +# Emoji Picker React - Styling & Customization + +## Basic Information + +- **Package**: emoji-picker-react +- **Root Selector**: `.EmojiPickerReact` +- **Dark Mode Selector**: `.EmojiPickerReact.epr-dark-theme` +- **Styles Location**: `src/styles/_emoji-picker.scss` + +## Available CSS Variables + +### Core Styling + +- `--epr-emoji-size`: Controls emoji size (default in Quorum: `28px`) +- `--epr-emoji-gap`: Space between emojis (default in Quorum: `6px`) +- `--epr-bg-color`: Picker background color +- `--epr-text-color`: Text color in the picker +- `--epr-category-navigation-button-size`: Size of category nav buttons (default: `38px`) + +### Backgrounds + +- `--epr-category-label-bg-color`: Category label background (should match bg-color) +- `--epr-search-input-bg-color`: Search input background +- `--epr-search-input-bg-color-active`: Search input background when active +- `--epr-search-input-focus-bg-color`: Search input background when focused +- `--epr-hover-bg-color`: Hovered emoji background color +- `--epr-focus-bg-color`: Focused emoji background color + +### Borders + +- `--epr-picker-border-color`: Main picker border color +- `--epr-search-input-border-color`: Search input border color +- `--epr-search-border-color`: Search container border color + +### Text + +- `--epr-search-input-placeholder-color`: Search placeholder text color +- `--epr-category-label-text-color`: Category label text color + +## Quorum Customization (src/styles/_emoji-picker.scss) + +```scss +.EmojiPickerReact { + /* Panel styling */ + box-shadow: $shadow-lg !important; + + /* Backgrounds - use Quorum design tokens */ + --epr-bg-color: var(--color-bg-modal) !important; + --epr-category-label-bg-color: var(--color-bg-modal) !important; + --epr-search-input-bg-color: var(--color-field-bg) !important; + --epr-hover-bg-color: var(--surface-3) !important; + --epr-focus-bg-color: var(--surface-3) !important; + + /* Borders */ + --epr-picker-border-color: var(--color-border-default) !important; + --epr-search-border-color: var(--accent) !important; + + /* Text */ + --epr-text-color: var(--color-text-main) !important; + --epr-category-label-text-color: var(--color-text-subtle) !important; + + /* UI sizing */ + --epr-category-navigation-button-size: 38px !important; + --epr-emoji-size: 28px !important; + --epr-emoji-gap: 6px !important; +} +``` + +## Important Notes + +- When changing `--epr-bg-color`, also change `--epr-category-label-bg-color` to match +- Use more specific selectors when overriding default styles +- Component uses CSS variables for most customization needs +- Quorum applies Sen font family globally to the picker +- Focus outlines are removed from category buttons for cleaner appearance + +## Common Classes (Observed) + +- `.epr-cat-btn` - Category navigation buttons +- `.epr-cat-btn.epr-active` - Active category button +- `.epr-emoji-img` - Emoji images +- `.epr-emoji-native` - Native emoji elements +- `.epr-category-nav` - Category navigation container +- `.epr-search` - Search input +- `.epr-body` - Main emoji container +- `.epr-skin-tone-select` - Skin tone picker +- `button[class*='epr-emoji']` - Emoji buttons (used for focus state resets) + +## Related Files + +- `src/styles/_emoji-picker.scss` - Global emoji picker theming +- `src/components/message/EmojiPickerDrawer.tsx` - Mobile drawer component +- `src/components/message/EmojiPickerDrawer.scss` - Drawer-specific styles +- `src/hooks/business/messages/useEmojiPicker.ts` - Emoji picker hook + +--- + +_Last updated: 2025-12-09_ diff --git a/.agents/docs/features/messages/hash-navigation-to-old-messages.md b/.agents/docs/features/messages/hash-navigation-to-old-messages.md new file mode 100644 index 000000000..9444e74d2 --- /dev/null +++ b/.agents/docs/features/messages/hash-navigation-to-old-messages.md @@ -0,0 +1,253 @@ +--- +type: doc +title: Hash Navigation to Old Messages +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-11-13T00:00:00.000Z +--- + +# Hash Navigation to Old Messages + +## Overview + +### Problem +Clicking links with `#msg-{messageId}` would silently fail if the target message wasn't in the currently loaded ~100 messages. This affected search results, pinned messages, notifications, and direct URL hashes in both channels and direct messages. + +### Solution +Bidirectional message loading automatically fetches messages around the target when clicked. Users can then scroll both directions: +- **Up**: Load older messages (existing behavior) +- **Down**: Load newer messages until reaching present (new) + +The system loads 40 messages before + target + 40 messages after, creating an 81-message context window centered on the target. + +--- + +## How It Works + +### Navigation Flow +``` +1. User clicks link → #msg-{messageId} +2. MessageList searches current messages → Not found +3. Calls onHashMessageNotFound(messageId) +4. Shows loading spinner +5. Queries IndexedDB for target message + surrounding context +6. Updates React Query cache with new page +7. MessageList re-renders → Target found +8. Scrolls to target + highlights +``` + +### Scrolling Behavior + +After jumping to an old message: +- **Manual scrolling works normally** - no auto-scroll interference +- **Scroll up** → `fetchPreviousPage()` loads older messages +- **Scroll down** → `fetchNextPage()` loads newer messages +- **Reach present** → Auto-scroll resumes for real-time messages + +Auto-scroll is controlled by two conditions: +```typescript +followOutput={(isAtBottom) => { + if (hasJumpedToOldMessage) return false; // Manual pagination mode + if (isAtBottom && hasNextPage === false) return 'smooth'; // At actual present + return false; +}} +``` + +--- + +## Implementation + +### Core Components + +**1. Bidirectional Loading Utility** (`src/hooks/queries/messages/loadMessagesAround.ts`) +```typescript +async function loadMessagesAround({ + messageDB, spaceId, channelId, targetMessageId, + beforeLimit = 40, afterLimit = 40 +}): Promise<{ + messages: Message[]; + targetMessage: Message; + prevCursor: number | null; + nextCursor: number | null; +}> +``` + +**2. MessageList Props** (`src/components/message/MessageList.tsx`) +```typescript +interface MessageListProps { + onHashMessageNotFound?: (messageId: string) => Promise<void>; + isLoadingHashMessage?: boolean; + fetchPreviousPage: () => void; + fetchNextPage: () => void; + hasNextPage?: boolean; +} +``` + +**3. Hash Detection Logic** (MessageList.tsx:299-307) +```typescript +if (onHashMessageNotFound && !hasProcessedHash) { + setHasProcessedHash(true); + setHasJumpedToOldMessage(true); // Disable auto-scroll + onHashMessageNotFound(msgId).catch(console.error); +} +``` + +**4. Parent Component Handler** + +**Channels** (Channel.tsx:292-336) and **Direct Messages** (DirectMessage.tsx:465-507) share identical handler: + +```typescript +const handleHashMessageNotFound = useCallback(async (messageId: string) => { + setIsLoadingHashMessage(true); + const { messages, prevCursor, nextCursor } = await loadMessagesAround({ + messageDB, spaceId, channelId, targetMessageId: messageId + }); + queryClient.setQueryData(buildMessagesKey({ spaceId, channelId }), { + pages: [{ messages, prevCursor, nextCursor }], + pageParams: [undefined], + }); + setIsLoadingHashMessage(false); +}, [messageDB, spaceId, channelId, queryClient]); +``` + +Note: For Direct Messages, both `spaceId` and `channelId` are set to the conversation address. + +### State Management + +**Jump Flag** - Tracks hash navigation state: +```typescript +const [hasJumpedToOldMessage, setHasJumpedToOldMessage] = useState(false); + +// Set on hash navigation +setHasJumpedToOldMessage(true); + +// Reset when: +// 1. User reaches present (hasNextPage === false) +// 2. User clicks "Jump to Present" button +// 3. User navigates to different channel/conversation +``` + +This prevents auto-scroll during manual pagination while allowing it to resume naturally at the present. + +--- + +## Loading Indicator + +A centered spinner with "Loading message..." text appears during the async fetch operation: + +```typescript +{isLoadingHashMessage && ( + <div className="absolute top-1/2 left-1/2 ..."> + <Spinner /> + <span>Loading message...</span> + </div> +)} +``` + +**Why needed**: IndexedDB queries + React Query updates can take 100ms-2s depending on channel size and device performance. + +--- + +## Error Handling + +**Message not found**: +```typescript +const targetMessage = await messageDB.getMessage({...}); +if (!targetMessage) throw new Error('Message not found'); +``` +→ Hash removed from URL, loading indicator hidden, user not stuck + +**Database errors**: Caught, logged, hash removed to prevent infinite retry + +**Network errors**: Same handling (though rare for IndexedDB operations) + +--- + +## Universal Fix + +All components using `#msg-{messageId}` benefit automatically in both channels and direct messages: +- ✅ Search results (`SearchResults.tsx`) +- ✅ Pinned messages (`PinnedMessagesPanel.tsx`) +- ✅ Notifications (`NotificationPanel.tsx`) +- ✅ Direct URL hashes (bookmarked/shared links) +- ✅ Future components using this pattern + +No changes required in navigation sources - fix is centralized in MessageList and works for both channels and DMs. + +--- + +## Testing Guide + +### Key Scenarios + +**1. Navigation to Old Messages** +- Search for message from weeks ago in channel/DM → Click result → Should load and scroll +- Click old pinned message → Should load and scroll +- Click notification for old reply/mention in channel/DM → Should load and scroll + +**2. Bidirectional Scrolling** +- After jumping to old message: + - Scroll DOWN manually → Loads progressively to present (NO auto-scroll) + - Scroll UP → Loads older messages normally + - Reach present → Auto-scroll resumes for new messages + +**3. Recent Messages (Regression)** +- Click hash to recent message → NO loading indicator, immediate scroll +- Verify normal behavior unchanged + +**4. Edge Cases** +- Non-existent message ID → Error handling, no infinite loading +- Deleted message → Graceful failure +- Rapid navigation → Last click wins, no conflicts +- Large channels/DM histories (10k+ messages) → Performance acceptable (<2s) + +--- + +## Performance + +**Message Loading**: 81 messages (40 + 1 + 40) +- Provides sufficient context +- Allows scrolling in both directions +- Not too many to cause memory issues + +**React Query Cache**: Single page centered on target +- Replaces previous pages on hash navigation +- Prevents memory bloat +- Works with existing infinite scroll + +**Typical Timing**: +- Best case: 100-300ms +- Average: 500ms-1s +- Worst case: 2-3s (slow device, large channel/DM history) + +--- + +## Code References + +**Modified Files**: +- `src/hooks/queries/messages/loadMessagesAround.ts` - New utility +- `src/hooks/business/channels/useChannelMessages.ts:33,155-156` - Added `fetchNextPage`, `hasNextPage` (Channels) +- `src/hooks/business/conversations/useDirectMessagesList.ts:14,36,113` - Added `hasNextPage` (Direct Messages) +- `src/components/space/Channel.tsx:139-140,292-336,838-841` - Handler + props (Channels) +- `src/components/direct/DirectMessage.tsx:75,465-507,722-723` - Handler + props (Direct Messages) +- `src/components/message/MessageList.tsx:45-46,95,121,303,340-344,380-395` - Props, flag, logic + +**Key Functions**: +- `loadMessagesAround()` - Bidirectional loading utility +- `handleHashMessageNotFound()` - Channel handler +- `handleBottomStateChange()` - Forward pagination trigger +- `followOutput()` - Auto-scroll control + +--- + +## Related Documentation + +- `.agents/bugs/auto-jump-unread-virtuoso-scroll-conflict.md` - Why auto-jump failed (different from hash navigation) +- `.agents/docs/features/search-feature.md` - Search functionality +- `.agents/docs/features/messages/pinned-messages.md` - Pinned messages +- `.agents/docs/features/mention-notification-system.md` - Notifications + +--- + +*Last updated: 2025-11-13* +*Verified: 2025-12-09 - File paths and architecture confirmed current* diff --git a/.agents/docs/features/messages/markdown-renderer.md b/.agents/docs/features/messages/markdown-renderer.md new file mode 100644 index 000000000..77d460529 --- /dev/null +++ b/.agents/docs/features/messages/markdown-renderer.md @@ -0,0 +1,548 @@ +--- +type: doc +title: Markdown Renderer +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Markdown Renderer + +## Overview + +Messages automatically detect markdown patterns and render with enhanced formatting when markdown syntax is used. The renderer now includes intelligent URL processing, YouTube video embedding, and performance optimizations to prevent component remounting. + +## Supported Features + +### **Text Formatting** +- **Headers:** H3 only (`### Title`), H1/H2 auto-convert to H3 for design consistency +- **Bold text:** `**text**` or `__text__` +- **Italic text:** `*text*` or `_text_` +- **Strikethrough:** `~~text~~` +- **Spoiler:** `||hidden text||` - Click to reveal hidden content +- **Inline code:** `` `code` `` + +### **Structural Elements** +- **Code blocks:** ` ```language\ncode\n``` ` with syntax highlighting +- **Unordered lists:** `- item` or `* item` +- **Ordered lists:** `1. item` +- **Blockquotes:** `> quote text` +- **Tables:** GitHub-flavored markdown tables with hover effects +- **Horizontal rules:** `---` or `***` + +### **Smart URL Processing** +- **YouTube videos:** Automatically detected and rendered as interactive video embeds +- **Regular URLs:** Auto-converted to clickable links with `target="_blank"` +- **Protected contexts:** URLs in code blocks and existing markdown links are preserved +- **Inline vs standalone:** Different handling for URLs on their own line vs inline + +### **Security (Updated 2025-11-18)** +- **No HTML injection:** The `rehype-raw` plugin has been removed to eliminate critical HTML injection vulnerabilities +- **Safe mentions:** User mentions use placeholder tokens that React escapes automatically +- **Word boundary validation:** Mentions only process when surrounded by whitespace (prevents mentions inside markdown syntax like `**@user**`, `*@user*`, `[text](@user)`) +- **XSS protection:** All user-controlled content is safely rendered through React components + +## Code Block Features + +- **Auto-scrolling:** Long code blocks (>10 lines or >500 chars) use ScrollContainer +- **Copy functionality:** Copy-to-clipboard button on all code blocks +- **Responsive design:** Aggressive line wrapping (`break-all`) for mobile +- **Unclosed blocks:** Auto-completes missing closing ` ``` ` + +## Key Files + +- `src/components/message/MessageMarkdownRenderer.tsx` - Main renderer component +- `src/components/message/MarkdownToolbar.tsx` - Discord-style formatting toolbar +- `src/utils/youtubeUtils.ts` - Centralized YouTube URL utilities +- `src/utils/codeFormatting.ts` - Code block analysis utilities +- `src/utils/markdownFormatting.ts` - Markdown formatting functions for toolbar +- `src/utils/messageLinkUtils.ts` - Message link URL parsing and validation +- `src/utils/environmentDomains.ts` - Environment-aware domain detection (localhost/staging/prod) +- `src/hooks/business/messages/useMessageFormatting.ts` - Pattern detection & shouldUseMarkdown() +- `src/components/message/Message.tsx` - Integration point (markdown vs token-based routing) +- `src/config/features.ts` - Feature flag configuration (ENABLE_MARKDOWN) + +## Dual Rendering Architecture + +The message rendering system maintains **two independent rendering paths** for reliability and backward compatibility: + +### System 1: MessageMarkdownRenderer (Primary Path) + +**Location**: `src/components/message/MessageMarkdownRenderer.tsx` +**Activation**: When `ENABLE_MARKDOWN && shouldUseMarkdown()` returns true +**Current behavior**: `shouldUseMarkdown()` always returns `true` (all messages use this path) + +**Supported Features**: +- ✅ Markdown formatting (`**bold**`, `*italic*`, code blocks, tables, etc.) +- ✅ User mentions (`@<address>`) via safe placeholder tokens with display name lookup +- ✅ Role mentions (`@role`) via safe placeholder tokens +- ✅ Channel mentions (`#<channelId>`) via safe placeholder tokens with channel name lookup +- ✅ YouTube embeds (standalone URLs) and links (inline URLs) +- ✅ Regular URL auto-linking +- ✅ Invite links (via placeholder tokens) +- ✅ Security hardened (no HTML injection, XSS protection, display name security) + +**Security Architecture**: +- Uses placeholder token system (`<<<TOKEN>>>`) for dynamic content +- React component handlers render tokens safely +- All attributes auto-escaped by React +- No raw HTML parsing + +### System 2: Token-Based Rendering (Fallback Path) + +**Location**: `src/components/message/Message.tsx` (lines 664-744) +**Activation**: When `ENABLE_MARKDOWN === false` OR `shouldUseMarkdown()` returns false +**Current behavior**: Unreachable code (kept for emergency fallback) + +**Supported Features**: +- ✅ User mentions (`@<address>`) via React components +- ✅ Role mentions (`@role`) via React components +- ✅ YouTube embeds (all URLs) +- ✅ Regular URL auto-linking +- ✅ Invite links via `<InviteLink>` component +- ❌ No markdown formatting support + +**Why It's Kept**: +1. **Emergency fallback**: If markdown rendering has critical issues, can disable via `ENABLE_MARKDOWN = false` +2. **Backward compatibility**: Existing code path maintained for safety +3. **Feature completeness**: Includes invite link support that was later added to MessageMarkdownRenderer +4. **Testing**: Useful for comparing rendering behavior + +### Routing Decision Flow + +```typescript +// Message.tsx (simplified) +if (ENABLE_MARKDOWN && formatting.shouldUseMarkdown()) { + // System 1: MessageMarkdownRenderer (PRIMARY) + return <MessageMarkdownRenderer content={contentData.fullText} /> +} else { + // System 2: Token-based rendering (FALLBACK) + return ( + // Lines 664-744: Token-based rendering + // Includes invite link handling at lines 708-715 + ) +} +``` + +**Current State**: +- `ENABLE_MARKDOWN = true` in `src/config/features.ts` +- `shouldUseMarkdown()` always returns `true` in `useMessageFormatting.ts` +- **Result**: All messages use System 1 (MessageMarkdownRenderer) +- System 2 code exists but is unreachable + +### When to Use Each System + +**Use System 1 (MessageMarkdownRenderer) - DEFAULT**: +- Normal operation +- When users need markdown formatting +- For maximum security (security-hardened architecture) + +**Use System 2 (Token-based) - EMERGENCY ONLY**: +- If critical bug found in MessageMarkdownRenderer +- For debugging/comparison purposes +- Set `ENABLE_MARKDOWN = false` in `src/config/features.ts` + +**Trade-offs**: +- System 1: Full features, markdown support, security hardened +- System 2: Simpler, no markdown, but all links/embeds still work + +## Architecture + +### **Processing Pipeline (Security Hardened 2025-11-07)** + +```typescript +// Stable processing functions (outside component scope) +const processURLs = (text: string): string => { + /* Convert URLs to markdown links (protects code blocks, inline code, existing md links) */ +}; + +const processStandaloneYouTubeUrls = (text: string): string => { + /* Detect standalone YouTube URLs and convert to markdown image syntax */ + /* Inline YouTube URLs remain as plain URLs for link processing */ +}; + +const processMentions = (text: string): string => { + /* Replace @mentions with safe placeholder tokens: <<<MENTION_USER:address>>> */ + /* Prevents markdown interpretation and XSS attacks */ +}; + +const processRoleMentions = (text: string): string => { + /* Replace @role mentions with safe placeholders */ +}; + +const processChannelMentions = (text: string): string => { + /* Replace #channel mentions with safe placeholders */ +}; + +const processMessageLinks = (text: string): string => { + /* Replace message URLs with <<<MESSAGE_LINK:channelId:messageId:channelName>>> */ + /* Same-space only, protects code blocks/inline code/markdown links */ +}; + +// Processing pipeline (order matters!) +const processedContent = useMemo(() => { + return fixUnclosedCodeBlocks( + convertHeadersToH3( + processURLs( // Last: convert remaining URLs to links + processMessageLinks( // Before URLs: extract message links + processChannelMentions( + processRoleMentions( + processMentions( + processStandaloneYouTubeUrls( + processInviteLinks(content) + ) + ) + ) + ) + ) + ) + ) + ); +}, [content, processMentions, processRoleMentions, processChannelMentions, processMessageLinks]); +``` + +### **Component Rendering Flow (Updated 2025-11-07)** + +1. **Route Decision:** Message.tsx ALWAYS uses MessageMarkdownRenderer (security hardened) + - `shouldUseMarkdown()` now returns `true` for all messages + - Ensures all content goes through secure rendering path + - Token-based rendering is legacy fallback only +2. **Content Processing:** Secure pipeline transforms content: + - YouTube URLs: Standalone → embeds, Inline → clickable links + - Mentions: Converted to safe placeholder tokens + - URLs: All converted to markdown links for consistent processing +3. **Component Rendering:** React component handlers process placeholders: + - `text`, `p`, `h3` components: Convert mention tokens to styled React components via shared `processMentionTokens()` function + - `img` component: Renders YouTube embeds from `![youtube-embed](videoId)` syntax + - `a` component: Renders all links (including inline YouTube URLs) as clickable links +4. **Security:** React automatically escapes all attributes, preventing XSS + +### **Performance Optimizations** + +- **Stable functions:** Processing functions moved outside component scope +- **Memoized components:** `useMemo(() => ({ ... }), [])` prevents re-creation +- **Minimal dependencies:** Only `content` triggers re-processing +- **Persistent state:** YouTube video state survives component re-renders + +## Disabled Features (Design Decisions) + +- **Headers:** H1, H2, H4, H5, H6 are disabled and convert to H3 for design consistency +- **Images:** No native markdown image support (uses existing image handling system) + +## Smart Link Processing + +Unlike the old implementation, links are now **intelligently processed**: + +### **Enabled Link Features (Updated 2025-11-07)** +- **Standalone YouTube URLs:** `https://youtube.com/watch?v=abc123` (on its own line) → Interactive video embed +- **Inline YouTube URLs:** `Check this https://youtube.com/watch?v=abc out` → Clickable link (NOT embed) +- **Regular URLs:** `https://example.com` → Clickable link with `target="_blank"` +- **Email links:** `mailto:user@example.com` → Clickable email links +- **Automatic detection:** URLs in plain text are auto-converted to links + +### **Protected Contexts** +- **Code blocks:** URLs inside ` ```code``` ` blocks remain as plain text +- **Existing markdown links:** `[text](url)` are preserved and enhanced +- **Inline code:** URLs inside `` `code` `` remain as plain text + +### **Implementation (Updated 2025-11-07)** + +```tsx +// Standalone YouTube URLs converted to markdown image syntax +const processStandaloneYouTubeUrls = (text: string): string => { + const lines = text.split('\n'); + const processedLines = lines.map(line => { + const trimmedLine = line.trim(); + return replaceYouTubeURLsInText(line, (url) => { + const isStandalone = trimmedLine === url.trim(); + if (isStandalone) { + const videoId = extractYouTubeVideoId(url); + if (videoId) { + return `![youtube-embed](${videoId})`; // Markdown image syntax + } + } + return url; // Inline URLs stay as-is + }); + }); + return processedLines.join('\n'); +}; + +// Image component catches YouTube embeds and invite cards +img: ({ src, alt, ...props }: any) => { + if (alt === 'youtube-embed' && src) { + return ( + <div className="my-2"> + <YouTubeFacade + videoId={src} + className="rounded-lg youtube-embed" + style={{ width: '100%', maxWidth: 560, aspectRatio: '16/9' }} + /> + </div> + ); + } + if (alt === 'invite-card' && src) { + return ( + <div className="my-2"> + <InviteLink inviteLink={src} /> + </div> + ); + } + return null; // No regular image support +}, + +// Paragraph component - prevents invalid HTML nesting +p: ({ children, node, ...props }: any) => { + // Block embeds (YouTube, invite cards) render <div> elements + // Detect at AST level if paragraph contains only an image node + // If so, render as fragment to avoid invalid <p><div> nesting + if (node?.children?.length === 1 && node.children[0].tagName === 'img') { + return <>{children}</>; + } + return <p className="mb-2 last:mb-0">{children}</p>; +}, + +// Link component renders ALL links as clickable (including YouTube) +a: ({ href, children, ...props }: any) => { + if (href) { + return ( + <a href={href} target="_blank" rel="noopener noreferrer" className="link"> + {children} + </a> + ); + } + return <span>{children}</span>; +} +``` + +## Special Token System + +MessageMarkdownRenderer uses special tokens to safely render dynamic content like embeds and mentions. These tokens are created during text processing and caught by React component handlers. + +### YouTube Embeds +- **Token Pattern**: `![youtube-embed](videoId)` +- **Creation**: `processStandaloneYouTubeUrls()` converts standalone YouTube URLs +- **Rendering**: `img` component catches `alt="youtube-embed"` and renders `<YouTubeFacade>` +- **Example**: `"https://youtube.com/watch?v=abc"` → `"![youtube-embed](abc)"` → Video embed + +### Invite Cards +- **Token Pattern**: `![invite-card](url)` +- **Creation**: `processInviteLinks()` converts invite URLs to markdown image syntax +- **Rendering**: `img` component catches `alt="invite-card"` and renders `<InviteLink>` +- **Example**: `"https://invite.url"` → `"![invite-card](https://invite.url)"` → Invite card + +### User Mentions +- **Token Pattern**: `<<<MENTION_USER:address:displayName>>>` +- **Creation**: `processMentions()` converts `@<address>` format to safe tokens +- **Format**: Only `@<Qm...>` is supported +- **Rendering**: `text` and `p` components catch tokens and render styled spans +- **Security**: Prevents markdown interpretation and XSS attacks +- **Display Name Handling**: ALWAYS uses `mapSenderToUser()` lookup for security + - Extracts address → lookup real display name from space data + - Prevents name-spoofing and impersonation attacks + - See [useMessageFormatting.ts:157-175](src/hooks/business/messages/useMessageFormatting.ts#L157-L175) +- **Example**: `"Hey @<Qm123>"` → `"Hey <<<MENTION_USER:Qm123:displayName>>>"` → Styled mention with lookup name + +### Everyone Mentions +- **Token Pattern**: `<<<MENTION_EVERYONE>>>` +- **Creation**: `processMentions()` converts `@everyone` to safe token +- **Rendering**: `text` and `p` components render styled `@everyone` spans + +### Role Mentions +- **Token Pattern**: `<<<MENTION_ROLE:roleTag:displayName>>>` +- **Creation**: `processRoleMentions()` converts `@roleTag` to safe tokens +- **Rendering**: `text` and `p` components render styled role mention spans + +**Why Tokens?** +- Prevents markdown parser from interpreting dynamic content incorrectly +- React components handle tokens safely (automatic attribute escaping) +- Enables complex rendering (embeds, styled mentions) within markdown flow +- Security: No raw HTML injection possible + +### Channel Mentions +- **Token Pattern**: `<<<MENTION_CHANNEL:channelId:channelName:displayName>>>` +- **Creation**: `processChannelMentions()` converts `#<channelId>` format to safe tokens +- **Format**: Only `#<ch-abc123>` is supported +- **Rendering**: `text` and `p` components catch tokens and render clickable channel spans +- **Navigation**: Click handler navigates to the referenced channel +- **Display Name Handling**: ALWAYS looks up channel name from `spaceChannels` array for security + - Extracts channelId → lookup real channel name from space data + - Prevents spoofing attacks + - See [useMessageFormatting.ts:196-218](src/hooks/business/messages/useMessageFormatting.ts#L196-L218) +- **Example**: `"Check #<ch-123>"` → `"<<<MENTION_CHANNEL:ch-123:channelName:displayName>>>"` → Clickable span showing lookup name + +### Message Links (Discord-style) +- **Token Pattern**: `<<<MESSAGE_LINK:channelId:messageId:channelName>>>` +- **Creation**: `processMessageLinks()` converts message URLs to styled tokens +- **URL Format**: `https://qm.one/spaces/{spaceId}/{channelId}#msg-{messageId}` +- **Rendering**: Styled span showing `#channelName › 📄` (channel name + separator + message icon) +- **Navigation**: Click navigates to the specific message in the channel +- **Same-Space Only**: Only links to the current space are converted; cross-space links remain as regular URLs +- **Protected Contexts**: URLs inside code blocks, inline code, or markdown links `[text](url)` are NOT converted +- **CSS Classes**: `.message-mentions-message-link`, `.message-mentions-message-link__separator`, `.message-mentions-message-link__icon` +- **Example**: `"See https://qm.one/spaces/Qm.../Qm...#msg-abc123"` → `"See #general › 📄"` (clickable) + +**Key Files**: +- `src/utils/messageLinkUtils.ts` - URL parsing and validation +- `src/utils/environmentDomains.ts` - Environment-aware domain detection (localhost, staging, production) + +**Related**: See `src/utils/markdownStripping.ts` for token handling in plain text contexts + +### Spoilers +- **Syntax**: `||hidden text||` +- **Detection**: `processMentionTokens()` matches `||content||` pattern directly (not a preprocessing token) +- **Rendering**: Clickable `<span className="message-spoiler">` with dot pattern overlay +- **Reveal**: Click or keyboard (Enter/Space) toggles `.message-spoiler--revealed` class +- **Styling**: Dot pattern background (`radial-gradient`), theme-aware (dark/light) +- **Accessibility**: `tabIndex={0}`, `role="button"`, `aria-label`, keyboard support +- **Limitation**: Only plain text content works inside spoilers. URLs, mentions, code, and other markdown syntax break the pattern because markdown processes them first. +- **CSS Classes**: `.message-spoiler`, `.message-spoiler--revealed` +- **Example**: `"This is ||secret|| text"` → Hidden content revealed on click + +**Why not a preprocessing token?** +Multiple approaches were tried (token system, base64, placeholders) but markdown processing corrupted or split the tokens. The current post-markdown detection is the simplest solution that works for the primary use case (hiding short text). + +**Related Task**: `.agents/tasks/spoiler-full-markdown-support.md` (backlog for full markdown support inside spoilers) + +## Dependencies + +- `react-markdown` - Core markdown parser +- `remark-gfm` - GitHub-flavored markdown support (tables, strikethrough, etc.) +- `remark-breaks` - Line break support +- ~~`rehype-raw`~~ - **REMOVED 2025-11-07** (security vulnerability) +- `ScrollContainer` - Long code block scrolling +- `ClickToCopyContent` - Code copy functionality +- `YouTubeFacade` - Lightweight video thumbnail with click-to-play (replaces YouTubeEmbed) +- `youtubeUtils` - Centralized YouTube URL processing + +## Markdown Formatting Toolbar + +**Location**: `src/components/message/MarkdownToolbar.tsx` + +A Discord-style floating toolbar that appears above selected text in the MessageComposer, providing quick access to markdown formatting options. + +### **Features** +- **Heading:** Insert H3 heading (`### Text`) +- **Bold:** Toggle bold formatting (`**text**`) +- **Italic:** Toggle italic formatting (`*text*`) +- **Strikethrough:** Toggle strikethrough (`~~text~~`) +- **Code:** Wrap in inline code (`` `code` ``) +- **Blockquote:** Insert blockquote (`> quote`) +- **Spoiler:** Hide text with spoiler syntax (`||text||`) + +### **Keyboard Shortcuts** +| Action | Windows/Linux | macOS | +|--------|---------------|-------| +| Bold | `Ctrl + B` | `Cmd + B` | +| Italic | `Ctrl + I` | `Cmd + I` | +| Strikethrough | `Ctrl + Shift + X` | `Cmd + Shift + X` | +| Inline Code | `Ctrl + Shift + M` | `Cmd + Shift + M` | + +### **Behavior** +- Appears on text selection in MessageComposer +- Positioned above selected text (floating) +- One-click formatting application +- Integrates with `src/utils/markdownFormatting.ts` utility functions + +### **Implementation** +```tsx +<MarkdownToolbar + visible={showToolbar} + position={{ top: toolbarTop, left: toolbarLeft }} + onFormat={handleFormat} +/> +``` + +## Feature Flag Configuration + +**File**: `src/config/features.ts` + +The markdown rendering feature can be toggled via the `ENABLE_MARKDOWN` flag: + +```typescript +/** + * Markdown Rendering Feature + * Controls markdown rendering and formatting toolbar in messages. + * When disabled, messages will use plain text rendering. + */ +export const ENABLE_MARKDOWN = false; // Default: disabled +``` + +### **What the Flag Controls** +- Markdown renderer (MessageMarkdownRenderer) +- Markdown formatting toolbar (MarkdownToolbar) +- Pattern detection for markdown syntax +- When `false`: Messages use plain text/token-based rendering + +### **Usage** +Import and check the flag before enabling markdown features: +```typescript +import { ENABLE_MARKDOWN } from '@/config/features'; + +if (ENABLE_MARKDOWN && formatting.shouldUseMarkdown()) { + // Use markdown renderer +} +``` + +## Integration Example + +```tsx +// Message.tsx - Routing logic +if (contentData.type === 'post') { + // Check if we should use markdown rendering + if (formatting.shouldUseMarkdown()) { + return ( + <Container className="message-post-content break-words"> + <MessageMarkdownRenderer content={contentData.fullText} /> + </Container> + ); + } + // Fall back to token-based rendering + return <TokenBasedRenderer />; +} +``` + + +## Security Hardening (2025-11-07) + +### Critical Vulnerabilities Fixed + +1. **HTML Injection via rehype-raw** + - **Issue**: `rehype-raw` plugin allowed arbitrary HTML rendering + - **Impact**: XSS attacks, phishing, UI spoofing, data exfiltration + - **Fix**: Removed `rehype-raw` completely, use markdown image syntax for YouTube embeds + +2. **Mention Attribute Injection** + - **Issue**: User display names injected directly into HTML attributes without escaping + - **Impact**: XSS via malicious display names like `"><script>alert(1)</script>` + - **Fix**: Use safe placeholder tokens (<<<MENTION_USER:address>>>) rendered by React components + +3. **Role Mention Attribute Injection** + - **Issue**: Role display names in title attributes without escaping + - **Impact**: XSS via malicious role names + - **Fix**: Use placeholder tokens rendered safely by React + +### Security Architecture + +All user-controlled content now follows this pattern: +1. Convert to safe placeholder tokens during text processing +2. React component handlers catch placeholders +3. React automatically escapes all attributes +4. No raw HTML injection possible + +**Related Task**: `.agents/tasks/remove-rehype-raw-security-fix.md` + +## Related Documentation + +- [Message Preview Rendering](message-preview-rendering.md) - Preview systems for panels (uses stripping, not full markdown) +- [Markdown Stripping](markdown-stripping.md) - Text processing utilities for previews +- [Bookmarks](bookmarks.md) - Hybrid preview rendering for bookmarks + +--- +**Last Updated**: 2026-01-09 +**Security Hardening**: Complete (rehype-raw removed, XSS vulnerabilities fixed, word boundary validation added, display name lookup for security) +**Performance Optimization**: Complete +**Mention Formats**: Only `@<address>` and `#<channelId>` supported (display names looked up from space data for security) +**Message Links**: Complete (Discord-style rendering with same-space validation) +**Spoilers**: Complete (plain text only, dot pattern styling, keyboard accessible) +**Keyboard Shortcuts**: Complete (Bold, Italic, Strikethrough, Inline Code) +**Recent Changes**: Updated documentation to reflect current mention format support (no enhanced formats with embedded names) diff --git a/.agents/docs/features/messages/markdown-stripping.md b/.agents/docs/features/messages/markdown-stripping.md new file mode 100644 index 000000000..96f352b47 --- /dev/null +++ b/.agents/docs/features/messages/markdown-stripping.md @@ -0,0 +1,167 @@ +--- +type: doc +title: Markdown Stripping +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Markdown Stripping + +## Overview + +Unified system for processing markdown text with flexible options for different contexts. Supports both "smart" stripping (preserves structure and formatting intent) and "dumb" stripping (collapses to plain text) depending on use case. Used across search results, message previews, and notifications. + +## Key Functions + +### `processMarkdownText(text: string, options: MarkdownProcessingOptions): string` +- **Purpose**: Unified markdown processing with flexible configuration +- **Used in**: MessagePreview, PinnedMessagesPanel, SearchResults (via legacy wrappers) +- **Performance**: 20-100x faster than full markdown rendering +- **Options**: + ```typescript + interface MarkdownProcessingOptions { + removeMentions?: boolean; // Remove @mentions entirely (default: false) + removeFormatting?: boolean; // Remove markdown syntax (default: true) + removeStructure?: boolean; // Remove line breaks, collapse whitespace (default: false) + preserveLineBreaks?: boolean; // Keep paragraph structure (default: true) + preserveEmphasis?: boolean; // Keep bold/italic intent without syntax (default: true) + preserveHeaders?: boolean; // Keep header content without ### syntax (default: true) + truncateLength?: number; // Optional length limit with smart truncation + replaceMentionsWithNames?: boolean; // Convert @<addr> to @DisplayName (default: false) + mapSenderToUser?: (senderId: string) => { displayName?: string } | undefined; + } + ``` + +### `stripMarkdown(text: string): string` +- **Purpose**: Strip markdown but preserve mentions (legacy function) +- **Used in**: Basic stripping needs +- **Removes**: Bold, italic, code, links, tables, YouTube embeds, invite cards +- **Preserves**: `@<address>`, `@everyone`, `@roleTag` patterns + +### `stripMarkdownAndMentions(text: string): string` +- **Purpose**: Strip everything for clean text (legacy function) +- **Used in**: Search results (pure plain text) +- **Removes**: All markdown + all mention patterns +- **Note**: Now implemented using `processMarkdownText()` internally + +## Implementation Details + +**Library-based approach** using `unified` + `remark` + `strip-markdown`: +- Same parser as MessageMarkdownRenderer for consistency +- Handles all GFM features (tables, task lists, etc.) +- Properly handles edge cases (nested formatting, escaped characters) + +**Mention Protection**: `@<address>` patterns use angle brackets that remark treats as HTML tags. Solution: Replace with unicode placeholders (`⟨MENTION0⟩`) before processing, restore after. + +## Special Token System + +The markdown stripping utilities are aware of the special token patterns used by MessageMarkdownRenderer: + +### YouTube Embeds +- **Token Pattern**: `![youtube-embed](videoId)` +- **Purpose**: Markdown image syntax signals YouTube embed +- **Stripping Behavior**: Completely removed by `stripMarkdown()` +- **Example**: `"Check this ![youtube-embed](abc123)"` → `"Check this"` + +### Invite Cards +- **Token Pattern**: `![invite-card](url)` +- **Purpose**: Markdown image syntax signals invite card render +- **Stripping Behavior**: Completely removed by `stripMarkdown()` +- **Example**: `"Join ![invite-card](https://...)"` → `"Join"` + +### User Mentions +- **Raw Pattern**: `@<Qm[a-zA-Z0-9]+>` (from message text) +- **Processed Token**: `<<<MENTION_USER:address>>>` (only in MessageMarkdownRenderer) +- **Stripping Behavior**: + - `stripMarkdown()`: Preserves raw `@<address>` patterns + - `stripMarkdownAndMentions()`: Removes all mention patterns +- **Note**: Raw message text contains `@<address>`, NOT processed tokens + +### Everyone/Role Mentions +- **Patterns**: `@everyone`, `@roleTag` +- **Stripping Behavior**: + - `stripMarkdown()`: Preserves for notification display + - `stripMarkdownAndMentions()`: Removes completely + +## Processing Modes + +### Smart Stripping (MessagePreview, PinnedMessagesPanel) +- **Goal**: Preserve structure and formatting intent while removing syntax +- **Use case**: Message previews where readability matters +- **Features**: Keeps line breaks, converts `### Title` → `Title`, `**bold**` → `bold` + +### Dumb Stripping (SearchResults) +- **Goal**: Collapse to pure plain text for search +- **Use case**: Search results where brevity matters +- **Features**: Removes everything, collapses whitespace + +## Usage Examples + +### Smart Stripping (Message Previews) +```typescript +// MessagePreview.tsx - preserve structure and formatting intent +const smartProcessedText = processMarkdownText(fullText, { + preserveLineBreaks: true, // Keep paragraph structure + preserveEmphasis: true, // Keep bold/italic intent without syntax + preserveHeaders: true, // Keep header content without ### syntax + removeFormatting: true, // Remove markdown syntax + removeStructure: false, // Preserve line breaks for readability +}); + +// Input: "### Important\n\n**Check** this @<Qm123> message!" +// Output: "Important\n\nCheck this @<Qm123> message!" +``` + +### Dumb Stripping (Search Results) +```typescript +// SearchResults - collapse to plain text +const cleanSnippet = processMarkdownText(text, { + removeMentions: true, + removeStructure: true, + preserveLineBreaks: false, + preserveEmphasis: false +}); + +// Input: "### Important\n\n**Check** this @<Qm123> message!" +// Output: "Important Check this message!" +``` + +### Legacy Function Usage +```typescript +// Legacy approach (still supported) +const cleanSnippet = stripMarkdown(contextualSnippet); // Preserve mentions +const plainSnippet = stripMarkdownAndMentions(contextualSnippet); // Remove everything +``` + +## Performance Benefits + +- **MessagePreview/PinnedMessagesPanel**: Smart stripping is 20-100x faster than full markdown rendering +- **Memory efficient**: Lightweight text processing vs heavy React component trees +- **Consistent**: Same remark parser as MessageMarkdownRenderer ensures compatibility + +## Key Files + +- `src/utils/markdownStripping.ts` - Core unified stripping utilities +- `src/components/message/MessagePreview.tsx` - Smart stripping for message previews +- `src/components/message/PinnedMessagesPanel.tsx` - Smart stripping for pinned messages +- `src/components/search/SearchResultItem.tsx` - Dumb stripping for search integration +- `src/components/notifications/NotificationItem.tsx` - Unified mention rendering with formatting hooks + +## Dependencies + +- `unified` - Unified text processing +- `remark-parse` - Markdown parser +- `remark-gfm` - GitHub-flavored markdown +- `remark-stringify` - Convert back to string +- `strip-markdown` - Official remark plugin for stripping markdown + +## Related Documentation + +- [Message Preview Rendering](message-preview-rendering.md) - How MessagePreview uses these utilities +- [Markdown Renderer](markdown-renderer.md) - Full message rendering (dual system) +- [Bookmarks](bookmarks.md) - Hybrid preview rendering for bookmarks + +--- +**Last Updated**: 2025-12-02 +**Verified**: 2025-12-09 - File paths confirmed current diff --git a/.agents/docs/features/messages/message-actions-mobile.md b/.agents/docs/features/messages/message-actions-mobile.md new file mode 100644 index 000000000..00763004c --- /dev/null +++ b/.agents/docs/features/messages/message-actions-mobile.md @@ -0,0 +1,351 @@ +--- +type: doc +title: Mobile Message Actions Implementation +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Mobile Message Actions Implementation + +## Summary + +This feature transforms the desktop hover-based message actions into a comprehensive responsive system that provides optimal user experience across all device types. The implementation includes a Discord-inspired mobile drawer for touch devices, enhanced tablet interactions, and preserved desktop functionality. + +## Feature Overview + +### Core Functionality + +- **Mobile (≤ 768px)**: Long-press messages to open a bottom drawer with quick reactions and action menu +- **Tablet (> 768px + touch)**: Long-press messages to show inline actions, with proper state management +- **Desktop (> 768px + mouse)**: Hover messages to reveal inline actions (existing behavior preserved) + +### Key Components + +1. **Common MobileDrawer**: Shared component providing consistent drawer behavior across all mobile interfaces +2. **Mobile Drawer System**: Touch-friendly bottom drawer with quick reactions and action menu +3. **Responsive Detection**: Smart device and interaction mode detection +4. **Smooth Animations**: Professional slide-up/slide-down animations with swipe-to-close support +5. **Modal Context Integration**: High-level rendering to solve z-index stacking issues + +## Technical Architecture + +### Device Detection Logic + +```typescript +const { isMobile } = useResponsiveLayout(); // ≤ 768px +const isTouchDevice = 'ontouchstart' in window; +const useMobileDrawer = isMobile; +const useDesktopTap = !isMobile && isTouchDevice; +const useDesktopHover = !isMobile && !isTouchDevice; +``` + +### Interaction Patterns + +- **Mobile**: `useLongPress` hook with 500ms delay + haptic feedback +- **Tablet**: `useLongPress` hook with inline action display +- **Desktop**: Traditional mouse hover events + +### Component Hierarchy + +``` +AppWithSearch (modal context level) +├── MessageActionsDrawer (mobile drawer) +│ ├── MobileDrawer (common drawer component) +│ ├── QuickReactionButton (touch-optimized reactions) +│ └── ActionMenuItem (menu actions) +├── EmojiPickerDrawer (mobile emoji picker) +│ └── MobileDrawer (common drawer component) +└── Message (individual message component) + └── Long-press/hover detection +``` + +## Key Technical Decisions + +### 1. Modal Context Architecture + +**Decision**: Render mobile drawer at `AppWithSearch` level instead of within `Message` component. + +**Rationale**: + +- Solves z-index stacking context issues (gear icon appearing above drawer) +- Follows established modal pattern in the codebase +- Ensures drawer appears above all UI elements + +**Implementation**: Uses same pattern as `UserSettingsModal`, `SpaceSettingsModal`, etc. + +### 2. Common MobileDrawer Component + +**Decision**: Create a shared `MobileDrawer` component used by all mobile drawer interfaces. + +**Rationale**: + +- Consistent behavior across all mobile drawers (actions, emoji picker, future features) +- Shared animation system and swipe-to-close functionality +- Single source of truth for drawer styling and accessibility features +- Reduced code duplication and maintenance overhead + +**Implementation**: + +- Full-width on mobile devices (≤768px) for maximum content space +- Constrained width (500px max) and centered on tablets/desktop +- Built-in swipe-to-close gesture support with visual feedback +- Comprehensive accessibility features (ARIA labels, keyboard support) +- Smooth animations with reduced motion support + +### 3. Responsive Breakpoint Strategy + +**Decision**: Use 768px as mobile breakpoint instead of 1024px. + +**Rationale**: + +- Better aligns with mobile-first design principles +- Tablets (768px+) benefit from inline actions rather than full-screen drawer +- Matches common responsive design patterns + +### 3. Animation System + +**Decision**: CSS-based slide animations with JavaScript state management. + +**Rationale**: + +- Smooth 60fps animations using CSS transforms +- Proper lifecycle management prevents flickering +- Consistent 300ms timing for professional feel + +### 4. Touch Event Handling + +**Decision**: Custom `useLongPress` hook with configurable delay and movement threshold. + +**Rationale**: + +- Prevents accidental triggers from scrolling gestures +- Provides haptic feedback on supported devices +- Handles both touch and mouse events uniformly + +## File Structure + +### New Files Created + +``` +src/components/ui/MobileDrawer.tsx - Common mobile drawer component +src/components/ui/MobileDrawer.scss - Shared drawer styling +src/hooks/useLongPress.ts - Long-press gesture detection +src/components/message/MessageActionsDrawer.tsx - Mobile drawer UI +src/components/message/MessageActionsDrawer.scss - Drawer styling +src/components/message/EmojiPickerDrawer.tsx - Mobile emoji picker +src/components/message/EmojiPickerDrawer.scss - Emoji picker styling +src/components/message/QuickReactionButton.tsx - Reaction buttons +src/components/message/ActionMenuItem.tsx - Menu action items +``` + +### Modified Files + +``` +src/components/AppWithSearch.tsx - Modal context integration +src/components/message/Message.tsx - Responsive interaction logic +src/hooks/useResponsiveLayout.ts - Updated mobile breakpoint +src/styles/_components.scss - Removed emoji picker scaling +src/index.scss - Added new stylesheet imports +``` + +## Integration Points + +### 1. Modal Context System + +- Extends existing `ModalContextType` with drawer methods +- Follows established modal rendering patterns +- Maintains consistent state management + +### 2. Responsive Layout Provider + +- Leverages existing `useResponsiveLayout` hook +- Maintains consistency with sidebar and other responsive components +- Updated breakpoint affects entire application + +### 3. Emoji Picker Integration + +- Reuses existing `emoji-picker-react` component +- Maintains custom emoji support and theming +- Wraps in `Modal` component for mobile presentation + +### 4. Message System + +- Preserves all existing message functionality +- Maintains reaction system and message actions +- Enhances UX without breaking existing features + +## Performance Considerations + +### Optimizations Implemented + +1. **Conditional Event Listeners**: Only attach touch handlers when needed +2. **CSS Transforms**: Hardware-accelerated animations +3. **Component Lazy Loading**: Drawer only renders when needed +4. **Event Delegation**: Efficient event handling in long message lists + +### Memory Management + +- Proper cleanup of event listeners +- Animation state cleanup prevents memory leaks +- Modal context prevents component tree bloat + +## Styling Philosophy + +### Mobile-First Design + +- Touch targets: 44px minimum (accessibility compliant) +- Generous spacing for finger navigation +- Optimized typography for mobile readability + +### Design System Integration + +- Uses existing CSS custom properties +- Maintains dark/light theme compatibility +- Follows established color and spacing patterns + +### Animation Principles + +- Smooth, predictable motion +- Reduced motion support for accessibility +- Consistent timing across all interactions + +## Known Limitations + +### 1. Emoji Picker Mobile Optimization + +- Current solution wraps existing picker in MobileDrawer +- Uses shared drawer component for consistency +- Search functionality optimized for mobile keyboards + +### 2. Tablet Edge Cases + +- Complex gesture detection on hybrid devices +- Some Windows tablets may not report touch capability correctly +- Requires testing on various tablet form factors + +### 3. Performance on Low-End Devices + +- Heavy animation may impact performance on older devices +- Large emoji sets could cause memory pressure +- Network-dependent custom emoji loading + +## Future Enhancements + +### Short-Term Improvements + +1. **Keyboard Navigation**: Enhanced keyboard support for accessibility +2. **Custom Emoji Optimization**: Lazy loading and caching strategies +3. **Haptic Feedback**: Enhanced feedback patterns for different actions +4. **Voice Commands**: Basic voice control integration + +### Medium-Term Enhancements + +1. **Gesture Recognition**: Advanced gesture support (pinch, swipe patterns) +2. **Message Threading**: Integrate with message threading when implemented +3. **Bulk Actions**: Multi-select message actions for power users +4. **Voice Commands**: Integration with voice control systems + +### Long-Term Vision + +1. **AI-Powered Suggestions**: Smart reaction and action suggestions +2. **Contextual Actions**: Dynamic action menus based on message content +3. **Cross-Platform Sync**: Synchronized interaction preferences +4. **Advanced Analytics**: User interaction pattern analysis + +## Testing Strategy + +### Device Testing Matrix + +- **Mobile**: iOS Safari, Chrome Android, Samsung Internet +- **Tablet**: iPad Safari, Android Chrome, Surface Pro Edge +- **Desktop**: Chrome, Firefox, Safari, Edge + +### Interaction Testing + +- Touch accuracy and responsiveness +- Animation performance across devices +- Accessibility compliance (screen readers, keyboard navigation) +- Cross-browser compatibility + +### Performance Testing + +- Memory usage in long conversations +- Animation frame rates on various devices +- Network performance with custom emojis + +## Accessibility Compliance + +### Standards Met + +- WCAG 2.1 AA touch target sizes (44px minimum) +- Proper ARIA labels and roles +- Keyboard navigation support +- Screen reader compatibility +- High contrast mode support + +### Features Implemented + +- `aria-label` attributes for all interactive elements +- Proper focus management in drawer +- Reduced motion preference respect +- Color contrast compliance in all themes + +## Development Notes + +### Code Patterns + +- Consistent use of TypeScript interfaces +- Proper React hooks lifecycle management +- CSS-in-JS avoided in favor of SCSS modules +- Mobile-first responsive design approach + +### Debugging Tips + +- Console logging available for device detection +- CSS classes for debugging interaction modes +- Performance profiling hooks for animation monitoring +- Network tab monitoring for emoji loading + +### Maintenance Considerations + +- Regular testing on new device releases +- CSS custom property updates for design system changes +- Performance monitoring for large message lists +- Accessibility audits for new features + +## Related Documentation + +- [Modal System Documentation](.agents/docs/new-modal-component.md) +- [Responsive Layout Documentation](.agents/docs/responsive-layout.md) +- [Emoji System Documentation](.agents/docs/emojipicker-responsive.md) +- [Touch Interface Guidelines](.agents/docs/reacttooltip-mobile.md) + +--- + +## Recent Updates (Latest) + +### MobileDrawer Component Integration + +- **Date**: Latest update +- **Changes**: Refactored to use common `MobileDrawer.tsx` component +- **Benefits**: + - Consistent drawer behavior across all mobile interfaces + - Full-width support on mobile devices (follows mobile UI best practices) + - Built-in swipe-to-close functionality with visual feedback + - Shared accessibility features and animation system + - Reduced code duplication and maintenance overhead + +### Component Updates + +- `MessageActionsDrawer.tsx`: Now uses `MobileDrawer` component +- `EmojiPickerDrawer.tsx`: Now uses `MobileDrawer` component +- `MobileDrawer.scss`: Updated with mobile-first responsive design (full-width on mobile, constrained on tablets/desktop) + +--- + +_This feature represents a significant enhancement to the mobile user experience while maintaining full backward compatibility with existing desktop functionality. The implementation follows established patterns in the codebase and provides a foundation for future mobile-first feature development._ + +--- + +_Verified: 2025-12-09 - All file paths and architecture confirmed current_ diff --git a/.agents/docs/features/messages/message-highlight-system.md b/.agents/docs/features/messages/message-highlight-system.md new file mode 100644 index 000000000..19ef3d884 --- /dev/null +++ b/.agents/docs/features/messages/message-highlight-system.md @@ -0,0 +1,216 @@ +--- +type: doc +title: Message Highlight System +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-05T00:00:00.000Z +--- + +# Message Highlight System + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Overview + +The message highlighting system provides visual feedback when navigating to specific messages. It uses a **dual-mechanism architecture** that serves different use cases: + +1. **URL Hash** (`#msg-{id}`) - Cross-component communication for navigation from panels, search, notifications +2. **Local State** - Self-highlighting for mentions when they enter the viewport + +This separation exists because URL hash cannot easily trigger on viewport entry, while local React state cannot communicate across component instances. + +## Architecture + +### Key Components + +| File | Purpose | +|------|---------| +| `src/hooks/business/messages/useMessageHighlight.ts` | Local state hook for self-highlighting | +| `src/components/message/Message.tsx:256-267` | Dual-mechanism detection logic | +| `src/components/message/Message.scss:1-34` | CSS animations for highlight effects | +| `src/hooks/business/messages/useViewportMentionHighlight.ts` | Mention auto-highlight on viewport entry | +| `src/components/message/MessageList.tsx:314-360` | Hash detection and scroll handling | + +### How Hash-Based Highlighting Works + +URL hash is global browser state that all Message components can detect: + +``` +window.location.hash = "#msg-abc123" + ↓ +React Router's useLocation() notifies all subscribers + ↓ +Each Message component re-renders + ↓ +Message with id "abc123" checks: location.hash === `#msg-${message.messageId}` + ↓ +Match found → applies .message-highlighted class +``` + +### How Local State Highlighting Works + +Local state only works for **self-highlighting** (a component highlighting itself): + +``` +Message component renders with mention + ↓ +useViewportMentionHighlight observes viewport entry + ↓ +Calls highlightMessage(messageId) from THIS component's hook instance + ↓ +Updates THIS component's local state + ↓ +Component re-renders with .message-highlighted-mention class +``` + +### Why Local State Doesn't Work Cross-Component + +Each `useMessageHighlight()` call creates isolated state: + +```tsx +// PinnedMessagesPanel calls: +const { highlightMessage } = useMessageHighlight(); +highlightMessage("msg-123"); // Sets Panel's highlightedMessageId to "msg-123" + +// Message component with id "msg-123" has its own state: +const { isHighlighted } = useMessageHighlight(); +isHighlighted("msg-123"); // Returns false - checks Message's state, not Panel's +``` + +This is why panels use hash navigation instead of direct state calls. + +## CSS Animations + +Two highlight variants exist in `Message.scss`: + +| Class | Animation | Duration | Opacity | Use Case | +|-------|-----------|----------|---------|----------| +| `.message-highlighted` | `flash-highlight` | 8s | 20% → 0 | Navigation highlights | +| `.message-highlighted-mention` | `flash-highlight-mention` | 61s | 10% → 0 | Mention auto-highlight | + +Both use the `--warning` color (yellow/gold `#e7b04a`). The mention variant is more subtle (10% opacity) and persists longer to ensure users notice it. + +## Entry Points + +All navigation sources use hash-based highlighting: + +| Source | File | Pattern | +|--------|------|---------| +| Reply snippet click | `Message.tsx:436-456` | `navigate(path#msg-{id})` + 8s cleanup | +| Pinned message click | `PinnedMessagesPanel.tsx:155-175` | `navigate(path#msg-{id})` + 8s cleanup | +| Bookmark click | `BookmarksPanel.tsx:110-132` | `navigate(path#msg-{id})` + 8s cleanup | +| Notification click | `NotificationPanel.tsx:110-126` | `navigate(path#msg-{id})` + 8s cleanup | +| Search result click | `useGlobalSearchNavigation.ts:20-44` | `navigate(path#msg-{id})` + 8s cleanup | +| URL hash on load | `MessageList.tsx:314-337` | Hash detection + 8s cleanup | +| Mention viewport entry | `useViewportMentionHighlight.ts` | Local state (no hash) | + +## Message Link Formats + +- **Space channel**: `/spaces/{spaceId}/{channelId}#msg-{messageId}` +- **Direct message**: `/messages/{dmAddress}#msg-{messageId}` +- **Copy link action**: `useMessageActions.ts:115` + +## Hash Cleanup + +All navigation sources clean up the hash after 8 seconds (matching CSS animation): + +```typescript +setTimeout(() => { + history.replaceState( + null, + '', + window.location.pathname + window.location.search + ); +}, 8000); +``` + +This prevents stale hashes from causing re-highlighting on page refresh. + +## Detection Logic in Message Component + +The Message component checks **both** mechanisms (`Message.tsx:262-267`): + +```typescript +const isMessageHighlighted = useMemo(() => { + // Check BOTH mechanisms - hash for cross-component, state for self-highlighting + const isUrlTarget = location.hash === `#msg-${message.messageId}`; + const isStateHighlighted = isHighlighted(message.messageId); + return isUrlTarget || isStateHighlighted; +}, [message.messageId, location.hash, isHighlighted]); +``` + +The variant is determined by `getHighlightVariant()` which returns `'mention'` for viewport-triggered highlights. + +## Usage Examples + +### Navigating to a Message (Cross-Component) + +```typescript +// In any panel or navigation component: +const navigate = useNavigate(); + +const handleJumpToMessage = (messageId: string) => { + // Navigate with hash - Message component detects and highlights + navigate(`${currentPath}#msg-${messageId}`); + + // Clean up hash after animation completes + setTimeout(() => { + history.replaceState(null, '', window.location.pathname + window.location.search); + }, 8000); +}; +``` + +### Self-Highlighting (Within Message Component) + +```typescript +// Only used for mention auto-highlighting: +const { highlightMessage } = useMessageHighlight(); + +// Called by useViewportMentionHighlight when mention enters viewport +highlightMessage(messageId, { duration: 61000, variant: 'mention' }); +``` + +## Technical Decisions + +### Why URL Hash Instead of Global State + +- **Simplicity**: No Redux/Context setup needed +- **URL Shareability**: Links with hash can be shared and work on page load +- **Browser Native**: `useLocation()` handles all subscription logic +- **Cross-Route**: Works across route changes without extra wiring + +### Why Keep Local State for Mentions + +- **Viewport Detection**: Can't set hash for every visible message +- **No URL Pollution**: Mentions don't need shareable URLs +- **Self-Contained**: Message highlights itself, no cross-component communication needed + +### Why 8 Second Duration + +Matches the CSS `flash-highlight` animation which: +- Holds at 20% opacity for 4 seconds +- Fades out over the next 4 seconds + +Cleaning up the hash earlier would cause the highlight to stop mid-animation. + +## Known Limitations + +1. **Hash Conflict with Delete**: If a message is deleted while its hash is in the URL, the hash persists. See `.agents/bugs/message-hash-navigation-conflict.md`. + +2. **Single Active Hash**: Only one message can be hash-highlighted at a time. Clicking a new link replaces the previous hash. + +3. **No Highlight on Back Navigation**: Browser back button restores the hash but doesn't re-trigger the animation since CSS animation only plays once per class application. + +## Related Documentation + +- `.agents/docs/features/messages/hash-navigation-to-old-messages.md` - Loading older messages for hash navigation +- `.agents/docs/features/messages/pinned-messages.md` - Pinned messages panel +- `.agents/docs/features/messages/bookmarks.md` - Bookmarks panel +- `.agents/docs/features/mention-notification-system.md` - Mention detection and notifications +- `.agents/tasks/message-highlight-system-refactor.md` - Refactoring task with implementation details + +--- + + +_Verified: 2025-12-09 - File paths confirmed current_ diff --git a/.agents/docs/features/messages/message-preview-rendering.md b/.agents/docs/features/messages/message-preview-rendering.md new file mode 100644 index 000000000..c359a6bb9 --- /dev/null +++ b/.agents/docs/features/messages/message-preview-rendering.md @@ -0,0 +1,288 @@ +--- +type: doc +title: Message Preview Rendering +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Message Preview Rendering + +## Overview + +This document describes the **preview rendering systems** used across panels and search results. These systems are distinct from the full `Message.tsx` rendering and are optimized for displaying message content in compact, read-only contexts. + +## Architecture Diagram + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ FULL MESSAGE RENDERING │ +│ (Chat View - Message.tsx) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Message.tsx ──► ENABLE_MARKDOWN && shouldUseMarkdown()? │ +│ │ │ +│ ┌─────────┴─────────┐ │ +│ │ YES │ NO │ +│ ▼ ▼ │ +│ MessageMarkdownRenderer Token-based Fallback │ +│ (full markdown support) (plain text + special tokens) │ +│ │ +│ See: markdown-renderer.md (Dual Rendering Architecture) │ +└─────────────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────────────┐ +│ PREVIEW RENDERING SYSTEMS │ +│ (Panels, Search, Notifications) │ +├─────────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ RICH PREVIEWS (MessagePreview Component) │ │ +│ │ Consumers: BookmarksPanel, PinnedMessagesPanel │ │ +│ ├─────────────────────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ Raw Message Text │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ processMarkdownText() ─────► Smart Stripping │ │ +│ │ (markdown-stripping.ts) - Removes markdown syntax │ │ +│ │ - Preserves structure/line breaks │ │ +│ │ - Keeps mentions intact │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ renderPreviewTextWithSpecialTokens() ──► Token Processing │ │ +│ │ (MessagePreview.tsx) - Smart tokenization │ │ +│ │ - Mention rendering │ │ +│ │ - Link rendering │ │ +│ │ - YouTube previews │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ React Components (styled spans, links, embeds) │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ PLAIN TEXT PREVIEWS │ │ +│ │ Consumers: SearchResultItem, DM conversation list │ │ +│ ├─────────────────────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ Raw Message Text │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ stripMarkdownAndMentions() ──► Dumb Stripping │ │ +│ │ (markdown-stripping.ts) - Removes ALL formatting │ │ +│ │ - Removes mentions │ │ +│ │ - Collapses whitespace │ │ +│ │ │ │ │ +│ │ ▼ │ │ +│ │ Plain text string (rendered as-is) │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────────┐ │ +│ │ CACHED PREVIEWS (Bookmark Fallback) │ │ +│ │ Used when: Message not in local IndexedDB (cross-device sync) │ │ +│ ├─────────────────────────────────────────────────────────────────────┤ │ +│ │ │ │ +│ │ Bookmark.cachedPreview ──► Pre-computed at bookmark time │ │ +│ │ │ - textSnippet (plain text) │ │ +│ │ │ - imageUrl/thumbnailUrl │ │ +│ │ │ - stickerId │ │ +│ │ ▼ │ │ +│ │ BookmarkItem renders based on contentType │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────────┘ +``` + +## Systems Comparison + +| System | Component | Stripping Mode | Interactive | Use Case | +|--------|-----------|----------------|-------------|----------| +| Full Message | `Message.tsx` | None (full render) | Yes | Chat view | +| Rich Preview | `MessagePreview` | Smart | Limited | Panels (Bookmarks, Pinned) | +| Plain Preview | Direct stripping | Dumb | No | Search results, DM list | +| Cached Preview | `BookmarkItem` | Pre-computed | No | Cross-device bookmarks | + +## MessagePreview Component + +**Location**: `src/components/message/MessagePreview.tsx` + +The central component for rendering message previews in panels. Provides a rich but compact representation of messages. + +### Processing Pipeline + +```typescript +// 1. Smart markdown stripping (removes syntax, keeps structure) +const smartProcessedText = processMarkdownText(fullText, { + preserveLineBreaks: true, // Keep paragraph structure + preserveEmphasis: true, // Keep bold/italic intent + preserveHeaders: true, // Keep header content + removeFormatting: true, // Remove markdown syntax + removeStructure: false, // Preserve line breaks +}); + +// 2. Token processing (renders mentions, links, etc.) +const processedContent = renderPreviewTextWithSpecialTokens( + smartProcessedText, + formatting, + messageId, + disableMentionInteractivity, + onChannelClick +); +``` + +### Smart Tokenization + +The token processor uses regex-based smart tokenization to preserve mention patterns with spaces in display names: + +```typescript +// Matches: @[Display Name]<address> or #[Channel Name]<channelId> +const mentionPattern = /(@(?:\[[^\]]+\])?<[^>]+>|#(?:\[[^\]]+\])?<[^>]+>)/g; +``` + +This prevents `#[General Chat]<channelId>` from being split into separate tokens. + +### Supported Content Types + +| Type | Rendering | +|------|-----------| +| `post` | Smart-stripped text with token processing | +| `embed` | Image thumbnail (200x150 max) or YouTube preview | +| `sticker` | Sticker image (120x120 max) | + +### Props + +```typescript +interface MessagePreviewProps { + message: MessageType; + mapSenderToUser?: (senderId: string) => any; + stickers?: { [key: string]: Sticker }; + showBackground?: boolean; // Default: true + hideHeader?: boolean; // Default: false + spaceRoles?: Role[]; + spaceChannels?: Channel[]; + onChannelClick?: (channelId: string) => void; + disableMentionInteractivity?: boolean; // Default: false +} +``` + +## Consumers + +### PinnedMessagesPanel + +**Location**: `src/components/message/PinnedMessagesPanel.tsx` + +Uses `MessagePreview` directly with full message objects (always available locally since pinned messages are per-channel). + +```typescript +<MessagePreview + message={message} + mapSenderToUser={mapSenderToUser} + stickers={stickers} + showBackground={false} + hideHeader={true} + spaceRoles={spaceRoles} + spaceChannels={spaceChannels} + onChannelClick={onChannelClick} + disableMentionInteractivity={true} +/> +``` + +### BookmarksPanel / BookmarkItem + +**Location**: `src/components/bookmarks/BookmarkItem.tsx` + +Uses **hybrid rendering**: `MessagePreview` when message exists locally, cached preview fallback otherwise. + +```typescript +// Try to resolve message from local IndexedDB +const { data: resolvedMessage } = useResolvedBookmark(bookmark, true); + +// Render with MessagePreview if available, else cached fallback +if (resolvedMessage && mapSenderToUser) { + return <MessagePreview message={resolvedMessage} ... />; +} +return renderCachedPreview(); // Uses bookmark.cachedPreview +``` + +**Why hybrid?** Bookmarks are cross-context (can bookmark messages from any space/DM). When viewing bookmarks on a different device or for unloaded channels, the message may not exist in local IndexedDB. + +### SearchResultItem + +**Location**: `src/components/search/SearchResultItem.tsx` + +Uses **dumb stripping** for plain text display (no interactive elements needed in search results). + +```typescript +import { stripMarkdownAndMentions } from '../../utils/markdownStripping'; + +const cleanSnippet = stripMarkdownAndMentions(contextualSnippet); +// Renders as plain text +``` + +### DM Conversation Previews + +**Location**: `src/components/direct/DirectMessageContactsList.tsx` + +Uses simple text extraction for conversation list previews (last message snippet). + +## Relationship to Other Systems + +### markdown-renderer.md +Documents the **full message rendering** dual system in `Message.tsx`. MessagePreview is a separate, lighter system for preview contexts. + +**Key difference**: +- `MessageMarkdownRenderer`: Full markdown parsing with react-markdown +- `MessagePreview`: Smart stripping + custom token processing (no markdown library at render time) + +### markdown-stripping.md +Documents the **stripping utilities** that MessagePreview uses. The utilities provide the text processing layer; MessagePreview adds the React rendering layer on top. + +**Processing chain**: +``` +markdown-stripping.ts (processMarkdownText) + ↓ +MessagePreview.tsx (renderPreviewTextWithSpecialTokens) + ↓ +React components (spans, links, embeds) +``` + +### bookmarks.md +Documents the **bookmarks feature** including the hybrid MessagePreview approach for cross-device scenarios. + +## Key Files + +| File | Purpose | +|------|---------| +| `src/components/message/MessagePreview.tsx` | Core preview component | +| `src/utils/markdownStripping.ts` | Text processing utilities | +| `src/components/message/PinnedMessagesPanel.tsx` | Pinned messages consumer | +| `src/components/bookmarks/BookmarkItem.tsx` | Bookmarks consumer (hybrid) | +| `src/components/search/SearchResultItem.tsx` | Search consumer (plain text) | +| `src/hooks/queries/bookmarks/useResolvedBookmark.ts` | Message resolution for bookmarks | + +## Performance Characteristics + +| Operation | Cost | +|-----------|------| +| Smart stripping | ~0.1-1ms per message | +| Token processing | ~0.1ms per message | +| Full markdown render | ~5-50ms per message | + +MessagePreview is **20-100x faster** than full markdown rendering, making it suitable for virtualized lists with many items. + +## When to Use What + +| Scenario | System | Why | +|----------|--------|-----| +| Chat messages | `Message.tsx` + `MessageMarkdownRenderer` | Full interactivity, markdown support | +| Pinned/Bookmarked messages | `MessagePreview` | Rich preview, compact, fast | +| Search results | `stripMarkdownAndMentions()` | Plain text sufficient, fastest | +| Cross-device bookmarks | Cached preview fallback | Message may not exist locally | +| DM list previews | Simple text extraction | Just need last message snippet | + +--- + + +*Verified: 2025-12-09 - File paths confirmed current* diff --git a/.agents/docs/features/messages/message-sending-indicator.md b/.agents/docs/features/messages/message-sending-indicator.md new file mode 100644 index 000000000..b965a33ca --- /dev/null +++ b/.agents/docs/features/messages/message-sending-indicator.md @@ -0,0 +1,255 @@ +--- +type: doc +title: Message Sending Indicator +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-18T00:00:00.000Z +--- + +# Message Sending Indicator + +> **⚠️ AI-Generated**: May contain errors. Verify before use. +> **Reviewed by**: feature-analyzer agent, security-analyst agent + +## Overview + +The message sending indicator provides immediate visual feedback when users send messages. Instead of waiting for network transmission to complete before showing the message (which can take several seconds), messages appear instantly in the UI with a "Sending..." status indicator. Once the message is successfully delivered, the indicator disappears. If delivery fails, users see a "Failed to send" message with a retry option. + +This optimistic UI pattern is standard in modern messaging applications (WhatsApp, Telegram, Slack) and significantly improves perceived responsiveness. The feature works identically for both Channel messages (group chats within Spaces) and Direct Messages (1:1 conversations). + +## Architecture + +### Type System + +The feature extends the existing `Message` type with two optional client-side fields: + +```typescript +// src/api/quorumApi.ts:90 +export type MessageSendStatus = 'sending' | 'sent' | 'failed'; + +// Added to Message type (lines 134-137) +export type Message = { + // ... existing fields ... + + /** Client-side ephemeral - NEVER persist to IndexedDB or transmit */ + sendStatus?: MessageSendStatus; + /** Client-side ephemeral - sanitized error message for display */ + sendError?: string; +}; +``` + +**Critical security constraint**: These fields exist only in client memory. They are explicitly stripped before: +- Persisting to IndexedDB (`saveMessage()`) +- Encrypting for transmission (`TripleRatchetEncrypt()` / `DoubleRatchetEncrypt()`) + +### Data Flow + +The optimistic update flow differs from the original synchronous approach: + +``` +┌─────────────────────────────────────────────────────────────────────┐ +│ BEFORE (synchronous) │ +│ │ +│ User clicks Send → Encrypt → Network send → Save → Add to UI │ +│ └──────── 2-5 seconds ────────┘ │ +│ User sees nothing until complete │ +└─────────────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────────────┐ +│ AFTER (optimistic) │ +│ │ +│ User clicks Send │ +│ ↓ │ +│ Generate nonce (crypto.randomUUID()) │ +│ ↓ │ +│ Calculate messageId (SHA-256 hash) │ +│ ↓ │ +│ Generate signature (Ed448) ← MUST happen before display │ +│ ↓ │ +│ Add to React Query cache with sendStatus: 'sending' │ +│ ↓ │ +│ MESSAGE APPEARS IMMEDIATELY (~50ms) │ +│ ↓ │ +│ Enqueue background work: │ +│ - Encrypt (Triple/Double Ratchet) │ +│ - Send to network │ +│ - Persist to IndexedDB (without sendStatus) │ +│ ↓ │ +│ On success: Update cache → remove sendStatus → indicator disappears │ +│ On failure: Update cache → sendStatus: 'failed' → show retry option │ +└─────────────────────────────────────────────────────────────────────┘ +``` + +### Key Components + +#### MessageService (`src/services/MessageService.ts`) + +**`updateMessageStatus()`** - Updates a message's send status in the React Query cache without causing duplicates: + +```typescript +updateMessageStatus( + queryClient: QueryClient, + spaceId: string, + channelId: string, + messageId: string, + status: 'sent' | 'failed', + error?: string +) +``` + +The method handles a race condition: if the server's copy of the message arrives via WebSocket before the status update runs, the optimistic message (with `sendStatus`) gets replaced by the server version (without `sendStatus`). The update checks for this and skips if the message no longer has a `sendStatus` field. + +**`retryMessage()` / `retryDirectMessage()`** - Re-queues a failed message for another send attempt. The same signed message is reused (safe because the signature only covers the messageId, and encryption creates fresh envelopes). + +#### Message Component (`src/components/message/Message.tsx`) + +The status indicator renders at the bottom of each message: + +```tsx +{message.sendStatus === 'sending' && ( + <FlexRow className="message-status sending"> + <Icon name="clock" size="xs" /> + <Text size="sm" variant="warning">{t`Sending...`}</Text> + </FlexRow> +)} + +{message.sendStatus === 'failed' && ( + <FlexRow className="message-status failed"> + <Icon name="warning" size="xs" /> + <Text size="sm" variant="danger"> + {t`Failed to send.`}{' '} + <Text as="span" className="message-status__retry" onClick={handleRetry}> + {t`Retry`} + </Text> + </Text> + </FlexRow> +)} +``` + +**React.memo integration**: The `Message` component uses `React.memo` with a custom comparison function. The `sendStatus` field is included in the comparison to ensure the component re-renders when status changes. + +#### Styles (`src/components/message/Message.scss`) + +```scss +.message-status { + &.sending { + color: rgb(var(--warning)); + // Only show after 1s delay - avoids flicker for fast sends + opacity: 0; + animation: fadeInSending 0.2s ease-in forwards; + animation-delay: 1s; + } + + &.failed { + color: rgb(var(--danger)); + } + + &__retry { + text-decoration: underline; + cursor: pointer; + } +} +``` + +The 1-second animation delay is a UX optimization: most messages send in under a second, so showing "Sending..." briefly would create unnecessary visual noise. The indicator only becomes visible if the send takes longer than expected. + +### Integration Points + +| Layer | Component | Responsibility | +|-------|-----------|----------------| +| Context | `MessageDB.tsx` | Exposes `retryMessage` and `retryDirectMessage` via React hook | +| Page | `Channel.tsx` | Creates `handleRetryMessage` callback for channel messages | +| Page | `DirectMessage.tsx` | Creates `handleRetryMessage` callback for DM messages | +| List | `MessageList.tsx` | Passes `onRetryMessage` prop down to individual messages | +| Item | `Message.tsx` | Renders status indicator, handles retry click | +| Service | `MessageService.ts` | Implements optimistic updates, status transitions, retry logic | + +### Message Sorting + +When a message is in "sending" state, it sorts to the end of the message list. This prevents a jarring UX where an older message from another user arrives and pushes the pending message up: + +```typescript +// In addMessage() - sort logic for last page only +newMessages.sort((a: Message, b: Message) => { + // Pending messages always go to END + if (a.sendStatus === 'sending' && b.sendStatus !== 'sending') return 1; + if (b.sendStatus === 'sending' && a.sendStatus !== 'sending') return -1; + // Otherwise maintain chronological order + return a.createdDate - b.createdDate; +}); +``` + +Once the message transitions to "sent", it stays in its chronological position. + +## Security Considerations + +### Signature Timing + +The message signature **must** be generated before the optimistic display. This maintains non-repudiability: the user has cryptographically committed to the message content before seeing it in the UI. + +```typescript +// Signature generation (Ed448) - happens OUTSIDE enqueueOutbound() +const signature = ch.js_sign_ed448( + privateKey, + Buffer.from(messageId).toString('base64') +); + +// Then add to cache and display +addMessage(queryClient, spaceId, channelId, signedMessage); + +// THEN enqueue the network operation +enqueueOutbound(async () => { /* encrypt and send */ }); +``` + +### Retry Safety + +Retrying a failed message reuses the original signature. This is secure because: + +1. **Signature covers only messageId**: No timestamps, nonces, or other timing data. The signature is deterministic for the same messageId + key pair. + +2. **Encryption is unique per attempt**: Triple Ratchet (channels) and Double Ratchet (DMs) generate fresh encrypted envelopes for each send attempt as the ratchet state advances. + +3. **Client deduplication**: The `addMessage()` function filters by messageId, preventing duplicate messages in the UI. + +4. **No replay vulnerability**: The server and other clients have their own deduplication based on messageId. + +### Error Sanitization + +Error messages shown to users are sanitized to prevent information leakage: + +```typescript +const sanitizeError = (error: unknown): string => { + if (error instanceof Error) { + if (error.message.includes('network') || error.message.includes('fetch')) { + return 'Network error'; + } + if (error.message.includes('encrypt') || error.message.includes('ratchet')) { + return 'Encryption error'; + } + } + return 'Send failed'; +}; +``` + +This ensures users never see: IP addresses, hostnames, file paths, stack traces, or internal error codes. + +## Action Queue Integration + +Message sending is now integrated with the [Action Queue](../action-queue.md), providing: + +| Feature | Description | +|---------|-------------| +| **Crash recovery** | Messages persist to IndexedDB and survive app restarts | +| **Automatic retry** | Failed messages retry with exponential backoff (2s, 4s, 8s) | +| **Offline support** | Messages queue while offline and send when connectivity is restored | +| **Multi-tab safety** | Status-based gating prevents duplicate sends across tabs | + +The signing/encryption separation described above enables safe retries - the same signed message can be re-encrypted and sent without creating duplicates. + +## Related Documentation + +- **Implementation Task**: `.agents/tasks/.done/message-sending-indicator.md` - Full implementation plan with phases +- **Action Queue**: [Action Queue](../action-queue.md) - Persistent queue with retry and offline support + +--- diff --git a/.agents/docs/features/messages/message-signing-system.md b/.agents/docs/features/messages/message-signing-system.md new file mode 100644 index 000000000..6738bf729 --- /dev/null +++ b/.agents/docs/features/messages/message-signing-system.md @@ -0,0 +1,350 @@ +--- +type: doc +title: Message Signing System +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Message Signing System + +**Last Updated:** December 9, 2025 + +## Overview + +The message signing system provides **4-level hierarchical control** over cryptographic message signing (non-repudiation) across different contexts in the application. This ensures messages can be cryptographically verified while allowing granular user control. + +## Architecture + +### Hierarchy Levels (Most Specific Wins) + +1. **Per-message Toggle** - Individual message override +2. **Conversation-level Settings** - Direct message conversation defaults +3. **Space-level Settings** - Entire space requirements +4. **Global User Settings** - Application-wide default + +### Terminology + +⚠️ **Important:** The codebase uses confusing terminology: + +- `nonRepudiable: true` = Messages **ARE** signed (cannot be denied) +- `isRepudiable: true` = Messages **CAN BE** unsigned (allows choice) +- `skipSigning: true` = This specific message will **NOT** be signed + +## Components and Implementation + +### 1. Global User Settings + +**File:** `src/components/modals/UserSettingsModal/Privacy.tsx` +**Hook:** `src/hooks/business/user/useUserSettings.ts` + +```typescript +// User configuration +type UserConfig = { + nonRepudiable?: boolean; // Default: true (always sign) +} + +// UI: Privacy/Security tab +<Switch + value={nonRepudiable} + onChange={setNonRepudiable} + label="Always sign Direct Messages" +/> +``` + +**Purpose:** Sets the default signing behavior for all new conversations and spaces. + +**Default:** `true` (all messages are signed by default) + +### 2. Space-level Settings + +**File:** `src/components/modals/SpaceSettingsModal/SpaceSettingsModal.tsx` + +```typescript +// Space configuration +type Space = { + isRepudiable: boolean; // When true, allows per-message choice +} + +// UI: General tab +<Switch + value={!isRepudiable} // Inverted logic! + onChange={(val) => setIsRepudiable(!val)} + label="Require Message Signing" +/> +``` + +**Purpose:** Controls whether an entire space requires signing or allows per-message choice. + +**Logic:** +- `isRepudiable: false` → All space messages MUST be signed +- `isRepudiable: true` → Users can choose per message + +### 3. Conversation-level Settings + +**File:** `src/components/modals/ConversationSettingsModal.tsx` + +```typescript +// Conversation configuration +type Conversation = { + isRepudiable?: boolean; // When set, overrides user default +} + +// Priority resolution +if (typeof convIsRepudiable !== 'undefined') { + setNonRepudiable(!convIsRepudiable); // Use conversation setting +} else { + setNonRepudiable(userConfig.nonRepudiable ?? true); // Use user default +} +``` + +**Purpose:** Allows per-conversation override of user's global signing preference. + +**Scope:** Direct messages only (not applicable to spaces) + +### 4. Per-message Toggle + +**File:** `src/components/message/MessageComposer.tsx` + +```typescript +// Lock/unlock toggle +<Button + iconName={skipSigning ? 'unlock' : 'lock'} + onClick={onSigningToggle} + className={skipSigning ? 'text-warning' : 'text-main'} +/> + +// Toggle visibility +showSigningToggle={ + // In spaces: only if space allows choice + space?.isRepudiable || + // In DMs: only if conversation allows choice + !nonRepudiable +} +``` + +**Purpose:** Allows users to override signing on individual messages when permitted. + +**Visual States:** +- 🔒 **Lock** (locked): Message will be signed +- 🔓 **Unlock** (unlocked): Message will NOT be signed (shows warning color) + +## Context-Specific Logic + +### Direct Messages (DirectMessage.tsx) + +```typescript +// Complex priority resolution +React.useEffect(() => { + const convIsRepudiable = conversation?.conversation?.isRepudiable; + const userNonRepudiable = userConfig?.nonRepudiable ?? true; + + if (typeof convIsRepudiable !== 'undefined') { + // 1. Conversation setting exists - use it + const convNonRepudiable = !convIsRepudiable; + setNonRepudiable(convNonRepudiable); + setSkipSigning(convNonRepudiable ? false : !userNonRepudiable); + } else { + // 2. Fall back to user global setting + setNonRepudiable(userNonRepudiable); + setSkipSigning(userNonRepudiable ? false : true); + } +}, [conversation, userConfig]); + +// Final message signing decision +const effectiveSkip = nonRepudiable ? false : skipSigning; +``` + +**Hierarchy:** +1. Conversation setting (if exists) +2. User global setting +3. Default `true` (always sign) + +### Spaces (Channel.tsx) + +```typescript +// Simpler space-based logic +React.useEffect(() => { + if (space?.isRepudiable) { + // Space allows choice - use user preference + setNonRepudiable(userConfig?.nonRepudiable ?? true); + setSkipSigning(!userConfig?.nonRepudiable); + } else { + // Space requires signing - always sign + setNonRepudiable(true); + setSkipSigning(false); + } +}, [space, userConfig]); + +// Final message signing decision +const effectiveSkip = space?.isRepudiable ? skipSigning : false; +``` + +**Hierarchy:** +1. Space setting (overrides everything) +2. User global setting (if space allows choice) +3. Default `true` (always sign) + +## Data Storage + +### IndexedDB Schema + +```typescript +// User config table +{ + address: string; + nonRepudiable?: boolean; // Global default +} + +// Spaces table +{ + spaceId: string; + isRepudiable: boolean; // Space-wide policy +} + +// Conversations table +{ + conversationId: string; + isRepudiable?: boolean; // Conversation override +} +``` + +### Critical Implementation Details + +1. **Database Preservation:** The `MessageService.saveMessage()` function (which interacts with `src/db/messages.ts`) was modified to preserve existing conversation data including `isRepudiable` when updating conversation timestamps. + +2. **React Query Cache:** The `MessageService` (or a related service) ensures that the `addOrUpdateConversation()` function preserves the `isRepudiable` field when updating conversation lists. + +3. **Invalidation Chain:** Changes trigger React Query invalidation to update all dependent components. + +## User Interface + +### Settings Locations + +| Level | Location | Control | +|-------|----------|---------| +| Global | UserSettingsModal/Privacy.tsx | "Always sign Direct Messages" | +| Space | SpaceSettingsModal/General.tsx | "Require Message Signing" | +| Conversation | ConversationSettingsModal.tsx | "Always sign messages" | +| Message | MessageComposer | Lock/unlock icon | + +### Visual Indicators + +- **Lock Icon** 🔒: Message will be signed +- **Unlock Icon** 🔓: Message will NOT be signed (warning color) +- **No Toggle**: No choice available (determined by higher-level setting) + +## Business Rules + +### Default Behavior +- All messages are signed by default (`nonRepudiable: true`) +- Provides cryptographic non-repudiation out of the box +- Users must explicitly opt-out at appropriate levels + +### Override Hierarchy +``` +Most Specific → Least Specific + +Per-message toggle (if visible) + ↓ +Conversation setting (Direct Messages only) + ↓ +Space setting (Spaces only) + ↓ +User global setting + ↓ +Default: true (always sign) +``` + +### Visibility Rules + +| Context | Toggle Visible When | +|---------|-------------------| +| Direct Messages | `!nonRepudiable` (conversation allows choice) | +| Spaces | `space?.isRepudiable` (space allows choice) | + +## Error Handling + +- Settings default to secure state (`true` = always sign) on errors +- Database operations are wrapped in try/catch with graceful fallbacks +- React Query provides automatic retry and caching +- Console errors logged for debugging without exposing to users + +## Security Implications + +1. **Default Security:** Secure by default - all messages signed unless explicitly configured otherwise +2. **Granular Control:** Users can reduce security for convenience at multiple levels +3. **Non-repudiation:** Signed messages provide cryptographic proof of authorship +4. **Privacy Trade-off:** Unsigned messages provide more privacy but less accountability + +## Recent Bug Fix (September 2025) + +**Issue:** Conversation-level signing settings were reverting after sending messages. + +**Root Cause:** The `MessageService.saveMessage()` function was overwriting conversation records without preserving the `isRepudiable` field. + +**Fix:** Modified `MessageService.saveMessage()` (which interacts with `src/db/messages.ts`) to: +1. Retrieve existing conversation data +2. Preserve all existing fields including `isRepudiable` +3. Only update necessary fields (timestamp, icon, displayName) + +**Files Modified:** +- `src/db/messages.ts` - Fixed database preservation (low-level IndexedDB interaction) +- `src/components/context/MessageDB.tsx` - `MessageDB Context` now provides access to `MessageService` which handles enhanced React Query cache preservation. + +## Testing + +To verify the feature works correctly: + +1. **Global Settings:** Change in UserSettingsModal → affects new conversations +2. **Space Settings:** Toggle in SpaceSettingsModal → affects all space messages +3. **Conversation Settings:** Change in ConversationSettingsModal → persists after sending messages +4. **Per-message Toggle:** Click lock/unlock → applies to individual message +5. **Hierarchy:** Verify more specific settings override general ones + +## Developer Notes + +- The inverted boolean logic (`isRepudiable` vs `nonRepudiable`) is confusing but consistent throughout the codebase +- Always preserve existing conversation data when updating database records +- React Query invalidation is critical for UI consistency across components +- Consider the security implications when modifying default behaviors + +## Future Optimization Opportunities + +**⚠️ Analysis Report (September 2025):** Feature-analyzer identified this system as **over-engineered** with potential for simplification: + +### Priority Improvements + +1. **Unify Boolean Semantics** + - Current mixed use of `nonRepudiable` vs `isRepudiable` creates confusion + - Standardize on single terminology throughout codebase + - Consider using `requireSigning` for clarity + +2. **Simplify Hierarchy** + - Current 4-level system may be unnecessarily complex + - Evaluate if conversation-level settings could merge with global user settings + - Consider reducing to: Global → Space → Per-message + +3. **Data Flow Consolidation** + - Multiple data update patterns create maintenance burden + - Risk of similar corruption bugs in other database operations + - Consider centralizing conversation update logic + +4. **Code Consistency** + - DirectMessage and Channel have different implementation patterns + - Opportunity to create shared hooks/utilities for signing logic + - Standardize error handling patterns + +### Security Considerations + +- Complex override hierarchy may have undiscovered edge cases +- Simplification could reduce attack surface +- Maintain default-secure behavior during any refactoring + +### Technical Debt + +- Boolean logic inversion requires careful mental mapping +- Database preservation pattern should be applied consistently +- React Query cache management could be abstracted + +**Recommendation:** Address these optimizations before expanding the feature further to prevent complexity debt from compounding. diff --git a/.agents/docs/features/messages/new-messages-separator.md b/.agents/docs/features/messages/new-messages-separator.md new file mode 100644 index 000000000..ec2640295 --- /dev/null +++ b/.agents/docs/features/messages/new-messages-separator.md @@ -0,0 +1,323 @@ +--- +type: doc +title: New Messages Separator +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-24T00:00:00.000Z +--- + +# New Messages Separator + +## Overview + +### Problem +When auto-jumping to the first unread message in a channel or direct message, users need a clear visual indicator marking where the new unread messages begin. The existing subtle unread line was insufficient for drawing attention. + +### Solution +Display an accent-colored separator line above the first unread message when auto-jumping. The separator features a "New" pill on the right side and persists until scrolled out of view. + +**Key Features:** +- Accent-colored horizontal line spanning the full width +- Compact "New" pill positioned on the right in accent color +- Automatically dismisses when scrolled out of viewport +- Reappears on next channel visit if unreads remain + +--- + +## How It Works + +### Trigger Flow +``` +1. User opens channel or DM with unreads +2. Auto-jump logic (Channel.tsx or DirectMessage.tsx) triggers +3. Checks thresholds: 5+ unreads OR first unread is 5+ minutes old +4. If threshold met: Sets newMessagesSeparator state + scrolls to first unread +5. If threshold not met: Only scrolls to first unread (no separator) +6. MessageList renders separator before first unread message (if shown) +7. User scrolls → Virtuoso's rangeChanged detects visibility change +8. When separator leaves viewport (up or down) → dismissed +``` + +**Thresholds (prevents separator spam during active chatting):** +- Show separator if **5+ unread messages**, OR +- Show separator if **first unread is 5+ minutes old** +- Always scroll to first unread (even if no separator shown) + +### Visual Appearance + +**Styling:** +- Line: Full-width accent-colored line (`border-accent`) +- Pill: "New" text in accent background with white text, positioned on the right +- Layout: Line extends full width, pill attached to right end +- Internationalization: Lingui i18n for "New" label + +**Example:** +``` +[...read messages...] +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [NEW] ← Accent color line + pill +[First unread message] +[More unread messages] +[...newest messages...] +``` + +--- + +## Implementation + +### 1. NewMessagesSeparator Component (NewMessagesSeparator.tsx) + +```typescript +interface NewMessagesSeparatorProps { + className?: string; // Additional CSS classes +} + +export const NewMessagesSeparator: React.FC<NewMessagesSeparatorProps> = ({ + className = '', +}) => { + return ( + <FlexRow + align="center" + justify="center" + className={`my-4 px-4 ${className}`} + data-testid="new-messages-separator" + > + {/* Full-width separator line */} + <div className="flex-1 h-px border-t border-accent" /> + + {/* "New" pill on the right */} + <Text + size="xs" + className="ml-2 px-2 py-0.5 rounded-full bg-accent text-white select-none uppercase font-semibold tracking-wide" + testId="new-messages-separator-label" + > + {i18n._(t`New`)} + </Text> + </FlexRow> + ); +}; +``` + +**Key Features:** +- Uses primitive components (FlexRow, Text) for cross-platform compatibility +- Accent-colored pill with white text for high visibility +- Lingui i18n with `i18n._()` and `t` macro for translations +- Compact design - just "New" label, no count +- No ref forwarding needed (dismissal handled by Virtuoso) + +### 2. State Management + +**Channels** (Channel.tsx:124-128) and **Direct Messages** (DirectMessage.tsx:69-72) share identical state: + +```typescript +// New Messages separator state (consolidated) +const [newMessagesSeparator, setNewMessagesSeparator] = useState<{ + firstUnreadMessageId: string; + initialUnreadCount: number; // Used for threshold logic, not displayed +} | null>(null); +``` + +**State Lifecycle:** +- Set when auto-jump triggers **and thresholds met** + - Channels: Channel.tsx:390-406, 435-452 + - Direct Messages: DirectMessage.tsx:390-411, 437-450 +- `initialUnreadCount` used only for threshold checks (not displayed in UI) +- Reset to `null` when channel/conversation changes + - Channels: Channel.tsx:467-470 + - Direct Messages: DirectMessage.tsx:461-462 +- Dismissed when scrolled out of view (via callback from MessageList) + +**Key Design Decisions:** +- **Thresholds**: 5+ unreads OR 5+ minutes old (prevents spam during active chat) +- **Consolidated state**: Single object instead of multiple useState calls +- **DM-specific logic**: In Direct Messages, only counts messages from the other party (excludes current user's messages) + +### 3. Integration in MessageList (MessageList.tsx) + +**New Props (MessageList.tsx:71-75):** +```typescript +interface MessageListProps { + // ... existing props + newMessagesSeparator?: { + firstUnreadMessageId: string; + initialUnreadCount: number; // For threshold logic only + } | null; + onDismissSeparator?: () => void; +} +``` + +**Rendering Logic (MessageList.tsx:207-225):** +```typescript +const rowRenderer = useCallback((index: number) => { + const message = messageList[index]; + + // Check if separator needed before this message + const needsNewMessagesSeparator = + newMessagesSeparator && + message.messageId === newMessagesSeparator.firstUnreadMessageId; + + return ( + <React.Fragment> + {/* Date separator (if needed) */} + {needsDateSeparator && <DateSeparator ... />} + + {/* New Messages separator (if needed) */} + {needsNewMessagesSeparator && <NewMessagesSeparator />} + + {/* Message */} + <Message ... /> + </React.Fragment> + ); +}, [/* dependencies including newMessagesSeparator */]); +``` + +### 4. Dismissal Logic (MessageList.tsx:405-446) + +**Virtuoso `rangeChanged` Implementation:** + +```typescript +// Track if separator has been visible (for dismissal logic) +const [separatorWasVisible, setSeparatorWasVisible] = useState(false); + +// Reset tracking when separator changes +useEffect(() => { + if (!newMessagesSeparator) { + setSeparatorWasVisible(false); + } +}, [newMessagesSeparator]); + +// Handle separator dismissal via Virtuoso's rangeChanged callback +const handleRangeChanged = useCallback( + (range: { startIndex: number; endIndex: number }) => { + if (!newMessagesSeparator || !onDismissSeparator) { + return; + } + + const firstUnreadIndex = messageList.findIndex( + (m) => m.messageId === newMessagesSeparator.firstUnreadMessageId + ); + + if (firstUnreadIndex === -1) return; + + const isVisible = + firstUnreadIndex >= range.startIndex && + firstUnreadIndex <= range.endIndex; + + if (isVisible && !separatorWasVisible) { + // First time separator becomes visible + setSeparatorWasVisible(true); + } else if (!isVisible && separatorWasVisible) { + // Separator scrolled out of view - dismiss it + onDismissSeparator(); + } + }, + [newMessagesSeparator, onDismissSeparator, messageList, separatorWasVisible] +); + +// Attach to Virtuoso +<Virtuoso + // ... other props + rangeChanged={handleRangeChanged} +/> +``` + +**Why Virtuoso's `rangeChanged` Instead of Intersection Observer:** +- ✅ Works natively with Virtuoso's virtualization system +- ✅ No ref management or DOM observation needed +- ✅ More reliable - not affected by virtualized unmounting/remounting +- ✅ Tracks index ranges Virtuoso already computes internally +- ✅ Simpler code (~30 lines vs ~80 lines with Intersection Observer) +- ✅ No timing issues or race conditions + +**Dismissal Triggers:** +- User scrolls separator out of visible range (up or down) +- User switches to different channel/conversation (state reset in Channel.tsx or DirectMessage.tsx) + +**Persistence:** +- Separator stays visible while in Virtuoso's rendered range +- Natural dismissal - no arbitrary timers +- Reappears on channel revisit if unreads remain + +--- + +## Edge Cases Handled + +### Active Chatting (Threshold Protection) +- **Scenario**: User actively chatting, 1-4 new messages < 5 minutes old +- **Behavior**: Auto-jumps to first unread, but **no separator** shown (prevents spam) +- **Code**: Threshold checks + - Channels: Channel.tsx:390-406, 435-452 + - Direct Messages: DirectMessage.tsx:390-411, 437-450 + +### Direct Message Sender Filtering +- **Scenario**: In DMs, user sends messages after having unread messages from the other party +- **Behavior**: Only counts messages from the other party, not the current user's own messages +- **Rationale**: In 1-on-1 conversations, user's own messages are not "new" to them +- **Implementation**: Filters by `m.content.senderId !== currentUserId` in DirectMessage.tsx +- **Note**: In Spaces/Channels, all unread messages are counted (acceptable UX for group contexts) + +### Hash Navigation Priority +- **Scenario**: URL contains `#msg-{messageId}` +- **Behavior**: Separator NOT shown (hash navigation takes priority) + +--- + +## Performance + +**Threshold Calculation:** +- O(n) calculation happens once when auto-jump triggers (Channel.tsx or DirectMessage.tsx) +- In Direct Messages, includes additional sender ID check (negligible overhead) +- No additional database queries +- No UI count display - simpler rendering + +**Virtuoso `rangeChanged` Callback:** +- Leverages Virtuoso's existing range tracking (no additional overhead) +- Only runs when visible range changes (user scrolls) +- Simple index comparison (O(n) worst case to find separator index) +- No DOM observers or ref management overhead + +**Render Impact:** +- One additional component in render tree when visible +- Minimal re-renders (only when dependencies change) +- Same pattern as DateSeparator (proven performant) +- ~30 lines of dismissal logic (very lightweight) + +--- + +## Code References + +**New Files:** +- `src/components/message/NewMessagesSeparator.tsx` - Separator component + +**Modified Files:** +- `src/components/message/MessageList.tsx:15,71-75,118-119,142-143,207-225,285,405-446,497` - Separator rendering and dismissal logic via Virtuoso +- `src/components/space/Channel.tsx:124-128,390-406,435-452,467-470,971-972` - State management with thresholds and props passing (Channels) +- `src/components/direct/DirectMessage.tsx:69-72,390-411,437-450,461-462,720-721` - State management with thresholds and props passing (Direct Messages, with sender filtering) + +--- + +## Related Documentation + +- [auto-jump-first-unread.md](./auto-jump-first-unread.md) - Auto-jump infrastructure +- [hash-navigation-to-old-messages.md](./hash-navigation-to-old-messages.md) - Shared bidirectional loading +- [date-separators-messages-list.md](../../tasks/.done/date-separators-messages-list.md) - DateSeparator pattern +- [new-messages-separator-intersection-observer-issues.md](../../bugs/new-messages-separator-intersection-observer-issues.md) - Bug investigation & solution + +## Implementation Notes + +**Key Technical Decisions:** +1. **Hybrid thresholds**: 5+ unreads OR 5+ minutes old (prevents spam during active chat) +2. **Virtuoso `rangeChanged` over Intersection Observer**: More reliable with virtualized lists +3. **Consolidated state object**: Single `newMessagesSeparator` object instead of multiple useState calls +4. **Simple "New" pill**: Compact design without count for cleaner UI +5. **DM sender filtering**: Only count other party's messages in Direct Messages (better UX for 1-on-1 conversations) + +**Lessons Learned:** +- Intersection Observer doesn't work reliably with Virtuoso's virtualization (components unmount when out of view) +- Using Virtuoso's built-in APIs (`rangeChanged`, `itemsRendered`) is more reliable than DOM observation +- Scroll containers require special handling - default Intersection Observer observes viewport, not nested containers +- Consolidated state reduces complexity and prevents state synchronization bugs + +--- + +*Last updated: 2025-12-24 - Simplified to "New" pill design (no count)* +*Verified: 2025-12-09 - File paths confirmed current* diff --git a/.agents/docs/features/messages/pinned-messages.md b/.agents/docs/features/messages/pinned-messages.md new file mode 100644 index 000000000..62ad3efa1 --- /dev/null +++ b/.agents/docs/features/messages/pinned-messages.md @@ -0,0 +1,390 @@ +--- +type: doc +title: Pinned Messages Feature +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-12T00:00:00.000Z +--- + +# Pinned Messages Feature + +## Overview + +The pinned messages feature allows authorized users to pin important messages within Space channels, making them easily accessible through a dedicated panel. Pin/unpin actions are broadcast to all space members with full defense-in-depth validation, ensuring pins synchronize across all devices while maintaining security. + +**Key Features:** +- ✅ Cross-client synchronization via encrypted broadcast +- ✅ Role-based permissions with `message:pin` permission +- ✅ Defense-in-depth validation (UI → Sending → Receiving) +- ✅ Space Channels only (DMs not supported) +- ✅ Pin limit enforcement (50 max per channel) + +## User Experience + +- **Role-based permissions**: Users with `message:pin` permission or read-only channel managers can pin/unpin messages +- **Space Channels only**: Pin feature available in Space Channels, not in DMs +- **Pin from message actions**: Hover over any message to reveal pin/unpin button (thumbtack icon) +- **Visual indicators**: Pinned messages show a thumbtack icon next to the sender name +- **Pinned messages panel**: Access all pinned messages via thumbtack button in channel header +- **Quick navigation**: Jump directly to pinned messages in the conversation +- **Confirmation feedback**: Shows "Pinned!" and "Unpinned!" tooltips after actions +- **Real-time sync**: Pin/unpin actions broadcast to all space members and sync across devices + +## Architecture + +### Database Layer + +**File: `src/db/messages.ts`** + +- Database schema version bumped from 2 to 3 +- Added `by_channel_pinned` index (created but not actively used due to IndexedDB limitations) +- Three new methods: + - `getPinnedMessages()`: Retrieves all pinned messages for a channel (uses `by_conversation_time` index with filtering) + - `updateMessagePinStatus()`: Updates pin status with metadata + - `getPinnedMessageCount()`: Returns count of pinned messages (uses `by_conversation_time` index with filtering) + +**Message fields added:** + +- `isPinned?: boolean` - Whether message is pinned +- `pinnedAt?: number` - Timestamp when message was pinned +- `pinnedBy?: string` - Address of user who pinned the message + +**Implementation Note:** While a dedicated `by_channel_pinned` index was created, the implementation uses the existing `by_conversation_time` index and filters results in memory. This approach was chosen for reliability as IndexedDB has limitations with boolean values in compound index keys. + +### API Types + +**File: `src/api/quorumApi.ts`** + +- Extended `Message` type with pin-related fields +- `PinMessage` type used for cross-client synchronization: + ```typescript + type PinMessage = { + senderId: string; + type: 'pin'; + targetMessageId: string; + action: 'pin' | 'unpin'; + }; + ``` + +### Message Broadcasting + +**File: `src/services/MessageService.ts`** + +Pin/unpin actions are broadcast to all space members using the same pattern as reactions, deletions, and edits: + +**Sending (`submitChannelMessage` - lines 3100-3232):** +- Validates user permissions before broadcast +- Generates message ID using SHA-256(nonce + 'pin' + senderId + canonicalize(pinMessage)) +- Creates Message envelope with PinMessage content +- Signs if non-repudiable space +- Encrypts with Triple Ratchet +- Sends via `sendHubMessage()` +- Calls `saveMessage()` and `addMessage()` for local updates + +**Receiving (`saveMessage` - lines 448-523):** +- Validates target message exists +- Rejects DMs (pins are Space-only) +- Validates permissions: + - Read-only channels: Only managers via `managerRoleIds` + - Regular channels: Explicit `message:pin` role permission (NO isSpaceOwner bypass) +- Pin limit validation (50 max) +- Updates target message with `isPinned`, `pinnedAt`, `pinnedBy` fields +- Persists to IndexedDB + +**Receiving (`addMessage` - lines 882-978):** +- Same permission validation as saveMessage (defense-in-depth) +- Pin limit validation +- Updates React Query cache +- Invalidates `pinnedMessages` and `pinnedMessageCount` query caches + +**Canonicalization (`src/utils/canonicalize.ts` - lines 104-110):** +```typescript +if (pendingMessage.type === 'pin') { + return ( + pendingMessage.type + + pendingMessage.targetMessageId + + pendingMessage.action // Ensures unique IDs for pin vs unpin + ); +} +``` + +### Business Logic + +**File: `src/hooks/business/messages/usePinnedMessages.ts`** + +Main hook managing all pinned message functionality: + +**Key features:** + +- React Query integration with network broadcast +- Permission checking (role-based with `message:pin` permission) +- Pin limit enforcement (50 messages maximum, configurable via `PINNED_MESSAGES_CONFIG.MAX_PINS`) +- Network broadcast via `submitChannelMessage()` instead of local-only updates +- Automatic query invalidation for real-time updates +- Comprehensive error handling with try-catch blocks and validation +- Error state exposure via `pinError` and `unpinError` properties + +**Pin/Unpin Implementation (lines 71-160):** +- Replaced local `updateMessagePinStatus()` with `submitChannelMessage()` broadcast +- Creates `PinMessage` object with `action: 'pin'` or `action: 'unpin'` +- Broadcasts to all space members via encrypted message +- Receiving clients independently validate and apply changes + +**Exported functions:** + +- `pinnedMessages`: Array of pinned messages (sorted by creation date, newest first) +- `pinnedCount`: Count of pinned messages +- `canPinMessages`: Boolean indicating user permissions +- `pinMessage(messageId)`: Pin a message +- `unpinMessage(messageId)`: Unpin a message +- `togglePin(message)`: Smart toggle based on current state + +**Query invalidation strategy:** + +```typescript +// Critical: Uses correct case-sensitive query keys +queryClient.invalidateQueries({ + queryKey: ['pinnedMessages', spaceId, channelId], +}); +queryClient.invalidateQueries({ + queryKey: ['pinnedMessageCount', spaceId, channelId], +}); +queryClient.invalidateQueries({ queryKey: ['Messages', spaceId, channelId] }); // Capital 'M' +``` + +### UI Components + +#### Message Integration + +**File: `src/components/message/Message.tsx`** + +- Integrates `usePinnedMessages` hook +- Displays thumbtack icon next to sender name for pinned messages +- Passes pin functionality to MessageActions component + +**File: `src/components/message/MessageActions.tsx`** + +- Adds pin/unpin button to message hover actions +- Shows confirmation tooltips ("Pinned!" / "Unpinned!") +- Icon changes based on pin state: `thumbtack` → `thumbtack-slash` +- Color changes: muted → accent blue when pinned +- Confirmation duration configurable via `MESSAGE_ACTIONS_CONFIG.PIN_CONFIRMATION_DURATION` (2000ms) + +#### Channel Header + +**File: `src/components/space/Channel.tsx`** + +- Thumbtack button in header shows pin count badge +- Opens PinnedMessagesPanel when clicked +- Button color changes based on pin count (accent when > 0) + +#### Pinned Messages Panel + +**File: `src/components/message/PinnedMessagesPanel.tsx`** +**File: `src/components/message/PinnedMessagesPanel.scss`** + +Full-featured panel displaying all pinned messages: + +**Features:** + +- Message preview with sender name and original post date +- Jump-to-message functionality with smooth scrolling and highlight effect +- Unpin functionality (only visible to space owners) +- Empty states for loading and no pinned messages +- Uses reusable DropdownPanel component +- Text preview truncation at 800 characters (configurable via `PINNED_PANEL_CONFIG.TEXT_PREVIEW_LENGTH`) +- Mobile tooltip auto-hide after 3000ms (configurable via `PINNED_PANEL_CONFIG.TOOLTIP_DURATION_MOBILE`) + +**Layout:** + +- Header: Count of pinned messages with close button +- List: Individual message items with actions +- Actions: Jump (arrow-right) and Unpin (times icon) buttons +- Styling: Consistent with search results appearance + +### Reusable Components + +#### DropdownPanel Component + +**File: `src/components/ui/DropdownPanel.tsx`** +**File: `src/components/ui/DropdownPanel.scss`** + +Shared component used by both SearchResults and PinnedMessagesPanel: + +**Props:** + +- `isOpen`: Controls visibility +- `position`: 'absolute' or 'fixed' +- `positionStyle`: 'search-results', 'right-aligned', or 'centered' +- `maxWidth/maxHeight`: Size constraints +- `title/resultsCount`: Header content +- `showCloseButton`: Optional close button + +**Benefits:** + +- Consistent positioning and animation across dropdowns +- Unified keyboard (Escape) and outside-click handling +- Standardized styling and responsive behavior + +#### Updated SearchResults + +**File: `src/components/search/SearchResults.tsx`** +**File: `src/components/search/SearchResults.scss`** + +Refactored to use DropdownPanel for consistency: + +- Removed duplicate positioning and styling code +- Uses `right-aligned` positioning to prevent off-screen issues +- Maintains all existing search functionality + +### Icon System + +**Files: `src/components/primitives/Icon/iconMapping.ts` & `types.ts`** + +Added thumbtack-related icons: + +- `thumbtack`: Main pin icon (`IconPin`) +- `thumbtack-slash`: Unpin icon (`IconPinOff`) +- `pin`: Alias for thumbtack + +## Logic Flow + +### Pinning a Message + +1. User hovers over message → MessageActions appear +2. User clicks thumbtack icon → `handlePinClick()` triggered +3. `handlePinClick()` calls `togglePin(message)` +4. `togglePin()` determines action based on `message.isPinned` +5. Calls `pinMessage(messageId)` → triggers pin mutation +6. Mutation creates `PinMessage` object and calls `submitChannelMessage()` +7. **Sending client:** + - Validates permissions (UI layer) + - Validates permissions again (sending layer) + - Encrypts and broadcasts to all space members + - Calls `saveMessage()` and `addMessage()` for local updates +8. **All receiving clients (including sender):** + - Decrypt incoming message + - Validate permissions independently (receiving layer) + - Validate pin limit (50 max) + - Update local database and React Query cache + - Invalidate `pinnedMessages` and `pinnedMessageCount` queries +9. UI updates automatically with new pin state across all devices +10. Shows "Pinned!" confirmation tooltip for 2 seconds + +### Viewing Pinned Messages + +1. User clicks thumbtack button in channel header +2. Opens PinnedMessagesPanel with `isOpen={true}` +3. Panel fetches data via `usePinnedMessages` hook +4. Hook queries `getPinnedMessages()` from database +5. Messages sorted by creation date (newest first) +6. Panel renders list with jump and unpin actions + +### Jump to Message + +1. User clicks jump button (arrow-right) in panel +2. `handleJumpToMessage(messageId)` triggered +3. Panel closes and navigates to `#msg-${messageId}` +4. MessageList detects hash and scrolls to message +5. Message component detects hash match and applies `.message-highlighted` class +6. Yellow highlight effect (8 second CSS animation) +7. Hash is cleaned up after 8 seconds + +See `.agents/docs/features/messages/message-highlight-system.md` for the full highlighting architecture. + +## Key Technical Details + +### Configuration Constants + +All magic numbers have been extracted into configuration objects: + +- `PINNED_MESSAGES_CONFIG.MAX_PINS`: 50 (maximum pinned messages per channel) +- `PINNED_PANEL_CONFIG.TEXT_PREVIEW_LENGTH`: 800 (characters shown in preview) +- `PINNED_PANEL_CONFIG.TOOLTIP_DURATION_MOBILE`: 3000ms (mobile tooltip auto-hide) +- `MESSAGE_ACTIONS_CONFIG.PIN_CONFIRMATION_DURATION`: 2000ms (confirmation tooltip duration) + +### Query Key Management + +- **Critical bug fix**: Messages query uses capital 'M' (`['Messages', ...]`) +- Pin mutations must invalidate with correct case-sensitive key +- Ensures real-time UI updates after pin operations + +### Permission System + +**Defense-in-Depth (3 Layers):** + +1. **UI Layer**: Permission checked in hook via `canUserPin()` + - Read-only channels: Check `managerRoleIds` first + - Regular channels: Check `message:pin` role permission via `hasPermission()` (includes isSpaceOwner bypass for UI only) + - UI elements conditionally rendered based on permissions + +2. **Sending Layer** (`MessageService.ts:3100-3232`): + - Same permission logic before broadcast + - Prevents unauthorized messages from being sent + +3. **Receiving Layer** (`MessageService.ts:448-523, 882-978`): + - **Independent validation** by each receiving client + - Read-only channels: Only managers via `managerRoleIds` + - Regular channels: **Explicit `message:pin` role permission only** (NO isSpaceOwner bypass) + - Space owners must assign themselves a role with `message:pin` permission + - Protects against malicious/modified clients + +**Security Guarantees:** +- ✅ Unauthorized pins never displayed to honest users +- ✅ Silent rejection (attacker only sees their own pin) +- ✅ Pin limit enforced on both sending and receiving sides +- ✅ DMs explicitly rejected (pins are Space-only) +- ✅ Rate limiting via existing message throttle (10 msgs/10 sec) + +### Error Handling + +- Comprehensive try-catch blocks in mutation functions +- Validation of messageId, spaceId, and channelId parameters +- Error logging for debugging and monitoring +- Error states exposed via `pinError` and `unpinError` properties + +### Performance Optimization + +- React Query caching prevents unnecessary database calls +- Optimistic updates provide immediate UI feedback +- Query invalidation strategy updates only relevant caches +- Database queries use existing `by_conversation_time` index with in-memory filtering for reliability + +### Mobile Compatibility + +- Uses primitive components for cross-platform support +- Responsive design with mobile-first approach +- Touch-friendly tooltips with auto-hide timers + +## Completed Enhancements + +- ✅ **Cross-Client Sync** (2025-12-12): Pin/unpin actions now broadcast to all space members +- ✅ **Role-based Permissions** (2025-12-12): Extended to use `message:pin` permission, not just space owners +- ✅ **Defense-in-Depth Security** (2025-12-12): 3-layer validation (UI → Sending → Receiving) +- ✅ **DM Protection** (2025-12-12): Pins explicitly rejected in DMs (Space-only feature) + +## Future Enhancements + +1. **System Messages**: Show pin/unpin notifications in conversation feed +2. **Pin Categories**: Allow organizing pins by topic or importance +3. **Pin History**: Track pin/unpin activity for moderation +4. **Bulk Operations**: Pin/unpin multiple messages at once + +## Related Documentation + +- [Message Preview Rendering](message-preview-rendering.md) - Overview of preview rendering systems (MessagePreview used here) +- [Bookmarks](bookmarks.md) - Similar panel pattern with hybrid rendering +- [Markdown Stripping](markdown-stripping.md) - Text processing used by MessagePreview +- [Security Architecture](../security.md) - Defense-in-depth validation pattern used for pins +- [Data Management Architecture](../../data-management-architecture-guide.md) - Message sync patterns + +## Implementation Tasks + +- [Pinned Messages Feature Plan](.agents/tasks/.done/pinned-messages-feature.md) - Original feature implementation +- [Pinned Messages Sync Task](.agents/tasks/pinned-messages-sync.md) - Cross-client synchronization (✅ COMPLETED 2025-12-12) + +--- + + +_Last updated: 2025-12-12_ +_Major Update: Added cross-client synchronization with full defense-in-depth validation_ diff --git a/.agents/docs/features/messages/youtube-facade-optimization.md b/.agents/docs/features/messages/youtube-facade-optimization.md new file mode 100644 index 000000000..92b33d2a8 --- /dev/null +++ b/.agents/docs/features/messages/youtube-facade-optimization.md @@ -0,0 +1,341 @@ +--- +type: doc +title: YouTube Facade Optimization +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# YouTube Facade Optimization + +## Overview + +This document describes the YouTube facade pattern implementation that optimizes YouTube video embeds in message lists. Instead of loading heavy iframes immediately, the system displays lightweight thumbnail previews that load the full video player only when clicked. + +## Problem Solved + +YouTube embeds are resource-intensive: +- Each iframe loads ~1MB+ of JavaScript/CSS +- Multiple videos in view cause significant performance degradation +- 20+ videos could load 20MB+ of resources simultaneously +- Scrolling through message history with videos was sluggish + +## Solution: Facade Pattern + +The facade pattern replaces heavy YouTube iframes with lightweight thumbnail images until user interaction. + +### How It Works + +1. **URL Detection**: System identifies YouTube URLs in messages +2. **Thumbnail Display**: Shows static YouTube thumbnail image (direct URL, no API) +3. **User Clicks Play**: Replaces thumbnail with actual YouTube iframe +4. **State Persistence**: Remembers which videos are playing during session + +### Architecture + +``` +src/ +├── utils/ +│ └── youtubeUtils.ts # Centralized YouTube URL utilities +├── components/ +│ ├── ui/ +│ │ ├── YouTubeEmbed.tsx # Main wrapper component +│ │ └── YouTubeFacade.tsx # Thumbnail facade implementation +│ └── message/ +│ ├── Message.tsx # Uses YouTubeEmbed for videos +│ ├── MessagePreview.tsx # Uses YouTubeEmbed with previewOnly mode +│ └── MessageMarkdownRenderer.tsx # Handles YouTube URLs in markdown +└── hooks/ + └── business/messages/ + └── useMessageFormatting.ts # Uses centralized utilities +``` + +## Implementation Details + +### Centralized YouTube Utilities (`src/utils/youtubeUtils.ts`) + +All YouTube URL operations are centralized to eliminate code duplication: + +```typescript +// Comprehensive YouTube URL regex +export const YOUTUBE_URL_REGEX = /^(?:https?:\/\/)?(?:www\.|m\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|live\/|v\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})(?:\S+)?$/; + +// Core utilities +export const isYouTubeURL = (url: string): boolean; +export const extractYouTubeVideoId = (url: string): string | null; +export const convertToYouTubeEmbedURL = (url: string): string | null; +export const getYouTubeThumbnailURL = (videoId: string, quality: 'maxres' | 'hq' | 'mq' | 'default'): string; +``` + +### YouTubeEmbed Component (`src/components/ui/YouTubeEmbed.tsx`) + +Main wrapper with preview mode support: + +```tsx +export const YouTubeEmbed: React.FC<YouTubeEmbedProps> = ({ + src, + title, + allow = "autoplay; encrypted-media", + style, + className = '', + previewOnly = false, // New: disable click-to-play for previews +}) => { + const youtubeVideoId = extractYouTubeVideoId(src); + + if (!youtubeVideoId) { + return <iframe src={src} ... />; // Fallback + } + + return ( + <div className={`relative youtube-embed rounded-lg ${className}`}> + <YouTubeFacade + videoId={youtubeVideoId} + previewOnly={previewOnly} + {...props} + /> + </div> + ); +}; +``` + +### YouTubeFacade Component (`src/components/ui/YouTubeFacade.tsx`) + +Enhanced facade implementation with anti-restart protection: + +```tsx +// Dual cache system for optimal state management +const iframeStateCache = new Map<string, boolean>(); // Tracks loaded videos +const autoplayBlockCache = new Set<string>(); // Prevents unwanted autoplay + +export const YouTubeFacade: React.FC<YouTubeFacadeProps> = ({ + videoId, + previewOnly = false +}) => { + const [isLoaded, setIsLoaded] = useState(() => iframeStateCache.get(videoId) || false); + const [thumbnailQuality, setThumbnailQuality] = useState<'maxres' | 'hq' | 'mq' | 'default'>('maxres'); + + // Smart autoplay: only on first click, prevents restart on re-renders + const embedUrl = useMemo(() => { + const baseUrl = `https://www.youtube.com/embed/${videoId}`; + if (isLoaded && !autoplayBlockCache.has(videoId)) { + return `${baseUrl}?autoplay=1`; + } + return baseUrl; + }, [videoId, isLoaded]); + + // Anti-restart protection: block autoplay after initial load + useEffect(() => { + if (isLoaded) { + const timer = setTimeout(() => { + autoplayBlockCache.add(videoId); + }, 3000); + return () => clearTimeout(timer); + } + }, [isLoaded, videoId]); + + // Preview mode: show thumbnail only (no click functionality) + if (previewOnly || !isLoaded) { + return ( + <div + className={`relative ${previewOnly ? '' : 'cursor-pointer group'}`} + onClick={previewOnly ? undefined : () => { + setIsLoaded(true); + iframeStateCache.set(videoId, true); + autoplayBlockCache.delete(videoId); // Allow autoplay on fresh click + }} + > + <img + src={getYouTubeThumbnailURL(videoId, thumbnailQuality)} + className="w-full h-full object-cover rounded-lg" + onError={() => {/* Quality fallback logic */}} + /> + {!previewOnly && <PlayButtonOverlay />} + <YouTubeBadge /> + </div> + ); + } + + return <iframe src={embedUrl} /* ...iframe props */ />; +}; +``` + +### Markdown Integration (`src/components/message/MessageMarkdownRenderer.tsx`) - Updated 2025-11-07 + +YouTube URLs in markdown content are intelligently processed with **standalone vs inline detection**: + +```tsx +// Process YouTube URLs line-by-line to detect standalone vs inline +const processStandaloneYouTubeUrls = (text: string): string => { + const lines = text.split('\n'); + const processedLines = lines.map(line => { + const trimmedLine = line.trim(); + return replaceYouTubeURLsInText(line, (url) => { + // Check if URL is alone on its line (standalone) + const isStandalone = trimmedLine === url.trim(); + if (isStandalone) { + const videoId = extractYouTubeVideoId(url); + if (videoId) { + // Convert to markdown image syntax for embed + return `![youtube-embed](${videoId})`; + } + } + // Inline URLs stay as-is (will become links) + return url; + }); + }); + return processedLines.join('\n'); +}; + +// Image component catches YouTube embeds +const components = useMemo(() => ({ + img: ({ src, alt, ...props }: any) => { + if (alt === 'youtube-embed' && src) { + return ( + <div className="my-2"> + <YouTubeFacade + videoId={src} + className="rounded-lg youtube-embed" + style={{ width: '100%', maxWidth: 560, aspectRatio: '16/9' }} + /> + </div> + ); + } + return null; + }, + + // Link component renders ALL links as clickable (including inline YouTube URLs) + a: ({ href, children, ...props }: any) => { + if (href) { + return ( + <a href={href} target="_blank" rel="noopener noreferrer" className="link"> + {children} + </a> + ); + } + return <span>{children}</span>; + }, + // ... other components +}), []); // Stable components prevent YouTube remounting +``` + +**Key Change (2025-11-07)**: Inline YouTube URLs now render as clickable links instead of embeds to avoid cluttering messages. + +### URL Pattern Matching + +Centralized regex supports all YouTube URL formats: +- `https://www.youtube.com/watch?v=VIDEO_ID` +- `https://youtu.be/VIDEO_ID` +- `https://www.youtube.com/embed/VIDEO_ID` +- `https://m.youtube.com/watch?v=VIDEO_ID` +- `https://youtube.com/live/VIDEO_ID` + +```typescript +export const YOUTUBE_URL_REGEX = /^(?:https?:\/\/)?(?:www\.|m\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|live\/|v\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})(?:\S+)?$/; +``` + +## Performance Benefits + +### Before (Direct iframe embeds) +- **Initial Load**: All YouTube iframes load immediately +- **Memory Usage**: ~1MB per video × number of videos +- **Network Requests**: Multiple simultaneous heavy requests +- **User Experience**: Sluggish scrolling, delayed page loads + +### After (Facade pattern) +- **Initial Load**: Only lightweight thumbnail images (~50KB each) +- **Memory Usage**: 95% reduction until user interaction +- **Network Requests**: Single image request per video +- **User Experience**: Smooth scrolling, instant page loads + +### Metrics +- **Page Size Reduction**: ~95% for pages with multiple videos +- **Load Time**: 10× faster for message lists with videos +- **Memory Usage**: Minimal until user clicks play +- **CPU Usage**: Near-zero until video activation + +## Integration Points + +### Message Component (Full Interactive) +```tsx +// src/components/message/Message.tsx +if (tokenData.type === 'youtube') { + return ( + <YouTubeEmbed + src={`https://www.youtube.com/embed/${tokenData.videoId}`} + className="rounded-lg youtube-embed" + // previewOnly=false (default) - full click-to-play functionality + /> + ); +} +``` + +### MessagePreview Component (Preview Only) +```tsx +// src/components/message/MessagePreview.tsx - Used in PinnedMessagesPanel +{contentData.content.videoUrl?.startsWith('https://www.youtube.com/embed') && ( + <YouTubeEmbed + src={contentData.content.videoUrl} + className="rounded-lg youtube-embed" + previewOnly={true} // Shows thumbnail + play icon, but not clickable + /> +)} +``` + +### Markdown Messages (Updated 2025-11-07) +```tsx +// Automatic detection in MessageMarkdownRenderer + +// Standalone URL (on its own line) - becomes embed +https://www.youtube.com/watch?v=abc123 + +// Inline URL (mixed with text) - becomes clickable link +Check this video https://www.youtube.com/watch?v=abc123 out! + +// Regular URLs - always become clickable links +https://example.com +``` + +## CSS Styling + +YouTube embeds maintain responsive design with hardware acceleration: + +```scss +.youtube-embed { + width: 100%; + max-width: 560px; + height: auto; + aspect-ratio: 16 / 9; + transform: translateZ(0); /* Hardware acceleration */ + position: relative; + contain: layout style paint; /* CSS containment */ +} +``` + + +## Security & Privacy Considerations + +**Note from Cassie (Q Founder) - 2025-09-21** + +The current YouTube facade implementation is acceptable for now, but we should be aware of potential privacy risks. Remote images (including YouTube thumbnails) can potentially be used for deanonymization attacks. + +**Long-term Security Approach:** + +For enhanced privacy protection, we should consider implementing a Signal-like approach: +1. **Client-side metadata fetching**: The sending client fetches OpenGraph data (preview images and text) from the link +2. **Content encryption**: The fetched image data is encrypted and sent to recipients +3. **No direct external requests**: Recipients never need to make requests to external URLs + +**Future Safety Gradient:** + +We should implement user preference levels for external content: +- **Paranoid mode**: Refuse to load external images/embeds entirely +- **Permissive mode**: Allow external content with proper privacy protections +- **Default mode**: Load with `no-referrer` behaviors and other privacy safeguards + +**Current Status**: The facade feature works well for performance, but we should evaluate whether we're properly implementing `no-referrer` policies to minimize tracking potential. + +--- + +**Last Updated**: 2025-11-07 +**Recent Changes**: Inline vs standalone URL detection, security hardening (rehype-raw removal) +**Verified**: 2025-12-09 - File paths confirmed current diff --git a/.agents/docs/features/modal-save-overlay.md b/.agents/docs/features/modal-save-overlay.md new file mode 100644 index 000000000..5676afcfc --- /dev/null +++ b/.agents/docs/features/modal-save-overlay.md @@ -0,0 +1,200 @@ +--- +type: doc +title: Modal Save Overlay System +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-01-16T00:00:00.000Z +--- + +# Modal Save Overlay System + +A centralized system for displaying loading overlays during async operations in modals, preventing user interaction and providing visual feedback during save operations. + +## Overview + +The Modal Save Overlay system provides consistent UX for long-running operations (3+ seconds) by: +- Blocking user interaction (ESC, backdrop clicks, form inputs) +- Displaying spinner with customizable messages +- Implementing automatic timeout failsafes +- Supporting both timeout-based and completion-based operations + +## Core Components + +### 1. ModalSaveOverlay Component +**File**: `src/components/modals/ModalSaveOverlay.tsx` + +Reusable overlay component with spinner and message display. + +```tsx +<ModalSaveOverlay + visible={isSaving} + message="Saving..." + className="optional-custom-class" +/> +``` + +### 2. useModalSaveState Hook +**File**: `src/hooks/business/ui/useModalSaveState.ts` + +Business logic hook managing save states with timeout handling. + +```tsx +const { isSaving, saveWithTimeout, saveUntilComplete } = useModalSaveState({ + defaultTimeout: 3000, // Close after 3 seconds + maxTimeout: 30000, // 30s failsafe + onSaveComplete: dismiss, + onSaveError: handleError, +}); +``` + +### 3. Centralized CSS +**File**: `src/styles/_modal_common.scss` + +```scss +.modal-save-overlay { + position: absolute; inset: 0; z-index: 50; + display: flex; align-items: center; justify-content: center; +} +.modal-save-backdrop { + position: absolute; inset: 0; + background-color: rgba(0, 0, 0, 0.3); + backdrop-filter: blur(4px); border-radius: 0.5rem; +} +.modal-save-content { + position: relative; display: flex; align-items: center; gap: 0.75rem; + .modal-save-spinner { color: var(--accent); font-size: 24px; } + .modal-save-text { font-size: 1.125rem; font-weight: 500; color: white; } +} +``` + +## Implementation Pattern + +### Standard Implementation (Save Operations) + +```tsx +// 1. Import dependencies +import ModalSaveOverlay from '../modals/ModalSaveOverlay'; +import { useModalSaveState } from '../../hooks'; + +// 2. Set up hook +const { isSaving, saveWithTimeout } = useModalSaveState({ + defaultTimeout: 3000, + onSaveComplete: dismiss, +}); + +// 3. Protect modal close mechanisms +<Modal + onClose={isSaving ? undefined : dismiss} + closeOnBackdropClick={!isSaving} + closeOnEscape={!isSaving} +> + {/* 4. Add overlay */} + <ModalSaveOverlay visible={isSaving} message="Saving..." /> + + {/* 5. Disable buttons during save */} + <Button onClick={handleSave} disabled={isSaving}> + Save Changes + </Button> +</Modal> + +// 6. Implement save handler +const handleSave = useCallback(async () => { + saveWithTimeout(async () => { + await actualSaveFunction(); + }); +}, [saveWithTimeout]); +``` + +## Current Implementations + +### ChannelEditorModal +- **Implementation**: `useModalSaveState` hook (`saveWithTimeout`) +- **Timeout**: 3000ms for save operations +- **Protection**: Modal close mechanisms disabled during save +- **Message**: "Saving..." + +### GroupEditorModal +- **Implementation**: `useModalSaveState` hook (`saveWithTimeout`) +- **Timeout**: 3000ms for save operations +- **Protection**: Modal close mechanisms disabled during save +- **Message**: "Saving..." + +### UserSettingsModal +- **Implementation**: `useModalSaveState` hook (`saveUntilComplete`) +- **Timeout**: Closes only on completion (30s failsafe) +- **Protection**: Modal close mechanisms disabled during save +- **Message**: "Saving..." (default) + +### SpaceSettingsModal +- **Implementation**: `useModalSaveState` hook (`saveUntilComplete`) +- **Timeout**: Closes only on completion (30s failsafe) +- **Protection**: Modal close mechanisms disabled during save +- **Message**: "Saving..." (default) + +### KickUserModal +- **Implementation**: `useModalSaveState` hook (`saveUntilComplete`) +- **Timeout**: 3000ms for save operations +- **Protection**: Modal close mechanisms disabled during save +- **Message**: "Kicking..." + +**✅ All modals now use the consistent `useModalSaveState` pattern!** + +## Operation Types + +### saveWithTimeout() +- Shows overlay immediately +- Executes save function +- Closes modal after specified timeout (regardless of completion) +- **Use case**: Operations with predictable duration + +### saveUntilComplete() +- Shows overlay until operation completes +- Includes 30-second failsafe timeout +- Closes modal only after successful completion +- **Use case**: Operations with unpredictable duration + +## Important Limitations + +### ⚠️ Delete/Confirm Operations Disabled + +**The Modal Save Overlay system is intentionally NOT used for delete operations via `ConfirmationModal.tsx`.** + +**Reason**: During implementation, we discovered conflicts when combining: +- ConfirmationModal timeout overlays +- useChannelManagement delete completion callbacks +- Parent modal dismiss logic + +**Result**: Delete operations became convoluted with: +- Double dismiss calls +- Complex state management (`shouldStayVisible`, `isProcessing`) +- Inconsistent UX between delete paths + +**Current behavior**: Delete operations dismiss modals immediately after completion without overlay. + +**Future consideration**: This could be re-implemented with a cleaner architecture for optimal UX, but requires careful design to avoid callback conflicts. + +## Files Modified/Created + +### New Files +- `src/components/modals/ModalSaveOverlay.tsx` - Overlay component +- `src/hooks/business/ui/useModalSaveState.ts` - State management hook + +### Modified Files +- `src/components/modals/ChannelEditorModal.tsx` - Migrated to `useModalSaveState` hook (saves only) +- `src/components/modals/GroupEditorModal.tsx` - Migrated to `useModalSaveState` hook, updated error handling +- `src/components/modals/SpaceSettingsModal/SpaceSettingsModal.tsx` - Migrated to `useModalSaveState` hook (`saveUntilComplete`) +- `src/components/modals/UserSettingsModal.tsx` - Migrated to `useModalSaveState` hook (`saveUntilComplete`) +- `src/components/modals/ConfirmationModal.tsx` - Simplified (removed timeout overlay logic) +- `src/hooks/business/channels/useChannelManagement.ts` - Cleaned up legacy `showWarning` +- `src/hooks/business/channels/useGroupManagement.ts` - Removed automatic error timeout +- `src/hooks/business/ui/index.ts` - Added export + +### CSS +- `src/styles/_modal_common.scss` - Centralized overlay styles + + +--- + + +*Last Updated: 2025-01-16 (Complete migration)* +*Verified: 2025-12-09 - File paths confirmed current* diff --git a/.agents/docs/features/modals.md b/.agents/docs/features/modals.md new file mode 100644 index 000000000..05ab63bde --- /dev/null +++ b/.agents/docs/features/modals.md @@ -0,0 +1,179 @@ +--- +type: doc +title: Modal System Documentation +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Modal System Documentation + +## Overview + +The app uses **two modal rendering systems** - this is intentional, not a temporary state. + +## Architecture + +### Two Rendering Systems + +| System | Location | Modals | Best For | +|--------|----------|--------|----------| +| **ModalProvider** | Router level | 8 modals | Top-level triggers (NavMenu, menus) | +| **Layout-Level** | Layout.tsx | 6 modals | Deep triggers (Message.tsx) - context providers avoid prop drilling | + +> ⚠️ **Never render modals at component level** (e.g., inside Message.tsx). This causes z-index issues where NavMenu appears above the modal overlay. + +### Why Two Systems? + +After feature-analyzer review, the hybrid architecture was determined to be **correct by design**: + +1. **Performance**: ModalProvider context changes re-render ALL children. Layout-Level uses local state = fewer re-renders. +2. **Prop Drilling**: Layout-Level modals (ConfirmationModal, ImageModal, EditHistoryModal) are triggered 5+ levels deep. Context providers avoid passing functions through every level. +3. **No Bugs**: Both systems have perfect z-index stacking. + +**Do NOT attempt to unify** - see `.agents/tasks/.archived/modal-system-unification.md` for full analysis. + +### When to Use Each System + +| Criteria | → System | +|----------|----------| +| Triggered from NavMenu, space menus, context menus | ModalProvider | +| Needs to open from multiple unrelated components | ModalProvider | +| Multi-section interface (settings) | ModalProvider | +| Triggered deep in component tree (Message.tsx) | Layout-Level | +| Simple viewer/confirmation | Layout-Level | +| Performance critical | Layout-Level | + +## Modal Inventory + +### Layout-Level Modals (6) + +| Modal | File | Purpose | +|-------|------|---------| +| CreateSpaceModal | `src/components/modals/CreateSpaceModal.tsx` | Create new space | +| AddSpaceModal | `src/components/modals/AddSpaceModal.tsx` | Join via invite or create | +| JoinSpaceModal | `src/components/modals/JoinSpaceModal.tsx` | Join space (legacy) | +| ConfirmationModal | `src/components/modals/ConfirmationModal.tsx` | Universal confirmation dialog | +| ImageModal | `src/components/modals/ImageModal.tsx` | Full-screen image viewer | +| EditHistoryModal | `src/components/modals/EditHistoryModal.tsx` | Message edit history | + +**Context Providers**: ConfirmationModalProvider, ImageModalProvider, EditHistoryModalProvider + +### ModalProvider Modals (9) + +| Modal | File | Purpose | +|-------|------|---------| +| UserSettingsModal | `src/components/modals/UserSettingsModal/` | User settings (multi-section) | +| SpaceSettingsModal | `src/components/modals/SpaceSettingsModal/` | Space management (multi-section) | +| ChannelEditorModal | `src/components/modals/ChannelEditorModal.tsx` | Create/edit channels | +| GroupEditorModal | `src/components/modals/GroupEditorModal.tsx` | Create/edit channel groups | +| LeaveSpaceModal | `src/components/modals/LeaveSpaceModal.tsx` | Leave space confirmation | +| KickUserModal | `src/components/modals/KickUserModal.tsx` | Kick user confirmation | +| MuteUserModal | `src/components/modals/MuteUserModal.tsx` | Mute user confirmation | +| NewDirectMessageModal | `src/components/modals/NewDirectMessageModal.tsx` | Start new DM | +| ConversationSettingsModal | `src/components/modals/ConversationSettingsModal.tsx` | DM conversation settings | + +**Access**: `const { openUserSettings, openSpaceEditor, ... } = useModals();` + +### Utility Component + +| Component | File | Purpose | +|-----------|------|---------| +| ModalSaveOverlay | `src/components/modals/ModalSaveOverlay.tsx` | Loading overlay for async operations | + +## Implementation + +### Adding a ModalProvider Modal + +1. **Add state** (`src/hooks/business/ui/useModalState.ts`): +```tsx +myModal: { isOpen: boolean; data?: any; } +``` + +2. **Add to ModalProvider** (`src/components/context/ModalProvider.tsx`): +```tsx +{modalState.state.myModal.isOpen && <MyModal onClose={modalState.closeMyModal} />} +``` + +3. **Use**: `const { openMyModal } = useModals();` + +### Adding a Layout-Level Modal + +1. **Add state** (`src/hooks/business/ui/useModalManagement.ts`): +```tsx +const [myModalVisible, setMyModalVisible] = useState(false); +``` + +2. **Add to Layout** (`src/components/Layout.tsx`): +```tsx +{myModalVisible && <MyModal visible={myModalVisible} onClose={() => setMyModalVisible(false)} />} +``` + +3. **For deep access**: Create a context provider (see ConfirmationModalProvider pattern) + +### Using ConfirmationModal + +```tsx +const { showConfirmationModal } = useConfirmationModal(); + +showConfirmationModal({ + title: t`Delete Message`, + message: t`Are you sure?`, + preview: <MessagePreview message={message} />, + variant: 'danger', + protipAction: t`delete`, + onConfirm: handleDelete, +}); +``` + +## Modal Sizes + +| Size | Width | Use Case | +|------|-------|----------| +| `small` | 400px | Simple editors | +| `medium` | 600px | Standard forms | +| `large` | 800px | Multi-section settings | +| `full` | 95vw | Content-heavy modals | + +## Core Components + +### Modal Primitive (`src/components/primitives/Modal`) + +```tsx +<Modal + title="Title" + visible={visible} + onClose={onClose} + size="medium" + closeOnBackdropClick={true} + closeOnEscape={true} +> + {content} +</Modal> +``` + +### Required Primitives + +All modals must use: `Button`, `Input`, `Switch`, `Icon`, `Tooltip`, `Select` from `../primitives` + +**Exception**: Keep ReactTooltip for file upload areas (conflicts with react-dropzone) + +## CSS + +- **Primitive styles**: `src/components/primitives/Modal/Modal.scss` +- **App styles**: `src/styles/_modal_common.scss` +- **Responsive classes**: `modal-buttons-responsive`, `modal-body`, `modal-width-medium` + +## Related Files + +| File | Purpose | +|------|---------| +| `src/components/context/ModalProvider.tsx` | ModalProvider system | +| `src/hooks/business/ui/useModalState.ts` | ModalProvider state | +| `src/hooks/business/ui/useModalManagement.ts` | Layout-Level state | +| `src/components/Layout.tsx` | Layout-Level rendering | + +--- + +**Last Updated:** 2025-12-15 +**Verified:** 2025-12-15 - Added MuteUserModal to ModalProvider section diff --git a/.agents/docs/features/mute-conversation-system.md b/.agents/docs/features/mute-conversation-system.md new file mode 100644 index 000000000..f95df8d37 --- /dev/null +++ b/.agents/docs/features/mute-conversation-system.md @@ -0,0 +1,176 @@ +--- +type: doc +title: Mute Conversation System +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-09T00:00:00.000Z +--- + +# Mute Conversation System + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Overview + +The DM conversation mute feature allows users to mute individual direct message conversations. When muted, conversations: +- Don't show unread indicators (blue dot) +- Don't count toward the NavMenu DM badge +- Don't trigger desktop notifications + +This is useful for low-priority contacts, noisy conversations, or bot accounts where users want to keep the conversation but don't need immediate alerts. + +## Architecture + +### Data Storage + +Muted conversations are stored in the `UserConfig` type in IndexedDB: + +```typescript +// src/db/messages.ts +type UserConfig = { + // ... other fields + mutedConversations?: string[]; // Array of conversationId strings +}; +``` + +The `conversationId` format is `address/address` (counterparty wallet address repeated), matching the existing `favoriteDMs` pattern. + +### Core Hook + +The `useDMMute` hook ([src/hooks/business/dm/useDMMute.ts](src/hooks/business/dm/useDMMute.ts)) provides the mute API: + +```typescript +const { + muted, // string[] - array of muted conversation IDs + mutedSet, // Set<string> - for O(1) lookup + isMuted, // (conversationId: string) => boolean + muteConversation, // async (conversationId: string) => void + unmuteConversation, // async (conversationId: string) => void + toggleMute // async (conversationId: string) => void +} = useDMMute(); +``` + +The hook follows the exact same pattern as `useDMFavorites`: +- Optimistic UI updates via `queryClient.setQueryData` +- Action Queue integration via `save-user-config` action type +- Dedup key pattern: `config:${userAddress}` +- Cache invalidation for immediate badge updates + +### Integration Points + +1. **Context Menu** ([DirectMessageContactsList.tsx](src/components/direct/DirectMessageContactsList.tsx)) + - "Mute Conversation" / "Unmute Conversation" option + - Bell icon toggles between `bell` (unmute) and `bell-off` (mute) + +2. **Settings Modal** ([ConversationSettingsModal.tsx](src/components/modals/ConversationSettingsModal.tsx)) + - Switch toggle for mute status + - Tooltip explains the effect of muting + +3. **Unread Indicators** ([DirectMessageContactsList.tsx](src/components/direct/DirectMessageContactsList.tsx)) + - Visual unread dot suppressed for muted conversations + - `unread` prop excludes muted: `!mutedSet.has(c.conversationId)` + +4. **NavMenu Badge** ([useDirectMessageUnreadCount.ts](src/hooks/business/messages/useDirectMessageUnreadCount.ts)) + - Muted conversations excluded from unread count + - Cache invalidation ensures immediate updates on mute/unmute + +5. **Desktop Notifications** ([NotificationService.ts](src/services/NotificationService.ts), [MessageService.ts](src/services/MessageService.ts)) + - NotificationService maintains `mutedConversations` Set + - MessageService checks before incrementing notification count + - React layer syncs via `useMutedConversationsSync` hook in Layout + +6. **Filter System** ([DirectMessageContactsList.tsx](src/components/direct/DirectMessageContactsList.tsx)) + - "Muted" filter option in DM list + - Filter only appears when muted conversations exist + - Auto-resets to "All" when last muted conversation is unmuted + +## Data Flow + +### Muting a Conversation + +``` +User clicks "Mute" in context menu + │ + ├─► useDMMute.toggleMute(conversationId) + │ + ├─► Optimistic update: queryClient.setQueryData (instant UI) + │ + ├─► Cache invalidation: invalidateQueries(['unread-counts', ...]) + │ + └─► Action Queue: enqueue('save-user-config', {...}) + │ + └─► Background: Config saved to IndexedDB + synced to server +``` + +### Notification Filtering + +``` +WebSocket receives DM message + │ + ├─► MessageService.addMessage() + │ + ├─► Check: notificationService.isConversationMuted(conversationId) + │ + └─► If not muted: incrementPendingNotificationCount() +``` + +### Config Sync to NotificationService + +``` +Layout component mounts + │ + └─► useMutedConversationsSync() hook + │ + ├─► useConfig() subscribes to config changes + │ + └─► useEffect: notificationService.setMutedConversations(Set) +``` + +## Filter System + +The DM list filter system conditionally shows filters based on data availability: + +| Filter | Shown When | +|--------|------------| +| All | Always | +| Favorites | At least one favorite exists | +| Unknown | At least one unknown contact exists | +| Muted | At least one muted conversation exists | + +If no filters are available (no favorites, no unknown, no muted), the filter dropdown is hidden entirely. + +When a filter's data becomes empty while that filter is active (e.g., unmuting the last muted conversation while "Muted" filter is selected), the filter auto-resets to "All". + +## Offline Support + +Mute operations work offline via Action Queue: + +1. User mutes conversation while offline +2. Optimistic update shows immediate UI change +3. Action queued to IndexedDB (`save-user-config`) +4. When online, action processed and synced to server +5. Dedup key collapses rapid toggles to final state + +## Related Features + +- **Channel Mute** ([useChannelMute.ts](src/hooks/business/channels/useChannelMute.ts)) - Similar concept for space channels +- **DM Favorites** ([useDMFavorites.ts](src/hooks/business/dm/useDMFavorites.ts)) - Pattern reference for implementation +- **Desktop Notifications** ([desktop-notifications.md](.agents/docs/features/desktop-notifications.md)) - Notification system overview +- **Action Queue** ([action-queue.md](.agents/docs/features/action-queue.md)) - Offline support mechanism + +## Technical Decisions + +### Why NotificationService Instead of MessageService Dependencies? + +The notification filtering is implemented in NotificationService rather than passed through MessageService dependencies because: +1. NotificationService is a singleton already managing notification state +2. Avoids complex dependency injection changes +3. React layer can easily sync config changes to the service +4. Clean separation: MessageService asks "should I notify?", NotificationService knows the answer + +### Why No Visual Indicator (Opacity)? + +Unlike channel mute which shows muted channels at 60% opacity, muted DM conversations have no visual distinction. This was a deliberate choice to keep the UI clean - users can use the "Muted" filter to find muted conversations when needed. + +--- diff --git a/.agents/docs/features/mute-user-system.md b/.agents/docs/features/mute-user-system.md new file mode 100644 index 000000000..23052f9e8 --- /dev/null +++ b/.agents/docs/features/mute-user-system.md @@ -0,0 +1,382 @@ +--- +type: doc +title: Mute User System Documentation +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-17T00:00:00.000Z +--- + +# Mute User System Documentation + +> **⚠️ AI-Generated**: May contain errors. Verify before use. +> **Reviewed by**: security-analyst agent + +## Overview + +The Mute User system allows moderators with `user:mute` permission to silence users in spaces. Unlike kick (which is space-owner only and protocol-enforced), mute is **client-enforced** through receiving-side validation - each client independently ignores messages from muted users. This provides effective moderation without requiring protocol-level changes. + +## Architecture + +### Component Structure + +``` +MuteUserModal (UI Component) + ↓ +useUserMuting (Business Logic Hook) + ↓ +submitChannelMessage (Network Broadcast) + ↓ +MessageService.addMessage (Receiving-Side Validation) + ↓ +IndexedDB (muted_users store) +``` + +### Key Design Principles + +1. **Client-Enforced**: Each client validates mute permissions independently +2. **Receive-Side Validation**: Mute messages are validated when received, not when sent +3. **No Space Owner Bypass**: Space owners must have `user:mute` role permission (receiving side cannot verify owner status) +4. **Fail-Secure**: Reject mute when space data unavailable + +## Components + +### MuteUserModal (UI Component) + +**Location**: `src/components/modals/MuteUserModal.tsx` + +**Purpose**: Cross-platform modal UI for muting/unmuting users. + +**Key Features**: +- ✅ **Single component for both modes** - Uses `isUnmuting` prop +- ✅ **Cross-platform compatible** - Uses only primitives (Container, Text, FlexRow, Button, Modal) +- ✅ **Error handling** - Shows errors via Callout component +- ✅ **Success feedback** - Button changes to "User Muted!/User Unmuted!" then auto-closes + +**Props**: + +```tsx +interface MuteUserModalProps { + visible: boolean; + onClose: () => void; + onConfirm: (days: number) => Promise<void>; // days: 0 = forever, 1-365 = duration + userName: string; + userIcon?: string; + userAddress: string; + isUnmuting?: boolean; // Controls mute vs unmute mode +} +``` + +**Duration Input** (V2): +- Numeric input field (0-365 days) +- `0` = mute forever (permanent until manually unmuted) +- `1-365` = mute for specified number of days (auto-expires) +- Default: 1 day +- Error-proof: Silent clamp to 0-365 range, non-numeric characters filtered + +### useUserMuting (Business Logic Hook) + +**Location**: `src/hooks/business/user/useUserMuting.ts` + +**Purpose**: Encapsulates all mute/unmute business logic and network operations. + +**Key Functions**: + +#### `muteUser(targetUserId: string, days: number = 0)` + +- Validates required parameters (spaceId, currentUser, targetUserId) +- Converts `days` to `duration` in milliseconds (0 = undefined for forever) +- Calculates `expiresAt` timestamp (timestamp + duration) +- Creates `MuteMessage` with unique `muteId`, timestamp, and optional `duration` +- Broadcasts via `submitChannelMessage` +- Stores locally in IndexedDB with `expiresAt` for immediate effect +- Invalidates React Query cache + +#### `unmuteUser(targetUserId: string)` + +- Same validation as muteUser +- Creates `MuteMessage` with `action: 'unmute'` +- Broadcasts and removes local mute record + +**Return Values**: + +```tsx +{ + muting: boolean; // Loading state + muteUser: (targetUserId: string, days?: number) => Promise<void>; + unmuteUser: (targetUserId: string) => Promise<void>; +} +``` + +### useMutedUsers (Query Hook) + +**Location**: `src/hooks/queries/mutedUsers/useMutedUsers.ts` + +**Purpose**: React Query hook for fetching muted users list from IndexedDB. + +```typescript +const { data: mutedUsers } = useMutedUsers({ spaceId }); + +// Check if user is muted (must also check expiration) +const muteRecord = mutedUsers?.find(m => m.targetUserId === userAddress); +const isMuted = muteRecord + ? (!muteRecord.expiresAt || muteRecord.expiresAt > Date.now()) + : false; +``` + +## Data Flow + +### Muting a User + +``` +1. User clicks "Mute" in UserProfile + ↓ +2. MuteUserModal opens for confirmation + ↓ +3. User confirms → useUserMuting.muteUser() called + ↓ +4. MuteMessage created with unique muteId + timestamp + ↓ +5. Message broadcast via submitChannelMessage (network) + ↓ +6. Local IndexedDB updated immediately (muted_users store) + ↓ +7. React Query cache invalidated → UI updates +``` + +### Receiving Mute Messages + +``` +1. MessageService.addMessage receives mute message + ↓ +2. Validation checks: + - Reject if DM (mute is Space-only) + - Reject if self-mute (sender === target) + - Reject if space data unavailable (fail-secure) + - Reject if sender lacks user:mute permission + - Reject if duplicate muteId (replay protection) + ↓ +3. Store in IndexedDB muted_users table + ↓ +4. Invalidate mutedUsers query cache +``` + +### Filtering Muted Users' Messages + +``` +1. MessageService.addMessage receives any message + ↓ +2. Check if sender is muted in this space + ↓ +3. If muted: Drop message silently (never added to UI cache) + ↓ +4. If not muted: Process message normally +``` + +## Database Schema + +### muted_users Store (IndexedDB) + +**Location**: `src/db/messages.ts` (DB version 5) + +```typescript +type MutedUserRecord = { + spaceId: string; // Space where user is muted + targetUserId: string; // User who is muted + mutedAt: number; // Timestamp when muted + mutedBy: string; // User who performed the mute + lastMuteId: string; // For deduplication/replay protection + expiresAt?: number; // V2: When mute expires (undefined = forever) +}; + +// Composite key: [spaceId, targetUserId] +// Indexes: by_space (spaceId), by_mute_id (lastMuteId) +``` + +### Database Methods + +- `getMutedUsers(spaceId)`: Get all muted users in a space +- `isUserMuted(spaceId, userId)`: Check if specific user is muted (includes expiration check) +- `muteUser(spaceId, targetUserId, mutedBy, muteId, timestamp, expiresAt?)`: Add mute record +- `unmuteUser(spaceId, targetUserId)`: Remove mute record +- `getMuteByMuteId(muteId)`: For deduplication checks + +## Message Types + +**Location**: `src/api/quorumApi.ts` + +```typescript +export type MuteMessage = { + senderId: string; // Who performed the mute/unmute + type: 'mute'; + targetUserId: string; // Who got muted/unmuted + muteId: string; // UUID for deduplication (replay protection) + timestamp: number; // For ordering/conflict resolution + action: 'mute' | 'unmute'; // Mute or unmute action + duration?: number; // V2: Duration in milliseconds (undefined = forever) +}; +``` + +**Note**: Mute and unmute now use the same `MuteMessage` type with an `action` field to distinguish between operations. + +## Permission Integration + +### UI Permission Checking + +**Location**: `src/utils/channelPermissions.ts` + +```typescript +canMuteUser(): boolean { + const { channel } = this.context; + + // NOTE: NO isSpaceOwner bypass - receiving side can't verify owner status + // Space owners must assign themselves a role with user:mute permission + + // 1. Read-only channels: Only managers can mute + if (channel?.isReadOnly) { + return this.isReadOnlyChannelManager(); + } + + // 2. Regular channels: Check for user:mute permission via roles + return this.hasTraditionalRolePermission('user:mute'); +} +``` + +### Receiving-Side Permission Validation + +**Location**: `src/services/MessageService.ts` + +```typescript +// Check permission - sender must have user:mute via roles +const hasPermission = space.roles?.some( + (role) => + role.members?.includes(muteContent.senderId) && + role.permissions?.includes('user:mute') +); + +if (!hasPermission) { + return; // Reject silently +} +``` + +## UI Behavior + +### UserProfile Button States + +**Location**: `src/components/user/UserProfile.tsx` + +| Context | User State | Button Shown | +|---------|------------|--------------| +| Viewing others | Not muted | "Mute" (volume-off icon) | +| Viewing others | Muted | "Unmute" (volume icon) | +| Own profile | Not muted | Hidden (prevent self-muting) | +| Own profile | Muted | "Unmute" (can unmute self) | + +### MessageComposer for Muted Users + +**Location**: `src/components/space/Channel.tsx` + +Muted users see a disabled composer with message showing remaining time: +- Timed mute: "You are muted for 3 days" / "You are muted for 24 hours" +- Forever mute: "You have been muted in this Space" + +**Auto-Refresh**: When a timed mute expires, a `setTimeout` automatically invalidates the muted users cache, enabling the composer without requiring a page refresh. + +**Helper Function**: `formatMuteRemaining(expiresAt)` in `src/utils/dateFormatting.ts`: +- Shows "X days" for mutes > 1 day remaining +- Shows "X hours" for mutes ≤ 1 day remaining +- Uses `Math.ceil` for user-friendly rounding (e.g., 23h 45m → "24 hours") + +## Security Considerations + +### Implemented Mitigations + +| Threat | Mitigation | +|--------|------------| +| Replay attacks | `muteId` + deduplication check | +| Race conditions | Timestamp-based last-write-wins | +| Unauthorized mute | Receiving-side permission validation | +| Cross-space leaks | Per-space scoping with spaceId | +| Space owner bypass | No isSpaceOwner bypass (can't verify on receive) | +| Self-mute DoS | Self-mute rejected | + +### Design Decisions + +- **No public announcements**: Other users don't see "X was muted" messages (prevents harassment) +- **Muted user feedback**: They see disabled composer - clear notification without public shaming +- **Consistent enforcement**: Both sending and receiving sides check permissions + +## Related Files + +### Core Implementation +- `src/hooks/business/user/useUserMuting.ts` - Business logic hook +- `src/hooks/queries/mutedUsers/` - React Query hooks +- `src/components/modals/MuteUserModal.tsx` - UI modal +- `src/services/MessageService.ts` - Receive-side validation +- `src/db/messages.ts` - Database layer + +### Permission System +- `src/utils/channelPermissions.ts` - canMuteUser method +- `src/api/quorumApi.ts` - Permission and message types +- `src/components/modals/SpaceSettingsModal/Roles.tsx` - Role management UI + +### UI Integration +- `src/components/user/UserProfile.tsx` - Mute/unmute button +- `src/components/space/Channel.tsx` - MessageComposer disabled state +- `src/components/context/ModalProvider.tsx` - Modal state management + +### Supporting Files +- `src/utils/canonicalize.ts` - Message canonicalization for mute (includes `duration`) +- `src/utils/dateFormatting.ts` - `formatMuteRemaining()` helper for time display +- `src/hooks/business/ui/useModalState.ts` - MuteUserTarget interface + +## Known Limitations + +### Space Owner Can Be Muted + +Users with `user:mute` permission can mute the space owner. This differs from kick (where space owners are protected). + +**Why**: The receiving side cannot verify space ownership - there's no `Space.ownerAddress` exposed to clients for privacy reasons. Adding owner protection would require exposing owner identity, which conflicts with the privacy-first design. + +**Mitigation**: Space owners can assign themselves a role with `user:mute` permission and unmute themselves if muted by a moderator. This is an acceptable trade-off since: +- Space owners control role assignments +- The effect is temporary (reversible via self-unmute) +- Malicious moderators can be removed by the owner + +### Mute Duration (V2 - Implemented) + +Mute duration is now supported: +- **0 days** = Forever (permanent until manually unmuted) +- **1-365 days** = Timed mute (auto-expires after specified duration) + +Duration is calculated client-side using `setTimeout`. Note: JS `setTimeout` max is ~24.8 days, but this is acceptable since users typically refresh/restart the app before then. + +### Client-Enforced Only + +Mute is enforced by each client independently. A malicious custom client could choose to ignore mute state and display muted users' messages. However, honest clients will still hide those messages. + +## Comparison: Mute vs Kick + +| Aspect | Mute | Kick | +|--------|------|------| +| **Enforcement** | Client-side (receiving validation) | Protocol-level (ED448 signed) | +| **Permission** | `user:mute` role permission | Space owner only | +| **Reversible** | Yes (unmute or auto-expires) | Requires re-invite | +| **Duration** | 0-365 days (0 = forever) | Permanent | +| **Effect** | Messages hidden from others | User removed from space | +| **Visibility** | Silent (user knows via disabled composer) | Visible kick message | +| **Space owner bypass** | No (can't verify on receive) | Yes (protocol verifies) | + +## Related Documentation + +- **[Kick User System](./kick-user-system.md)** - Protocol-enforced user removal +- **[Space Permissions Architecture](../space-permissions/space-permissions-architecture.md)** - Permission system overview +- **[Space Roles System](../space-permissions/space-roles-system.md)** - Role-based permissions +- **[Security Architecture](./security.md)** - Defense-in-depth validation +- **[Background Action Queue](../../tasks/background-action-queue.md)** - Future: offline resilience and retry for mute operations + +--- + + +*Status: Production Ready* +*Cross-Platform: ✅ Web + Mobile Compatible* diff --git a/.agents/docs/features/notification-indicators-system.md b/.agents/docs/features/notification-indicators-system.md new file mode 100644 index 000000000..24e92d2a0 --- /dev/null +++ b/.agents/docs/features/notification-indicators-system.md @@ -0,0 +1,310 @@ +--- +type: doc +title: Notification Indicators System +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2026-01-06T00:00:00.000Z +--- + +# Notification Indicators System + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Overview + +Discord-style visual notification system providing feedback about unread content across the application. The system uses two types of indicators: + +1. **Unread Dots/Toggles**: Visual markers showing unread messages exist (no count) +2. **Notification Bubbles**: Numbered badges showing mention + reply counts + +These indicators appear on: +- **NavMenu**: Space icons, folder icons, DM icon +- **ChannelList**: Individual channel items +- **DirectMessageContactsList**: Individual DM contacts + +--- + +## Architecture + +### Indicator Types by Location + +| Location | Unread Indicator | Notification Bubble | +|----------|------------------|---------------------| +| **Space Icon** | White toggle bar on left | Accent-colored count badge | +| **Folder Icon** | White toggle bar on left | Sum of space counts | +| **DM Icon** | White toggle bar on left | Unread conversation count | +| **Channel Item** | Dot next to icon | Combined mention+reply count | +| **DM Contact** | Bold name + dot | N/A | + +### Data Flow + +``` +New message arrives + ↓ +MessageService.ts saves message + invalidates caches + ↓ +React Query refetches counts + ↓ +Components re-render with updated indicators +``` + +``` +User reads channel/DM (stays 2+ seconds) + ↓ +useUpdateReadTime mutation saves lastReadTimestamp + ↓ +Cache invalidation triggers refetch + ↓ +Components re-render, indicators disappear +``` + +### Core Data Model + +Both channels and DMs use timestamp-based unread detection: + +- `conversation.timestamp` - Auto-updated on every message save +- `conversation.lastReadTimestamp` - Updated when user reads content +- **Unread check**: `(lastReadTimestamp ?? 0) < timestamp` + +This provides O(1) unread status per conversation. + +--- + +## Key Components + +### Space Icon (NavMenu) + +**Files**: `src/components/navbar/SpaceIcon.tsx`, `NavMenu.tsx` + +| Indicator | Data Source | Condition | +|-----------|-------------|-----------| +| Toggle bar | `spaceUnreadCounts[spaceId]` | Any channel has unread messages | +| Count bubble | `spaceMentionCounts + spaceReplyCounts` | Mentions or replies exist | + +**Context Menu**: Right-click (desktop) or long-press (touch) shows "Mark All as Read" when any notifications exist (unread messages, mentions, or replies). This marks ALL channels in the space as read, including muted channels. + +### Folder Icon (NavMenu) + +**Files**: `src/components/navbar/FolderButton.tsx`, `FolderContainer.tsx` + +| Indicator | Data Source | Condition | +|-----------|-------------|-----------| +| Toggle bar | `spaces.some(s => s.notifs > 0)` | Any space in folder has unreads | +| Count bubble | Sum of all `spaceMentionCounts` in folder | Any space has mentions/replies | + +When folder is expanded, indicators hide (individual space icons show their own). + +### DM Icon (NavMenu) + +**File**: `src/components/navbar/NavMenu.tsx` + +| Indicator | Data Source | Condition | +|-----------|-------------|-----------| +| Toggle bar | `dmUnreadCount > 0` | Any DM has unread messages | +| Count bubble | `useDirectMessageUnreadCount()` | Unread conversations exist | + +**Context Menu**: Right-click (desktop) or long-press (touch) shows "Mark All as Read" when unread DMs exist. + +### Channel Item (ChannelList) + +**Files**: `src/components/space/ChannelItem.tsx`, `ChannelList.tsx` + +| Indicator | Data Source | Condition | +|-----------|-------------|-----------| +| Unread dot | `unreadCounts[channelId]` | Channel has unread messages | +| Count bubble | `mentionCounts + replyCounts` | Mentions or replies exist | + +### DM Contact (DirectMessageContactsList) + +**Files**: `src/components/direct/DirectMessageContact.tsx`, `DirectMessageContactsList.tsx` + +| Indicator | Data Source | Condition | +|-----------|-------------|-----------| +| Bold name + dot | `lastReadTimestamp < timestamp` | Unread messages exist | + +--- + +## Hooks Reference + +### Count Hooks + +| Hook | Purpose | Query Key | +|------|---------|-----------| +| `useChannelUnreadCounts` | Per-channel unread status | `['unread-counts', 'channel', spaceId, ...]` | +| `useSpaceUnreadCounts` | Per-space unread status | `['unread-counts', 'space', ...]` | +| `useDirectMessageUnreadCount` | Total DM unread count | `['unread-counts', 'direct-messages', ...]` | +| `useChannelMentionCounts` | Per-channel mention counts | `['mention-counts', 'channel', spaceId, ...]` | +| `useSpaceMentionCounts` | Per-space mention counts | `['mention-counts', 'space', ...]` | +| `useReplyNotificationCounts` | Per-channel reply counts | `['reply-counts', 'channel', spaceId, ...]` | +| `useSpaceReplyCounts` | Per-space reply counts | `['reply-counts', 'space', ...]` | + +### Update Hook + +| Hook | Purpose | Invalidates | +|------|---------|-------------| +| `useUpdateReadTime` | Save read timestamp + invalidate caches | All related count caches | + +--- + +## Cache Invalidation + +### When Reading Content (Indicators Decrease) + +**File**: `src/hooks/business/conversations/useUpdateReadTime.ts` + +After user stays in channel/DM for 2+ seconds: + +```typescript +// Invalidation order in onSuccess: +1. ['Conversation', conversationId] +2. ['mention-counts', 'channel', spaceId] +3. ['mention-counts', 'space'] +4. ['reply-counts', 'channel', spaceId] +5. ['reply-counts', 'space'] +6. ['unread-counts', 'channel', spaceId] +7. ['unread-counts', 'space'] +8. ['unread-counts', 'direct-messages'] +9. ['Conversations', 'direct'] (DMs only) +``` + +### When New Messages Arrive (Indicators Increase) + +**File**: `src/services/MessageService.ts` + +```typescript +// For messages with mentions +['mention-counts', 'space'] // Space-level (SpaceIcon bubble) +['mention-counts', 'channel', spaceId] // Channel-level (ChannelList bubble) +['mention-notifications', spaceId] +['unread-counts', 'channel', spaceId] +['unread-counts', 'space'] + +// For reply messages +['reply-counts', 'space'] // Space-level (SpaceIcon bubble) +['reply-counts', 'channel', spaceId] // Channel-level (ChannelList bubble) +['reply-notifications', spaceId] + +// For DM messages +['unread-counts', 'direct-messages'] +``` + +### Space "Mark All as Read" (Context Menu) + +**File**: `src/components/navbar/NavMenu.tsx` (`handleMarkSpaceAsRead`) + +When "Mark All as Read" is selected from a Space Icon context menu: + +1. Gets all channel IDs from the space's groups +2. Saves `lastReadTimestamp` for each channel via `messageDB.saveReadTime()` +3. Invalidates caches: + - Space-level: `['mention-counts', 'space']`, `['reply-counts', 'space']`, `['unread-counts', 'space']` + - Channel-level: `['mention-counts', 'channel', spaceId]`, `['reply-counts', 'channel', spaceId]`, `['unread-counts', 'channel', spaceId]` + - NotificationPanel: `['mention-notifications', spaceId]`, `['reply-notifications', spaceId]` + - Conversations: `['conversation']` + +This ensures SpaceIcon indicators, ChannelList indicators, and NotificationPanel all update correctly. + +### NotificationPanel "Mark All as Read" + +**File**: `src/components/notifications/NotificationPanel.tsx` (`handleMarkAllRead`) + +When "Mark All as Read" button is clicked in NotificationPanel: + +1. Gets channels that have notifications from current list +2. Saves `lastReadTimestamp` for each channel via `messageDB.saveReadTime()` +3. Invalidates caches (same pattern as Space context menu): + - Space-level: `['mention-counts', 'space']`, `['reply-counts', 'space']`, `['unread-counts', 'space']` + - Channel-level: `['mention-counts', 'channel', spaceId]`, `['reply-counts', 'channel', spaceId]`, `['unread-counts', 'channel', spaceId]` + - NotificationPanel: `['mention-notifications', spaceId]`, `['reply-notifications', spaceId]` + - Conversations: `['conversation']` + +### DM "Mark All as Read" Context + +**File**: `src/context/DmReadStateContext.tsx` + +For immediate UI updates when marking all DMs as read, a React Context provides state-driven re-renders: + +- `markAllReadTimestamp`: Timestamp when bulk mark-as-read was triggered +- `markAllAsRead()`: Sets timestamp, triggers immediate UI update +- Components check this timestamp to override unread calculation + +--- + +## React Query Settings + +| Setting | Value | Rationale | +|---------|-------|-----------| +| Stale Time | 90 seconds | Balance freshness with performance | +| Refetch on Window Focus | Yes | Update when user returns to app | +| Early Exit | 10 notifications | UI shows "9+" beyond this | + +--- + +## Technical Decisions + +### Timestamp-Based Unread Detection +**Decision**: Use `lastReadTimestamp < conversation.timestamp` instead of iterating messages +**Rationale**: O(1) comparison vs O(n) cursor iteration, ~90% complexity reduction + +### Separate Cache Keys for Mentions vs Replies +**Decision**: Independent query keys for mentions and replies +**Rationale**: Allows independent invalidation, prevents race conditions + +### React Context for DM Bulk Operations +**Decision**: Use `DmReadStateContext` for "Mark All as Read" instead of pure cache invalidation +**Rationale**: `useSuspenseInfiniteQuery` doesn't reliably re-render on `setQueryData` + +### 2-Second Reading Delay +**Decision**: Wait 2 seconds before marking content as read +**Rationale**: Prevents false positives when quickly scrolling through channels + +--- + +## File Reference + +``` +src/ +├── context/ +│ └── DmReadStateContext.tsx # DM bulk read state +├── hooks/ +│ └── business/ +│ ├── conversations/ +│ │ └── useUpdateReadTime.ts # Read time mutation + invalidation +│ ├── mentions/ +│ │ ├── useChannelMentionCounts.ts # Channel mention counts +│ │ └── useSpaceMentionCounts.ts # Space mention counts +│ ├── replies/ +│ │ ├── useReplyNotificationCounts.ts # Channel reply counts +│ │ └── useSpaceReplyCounts.ts # Space reply counts +│ └── messages/ +│ ├── useChannelUnreadCounts.ts # Channel unread status +│ ├── useSpaceUnreadCounts.ts # Space unread status +│ └── useDirectMessageUnreadCount.ts # DM unread count +├── components/ +│ ├── navbar/ +│ │ ├── SpaceIcon.tsx # Space icon with indicators +│ │ ├── FolderButton.tsx # Folder icon with indicators +│ │ ├── FolderContainer.tsx # Folder aggregation logic +│ │ └── NavMenu.tsx # Wires all NavMenu indicators +│ ├── notifications/ +│ │ └── NotificationPanel.tsx # Mark all read button +│ ├── space/ +│ │ ├── ChannelList.tsx # Wires channel indicators +│ │ └── ChannelItem.tsx # Channel item with indicators +│ └── direct/ +│ ├── DirectMessageContactsList.tsx # DM list with indicators +│ └── DirectMessageContact.tsx # DM contact with indicators +└── services/ + └── MessageService.ts # Cache invalidation on new messages +``` + +--- + +## Related Documentation + +- [Mention Notification System](./mention-notification-system.md) - Mention detection, extraction, rendering +- [Channel/Space Mute System](./channel-space-mute-system.md) - Mute settings affect counts +- [Space Folders](./space-folders.md) - Folder aggregation behavior + +--- diff --git a/.agents/docs/features/offline-support.md b/.agents/docs/features/offline-support.md new file mode 100644 index 000000000..677710f0e --- /dev/null +++ b/.agents/docs/features/offline-support.md @@ -0,0 +1,278 @@ +--- +type: doc +title: Offline Support +status: done +ai_generated: true +created: 2026-01-09T00:00:00.000Z +updated: 2025-12-20T00:00:00.000Z +--- + +# Offline Support + +> **⚠️ AI-Generated**: May contain errors. Verify before use. + +## Overview + +The application provides comprehensive offline support allowing users to continue using the app when network connectivity is unavailable. This includes viewing cached data, navigating between conversations, and queuing actions for later execution. + +Offline support is built on three complementary systems: +1. **Offline Detection** - Combines WebSocket connection state with `navigator.onLine` for reliable detection +2. **React Query networkMode configuration** - Ensures IndexedDB queries run regardless of network state +3. **Action Queue** - Persists and retries user actions when connectivity is restored + +## Architecture + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ OFFLINE CAPABILITIES │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌─────────────────┐ ┌─────────────────┐ ┌─────────────┐ │ +│ │ Navigation │ │ Data Viewing │ │ Actions │ │ +│ │ (IndexedDB) │ │ (IndexedDB) │ │ (Queued) │ │ +│ └────────┬────────┘ └────────┬────────┘ └──────┬──────┘ │ +│ │ │ │ │ +│ ▼ ▼ ▼ │ +│ ┌─────────────────────────────────────────────────────────────┐│ +│ │ IndexedDB ││ +│ │ - Spaces, Channels, Messages ││ +│ │ - Conversations, Encryption States ││ +│ │ - User Config, Bookmarks ││ +│ │ - Action Queue Tasks ││ +│ └─────────────────────────────────────────────────────────────┘│ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +## Offline Detection + +Offline detection combines WebSocket connection state with `navigator.onLine` for reliable detection across all scenarios: + +```typescript +// ActionQueueContext.tsx +const { connected: wsConnected } = useWebSocket(); +const [navOnline, setNavOnline] = useState(navigator.onLine); + +// Offline if EITHER signal says offline +const isOnline = wsConnected && navOnline; +``` + +The WebSocket `onclose` event fires reliably when network connectivity is lost, while `navigator.onLine` handles browser-specific offline triggers (DevTools, airplane mode). + +### Detection Timing + +| Event | Detection Time | Notes | +|-------|----------------|-------| +| DevTools "Offline" mode | Instant | Browser kills all connections immediately | +| Wi-Fi disconnect | 10-30 seconds | TCP timeout before WebSocket `onclose` fires | +| Airplane mode | Instant | OS-level network kill | +| Server down | 10-30 seconds | Same as Wi-Fi disconnect | + +The delay for Wi-Fi disconnect is standard TCP timeout behavior at the OS level. + +### Browser & Platform Compatibility + +| Platform | Status | Notes | +|----------|--------|-------| +| Chrome | ✅ Supported | WebSocket API fully supported | +| Brave | ✅ Supported | Chromium-based | +| Firefox | ✅ Supported | WebSocket and Navigator APIs fully supported | +| Safari | ✅ Supported | WebSocket and Navigator APIs fully supported | +| Edge | ✅ Supported | Chromium-based | +| Electron | ✅ Supported | Uses Chromium | +| Mobile (React Native) | ⚠️ Unverified | Different WebSocket implementation | + +### Components + +| File | Purpose | +|------|---------| +| [ActionQueueContext.tsx](src/components/context/ActionQueueContext.tsx) | Combines WebSocket + Navigator signals for `isOnline` state | +| [WebsocketProvider.tsx](src/components/context/WebsocketProvider.tsx) | Provides `connected` state from WebSocket events | +| [ActionQueueService.ts](src/services/ActionQueueService.ts) | Uses `isOnlineCallback` to check connectivity before queue processing | +| [OfflineBanner.tsx](src/components/ui/OfflineBanner.tsx) | Displays banner when offline | + +--- + +## React Query Configuration + +All IndexedDB-based query hooks use `networkMode: 'always'` to ensure they run regardless of browser network state: + +```typescript +return useSuspenseQuery({ + queryKey: buildSpaceKey({ spaceId }), + queryFn: buildSpaceFetcher({ messageDB, spaceId }), + refetchOnMount: true, + networkMode: 'always', // This query uses IndexedDB, not network +}); +``` + +### Configured Hooks + +| Hook | Data Source | Purpose | +|------|-------------|---------| +| `useSpace` | IndexedDB | Space metadata | +| `useSpaces` | IndexedDB | List of all spaces | +| `useSpaceMembers` | IndexedDB | Space member list | +| `useSpaceOwner` | IndexedDB | Space ownership | +| `useMessages` | IndexedDB | Channel/DM messages | +| `useConfig` | IndexedDB | User configuration | +| `useConversation` | IndexedDB | Single conversation metadata | +| `useConversations` | IndexedDB | Conversation list | +| `useEncryptionStates` | IndexedDB | DM encryption states | +| `useMutedUsers` | IndexedDB | Muted user list | +| `useBookmarks` | IndexedDB | User bookmarks | + +## Space Navigation (Fully Offline) + +Space navigation works completely offline because all required data is stored in IndexedDB. + +### Data Flow + +``` +User clicks Space icon + │ + ▼ +navigate('/space/:spaceId/:channelId') + │ + ▼ +Space.tsx renders + ├─► useSpace({ spaceId }) → IndexedDB ✅ + ├─► useSpaceMembers({ spaceId }) → IndexedDB ✅ + └─► useMessages({ spaceId, channelId }) → IndexedDB ✅ +``` + +### What Works Offline + +| Feature | Status | Notes | +|---------|--------|-------| +| Navigate between spaces | ✅ Works | All data in IndexedDB | +| View channel messages | ✅ Works | Messages cached locally | +| See member list | ✅ Works | Members cached locally | +| Send messages | ⚠️ Queued | Via Action Queue | +| Reactions | ⚠️ Queued | Via Action Queue | + +## DM Navigation (Graceful Degradation) + +DM navigation uses IndexedDB for cached data with a fallback chain for registration data: + +```typescript +// Uses useQuery instead of useSuspenseQuery +const { data: registration } = useRegistrationOptional({ address: address! }); + +// Fallback chain for user display info +const members = useMemo(() => { + if (conversation?.conversation) { + // Priority 1: Use conversation data from IndexedDB (available offline) + return { displayName, userIcon, address }; + } else if (registration?.registration) { + // Priority 2: Use registration data from network API + return { displayName, userIcon, address }; + } else { + // Priority 3: Offline fallback - use address as identifier + return { displayName: 'Unknown User', userIcon: defaultIcon, address }; + } +}, [registration, conversation, address]); +``` + +### useRegistrationOptional Hook + +A non-suspense version of `useRegistration` that fails gracefully when offline: + +```typescript +// src/hooks/queries/registration/useRegistrationOptional.ts +const useRegistrationOptional = ({ address }: { address: string }) => { + const { apiClient } = useQuorumApiClient(); + + return useQuery({ + queryKey: buildRegistrationKey({ address }), + queryFn: buildRegistrationFetcher({ apiClient, address }), + networkMode: 'always', // Allow query to run offline (will fail gracefully) + staleTime: Infinity, // Don't refetch if we have data + gcTime: Infinity, // Keep in cache forever + retry: false, // Don't retry failed requests when offline + }); +}; +``` + +### What Works Offline + +| Feature | Status | Notes | +|---------|--------|-------| +| View DM conversation list | ✅ Works | `useConversations` uses IndexedDB | +| Navigate to DM conversation | ✅ Works | Component renders with fallback | +| View message history | ✅ Works | Messages cached in IndexedDB | +| See user display name | ⚠️ Partial | Falls back to cached conversation data or "Unknown User" | +| See user avatar | ⚠️ Partial | Falls back to cached conversation data or default | +| Send messages | ❌ Blocked | Requires registration data for encryption | +| Reactions/Edits/Deletes | ❌ Blocked | Requires registration data | + +### Sending Limitation + +DM message sending requires registration data for Double Ratchet encryption. When offline, the send handler returns early: + +```typescript +if (!self?.registration || !registration?.registration) { + console.warn('Cannot send message: registration data unavailable (offline?)'); + return; +} +``` + +## Action Queue Integration + +The [Action Queue](action-queue.md) handles offline actions by persisting them to IndexedDB and processing when connectivity is restored. + +### Supported Offline Actions + +**Space Actions** (fully offline-capable): +- Send channel message +- Add/remove reactions +- Pin/unpin messages +- Edit/delete messages +- Save user config +- Update space settings + +**DM Actions** (require prior online session): +- Send direct message +- Add/remove reactions +- Edit/delete messages + +### Visibility + +The offline banner displays queued action count: +``` +┌─────────────────────────────────────────┐ +│ ⚠️ You're offline (3 actions queued) │ +└─────────────────────────────────────────┘ +``` + +## Technical Notes + +- **networkMode: 'always'** - IndexedDB queries use this setting because they don't depend on network connectivity. React Query's default `networkMode: 'online'` would unnecessarily block local database queries. + +- **useRegistrationOptional** - Uses a non-suspense query with fallback instead of caching registration in IndexedDB. This provides graceful degradation without schema migration complexity. + +- **DM sends require network** - DM encryption requires the counterparty's registration data (public keys, inbox addresses). This data must be fetched from the network, so DM sending cannot be fully queued offline. + +## Key Components + +| File | Purpose | +|------|---------| +| [useRegistrationOptional.ts](src/hooks/queries/registration/useRegistrationOptional.ts) | Non-suspense registration query for offline resilience | +| [DirectMessage.tsx](src/components/direct/DirectMessage.tsx) | DM component with fallback logic | +| [ActionQueueService.ts](src/services/ActionQueueService.ts) | Offline action persistence and retry | +| [OfflineBanner.tsx](src/components/ui/OfflineBanner.tsx) | Offline status and queue count display | + +## Known Limitations + +1. **DM sending requires network** - Cannot send DMs when offline due to encryption requirements +2. **New DM conversations** - Cannot start new conversations offline (no registration data) +3. **Profile updates** - Changes to other users' profiles won't be visible until back online +4. **First-time navigation** - Navigating to a conversation never opened online shows "Unknown User" + +## Related Documentation + +- [Action Queue](action-queue.md) - Offline action handling and retry logic +- [DM Offline Navigation Bug (Solved)](../../bugs/.solved/dm-offline-navigation-empty.md) - Implementation details +- [Space Offline Navigation Bug (Solved)](../../bugs/.solved/offline-navigation-issues.md) - Root cause analysis + +--- diff --git a/.agents/docs/features/primitives/01-introduction-and-concepts.md b/.agents/docs/features/primitives/01-introduction-and-concepts.md new file mode 100644 index 000000000..6e7c06280 --- /dev/null +++ b/.agents/docs/features/primitives/01-introduction-and-concepts.md @@ -0,0 +1,428 @@ +--- +type: doc +title: Introduction and Core Concepts +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-10-14T00:00:00.000Z +--- + +# Introduction and Core Concepts + +**[← Back to Primitives INDEX](./INDEX.md)** + + +## What Are Primitive Components? + +Primitive components are the foundation of our cross-platform UI system. They provide a unified API that works seamlessly across web and React Native, abstracting platform differences while enabling platform-specific optimizations. + +## ⚠️ **Platform Requirements - IMPORTANT** + +### **Mobile (React Native)** + +- ✅ **Primitives are MANDATORY** - React Native requires specific components +- ❌ **Cannot use HTML elements** (`<div>`, `<span>`, `<p>`, etc.) +- ❌ **Cannot use CSS classes** - Must use style objects + +### **Web** + +- 🤔 **Primitives are RECOMMENDED** but not required +- ✅ **Can still use raw HTML** when it makes sense +- ✅ **Mix primitives with existing HTML/CSS** during migration + +### **Shared Components** + +- ✅ **Use primitives ONLY** if component must work on both web AND mobile +- 🎯 **Otherwise, choose based on practicality** (see [When to Use Primitives](./03-when-to-use-primitives.md)) + +## 🎯 **Core Philosophy** + +### **Mobile-First Cross-Platform Development** + +Every component is designed to work on both desktop and mobile from day one. This isn't "responsive web design" – it's true cross-platform development with React Native. + +### **Consistency Through Abstraction** + +Rather than learning different APIs for web vs mobile, you learn one API that works everywhere. Platform differences are handled internally. + +### **Progressive Enhancement** + +Start with basic functionality that works everywhere, then add platform-specific features (haptic feedback, keyboard types, etc.) where beneficial. + +--- + +## 🏗️ **Architecture Overview** + +### **File Structure Pattern** + +``` +src/components/primitives/Button/ +├── index.ts # Exports the appropriate version +├── Button.web.tsx # Web-specific implementation +├── Button.native.tsx # React Native implementation +├── Button.scss # Web styles +└── types.ts # Shared type definitions +``` + +### **Import Resolution** + +```tsx +// This automatically imports the right version +import { Button } from '../components/primitives/Button'; + +// Metro (React Native) picks Button.native.tsx +// Webpack (Web) picks Button.web.tsx +``` + +### **Shared Type System** + +```tsx +// types.ts - shared across platforms +export interface BaseButtonProps { + type?: + | 'primary' + | 'secondary' + | 'light' + | 'light-outline' + | 'subtle' + | 'danger' + | 'unstyled'; + size?: 'small' | 'normal' | 'large'; + disabled?: boolean; + fullWidth?: boolean; + iconName?: IconName; + iconOnly?: boolean; + onClick: () => void; + children?: React.ReactNode; +} + +// Platform-specific props are conditionally added +export interface NativeButtonProps extends BaseButtonProps { + hapticFeedback?: boolean; // Only on native + accessibilityLabel?: string; + fullWidthWithMargin?: boolean; +} +``` + +--- + +## 🎨 **Design System Integration** + +### **Semantic Color Variables** + +Instead of hardcoded colors, use semantic variables that automatically adapt to light/dark themes: + +```tsx +// ❌ Hardcoded colors +<Container style={{ backgroundColor: '#ffffff', borderColor: '#e5e7eb' }}> + +// ✅ Semantic colors (Quilibrium system) +<Container className="bg-surface-1 border-default"> + +// ✅ Or with theme variables +<Container style={{ backgroundColor: theme.colors.bg.surface1 }}> +``` + +### **Consistent Spacing System** + +```tsx +// Standardized gap values used across Quilibrium +gap: 'none' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' + +// Maps to consistent pixel values +// xs: 4px, sm: 8px, md: 16px, lg: 24px, xl: 32px + +// Real usage in UserProfile component: +<Flex gap="sm" align="center"> + <Icon name="user" /> + <Text variant="strong">User Profile</Text> +</Flex> +``` + +### **Typography Hierarchy** + +```tsx +// Semantic typography components in Quilibrium +<Text variant="strong" size="xl">Main page title</Text> +<Text variant="strong" size="lg">Section title</Text> +<Text variant="default">Body content</Text> +<Text variant="subtle" size="sm">Helper text</Text> +<Text variant="error">Error message</Text> + +// Real usage from SwitchTestScreen: +<Text size="sm" variant="default"> + Basic Switch ({basicSwitch ? 'ON' : 'OFF'}) +</Text> +``` + +--- + +## 🚀 **Key Benefits** + +### **1. Development Speed** + +- Write once, works everywhere +- No need to learn platform-specific APIs +- Consistent behavior reduces debugging + +### **2. Design Consistency** + +- Unified color system and spacing +- Consistent interactive states (hover, focus, disabled) +- Automatic theme switching (light/dark) + +### **3. Maintainability** + +- Bug fixes apply to all platforms +- API changes happen in one place +- Predictable component behavior + +### **4. Accessibility** + +- Built-in screen reader support +- Proper touch targets on mobile +- Keyboard navigation on web + +### **5. Future-Proof** + +- Easy to add new platforms (e.g., desktop apps) +- Component evolution without breaking changes +- A/B testing and analytics integration points + +--- + +## 🎯 **Container + Layout Architecture** + +### **Core Pattern: Separation of Concerns** + +Our architecture separates **styling** from **layout** for maximum flexibility and consistency: + +```tsx +// ✅ REAL EXAMPLE: From UserProfile component +<Container + className="user-profile" + onClick={(e: React.MouseEvent) => e.stopPropagation()} +> + <Flex gap="sm" align="center"> + <Icon name="user" /> + <Text variant="strong">{user.name}</Text> + </Flex> + + <Flex gap="xs" align="center"> + <Text variant="subtle" size="sm"> + Address: + </Text> + <ClickToCopyContent text={user.address} tooltipText="Copy address"> + <Text variant="subtle" className="font-mono"> + {user.address.slice(0, 8)}... + </Text> + </ClickToCopyContent> + </Flex> +</Container> +``` + +### **Component Responsibilities** + +#### **Container (Styling Container)** + +- Visual styling: colors, borders, shadows, border radius +- Click/press handlers that work cross-platform +- Background colors and themed styling +- CSS classes and inline styles + +#### **Flex Primitive (Layout)** + +- Content organization and spacing (gap: 'xs' | 'sm' | 'md' | 'lg' | 'xl') +- Direction: direction="row" (default) | "column" +- Alignment: align="center" | "start" | "end" | "stretch" +- Justification: justify="start" | "center" | "between" | "around" +- Responsive behavior and wrapping + +#### **Text Components (Content)** + +- Typography and text rendering +- Built-in spacing props for better mobile experience +- Semantic variants (strong, subtle, error, etc.) + +### **Why This Pattern Works** + +1. **Predictable Spacing**: Gap system eliminates margin calculation issues +2. **Platform Consistency**: Flex primitives handle platform differences automatically +3. **Maintainable**: Clear separation between styling and layout concerns +4. **Flexible**: Mix and match containers with different layout patterns + +--- + +## 📱 **Mobile-First Enhancements** + +### **Enhanced Text Component** + +React Native text handling is different from web. Our Text primitive solves common issues: + +```tsx +// ✅ Real example: Enhanced Text from Quilibrium codebase +<Text variant="strong" size="lg" marginBottom={8}> + Switch Component Demo +</Text> +<Text variant="subtle" align="center"> + Cross-platform toggle switch with multiple sizes and variants +</Text> + +// ❌ Web approach (doesn't work well on mobile) +<div style={{ marginBottom: 16 }}> + <p>Text without proper mobile optimizations</p> +</div> +``` + +**Enhancements:** + +- **Automatic line height** (1.4x font size) for better readability +- **Built-in spacing props** to reduce View wrapper verbosity +- **Better Android alignment** with `includeFontPadding: false` +- **Semantic components** (Paragraph, Label, etc.) for common patterns + +### **Touch-Optimized Interactions** + +```tsx +// Real example from UserProfile component +<Button + type="primary" + size="small" + onClick={() => sendMessage(user)} + iconName="paper-plane" + tooltip="Send direct message" +> + Message +</Button> + +// With mobile-specific props +<Button + type="primary" + onClick={handleSave} + hapticFeedback={true} // Tactile feedback on mobile + accessibilityLabel="Save user profile" // Screen reader support +> + Save Profile +</Button> +``` + +### **Platform-Specific Input Types** + +```tsx +// Real examples from Quilibrium forms +<Input + type="email" + placeholder="Enter email address" + keyboardType="email-address" // Shows @ key on mobile keyboards + autoComplete="email" // Enables autofill + returnKeyType="done" // Custom return key label + error={emailError} +/> + +<Input + type="password" + placeholder="Enter passphrase" + secureTextEntry={true} // Hide password on mobile + autoComplete="current-password" + error={passwordError} +/> +``` + +--- + +## 🔄 **Migration Strategy** + +### **From Web to Cross-Platform** + +1. **Identify Platform-Specific Code** + - HTML elements (`<div>`, `<button>`, `<input>`) + - CSS classes and styling + - Web-only event handlers + +2. **Map to Primitive Equivalents** + - `<div>` → `<Container>` (styling) + `<Flex>` (layout) + - `<button>` → `<Button>` with type, size, and icon props + - `<input>` → `<Input>` with error handling and mobile keyboard types + - CSS classes → component props and semantic variants + +3. **Use Semantic Components** + - `<p>` → `<Text variant="default">` + - `<h2>` → `<Text variant="strong" size="lg">` + - `<strong>` → `<Text variant="strong">` + - `<small>` → `<Text variant="subtle" size="sm">` + +4. **Test on Mobile** + - Run in React Native simulator + - Verify touch targets are appropriate size + - Test keyboard behavior and accessibility + +### **Gradual Adoption** + +You don't need to convert everything at once: + +```tsx +// ✅ Real example: Mix primitives with existing SCSS during migration +<div className="user-profile-complex-layout"> + {' '} + {/* Keep existing SCSS */} + <Flex gap="sm" align="center"> + <Icon name="user" /> + <Text variant="strong">{user.name}</Text> + </Flex> + {/* Use primitive buttons for consistency */} + <Flex gap="xs"> + <Button type="secondary" size="small" onClick={() => kickUser(user)}> + Remove User + </Button> + <Button type="primary" size="small" onClick={() => sendMessage(user)}> + Send Message + </Button> + </Flex> +</div> +``` + +--- + +## 🎯 **When to Use Primitives** + +### **Always Use For:** + +- **Interactive elements**: Button, Input, Select, Modal, Switch +- **Layout containers**: Flex for consistent spacing +- **Design system elements**: Text (required on mobile), Icon + +### **Consider For:** + +- **Simple containers**: Use View if you need theme consistency +- **Form elements**: Primitives provide consistent validation/error states + +### **Don't Force For:** + +- **Highly specialized components**: Complex animations, charts +- **Third-party integrations**: When the library expects specific HTML structures +- **Performance-critical sections**: Where extra abstraction layers could impact performance + +--- + +## 🚀 **Getting Started** + +### **Next Steps** + +1. **Need quick component reference?** → [02-primitives-quick-reference.md](./02-primitives-quick-reference.md) +2. **Understanding when to use primitives?** → [03-when-to-use-primitives.md](./03-when-to-use-primitives.md) +3. **Converting web components?** → [04-web-to-native-migration.md](./04-web-to-native-migration.md) + +### **Development Workflow** + +1. Design your component using primitives +2. Test on web first (faster iteration) +3. Test on mobile simulator to verify behavior +4. Add platform-specific enhancements if needed +5. Validate accessibility with screen readers + +--- + +_Last updated: 2026-01-14 - Updated to use unified Flex primitive_ + +--- + +[← Back to Primitives INDEX](./INDEX.md) diff --git a/.agents/docs/features/primitives/02-primitives-quick-reference.md b/.agents/docs/features/primitives/02-primitives-quick-reference.md new file mode 100644 index 000000000..0722f5fa5 --- /dev/null +++ b/.agents/docs/features/primitives/02-primitives-quick-reference.md @@ -0,0 +1,788 @@ +--- +type: doc +title: Primitives Quick Reference +status: done +created: 2026-01-09T00:00:00.000Z +updated: 2025-10-14T00:00:00.000Z +--- + +# Primitives Quick Reference + +**[← Back to Primitives INDEX](./INDEX.md)** + + +Fast lookup guide for all primitive components with essential props and examples. + +## 📝 Text & Typography + +### Text (Cross-Platform) + +```tsx +<Text + variant="default|strong|subtle|muted|error|success|warning|link" + size="xs|sm|base|lg|xl|2xl|3xl" + weight="normal|medium|semibold|bold" + align="left|center|right" + marginTop={number} + marginBottom={number} + lineHeight={number} +> + Content +</Text> +``` + +**Examples:** +```tsx +<Text variant="strong" size="lg">Page Title</Text> +<Text>Default body text</Text> +<Text variant="subtle" size="sm">Helper text</Text> +<Text variant="error">Error message</Text> +``` + +### Web Alternative: CSS Typography Classes + +For web-only code, prefer plain HTML with CSS classes: + +```tsx +// Instead of Text primitive (simpler, no abstraction) +<p className="text-body">Main content text</p> +<span className="text-small text-subtle">Helper text</span> +<h1 className="text-title">Page title</h1> + +// Available typography classes: +// text-title-large, text-title, text-subtitle, text-subtitle-2 +// text-body, text-label, text-label-strong +// text-small, text-small-desktop + +// Color classes: text-strong, text-subtle, text-muted, etc. +``` + +### Semantic Typography Helpers (Native Only) + +```tsx +<Title>Page Title // size="lg" (24px), weight="bold" (default) +Section Title // size="sm" (18px), weight="bold" +Content paragraph // marginBottom={16}, lineHeight="1.4x" + // size="sm", weight="strong", marginBottom={8} +Helper text // size="sm", variant="subtle", marginTop={8} +``` + +--- + +## 🎨 Buttons + +### Button + +```tsx + +``` + +**Quick Examples:** + +```tsx + + + + +``` + +--- + +## 📝 Form Elements + +### Input + +```tsx + {}} + placeholder="placeholder text" + type="text|email|password|number|tel|url|search" + variant="filled|bordered|onboarding" + error={boolean} + errorMessage="error text" + disabled={boolean} + noFocusStyle={boolean} + autoFocus={boolean} + className="css-classes" // Web only + style={CSSProperties} + testID="test-id" + accessibilityLabel="description" + // Native-specific props: + keyboardType="default|email-address|numeric|phone-pad|number-pad|decimal-pad|url" // Native only + returnKeyType="done|go|next|search|send" // Native only + autoComplete="off|email|name|tel|username|password" // Native only + secureTextEntry={boolean} // Native only + onSubmitEditing={() => {}} // Native only + onBlur={() => {}} + onFocus={() => {}} +/> +``` + +### TextArea + +```tsx +