diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index bcf4615..aa164bb 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -image: denoland/deno:1.38.4 +image: denoland/deno:1.38.5 default: interruptible: true diff --git a/.tool-versions b/.tool-versions index 1252389..fd4bb2f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1 +1 @@ -deno 1.38.4 +deno 1.38.5 \ No newline at end of file diff --git a/scripts/relays.ts b/scripts/relays.ts index 94d0afd..84f8a7e 100644 --- a/scripts/relays.ts +++ b/scripts/relays.ts @@ -18,6 +18,6 @@ async function sync([url]: string[]) { const response = await fetch(url); const data = await response.json(); const values = filteredArray(relaySchema).parse(data) as `wss://${string}`[]; - await addRelays(values); + await addRelays(values, { active: true }); console.log(`Done: added ${values.length} relays.`); } diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index 2896b96..22c9a63 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -59,7 +59,7 @@ const createAccountController: AppController = async (c) => { const verifyCredentialsController: AppController = async (c) => { const pubkey = c.get('pubkey')!; - const event = await getAuthor(pubkey); + const event = await getAuthor(pubkey, { relations: ['author_stats'] }); if (event) { return c.json(await renderAccount(event, { withSource: true })); } else { @@ -138,7 +138,15 @@ const accountStatusesController: AppController = async (c) => { return c.json([]); } - const filter: DittoFilter<1> = { authors: [pubkey], kinds: [1], relations: ['author'], since, until, limit }; + const filter: DittoFilter<1> = { + authors: [pubkey], + kinds: [1], + relations: ['author', 'event_stats', 'author_stats'], + since, + until, + limit, + }; + if (tagged) { filter['#t'] = [tagged]; } @@ -257,7 +265,9 @@ 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, relations: ['author'] }], { timeout: Time.seconds(1) }); + const events1 = await mixer.getFilters([{ kinds: [1], ids, relations: ['author', 'event_stats', 'author_stats'] }], { + 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 b0f71ce..e5f1778 100644 --- a/src/controllers/api/search.ts +++ b/src/controllers/api/search.ts @@ -69,7 +69,7 @@ function searchEvents({ q, type, limit, account_id }: SearchQuery): Promise ({ ...filter, relations: ['author'] })); + return filters; } export { searchController }; diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 114923d..cf0446a 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/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, relations: ['author'] }); + const event = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] }); if (event) { return c.json(await renderStatus(event, c.get('pubkey'))); } @@ -89,7 +89,7 @@ const createStatusController: AppController = async (c) => { const contextController: AppController = async (c) => { const id = c.req.param('id'); - const event = await getEvent(id, { kind: 1, relations: ['author'] }); + const event = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] }); async function renderStatuses(events: Event<1>[]) { const statuses = await Promise.all(events.map((event) => renderStatus(event, c.get('pubkey')))); @@ -110,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, relations: ['author'] }); + const target = await getEvent(id, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] }); if (target) { await createEvent({ diff --git a/src/controllers/api/timelines.ts b/src/controllers/api/timelines.ts index d3ffcdc..a29cdc8 100644 --- a/src/controllers/api/timelines.ts +++ b/src/controllers/api/timelines.ts @@ -35,7 +35,7 @@ const hashtagTimelineController: AppController = (c) => { /** Render statuses for timelines. */ async function renderStatuses(c: AppContext, filters: DittoFilter<1>[]) { const events = await mixer.getFilters( - filters.map((filter) => ({ ...filter, relations: ['author'] })), + filters.map((filter) => ({ ...filter, relations: ['author', 'event_stats', 'author_stats'] })), { timeout: Time.seconds(1) }, ); diff --git a/src/db.ts b/src/db.ts index a722d4e..4f1a8d9 100644 --- a/src/db.ts +++ b/src/db.ts @@ -13,6 +13,22 @@ interface DittoDB { users: UserRow; relays: RelayRow; unattached_media: UnattachedMediaRow; + author_stats: AuthorStatsRow; + event_stats: EventStatsRow; +} + +interface AuthorStatsRow { + pubkey: string; + followers_count: number; + following_count: number; + notes_count: number; +} + +interface EventStatsRow { + event_id: string; + replies_count: number; + reposts_count: number; + reactions_count: number; } interface EventRow { @@ -101,7 +117,7 @@ async function migrate() { console.log('Everything up-to-date.'); } else { console.log('Migrations finished!'); - for (const { migrationName, status } of results.results) { + for (const { migrationName, status } of results.results!) { console.log(` - ${migrationName}: ${status}`); } } @@ -110,4 +126,4 @@ async function migrate() { await migrate(); -export { db, type DittoDB, type EventRow, type TagRow, type UserRow }; +export { type AuthorStatsRow, db, type DittoDB, type EventRow, type EventStatsRow, type TagRow, type UserRow }; diff --git a/src/db/events.ts b/src/db/events.ts index b8db69a..2a5f2fd 100644 --- a/src/db/events.ts +++ b/src/db/events.ts @@ -80,6 +80,9 @@ type EventQuery = SelectQueryBuilder; /** Build the query for a filter. */ @@ -134,7 +140,7 @@ function getFilterQuery(filter: DittoFilter): EventQuery { query = query .leftJoin('tags', 'tags.event_id', 'events.id') .where('tags.tag', '=', tag) - .where('tags.value', 'in', value) as typeof query; + .where('tags.value', 'in', value); } } @@ -165,7 +171,27 @@ function getFilterQuery(filter: DittoFilter): EventQuery { 'authors.tags as author_tags', 'authors.created_at as author_created_at', 'authors.sig as author_sig', - ]) as typeof query; + ]); + } + + if (filter.relations?.includes('author_stats')) { + query = query + .leftJoin('author_stats', 'author_stats.pubkey', 'events.pubkey') + .select((eb) => [ + eb.fn.coalesce('author_stats.followers_count', eb.val(0)).as('author_stats_followers_count'), + eb.fn.coalesce('author_stats.following_count', eb.val(0)).as('author_stats_following_count'), + eb.fn.coalesce('author_stats.notes_count', eb.val(0)).as('author_stats_notes_count'), + ]); + } + + if (filter.relations?.includes('event_stats')) { + query = query + .leftJoin('event_stats', 'event_stats.event_id', 'events.id') + .select((eb) => [ + eb.fn.coalesce('event_stats.replies_count', eb.val(0)).as('stats_replies_count'), + eb.fn.coalesce('event_stats.reposts_count', eb.val(0)).as('stats_reposts_count'), + eb.fn.coalesce('event_stats.reactions_count', eb.val(0)).as('stats_reactions_count'), + ]); } if (filter.search) { @@ -184,8 +210,13 @@ function getFiltersQuery(filters: DittoFilter[]) { .reduce((result, query) => result.unionAll(query)); } +type AuthorStats = Omit; +type EventStats = Omit; + interface DittoEvent extends Event { - author?: Event<0>; + author?: DittoEvent<0>; + author_stats?: AuthorStats; + event_stats?: EventStats; } /** Get events for filters from the database. */ @@ -223,6 +254,22 @@ async function getFilters( }; } + if (typeof row.author_stats_followers_count === 'number') { + event.author_stats = { + followers_count: row.author_stats_followers_count, + following_count: row.author_stats_following_count!, + notes_count: row.author_stats_notes_count!, + }; + } + + if (typeof row.stats_replies_count === 'number') { + event.event_stats = { + replies_count: row.stats_replies_count, + reposts_count: row.stats_reposts_count!, + reactions_count: row.stats_reactions_count!, + }; + } + return event; }); } diff --git a/src/db/migrations/009_add_stats.ts b/src/db/migrations/009_add_stats.ts new file mode 100644 index 0000000..60d9447 --- /dev/null +++ b/src/db/migrations/009_add_stats.ts @@ -0,0 +1,24 @@ +import { Kysely } from '@/deps.ts'; + +export async function up(db: Kysely): Promise { + await db.schema + .createTable('author_stats') + .addColumn('pubkey', 'text', (col) => col.primaryKey()) + .addColumn('followers_count', 'integer', (col) => col.notNull().defaultTo(0)) + .addColumn('following_count', 'integer', (col) => col.notNull().defaultTo(0)) + .addColumn('notes_count', 'integer', (col) => col.notNull().defaultTo(0)) + .execute(); + + await db.schema + .createTable('event_stats') + .addColumn('event_id', 'text', (col) => col.primaryKey().references('events.id').onDelete('cascade')) + .addColumn('replies_count', 'integer', (col) => col.notNull().defaultTo(0)) + .addColumn('reposts_count', 'integer', (col) => col.notNull().defaultTo(0)) + .addColumn('reactions_count', 'integer', (col) => col.notNull().defaultTo(0)) + .execute(); +} + +export async function down(db: Kysely): Promise { + await db.schema.dropTable('author_stats').execute(); + await db.schema.dropTable('event_stats').execute(); +} diff --git a/src/db/relays.ts b/src/db/relays.ts index d41948f..836f520 100644 --- a/src/db/relays.ts +++ b/src/db/relays.ts @@ -1,14 +1,19 @@ import { tldts } from '@/deps.ts'; import { db } from '@/db.ts'; +interface AddRelaysOpts { + active?: boolean; +} + /** Inserts relays into the database, skipping duplicates. */ -function addRelays(relays: `wss://${string}`[]) { +function addRelays(relays: `wss://${string}`[], opts: AddRelaysOpts = {}) { if (!relays.length) return Promise.resolve(); + const { active = false } = opts; const values = relays.map((url) => ({ url: new URL(url).toString(), domain: tldts.getDomain(url)!, - active: true, + active, })); return db.insertInto('relays') diff --git a/src/deps.ts b/src/deps.ts index f1cedc5..a6b893c 100644 --- a/src/deps.ts +++ b/src/deps.ts @@ -63,6 +63,7 @@ export { type CompiledQuery, FileMigrationProvider, type Insertable, + type InsertQueryBuilder, Kysely, Migrator, type NullableInsertKeys, diff --git a/src/filter.ts b/src/filter.ts index e4e336f..76a0fcd 100644 --- a/src/filter.ts +++ b/src/filter.ts @@ -4,7 +4,7 @@ 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'; +type Relation = 'author' | 'author_stats' | 'event_stats'; /** Custom filter interface that extends Nostr filters with extra options for Ditto. */ interface DittoFilter extends Filter { diff --git a/src/pipeline.ts b/src/pipeline.ts index 9749a9c..4f0c51a 100644 --- a/src/pipeline.ts +++ b/src/pipeline.ts @@ -8,6 +8,7 @@ import { isEphemeralKind } from '@/kinds.ts'; import * as mixer from '@/mixer.ts'; import { publish } from '@/pool.ts'; import { isLocallyFollowed } from '@/queries.ts'; +import { updateStats } from '@/stats.ts'; import { Sub } from '@/subs.ts'; import { getTagSet } from '@/tags.ts'; import { eventAge, isRelay, nostrDate, Time } from '@/utils.ts'; @@ -68,7 +69,10 @@ async function storeEvent(event: Event, data: EventData): Promise { if (deletion) { return Promise.reject(new RelayError('blocked', 'event was deleted')); } else { - await eventsDB.insertEvent(event, data).catch(console.warn); + await Promise.all([ + eventsDB.insertEvent(event, data).catch(console.warn), + updateStats(event).catch(console.warn), + ]); } } else { return Promise.reject(new RelayError('blocked', 'only registered users can post')); diff --git a/src/queries.ts b/src/queries.ts index de9bd33..edd5f4a 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -27,8 +27,14 @@ const getEvent = async ( }; /** Get a Nostr `set_medatadata` event for a user's pubkey. */ -const getAuthor = async (pubkey: string, timeout = 1000): Promise | undefined> => { - const [event] = await mixer.getFilters([{ authors: [pubkey], kinds: [0], limit: 1 }], { limit: 1, timeout }); +const getAuthor = async (pubkey: string, opts: GetEventOpts<0> = {}): Promise | undefined> => { + const { relations, timeout = 1000 } = opts; + + const [event] = await mixer.getFilters( + [{ authors: [pubkey], relations, kinds: [0], limit: 1 }], + { limit: 1, timeout }, + ); + return event; }; @@ -60,7 +66,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, relations: ['author'] }); + const parentEvent = await getEvent(inReplyTo, { kind: 1, relations: ['author', 'event_stats', 'author_stats'] }); if (parentEvent) { result.push(parentEvent); @@ -73,7 +79,10 @@ async function getAncestors(event: Event<1>, result = [] as Event<1>[]): Promise } function getDescendants(eventId: string): Promise[]> { - return mixer.getFilters([{ kinds: [1], '#e': [eventId], relations: ['author'] }], { limit: 200, timeout: 2000 }); + return mixer.getFilters( + [{ kinds: [1], '#e': [eventId], relations: ['author', 'event_stats', 'author_stats'] }], + { limit: 200, timeout: 2000 }, + ); } /** Returns whether the pubkey is followed by a local user. */ diff --git a/src/sentry.ts b/src/sentry.ts index eefe9c5..8a30e3b 100644 --- a/src/sentry.ts +++ b/src/sentry.ts @@ -6,6 +6,6 @@ if (Conf.sentryDsn) { console.log('Sentry enabled'); Sentry.init({ dsn: Conf.sentryDsn, - tracesSampleRate: 1.0, + tracesSampleRate: .2, }); } diff --git a/src/stats.ts b/src/stats.ts new file mode 100644 index 0000000..32208df --- /dev/null +++ b/src/stats.ts @@ -0,0 +1,176 @@ +import { type AuthorStatsRow, db, type DittoDB, type EventStatsRow } from '@/db.ts'; +import * as eventsDB from '@/db/events.ts'; +import { type Event, findReplyTag, type InsertQueryBuilder } from '@/deps.ts'; + +type AuthorStat = keyof Omit; +type EventStat = keyof Omit; + +type AuthorStatDiff = ['author_stats', pubkey: string, stat: AuthorStat, diff: number]; +type EventStatDiff = ['event_stats', eventId: string, stat: EventStat, diff: number]; +type StatDiff = AuthorStatDiff | EventStatDiff; + +/** Store stats for the event in LMDB. */ +async function updateStats(event: Event) { + let prev: Event | undefined; + const queries: InsertQueryBuilder[] = []; + + // Kind 3 is a special case - replace the count with the new list. + if (event.kind === 3) { + prev = await maybeGetPrev(event); + if (!prev || event.created_at >= prev.created_at) { + queries.push(updateFollowingCountQuery(event as Event<3>)); + } + } + + const statDiffs = getStatsDiff(event, prev); + const pubkeyDiffs = statDiffs.filter(([table]) => table === 'author_stats') as AuthorStatDiff[]; + const eventDiffs = statDiffs.filter(([table]) => table === 'event_stats') as EventStatDiff[]; + + if (pubkeyDiffs.length) queries.push(authorStatsQuery(pubkeyDiffs)); + if (eventDiffs.length) queries.push(eventStatsQuery(eventDiffs)); + + if (queries.length) { + await Promise.all(queries.map((query) => query.execute())); + } +} + +/** Calculate stats changes ahead of time so we can build an efficient query. */ +function getStatsDiff(event: Event, prev: Event | undefined): StatDiff[] { + const statDiffs: StatDiff[] = []; + + const firstTaggedId = event.tags.find(([name]) => name === 'e')?.[1]; + const inReplyToId = findReplyTag(event as Event<1>)?.[1]; + + switch (event.kind) { + case 1: + statDiffs.push(['author_stats', event.pubkey, 'notes_count', 1]); + if (inReplyToId) { + statDiffs.push(['event_stats', inReplyToId, 'replies_count', 1]); + } + break; + case 3: + statDiffs.push(...getFollowDiff(event as Event<3>, prev as Event<3> | undefined)); + break; + case 6: + if (firstTaggedId) { + statDiffs.push(['event_stats', firstTaggedId, 'reposts_count', 1]); + } + break; + case 7: + if (firstTaggedId) { + statDiffs.push(['event_stats', firstTaggedId, 'reactions_count', 1]); + } + } + + return statDiffs; +} + +/** Create an author stats query from the list of diffs. */ +function authorStatsQuery(diffs: AuthorStatDiff[]) { + const values: AuthorStatsRow[] = diffs.map(([_, pubkey, stat, diff]) => { + const row: AuthorStatsRow = { + pubkey, + followers_count: 0, + following_count: 0, + notes_count: 0, + }; + row[stat] = diff; + return row; + }); + + return db.insertInto('author_stats') + .values(values) + .onConflict((oc) => + oc + .column('pubkey') + .doUpdateSet((eb) => ({ + followers_count: eb('followers_count', '+', eb.ref('excluded.followers_count')), + following_count: eb('following_count', '+', eb.ref('excluded.following_count')), + notes_count: eb('notes_count', '+', eb.ref('excluded.notes_count')), + })) + ); +} + +/** Create an event stats query from the list of diffs. */ +function eventStatsQuery(diffs: EventStatDiff[]) { + const values: EventStatsRow[] = diffs.map(([_, event_id, stat, diff]) => { + const row: EventStatsRow = { + event_id, + replies_count: 0, + reposts_count: 0, + reactions_count: 0, + }; + row[stat] = diff; + return row; + }); + + return db.insertInto('event_stats') + .values(values) + .onConflict((oc) => + oc + .column('event_id') + .doUpdateSet((eb) => ({ + replies_count: eb('replies_count', '+', eb.ref('excluded.replies_count')), + reposts_count: eb('reposts_count', '+', eb.ref('excluded.reposts_count')), + reactions_count: eb('reactions_count', '+', eb.ref('excluded.reactions_count')), + })) + ); +} + +/** Get the last version of the event, if any. */ +async function maybeGetPrev(event: Event): Promise> { + const [prev] = await eventsDB.getFilters([ + { kinds: [event.kind], authors: [event.pubkey], limit: 1 }, + ]); + + return prev; +} + +/** Set the following count to the total number of unique "p" tags in the follow list. */ +function updateFollowingCountQuery({ pubkey, tags }: Event<3>) { + const following_count = new Set( + tags + .filter(([name]) => name === 'p') + .map(([_, value]) => value), + ).size; + + return db.insertInto('author_stats') + .values({ + pubkey, + following_count, + followers_count: 0, + notes_count: 0, + }) + .onConflict((oc) => + oc + .column('pubkey') + .doUpdateSet({ following_count }) + ); +} + +/** Compare the old and new follow events (if any), and return a diff array. */ +function getFollowDiff(event: Event<3>, prev?: Event<3>): AuthorStatDiff[] { + const prevTags = prev?.tags ?? []; + + const prevPubkeys = new Set( + prevTags + .filter(([name]) => name === 'p') + .map(([_, value]) => value), + ); + + const pubkeys = new Set( + event.tags + .filter(([name]) => name === 'p') + .map(([_, value]) => value), + ); + + const added = [...pubkeys].filter((pubkey) => !prevPubkeys.has(pubkey)); + const removed = [...prevPubkeys].filter((pubkey) => !pubkeys.has(pubkey)); + + return [ + ...added.map((pubkey): AuthorStatDiff => ['author_stats', pubkey, 'followers_count', 1]), + ...removed.map((pubkey): AuthorStatDiff => ['author_stats', pubkey, 'followers_count', -1]), + ]; +} + +export { updateStats }; diff --git a/src/views.ts b/src/views.ts index 3049710..2314a84 100644 --- a/src/views.ts +++ b/src/views.ts @@ -15,7 +15,7 @@ async function renderEventAccounts(c: AppContext, filters: Filter[]) { } const accounts = await Promise.all([...pubkeys].map(async (pubkey) => { - const author = await getAuthor(pubkey); + const author = await getAuthor(pubkey, { relations: ['author_stats'] }); if (author) { return renderAccount(author); } diff --git a/src/views/mastodon/accounts.ts b/src/views/mastodon/accounts.ts index 3a55fb7..bfcd7ef 100644 --- a/src/views/mastodon/accounts.ts +++ b/src/views/mastodon/accounts.ts @@ -1,8 +1,7 @@ import { Conf } from '@/config.ts'; -import * as eventsDB from '@/db/events.ts'; +import { type DittoEvent } from '@/db/events.ts'; import { findUser } from '@/db/users.ts'; import { lodash, nip19, type UnsignedEvent } from '@/deps.ts'; -import { getFollowedPubkeys } from '@/queries.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { verifyNip05Cached } from '@/utils/nip05.ts'; import { Nip05, nostrDate, nostrNow, parseNip05 } from '@/utils.ts'; @@ -12,7 +11,10 @@ interface ToAccountOpts { withSource?: boolean; } -async function renderAccount(event: UnsignedEvent<0>, opts: ToAccountOpts = {}) { +async function renderAccount( + event: Omit, 'id' | 'sig'>, + opts: ToAccountOpts = {}, +) { const { withSource = false } = opts; const { pubkey } = event; @@ -26,12 +28,9 @@ async function renderAccount(event: UnsignedEvent<0>, opts: ToAccountOpts = {}) const npub = nip19.npubEncode(pubkey); - const [user, parsed05, followersCount, followingCount, statusesCount] = await Promise.all([ + const [user, parsed05] = await Promise.all([ findUser({ pubkey }), parseAndVerifyNip05(nip05, pubkey), - eventsDB.countFilters([{ kinds: [3], '#p': [pubkey] }]), - getFollowedPubkeys(pubkey).then((pubkeys) => pubkeys.length), - eventsDB.countFilters([{ kinds: [1], authors: [pubkey] }]), ]); return { @@ -40,14 +39,14 @@ async function renderAccount(event: UnsignedEvent<0>, opts: ToAccountOpts = {}) avatar: picture, avatar_static: picture, bot: false, - created_at: event ? nostrDate(event.created_at).toISOString() : new Date().toISOString(), + created_at: nostrDate(event.created_at).toISOString(), discoverable: true, display_name: name, emojis: renderEmojis(event), fields: [], follow_requests_count: 0, - followers_count: followersCount, - following_count: followingCount, + followers_count: event.author_stats?.followers_count ?? 0, + following_count: event.author_stats?.following_count ?? 0, fqn: parsed05?.handle || npub, header: banner, header_static: banner, @@ -65,7 +64,7 @@ async function renderAccount(event: UnsignedEvent<0>, opts: ToAccountOpts = {}) follow_requests_count: 0, } : undefined, - statuses_count: statusesCount, + statuses_count: event.author_stats?.notes_count ?? 0, url: Conf.local(`/users/${pubkey}`), username: parsed05?.nickname || npub.substring(0, 8), pleroma: { diff --git a/src/views/mastodon/statuses.ts b/src/views/mastodon/statuses.ts index 86bdd13..17b1677 100644 --- a/src/views/mastodon/statuses.ts +++ b/src/views/mastodon/statuses.ts @@ -13,7 +13,10 @@ import { DittoAttachment, renderAttachment } from '@/views/mastodon/attachments. import { renderEmojis } from '@/views/mastodon/emojis.ts'; async function renderStatus(event: eventsDB.DittoEvent<1>, viewerPubkey?: string) { - const account = event.author ? await renderAccount(event.author) : await accountFromPubkey(event.pubkey); + const account = event.author + ? await renderAccount({ ...event.author, author_stats: event.author_stats }) + : await accountFromPubkey(event.pubkey); + const replyTag = findReplyTag(event); const mentionedPubkeys = [ @@ -26,13 +29,10 @@ async function renderStatus(event: eventsDB.DittoEvent<1>, viewerPubkey?: string const { html, links, firstUrl } = parseNoteContent(event.content); - const [mentions, card, repliesCount, reblogsCount, favouritesCount, [repostEvent], [reactionEvent]] = await Promise + const [mentions, card, [repostEvent], [reactionEvent]] = await Promise .all([ Promise.all(mentionedPubkeys.map(toMention)), firstUrl ? unfurlCardCached(firstUrl) : null, - eventsDB.countFilters([{ kinds: [1], '#e': [event.id] }]), - eventsDB.countFilters([{ kinds: [6], '#e': [event.id] }]), - eventsDB.countFilters([{ kinds: [7], '#e': [event.id] }]), viewerPubkey ? eventsDB.getFilters([{ kinds: [6], '#e': [event.id], authors: [viewerPubkey] }], { limit: 1 }) : [], @@ -66,9 +66,9 @@ async function renderStatus(event: eventsDB.DittoEvent<1>, viewerPubkey?: string spoiler_text: (cw ? cw[1] : subject?.[1]) || '', visibility: 'public', language: event.tags.find((tag) => tag[0] === 'lang')?.[1] || null, - replies_count: repliesCount, - reblogs_count: reblogsCount, - favourites_count: favouritesCount, + replies_count: event.event_stats?.replies_count ?? 0, + reblogs_count: event.event_stats?.reposts_count ?? 0, + favourites_count: event.event_stats?.reactions_count ?? 0, favourited: reactionEvent?.content === '+', reblogged: Boolean(repostEvent), muted: false, @@ -86,8 +86,8 @@ async function renderStatus(event: eventsDB.DittoEvent<1>, viewerPubkey?: string } async function toMention(pubkey: string) { - const profile = await getAuthor(pubkey); - const account = profile ? await renderAccount(profile) : undefined; + const author = await getAuthor(pubkey); + const account = author ? await renderAccount(author) : undefined; if (account) { return {