From af71b37964fe8bc8883cf337762be2bc90aac8cc Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Thu, 4 Jun 2026 13:35:59 +0100 Subject: [PATCH 1/5] docs(messages): document protocol-scoped unread badges (#279) Add feature docs for unread count behaviour and cross-link legacy docs/messages/ tree. Records WS protocol gap as root cause for #279. --- docs/features/messages/README.md | 48 +++++++++ docs/features/messages/unread-count.md | 141 +++++++++++++++++++++++++ docs/messages/README.md | 17 +-- docs/messages/architecture.md | 2 + docs/messages/current-features.md | 4 +- docs/messages/gaps-and-roadmap.md | 4 +- 6 files changed, 207 insertions(+), 9 deletions(-) create mode 100644 docs/features/messages/README.md create mode 100644 docs/features/messages/unread-count.md diff --git a/docs/features/messages/README.md b/docs/features/messages/README.md new file mode 100644 index 0000000..cc67d4f --- /dev/null +++ b/docs/features/messages/README.md @@ -0,0 +1,48 @@ +# 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 in code | **Bug [#279](https://github.com/pskillen/meshflow-ui/issues/279)** — WS payloads lack `protocol` | +| Per-channel / 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** — messages received over WS while the user is **not** on that protocol’s messages route; stored in React state, not `localStorage`. +- **On messages page** — WS still fires, but `WebSocketProvider` does not add to unread; `useMessagesWithWebSocket` may prepend if channel + protocol match. + +## 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..d44c31f --- /dev/null +++ b/docs/features/messages/unread-count.md @@ -0,0 +1,141 @@ +# Messages UI — unread count + +**Purpose:** How sidebar **Messages** badges and mark-as-read behave today, and why MeshCore traffic can inflate the Meshtastic badge ([#279](https://github.com/pskillen/meshflow-ui/issues/279)). + +**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) (WebSocket section), [gaps-and-roadmap.md](../../messages/gaps-and-roadmap.md). + +--- + +## Product behaviour (intended) + +| Rule | Behaviour | +| ----------------------------------- | ------------------------------------------------------------------------------ | +| MT nav badge (`/messages`) | Count of unread **Meshtastic** messages only | +| MC nav badge (`/meshcore/messages`) | Count of unread **MeshCore** messages only | +| User on MT messages page | Do not increment MT unread; clear MT unread when route is `/messages` | +| User on MC messages page | Do not increment MC unread; clear MC unread when route is `/meshcore/messages` | +| User clicks Messages in nav | `markAsReadForProtocol(that protocol)` then navigate | +| Persistence | None — refresh clears all unread | + +Unread means: **arrived via WebSocket while not on that protocol’s messages URL**, until cleared. It is not “messages since last login” from the server. + +--- + +## Code anchors + +| Piece | Path | +| ----------------------------- | ------------------------------------------------------------------------------------------------- | +| Unread state + helpers | `src/providers/WebSocketProvider.tsx` | +| Protocol derivation | `src/lib/message-protocol.ts` — `messageProtocol`, `isOnMessagesPage`, `messagesRouteForProtocol` | +| Nav badges + click clear | `src/components/nav-main.tsx` — `messagesProtocolForUrl`, `NavMenuItems` | +| WS connect + parse | `src/lib/websocket/websocketService.ts` — `ws…/ws/messages/?token=…` | +| On-page realtime (not unread) | `src/hooks/useMessagesWithWebSocket.ts` | +| Tests | `src/components/nav-main.test.tsx` (mocks WS; no unread assertions yet) | + +--- + +## Data flow + +```mermaid +sequenceDiagram + participant API as meshflow_api_WS + participant WSS as websocketService + participant ES as eventService + participant WSP as WebSocketProvider + participant Nav as nav_main + + API->>WSS: JSON TextMessage frame + WSS->>ES: MESSAGE_RECEIVED + ES->>WSP: messageHandler + alt on protocol messages page + WSP->>WSP: skip unread append + else elsewhere + WSP->>WSP: append to unreadMessages + toast + end + Nav->>WSP: unreadCountForProtocol(slug) + WSP->>Nav: filter by messageProtocol(m) === slug +``` + +### `WebSocketProvider` (session state) + +- State: `unreadMessages: TextMessage[]` (flat list, all protocols). +- **Increment:** `messageHandler` — if `!isOnMessagesPage(pathnameRef.current, messageProtocol(message))`, append message and show toast (sender label + body). +- **Clear one protocol:** `markAsReadForProtocol(protocol)` → `filter` out where `messageProtocol(m) === protocol`. +- **Clear all:** `markAllAsRead()` (exposed; nav does not use it). +- **Route effect:** on `location.pathname`, if `isOnMessagesPage(pathname, 'meshtastic'|'meshcore')`, call `markAsReadForProtocol` for that slug. + +Helpers: + +```ts +unreadCountForProtocol(p) => unreadMessages.filter((m) => messageProtocol(m) === p).length +hasUnreadForProtocol(p) => unreadMessages.some((m) => messageProtocol(m) === p) +hasUnreadMessages => unreadMessages.length > 0 // global; nav uses per-protocol helpers +``` + +### `messageProtocol(msg)` + +| Input `msg.protocol` | Result | +| ----------------------------- | ---------------- | +| `'meshcore'`, `2`, `'mc'` | `meshcore` | +| anything else / **undefined** | **`meshtastic`** | + +REST list responses include `protocol`; **WebSocket frames from the API today do not** (see API doc). That makes `messageProtocol` classify almost all live pushes as Meshtastic — the [#279](https://github.com/pskillen/meshflow-ui/issues/279) symptom. + +### Navigation (`nav-main.tsx`) + +- `messagesProtocolForUrl('/messages')` → `'meshtastic'`; `'/meshcore/messages'` → `'meshcore'`. +- Badge: red circle, count capped display `9+`, shown when `hasUnreadForProtocol(messagesProtocol)`. +- Link `onClick`: `preventDefault`, `markAsReadForProtocol(protocol)`, `navigate(url)`. + +No other components use unread badges today (grep: only `nav-main` + provider). + +### `useMessagesWithWebSocket` (same WS event, different path) + +Subscribes to `MESSAGE_RECEIVED` independently of unread: + +- Drops message if `options.protocol` set and `messageProtocol(message) !== options.protocol`. +- Prepends only when `message.channel === options.channelId` (constellation not checked on WS). + +So MC messages on the MC page may also fail to appear live until API sends `protocol` on WS (same classification issue). + +--- + +## Configuration + +- WS base URL: `config.apis.meshBot.baseUrl` (Meshflow API origin), path `/ws/messages/`, auth `?token={accessToken}`. +- Reconnect: exponential backoff, max 5 attempts; reconnect on token refresh. + +--- + +## Known gaps / fix direction + +| Gap | Notes | +| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | +| [#279](https://github.com/pskillen/meshflow-ui/issues/279) | Fix API `TextMessageWSSerializer` to include `protocol`; optionally add UI test with MC WS fixture | +| Toasts | Fire for every unread append even when badge is wrong protocol | +| No message `id` dedup | Duplicate WS deliveries could double-count | +| `hasUnreadMessages` | Global flag unused by nav; could confuse future features | +| Channel-level unread | Requested for [#281](https://github.com/pskillen/meshflow-ui/issues/281) / roadmap | +| `localStorage` | Not used — unread lost on reload | + +**Minimal fix:** API adds `protocol` to WS JSON; UI unchanged. **Hardening:** unit test `messageProtocol` + provider filter with MC payload; nav test with mocked counts per protocol. + +--- + +## Acceptance criteria mapping ([#279](https://github.com/pskillen/meshflow-ui/issues/279)) + +| Criterion | Current code | Blocker | +| ------------------------- | -------------------------------------- | ----------------------------------------------------------------- | +| MT badge = MT only | `unreadCountForProtocol('meshtastic')` | WS missing `protocol` → MC counted as MT | +| MC badge = MC only | `unreadCountForProtocol('meshcore')` | MC unread rarely increments; MT badge inflated | +| Mark-as-read per protocol | `markAsReadForProtocol` + route effect | Clearing MC works; MC items stuck in array if misclassified as MT | + +--- + +## 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) — WS serializer, signal, Redis group +- [#341](https://github.com/pskillen/meshflow-api/issues/341) — parent epic (full messages UI rework) 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..c13ab64 100644 --- a/docs/messages/current-features.md +++ b/docs/messages/current-features.md @@ -99,9 +99,11 @@ WS handler does not check `constellation_id` on the payload (only channel id). R ## Unread / notification counts +See **[docs/features/messages/unread-count.md](../features/messages/unread-count.md)** ([#279](https://github.com/pskillen/meshflow-ui/issues/279): WS payloads omit `protocol`, so `messageProtocol()` defaults live traffic to Meshtastic). + ### Implemented today -- **Protocol-scoped** unread array in `WebSocketProvider`. +- **Protocol-scoped** unread array in `WebSocketProvider` (intended; classification broken until API sends `protocol` on WS). - 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. diff --git a/docs/messages/gaps-and-roadmap.md b/docs/messages/gaps-and-roadmap.md index a2c4832..08b6188 100644 --- a/docs/messages/gaps-and-roadmap.md +++ b/docs/messages/gaps-and-roadmap.md @@ -42,8 +42,8 @@ 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. +- **Bug [#279](https://github.com/pskillen/meshflow-ui/issues/279):** nav uses per-protocol helpers, but API WebSocket JSON omits `protocol` — see [docs/features/messages/unread-count.md](../features/messages/unread-count.md). +- **Needed:** API add `protocol` to `TextMessageWSSerializer`; 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. ### Layout From 3111cd1de305b6b2f6782a34d6fbe1b29b6af995 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Thu, 4 Jun 2026 14:34:41 +0100 Subject: [PATCH 2/5] feat(messages): channel-aware unread and channel button row (#279) WebSocketProvider: per-channel unread, active view, protocol-level toast. ProtocolMessageHistoryPage: channel buttons with unread badges. --- src/components/nav-main.test.tsx | 4 + .../protocol/ProtocolMessageHistoryPage.tsx | 62 ++++++++---- src/providers/WebSocketProvider.tsx | 94 +++++++++++++++---- 3 files changed, 122 insertions(+), 38 deletions(-) diff --git a/src/components/nav-main.test.tsx b/src/components/nav-main.test.tsx index 0b66002..a7e49eb 100644 --- a/src/components/nav-main.test.tsx +++ b/src/components/nav-main.test.tsx @@ -10,8 +10,12 @@ vi.mock('@/providers/WebSocketProvider', () => ({ unreadMessages: [], markAllAsRead: vi.fn(), markAsReadForProtocol: vi.fn(), + markAsReadForChannel: vi.fn(), + setActiveMessagesView: vi.fn(), unreadCountForProtocol: () => 0, hasUnreadForProtocol: () => false, + unreadCountForChannel: () => 0, + hasUnreadForChannel: () => false, }), })); diff --git a/src/pages/protocol/ProtocolMessageHistoryPage.tsx b/src/pages/protocol/ProtocolMessageHistoryPage.tsx index 2158c6c..d6478cd 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, markAsReadForChannel, 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,9 @@ export function ProtocolMessageHistoryPage({ config }: ProtocolMessageHistoryPag } }; - const handleChannelSelect = (e: React.ChangeEvent) => { - setSelectedChannel(Number(e.target.value)); + const selectChannel = (channelId: number) => { + setSelectedChannel(channelId); + markAsReadForChannel(config.slug, channelId); }; return ( @@ -103,22 +119,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.tsx b/src/providers/WebSocketProvider.tsx index 5a9ebbe..7b7481e 100644 --- a/src/providers/WebSocketProvider.tsx +++ b/src/providers/WebSocketProvider.tsx @@ -7,15 +7,24 @@ import { TextMessage } from '@/lib/models'; import { eventService } from '@/lib/events/eventService'; import { messageProtocol, isOnMessagesPage, type MessageProtocolSlug } from '@/lib/message-protocol'; +export type ActiveMessagesView = { + protocol: MessageProtocolSlug; + channelId: number; +}; + interface WebSocketContextType { isConnected: boolean; connectionState: ConnectionState; unreadMessages: TextMessage[]; markAllAsRead: () => void; markAsReadForProtocol: (protocol: MessageProtocolSlug) => void; + markAsReadForChannel: (protocol: MessageProtocolSlug, channelId: number) => void; + 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 +33,61 @@ const WebSocketContext = createContext({ unreadMessages: [], markAllAsRead: () => {}, markAsReadForProtocol: () => {}, + markAsReadForChannel: () => {}, + 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 Number(message.channel) === 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 markAllAsRead = useCallback(() => { setUnreadMessages([]); }, []); @@ -63,6 +102,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 +132,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 +165,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 +172,25 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { unreadMessages, markAllAsRead, markAsReadForProtocol, + markAsReadForChannel, + setActiveMessagesView, hasUnreadMessages: unreadMessages.length > 0, unreadCountForProtocol, hasUnreadForProtocol, + unreadCountForChannel, + hasUnreadForChannel, }), [ connectionState, unreadMessages, markAllAsRead, markAsReadForProtocol, + markAsReadForChannel, + setActiveMessagesView, unreadCountForProtocol, hasUnreadForProtocol, + unreadCountForChannel, + hasUnreadForChannel, ] ); From f9714ace8e46ab08377ae725cd06a1102157662f Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Thu, 4 Jun 2026 14:35:26 +0100 Subject: [PATCH 3/5] test(messages): protocol and channel unread in WebSocketProvider (#279) Covers active view, mark-as-read, toast protocol scoping, no pathname wipe. --- src/providers/WebSocketProvider.test.tsx | 189 +++++++++++++++++++++-- 1 file changed, 179 insertions(+), 10 deletions(-) diff --git a/src/providers/WebSocketProvider.test.tsx b/src/providers/WebSocketProvider.test.tsx index 2cc2130..7ef03d8 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,58 @@ 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 +118,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 +162,139 @@ 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('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'); + }); }); From cae29c15b7450eadfa925bcb2bb3fa1a88d8b848 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Thu, 4 Jun 2026 14:35:53 +0100 Subject: [PATCH 4/5] docs(messages): update unread-count for channel badges and WS protocol (#279) Link deferred multi-constellation rollup to meshflow-api#396. --- docs/features/messages/README.md | 20 ++-- docs/features/messages/unread-count.md | 147 ++++++++----------------- docs/messages/current-features.md | 21 ++-- docs/messages/gaps-and-roadmap.md | 5 +- 4 files changed, 71 insertions(+), 122 deletions(-) diff --git a/docs/features/messages/README.md b/docs/features/messages/README.md index cc67d4f..f8f5fae 100644 --- a/docs/features/messages/README.md +++ b/docs/features/messages/README.md @@ -8,13 +8,15 @@ Documentation for the Meshflow SPA **text message history** experience (Meshtast ## 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 in code | **Bug [#279](https://github.com/pskillen/meshflow-ui/issues/279)** — WS payloads lack `protocol` | -| Per-channel / persisted unread | Not implemented | In-memory session only | +| 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 @@ -28,8 +30,8 @@ Documentation for the Meshflow SPA **text message history** experience (Meshtast ## Concepts - **Protocol slug** — `'meshtastic' | 'meshcore'` in UI; maps from API `protocol` string or legacy numeric `1`/`2`. -- **Unread** — messages received over WS while the user is **not** on that protocol’s messages route; stored in React state, not `localStorage`. -- **On messages page** — WS still fires, but `WebSocketProvider` does not add to unread; `useMessagesWithWebSocket` may prepend if channel + protocol match. +- **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 diff --git a/docs/features/messages/unread-count.md b/docs/features/messages/unread-count.md index d44c31f..3390ec9 100644 --- a/docs/features/messages/unread-count.md +++ b/docs/features/messages/unread-count.md @@ -1,38 +1,42 @@ # Messages UI — unread count -**Purpose:** How sidebar **Messages** badges and mark-as-read behave today, and why MeshCore traffic can inflate the Meshtastic badge ([#279](https://github.com/pskillen/meshflow-ui/issues/279)). +**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) (WebSocket section), [gaps-and-roadmap.md](../../messages/gaps-and-roadmap.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 (intended) +## 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. -| Rule | Behaviour | -| ----------------------------------- | ------------------------------------------------------------------------------ | -| MT nav badge (`/messages`) | Count of unread **Meshtastic** messages only | -| MC nav badge (`/meshcore/messages`) | Count of unread **MeshCore** messages only | -| User on MT messages page | Do not increment MT unread; clear MT unread when route is `/messages` | -| User on MC messages page | Do not increment MC unread; clear MC unread when route is `/meshcore/messages` | -| User clicks Messages in nav | `markAsReadForProtocol(that protocol)` then navigate | -| Persistence | None — refresh clears all unread | +**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. -Unread means: **arrived via WebSocket while not on that protocol’s messages URL**, until cleared. It is not “messages since last login” from the server. +**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` | -| Protocol derivation | `src/lib/message-protocol.ts` — `messageProtocol`, `isOnMessagesPage`, `messagesRouteForProtocol` | -| Nav badges + click clear | `src/components/nav-main.tsx` — `messagesProtocolForUrl`, `NavMenuItems` | -| WS connect + parse | `src/lib/websocket/websocketService.ts` — `ws…/ws/messages/?token=…` | -| On-page realtime (not unread) | `src/hooks/useMessagesWithWebSocket.ts` | -| Tests | `src/components/nav-main.test.tsx` (mocks WS; no unread assertions yet) | +| 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` | --- @@ -41,101 +45,46 @@ Unread means: **arrived via WebSocket while not on that protocol’s messages UR ```mermaid sequenceDiagram participant API as meshflow_api_WS - participant WSS as websocketService - participant ES as eventService participant WSP as WebSocketProvider participant Nav as nav_main + participant Page as ProtocolMessageHistoryPage - API->>WSS: JSON TextMessage frame - WSS->>ES: MESSAGE_RECEIVED - ES->>WSP: messageHandler - alt on protocol messages page - WSP->>WSP: skip unread append - else elsewhere - WSP->>WSP: append to unreadMessages + toast - end - Nav->>WSP: unreadCountForProtocol(slug) - WSP->>Nav: filter by messageProtocol(m) === slug + 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` (session state) - -- State: `unreadMessages: TextMessage[]` (flat list, all protocols). -- **Increment:** `messageHandler` — if `!isOnMessagesPage(pathnameRef.current, messageProtocol(message))`, append message and show toast (sender label + body). -- **Clear one protocol:** `markAsReadForProtocol(protocol)` → `filter` out where `messageProtocol(m) === protocol`. -- **Clear all:** `markAllAsRead()` (exposed; nav does not use it). -- **Route effect:** on `location.pathname`, if `isOnMessagesPage(pathname, 'meshtastic'|'meshcore')`, call `markAsReadForProtocol` for that slug. - -Helpers: - -```ts -unreadCountForProtocol(p) => unreadMessages.filter((m) => messageProtocol(m) === p).length -hasUnreadForProtocol(p) => unreadMessages.some((m) => messageProtocol(m) === p) -hasUnreadMessages => unreadMessages.length > 0 // global; nav uses per-protocol helpers -``` - -### `messageProtocol(msg)` - -| Input `msg.protocol` | Result | -| ----------------------------- | ---------------- | -| `'meshcore'`, `2`, `'mc'` | `meshcore` | -| anything else / **undefined** | **`meshtastic`** | - -REST list responses include `protocol`; **WebSocket frames from the API today do not** (see API doc). That makes `messageProtocol` classify almost all live pushes as Meshtastic — the [#279](https://github.com/pskillen/meshflow-ui/issues/279) symptom. - -### Navigation (`nav-main.tsx`) +### `WebSocketProvider` -- `messagesProtocolForUrl('/messages')` → `'meshtastic'`; `'/meshcore/messages'` → `'meshcore'`. -- Badge: red circle, count capped display `9+`, shown when `hasUnreadForProtocol(messagesProtocol)`. -- Link `onClick`: `preventDefault`, `markAsReadForProtocol(protocol)`, `navigate(url)`. - -No other components use unread badges today (grep: only `nav-main` + provider). - -### `useMessagesWithWebSocket` (same WS event, different path) - -Subscribes to `MESSAGE_RECEIVED` independently of unread: - -- Drops message if `options.protocol` set and `messageProtocol(message) !== options.protocol`. -- Prepends only when `message.channel === options.channelId` (constellation not checked on WS). - -So MC messages on the MC page may also fail to appear live until API sends `protocol` on WS (same classification issue). - ---- - -## Configuration - -- WS base URL: `config.apis.meshBot.baseUrl` (Meshflow API origin), path `/ws/messages/`, auth `?token={accessToken}`. -- Reconnect: exponential backoff, max 5 attempts; reconnect on token refresh. - ---- +- `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. -## Known gaps / fix direction +Helpers: `unreadCountForProtocol`, `hasUnreadForProtocol`, `unreadCountForChannel`, `hasUnreadForChannel`, `markAsReadForProtocol`, `markAsReadForChannel`, `setActiveMessagesView`. -| Gap | Notes | -| ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------- | -| [#279](https://github.com/pskillen/meshflow-ui/issues/279) | Fix API `TextMessageWSSerializer` to include `protocol`; optionally add UI test with MC WS fixture | -| Toasts | Fire for every unread append even when badge is wrong protocol | -| No message `id` dedup | Duplicate WS deliveries could double-count | -| `hasUnreadMessages` | Global flag unused by nav; could confuse future features | -| Channel-level unread | Requested for [#281](https://github.com/pskillen/meshflow-ui/issues/281) / roadmap | -| `localStorage` | Not used — unread lost on reload | +### `ProtocolMessageHistoryPage` -**Minimal fix:** API adds `protocol` to WS JSON; UI unchanged. **Hardening:** unit test `messageProtocol` + provider filter with MC payload; nav test with mocked counts per protocol. +- Registers `setActiveMessagesView` when `selectedChannel` changes. +- Channel buttons call `markAsReadForChannel` on user click (not on auto-select; see #396). --- -## Acceptance criteria mapping ([#279](https://github.com/pskillen/meshflow-ui/issues/279)) +## Known gaps -| Criterion | Current code | Blocker | -| ------------------------- | -------------------------------------- | ----------------------------------------------------------------- | -| MT badge = MT only | `unreadCountForProtocol('meshtastic')` | WS missing `protocol` → MC counted as MT | -| MC badge = MC only | `unreadCountForProtocol('meshcore')` | MC unread rarely increments; MT badge inflated | -| Mark-as-read per protocol | `markAsReadForProtocol` + route effect | Clearing MC works; MC items stuck in array if misclassified as MT | +| 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) — WS serializer, signal, Redis group -- [#341](https://github.com/pskillen/meshflow-api/issues/341) — parent epic (full messages UI rework) +- [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/current-features.md b/docs/messages/current-features.md index c13ab64..cf51318 100644 --- a/docs/messages/current-features.md +++ b/docs/messages/current-features.md @@ -99,23 +99,20 @@ WS handler does not check `constellation_id` on the payload (only channel id). R ## Unread / notification counts -See **[docs/features/messages/unread-count.md](../features/messages/unread-count.md)** ([#279](https://github.com/pskillen/meshflow-ui/issues/279): WS payloads omit `protocol`, so `messageProtocol()` defaults live traffic to Meshtastic). +See **[docs/features/messages/unread-count.md](../features/messages/unread-count.md)**. ### Implemented today -- **Protocol-scoped** unread array in `WebSocketProvider` (intended; classification broken until API sends `protocol` on WS). -- 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. +- **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). -### Not implemented +### Deferred / not implemented -- 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. - -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 08b6188..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 -- **Bug [#279](https://github.com/pskillen/meshflow-ui/issues/279):** nav uses per-protocol helpers, but API WebSocket JSON omits `protocol` — see [docs/features/messages/unread-count.md](../features/messages/unread-count.md). -- **Needed:** API add `protocol` to `TextMessageWSSerializer`; 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 From ab71355c04cd88b913ea405e0ccf16ffe2bb07a2 Mon Sep 17 00:00:00 2001 From: Patrick Skillen Date: Thu, 4 Jun 2026 14:50:28 +0100 Subject: [PATCH 5/5] fix(messages): correct protocol unread and show unread on channel switch (#279) Infer MeshCore from original_mc_packet_id and MC sender fields when WS omits protocol. Promote unread into MessageList via takeUnreadForChannel instead of clearing on channel click. Dedupe merged messages. --- src/components/nav-main.test.tsx | 1 + src/hooks/useMessagesWithWebSocket.ts | 114 +++++++++++++----- src/lib/message-protocol.test.ts | 37 +++++- src/lib/message-protocol.ts | 35 +++++- src/lib/models.ts | 1 + .../protocol/ProtocolMessageHistoryPage.tsx | 3 +- src/providers/WebSocketProvider.test.tsx | 47 ++++++++ src/providers/WebSocketProvider.tsx | 29 ++++- 8 files changed, 226 insertions(+), 41 deletions(-) diff --git a/src/components/nav-main.test.tsx b/src/components/nav-main.test.tsx index a7e49eb..c77a6d5 100644 --- a/src/components/nav-main.test.tsx +++ b/src/components/nav-main.test.tsx @@ -11,6 +11,7 @@ vi.mock('@/providers/WebSocketProvider', () => ({ markAllAsRead: vi.fn(), markAsReadForProtocol: vi.fn(), markAsReadForChannel: vi.fn(), + takeUnreadForChannel: () => [], setActiveMessagesView: vi.fn(), unreadCountForProtocol: () => 0, hasUnreadForProtocol: () => 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 d6478cd..6727bed 100644 --- a/src/pages/protocol/ProtocolMessageHistoryPage.tsx +++ b/src/pages/protocol/ProtocolMessageHistoryPage.tsx @@ -22,7 +22,7 @@ function channelUnreadBadgeLabel(count: number): string { } export function ProtocolMessageHistoryPage({ config }: ProtocolMessageHistoryPageProps) { - const { setActiveMessagesView, markAsReadForChannel, hasUnreadForChannel, unreadCountForChannel } = useWebSocket(); + const { setActiveMessagesView, hasUnreadForChannel, unreadCountForChannel } = useWebSocket(); const { constellations: allConstellations } = useConstellationsSuspense(); const constellations = useMemo( () => filterConstellationsForProtocol(allConstellations, config.slug), @@ -81,7 +81,6 @@ export function ProtocolMessageHistoryPage({ config }: ProtocolMessageHistoryPag const selectChannel = (channelId: number) => { setSelectedChannel(channelId); - markAsReadForChannel(config.slug, channelId); }; return ( diff --git a/src/providers/WebSocketProvider.test.tsx b/src/providers/WebSocketProvider.test.tsx index 7ef03d8..de910bc 100644 --- a/src/providers/WebSocketProvider.test.tsx +++ b/src/providers/WebSocketProvider.test.tsx @@ -82,6 +82,9 @@ function UnreadProbe() { + ); } @@ -280,6 +283,50 @@ describe('WebSocketProvider', () => { 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); diff --git a/src/providers/WebSocketProvider.tsx b/src/providers/WebSocketProvider.tsx index 7b7481e..8a4fecc 100644 --- a/src/providers/WebSocketProvider.tsx +++ b/src/providers/WebSocketProvider.tsx @@ -5,7 +5,12 @@ 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; @@ -19,6 +24,7 @@ interface WebSocketContextType { 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; @@ -34,6 +40,7 @@ const WebSocketContext = createContext({ markAllAsRead: () => {}, markAsReadForProtocol: () => {}, markAsReadForChannel: () => {}, + takeUnreadForChannel: () => [], setActiveMessagesView: () => {}, hasUnreadMessages: false, unreadCountForProtocol: () => 0, @@ -47,7 +54,7 @@ export function useWebSocket() { } function messageMatchesChannel(message: TextMessage, channelId: number): boolean { - return Number(message.channel) === channelId; + return channelIdFromMessage(message) === channelId; } function isActiveChannelView(pathname: string, view: ActiveMessagesView | null, message: TextMessage): boolean { @@ -88,6 +95,22 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { ); }, []); + 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([]); }, []); @@ -173,6 +196,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { markAllAsRead, markAsReadForProtocol, markAsReadForChannel, + takeUnreadForChannel, setActiveMessagesView, hasUnreadMessages: unreadMessages.length > 0, unreadCountForProtocol, @@ -186,6 +210,7 @@ export function WebSocketProvider({ children }: { children: React.ReactNode }) { markAllAsRead, markAsReadForProtocol, markAsReadForChannel, + takeUnreadForChannel, setActiveMessagesView, unreadCountForProtocol, hasUnreadForProtocol,