Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 27 additions & 1 deletion src/components/MeshCore/MeshCoreDirectMessagesView.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<MeshCoreDirectMessagesView
messages={dmMessages}
contacts={mixed}
status={makeStatus()}
actions={makeActions()}
/>,
);
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(
<MeshCoreDirectMessagesView
Expand Down
26 changes: 23 additions & 3 deletions src/components/MeshCore/MeshCoreDirectMessagesView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
MeshCoreMessage, MeshCoreActions, ConnectionStatus, MeshCoreNode,
} from './hooks/useMeshCore';
import { MeshCoreContact } from '../../utils/meshcoreHelpers';
import { getMeshCoreMessageContentMatchKeys } from '../../utils/messageContentFilter';
import { meshcoreRoleIcon, meshcoreRoleLabelKey, meshcoreRoleLabel } from './meshcoreRole';
import { MeshCoreMessageStream } from './MeshCoreMessageStream';
import { MeshCoreContactDetailPanel } from './MeshCoreContactDetailPanel';
Expand Down Expand Up @@ -221,6 +222,20 @@ export const MeshCoreDirectMessagesView: React.FC<MeshCoreDirectMessagesViewProp
});
}, [messages, contacts, selfKey, canonicalize, contactsByKey, favoriteByKey, sortField, sortDirection]);

// Issue #3922 (MeshCore): let the conversation filter match on message
// *content*, not just the contact's name / public key. Precompute the set of
// (canonicalized) peer keys whose DM history contains the current search term
// so the peer filter can do an O(1) membership check instead of rescanning
// messages per peer.
const messageContentMatchKeys = useMemo(
() =>
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
Expand All @@ -234,9 +249,14 @@ export const MeshCoreDirectMessagesView: React.FC<MeshCoreDirectMessagesViewProp
return peers.filter(key => {
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 [];
Expand Down Expand Up @@ -372,7 +392,7 @@ export const MeshCoreDirectMessagesView: React.FC<MeshCoreDirectMessagesViewProp
type="text"
value={searchQuery}
onChange={e => 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 && (
Expand Down
97 changes: 96 additions & 1 deletion src/utils/messageContentFilter.test.ts
Original file line number Diff line number Diff line change
@@ -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. */
Expand Down Expand Up @@ -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>): 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);
});
});
73 changes: 73 additions & 0 deletions src/utils/messageContentFilter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
const { canonicalize = (k) => k, isChannelKey = () => false, minLength = 2 } = options;
const matchKeys = new Set<string>();
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;
}
Loading