feat(search): parse from:/in:/after:/before: and pass them in the filter (#2871)

## Summary
- Adds a pure-TS operator parser (`from:` / `in:` / `after:` /
`before:`) with unit tests. Invalid date tokens stay in the FTS text.
- Extends `search_messages` so the desktop can send `authors` / `since`
/ `until` (and existing `#h`) on the filter the relay already
understands.
- Wires topbar search to strip operators from the prefix query, resolve
`in:` against local channels and `from:` against hex pubkeys / known
agents, then pass the structured fields through.

This is part 1 of #2853 (parser + command plumbing). Autocomplete chips
/ richer `from:@name` resolution can follow in a second PR.

## Test plan
- [x] `node --import ./test-loader.mjs --experimental-strip-types --test
src/features/search/lib/parseSearchOperators.test.mjs`
- [x] Added `search_messages_filter_emits_operator_fields` unit test
(full `buzz-desktop` crate build needs local sidecar binaries in this
environment)
- [ ] Manual: topbar `deploy from:<hex> after:2024-01-01` emits
authors/since on the bridge filter and returns narrowed hits

Made with [Cursor](https://cursor.com)

---------

Signed-off-by: Jatinder Mahajan <jatinder.mahajan@certifyos.com>
Co-authored-by: Jatinder Mahajan <jatinder.mahajan@certifyos.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
Jatinder Mahajan
2026-07-28 03:22:56 +05:30
committed by GitHub
parent 0019f80765
commit cb2a265b53
12 changed files with 756 additions and 67 deletions

View File

@@ -138,7 +138,14 @@ pub async fn get_feed(
})
}
fn build_search_messages_filter(q: &str, cap: u32, channel_id: Option<&str>) -> serde_json::Value {
fn build_search_messages_filter(
q: &str,
cap: u32,
channel_id: Option<&str>,
authors: Option<&[String]>,
since: Option<i64>,
until: Option<i64>,
) -> serde_json::Value {
let mut filter = serde_json::Map::new();
filter.insert(
"kinds".to_string(),
@@ -153,6 +160,25 @@ fn build_search_messages_filter(q: &str, cap: u32, channel_id: Option<&str>) ->
if let Some(cid) = channel_id {
filter.insert("#h".to_string(), serde_json::json!([cid]));
}
// Optional operators from the desktop search parser (#2853). The relay
// already maps authors/since/until onto FTS; search remains never the
// access boundary (hits are refetched and re-authorized).
if let Some(authors) = authors {
let cleaned: Vec<&str> = authors
.iter()
.map(|a| a.trim())
.filter(|a| !a.is_empty())
.collect();
if !cleaned.is_empty() {
filter.insert("authors".to_string(), serde_json::json!(cleaned));
}
}
if let Some(since) = since {
filter.insert("since".to_string(), serde_json::json!(since));
}
if let Some(until) = until {
filter.insert("until".to_string(), serde_json::json!(until));
}
serde_json::Value::Object(filter)
}
@@ -161,10 +187,20 @@ pub async fn search_messages(
q: String,
limit: Option<u32>,
channel_id: Option<String>,
authors: Option<Vec<String>>,
since: Option<i64>,
until: Option<i64>,
state: State<'_, AppState>,
) -> Result<SearchResponse, String> {
let cap = limit.unwrap_or(20).min(100);
let filter = build_search_messages_filter(&q, cap, channel_id.as_deref());
let filter = build_search_messages_filter(
&q,
cap,
channel_id.as_deref(),
authors.as_deref(),
since,
until,
);
let events = query_relay(&state, &[filter]).await?;
Ok(nostr_convert::search_response_from_events(&events))

View File

@@ -80,12 +80,38 @@ fn managed_agent_message_builder_rejects_invalid_mentions() {
}
#[test]
fn search_messages_filter_requests_prefix_mode_for_topbar_typeahead() {
let filter = build_search_messages_filter(" pro ", 12, Some("channel-1"));
let filter = build_search_messages_filter(" pro ", 12, Some("channel-1"), None, None, None);
assert_eq!(filter["search"], serde_json::json!("pro"));
assert_eq!(filter["search_mode"], serde_json::json!("prefix"));
assert_eq!(filter["limit"], serde_json::json!(12));
assert_eq!(filter["#h"], serde_json::json!(["channel-1"]));
assert!(filter.get("authors").is_none());
assert!(filter.get("since").is_none());
assert!(filter.get("until").is_none());
}
#[test]
fn search_messages_filter_emits_operator_fields() {
let authors =
vec!["aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899".to_string()];
let filter = build_search_messages_filter(
"deploy",
20,
Some("channel-uuid"),
Some(&authors),
Some(1_700_000_000),
Some(1_700_086_400),
);
assert_eq!(filter["search"], serde_json::json!("deploy"));
assert_eq!(filter["#h"], serde_json::json!(["channel-uuid"]));
assert_eq!(
filter["authors"],
serde_json::json!(["aabbccddeeff00112233445566778899aabbccddeeff00112233445566778899"])
);
assert_eq!(filter["since"], serde_json::json!(1_700_000_000));
assert_eq!(filter["until"], serde_json::json!(1_700_086_400));
}
#[test]

View File

@@ -6,23 +6,45 @@ export function useSearchMessagesQuery(
query: string,
options?: {
channelId?: string;
authors?: string[];
since?: number | null;
until?: number | null;
enabled?: boolean;
limit?: number;
unresolvedOperator?: boolean;
},
) {
const trimmedQuery = query.trim();
const enabled = options?.enabled ?? true;
const limit = options?.limit ?? 12;
const channelId = options?.channelId;
const authors = options?.authors;
const since = options?.since ?? null;
const until = options?.until ?? null;
const unresolvedOperator = options?.unresolvedOperator ?? false;
return useQuery({
queryKey: ["search-messages", trimmedQuery, limit, channelId ?? null],
queryKey: [
"search-messages",
trimmedQuery,
limit,
channelId ?? null,
authors ?? null,
since,
until,
unresolvedOperator,
],
queryFn: () =>
searchMessages({
q: trimmedQuery,
limit,
channelId,
authors,
since: since ?? undefined,
until: until ?? undefined,
}),
// Call sites own the "when to search" floor (FTS length / unresolved
// operators). Keep a single threshold here so it cannot drift.
enabled: enabled && trimmedQuery.length >= 2,
staleTime: 30_000,
gcTime: 5 * 60 * 1_000,

View File

@@ -0,0 +1,118 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
isChannelUuid,
isHexPubkey,
normalizeFromHandle,
normalizeInChannel,
parseSearchOperators,
} from "./parseSearchOperators.ts";
test("leaves plain text unchanged", () => {
assert.deepEqual(parseSearchOperators(" deploy "), {
text: "deploy",
from: null,
in: null,
since: null,
until: null,
});
});
test("extracts from / in / after / before and keeps remaining FTS text", () => {
const parsed = parseSearchOperators(
"deploy from:deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef in:#general after:2024-01-15 before:2024-02-01 status",
);
assert.equal(parsed.text, "deploy status");
assert.equal(
parsed.from,
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
);
assert.equal(parsed.in, "#general");
assert.equal(
parsed.since,
Math.floor(new Date(2024, 0, 15).getTime() / 1000),
);
assert.equal(
parsed.until,
Math.floor(new Date(2024, 1, 1).getTime() / 1000) - 1,
);
});
test("before: excludes the named day's exact local midnight", () => {
const localMidnight = Math.floor(new Date(2024, 1, 1).getTime() / 1000);
const parsed = parseSearchOperators("deploy before:2024-02-01");
assert.equal(parsed.until, localMidnight - 1);
// The relay keeps events where `created_at <= until`, so a message stamped
// exactly at midnight must fail that predicate.
assert.equal(localMidnight <= parsed.until, false);
});
test("keeps invalid date operators in the FTS text", () => {
const parsed = parseSearchOperators("notes after:yesterday before:soon");
assert.equal(parsed.text, "notes after:yesterday before:soon");
assert.equal(parsed.since, null);
assert.equal(parsed.until, null);
});
test("later operators of the same kind win", () => {
const parsed = parseSearchOperators(
"from:@alice from:@bob in:one in:two after:2024-01-01 after:2024-03-01",
);
assert.equal(parsed.from, "@bob");
assert.equal(parsed.in, "two");
assert.equal(parsed.since, Math.floor(new Date(2024, 2, 1).getTime() / 1000));
assert.equal(parsed.text, "");
});
test("rejects impossible calendar dates", () => {
const parsed = parseSearchOperators("x after:2024-02-30");
assert.equal(parsed.since, null);
assert.equal(parsed.text, "x after:2024-02-30");
});
test("does not treat hyphen or slash adjacent tokens as operators", () => {
assert.deepEqual(parseSearchOperators("built-in:react hooks"), {
text: "built-in:react hooks",
from: null,
in: null,
since: null,
until: null,
});
assert.deepEqual(parseSearchOperators("sign-in:flow broken"), {
text: "sign-in:flow broken",
from: null,
in: null,
since: null,
until: null,
});
assert.deepEqual(parseSearchOperators("https://x.com/in:foo"), {
text: "https://x.com/in:foo",
from: null,
in: null,
since: null,
until: null,
});
});
test("strips trailing punctuation from operator values", () => {
const parsed = parseSearchOperators("deploy in:general, from:@alice.");
assert.equal(parsed.in, "#general".replace("#", "") || "general");
assert.equal(parsed.in, "general");
assert.equal(parsed.from, "@alice");
assert.equal(parsed.text, "deploy");
});
test("helpers recognize hex pubkeys and channel uuids", () => {
assert.equal(
isHexPubkey(
"deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef",
),
true,
);
assert.equal(isHexPubkey("npub1abc"), false);
assert.equal(isChannelUuid("11111111-1111-1111-1111-111111111111"), true);
assert.equal(normalizeFromHandle("@alice"), "alice");
assert.equal(normalizeInChannel("#general"), "general");
});

View File

@@ -0,0 +1,168 @@
/**
* Parse Slack-style search operators out of a free-text query.
*
* Remaining text is the FTS / prefix query. Invalid operator tokens (for example
* `after:yesterday`) are left in the text so they still participate in FTS.
*
* Name resolution for `from:` / `in:` happens at the call site (autocomplete or
* local channel/user lists). This module only splits syntax.
*
* Operators must start at a token boundary (start of string or whitespace). A
* word-boundary `\b` is intentionally avoided: it also matches after `-` / `/`,
* which would turn `built-in:react` or `https://x.com/in:foo` into operators.
*/
export type ParsedSearchOperators = {
/** FTS / prefix query with operators removed. */
text: string;
/** Raw `from:` value (pubkey hex, npub, @name, …), if present. */
from: string | null;
/** Raw `in:` value (channel uuid, #name, …), if present. */
in: string | null;
/**
* `after:YYYY-MM-DD` → unix seconds at local start of that day (inclusive).
* Messages at or after this timestamp match.
*/
since: number | null;
/**
* `before:YYYY-MM-DD` → one second before local start of that day, because
* NIP-01 `until` is an *inclusive* upper bound. Messages strictly before
* local midnight match, so the named calendar day itself is not included
* (Slack-compatible).
*/
until: number | null;
};
/** Token-start only — not `\b`, which fires after hyphens and slashes. */
const OPERATOR_RE = /(?:^|\s)(from|in|after|before):(\S+)/gi;
function parseLocalDayStart(value: string): number | null {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!match) {
return null;
}
const year = Number(match[1]);
const month = Number(match[2]);
const day = Number(match[3]);
if (month < 1 || month > 12 || day < 1 || day > 31) {
return null;
}
const date = new Date(year, month - 1, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null;
}
return Math.floor(date.getTime() / 1000);
}
/** Drop trailing punctuation so `in:general,` still resolves to `general`. */
function cleanOperatorValue(value: string): string {
return value.replace(/[.,;:!?]+$/g, "");
}
/**
* Extract `from:` / `in:` / `after:` / `before:` operators from `raw`.
*
* Later occurrences of the same operator win. Date operators that are not
* `YYYY-MM-DD` stay in the returned `text`.
*
* Multi-word handles (`from:@Will Pfleger`) are a known limitation of part 1:
* only the first whitespace-delimited token is captured.
*/
export function parseSearchOperators(raw: string): ParsedSearchOperators {
let from: string | null = null;
let inValue: string | null = null;
let since: number | null = null;
let until: number | null = null;
const kept: string[] = [];
let lastIndex = 0;
for (const match of raw.matchAll(OPERATOR_RE)) {
const index = match.index ?? 0;
kept.push(raw.slice(lastIndex, index));
lastIndex = index + match[0].length;
const kind = match[1].toLowerCase();
const value = cleanOperatorValue(match[2]);
if (kind === "from") {
from = value;
continue;
}
if (kind === "in") {
inValue = value;
continue;
}
if (kind === "after") {
const parsed = parseLocalDayStart(value);
if (parsed === null) {
kept.push(match[0]);
} else {
since = parsed;
}
continue;
}
if (kind === "before") {
const parsed = parseLocalDayStart(value);
if (parsed === null) {
kept.push(match[0]);
} else {
// NIP-01 `until` is inclusive, so step back one second to keep
// `before:` exclusive of the named day's midnight.
until = parsed - 1;
}
}
}
kept.push(raw.slice(lastIndex));
return {
text: kept.join("").replace(/\s+/g, " ").trim(),
from,
in: inValue,
since,
until,
};
}
const HEX_PUBKEY_RE = /^[0-9a-f]{64}$/i;
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
/** True when `value` is a 64-char hex pubkey (optional `0x` rejected). */
export function isHexPubkey(value: string): boolean {
return HEX_PUBKEY_RE.test(value);
}
/** Strip a leading `@` from a `from:` value. */
export function normalizeFromHandle(value: string): string {
return value.startsWith("@") ? value.slice(1) : value;
}
/** Strip a leading `#` from an `in:` value. */
export function normalizeInChannel(value: string): string {
return value.startsWith("#") ? value.slice(1) : value;
}
/** True when `value` is a channel UUID. */
export function isChannelUuid(value: string): boolean {
return UUID_RE.test(value);
}
/**
* Result of resolving a `from:` / `in:` operator against local candidates.
*
* Distinguishes "no operator" from "operator present but unmatched" so callers
* never silently widen the search when resolution fails.
*/
export type OperatorResolveResult<T> =
| { status: "none" }
| { status: "resolved"; value: T }
| { status: "unresolved" };

View File

@@ -409,6 +409,7 @@ export function TopbarSearch({
const {
channelLookup,
debouncedQuery,
isWaitingOnFromResolution,
query,
resultProfiles,
results,
@@ -487,7 +488,10 @@ export function TopbarSearch({
const activeResults = isShowingSuggestions
? suggestionResults
: groupedSearchResults;
const isSearchLoading = searchQuery.isLoading || userSearchQuery.isLoading;
const isSearchLoading =
isWaitingOnFromResolution ||
searchQuery.isLoading ||
userSearchQuery.isLoading;
const openSearchDialog = React.useCallback(() => {
setSelectedMenuIndex(0);

View File

@@ -11,6 +11,14 @@ import {
} from "@/features/profile/hooks";
import { rankUserCandidatesBySearch } from "@/features/profile/lib/userCandidateSearch";
import { useSearchMessagesQuery } from "@/features/search/hooks";
import {
isChannelUuid,
isHexPubkey,
normalizeFromHandle,
normalizeInChannel,
parseSearchOperators,
type OperatorResolveResult,
} from "@/features/search/lib/parseSearchOperators";
import type { SearchResult } from "@/features/search/ui/SearchResultItem";
import type { Channel, SearchHit, UserSearchResult } from "@/shared/api/types";
import { normalizePubkey } from "@/shared/lib/pubkey";
@@ -34,6 +42,56 @@ function dedupeSearchHits(hits: SearchHit[]) {
});
}
function resolveChannelIdFromOperator(
raw: string | null,
channels: Channel[],
channelLabels?: Record<string, string>,
): OperatorResolveResult<string> {
if (!raw) {
return { status: "none" };
}
const value = normalizeInChannel(raw);
if (!value) {
return { status: "none" };
}
if (isChannelUuid(value)) {
return { status: "resolved", value };
}
const needle = value.toLowerCase();
const match = channels.find((channel) => {
const label = channelLabels?.[channel.id]?.trim() || channel.name;
return (
channel.name.toLowerCase() === needle || label.toLowerCase() === needle
);
});
return match
? { status: "resolved", value: match.id }
: { status: "unresolved" };
}
function resolveAuthorFromOperator(
raw: string | null,
candidates: Array<{ pubkey: string; displayName?: string | null }>,
): OperatorResolveResult<string> {
if (!raw) {
return { status: "none" };
}
if (isHexPubkey(raw)) {
return { status: "resolved", value: normalizePubkey(raw) };
}
const handle = normalizeFromHandle(raw).toLowerCase();
if (!handle) {
return { status: "unresolved" };
}
const match = candidates.find((candidate) => {
const name = candidate.displayName?.trim().toLowerCase();
return name === handle || normalizePubkey(candidate.pubkey) === handle;
});
return match
? { status: "resolved", value: normalizePubkey(match.pubkey) }
: { status: "unresolved" };
}
export function useSearchResults({
channelLabels,
channels,
@@ -55,21 +113,128 @@ export function useSearchResults({
[channels],
);
const searchQuery = useSearchMessagesQuery(debouncedQuery, {
enabled,
const parsedQuery = React.useMemo(
() => parseSearchOperators(debouncedQuery),
[debouncedQuery],
);
const channelResolution = React.useMemo(
() => resolveChannelIdFromOperator(parsedQuery.in, channels, channelLabels),
[parsedQuery.in, channels, channelLabels],
);
const ftsQuery = parsedQuery.text;
const hasSearchQuery =
debouncedQuery.trim().length >= MIN_SEARCH_QUERY_LENGTH ||
parsedQuery.since !== null ||
parsedQuery.until !== null ||
parsedQuery.from !== null ||
parsedQuery.in !== null;
const searchBackedQueriesEnabled = enabled && hasSearchQuery;
const fromHandleForLookup =
parsedQuery.from && !isHexPubkey(parsedQuery.from)
? normalizeFromHandle(parsedQuery.from)
: "";
const managedAgentsQuery = useManagedAgentsQuery({
enabled: searchBackedQueriesEnabled,
});
const relayAgentsQuery = useRelayAgentsQuery({
enabled: searchBackedQueriesEnabled,
});
// Resolve `from:@name` against people, not only agents.
const fromUserSearchQuery = useUserSearchQuery(fromHandleForLookup, {
enabled: searchBackedQueriesEnabled && fromHandleForLookup.length >= 1,
limit,
});
const userSearchQuery = useUserSearchQuery(ftsQuery, {
enabled: searchBackedQueriesEnabled,
limit,
});
const messageResults = React.useMemo(
() => dedupeSearchHits(searchQuery.data?.hits ?? []),
[searchQuery.data?.hits],
const authorCandidateSeed = React.useMemo(() => {
const candidates: Array<{ pubkey: string; displayName?: string | null }> =
[];
const seen = new Set<string>();
const push = (pubkey: string, displayName?: string | null) => {
const key = normalizePubkey(pubkey);
if (seen.has(key)) {
return;
}
seen.add(key);
candidates.push({ pubkey: key, displayName });
};
for (const agent of managedAgentsQuery.data ?? []) {
push(agent.pubkey, agent.name);
}
for (const agent of relayAgentsQuery.data ?? []) {
push(agent.pubkey, agent.name);
}
for (const user of fromUserSearchQuery.data ?? []) {
push(user.pubkey, user.displayName);
}
for (const user of userSearchQuery.data ?? []) {
push(user.pubkey, user.displayName);
}
return candidates;
}, [
managedAgentsQuery.data,
relayAgentsQuery.data,
fromUserSearchQuery.data,
userSearchQuery.data,
]);
const authorResolution = React.useMemo(
() => resolveAuthorFromOperator(parsedQuery.from, authorCandidateSeed),
[parsedQuery.from, authorCandidateSeed],
);
const hasUnresolvedOperator =
authorResolution.status === "unresolved" ||
channelResolution.status === "unresolved";
// While `from:@name` user search is still loading, hold off so we do not
// flash an unresolved empty state before candidates arrive.
const waitingOnFromResolution =
Boolean(fromHandleForLookup) &&
fromUserSearchQuery.isLoading &&
authorResolution.status === "unresolved";
const searchQuery = useSearchMessagesQuery(ftsQuery, {
enabled:
enabled &&
!hasUnresolvedOperator &&
!waitingOnFromResolution &&
ftsQuery.length >= MIN_SEARCH_QUERY_LENGTH,
limit,
channelId:
channelResolution.status === "resolved"
? channelResolution.value
: undefined,
authors:
authorResolution.status === "resolved"
? [authorResolution.value]
: undefined,
since: parsedQuery.since,
until: parsedQuery.until,
unresolvedOperator: hasUnresolvedOperator,
});
const messageResults = React.useMemo(() => {
if (hasUnresolvedOperator) {
return [];
}
return dedupeSearchHits(searchQuery.data?.hits ?? []);
}, [hasUnresolvedOperator, searchQuery.data?.hits]);
const channelResults = React.useMemo(() => {
if (debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH) {
if (ftsQuery.length < MIN_SEARCH_QUERY_LENGTH) {
return [];
}
const normalizedQuery = debouncedQuery.toLowerCase();
const normalizedQuery = ftsQuery.toLowerCase();
return channels
.filter(
@@ -100,21 +265,7 @@ export function useSearchResults({
return aDisplayName.localeCompare(bDisplayName);
})
.slice(0, 5);
}, [channelLabels, channels, debouncedQuery]);
const hasSearchQuery = debouncedQuery.length >= MIN_SEARCH_QUERY_LENGTH;
const searchBackedQueriesEnabled = enabled && hasSearchQuery;
const userSearchQuery = useUserSearchQuery(debouncedQuery, {
enabled: searchBackedQueriesEnabled,
limit,
});
const managedAgentsQuery = useManagedAgentsQuery({
enabled: searchBackedQueriesEnabled,
});
const relayAgentsQuery = useRelayAgentsQuery({
enabled: searchBackedQueriesEnabled,
});
}, [channelLabels, channels, ftsQuery]);
const managedAgentPubkeys = React.useMemo(
() =>
new Set(
@@ -145,11 +296,11 @@ export function useSearchResults({
return pubkeys;
}, [managedAgentPubkeys, relayAgentsQuery.data]);
const userResults = React.useMemo<UserSearchResult[]>(() => {
if (debouncedQuery.length < MIN_SEARCH_QUERY_LENGTH) {
if (ftsQuery.length < MIN_SEARCH_QUERY_LENGTH) {
return [];
}
const normalizedQuery = debouncedQuery.toLowerCase();
const normalizedQuery = ftsQuery.toLowerCase();
const candidatesByPubkey = new Map<string, UserSearchResult>();
const matchesQuery = (candidate: UserSearchResult) =>
@@ -241,11 +392,11 @@ export function useSearchResults({
candidates: [...candidatesByPubkey.values()],
getLabel: formatUserResultName,
limit,
query: debouncedQuery,
query: ftsQuery,
});
}, [
debouncedQuery,
eligibleAgentPubkeys,
ftsQuery,
isArchivedDiscovery,
limit,
managedAgentPubkeys,
@@ -318,6 +469,7 @@ export function useSearchResults({
channelLookup,
channelResults,
debouncedQuery,
isWaitingOnFromResolution: waitingOnFromResolution,
messageResults,
query,
resultProfiles: resultProfilesQuery.data?.profiles,

View File

@@ -0,0 +1,28 @@
export type SearchMessagesInput = {
q: string;
limit?: number;
channelId?: string;
/** Hex pubkeys for `from:` operator. */
authors?: string[];
/** Unix seconds (`after:YYYY-MM-DD`). */
since?: number;
/** Unix seconds (`before:YYYY-MM-DD`). */
until?: number;
};
export type SearchHit = {
eventId: string;
content: string;
kind: number;
pubkey: string;
channelId: string | null;
channelName: string | null;
createdAt: number;
score: number;
threadRootId?: string | null;
};
export type SearchMessagesResponse = {
hits: SearchHit[];
found: number;
};

View File

@@ -469,6 +469,9 @@ export async function searchMessages(
q: input.q,
limit: input.limit,
channelId: input.channelId,
authors: input.authors,
since: input.since,
until: input.until,
});
return {

View File

@@ -268,28 +268,11 @@ export type GetHomeFeedInput = {
types?: string;
};
export type SearchMessagesInput = {
q: string;
limit?: number;
channelId?: string;
};
export type SearchHit = {
eventId: string;
content: string;
kind: number;
pubkey: string;
channelId: string | null;
channelName: string | null;
createdAt: number;
score: number;
threadRootId?: string | null;
};
export type SearchMessagesResponse = {
hits: SearchHit[];
found: number;
};
export type {
SearchHit,
SearchMessagesInput,
SearchMessagesResponse,
} from "./searchTypes";
// ── Relay Members ────────────────────────────────────────────────────────────

View File

@@ -8000,10 +8000,60 @@ async function handleUpdateManagedAgent(args: {
return { agent: cloneManagedAgent(agent), profile_sync_error: null };
}
/**
* Mock-mode `search_messages` predicate, mirroring the relay's filter contract.
*
* `since`/`until` are NIP-01 bounds and both inclusive — the relay keeps events
* where `since <= created_at <= until` (`crates/buzz-core/src/filter.rs`). The
* `before:` operator's exclusivity is encoded upstream in
* `parseSearchOperators`, which subtracts a second; the mock must not subtract
* it a second time.
*
* Exported so the boundary behavior is unit-testable without a browser.
*/
export function mockSearchHitMatches(
hit: Pick<
RawSearchHit,
"channel_id" | "pubkey" | "created_at" | "content" | "channel_name"
>,
filters: {
/** Lowercased FTS query; empty matches everything. */
query: string;
channelId?: string;
authorSet: Set<string> | null;
since?: number;
until?: number;
},
): boolean {
if (filters.channelId && hit.channel_id !== filters.channelId) {
return false;
}
if (filters.authorSet && !filters.authorSet.has(hit.pubkey.toLowerCase())) {
return false;
}
if (filters.since != null && hit.created_at < filters.since) {
return false;
}
if (filters.until != null && hit.created_at > filters.until) {
return false;
}
if (!filters.query) {
return true;
}
return (
hit.content.toLowerCase().includes(filters.query) ||
(hit.channel_name?.toLowerCase().includes(filters.query) ?? false)
);
}
async function handleSearchMessages(
args: {
q: string;
limit?: number;
channelId?: string;
authors?: string[];
since?: number;
until?: number;
},
config: E2eConfig | undefined,
): Promise<RawSearchResponse> {
@@ -8086,17 +8136,20 @@ async function handleSearchMessages(
}
}
const hits = mockHits
.filter((hit) => {
if (!query) {
return true;
}
const authorSet = args.authors?.length
? new Set(args.authors.map((author) => author.toLowerCase()))
: null;
return (
hit.content.toLowerCase().includes(query) ||
(hit.channel_name?.toLowerCase().includes(query) ?? false)
);
})
const hits = mockHits
.filter((hit) =>
mockSearchHitMatches(hit, {
query,
channelId: args.channelId,
authorSet,
since: args.since,
until: args.until,
}),
)
.slice(0, limit);
return {
@@ -8105,11 +8158,26 @@ async function handleSearchMessages(
};
}
// NIP-50 search via POST /query
// NIP-50 search via POST /query — forward operator pushdown fields.
const limit = args.limit ?? 20;
const events = await relayQuery(config, [
{ kinds: [9, 40002], search: args.q, limit },
]);
const filter: Record<string, unknown> = {
kinds: [9, 40002, 45001, 45003],
search: args.q,
limit,
};
if (args.channelId) {
filter["#h"] = [args.channelId];
}
if (args.authors?.length) {
filter.authors = args.authors;
}
if (args.since != null) {
filter.since = args.since;
}
if (args.until != null) {
filter.until = args.until;
}
const events = await relayQuery(config, [filter]);
const hits = events.map((ev) => ({
event_id: ev.id ?? "",
pubkey: ev.pubkey ?? "",

View File

@@ -0,0 +1,81 @@
/**
* Unit tests for the e2e bridge's mock `search_messages` predicate.
*
* The mock stands in for the relay in identity-less E2E runs, so its `since` /
* `until` comparators must match the relay's inclusive NIP-01 bounds
* (`crates/buzz-core/src/filter.rs` keeps `since <= created_at <= until`).
* `before:YYYY-MM-DD` gets its exclusivity from `parseSearchOperators`, which
* emits `localMidnight - 1`; a mock that also excluded its own bound would
* drop the last second of the range that production returns.
*/
import assert from "node:assert/strict";
import test from "node:test";
import { mockSearchHitMatches } from "./e2eBridge.ts";
const NO_FILTERS = { query: "", authorSet: null };
function hitAt(created_at) {
return {
event_id: "hit",
content: "deploy notes",
kind: 9,
pubkey: "a".repeat(64),
channel_id: "9a1657ac-f7aa-5db0-b632-d8bbeb6dfb50",
channel_name: "general",
created_at,
score: 1,
};
}
test("until is inclusive, so a before: bound keeps its own second", () => {
const localMidnight = Math.floor(new Date(2024, 1, 1).getTime() / 1000);
// What parseSearchOperators emits for `before:2024-02-01`.
const until = localMidnight - 1;
assert.equal(
mockSearchHitMatches(hitAt(until), { ...NO_FILTERS, until }),
true,
);
assert.equal(
mockSearchHitMatches(hitAt(localMidnight), { ...NO_FILTERS, until }),
false,
);
});
test("since is inclusive and excludes only earlier timestamps", () => {
const since = Math.floor(new Date(2024, 0, 15).getTime() / 1000);
assert.equal(
mockSearchHitMatches(hitAt(since), { ...NO_FILTERS, since }),
true,
);
assert.equal(
mockSearchHitMatches(hitAt(since - 1), { ...NO_FILTERS, since }),
false,
);
});
test("channel, author, and text filters still narrow results", () => {
const hit = hitAt(1_700_000_000);
assert.equal(
mockSearchHitMatches(hit, { ...NO_FILTERS, channelId: "other-channel" }),
false,
);
assert.equal(
mockSearchHitMatches(hit, {
...NO_FILTERS,
authorSet: new Set(["b".repeat(64)]),
}),
false,
);
assert.equal(
mockSearchHitMatches(hit, { query: "zzz", authorSet: null }),
false,
);
assert.equal(
mockSearchHitMatches(hit, { query: "deploy", authorSet: null }),
true,
);
});