diff --git a/docs/features/messages/README.md b/docs/features/messages/README.md new file mode 100644 index 0000000..f8f5fae --- /dev/null +++ b/docs/features/messages/README.md @@ -0,0 +1,50 @@ +# Messages UI (feature docs) + +Documentation for the Meshflow SPA **text message history** experience (Meshtastic and MeshCore): routes, realtime, and navigation unread badges. Use this tree for feature-level behaviour; older narrative docs also live under [`docs/messages/`](../../messages/README.md) (architecture, roadmap). + +**Backend counterpart:** [meshflow-api `docs/features/text-messages/`](https://github.com/pskillen/meshflow-api/blob/main/docs/features/text-messages/README.md) + +**Epic:** [meshflow-api#341](https://github.com/pskillen/meshflow-api/issues/341) — messages UI rework ([#277](https://github.com/pskillen/meshflow-ui/issues/277), [#278](https://github.com/pskillen/meshflow-ui/issues/278), [#281](https://github.com/pskillen/meshflow-ui/issues/281)). + +## Implementation status + +| Area | Status | Notes | +| --------------------------------------------------------- | --------------- | --------------------------------------------------------------------------------------------------- | +| Protocol-scoped pages (`/messages`, `/meshcore/messages`) | Shipped | Shared `ProtocolMessageHistoryPage` | +| REST history + pagination | Shipped | `useMessages` / `useMessagesSuspense` | +| WS live prepend on active channel | Shipped | `useMessagesWithWebSocket` | +| Sidebar unread badge per protocol | Shipped | Requires API WS `protocol` ([#279](https://github.com/pskillen/meshflow-ui/issues/279)) | +| Per-channel unread on messages page | Shipped | Active constellation only — [meshflow-api#396](https://github.com/pskillen/meshflow-api/issues/396) | +| Channel selector button row | Shipped | Interim until [#281](https://github.com/pskillen/meshflow-ui/issues/281) | +| Persisted unread | Not implemented | In-memory session only | + +## Documentation map + +| Doc | Purpose | +| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------- | +| [unread-count.md](unread-count.md) | Nav badges, `WebSocketProvider`, mark-as-read, [#279](https://github.com/pskillen/meshflow-ui/issues/279) | +| [../../messages/architecture.md](../../messages/architecture.md) | Routes, components, hooks (broader) | +| [../../messages/current-features.md](../../messages/current-features.md) | List layout, grouping, heard dialog | +| [../../messages/gaps-and-roadmap.md](../../messages/gaps-and-roadmap.md) | Issue mapping, planned UX | + +## Concepts + +- **Protocol slug** — `'meshtastic' | 'meshcore'` in UI; maps from API `protocol` string or legacy numeric `1`/`2`. +- **Unread** — in-memory list in `WebSocketProvider`; nav sums per protocol; channel buttons per `channel` id. +- **On messages page** — unread skipped only for the **active channel** (`setActiveMessagesView`); other channels still badge; toast suppressed for that protocol’s route only. + +## Source map + +| Area | Path | +| ---------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| Pages | `src/pages/messages/MessageHistory.tsx`, `src/pages/meshcore/MeshCoreMessages.tsx`, `src/pages/protocol/ProtocolMessageHistoryPage.tsx` | +| List / item | `src/components/messages/MessageList.tsx`, `MessageItem.tsx` | +| Protocol helpers | `src/lib/message-protocol.ts`, `src/lib/message-channels.ts` | +| Data | `src/hooks/api/useMessages.ts`, `src/hooks/useMessagesWithWebSocket.ts` | +| Realtime / nav | `src/providers/WebSocketProvider.tsx`, `src/components/nav-main.tsx` | +| WS client | `src/lib/websocket/websocketService.ts` | + +## Related API + +- `GET /api/messages/text/` — `protocol`, `channel_id`, `constellation_id` +- `WS /ws/messages/?token=…` — see [API unread-count doc](https://github.com/pskillen/meshflow-api/blob/main/docs/features/text-messages/unread-count.md) diff --git a/docs/features/messages/unread-count.md b/docs/features/messages/unread-count.md new file mode 100644 index 0000000..3390ec9 --- /dev/null +++ b/docs/features/messages/unread-count.md @@ -0,0 +1,90 @@ +# Messages UI — unread count + +**Purpose:** Sidebar **Messages** badges (per protocol), per-channel badges on the messages page, and mark-as-read behaviour. + +**API counterpart:** [meshflow-api `docs/features/text-messages/unread-count.md`](https://github.com/pskillen/meshflow-api/blob/main/docs/features/text-messages/unread-count.md) + +**Broader UI context:** [architecture.md](../../messages/architecture.md), [gaps-and-roadmap.md](../../messages/gaps-and-roadmap.md). + +**Tracking:** Fixed in [#279](https://github.com/pskillen/meshflow-ui/issues/279). Deferred rollup: [meshflow-api#396](https://github.com/pskillen/meshflow-api/issues/396). + +--- + +## Product behaviour + +| Surface | Count | Clear when | +| ---------------------------------- | ------------------------------------ | ------------------------------------------------ | +| Sidebar Messages link (MT / MC) | All unread for that **protocol** | Nav click: `markAsReadForProtocol` | +| Channel button row (messages page) | Unread for **protocol + channel id** | User clicks channel (`markAsReadForChannel`) | +| Active channel while on page | No unread increment | `setActiveMessagesView({ protocol, channelId })` | + +Unread is **in-memory only** (lost on refresh). Not synced to the API. + +**Toast:** protocol-level only — fires when the user is **not** on that protocol’s messages route (`/messages` or `/meshcore/messages`). Sibling channels on the same page do not toast; unread badges still increment. Toast visibility in production has been unreliable; verify manually after deploy. + +**Channel picker:** button row (same styling as constellation buttons), interim until [#281](https://github.com/pskillen/meshflow-ui/issues/281) / epic [#341](https://github.com/pskillen/meshflow-api/issues/341). + +--- + +## Code anchors + +| Piece | Path | +| ------------------------------ | --------------------------------------------------- | +| Unread state + helpers | `src/providers/WebSocketProvider.tsx` | +| Messages page + channel badges | `src/pages/protocol/ProtocolMessageHistoryPage.tsx` | +| Protocol derivation | `src/lib/message-protocol.ts` | +| Nav badges | `src/components/nav-main.tsx` | +| WS client | `src/lib/websocket/websocketService.ts` | +| On-page realtime | `src/hooks/useMessagesWithWebSocket.ts` | +| Tests | `src/providers/WebSocketProvider.test.tsx` | + +--- + +## Data flow + +```mermaid +sequenceDiagram + participant API as meshflow_api_WS + participant WSP as WebSocketProvider + participant Nav as nav_main + participant Page as ProtocolMessageHistoryPage + + API->>WSP: JSON with protocol and channel + WSP->>WSP: skip unread if active channel view + WSP->>Nav: unreadCountForProtocol + WSP->>Page: unreadCountForChannel on buttons + Page->>WSP: setActiveMessagesView on channel change +``` + +### `WebSocketProvider` + +- `unreadMessages: TextMessage[]` — deduped by `message.id` on append. +- `activeMessagesViewRef` — set by messages page; cleared on unmount or when leaving messages routes. +- **Increment:** unless viewing that protocol’s messages page **and** the message’s `channel` matches the active view. +- **Toast:** only when `!isOnMessagesPage(pathname, proto)` (other protocol still toasts while on messages page). +- **Removed:** pathname effect that cleared entire protocol on entering messages pages. + +Helpers: `unreadCountForProtocol`, `hasUnreadForProtocol`, `unreadCountForChannel`, `hasUnreadForChannel`, `markAsReadForProtocol`, `markAsReadForChannel`, `setActiveMessagesView`. + +### `ProtocolMessageHistoryPage` + +- Registers `setActiveMessagesView` when `selectedChannel` changes. +- Channel buttons call `markAsReadForChannel` on user click (not on auto-select; see #396). + +--- + +## Known gaps + +| Gap | Notes | +| ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------- | +| [meshflow-api#396](https://github.com/pskillen/meshflow-api/issues/396) | Per-channel badges only for **active constellation**; constellation-level rollup; auto-select mark-as-read nuance | +| Toast reliability | May not appear in practice — verify; log separate issue if dead | +| `localStorage` | Unread not persisted across reload | +| `hasUnreadMessages` / `markAllAsRead` | Exposed but unused by nav | + +--- + +## Related + +- [README.md](README.md) — messages feature hub +- [meshflow-api unread-count.md](https://github.com/pskillen/meshflow-api/blob/main/docs/features/text-messages/unread-count.md) diff --git a/docs/messages/README.md b/docs/messages/README.md index 9519ca1..955a5e3 100644 --- a/docs/messages/README.md +++ b/docs/messages/README.md @@ -2,13 +2,17 @@ Documentation for the Meshflow UI **text message history** experience (Meshtastic and MeshCore). Use this as the baseline before implementing navigation, channel picker, and layout upgrades ([#277](https://github.com/pskillen/meshflow-ui/issues/277), [#278](https://github.com/pskillen/meshflow-ui/issues/278), [#281](https://github.com/pskillen/meshflow-ui/issues/281); parent epic [meshflow-api#341](https://github.com/pskillen/meshflow-api/issues/341)). +**Feature docs (new):** [docs/features/messages/](../features/messages/README.md) — hub including dedicated [unread-count.md](../features/messages/unread-count.md) ([#279](https://github.com/pskillen/meshflow-ui/issues/279)). + ## Contents -| Doc | Purpose | -| ----------------------------------------- | ----------------------------------------------------------------------- | -| [Architecture](./architecture.md) | Routes, components, hooks, API/WS integration | -| [Current features](./current-features.md) | Reverse-engineered behaviour today (grouping, layout, unread, realtime) | -| [Gaps and roadmap](./gaps-and-roadmap.md) | Known gaps, issue mapping, design directions | +| Doc | Purpose | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| [Feature hub](../features/messages/README.md) | Unread badges, cross-links to API | +| [Unread count](../features/messages/unread-count.md) | Nav badges, `WebSocketProvider`, [#279](https://github.com/pskillen/meshflow-ui/issues/279) | +| [Architecture](./architecture.md) | Routes, components, hooks, API/WS integration | +| [Current features](./current-features.md) | Reverse-engineered behaviour today (grouping, layout, unread, realtime) | +| [Gaps and roadmap](./gaps-and-roadmap.md) | Known gaps, issue mapping, design directions | ## Product summary @@ -29,6 +33,7 @@ Documentation for the Meshflow UI **text message history** experience (Meshtasti ## Related API / backend docs +- [meshflow-api text-messages feature](https://github.com/pskillen/meshflow-api/blob/main/docs/features/text-messages/README.md) — REST + WS; [unread-count](https://github.com/pskillen/meshflow-api/blob/main/docs/features/text-messages/unread-count.md) (WS `protocol` gap) - OpenAPI: `GET /api/messages/text/` (`channel_id`, `constellation_id`, `protocol`, `sender_node_id`, pagination) -- WebSocket: `/ws/messages/?token=…` — pushes `TextMessage` payloads +- WebSocket: `/ws/messages/?token=…` — pushes `TextMessage` payloads (narrower than REST today) - Constellations/channels: `Constellation.protocol`, `MessageChannel.protocol`, `display_label`, `mc_channel_type` ([meshflow-api text-message-channels](https://github.com/pskillen/meshflow-api/blob/main/docs/features/meshcore/text-message-channels.md)) diff --git a/docs/messages/architecture.md b/docs/messages/architecture.md index f766f74..84dbafe 100644 --- a/docs/messages/architecture.md +++ b/docs/messages/architecture.md @@ -79,6 +79,8 @@ Query keys include `protocol`, `channelId`, `constellationId`, `nodeId`, `pageSi ## WebSocket and unread +Canonical detail: [docs/features/messages/unread-count.md](../features/messages/unread-count.md) and [meshflow-api text-messages/unread-count.md](https://github.com/pskillen/meshflow-api/blob/main/docs/features/text-messages/unread-count.md). + ### Connection - `websocketService` → `ws…/ws/messages/?token=…` (from `config.apis.meshBot.baseUrl`). diff --git a/docs/messages/current-features.md b/docs/messages/current-features.md index d773afa..cf51318 100644 --- a/docs/messages/current-features.md +++ b/docs/messages/current-features.md @@ -99,21 +99,20 @@ WS handler does not check `constellation_id` on the payload (only channel id). R ## Unread / notification counts -### Implemented today +See **[docs/features/messages/unread-count.md](../features/messages/unread-count.md)**. -- **Protocol-scoped** unread array in `WebSocketProvider`. -- Sidebar **Messages** link per protocol: red badge with count (max display `9+`). -- Clearing: navigate to that protocol’s messages URL, or click nav link (explicit `markAsReadForProtocol` before navigate). -- **Global** `hasUnreadMessages` / `markAllAsRead` exist but nav uses per-protocol helpers. +### Implemented today -### Not implemented +- **Protocol-scoped** nav badges (`unreadCountForProtocol`); requires API WS `protocol` field. +- **Per-channel** badges on channel button row; active channel suppressed via `setActiveMessagesView`. +- Channel picker: **button row** (not dropdown). +- Clearing: nav link → `markAsReadForProtocol`; channel click → `markAsReadForChannel`. +- Toast: protocol-level only (not per channel). -- Per-**channel** unread badges. -- Per-**constellation** aggregates. -- `localStorage` persistence of unread across reloads (unread is in-memory only). -- Distinction between “unread since last visit” vs total — current model is “messages received while not on that protocol’s page” until cleared. +### Deferred / not implemented -User request: channel-level counts beside a future channel list, plus total on protocol nav link — aligns with extending `unreadMessages` indexing (channel + protocol keys). +- Constellation-level unread rollup — [meshflow-api#396](https://github.com/pskillen/meshflow-api/issues/396). +- `localStorage` persistence across reload. ## Default selection UX diff --git a/docs/messages/gaps-and-roadmap.md b/docs/messages/gaps-and-roadmap.md index a2c4832..8a4cad5 100644 --- a/docs/messages/gaps-and-roadmap.md +++ b/docs/messages/gaps-and-roadmap.md @@ -42,8 +42,9 @@ Likely layout: **constellation tabs** (row 1) + **channel sidebar or chip list** ### Unread model -- **Done:** protocol-separated sidebar badges. -- **Needed:** channel-level counts; optional constellation rollup; persist or rehydrate strategy; define whether counts increment for messages on inactive channels while user stays on messages page. +- **Shipped ([#279](https://github.com/pskillen/meshflow-ui/issues/279)):** protocol nav badges, per-channel badges on messages page, channel button row — [docs/features/messages/unread-count.md](../features/messages/unread-count.md). +- **Deferred ([meshflow-api#396](https://github.com/pskillen/meshflow-api/issues/396)):** constellation-level unread rollup; per-channel badges across constellations; auto-select mark-as-read nuance. +- **Future:** persist or rehydrate unread across reload. ### Layout diff --git a/src/components/nav-main.test.tsx b/src/components/nav-main.test.tsx index 0b66002..c77a6d5 100644 --- a/src/components/nav-main.test.tsx +++ b/src/components/nav-main.test.tsx @@ -10,8 +10,13 @@ vi.mock('@/providers/WebSocketProvider', () => ({ unreadMessages: [], markAllAsRead: vi.fn(), markAsReadForProtocol: vi.fn(), + markAsReadForChannel: vi.fn(), + takeUnreadForChannel: () => [], + setActiveMessagesView: vi.fn(), unreadCountForProtocol: () => 0, hasUnreadForProtocol: () => false, + unreadCountForChannel: () => 0, + hasUnreadForChannel: () => false, }), })); diff --git a/src/hooks/useMessagesWithWebSocket.ts b/src/hooks/useMessagesWithWebSocket.ts index 599162f..37760b9 100644 --- a/src/hooks/useMessagesWithWebSocket.ts +++ b/src/hooks/useMessagesWithWebSocket.ts @@ -1,11 +1,11 @@ -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useQueryClient } from '@tanstack/react-query'; import { useMessagesSuspense } from '@/hooks/api/useMessages'; import { useWebSocket } from '@/providers/WebSocketProvider'; import { eventService } from '@/lib/events/eventService'; import { WebSocketEventType } from '@/lib/websocket/websocketService'; import { TextMessage } from '@/lib/models'; -import { messageProtocol } from '@/lib/message-protocol'; +import { channelIdFromMessage, messageProtocol } from '@/lib/message-protocol'; import type { ProtocolSlug } from '@/lib/mesh-protocol'; interface UseMessagesWithWebSocketOptions { @@ -16,12 +16,49 @@ interface UseMessagesWithWebSocketOptions { pageSize?: number; } +function prependToMessagesQuery( + queryClient: ReturnType, + queryKey: readonly (string | number | undefined)[], + messages: TextMessage[] +) { + if (messages.length === 0) { + return; + } + queryClient.setQueryData( + queryKey, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (oldData: any) => { + if (!oldData?.pages?.length) { + return oldData; + } + const existingIds = new Set(); + for (const page of oldData.pages) { + for (const row of page.results ?? []) { + existingIds.add(row.id); + } + } + const toAdd = messages.filter((m) => !existingIds.has(m.id)); + if (toAdd.length === 0) { + return oldData; + } + const newData = { ...oldData }; + newData.pages = [...oldData.pages]; + newData.pages[0] = { + ...newData.pages[0], + results: [...toAdd, ...(newData.pages[0].results ?? [])], + count: (newData.pages[0].count || 0) + toAdd.length, + }; + return newData; + } + ); +} + /** * Hook to fetch messages and subscribe to real-time updates via WebSocket */ export function useMessagesWithWebSocket(options?: UseMessagesWithWebSocketOptions) { const queryClient = useQueryClient(); - const { isConnected } = useWebSocket(); + const { isConnected, takeUnreadForChannel } = useWebSocket(); const [realtimeMessages, setRealtimeMessages] = useState([]); const messagesResult = useMessagesSuspense({ @@ -31,16 +68,48 @@ export function useMessagesWithWebSocket(options?: UseMessagesWithWebSocketOptio pageSize: options?.pageSize, }); - const queryKey = [ - 'messages', - options?.protocol, - options?.channelId, - options?.constellationId, - options?.nodeId, - options?.pageSize || 250, - ] as const; + const queryKey = useMemo( + () => + [ + 'messages', + options?.protocol, + options?.channelId, + options?.constellationId, + options?.nodeId, + options?.pageSize || 250, + ] as const, + [options?.protocol, options?.channelId, options?.constellationId, options?.nodeId, options?.pageSize] + ); - const allMessages = [...messagesResult.messages, ...realtimeMessages]; + const allMessages = useMemo(() => { + const seen = new Set(); + const merged: TextMessage[] = []; + for (const m of realtimeMessages) { + if (!seen.has(m.id)) { + seen.add(m.id); + merged.push(m); + } + } + for (const m of messagesResult.messages) { + if (!seen.has(m.id)) { + seen.add(m.id); + merged.push(m); + } + } + return merged; + }, [realtimeMessages, messagesResult.messages]); + + // Promote channel unread into the list when switching channels. + useEffect(() => { + if (options?.channelId == null || !options?.protocol) { + setRealtimeMessages([]); + return; + } + + const taken = takeUnreadForChannel(options.protocol, options.channelId); + setRealtimeMessages(taken); + prependToMessagesQuery(queryClient, queryKey, taken); + }, [options?.channelId, options?.constellationId, options?.protocol, takeUnreadForChannel, queryClient, queryKey]); useEffect(() => { const handleNewMessage = (message: TextMessage) => { @@ -48,30 +117,15 @@ export function useMessagesWithWebSocket(options?: UseMessagesWithWebSocketOptio return; } - const matchesChannel = options?.channelId ? message.channel === options.channelId : true; + const messageChannelId = channelIdFromMessage(message); + const matchesChannel = options?.channelId != null ? messageChannelId === options.channelId : true; if (matchesChannel) { const isDuplicate = [...messagesResult.messages, ...realtimeMessages].some((m) => m.id === message.id); if (!isDuplicate) { setRealtimeMessages((prev) => [message, ...prev]); - - queryClient.setQueryData( - queryKey, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - (oldData: any) => { - if (!oldData || !oldData.pages || !oldData.pages.length) return oldData; - - const newData = { ...oldData }; - newData.pages[0] = { - ...newData.pages[0], - results: [message, ...newData.pages[0].results], - count: (newData.pages[0].count || 0) + 1, - }; - - return newData; - } - ); + prependToMessagesQuery(queryClient, queryKey, [message]); } } }; diff --git a/src/lib/message-protocol.test.ts b/src/lib/message-protocol.test.ts index 860d7ce..b2f14cc 100644 --- a/src/lib/message-protocol.test.ts +++ b/src/lib/message-protocol.test.ts @@ -1,12 +1,11 @@ import { describe, it, expect } from 'vitest'; -import { messageProtocol, isOnMessagesPage, messagesRouteForProtocol } from './message-protocol'; +import { messageProtocol, isOnMessagesPage, messagesRouteForProtocol, channelIdFromMessage } from './message-protocol'; import type { TextMessage } from '@/lib/models'; -function msg(protocol?: TextMessage['protocol']): TextMessage { +function msg(overrides: Partial = {}): TextMessage { return { id: '1', packet_id: 1, - protocol, sender: { node_id_str: '!a', long_name: null, short_name: null }, recipient_meshtastic_node_id: null, channel: 1, @@ -15,17 +14,43 @@ function msg(protocol?: TextMessage['protocol']): TextMessage { is_emoji: false, reply_to_meshtastic_packet_id: null, heard: [], + ...overrides, }; } describe('message-protocol', () => { it('normalizes protocol from API string or number', () => { - expect(messageProtocol(msg('meshcore'))).toBe('meshcore'); - expect(messageProtocol(msg(2))).toBe('meshcore'); - expect(messageProtocol(msg('meshtastic'))).toBe('meshtastic'); + expect(messageProtocol(msg({ protocol: 'meshcore' }))).toBe('meshcore'); + expect(messageProtocol(msg({ protocol: 2 }))).toBe('meshcore'); + expect(messageProtocol(msg({ protocol: 'meshtastic' }))).toBe('meshtastic'); + expect(messageProtocol(msg({ protocol: 1 }))).toBe('meshtastic'); + }); + + it('infers meshcore without protocol field', () => { + expect(messageProtocol(msg({ original_mc_packet_id: 'abc' }))).toBe('meshcore'); + expect(messageProtocol(msg({ mc_sender_label: 'Alice' }))).toBe('meshcore'); + expect( + messageProtocol( + msg({ + mc_sender_candidates: [ + { + internal_id: '1', + node_id_str: 'mc:x', + long_name: 'X', + short_name: 'X', + position: null, + }, + ], + }) + ) + ).toBe('meshcore'); expect(messageProtocol(msg())).toBe('meshtastic'); }); + it('reads channel id from nested channel object', () => { + expect(channelIdFromMessage(msg({ channel: { id: 42 } as unknown as number }))).toBe(42); + }); + it('maps routes and page detection', () => { expect(messagesRouteForProtocol('meshcore')).toBe('/meshcore/messages'); expect(isOnMessagesPage('/messages', 'meshtastic')).toBe(true); diff --git a/src/lib/message-protocol.ts b/src/lib/message-protocol.ts index 13664a6..eb87999 100644 --- a/src/lib/message-protocol.ts +++ b/src/lib/message-protocol.ts @@ -2,12 +2,45 @@ import type { TextMessage } from '@/lib/models'; export type MessageProtocolSlug = 'meshtastic' | 'meshcore'; +/** Channel id from API (numeric PK); tolerates legacy nested `{ id }` shapes. */ +export function channelIdFromMessage(message: TextMessage): number { + const ch = message.channel as number | { id: number }; + if (typeof ch === 'object' && ch != null && 'id' in ch) { + return Number(ch.id); + } + return Number(ch); +} + /** Normalize API/WS protocol field (string or legacy numeric MeshProtocol). */ export function messageProtocol(msg: TextMessage): MessageProtocolSlug { const p = msg.protocol; - if (p === 'meshcore' || p === 2 || p === 'mc') { + if (p != null && p !== '') { + if (p === 2 || p === '2') { + return 'meshcore'; + } + if (p === 1 || p === '1') { + return 'meshtastic'; + } + const s = String(p).toLowerCase(); + if (s === 'meshcore' || s === 'mc') { + return 'meshcore'; + } + if (s === 'meshtastic' || s === 'mt') { + return 'meshtastic'; + } + } + + // Fallbacks when older API WS payloads omit protocol (see meshflow-ui#279). + if (msg.original_mc_packet_id) { return 'meshcore'; } + if (msg.mc_sender_label != null) { + return 'meshcore'; + } + if (msg.mc_sender_candidates && msg.mc_sender_candidates.length > 0) { + return 'meshcore'; + } + return 'meshtastic'; } diff --git a/src/lib/models.ts b/src/lib/models.ts index 0846b79..9305d1d 100644 --- a/src/lib/models.ts +++ b/src/lib/models.ts @@ -445,6 +445,7 @@ export interface TextMessage { id: string; // UUID packet_id: number | string; protocol?: MeshProtocol | 'meshtastic' | 'meshcore' | string; + original_mc_packet_id?: string | null; sender: TextMessageSender | null; sender_position?: MapPosition | null; mc_sender_label?: string | null; diff --git a/src/pages/protocol/ProtocolMessageHistoryPage.tsx b/src/pages/protocol/ProtocolMessageHistoryPage.tsx index 2158c6c..6727bed 100644 --- a/src/pages/protocol/ProtocolMessageHistoryPage.tsx +++ b/src/pages/protocol/ProtocolMessageHistoryPage.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useMemo } from 'react'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { MessageList } from '@/components/messages/MessageList'; import { useConstellationsSuspense } from '@/hooks/api/useConstellations'; +import { useWebSocket } from '@/providers/WebSocketProvider'; import type { MessageChannel } from '@/lib/models'; import type { ProtocolPageConfig } from '@/lib/mesh-protocol'; import { filterChannelsForProtocol, formatMessageChannelLabel } from '@/lib/message-channels'; @@ -16,7 +17,12 @@ type ProtocolMessageHistoryPageProps = { config: ProtocolPageConfig; }; +function channelUnreadBadgeLabel(count: number): string { + return count > 9 ? '9+' : String(count); +} + export function ProtocolMessageHistoryPage({ config }: ProtocolMessageHistoryPageProps) { + const { setActiveMessagesView, hasUnreadForChannel, unreadCountForChannel } = useWebSocket(); const { constellations: allConstellations } = useConstellationsSuspense(); const constellations = useMemo( () => filterConstellationsForProtocol(allConstellations, config.slug), @@ -56,6 +62,15 @@ export function ProtocolMessageHistoryPage({ config }: ProtocolMessageHistoryPag } }, [channels, selectedChannel, activeConstellationId]); + useEffect(() => { + if (selectedChannel != null) { + setActiveMessagesView({ protocol: config.slug, channelId: selectedChannel }); + } else { + setActiveMessagesView(null); + } + return () => setActiveMessagesView(null); + }, [selectedChannel, config.slug, setActiveMessagesView]); + const selectConstellation = (id: number) => { setSelectedConstellation(id); setSelectedChannel(null); @@ -64,8 +79,8 @@ export function ProtocolMessageHistoryPage({ config }: ProtocolMessageHistoryPag } }; - const handleChannelSelect = (e: React.ChangeEvent) => { - setSelectedChannel(Number(e.target.value)); + const selectChannel = (channelId: number) => { + setSelectedChannel(channelId); }; return ( @@ -103,22 +118,32 @@ export function ProtocolMessageHistoryPage({ config }: ProtocolMessageHistoryPag
- - +

Channel

+
+ {channels.map((ch: MessageChannel) => { + const unreadCount = unreadCountForChannel(config.slug, ch.id); + return ( + + ); + })} +
{selectedChannel != null && activeConstellationId != null ? ( diff --git a/src/providers/WebSocketProvider.test.tsx b/src/providers/WebSocketProvider.test.tsx index 2cc2130..de910bc 100644 --- a/src/providers/WebSocketProvider.test.tsx +++ b/src/providers/WebSocketProvider.test.tsx @@ -1,7 +1,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, act } from '@testing-library/react'; +import { render, act, screen } from '@testing-library/react'; import { MemoryRouter, Routes, Route, useNavigate } from 'react-router-dom'; -import { WebSocketProvider } from './WebSocketProvider'; +import { WebSocketProvider, useWebSocket } from './WebSocketProvider'; import { eventService } from '@/lib/events/eventService'; import { WebSocketEventType } from '@/lib/websocket/websocketService'; import type { TextMessage } from '@/lib/models'; @@ -47,18 +47,61 @@ vi.mock('@/hooks/use-toast', () => ({ useToast: () => ({ toast: toastMock }), })); +function makeMessage(overrides: Partial & { id: string; channel: number }): TextMessage { + return { + packet_id: 1, + protocol: 'meshtastic', + sender: { node_id_str: '!aabbccdd', long_name: null, short_name: 'AB' }, + recipient_meshtastic_node_id: null, + sent_at: '2025-01-01T00:00:00Z', + message_text: 'hello', + is_emoji: false, + reply_to_meshtastic_packet_id: null, + heard: [], + ...overrides, + } as TextMessage; +} + +function UnreadProbe() { + const ws = useWebSocket(); + return ( +
+ {ws.unreadCountForProtocol('meshtastic')} + {ws.unreadCountForProtocol('meshcore')} + {ws.unreadCountForChannel('meshtastic', 1)} + {ws.unreadCountForChannel('meshtastic', 2)} + + + + + +
+ ); +} + function NavigationHarness({ onNavigate }: { onNavigate: (navigate: ReturnType) => void }) { const navigate = useNavigate(); onNavigate(navigate); return null; } -function renderWithRoutes(initialPath: string) { +function renderWithRoutes(initialPath: string, withProbe = false) { let navigateFn: ReturnType | null = null; const utils = render( + {withProbe ? : null} (navigateFn = n)} />} /> (navigateFn = n)} />} /> @@ -78,13 +121,7 @@ function renderWithRoutes(initialPath: string) { }; } -const sampleMessage = { - id: 1, - message_text: 'hello', - protocol: 'meshtastic', - channel: 1, - sender: { node_id_str: '!aabbccdd', short_name: 'AB' }, -} as unknown as TextMessage; +const sampleMessage = makeMessage({ id: 'mt-1', channel: 1, protocol: 'meshtastic' }); describe('WebSocketProvider', () => { beforeEach(() => { @@ -128,4 +165,183 @@ describe('WebSocketProvider', () => { expect(toastMock).not.toHaveBeenCalled(); }); + + it('scopes unread counts by protocol', () => { + renderWithRoutes('/', true); + + act(() => { + eventService.emit(WebSocketEventType.MESSAGE_RECEIVED, sampleMessage); + eventService.emit( + WebSocketEventType.MESSAGE_RECEIVED, + makeMessage({ id: 'mc-1', channel: 3, protocol: 'meshcore', message_text: 'mc' }) + ); + }); + + expect(screen.getByTestId('mt-count')).toHaveTextContent('1'); + expect(screen.getByTestId('mc-count')).toHaveTextContent('1'); + }); + + it('does not count unread for the active channel on the messages page', () => { + renderWithRoutes('/messages', true); + + act(() => { + screen.getByText('set-active-ch1').click(); + }); + + act(() => { + eventService.emit(WebSocketEventType.MESSAGE_RECEIVED, sampleMessage); + eventService.emit( + WebSocketEventType.MESSAGE_RECEIVED, + makeMessage({ id: 'mt-2', channel: 2, protocol: 'meshtastic' }) + ); + }); + + expect(screen.getByTestId('mt-count')).toHaveTextContent('1'); + expect(screen.getByTestId('ch1-count')).toHaveTextContent('0'); + expect(screen.getByTestId('ch2-count')).toHaveTextContent('1'); + expect(toastMock).not.toHaveBeenCalled(); + }); + + it('markAsReadForChannel clears only that channel', () => { + renderWithRoutes('/', true); + + act(() => { + eventService.emit(WebSocketEventType.MESSAGE_RECEIVED, sampleMessage); + eventService.emit( + WebSocketEventType.MESSAGE_RECEIVED, + makeMessage({ id: 'mt-2', channel: 2, protocol: 'meshtastic' }) + ); + }); + + act(() => { + screen.getByText('mark-ch2-read').click(); + }); + + expect(screen.getByTestId('mt-count')).toHaveTextContent('1'); + expect(screen.getByTestId('ch2-count')).toHaveTextContent('0'); + }); + + it('does not clear unrelated protocol unread when navigating to messages', () => { + const { navigate } = renderWithRoutes('/', true); + + act(() => { + eventService.emit( + WebSocketEventType.MESSAGE_RECEIVED, + makeMessage({ id: 'mc-1', channel: 5, protocol: 'meshcore' }) + ); + }); + + expect(screen.getByTestId('mc-count')).toHaveTextContent('1'); + + navigate('/messages'); + + act(() => { + eventService.emit(WebSocketEventType.MESSAGE_RECEIVED, sampleMessage); + }); + + expect(screen.getByTestId('mc-count')).toHaveTextContent('1'); + expect(screen.getByTestId('mt-count')).toHaveTextContent('1'); + }); + + it('resumes counting unread for active channel after clear-active', () => { + renderWithRoutes('/messages', true); + + act(() => { + screen.getByText('set-active-ch1').click(); + }); + + act(() => { + eventService.emit(WebSocketEventType.MESSAGE_RECEIVED, sampleMessage); + }); + + expect(screen.getByTestId('ch1-count')).toHaveTextContent('0'); + + act(() => { + screen.getByText('clear-active').click(); + }); + + act(() => { + eventService.emit( + WebSocketEventType.MESSAGE_RECEIVED, + makeMessage({ id: 'mt-3', channel: 1, protocol: 'meshtastic' }) + ); + }); + + expect(screen.getByTestId('ch1-count')).toHaveTextContent('1'); + }); + + it('toasts for other protocol while on messages page', () => { + renderWithRoutes('/messages', true); + + act(() => { + eventService.emit( + WebSocketEventType.MESSAGE_RECEIVED, + makeMessage({ id: 'mc-1', channel: 3, protocol: 'meshcore' }) + ); + }); + + expect(toastMock).toHaveBeenCalled(); + }); + + it('takeUnreadForChannel returns and removes only that channel', () => { + renderWithRoutes('/', true); + + act(() => { + eventService.emit( + WebSocketEventType.MESSAGE_RECEIVED, + makeMessage({ id: 'mt-1', channel: 1, protocol: 'meshtastic' }) + ); + eventService.emit( + WebSocketEventType.MESSAGE_RECEIVED, + makeMessage({ id: 'mt-2', channel: 2, protocol: 'meshtastic' }) + ); + }); + + expect(screen.getByTestId('mt-count')).toHaveTextContent('2'); + + act(() => { + screen.getByText('take-ch2').click(); + }); + + expect(screen.getByTestId('mt-count')).toHaveTextContent('1'); + expect(screen.getByTestId('ch2-count')).toHaveTextContent('0'); + }); + + it('classifies meshcore without protocol using mc provenance fields', () => { + renderWithRoutes('/', true); + + act(() => { + eventService.emit( + WebSocketEventType.MESSAGE_RECEIVED, + makeMessage({ + id: 'mc-infer', + channel: 9, + protocol: undefined, + original_mc_packet_id: 'deadbeef', + sender: null, + }) + ); + }); + + expect(screen.getByTestId('mt-count')).toHaveTextContent('0'); + expect(screen.getByTestId('mc-count')).toHaveTextContent('1'); + }); + + it('markAsReadForProtocol clears all unread for that protocol', () => { + renderWithRoutes('/', true); + + act(() => { + eventService.emit(WebSocketEventType.MESSAGE_RECEIVED, sampleMessage); + eventService.emit( + WebSocketEventType.MESSAGE_RECEIVED, + makeMessage({ id: 'mt-2', channel: 2, protocol: 'meshtastic' }) + ); + }); + + act(() => { + screen.getByText('mark-mt-read').click(); + }); + + expect(screen.getByTestId('mt-count')).toHaveTextContent('0'); + }); }); diff --git a/src/providers/WebSocketProvider.tsx b/src/providers/WebSocketProvider.tsx index 5a9ebbe..8a4fecc 100644 --- a/src/providers/WebSocketProvider.tsx +++ b/src/providers/WebSocketProvider.tsx @@ -5,7 +5,17 @@ import { toast } from '@/hooks/use-toast'; import { websocketService, WebSocketEventType, ConnectionState } from '@/lib/websocket/websocketService'; import { TextMessage } from '@/lib/models'; import { eventService } from '@/lib/events/eventService'; -import { messageProtocol, isOnMessagesPage, type MessageProtocolSlug } from '@/lib/message-protocol'; +import { + channelIdFromMessage, + messageProtocol, + isOnMessagesPage, + type MessageProtocolSlug, +} from '@/lib/message-protocol'; + +export type ActiveMessagesView = { + protocol: MessageProtocolSlug; + channelId: number; +}; interface WebSocketContextType { isConnected: boolean; @@ -13,9 +23,14 @@ interface WebSocketContextType { unreadMessages: TextMessage[]; markAllAsRead: () => void; markAsReadForProtocol: (protocol: MessageProtocolSlug) => void; + markAsReadForChannel: (protocol: MessageProtocolSlug, channelId: number) => void; + takeUnreadForChannel: (protocol: MessageProtocolSlug, channelId: number) => TextMessage[]; + setActiveMessagesView: (view: ActiveMessagesView | null) => void; hasUnreadMessages: boolean; unreadCountForProtocol: (protocol: MessageProtocolSlug) => number; hasUnreadForProtocol: (protocol: MessageProtocolSlug) => boolean; + unreadCountForChannel: (protocol: MessageProtocolSlug, channelId: number) => number; + hasUnreadForChannel: (protocol: MessageProtocolSlug, channelId: number) => boolean; } const WebSocketContext = createContext({ @@ -24,31 +39,78 @@ const WebSocketContext = createContext({ unreadMessages: [], markAllAsRead: () => {}, markAsReadForProtocol: () => {}, + markAsReadForChannel: () => {}, + takeUnreadForChannel: () => [], + setActiveMessagesView: () => {}, hasUnreadMessages: false, unreadCountForProtocol: () => 0, hasUnreadForProtocol: () => false, + unreadCountForChannel: () => 0, + hasUnreadForChannel: () => false, }); export function useWebSocket() { return useContext(WebSocketContext); } +function messageMatchesChannel(message: TextMessage, channelId: number): boolean { + return channelIdFromMessage(message) === channelId; +} + +function isActiveChannelView(pathname: string, view: ActiveMessagesView | null, message: TextMessage): boolean { + const proto = messageProtocol(message); + if (!isOnMessagesPage(pathname, proto) || view == null || view.protocol !== proto) { + return false; + } + return messageMatchesChannel(message, view.channelId); +} + export function WebSocketProvider({ children }: { children: React.ReactNode }) { const config = useConfig(); const location = useLocation(); const pathnameRef = useRef(location.pathname); + const activeMessagesViewRef = useRef(null); const [connectionState, setConnectionState] = useState(ConnectionState.DISCONNECTED); const [unreadMessages, setUnreadMessages] = useState([]); useEffect(() => { pathnameRef.current = location.pathname; + if (!isOnMessagesPage(location.pathname, 'meshtastic') && !isOnMessagesPage(location.pathname, 'meshcore')) { + activeMessagesViewRef.current = null; + } }, [location.pathname]); + const setActiveMessagesView = useCallback((view: ActiveMessagesView | null) => { + activeMessagesViewRef.current = view; + }, []); + const markAsReadForProtocol = useCallback((protocol: MessageProtocolSlug) => { setUnreadMessages((prev) => prev.filter((m) => messageProtocol(m) !== protocol)); }, []); + const markAsReadForChannel = useCallback((protocol: MessageProtocolSlug, channelId: number) => { + setUnreadMessages((prev) => + prev.filter((m) => messageProtocol(m) !== protocol || !messageMatchesChannel(m, channelId)) + ); + }, []); + + const takeUnreadForChannel = useCallback((protocol: MessageProtocolSlug, channelId: number): TextMessage[] => { + const taken: TextMessage[] = []; + setUnreadMessages((prev) => { + const keep: TextMessage[] = []; + for (const m of prev) { + if (messageProtocol(m) === protocol && messageMatchesChannel(m, channelId)) { + taken.push(m); + } else { + keep.push(m); + } + } + return keep; + }); + return taken; + }, []); + const markAllAsRead = useCallback(() => { setUnreadMessages([]); }, []); @@ -63,6 +125,18 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { [unreadMessages] ); + const unreadCountForChannel = useCallback( + (protocol: MessageProtocolSlug, channelId: number) => + unreadMessages.filter((m) => messageProtocol(m) === protocol && messageMatchesChannel(m, channelId)).length, + [unreadMessages] + ); + + const hasUnreadForChannel = useCallback( + (protocol: MessageProtocolSlug, channelId: number) => + unreadMessages.some((m) => messageProtocol(m) === protocol && messageMatchesChannel(m, channelId)), + [unreadMessages] + ); + useEffect(() => { websocketService.initialize(config.apis.meshBot.baseUrl); websocketService.connect(); @@ -81,19 +155,23 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { const messageHandler = (message: TextMessage) => { const proto = messageProtocol(message); - if (isOnMessagesPage(pathnameRef.current, proto)) { - return; - } + const pathname = pathnameRef.current; + const onProtoPage = isOnMessagesPage(pathname, proto); + const isActiveChannel = isActiveChannelView(pathname, activeMessagesViewRef.current, message); - setUnreadMessages((prev) => [...prev, message]); + if (!isActiveChannel) { + setUnreadMessages((prev) => (prev.some((m) => m.id === message.id) ? prev : [...prev, message])); + } - const senderLabel = - message.sender?.long_name || message.sender?.short_name || message.sender?.node_id_str || 'Unknown'; - toast({ - title: `New message from ${senderLabel}`, - description: message.message_text, - duration: 5000, - }); + if (!onProtoPage) { + const senderLabel = + message.sender?.long_name || message.sender?.short_name || message.sender?.node_id_str || 'Unknown'; + toast({ + title: `New message from ${senderLabel}`, + description: message.message_text, + duration: 5000, + }); + } }; eventService.subscribe(WebSocketEventType.CONNECTED, connectedHandler); @@ -110,15 +188,6 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { }; }, [config.apis.meshBot.baseUrl]); - useEffect(() => { - if (isOnMessagesPage(location.pathname, 'meshtastic')) { - markAsReadForProtocol('meshtastic'); - } - if (isOnMessagesPage(location.pathname, 'meshcore')) { - markAsReadForProtocol('meshcore'); - } - }, [location.pathname, markAsReadForProtocol]); - const contextValue = useMemo( () => ({ isConnected: connectionState === ConnectionState.CONNECTED, @@ -126,17 +195,27 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { unreadMessages, markAllAsRead, markAsReadForProtocol, + markAsReadForChannel, + takeUnreadForChannel, + setActiveMessagesView, hasUnreadMessages: unreadMessages.length > 0, unreadCountForProtocol, hasUnreadForProtocol, + unreadCountForChannel, + hasUnreadForChannel, }), [ connectionState, unreadMessages, markAllAsRead, markAsReadForProtocol, + markAsReadForChannel, + takeUnreadForChannel, + setActiveMessagesView, unreadCountForProtocol, hasUnreadForProtocol, + unreadCountForChannel, + hasUnreadForChannel, ] );