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
2 changes: 1 addition & 1 deletion public/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
18 changes: 16 additions & 2 deletions src/components/MessagesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -524,6 +525,15 @@ const MessagesTab: React.FC<MessagesTabProps> = ({
[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) => {
Expand Down Expand Up @@ -747,7 +757,9 @@ const MessagesTab: React.FC<MessagesTabProps> = ({
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)
);
});

Expand Down Expand Up @@ -1029,7 +1041,9 @@ const MessagesTab: React.FC<MessagesTabProps> = ({
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 => {
Expand Down
85 changes: 85 additions & 0 deletions src/utils/messageContentFilter.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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);
});
});
52 changes: 52 additions & 0 deletions src/utils/messageContentFilter.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
const matchIds = new Set<string>();
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;
}
Loading