diff --git a/src/app.ts b/src/app.ts index 73fcb2d8..0ecc5b2f 100644 --- a/src/app.ts +++ b/src/app.ts @@ -18,6 +18,8 @@ import { accountStatusesController, createAccountController, followController, + followersController, + followingController, relationshipsController, updateCredentialsController, verifyCredentialsController, @@ -105,6 +107,8 @@ app.get('/api/v1/accounts/search', accountSearchController); app.get('/api/v1/accounts/lookup', accountLookupController); app.get('/api/v1/accounts/relationships', relationshipsController); app.post('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/follow', followController); +app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/followers', followersController); +app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/following', followingController); app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}/statuses', accountStatusesController); app.get('/api/v1/accounts/:pubkey{[0-9a-f]{64}}', accountController); diff --git a/src/controllers/api/accounts.ts b/src/controllers/api/accounts.ts index f39bed3c..ba260f65 100644 --- a/src/controllers/api/accounts.ts +++ b/src/controllers/api/accounts.ts @@ -1,13 +1,14 @@ import { type AppController } from '@/app.ts'; import { type Filter, findReplyTag, z } from '@/deps.ts'; import * as mixer from '@/mixer.ts'; -import { getAuthor, getFollows, syncUser } from '@/queries.ts'; +import { getAuthor, getFollowedPubkeys, getFollows, syncUser } from '@/queries.ts'; import { booleanParamSchema } from '@/schema.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { toAccount, toRelationship, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; import { isFollowing, lookupAccount } from '@/utils.ts'; import { paginated, paginationSchema, parseBody } from '@/utils/web.ts'; import { createEvent } from '@/utils/web.ts'; +import { renderEventAccounts } from '@/views.ts'; const createAccountController: AppController = (c) => { return c.json({ error: 'Please log in with Nostr.' }, 405); @@ -173,6 +174,25 @@ const followController: AppController = async (c) => { return c.json(relationship); }; +const followersController: AppController = (c) => { + const pubkey = c.req.param('pubkey'); + const params = paginationSchema.parse(c.req.query()); + return renderEventAccounts(c, [{ kinds: [3], '#p': [pubkey], ...params }]); +}; + +const followingController: AppController = async (c) => { + const pubkey = c.req.param('pubkey'); + const pubkeys = await getFollowedPubkeys(pubkey); + + // TODO: pagination by offset. + const accounts = await Promise.all(pubkeys.map(async (pubkey) => { + const event = await getAuthor(pubkey); + return event ? await toAccount(event) : undefined; + })); + + return c.json(accounts.filter(Boolean)); +}; + export { accountController, accountLookupController, @@ -180,6 +200,8 @@ export { accountStatusesController, createAccountController, followController, + followersController, + followingController, relationshipsController, updateCredentialsController, verifyCredentialsController, diff --git a/src/controllers/api/statuses.ts b/src/controllers/api/statuses.ts index 9efc4e40..f7839f8f 100644 --- a/src/controllers/api/statuses.ts +++ b/src/controllers/api/statuses.ts @@ -1,9 +1,9 @@ import { type AppController } from '@/app.ts'; import { type Event, ISO6391, z } from '@/deps.ts'; -import * as mixer from '@/mixer.ts'; -import { getAncestors, getAuthor, getDescendants, getEvent } from '@/queries.ts'; -import { toAccount, toStatus } from '@/transformers/nostr-to-mastoapi.ts'; -import { createEvent, paginated, parseBody } from '@/utils/web.ts'; +import { getAncestors, getDescendants, getEvent } from '@/queries.ts'; +import { toStatus } from '@/transformers/nostr-to-mastoapi.ts'; +import { createEvent, paginationSchema, parseBody } from '@/utils/web.ts'; +import { renderEventAccounts } from '@/views.ts'; const createStatusSchema = z.object({ in_reply_to_id: z.string().regex(/[0-9a-f]{64}/).nullish(), @@ -129,34 +129,16 @@ const favouriteController: AppController = async (c) => { } }; -const favouritedByController: AppController = async (c) => { +const favouritedByController: AppController = (c) => { const id = c.req.param('id'); - - const events = await mixer.getFilters([{ kinds: [7], '#e': [id] }]); - - const accounts = await Promise.all(events.map(async ({ pubkey }) => { - const author = await getAuthor(pubkey); - if (author) { - return toAccount(author); - } - })); - - return paginated(c, events, accounts); + const params = paginationSchema.parse(c.req.query()); + return renderEventAccounts(c, [{ kinds: [7], '#e': [id], ...params }]); }; -const rebloggedByController: AppController = async (c) => { +const rebloggedByController: AppController = (c) => { const id = c.req.param('id'); - - const events = await mixer.getFilters([{ kinds: [6], '#e': [id] }]); - - const accounts = await Promise.all(events.map(async ({ pubkey }) => { - const author = await getAuthor(pubkey); - if (author) { - return toAccount(author); - } - })); - - return paginated(c, events, accounts); + const params = paginationSchema.parse(c.req.query()); + return renderEventAccounts(c, [{ kinds: [6], '#e': [id], ...params }]); }; export { diff --git a/src/queries.ts b/src/queries.ts index 5429abd3..ff1b1860 100644 --- a/src/queries.ts +++ b/src/queries.ts @@ -87,4 +87,14 @@ async function syncUser(pubkey: string): Promise { ], { timeout: 5000 }); } -export { getAncestors, getAuthor, getDescendants, getEvent, getFeedPubkeys, getFollows, isLocallyFollowed, syncUser }; +export { + getAncestors, + getAuthor, + getDescendants, + getEvent, + getFeedPubkeys, + getFollowedPubkeys, + getFollows, + isLocallyFollowed, + syncUser, +}; diff --git a/src/transformers/nostr-to-mastoapi.ts b/src/transformers/nostr-to-mastoapi.ts index f4ee603a..019c42cb 100644 --- a/src/transformers/nostr-to-mastoapi.ts +++ b/src/transformers/nostr-to-mastoapi.ts @@ -5,7 +5,7 @@ import * as eventsDB from '@/db/events.ts'; import { type Event, findReplyTag, lodash, nip19, sanitizeHtml, TTLCache, unfurl, z } from '@/deps.ts'; import { verifyNip05Cached } from '@/nip05.ts'; import { getMediaLinks, type MediaLink, parseNoteContent } from '@/note.ts'; -import { getAuthor, getFollows } from '@/queries.ts'; +import { getAuthor, getFollowedPubkeys, getFollows } from '@/queries.ts'; import { emojiTagSchema, filteredArray } from '@/schema.ts'; import { jsonMetaContentSchema } from '@/schemas/nostr.ts'; import { isFollowing, type Nip05, nostrDate, parseNip05, Time } from '@/utils.ts'; @@ -24,14 +24,12 @@ async function toAccount(event: Event<0>, opts: ToAccountOpts = {}) { const { name, nip05, picture, banner, about } = jsonMetaContentSchema.parse(event.content); const npub = nip19.npubEncode(pubkey); - let parsed05: Nip05 | undefined; - try { - if (nip05 && await verifyNip05Cached(nip05, pubkey)) { - parsed05 = parseNip05(nip05); - } - } catch (_e) { - // - } + const [parsed05, followersCount, followingCount, statusesCount] = await Promise.all([ + parseAndVerifyNip05(nip05, pubkey), + eventsDB.countFilters([{ kinds: [3], '#p': [pubkey] }]), + getFollowedPubkeys(pubkey).then((pubkeys) => pubkeys.length), + eventsDB.countFilters([{ kinds: [1], authors: [pubkey] }]), + ]); return { id: pubkey, @@ -45,8 +43,8 @@ async function toAccount(event: Event<0>, opts: ToAccountOpts = {}) { emojis: toEmojis(event), fields: [], follow_requests_count: 0, - followers_count: 0, - following_count: 0, + followers_count: followersCount, + following_count: followingCount, fqn: parsed05?.handle || npub, header: banner || DEFAULT_BANNER, header_static: banner || DEFAULT_BANNER, @@ -64,12 +62,18 @@ async function toAccount(event: Event<0>, opts: ToAccountOpts = {}) { follow_requests_count: 0, } : undefined, - statuses_count: 0, + statuses_count: statusesCount, url: Conf.local(`/users/${pubkey}`), username: parsed05?.nickname || npub.substring(0, 8), }; } +async function parseAndVerifyNip05(nip05: string | undefined, pubkey: string): Promise { + if (nip05 && await verifyNip05Cached(nip05, pubkey)) { + return parseNip05(nip05); + } +} + async function toMention(pubkey: string) { const profile = await getAuthor(pubkey); const account = profile ? await toAccount(profile) : undefined; diff --git a/src/views.ts b/src/views.ts new file mode 100644 index 00000000..2765c6de --- /dev/null +++ b/src/views.ts @@ -0,0 +1,27 @@ +import { AppContext } from '@/app.ts'; +import { type Filter } from '@/deps.ts'; +import * as mixer from '@/mixer.ts'; +import { getAuthor } from '@/queries.ts'; +import { toAccount } from '@/transformers/nostr-to-mastoapi.ts'; +import { paginated } from '@/utils/web.ts'; + +/** Render account objects for the author of each event. */ +async function renderEventAccounts(c: AppContext, filters: Filter[]) { + const events = await mixer.getFilters(filters); + const pubkeys = new Set(events.map(({ pubkey }) => pubkey)); + + if (!pubkeys.size) { + return c.json([]); + } + + const accounts = await Promise.all([...pubkeys].map(async (pubkey) => { + const author = await getAuthor(pubkey); + if (author) { + return toAccount(author); + } + })); + + return paginated(c, events, accounts); +} + +export { renderEventAccounts };