diff --git a/public/locales/en.json b/public/locales/en.json index 4caf4d9f7..359b89a3a 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -367,7 +367,7 @@ "messages.nodes_header": "Nodes", "messages.mark_all_read_title": "Mark all direct messages as read", "messages.mark_all_read_button": "Mark All Read", - "messages.filter_placeholder": "Filter nodes...", + "messages.filter_placeholder": "Filter nodes or messages...", "messages.filter_conversations_title": "Filter conversations", "messages.all_conversations": "All Conversations", "messages.unread_only": "Unread Only", diff --git a/src/components/MessagesTab.tsx b/src/components/MessagesTab.tsx index bff3dfba5..ce01b458f 100644 --- a/src/components/MessagesTab.tsx +++ b/src/components/MessagesTab.tsx @@ -28,6 +28,7 @@ import { isDeviceDbWarningMitigatable } from '../utils/deviceDbWarning'; import { applyHomoglyphOptimization } from '../utils/homoglyph'; import { calculateDistance, formatDistance, getDistanceToNode } from '../utils/distance'; import { renderMessageWithLinks } from '../utils/linkRenderer'; +import { getMessageContentMatchNodeIds } from '../utils/messageContentFilter'; import { isNodeComplete, isInfrastructureNode, hasValidPosition, parseNodeId } from '../utils/nodeHelpers'; import { getEffectiveHops } from '../utils/nodeHops'; import { scrollInputIntoView } from '../utils/scrollInputIntoView'; @@ -524,6 +525,15 @@ const MessagesTab: React.FC = ({ [messages] ); + // Issue #3922: let the conversation filter match on message *content*, not + // just the partner's name/id. Precompute the set of node IDs whose DM + // history contains the current filter term so both node-list renderers can + // do an O(1) membership check instead of rescanning messages per node. + const messageContentMatchNodeIds = useMemo( + () => getMessageContentMatchNodeIds(messages, messagesNodeFilter), + [messages, messagesNodeFilter] + ); + // Handle relay node click - opens modal to show potential relay nodes const handleRelayClick = useCallback( async (msg: MeshMessage) => { @@ -747,7 +757,9 @@ const MessagesTab: React.FC = ({ return ( node.user?.longName?.toLowerCase().includes(searchTerm) || node.user?.shortName?.toLowerCase().includes(searchTerm) || - node.user?.id?.toLowerCase().includes(searchTerm) + node.user?.id?.toLowerCase().includes(searchTerm) || + // Issue #3922: also match conversations by message content. + (node.user?.id ? messageContentMatchNodeIds.has(node.user.id) : false) ); }); @@ -1029,7 +1041,9 @@ const MessagesTab: React.FC = ({ return ( node.user?.longName?.toLowerCase().includes(searchTerm) || node.user?.shortName?.toLowerCase().includes(searchTerm) || - node.user?.id?.toLowerCase().includes(searchTerm) + node.user?.id?.toLowerCase().includes(searchTerm) || + // Issue #3922: also match conversations by message content. + (node.user?.id ? messageContentMatchNodeIds.has(node.user.id) : false) ); }) .map(node => { diff --git a/src/utils/messageContentFilter.test.ts b/src/utils/messageContentFilter.test.ts new file mode 100644 index 000000000..2609a5a21 --- /dev/null +++ b/src/utils/messageContentFilter.test.ts @@ -0,0 +1,85 @@ +import { describe, it, expect } from 'vitest'; +import { getMessageContentMatchNodeIds } from './messageContentFilter'; +import type { MeshMessage } from '../types/message'; + +/** Minimal DM message factory with the fields the filter inspects. */ +function dm(overrides: Partial): MeshMessage { + return { + id: Math.random().toString(36).slice(2), + from: '!aaaa0001', + to: '!bbbb0002', + fromNodeId: '!aaaa0001', + toNodeId: '!bbbb0002', + text: '', + channel: -1, + portnum: 1, + timestamp: new Date(), + ...overrides, + }; +} + +describe('getMessageContentMatchNodeIds', () => { + it('returns an empty set when the term is shorter than minLength', () => { + const messages = [dm({ text: 'pizza party tonight' })]; + expect(getMessageContentMatchNodeIds(messages, 'p').size).toBe(0); + expect(getMessageContentMatchNodeIds(messages, '').size).toBe(0); + expect(getMessageContentMatchNodeIds(messages, ' ').size).toBe(0); + }); + + it('matches message body case-insensitively and returns both parties', () => { + const messages = [dm({ from: '!aaaa0001', to: '!bbbb0002', text: 'Pizza party tonight' })]; + const ids = getMessageContentMatchNodeIds(messages, 'PIZZA'); + expect(ids.has('!aaaa0001')).toBe(true); + expect(ids.has('!bbbb0002')).toBe(true); + expect(ids.size).toBe(2); + }); + + it('matches on substrings, not just whole words', () => { + const messages = [dm({ text: 'meet at the trailhead' })]; + expect(getMessageContentMatchNodeIds(messages, 'trail').size).toBe(2); + }); + + it('ignores broadcast messages (to === !ffffffff)', () => { + const messages = [dm({ to: '!ffffffff', text: 'pizza for everyone' })]; + expect(getMessageContentMatchNodeIds(messages, 'pizza').size).toBe(0); + }); + + it('ignores channel messages (channel !== -1)', () => { + const messages = [dm({ channel: 0, text: 'pizza in channel' })]; + expect(getMessageContentMatchNodeIds(messages, 'pizza').size).toBe(0); + }); + + it('ignores non-text portnums', () => { + const messages = [dm({ portnum: 3, text: 'pizza telemetry' })]; + expect(getMessageContentMatchNodeIds(messages, 'pizza').size).toBe(0); + }); + + it('ignores messages with no matching text', () => { + const messages = [dm({ text: 'hello world' })]; + expect(getMessageContentMatchNodeIds(messages, 'pizza').size).toBe(0); + }); + + it('handles empty / missing text safely', () => { + const messages = [dm({ text: '' }), dm({ text: undefined as unknown as string })]; + expect(getMessageContentMatchNodeIds(messages, 'pizza').size).toBe(0); + }); + + it('aggregates matches across multiple conversations', () => { + const messages = [ + dm({ from: '!aaaa0001', to: '!bbbb0002', text: 'pizza tonight' }), + dm({ from: '!cccc0003', to: '!aaaa0001', text: 'want pizza?' }), + dm({ from: '!dddd0004', to: '!aaaa0001', text: 'tacos instead' }), + ]; + const ids = getMessageContentMatchNodeIds(messages, 'pizza'); + expect(ids.has('!aaaa0001')).toBe(true); + expect(ids.has('!bbbb0002')).toBe(true); + expect(ids.has('!cccc0003')).toBe(true); + expect(ids.has('!dddd0004')).toBe(false); + }); + + it('respects a custom minLength', () => { + const messages = [dm({ text: 'ok' })]; + expect(getMessageContentMatchNodeIds(messages, 'ok', 3).size).toBe(0); + expect(getMessageContentMatchNodeIds(messages, 'ok', 2).size).toBe(2); + }); +}); diff --git a/src/utils/messageContentFilter.ts b/src/utils/messageContentFilter.ts new file mode 100644 index 000000000..f60f8bec3 --- /dev/null +++ b/src/utils/messageContentFilter.ts @@ -0,0 +1,52 @@ +import type { MeshMessage } from '../types/message'; + +/** + * Build the set of node IDs whose direct-message history contains a given + * search term in the message body. + * + * Issue #3922: the Messages conversation-list filter historically matched only + * on the conversation partner's node name / short name / node ID. This helper + * lets the same filter also surface conversations by *what was said*, so a user + * can find "that one message about X" without knowing who sent it. + * + * The match mirrors the DM predicate used by `getDMMessages` in MessagesTab: + * only text direct messages (`channel === -1`, `portnum === 1`) that are not + * broadcasts are considered. Both the `from` and `to` node IDs of a matching + * message are added, because the conversation partner may be either party + * depending on whether the message was inbound or outbound. + * + * The search is a case-insensitive substring match, matching the existing + * node-name filter behaviour and the "simple LIKE/ILIKE" approach requested in + * the issue. It operates over the in-memory `messages` array only (no network + * round-trip); the global Ctrl+K search modal covers exhaustive server-side + * full-text search. + * + * @param messages In-memory message list held by the Messages view. + * @param filter The raw filter text entered by the user. + * @param minLength Minimum trimmed term length before content matching kicks + * in, to avoid matching nearly every conversation on a single + * character. Defaults to 2. + * @returns A set of node IDs (e.g. `!abcd1234`) with at least one matching DM. + */ +export function getMessageContentMatchNodeIds( + messages: MeshMessage[], + filter: string, + minLength = 2, +): Set { + const matchIds = new Set(); + const term = filter.trim().toLowerCase(); + if (term.length < minLength) return matchIds; + + for (const msg of messages) { + // Direct text messages only — mirror getDMMessages()'s predicate. + if (msg.channel !== -1 || msg.portnum !== 1) continue; + if (msg.to === '!ffffffff') continue; + if (!msg.text) continue; + if (!msg.text.toLowerCase().includes(term)) continue; + + if (msg.from) matchIds.add(msg.from); + if (msg.to) matchIds.add(msg.to); + } + + return matchIds; +}