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;
+}