diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 68e28fd..2896b96 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -1,7 +1,8 @@ import { type AppController } from '@/app.ts'; import { Conf } from '@/config.ts'; import { insertUser } from '@/db/users.ts'; -import { type Filter, findReplyTag, nip19, z } from '@/deps.ts'; +import { findReplyTag, nip19, z } from '@/deps.ts'; +import { type DittoFilter } from '@/filter.ts'; import * as mixer from '@/mixer.ts'; import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts'; import { booleanParamSchema, fileSchema } from '@/schema.ts'; @@ -137,7 +138,7 @@ const accountStatusesController: AppController = async (c) => { return c.json([]); } - const filter: Filter<1> = { authors: [pubkey], kinds: [1], since, until, limit }; + const filter: DittoFilter<1> = { authors: [pubkey], kinds: [1], relations: ['author'], since, until, limit }; if (tagged) { filter['#t'] = [tagged]; } @@ -256,7 +257,7 @@ const favouritesController: AppController = async (c) => { .map((event) => event.tags.find((tag) => tag[0] === 'e')?.[1]) .filter((id): id is string => !!id); - const events1 = await mixer.getFilters([{ kinds: [1], ids }], { timeout: Time.seconds(1) }); + const events1 = await mixer.getFilters([{ kinds: [1], ids, relations: ['author'] }], { timeout: Time.seconds(1) }); const statuses = await Promise.all(events1.map((event) => renderStatus(event, c.get('pubkey')))); return paginated(c, events1, statuses); diff --git a/src/controllers/api/search.ts b/src/controllers/api/search.ts index 15417c8..b0f71ce 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -1,6 +1,7 @@ import { AppController } from '@/app.ts'; import * as eventsDB from '@/db/events.ts'; -import { type Event, type Filter, nip19, z } from '@/deps.ts'; +import { type Event, nip19, z } from '@/deps.ts'; +import { type DittoFilter } from '@/filter.ts'; import * as mixer from '@/mixer.ts'; import { booleanParamSchema } from '@/schema.ts'; import { nostrIdSchema } from '@/schemas/nostr.ts'; @@ -65,9 +66,10 @@ const searchController: AppController = async (c) => { function searchEvents({ q, type, limit, account_id }: SearchQuery): Promise { if (type === 'hashtags') return Promise.resolve([]); - const filter: Filter = { + const filter: DittoFilter = { kinds: typeToKinds(type), search: q, + relations: ['author'], limit, }; @@ -98,8 +100,8 @@ async function lookupEvent(query: SearchQuery): Promise { } /** Get filters to lookup the input value. */ -async function getLookupFilters({ q, type, resolve }: SearchQuery): Promise { - const filters: Filter[] = []; +async function getLookupFilters({ q, type, resolve }: SearchQuery): Promise { + const filters: DittoFilter[] = []; const accounts = !type || type === 'accounts'; const statuses = !type || type === 'statuses'; @@ -138,7 +140,7 @@ async function getLookupFilters({ q, type, resolve }: SearchQuery): Promise ({ ...filter, relations: ['author'] })); } export { searchController }; diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 0f6be9c..114923d 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -1,7 +1,7 @@ import { type AppController } from '@/app.ts'; import { getUnattachedMediaByIds } from '@/db/unattached-media.ts'; import { type Event, ISO6391, z } from '@/deps.ts'; -import { getAncestors, getDescendants, getEvent } from '@/queries.ts'; +import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; import { createEvent, paginationSchema, parseBody } from '@/utils/web.ts'; import { renderEventAccounts } from '@/views.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; @@ -29,7 +29,7 @@ const createStatusSchema = z.object({ const statusController: AppController = async (c) => { const id = c.req.param('id'); - const event = await getEvent(id, { kind: 1 }); + const event = await getEvent(id, { kind: 1, relations: ['author'] }); if (event) { return c.json(await renderStatus(event, c.get('pubkey'))); } @@ -83,12 +83,13 @@ const createStatusController: AppController = async (c) => { tags, }, c); - return c.json(await renderStatus(event, c.get('pubkey'))); + const author = await getAuthor(event.pubkey); + return c.json(await renderStatus({ ...event, author }, c.get('pubkey'))); }; const contextController: AppController = async (c) => { const id = c.req.param('id'); - const event = await getEvent(id, { kind: 1 }); + const event = await getEvent(id, { kind: 1, relations: ['author'] }); async function renderStatuses(events: Event<1>[]) { const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey')))); @@ -109,7 +110,7 @@ const contextController: AppController = async (c) => { const favouriteController: AppController = async (c) => { const id = c.req.param('id'); - const target = await getEvent(id, { kind: 1 }); + const target = await getEvent(id, { kind: 1, relations: ['author'] }); if (target) { await createEvent({ diff --git a/src/controllers/api/streaming.ts b/src/controllers/api/streaming.ts index d7aa677..8cdd7d4 100644 --- a/src/controllers/api/streaming.ts +++ b/src/controllers/api/streaming.ts @@ -1,7 +1,7 @@ import { type AppController } from '@/app.ts'; import { z } from '@/deps.ts'; import { type DittoFilter } from '@/filter.ts'; -import { getFeedPubkeys } from '@/queries.ts'; +import { getAuthor, getFeedPubkeys } from '@/queries.ts'; import { Sub } from '@/subs.ts'; import { bech32ToPubkey } from '@/utils.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; @@ -63,7 +63,8 @@ const streamingController: AppController = (c) => { if (filter) { for await (const event of Sub.sub(socket, '1', [filter])) { - const status = await renderStatus(event, pubkey); + const author = await getAuthor(event.pubkey); + const status = await renderStatus({ ...event, author }, pubkey); if (status) { send('update', status); } diff --git a/src/controllers/api/timelines.ts b/src/controllers/api/timelines.ts index 712f741..d3ffcdc 100644 --- a/src/controllers/api/timelines.ts +++ b/src/controllers/api/timelines.ts @@ -34,7 +34,10 @@ const hashtagTimelineController: AppController = (c) => { /** Render statuses for timelines. */ async function renderStatuses(c: AppContext, filters: DittoFilter<1>[]) { - const events = await mixer.getFilters(filters, { timeout: Time.seconds(1) }); + const events = await mixer.getFilters( + filters.map((filter) => ({ ...filter, relations: ['author'] })), + { timeout: Time.seconds(1) }, + ); if (!events.length) { return c.json([]); diff --git a/src/db/events.ts b/src/db/events.ts index f3ccc02..b8db69a 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -1,5 +1,5 @@ -import { db } from '@/db.ts'; -import { type Event } from '@/deps.ts'; +import { db, type DittoDB } from '@/db.ts'; +import { type Event, type SelectQueryBuilder } from '@/deps.ts'; import { isParameterizedReplaceableKind } from '@/kinds.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { EventData } from '@/types.ts'; @@ -72,8 +72,25 @@ function insertEvent(event: Event, data: EventData): Promise { }); } +type EventQuery = SelectQueryBuilder; + /** Build the query for a filter. */ -function getFilterQuery(filter: DittoFilter) { +function getFilterQuery(filter: DittoFilter): EventQuery { let query = db .selectFrom('events') .select([ @@ -127,6 +144,30 @@ function getFilterQuery(filter: DittoFilter) { : query.leftJoin('users', 'users.pubkey', 'events.pubkey').where('users.pubkey', 'is', null) as typeof query; } + if (filter.relations?.includes('author')) { + query = query + .leftJoin( + (eb) => + eb + .selectFrom('events') + .selectAll() + .where('kind', '=', 0) + .orderBy('created_at', 'desc') + .groupBy('pubkey') + .as('authors'), + (join) => join.onRef('authors.pubkey', '=', 'events.pubkey'), + ) + .select([ + 'authors.id as author_id', + 'authors.kind as author_kind', + 'authors.pubkey as author_pubkey', + 'authors.content as author_content', + 'authors.tags as author_tags', + 'authors.created_at as author_created_at', + 'authors.sig as author_sig', + ]) as typeof query; + } + if (filter.search) { query = query .innerJoin('events_fts', 'events_fts.id', 'events.id') @@ -143,11 +184,15 @@ function getFiltersQuery(filters: DittoFilter[]) { .reduce((result, query) => result.unionAll(query)); } +interface DittoEvent extends Event { + author?: Event<0>; +} + /** Get events for filters from the database. */ async function getFilters( filters: DittoFilter[], opts: GetFiltersOpts = {}, -): Promise[]> { +): Promise[]> { if (!filters.length) return Promise.resolve([]); let query = getFiltersQuery(filters); @@ -155,9 +200,31 @@ async function getFilters( query = query.limit(opts.limit); } - return (await query.execute()).map((event) => ( - { ...event, tags: JSON.parse(event.tags) } as Event - )); + return (await query.execute()).map((row) => { + const event: DittoEvent = { + id: row.id, + kind: row.kind as K, + pubkey: row.pubkey, + content: row.content, + created_at: row.created_at, + tags: JSON.parse(row.tags), + sig: row.sig, + }; + + if (row.author_id) { + event.author = { + id: row.author_id, + kind: row.author_kind! as 0, + pubkey: row.author_pubkey!, + content: row.author_content!, + created_at: row.author_created_at!, + tags: JSON.parse(row.author_tags!), + sig: row.author_sig!, + }; + } + + return event; + }); } /** Delete events based on filters from the database. */ @@ -242,4 +309,4 @@ function buildUserSearchContent(event: Event<0>): string { return [name, nip05, about].filter(Boolean).join('\n'); } -export { countFilters, deleteFilters, getFilters, insertEvent }; +export { countFilters, deleteFilters, type DittoEvent, getFilters, insertEvent }; diff --git a/src/deps.ts b/src/deps.ts index dbf3b46..f1cedc5 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -67,6 +67,7 @@ export { Migrator, type NullableInsertKeys, type QueryResult, + type SelectQueryBuilder, sql, } from 'npm:kysely@^0.26.3'; export { PolySqliteDialect } from 'https://gitlab.com/soapbox-pub/kysely-deno-sqlite/-/raw/v2.0.0/mod.ts'; diff --git a/src/filter.ts b/src/filter.ts index 4bc806a..e4e336f 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -3,9 +3,15 @@ import { type Event, type Filter, matchFilters } from '@/deps.ts'; import type { EventData } from '@/types.ts'; +/** Additional properties that may be added by Ditto to events. */ +type Relation = 'author'; + /** Custom filter interface that extends Nostr filters with extra options for Ditto. */ interface DittoFilter extends Filter { + /** Whether the event was authored by a local user. */ local?: boolean; + /** Additional fields to add to the returned event. */ + relations?: Relation[]; } /** Additional options to apply to the whole subscription. */ @@ -38,4 +44,4 @@ function matchDittoFilters(filters: DittoFilter[], event: Event, data: EventData return false; } -export { type DittoFilter, type GetFiltersOpts, matchDittoFilters }; +export { type DittoFilter, type GetFiltersOpts, matchDittoFilters, type Relation }; diff --git a/src/queries.ts b/src/queries.ts index 95bbaab..de9bd33 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -1,5 +1,6 @@ import * as eventsDB from '@/db/events.ts'; -import { type Event, type Filter, findReplyTag } from '@/deps.ts'; +import { type Event, findReplyTag } from '@/deps.ts'; +import { type DittoFilter, type Relation } from '@/filter.ts'; import * as mixer from '@/mixer.ts'; interface GetEventOpts { @@ -7,6 +8,8 @@ interface GetEventOpts { timeout?: number; /** Event kind. */ kind?: K; + /** Relations to include on the event. */ + relations?: Relation[]; } /** Get a Nostr event by its ID. */ @@ -14,8 +17,8 @@ const getEvent = async ( id: string, opts: GetEventOpts = {}, ): Promise | undefined> => { - const { kind, timeout = 1000 } = opts; - const filter: Filter = { ids: [id], limit: 1 }; + const { kind, relations, timeout = 1000 } = opts; + const filter: DittoFilter = { ids: [id], relations, limit: 1 }; if (kind) { filter.kinds = [kind]; } @@ -57,7 +60,7 @@ async function getAncestors(event: Event<1>, result = [] as Event<1>[]): Promise const inReplyTo = replyTag ? replyTag[1] : undefined; if (inReplyTo) { - const parentEvent = await getEvent(inReplyTo, { kind: 1 }); + const parentEvent = await getEvent(inReplyTo, { kind: 1, relations: ['author'] }); if (parentEvent) { result.push(parentEvent); @@ -70,7 +73,7 @@ async function getAncestors(event: Event<1>, result = [] as Event<1>[]): Promise } function getDescendants(eventId: string): Promise[]> { - return mixer.getFilters([{ kinds: [1], '#e': [eventId] }], { limit: 200, timeout: 2000 }); + return mixer.getFilters([{ kinds: [1], '#e': [eventId], relations: ['author'] }], { limit: 200, timeout: 2000 }); } /** Returns whether the pubkey is followed by a local user. */ diff --git a/src/views/mastodon/notifications.ts b/src/views/mastodon/notifications.ts index 59e5665..0e6b3c4 100644 --- a/src/views/mastodon/notifications.ts +++ b/src/views/mastodon/notifications.ts @@ -1,4 +1,5 @@ import { type Event } from '@/deps.ts'; +import { getAuthor } from '@/queries.ts'; import { nostrDate } from '@/utils.ts'; import { accountFromPubkey } from '@/views/mastodon/accounts.ts'; import { renderStatus } from '@/views/mastodon/statuses.ts'; @@ -11,7 +12,8 @@ function renderNotification(event: Event, viewerPubkey?: string) { } async function renderNotificationMention(event: Event<1>, viewerPubkey?: string) { - const status = await renderStatus(event, viewerPubkey); + const author = await getAuthor(event.pubkey); + const status = await renderStatus({ ...event, author }, viewerPubkey); if (!status) return; return { diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 131dcd6..86bdd13 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -2,7 +2,7 @@ import { isCWTag } from 'https://gitlab.com/soapbox-pub/mostr/-/raw/c67064aee5ad import { Conf } from '@/config.ts'; import * as eventsDB from '@/db/events.ts'; -import { type Event, findReplyTag, nip19 } from '@/deps.ts'; +import { findReplyTag, nip19 } from '@/deps.ts'; import { getMediaLinks, parseNoteContent } from '@/note.ts'; import { getAuthor } from '@/queries.ts'; import { jsonMediaDataSchema } from '@/schemas/nostr.ts'; @@ -12,10 +12,8 @@ import { accountFromPubkey, renderAccount } from '@/views/mastodon/accounts.ts'; import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments.ts'; import { renderEmojis } from '@/views/mastodon/emojis.ts'; -async function renderStatus(event: Event<1>, viewerPubkey?: string) { - const profile = await getAuthor(event.pubkey); - const account = profile ? await renderAccount(profile) : await accountFromPubkey(event.pubkey); - +async function renderStatus(event: eventsDB.DittoEvent<1>, viewerPubkey?: string) { + const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); const replyTag = findReplyTag(event); const mentionedPubkeys = [