diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index e5037a02..f27b20e1 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -6,7 +6,6 @@ import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { getAuthor, getFollowedPubkeys } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; -import { getPubkeysBySearch } from '@/controllers/api/search.ts'; import { Storages } from '@/storages.ts'; import { uploadFile } from '@/utils/upload.ts'; import { nostrNow } from '@/utils.ts'; @@ -19,6 +18,7 @@ import { renderReblog, renderStatus } from '@/views/mastodon/statuses.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; import { bech32ToPubkey } from '@/utils.ts'; import { addTag, deleteTag, findReplyTag, getTagSet } from '@/utils/tags.ts'; +import { getPubkeysBySearch } from '@/utils/search.ts'; const usernameSchema = z .string().min(1).max(30) @@ -117,6 +117,7 @@ const accountSearchController: AppController = async (c) => { const { signal } = c.req.raw; const { limit } = c.get('pagination'); const kysely = await Storages.kysely(); + const viewerPubkey = await c.get('signer')?.getPublicKey(); const result = accountSearchQuerySchema.safeParse(c.req.query()); @@ -135,7 +136,8 @@ const accountSearchController: AppController = async (c) => { return c.json(pubkey ? [await accountFromPubkey(pubkey)] : []); } - const pubkeys = await getPubkeysBySearch(kysely, { q: query, limit }); + const followedPubkeys: Set = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set(); + const pubkeys = Array.from(await getPubkeysBySearch(kysely, { q: query, limit, followedPubkeys })); let events = event ? [event] : await store.query([{ kinds: [0], authors: pubkeys, limit }], { signal, diff --git a/src/controllers/api/search.test.ts b/src/controllers/api/search.test.ts deleted file mode 100644 index 2c5e91bd..00000000 --- a/src/controllers/api/search.test.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { assertEquals } from '@std/assert'; - -import { createTestDB } from '@/test.ts'; -import { getPubkeysBySearch } from '@/controllers/api/search.ts'; - -Deno.test('fuzzy search works', async () => { - await using db = await createTestDB(); - - await db.kysely.insertInto('author_search').values({ - pubkey: '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4', - search: 'patrickReiis patrickdosreis.com', - }).execute(); - - assertEquals(await getPubkeysBySearch(db.kysely, { q: 'pat rick', limit: 1 }), []); - assertEquals(await getPubkeysBySearch(db.kysely, { q: 'patrick dos reis', limit: 1 }), [ - '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4', - ]); - assertEquals(await getPubkeysBySearch(db.kysely, { q: 'dosreis.com', limit: 1 }), [ - '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4', - ]); -}); diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 01fb6665..ce7ca9f3 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -1,10 +1,8 @@ import { NostrEvent, NostrFilter, NSchema as n } from '@nostrify/nostrify'; import { nip19 } from 'nostr-tools'; -import { Kysely, sql } from 'kysely'; import { z } from 'zod'; import { AppController } from '@/app.ts'; -import { DittoTables } from '@/db/DittoTables.ts'; import { booleanParamSchema } from '@/schema.ts'; import { Storages } from '@/storages.ts'; import { hydrateEvents } from '@/storages/hydrate.ts'; @@ -12,6 +10,8 @@ import { extractIdentifier, lookupPubkey } from '@/utils/lookup.ts'; import { nip05Cache } from '@/utils/nip05.ts'; import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; +import { getFollowedPubkeys } from '@/queries.ts'; +import { getPubkeysBySearch } from '@/utils/search.ts'; const searchQuerySchema = z.object({ q: z.string().transform(decodeURIComponent), @@ -27,6 +27,7 @@ type SearchQuery = z.infer; const searchController: AppController = async (c) => { const result = searchQuerySchema.safeParse(c.req.query()); const { signal } = c.req.raw; + const viewerPubkey = await c.get('signer')?.getPublicKey(); if (!result.success) { return c.json({ error: 'Bad request', schema: result.error }, 422); @@ -50,9 +51,7 @@ const searchController: AppController = async (c) => { if (event) { events = [event]; } - events.push(...(await searchEvents(result.data, signal))); - - const viewerPubkey = await c.get('signer')?.getPublicKey(); + events.push(...(await searchEvents({ ...result.data, viewerPubkey }, signal))); const [accounts, statuses] = await Promise.all([ Promise.all( @@ -77,8 +76,16 @@ const searchController: AppController = async (c) => { }; /** Get events for the search params. */ -async function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: AbortSignal): Promise { - if (type === 'hashtags') return Promise.resolve([]); +async function searchEvents( + { q, type, limit, account_id, viewerPubkey }: SearchQuery & { viewerPubkey?: string }, + signal: AbortSignal, +): Promise { + // Hashtag search is not supported. + if (type === 'hashtags') { + return Promise.resolve([]); + } + + const store = await Storages.search(); const filter: NostrFilter = { kinds: typeToKinds(type), @@ -86,35 +93,33 @@ async function searchEvents({ q, type, limit, account_id }: SearchQuery, signal: limit, }; + // For account search, use a special index, and prioritize followed accounts. + if (type === 'accounts') { + const kysely = await Storages.kysely(); + + const followedPubkeys = viewerPubkey ? await getFollowedPubkeys(viewerPubkey) : new Set(); + const searchPubkeys = await getPubkeysBySearch(kysely, { q, limit, followedPubkeys }); + + filter.authors = [...searchPubkeys]; + filter.search = undefined; + } + + // Results should only be shown from one author. if (account_id) { filter.authors = [account_id]; } - const pubkeys: string[] = []; - if (type === 'accounts') { - const kysely = await Storages.kysely(); - - pubkeys.push(...(await getPubkeysBySearch(kysely, { q, limit }))); - - if (!filter?.authors) { - filter.authors = pubkeys; - } else { - filter.authors.push(...pubkeys); - } - - filter.search = undefined; - } - - const store = await Storages.search(); - - let events = await store.query([filter], { signal }) + // Query the events. + let events = await store + .query([filter], { signal }) .then((events) => hydrateEvents({ events, store, signal })); - if (type !== 'accounts') return events; - - events = pubkeys - .map((pubkey) => events.find((event) => event.pubkey === pubkey)) - .filter((event) => !!event); + // When using an authors filter, return the events in the same order as the filter. + if (filter.authors) { + events = filter.authors + .map((pubkey) => events.find((event) => event.pubkey === pubkey)) + .filter((event) => !!event); + } return events; } @@ -194,16 +199,4 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery, signal: Abort return []; } -/** Get pubkeys whose name and NIP-05 is similar to 'q' */ -async function getPubkeysBySearch(kysely: Kysely, { q, limit }: Pick) { - const pubkeys = (await sql<{ pubkey: string }>` - SELECT *, word_similarity(${q}, search) AS sml - FROM author_search - WHERE ${q} % search - ORDER BY sml DESC, search LIMIT ${limit} - `.execute(kysely)).rows.map(({ pubkey }) => pubkey); - - return pubkeys; -} - -export { getPubkeysBySearch, searchController }; +export { searchController }; diff --git a/src/storages/EventsDB.ts b/src/storages/EventsDB.ts index b4dc0b9b..7d61c2de 100644 --- a/src/storages/EventsDB.ts +++ b/src/storages/EventsDB.ts @@ -153,22 +153,16 @@ class EventsDB extends NPostgres { search: tokens.filter((t) => typeof t === 'string').join(' '), }) as SelectQueryBuilder>; - const data = tokens.filter((t) => typeof t === 'object').reduce( - (acc, t) => acc.set(t.key, t.value), - new Map(), - ); + const languages = new Set(); - const domain = data.get('domain'); - const language = data.get('language'); - - if (domain) { - query = query - .innerJoin('pubkey_domains', 'nostr_events.pubkey', 'pubkey_domains.pubkey') - .where('pubkey_domains.domain', '=', domain); + for (const token of tokens) { + if (typeof token === 'object' && token.key === 'language') { + languages.add(token.value); + } } - if (language) { - query = query.where('language', '=', language); + if (languages.size) { + query = query.where('language', 'in', [...languages]); } return query; @@ -288,6 +282,39 @@ class EventsDB extends NPostgres { filters = structuredClone(filters); for (const filter of filters) { + if (filter.search) { + const tokens = NIP50.parseInput(filter.search); + + const domains = new Set(); + + for (const token of tokens) { + if (typeof token === 'object' && token.key === 'domain') { + domains.add(token.value); + } + } + + if (domains.size) { + const query = this.opts.kysely + .selectFrom('pubkey_domains') + .select('pubkey') + .where('domain', 'in', [...domains]); + + if (filter.authors) { + query.where('pubkey', 'in', filter.authors); + } + + const pubkeys = await query.execute().then((rows) => rows.map((row) => row.pubkey)); + + filter.authors = pubkeys; + } + + // Re-serialize the search string without the domain key. :facepalm: + filter.search = tokens + .filter((t) => typeof t === 'object' && t.key !== 'domain') + .map((t) => typeof t === 'object' ? `${t.key}:${t.value}` : t) + .join(' '); + } + if (filter.kinds) { // Ephemeral events are not stored, so don't bother querying for them. // If this results in an empty kinds array, NDatabase will remove the filter before querying and return no results. diff --git a/src/utils/search.test.ts b/src/utils/search.test.ts new file mode 100644 index 00000000..1f01a157 --- /dev/null +++ b/src/utils/search.test.ts @@ -0,0 +1,27 @@ +import { assertEquals } from '@std/assert'; + +import { createTestDB } from '@/test.ts'; +import { getPubkeysBySearch } from '@/utils/search.ts'; + +Deno.test('fuzzy search works', async () => { + await using db = await createTestDB(); + + await db.kysely.insertInto('author_search').values({ + pubkey: '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4', + search: 'patrickReiis patrickdosreis.com', + }).execute(); + + assertEquals(await getPubkeysBySearch(db.kysely, { q: 'pat rick', limit: 1, followedPubkeys: new Set() }), new Set()); + assertEquals( + await getPubkeysBySearch(db.kysely, { q: 'patrick dos reis', limit: 1, followedPubkeys: new Set() }), + new Set([ + '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4', + ]), + ); + assertEquals( + await getPubkeysBySearch(db.kysely, { q: 'dosreis.com', limit: 1, followedPubkeys: new Set() }), + new Set([ + '47259076c85f9240e852420d7213c95e95102f1de929fb60f33a2c32570c98c4', + ]), + ); +}); diff --git a/src/utils/search.ts b/src/utils/search.ts new file mode 100644 index 00000000..b0be761b --- /dev/null +++ b/src/utils/search.ts @@ -0,0 +1,32 @@ +import { Kysely, sql } from 'kysely'; + +import { DittoTables } from '@/db/DittoTables.ts'; + +/** Get pubkeys whose name and NIP-05 is similar to 'q' */ +export async function getPubkeysBySearch( + kysely: Kysely, + opts: { q: string; limit: number; followedPubkeys: Set }, +): Promise> { + const { q, limit, followedPubkeys } = opts; + + let query = kysely + .selectFrom('author_search') + .select((eb) => [ + 'pubkey', + 'search', + eb.fn('word_similarity', [sql`${q}`, 'search']).as('sml'), + ]) + .where(() => sql`${q} % search`) + .orderBy(['sml desc', 'search']) + .limit(limit); + + const pubkeys = new Set((await query.execute()).map(({ pubkey }) => pubkey)); + + if (followedPubkeys.size > 0) { + query = query.where('pubkey', 'in', [...followedPubkeys]); + } + + const followingPubkeys = new Set((await query.execute()).map(({ pubkey }) => pubkey)); + + return new Set(Array.from(followingPubkeys.union(pubkeys)).slice(0, limit)); +}