mirror of
https://github.com/block/buzz.git
synced 2026-11-12 20:31:12 +00:00
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:
@@ -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))
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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,
|
||||
|
||||
118
desktop/src/features/search/lib/parseSearchOperators.test.mjs
Normal file
118
desktop/src/features/search/lib/parseSearchOperators.test.mjs
Normal 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");
|
||||
});
|
||||
168
desktop/src/features/search/lib/parseSearchOperators.ts
Normal file
168
desktop/src/features/search/lib/parseSearchOperators.ts
Normal 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" };
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
28
desktop/src/shared/api/searchTypes.ts
Normal file
28
desktop/src/shared/api/searchTypes.ts
Normal 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;
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -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 ?? "",
|
||||
|
||||
81
desktop/src/testing/e2eBridgeSearch.test.mjs
Normal file
81
desktop/src/testing/e2eBridgeSearch.test.mjs
Normal 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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user