From 4f4eaf0a4fffc04e6a1b70503816447eb2bfdb6e Mon Sep 17 00:00:00 2001 From: Randall Hand Date: Sun, 5 Jul 2026 13:09:12 -0400 Subject: [PATCH] feat(meshcore): extend in-tab conversation content filter to MeshCore DMs (#3922) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PR #3931 (issue #3922) added an in-tab conversation-list filter that matches message body content, but scoped it to Meshtastic DMs only and excluded MeshCore. This extends the same behavior to the MeshCore Direct Messages view. - Add pure helper getMeshCoreMessageContentMatchKeys() alongside the existing getMessageContentMatchNodeIds() in src/utils/messageContentFilter.ts. It mirrors the MeshCore DM predicate (excludes room_post + channel pseudo-keys, requires a toPublicKey) and accepts optional canonicalize / isChannelKey hooks so match keys line up with the canonicalized peer keys in the DM list. - Wire it into MeshCoreDirectMessagesView's filteredPeers useMemo so the contact-list search also surfaces conversations by what was said, not just the contact name / public key. - Update the search placeholder ("Search contacts…" -> "Search contacts or messages…"), English only (inline default + new en.json key); other locales route through Weblate. - Add Vitest coverage: helper unit tests mirroring messageContentFilter.test.ts plus a DM-view integration test asserting a content match surfaces the right contact. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01AphLfgUJWaFNAgU5UXdJ4n --- public/locales/en.json | 1 + .../MeshCoreDirectMessagesView.test.tsx | 28 +++++- .../MeshCore/MeshCoreDirectMessagesView.tsx | 26 ++++- src/utils/messageContentFilter.test.ts | 97 ++++++++++++++++++- src/utils/messageContentFilter.ts | 73 ++++++++++++++ 5 files changed, 220 insertions(+), 5 deletions(-) diff --git a/public/locales/en.json b/public/locales/en.json index a9fa54125..a81459fc0 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -368,6 +368,7 @@ "messages.mark_all_read_title": "Mark all direct messages as read", "messages.mark_all_read_button": "Mark All Read", "messages.filter_placeholder": "Filter nodes or messages...", + "meshcore.search_contacts": "Search contacts or messages…", "messages.filter_conversations_title": "Filter conversations", "messages.all_conversations": "All Conversations", "messages.unread_only": "Unread Only", diff --git a/src/components/MeshCore/MeshCoreDirectMessagesView.test.tsx b/src/components/MeshCore/MeshCoreDirectMessagesView.test.tsx index a4c9e0e15..e685e7454 100644 --- a/src/components/MeshCore/MeshCoreDirectMessagesView.test.tsx +++ b/src/components/MeshCore/MeshCoreDirectMessagesView.test.tsx @@ -579,11 +579,37 @@ describe('MeshCoreDirectMessagesView — node-type filter (#3890)', () => { // Companions only… fireEvent.change(screen.getByTitle('Filter by node type'), { target: { value: '1' } }); // …then narrow further to "Carl". - fireEvent.change(screen.getByPlaceholderText('Search contacts…'), { target: { value: 'Carl' } }); + fireEvent.change(screen.getByPlaceholderText('Search contacts or messages…'), { target: { value: 'Carl' } }); expect(listedNames()).toEqual(['Companion Carl']); }); + it('matches a conversation by message content, not just the contact name (#3922)', () => { + const self = 'self'.padEnd(64, '0'); + const dmMessages: MeshCoreMessage[] = [ + // DM from self to Carl whose body mentions "pizza" — Carl's name has no + // "pizza", so only content matching should surface him. + { id: 'm1', fromPublicKey: self, toPublicKey: 'a'.repeat(64), text: 'pizza tonight?', timestamp: 5000, messageType: 'text' }, + // Unrelated DM to Rita — should stay hidden for a "pizza" search. + { id: 'm2', fromPublicKey: self, toPublicKey: 'b'.repeat(64), text: 'traceroute please', timestamp: 4000, messageType: 'text' }, + ]; + render( + , + ); + fireEvent.change(screen.getByPlaceholderText('Search contacts or messages…'), { target: { value: 'pizza' } }); + + const names = listedNames(); + expect(names).toContain('Companion Carl'); + expect(names).not.toContain('Repeater Rita'); + expect(names).not.toContain('Sensor Sam'); + expect(names).not.toContain('Companion Cora'); + }); + it('shows a type-specific empty state when no contact matches the filter', () => { render( + getMeshCoreMessageContentMatchKeys(messages, searchQuery, { + canonicalize, + isChannelKey: isChannelPseudoKey, + }), + [messages, searchQuery, canonicalize], + ); + const filteredPeers = useMemo(() => { let peers = dmPeers; // Node-type filter (#3890). A peer that only exists via a message thread @@ -234,9 +249,14 @@ export const MeshCoreDirectMessagesView: React.FC { const c = contactsByKey.get(key); const name = c?.advName || c?.name || ''; - return name.toLowerCase().includes(q) || key.toLowerCase().includes(q); + return ( + name.toLowerCase().includes(q) || + key.toLowerCase().includes(q) || + // Issue #3922: also match conversations by message content. + messageContentMatchKeys.has(key) + ); }); - }, [dmPeers, searchQuery, contactsByKey, typeFilter]); + }, [dmPeers, searchQuery, contactsByKey, typeFilter, messageContentMatchKeys]); const filtered = useMemo(() => { if (!selected) return []; @@ -372,7 +392,7 @@ export const MeshCoreDirectMessagesView: React.FC setSearchQuery(e.target.value)} - placeholder={t('meshcore.search_contacts', 'Search contacts…')} + placeholder={t('meshcore.search_contacts', 'Search contacts or messages…')} className="meshcore-search-input" /> {searchQuery && ( diff --git a/src/utils/messageContentFilter.test.ts b/src/utils/messageContentFilter.test.ts index 2609a5a21..d71102e84 100644 --- a/src/utils/messageContentFilter.test.ts +++ b/src/utils/messageContentFilter.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest'; -import { getMessageContentMatchNodeIds } from './messageContentFilter'; +import { + getMessageContentMatchNodeIds, + getMeshCoreMessageContentMatchKeys, + type MeshCoreContentMessage, +} from './messageContentFilter'; import type { MeshMessage } from '../types/message'; /** Minimal DM message factory with the fields the filter inspects. */ @@ -83,3 +87,94 @@ describe('getMessageContentMatchNodeIds', () => { expect(getMessageContentMatchNodeIds(messages, 'ok', 2).size).toBe(2); }); }); + +/** Minimal MeshCore DM message factory with the fields the filter inspects. */ +function mcdm(overrides: Partial): MeshCoreContentMessage { + return { + fromPublicKey: 'aaaa0001', + toPublicKey: 'bbbb0002', + text: '', + messageType: 'text', + ...overrides, + }; +} + +describe('getMeshCoreMessageContentMatchKeys', () => { + it('returns an empty set when the term is shorter than minLength', () => { + const messages = [mcdm({ text: 'pizza party tonight' })]; + expect(getMeshCoreMessageContentMatchKeys(messages, 'p').size).toBe(0); + expect(getMeshCoreMessageContentMatchKeys(messages, '').size).toBe(0); + expect(getMeshCoreMessageContentMatchKeys(messages, ' ').size).toBe(0); + }); + + it('matches message body case-insensitively and returns both parties', () => { + const messages = [mcdm({ fromPublicKey: 'aaaa0001', toPublicKey: 'bbbb0002', text: 'Pizza party tonight' })]; + const keys = getMeshCoreMessageContentMatchKeys(messages, 'PIZZA'); + expect(keys.has('aaaa0001')).toBe(true); + expect(keys.has('bbbb0002')).toBe(true); + expect(keys.size).toBe(2); + }); + + it('matches on substrings, not just whole words', () => { + const messages = [mcdm({ text: 'meet at the trailhead' })]; + expect(getMeshCoreMessageContentMatchKeys(messages, 'trail').size).toBe(2); + }); + + it('ignores room posts (messageType === room_post)', () => { + const messages = [mcdm({ messageType: 'room_post', text: 'pizza in the room' })]; + expect(getMeshCoreMessageContentMatchKeys(messages, 'pizza').size).toBe(0); + }); + + it('ignores messages with no toPublicKey', () => { + const messages = [mcdm({ toPublicKey: undefined, text: 'pizza broadcast' })]; + expect(getMeshCoreMessageContentMatchKeys(messages, 'pizza').size).toBe(0); + }); + + it('ignores channel pseudo-key messages when isChannelKey is supplied', () => { + const isChannelKey = (k: string) => k.startsWith('channel-'); + const messages = [ + mcdm({ fromPublicKey: 'channel-0', toPublicKey: 'channel-0', text: 'pizza in channel' }), + mcdm({ fromPublicKey: 'aaaa0001', toPublicKey: 'channel-1', text: 'pizza to channel' }), + ]; + expect(getMeshCoreMessageContentMatchKeys(messages, 'pizza', { isChannelKey }).size).toBe(0); + }); + + it('ignores messages with no matching text', () => { + const messages = [mcdm({ text: 'hello world' })]; + expect(getMeshCoreMessageContentMatchKeys(messages, 'pizza').size).toBe(0); + }); + + it('handles empty / missing text safely', () => { + const messages = [mcdm({ text: '' }), mcdm({ text: undefined })]; + expect(getMeshCoreMessageContentMatchKeys(messages, 'pizza').size).toBe(0); + }); + + it('aggregates matches across multiple conversations', () => { + const messages = [ + mcdm({ fromPublicKey: 'aaaa0001', toPublicKey: 'bbbb0002', text: 'pizza tonight' }), + mcdm({ fromPublicKey: 'cccc0003', toPublicKey: 'aaaa0001', text: 'want pizza?' }), + mcdm({ fromPublicKey: 'dddd0004', toPublicKey: 'aaaa0001', text: 'tacos instead' }), + ]; + const keys = getMeshCoreMessageContentMatchKeys(messages, 'pizza'); + expect(keys.has('aaaa0001')).toBe(true); + expect(keys.has('bbbb0002')).toBe(true); + expect(keys.has('cccc0003')).toBe(true); + expect(keys.has('dddd0004')).toBe(false); + }); + + it('canonicalizes prefix keys via the supplied callback', () => { + // Inbound messages arrive with a short pubkey prefix; canonicalize maps it + // to the full contact key so match keys align with the DM list. + const canonicalize = (k: string) => (k === 'bbbb' ? 'bbbb0002ffffffff' : k); + const messages = [mcdm({ fromPublicKey: 'aaaa0001', toPublicKey: 'bbbb', text: 'pizza time' })]; + const keys = getMeshCoreMessageContentMatchKeys(messages, 'pizza', { canonicalize }); + expect(keys.has('bbbb0002ffffffff')).toBe(true); + expect(keys.has('bbbb')).toBe(false); + }); + + it('respects a custom minLength', () => { + const messages = [mcdm({ text: 'ok' })]; + expect(getMeshCoreMessageContentMatchKeys(messages, 'ok', { minLength: 3 }).size).toBe(0); + expect(getMeshCoreMessageContentMatchKeys(messages, 'ok', { minLength: 2 }).size).toBe(2); + }); +}); diff --git a/src/utils/messageContentFilter.ts b/src/utils/messageContentFilter.ts index f60f8bec3..3b8044b3c 100644 --- a/src/utils/messageContentFilter.ts +++ b/src/utils/messageContentFilter.ts @@ -50,3 +50,76 @@ export function getMessageContentMatchNodeIds( return matchIds; } + +/** + * Minimal shape of the in-memory MeshCore message the content filter inspects. + * A subset of `MeshCoreMessage` (see `src/components/MeshCore/hooks/useMeshCore.ts`) + * so this helper stays pure and decoupled from the React hook types. + */ +export interface MeshCoreContentMessage { + fromPublicKey: string; + toPublicKey?: string; + text?: string; + /** 'text' (DMs / channel) or 'room_post' (room server posts). */ + messageType?: string; +} + +/** + * Build the set of MeshCore contact public keys whose direct-message history + * contains a given search term in the message body. + * + * Issue #3922 (MeshCore extension): mirrors {@link getMessageContentMatchNodeIds} + * for the MeshCore Direct Messages view, whose conversation-list filter + * historically matched only on the contact's name / public key. This lets the + * same filter also surface conversations by *what was said*. + * + * The DM predicate mirrors `dmPeers`/`filtered` in `MeshCoreDirectMessagesView`: + * room posts (`messageType === 'room_post'`) and channel pseudo-keys are + * excluded, and only messages with a `toPublicKey` are considered. Both parties' + * keys are added (canonicalized when a `canonicalize` fn is supplied), because + * the conversation partner may be either the sender or the recipient. + * + * The search is a case-insensitive substring match over the in-memory `messages` + * array only (no network round-trip); the global Ctrl+K search modal covers + * exhaustive server-side search across all MeshCore sources. + * + * @param messages In-memory MeshCore message list held by the DM view. + * @param filter The raw filter text entered by the user. + * @param options Optional hooks: + * - `canonicalize`: maps a raw (possibly prefix) key to its full contact key + * so match keys line up with the canonicalized peer keys in the DM list. + * - `isChannelKey`: predicate identifying synthetic `channel-*` pseudo-keys to + * exclude (mirrors `isChannelPseudoKey`). + * - `minLength`: minimum trimmed term length before content matching kicks in + * (defaults to 2), to avoid matching nearly every conversation on a single + * character. + * @returns A set of contact public keys with at least one matching DM. + */ +export function getMeshCoreMessageContentMatchKeys( + messages: MeshCoreContentMessage[], + filter: string, + options: { + canonicalize?: (key: string) => string; + isChannelKey?: (key: string) => boolean; + minLength?: number; + } = {}, +): Set { + const { canonicalize = (k) => k, isChannelKey = () => false, minLength = 2 } = options; + const matchKeys = new Set(); + const term = filter.trim().toLowerCase(); + if (term.length < minLength) return matchKeys; + + for (const msg of messages) { + // Direct text messages only — mirror the DM predicate in the DM view. + if (msg.messageType === 'room_post') continue; + if (!msg.toPublicKey) continue; + if (isChannelKey(msg.toPublicKey) || isChannelKey(msg.fromPublicKey)) continue; + if (!msg.text) continue; + if (!msg.text.toLowerCase().includes(term)) continue; + + if (msg.fromPublicKey) matchKeys.add(canonicalize(msg.fromPublicKey)); + if (msg.toPublicKey) matchKeys.add(canonicalize(msg.toPublicKey)); + } + + return matchKeys; +}