diff --git a/AGENTS.md b/AGENTS.md index fb57e8f..66438da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -228,6 +228,7 @@ All endpoints are prefixed with `/api` (e.g., `/api/health`). | POST | `/api/packets/maintenance` | Delete old packets (cleanup) | | POST | `/api/contacts/{key}/mark-read` | Mark contact conversation as read | | POST | `/api/channels/{key}/mark-read` | Mark channel as read | +| GET | `/api/read-state/unreads` | Server-computed unread counts, mentions, last message times | | POST | `/api/read-state/mark-all-read` | Mark all conversations as read | | GET | `/api/settings` | Get app settings | | PATCH | `/api/settings` | Update app settings | @@ -266,7 +267,7 @@ Read state (`last_read_at`) is tracked **server-side** for consistency across de - Stored as Unix timestamp in `contacts.last_read_at` and `channels.last_read_at` - Updated via `POST /api/contacts/{key}/mark-read` and `POST /api/channels/{key}/mark-read` - Bulk update via `POST /api/read-state/mark-all-read` -- Frontend compares `last_read_at` with message `received_at` to count unreads +- Aggregated counts via `GET /api/read-state/unreads` (server-side computation) **State Tracking Keys (Frontend)**: Generated by `getStateKey()` for message times (sidebar sorting): - Channels: `channel-{channel_key}` diff --git a/app/AGENTS.md b/app/AGENTS.md index 532e0d1..3932cf9 100644 --- a/app/AGENTS.md +++ b/app/AGENTS.md @@ -35,7 +35,7 @@ app/ ├── channels.py # Channel CRUD, radio sync, mark-read ├── messages.py # Message list and send (direct/channel) ├── packets.py # Raw packet endpoints, historical decryption - ├── read_state.py # Bulk read state operations (mark-all-read) + ├── read_state.py # Read state: unread counts, mark-all-read ├── settings.py # App settings (max_radio_contacts) └── ws.py # WebSocket endpoint at /api/ws ``` @@ -104,6 +104,10 @@ await ws_manager.broadcast("message", {"id": 1, "text": "Hello"}) Event types: `health`, `contacts`, `channels`, `message`, `contact`, `raw_packet`, `message_acked`, `error` +**Note:** The WebSocket initial connect only sends `health`. Contacts and channels are fetched +via REST (`GET /api/contacts`, `GET /api/channels`) for faster parallel loading. The WS still +broadcasts real-time `contacts`/`channels` updates when data changes. + Helper functions for common broadcasts: ```python @@ -490,6 +494,7 @@ All endpoints are prefixed with `/api`. - `DELETE /api/channels/{key}` - Delete channel ### Read State +- `GET /api/read-state/unreads?name=X` - Server-computed unread counts, mention flags, and last message times - `POST /api/read-state/mark-all-read` - Mark all contacts and channels as read ### Messages diff --git a/app/repository.py b/app/repository.py index 1d0b059..bb9cede 100644 --- a/app/repository.py +++ b/app/repository.py @@ -497,59 +497,6 @@ class MessageRepository: acked=row["acked"], ) - @staticmethod - async def get_bulk( - conversations: list[dict], - limit_per_conversation: int = 100, - ) -> dict[str, list["Message"]]: - """Fetch messages for multiple conversations in one query per conversation. - - Args: - conversations: List of {type: 'PRIV'|'CHAN', conversation_key: string} - limit_per_conversation: Max messages to return per conversation - - Returns: - Dict mapping 'type:conversation_key' to list of messages - """ - result: dict[str, list[Message]] = {} - - for conv in conversations: - msg_type = conv.get("type") - conv_key = conv.get("conversation_key") - if not msg_type or not conv_key: - continue - - key = f"{msg_type}:{conv_key}" - - cursor = await db.conn.execute( - """ - SELECT * FROM messages - WHERE type = ? AND conversation_key LIKE ? - ORDER BY received_at DESC - LIMIT ? - """, - (msg_type, f"{conv_key}%", limit_per_conversation), - ) - rows = await cursor.fetchall() - result[key] = [ - Message( - id=row["id"], - type=row["type"], - conversation_key=row["conversation_key"], - text=row["text"], - sender_timestamp=row["sender_timestamp"], - received_at=row["received_at"], - paths=MessageRepository._parse_paths(row["paths"]), - txt_type=row["txt_type"], - signature=row["signature"], - outgoing=bool(row["outgoing"]), - acked=row["acked"], - ) - for row in rows - ] - - return result - @staticmethod async def get_unread_counts(name: str | None = None) -> dict: """Get unread message counts, mention flags, and last message times for all conversations. diff --git a/app/routers/messages.py b/app/routers/messages.py index 89a2429..36a5283 100644 --- a/app/routers/messages.py +++ b/app/routers/messages.py @@ -38,19 +38,6 @@ async def list_messages( ) -@router.post("/bulk", response_model=dict[str, list[Message]]) -async def get_messages_bulk( - conversations: list[dict], - limit_per_conversation: int = Query(default=100, ge=1, le=1000), -) -> dict[str, list[Message]]: - """Fetch messages for multiple conversations in one request. - - Body should be a list of {type: 'PRIV'|'CHAN', conversation_key: string}. - Returns a dict mapping 'type:conversation_key' to list of messages. - """ - return await MessageRepository.get_bulk(conversations, limit_per_conversation) - - @router.post("/direct", response_model=Message) async def send_direct_message(request: SendDirectMessageRequest) -> Message: """Send a direct message to a contact.""" diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 7d1a1b0..9b3d7d8 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -90,10 +90,13 @@ The `preferences_migrated` flag prevents duplicate migrations. ### State Flow -1. **WebSocket** pushes real-time updates (health, contacts, channels, messages) -2. **REST API** fetches initial data and handles user actions +1. **REST API** fetches initial data on mount in parallel (config, settings, channels, contacts, unreads) +2. **WebSocket** pushes real-time updates (health, messages, contact changes, raw packets) 3. **Components** receive state as props, call handlers to trigger changes +**Note:** Contacts and channels are loaded via REST on mount (not from WebSocket initial push). +The WebSocket only sends health on initial connect, then broadcasts real-time updates. + ### Conversation Header For contacts, the header shows path information alongside "Last heard": @@ -229,8 +232,8 @@ interface Message { } interface Conversation { - type: 'contact' | 'channel' | 'raw' | 'map'; - id: string; // PublicKey for contacts, ChannelKey for channels, 'raw'/'map' for special views + type: 'contact' | 'channel' | 'raw' | 'map' | 'visualizer'; + id: string; // PublicKey for contacts, ChannelKey for channels, 'raw'/'map'/'visualizer' for special views name: string; } @@ -397,11 +400,8 @@ for local state tracking, while `conversation_key` is the raw database field. Unread tracking uses server-side `last_read_at` timestamps for cross-device consistency: ```typescript -// Contacts and channels include last_read_at from server -interface Contact { - // ... - last_read_at: number | null; // Unix timestamp when conversation was last read -} +// Fetch aggregated unread counts from server (replaces bulk message fetch + client-side counting) +await api.getUnreads(myName); // Returns { counts, mentions, last_message_times } // Mark as read via API (called automatically when viewing conversation) await api.markContactRead(publicKey); @@ -409,7 +409,9 @@ await api.markChannelRead(channelKey); await api.markAllRead(); // Bulk mark all as read ``` -Unread count = messages where `received_at > last_read_at`. +The `useUnreadCounts` hook fetches counts from `GET /api/read-state/unreads` on mount and +when channels/contacts change. Real-time increments are still tracked client-side via WebSocket +message events. The server computes unread counts using `last_read_at` vs `received_at`. ## Utility Functions