diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index c7123bc..674d9f1 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -1,27 +1,70 @@ import { AppController } from '@/app.ts'; import * as eventsDB from '@/db/events.ts'; -import { lookupAccount } from '@/utils.ts'; +import { type Event, nip05, nip19 } from '@/deps.ts'; +import * as mixer from '@/mixer.ts'; +import { lookupNip05Cached } from '@/nip05.ts'; +import { getAuthor } from '@/queries.ts'; import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; +import { bech32ToPubkey, dedupeEvents, Time } from '@/utils.ts'; +import { paginationSchema } from '@/utils/web.ts'; const searchController: AppController = async (c) => { const q = c.req.query('q'); + const params = paginationSchema.parse(c.req.query()); if (!q) { return c.json({ error: 'Missing `q` query parameter.' }, 422); } - // For now, only support looking up accounts. - // TODO: Support searching statuses and hashtags. - const event = await lookupAccount(decodeURIComponent(q)); + const [event, events] = await Promise.all([ + lookupEvent(decodeURIComponent(q)), + eventsDB.getFilters([{ kinds: [1], search: q, ...params }]), + ]); - const events = await eventsDB.getFilters([{ kinds: [1], search: q }]); - const statuses = await Promise.all(events.map((event) => toStatus(event, c.get('pubkey')))); + if (event) { + events.push(event); + } + + const results = dedupeEvents(events); + + const accounts = await Promise.all( + results + .filter((event): event is Event<0> => event.kind === 0) + .map((event) => toAccount(event)), + ); + + const statuses = await Promise.all( + results + .filter((event): event is Event<1> => event.kind === 1) + .map((event) => toStatus(event, c.get('pubkey'))), + ); return c.json({ - accounts: event ? [await toAccount(event)] : [], + accounts: accounts.filter(Boolean), statuses: statuses.filter(Boolean), hashtags: [], }); }; +/** Resolve a searched value into an event, if applicable. */ +async function lookupEvent(value: string): Promise | undefined> { + if (new RegExp(`^${nip19.BECH32_REGEX.source}$`).test(value)) { + const pubkey = bech32ToPubkey(value); + if (pubkey) { + return getAuthor(pubkey); + } + } else if (/^[0-9a-f]{64}$/.test(value)) { + const [event] = await mixer.getFilters( + [{ kinds: [0], authors: [value], limit: 1 }, { kinds: [1], ids: [value], limit: 1 }], + { limit: 1, timeout: Time.seconds(1) }, + ); + return event; + } else if (nip05.NIP05_REGEX.test(value)) { + const pubkey = await lookupNip05Cached(value); + if (pubkey) { + return getAuthor(pubkey); + } + } +} + export { searchController }; diff --git a/src/db/events.ts b/src/db/events.ts index 59d831b..e33298c 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -120,7 +120,7 @@ function getFilterQuery(filter: DittoFilter) { if (filter.search) { query = query .innerJoin('events_fts', 'events_fts.id', 'events.id') - .where('events_fts.content', 'match', filter.search); + .where('events_fts.content', 'match', JSON.stringify(filter.search)); } return query; diff --git a/src/mixer.ts b/src/mixer.ts index 4c160b3..9d18939 100644 --- a/src/mixer.ts +++ b/src/mixer.ts @@ -2,7 +2,7 @@ import { type Event, matchFilters } from '@/deps.ts'; import * as client from '@/client.ts'; import * as eventsDB from '@/db/events.ts'; -import { eventDateComparator } from '@/utils.ts'; +import { dedupeEvents, eventDateComparator } from '@/utils.ts'; import type { DittoFilter, GetFiltersOpts } from '@/filter.ts'; @@ -33,11 +33,6 @@ function unmixEvents(events: Event[], filters: DittoFilter< return events; } -/** Deduplicate events by ID. */ -function dedupeEvents(events: Event[]): Event[] { - return [...new Map(events.map((event) => [event.id, event])).values()]; -} - /** Take the newest events among replaceable ones. */ function takeNewestEvents(events: Event[]): Event[] { const isReplaceable = (kind: number) => diff --git a/src/utils.ts b/src/utils.ts index 7f47f31..d6d24d8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -101,8 +101,14 @@ function isFollowing(source: Event<3>, targetPubkey: string): boolean { ); } +/** Deduplicate events by ID. */ +function dedupeEvents(events: Event[]): Event[] { + return [...new Map(events.map((event) => [event.id, event])).values()]; +} + export { bech32ToPubkey, + dedupeEvents, eventAge, eventDateComparator, findTag,