diff --git a/packages/bsky/src/api/app/bsky/actor/getProfile.ts b/packages/bsky/src/api/app/bsky/actor/getProfile.ts index 51cbd313..e1db6abf 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfile.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfile.ts @@ -23,10 +23,17 @@ export default function (server: Server, ctx: AppContext) { 'AccountTakedown', ) } + const profile = await actorService.views.profileDetailed( + actorRes, + requester, + ) + if (!profile) { + throw new InvalidRequestError('Profile not found') + } return { encoding: 'application/json', - body: await actorService.views.profileDetailed(actorRes, requester), + body: profile, } }, }) diff --git a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts index ad676f01..3b97a034 100644 --- a/packages/bsky/src/api/app/bsky/actor/getProfiles.ts +++ b/packages/bsky/src/api/app/bsky/actor/getProfiles.ts @@ -15,7 +15,7 @@ export default function (server: Server, ctx: AppContext) { return { encoding: 'application/json', body: { - profiles: await actorService.views.profileDetailed( + profiles: await actorService.views.hydrateProfilesDetailed( actorsRes, requester, ), diff --git a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts index 37448cbf..ba51957c 100644 --- a/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts +++ b/packages/bsky/src/api/app/bsky/actor/getSuggestions.ts @@ -55,7 +55,10 @@ export default function (server: Server, ctx: AppContext) { encoding: 'application/json', body: { cursor: suggestionsRes.at(-1)?.did, - actors: await actorService.views.profile(suggestionsRes, viewer), + actors: await actorService.views.hydrateProfiles( + suggestionsRes, + viewer, + ), }, } }, diff --git a/packages/bsky/src/api/app/bsky/actor/searchActors.ts b/packages/bsky/src/api/app/bsky/actor/searchActors.ts index d2911443..9adc0b05 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActors.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActors.ts @@ -24,7 +24,9 @@ export default function (server: Server, ctx: AppContext) { : [] const keyset = new SearchKeyset(sql``, sql``) - const actors = await services.actor(db).views.profile(results, requester) + const actors = await services + .actor(db) + .views.hydrateProfiles(results, requester) const filtered = actors.filter( (actor) => !actor.viewer?.blocking && !actor.viewer?.blockedBy, ) diff --git a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts index 9148634f..a20ecd16 100644 --- a/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/bsky/src/api/app/bsky/actor/searchActorsTypeahead.ts @@ -22,7 +22,7 @@ export default function (server: Server, ctx: AppContext) { const actors = await services .actor(db) - .views.profileBasic(results, requester) + .views.hydrateProfilesBasic(results, requester) const filtered = actors.filter( (actor) => !actor.viewer?.blocking && !actor.viewer?.blockedBy, diff --git a/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts b/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts index 5ca0a141..a255bc94 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorFeeds.ts @@ -37,6 +37,9 @@ export default function (server: Server, ctx: AppContext) { feedsQb.execute(), actorService.views.profile(creatorRes, viewer), ]) + if (!creatorProfile) { + throw new InvalidRequestError(`Actor not found: ${actor}`) + } const profiles = { [creatorProfile.did]: creatorProfile } const feeds = feedsRes.map((row) => { diff --git a/packages/bsky/src/api/app/bsky/feed/getLikes.ts b/packages/bsky/src/api/app/bsky/feed/getLikes.ts index 4476b3bb..7a0d0551 100644 --- a/packages/bsky/src/api/app/bsky/feed/getLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getLikes.ts @@ -1,3 +1,4 @@ +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { paginate, TimeCidKeyset } from '../../../../db/pagination' import AppContext from '../../../../context' @@ -37,7 +38,19 @@ export default function (server: Server, ctx: AppContext) { }) const likesRes = await builder.execute() - const actors = await services.actor(db).views.profile(likesRes, requester) + const actors = await services + .actor(db) + .views.profiles(likesRes, requester) + + const likes = mapDefined(likesRes, (row) => + actors[row.did] + ? { + createdAt: row.createdAt, + indexedAt: row.indexedAt, + actor: actors[row.did], + } + : undefined, + ) return { encoding: 'application/json', @@ -45,11 +58,7 @@ export default function (server: Server, ctx: AppContext) { uri, cid, cursor: keyset.packFromResult(likesRes), - likes: likesRes.map((row, i) => ({ - createdAt: row.createdAt, - indexedAt: row.indexedAt, - actor: actors[i], - })), + likes, }, } }, diff --git a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts index c76e19b3..ff635641 100644 --- a/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/bsky/src/api/app/bsky/feed/getRepostedBy.ts @@ -34,7 +34,7 @@ export default function (server: Server, ctx: AppContext) { const repostedByRes = await builder.execute() const repostedBy = await services .actor(db) - .views.profile(repostedByRes, requester) + .views.hydrateProfiles(repostedByRes, requester) return { encoding: 'application/json', diff --git a/packages/bsky/src/api/app/bsky/graph/getBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getBlocks.ts index e2142deb..45af81a7 100644 --- a/packages/bsky/src/api/app/bsky/graph/getBlocks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getBlocks.ts @@ -33,7 +33,10 @@ export default function (server: Server, ctx: AppContext) { const blocksRes = await blocksReq.execute() const actorService = services.actor(db) - const blocks = await actorService.views.profile(blocksRes, requester) + const blocks = await actorService.views.hydrateProfiles( + blocksRes, + requester, + ) return { encoding: 'application/json', diff --git a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts index 6908d989..85f1dd78 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollowers.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollowers.ts @@ -37,9 +37,12 @@ export default function (server: Server, ctx: AppContext) { const followersRes = await followersReq.execute() const [followers, subject] = await Promise.all([ - actorService.views.profile(followersRes, requester), + actorService.views.hydrateProfiles(followersRes, requester), actorService.views.profile(subjectRes, requester), ]) + if (!subject) { + throw new InvalidRequestError(`Actor not found: ${actor}`) + } return { encoding: 'application/json', diff --git a/packages/bsky/src/api/app/bsky/graph/getFollows.ts b/packages/bsky/src/api/app/bsky/graph/getFollows.ts index 2db016da..3fdb45f2 100644 --- a/packages/bsky/src/api/app/bsky/graph/getFollows.ts +++ b/packages/bsky/src/api/app/bsky/graph/getFollows.ts @@ -37,9 +37,12 @@ export default function (server: Server, ctx: AppContext) { const followsRes = await followsReq.execute() const [follows, subject] = await Promise.all([ - actorService.views.profile(followsRes, requester), + actorService.views.hydrateProfiles(followsRes, requester), actorService.views.profile(creatorRes, requester), ]) + if (!subject) { + throw new InvalidRequestError(`Actor not found: ${actor}`) + } return { encoding: 'application/json', diff --git a/packages/bsky/src/api/app/bsky/graph/getList.ts b/packages/bsky/src/api/app/bsky/graph/getList.ts index 2814cf99..1a12baa5 100644 --- a/packages/bsky/src/api/app/bsky/graph/getList.ts +++ b/packages/bsky/src/api/app/bsky/graph/getList.ts @@ -2,7 +2,6 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../lexicon' import { paginate, TimeCidKeyset } from '../../../../db/pagination' import AppContext from '../../../../context' -import { ProfileView } from '../../../../lexicon/types/app/bsky/actor/defs' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getList({ @@ -40,20 +39,17 @@ export default function (server: Server, ctx: AppContext) { const itemsRes = await itemsReq.execute() const actorService = services.actor(db) - const profiles = await actorService.views.profile(itemsRes, requester) - const profilesMap = profiles.reduce( - (acc, cur) => ({ - ...acc, - [cur.did]: cur, - }), - {} as Record<string, ProfileView>, + const profiles = await actorService.views.hydrateProfiles( + itemsRes, + requester, ) - const items = itemsRes.map((item) => ({ - subject: profilesMap[item.did], - })) + const items = profiles.map((subject) => ({ subject })) const creator = await actorService.views.profile(listRes, requester) + if (!creator) { + throw new InvalidRequestError(`Actor not found: ${listRes.handle}`) + } const subject = { uri: listRes.uri, diff --git a/packages/bsky/src/api/app/bsky/graph/getListMutes.ts b/packages/bsky/src/api/app/bsky/graph/getListMutes.ts index f8f353bc..d85b86f9 100644 --- a/packages/bsky/src/api/app/bsky/graph/getListMutes.ts +++ b/packages/bsky/src/api/app/bsky/graph/getListMutes.ts @@ -1,7 +1,6 @@ import { Server } from '../../../../lexicon' import { paginate, TimeCidKeyset } from '../../../../db/pagination' import AppContext from '../../../../context' -import { ProfileView } from '../../../../lexicon/types/app/bsky/actor/defs' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getListMutes({ @@ -33,17 +32,10 @@ export default function (server: Server, ctx: AppContext) { const listsRes = await listsReq.execute() const actorService = ctx.services.actor(ctx.db) - const profiles = await actorService.views.profile(listsRes, requester) - const profilesMap = profiles.reduce( - (acc, cur) => ({ - ...acc, - [cur.did]: cur, - }), - {} as Record<string, ProfileView>, - ) + const profiles = await actorService.views.profiles(listsRes, requester) const lists = listsRes.map((row) => - graphService.formatListView(row, profilesMap), + graphService.formatListView(row, profiles), ) return { diff --git a/packages/bsky/src/api/app/bsky/graph/getLists.ts b/packages/bsky/src/api/app/bsky/graph/getLists.ts index b325a8a4..2f2d7878 100644 --- a/packages/bsky/src/api/app/bsky/graph/getLists.ts +++ b/packages/bsky/src/api/app/bsky/graph/getLists.ts @@ -35,6 +35,9 @@ export default function (server: Server, ctx: AppContext) { listsReq.execute(), actorService.views.profile(creatorRes, requester), ]) + if (!creator) { + throw new InvalidRequestError(`Actor not found: ${actor}`) + } const profileMap = { [creator.did]: creator, } diff --git a/packages/bsky/src/api/app/bsky/graph/getMutes.ts b/packages/bsky/src/api/app/bsky/graph/getMutes.ts index fa8b53f0..07250896 100644 --- a/packages/bsky/src/api/app/bsky/graph/getMutes.ts +++ b/packages/bsky/src/api/app/bsky/graph/getMutes.ts @@ -38,7 +38,7 @@ export default function (server: Server, ctx: AppContext) { encoding: 'application/json', body: { cursor: keyset.packFromResult(mutesRes), - mutes: await actorService.views.profile(mutesRes, requester), + mutes: await actorService.views.hydrateProfiles(mutesRes, requester), }, } }, diff --git a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts index 9aec05ed..463b9a6f 100644 --- a/packages/bsky/src/api/app/bsky/notification/listNotifications.ts +++ b/packages/bsky/src/api/app/bsky/notification/listNotifications.ts @@ -1,5 +1,6 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { jsonStringToLex } from '@atproto/lexicon' +import { mapDefined } from '@atproto/common' import { Server } from '../../../../lexicon' import { paginate, TimeCidKeyset } from '../../../../db/pagination' import AppContext from '../../../../context' @@ -78,7 +79,7 @@ export default function (server: Server, ctx: AppContext) { const labelService = ctx.services.label(ctx.db) const recordUris = notifs.map((notif) => notif.uri) const [authors, labels] = await Promise.all([ - actorService.views.profile( + actorService.views.profiles( notifs.map((notif) => ({ did: notif.authorDid, handle: notif.authorHandle, @@ -90,17 +91,21 @@ export default function (server: Server, ctx: AppContext) { labelService.getLabelsForUris(recordUris), ]) - const notifications = notifs.map((notif, i) => ({ - uri: notif.uri, - cid: notif.cid, - author: authors[i], - reason: notif.reason, - reasonSubject: notif.reasonSubject || undefined, - record: jsonStringToLex(notif.recordJson) as Record<string, unknown>, - isRead: seenAt ? notif.indexedAt <= seenAt : false, - indexedAt: notif.indexedAt, - labels: labels[notif.uri] ?? [], - })) + const notifications = mapDefined(notifs, (notif) => { + const author = authors[notif.authorDid] + if (!author) return undefined + return { + uri: notif.uri, + cid: notif.cid, + author, + reason: notif.reason, + reasonSubject: notif.reasonSubject || undefined, + record: jsonStringToLex(notif.recordJson) as Record<string, unknown>, + isRead: seenAt ? notif.indexedAt <= seenAt : false, + indexedAt: notif.indexedAt, + labels: labels[notif.uri] ?? [], + } + }) return { encoding: 'application/json', diff --git a/packages/bsky/src/services/actor/views.ts b/packages/bsky/src/services/actor/views.ts index 4bf75bef..7f0cc86e 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -1,4 +1,4 @@ -import { ArrayEl } from '@atproto/common' +import { mapDefined } from '@atproto/common' import { INVALID_HANDLE } from '@atproto/identifier' import { ProfileViewDetailed, @@ -10,32 +10,22 @@ import { noMatch, notSoftDeletedClause } from '../../db/util' import { Actor } from '../../db/tables/actor' import { ImageUriBuilder } from '../../image/uri' import { LabelService } from '../label' -import { ListViewBasic } from '../../lexicon/types/app/bsky/graph/defs' +import { GraphService } from '../graph' export class ActorViews { constructor(private db: Database, private imgUriBuilder: ImageUriBuilder) {} services = { - label: LabelService.creator(), + label: LabelService.creator()(this.db), + graph: GraphService.creator(this.imgUriBuilder)(this.db), } - profileDetailed( - result: ActorResult, + async profilesDetailed( + results: ActorResult[], viewer: string | null, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileViewDetailed> - profileDetailed( - result: ActorResult[], - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileViewDetailed[]> - async profileDetailed( - result: ActorResult | ActorResult[], - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileViewDetailed | ProfileViewDetailed[]> { - const results = Array.isArray(result) ? result : [result] - if (results.length === 0) return [] + ): Promise<Record<string, ProfileViewDetailed>> { + if (results.length === 0) return {} const { ref } = this.db.db.dynamic const { skipLabels = false, includeSoftDeleted = false } = opts ?? {} @@ -51,6 +41,7 @@ export class ActorViews { ) .select([ 'actor.did as did', + 'actor.handle as handle', 'profile.uri as profileUri', 'profile.displayName as displayName', 'profile.description as description', @@ -95,79 +86,101 @@ export class ActorViews { .where('mutedByDid', '=', viewer ?? '') .select('subjectDid') .as('requesterMuted'), + this.db.db + .selectFrom('list_item') + .if(!viewer, (q) => q.where(noMatch)) + .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') + .where('list_mute.mutedByDid', '=', viewer ?? '') + .whereRef('list_item.subjectDid', '=', ref('actor.did')) + .select('list_item.listUri') + .limit(1) + .as('requesterMutedByList'), ]) - const [profileInfos, labels, listMutes] = await Promise.all([ + const [profileInfos, labels] = await Promise.all([ profileInfosQb.execute(), - this.services.label(this.db).getLabelsForSubjects(skipLabels ? [] : dids), - this.getListMutes(dids, viewer), + this.services.label.getLabelsForSubjects(skipLabels ? [] : dids), ]) - const profileInfoByDid = profileInfos.reduce((acc, info) => { - return Object.assign(acc, { [info.did]: info }) - }, {} as Record<string, ArrayEl<typeof profileInfos>>) + const listUris: string[] = profileInfos + .map((a) => a.requesterMutedByList) + .filter((list) => !!list) + const listViews = await this.services.graph.getListViews(listUris, viewer) - const views = results.map((result) => { - const profileInfo = profileInfoByDid[result.did] - const avatar = profileInfo?.avatarCid + return profileInfos.reduce((acc, cur) => { + const actorLabels = labels[cur.did] ?? [] + const avatar = cur?.avatarCid ? this.imgUriBuilder.getCommonSignedUri( 'avatar', - profileInfo.did, - profileInfo.avatarCid, + cur.did, + cur.avatarCid, ) : undefined - const banner = profileInfo?.bannerCid + const banner = cur?.bannerCid ? this.imgUriBuilder.getCommonSignedUri( 'banner', - profileInfo.did, - profileInfo.bannerCid, + cur.did, + cur.bannerCid, ) : undefined - return { - did: result.did, - handle: result.handle ?? INVALID_HANDLE, - displayName: profileInfo?.displayName || undefined, - description: profileInfo?.description || undefined, + const mutedByList = + cur.requesterMutedByList && listViews[cur.requesterMutedByList] + ? this.services.graph.formatListViewBasic( + listViews[cur.requesterMutedByList], + ) + : undefined + const profile = { + did: cur.did, + handle: cur.handle ?? INVALID_HANDLE, + displayName: cur?.displayName || undefined, + description: cur?.description || undefined, avatar, banner, - followsCount: profileInfo?.followsCount ?? 0, - followersCount: profileInfo?.followersCount ?? 0, - postsCount: profileInfo?.postsCount ?? 0, - indexedAt: profileInfo?.indexedAt || undefined, + followsCount: cur?.followsCount ?? 0, + followersCount: cur?.followersCount ?? 0, + postsCount: cur?.postsCount ?? 0, + indexedAt: cur?.indexedAt || undefined, viewer: viewer ? { - following: profileInfo?.requesterFollowing || undefined, - followedBy: profileInfo?.requesterFollowedBy || undefined, - muted: !!profileInfo?.requesterMuted || !!listMutes[result.did], - mutedByList: listMutes[result.did], - blockedBy: !!profileInfo.requesterBlockedBy, - blocking: profileInfo.requesterBlocking || undefined, + following: cur?.requesterFollowing || undefined, + followedBy: cur?.requesterFollowedBy || undefined, + muted: !!cur?.requesterMuted || !!cur.requesterMutedByList, + mutedByList, + blockedBy: !!cur.requesterBlockedBy, + blocking: cur.requesterBlocking || undefined, } : undefined, - labels: labels[result.did] ?? [], + labels: skipLabels ? undefined : actorLabels, } - }) - - return Array.isArray(result) ? views : views[0] + acc[cur.did] = profile + return acc + }, {} as Record<string, ProfileViewDetailed>) } - profile( + async hydrateProfilesDetailed( + results: ActorResult[], + viewer: string | null, + opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, + ): Promise<ProfileViewDetailed[]> { + const profiles = await this.profilesDetailed(results, viewer, opts) + return mapDefined(results, (result) => profiles[result.did]) + } + + async profileDetailed( result: ActorResult, viewer: string | null, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileView> - profile( - result: ActorResult[], + ): Promise<ProfileViewDetailed | null> { + const profiles = await this.profilesDetailed([result], viewer, opts) + return profiles[result.did] ?? null + } + + async profiles( + results: ActorResult[], viewer: string | null, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileView[]> - async profile( - result: ActorResult | ActorResult[], - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileView | ProfileView[]> { - const results = Array.isArray(result) ? result : [result] - if (results.length === 0) return [] + ): Promise<Record<string, ProfileView>> { + if (results.length === 0) return {} const { ref } = this.db.db.dynamic const { skipLabels = false, includeSoftDeleted = false } = opts ?? {} @@ -182,6 +195,7 @@ export class ActorViews { ) .select([ 'actor.did as did', + 'actor.handle as handle', 'profile.uri as profileUri', 'profile.displayName as displayName', 'profile.description as description', @@ -222,120 +236,121 @@ export class ActorViews { .where('mutedByDid', '=', viewer ?? '') .select('subjectDid') .as('requesterMuted'), + this.db.db + .selectFrom('list_item') + .if(!viewer, (q) => q.where(noMatch)) + .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') + .where('list_mute.mutedByDid', '=', viewer ?? '') + .whereRef('list_item.subjectDid', '=', ref('actor.did')) + .select('list_item.listUri') + .limit(1) + .as('requesterMutedByList'), ]) - const [profileInfos, labels, listMutes] = await Promise.all([ + const [profileInfos, labels] = await Promise.all([ profileInfosQb.execute(), - this.services.label(this.db).getLabelsForSubjects(skipLabels ? [] : dids), - this.getListMutes(dids, viewer), + this.services.label.getLabelsForSubjects(skipLabels ? [] : dids), ]) - const profileInfoByDid = profileInfos.reduce((acc, info) => { - return Object.assign(acc, { [info.did]: info }) - }, {} as Record<string, ArrayEl<typeof profileInfos>>) + const listUris: string[] = profileInfos + .map((a) => a.requesterMutedByList) + .filter((list) => !!list) + const listViews = await this.services.graph.getListViews(listUris, viewer) - const views = results.map((result) => { - const profileInfo = profileInfoByDid[result.did] - const avatar = profileInfo?.avatarCid + return profileInfos.reduce((acc, cur) => { + const actorLabels = labels[cur.did] ?? [] + const avatar = cur?.avatarCid ? this.imgUriBuilder.getCommonSignedUri( 'avatar', - profileInfo.did, - profileInfo.avatarCid, + cur.did, + cur.avatarCid, ) : undefined - return { - did: result.did, - handle: result.handle ?? INVALID_HANDLE, - displayName: profileInfo?.displayName || undefined, - description: profileInfo?.description || undefined, + const mutedByList = + cur.requesterMutedByList && listViews[cur.requesterMutedByList] + ? this.services.graph.formatListViewBasic( + listViews[cur.requesterMutedByList], + ) + : undefined + const profile = { + did: cur.did, + handle: cur.handle ?? INVALID_HANDLE, + displayName: cur?.displayName || undefined, + description: cur?.description || undefined, avatar, - indexedAt: profileInfo?.indexedAt || undefined, + indexedAt: cur?.indexedAt || undefined, viewer: viewer ? { - muted: !!profileInfo?.requesterMuted || !!listMutes[result.did], - mutedByList: listMutes[result.did], - blockedBy: !!profileInfo.requesterBlockedBy, - blocking: profileInfo.requesterBlocking || undefined, - following: profileInfo?.requesterFollowing || undefined, - followedBy: profileInfo?.requesterFollowedBy || undefined, + muted: !!cur?.requesterMuted || !!cur.requesterMutedByList, + mutedByList, + blockedBy: !!cur.requesterBlockedBy, + blocking: cur.requesterBlocking || undefined, + following: cur?.requesterFollowing || undefined, + followedBy: cur?.requesterFollowedBy || undefined, } : undefined, - labels: labels[result.did] ?? [], + labels: skipLabels ? undefined : actorLabels, } - }) - - return Array.isArray(result) ? views : views[0] + acc[cur.did] = profile + return acc + }, {} as Record<string, ProfileView>) } - // @NOTE keep in sync with feedService.getActorViews() - profileBasic( + async hydrateProfiles( + results: ActorResult[], + viewer: string | null, + opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, + ): Promise<ProfileView[]> { + const profiles = await this.profiles(results, viewer, opts) + return mapDefined(results, (result) => profiles[result.did]) + } + + async profile( result: ActorResult, viewer: string | null, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileViewBasic> - profileBasic( - result: ActorResult[], - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileViewBasic[]> - async profileBasic( - result: ActorResult | ActorResult[], - viewer: string | null, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileViewBasic | ProfileViewBasic[]> { - const results = Array.isArray(result) ? result : [result] - if (results.length === 0) return [] - - const profiles = await this.profile(results, viewer, opts) - const views = profiles.map((view) => ({ - did: view.did, - handle: view.handle, - displayName: view.displayName, - avatar: view.avatar, - viewer: view.viewer, - })) - - return Array.isArray(result) ? views : views[0] + ): Promise<ProfileView | null> { + const profiles = await this.profiles([result], viewer, opts) + return profiles[result.did] ?? null } - async getListMutes( - subjects: string[], - mutedBy: string | null, - ): Promise<Record<string, ListViewBasic>> { - if (mutedBy === null) return {} - if (subjects.length < 1) return {} - const res = await this.db.db - .selectFrom('list_item') - .innerJoin('list_mute', 'list_mute.listUri', 'list_item.listUri') - .innerJoin('list', 'list.uri', 'list_item.listUri') - .where('list_mute.mutedByDid', '=', mutedBy) - .where('list_item.subjectDid', 'in', subjects) - .selectAll('list') - .select('list_item.subjectDid as subjectDid') - .execute() - return res.reduce( - (acc, cur) => ({ - ...acc, - [cur.subjectDid]: { - uri: cur.uri, - cid: cur.cid, - name: cur.name, - purpose: cur.purpose, - avatar: cur.avatarCid - ? this.imgUriBuilder.getCommonSignedUri( - 'avatar', - cur.creator, - cur.avatarCid, - ) - : undefined, - viewer: { - muted: true, - }, - indexedAt: cur.indexedAt, - }, - }), - {} as Record<string, ListViewBasic>, - ) + // @NOTE keep in sync with feedService.getActorViews() + async profilesBasic( + results: ActorResult[], + viewer: string | null, + opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, + ): Promise<Record<string, ProfileViewBasic>> { + if (results.length === 0) return {} + const profiles = await this.profiles(results, viewer, opts) + return Object.values(profiles).reduce((acc, cur) => { + const profile = { + did: cur.did, + handle: cur.handle, + displayName: cur.displayName, + avatar: cur.avatar, + viewer: cur.viewer, + } + acc[cur.did] = profile + return acc + }, {} as Record<string, ProfileViewBasic>) + } + + async hydrateProfilesBasic( + results: ActorResult[], + viewer: string | null, + opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, + ): Promise<ProfileViewBasic[]> { + const profiles = await this.profilesBasic(results, viewer, opts) + return mapDefined(results, (result) => profiles[result.did]) + } + + async profileBasic( + result: ActorResult, + viewer: string | null, + opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, + ): Promise<ProfileViewBasic | null> { + const profiles = await this.profilesBasic([result], viewer, opts) + return profiles[result.did] ?? null } } diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index c2156d2c..10d42731 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -191,27 +191,30 @@ export class FeedService { const listViews = await this.services.graph.getListViews(listUris, viewer) return actors.reduce((acc, cur) => { const actorLabels = labels[cur.did] ?? [] + const avatar = cur.avatarCid + ? this.imgUriBuilder.getCommonSignedUri( + 'avatar', + cur.did, + cur.avatarCid, + ) + : undefined + const mutedByList = + cur.requesterMutedByList && listViews[cur.requesterMutedByList] + ? this.services.graph.formatListViewBasic( + listViews[cur.requesterMutedByList], + ) + : undefined return { ...acc, [cur.did]: { did: cur.did, handle: cur.handle ?? INVALID_HANDLE, displayName: cur.displayName ?? undefined, - avatar: cur.avatarCid - ? this.imgUriBuilder.getCommonSignedUri( - 'avatar', - cur.did, - cur.avatarCid, - ) - : undefined, + avatar, viewer: viewer ? { muted: !!cur?.requesterMuted || !!cur?.requesterMutedByList, - mutedByList: cur.requesterMutedByList - ? this.services.graph.formatListViewBasic( - listViews[cur.requesterMutedByList], - ) - : undefined, + mutedByList, blockedBy: !!cur?.requesterBlockedBy, blocking: cur?.requesterBlocking || undefined, following: cur?.requesterFollowing || undefined, diff --git a/packages/common-web/src/arrays.ts b/packages/common-web/src/arrays.ts new file mode 100644 index 00000000..51598fc8 --- /dev/null +++ b/packages/common-web/src/arrays.ts @@ -0,0 +1,13 @@ +export const mapDefined = <T, S>( + arr: T[], + fn: (obj: T) => S | undefined, +): S[] => { + const output: S[] = [] + for (const item of arr) { + const val = fn(item) + if (val !== undefined) { + output.push(val) + } + } + return output +} diff --git a/packages/common-web/src/index.ts b/packages/common-web/src/index.ts index 8be988e7..e1256774 100644 --- a/packages/common-web/src/index.ts +++ b/packages/common-web/src/index.ts @@ -1,6 +1,7 @@ export * as check from './check' export * as util from './util' +export * from './arrays' export * from './async' export * from './util' export * from './tid' diff --git a/packages/pds/src/app-view/api/app/bsky/actor/getProfile.ts b/packages/pds/src/app-view/api/app/bsky/actor/getProfile.ts index df37174f..f84ecb7e 100644 --- a/packages/pds/src/app-view/api/app/bsky/actor/getProfile.ts +++ b/packages/pds/src/app-view/api/app/bsky/actor/getProfile.ts @@ -34,10 +34,17 @@ export default function (server: Server, ctx: AppContext) { 'AccountTakedown', ) } + const profile = await actorService.views.profileDetailed( + actorRes, + requester, + ) + if (!profile) { + throw new InvalidRequestError('Profile not found') + } return { encoding: 'application/json', - body: await actorService.views.profileDetailed(actorRes, requester), + body: profile, } }, }) diff --git a/packages/pds/src/app-view/api/app/bsky/actor/getProfiles.ts b/packages/pds/src/app-view/api/app/bsky/actor/getProfiles.ts index 673868df..78719975 100644 --- a/packages/pds/src/app-view/api/app/bsky/actor/getProfiles.ts +++ b/packages/pds/src/app-view/api/app/bsky/actor/getProfiles.ts @@ -26,7 +26,7 @@ export default function (server: Server, ctx: AppContext) { return { encoding: 'application/json', body: { - profiles: await actorService.views.profileDetailed( + profiles: await actorService.views.hydrateProfilesDetailed( actorsRes, requester, ), diff --git a/packages/pds/src/app-view/api/app/bsky/actor/getSuggestions.ts b/packages/pds/src/app-view/api/app/bsky/actor/getSuggestions.ts index 540f7795..edd426fe 100644 --- a/packages/pds/src/app-view/api/app/bsky/actor/getSuggestions.ts +++ b/packages/pds/src/app-view/api/app/bsky/actor/getSuggestions.ts @@ -64,14 +64,13 @@ export default function (server: Server, ctx: AppContext) { } const suggestionsRes = await suggestionsQb.execute() - return { encoding: 'application/json', body: { cursor: suggestionsRes.at(-1)?.did, actors: await services.appView .actor(ctx.db) - .views.profile(suggestionsRes, requester), + .views.hydrateProfiles(suggestionsRes, requester), }, } }, diff --git a/packages/pds/src/app-view/api/app/bsky/actor/searchActors.ts b/packages/pds/src/app-view/api/app/bsky/actor/searchActors.ts index 380fea56..20516204 100644 --- a/packages/pds/src/app-view/api/app/bsky/actor/searchActors.ts +++ b/packages/pds/src/app-view/api/app/bsky/actor/searchActors.ts @@ -52,7 +52,7 @@ export default function (server: Server, ctx: AppContext) { const actors = await services.appView .actor(db) - .views.profile(results, requester) + .views.hydrateProfiles(results, requester) const filtered = actors.filter( (actor) => !actor.viewer?.blocking && !actor.viewer?.blockedBy, diff --git a/packages/pds/src/app-view/api/app/bsky/actor/searchActorsTypeahead.ts b/packages/pds/src/app-view/api/app/bsky/actor/searchActorsTypeahead.ts index 2cf879af..9a133c59 100644 --- a/packages/pds/src/app-view/api/app/bsky/actor/searchActorsTypeahead.ts +++ b/packages/pds/src/app-view/api/app/bsky/actor/searchActorsTypeahead.ts @@ -48,7 +48,7 @@ export default function (server: Server, ctx: AppContext) { const actors = await services.appView .actor(db) - .views.profileBasic(results, requester) + .views.hydrateProfilesBasic(results, requester) const filtered = actors.filter( (actor) => !actor.viewer?.blocking && !actor.viewer?.blockedBy, diff --git a/packages/pds/src/app-view/api/app/bsky/feed/getActorFeeds.ts b/packages/pds/src/app-view/api/app/bsky/feed/getActorFeeds.ts index 6cc8becb..4ddbe491 100644 --- a/packages/pds/src/app-view/api/app/bsky/feed/getActorFeeds.ts +++ b/packages/pds/src/app-view/api/app/bsky/feed/getActorFeeds.ts @@ -48,6 +48,10 @@ export default function (server: Server, ctx: AppContext) { feedsQb.execute(), actorService.views.profile(creatorRes, requester), ]) + if (!creatorProfile) { + throw new InvalidRequestError(`Actor not found: ${actor}`) + } + const profiles = { [creatorProfile.did]: creatorProfile } const feeds = feedsRes.map((row) => diff --git a/packages/pds/src/app-view/api/app/bsky/feed/getLikes.ts b/packages/pds/src/app-view/api/app/bsky/feed/getLikes.ts index 262dd678..63d82d4d 100644 --- a/packages/pds/src/app-view/api/app/bsky/feed/getLikes.ts +++ b/packages/pds/src/app-view/api/app/bsky/feed/getLikes.ts @@ -1,3 +1,4 @@ +import { mapDefined } from '@atproto/common' import { Server } from '../../../../../lexicon' import { paginate, TimeCidKeyset } from '../../../../../db/pagination' import AppContext from '../../../../../context' @@ -57,7 +58,17 @@ export default function (server: Server, ctx: AppContext) { const likesRes = await builder.execute() const actors = await services.appView .actor(db) - .views.profile(likesRes, requester) + .views.profiles(likesRes, requester) + + const likes = mapDefined(likesRes, (row) => + actors[row.did] + ? { + createdAt: row.createdAt, + indexedAt: row.indexedAt, + actor: actors[row.did], + } + : undefined, + ) return { encoding: 'application/json', @@ -65,11 +76,7 @@ export default function (server: Server, ctx: AppContext) { uri, cid, cursor: keyset.packFromResult(likesRes), - likes: likesRes.map((row, i) => ({ - createdAt: row.createdAt, - indexedAt: row.indexedAt, - actor: actors[i], - })), + likes, }, } }, diff --git a/packages/pds/src/app-view/api/app/bsky/feed/getRepostedBy.ts b/packages/pds/src/app-view/api/app/bsky/feed/getRepostedBy.ts index 4632883d..fb243809 100644 --- a/packages/pds/src/app-view/api/app/bsky/feed/getRepostedBy.ts +++ b/packages/pds/src/app-view/api/app/bsky/feed/getRepostedBy.ts @@ -58,7 +58,7 @@ export default function (server: Server, ctx: AppContext) { const repostedByRes = await builder.execute() const repostedBy = await services.appView .actor(db) - .views.profile(repostedByRes, requester) + .views.hydrateProfiles(repostedByRes, requester) return { encoding: 'application/json', diff --git a/packages/pds/src/app-view/api/app/bsky/graph/getBlocks.ts b/packages/pds/src/app-view/api/app/bsky/graph/getBlocks.ts index e76eb296..b3eaef40 100644 --- a/packages/pds/src/app-view/api/app/bsky/graph/getBlocks.ts +++ b/packages/pds/src/app-view/api/app/bsky/graph/getBlocks.ts @@ -56,7 +56,10 @@ export default function (server: Server, ctx: AppContext) { const blocksRes = await blocksReq.execute() const actorService = services.appView.actor(db) - const blocks = await actorService.views.profile(blocksRes, requester) + const blocks = await actorService.views.hydrateProfiles( + blocksRes, + requester, + ) return { encoding: 'application/json', diff --git a/packages/pds/src/app-view/api/app/bsky/graph/getFollowers.ts b/packages/pds/src/app-view/api/app/bsky/graph/getFollowers.ts index 93bf7307..d74a4c26 100644 --- a/packages/pds/src/app-view/api/app/bsky/graph/getFollowers.ts +++ b/packages/pds/src/app-view/api/app/bsky/graph/getFollowers.ts @@ -60,9 +60,12 @@ export default function (server: Server, ctx: AppContext) { const followersRes = await followersReq.execute() const [followers, subject] = await Promise.all([ - actorService.views.profile(followersRes, requester), + actorService.views.hydrateProfiles(followersRes, requester), actorService.views.profile(subjectRes, requester), ]) + if (!subject) { + throw new InvalidRequestError(`Actor not found: ${actor}`) + } return { encoding: 'application/json', diff --git a/packages/pds/src/app-view/api/app/bsky/graph/getFollows.ts b/packages/pds/src/app-view/api/app/bsky/graph/getFollows.ts index 75edf58c..703b8355 100644 --- a/packages/pds/src/app-view/api/app/bsky/graph/getFollows.ts +++ b/packages/pds/src/app-view/api/app/bsky/graph/getFollows.ts @@ -60,9 +60,12 @@ export default function (server: Server, ctx: AppContext) { const followsRes = await followsReq.execute() const [follows, subject] = await Promise.all([ - actorService.views.profile(followsRes, requester), + actorService.views.hydrateProfiles(followsRes, requester), actorService.views.profile(creatorRes, requester), ]) + if (!subject) { + throw new InvalidRequestError(`Actor not found: ${actor}`) + } return { encoding: 'application/json', diff --git a/packages/pds/src/app-view/api/app/bsky/graph/getList.ts b/packages/pds/src/app-view/api/app/bsky/graph/getList.ts index c711ea93..00eeaba8 100644 --- a/packages/pds/src/app-view/api/app/bsky/graph/getList.ts +++ b/packages/pds/src/app-view/api/app/bsky/graph/getList.ts @@ -2,7 +2,6 @@ import { InvalidRequestError } from '@atproto/xrpc-server' import { Server } from '../../../../../lexicon' import { paginate, TimeCidKeyset } from '../../../../../db/pagination' import AppContext from '../../../../../context' -import { ProfileView } from '../../../../../lexicon/types/app/bsky/actor/defs' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getList({ @@ -51,20 +50,17 @@ export default function (server: Server, ctx: AppContext) { const itemsRes = await itemsReq.execute() const actorService = services.appView.actor(db) - const profiles = await actorService.views.profile(itemsRes, requester) - const profilesMap = profiles.reduce( - (acc, cur) => ({ - ...acc, - [cur.did]: cur, - }), - {} as Record<string, ProfileView>, + const profiles = await actorService.views.hydrateProfiles( + itemsRes, + requester, ) - const items = itemsRes.map((item) => ({ - subject: profilesMap[item.did], - })) + const items = profiles.map((subject) => ({ subject })) const creator = await actorService.views.profile(listRes, requester) + if (!creator) { + throw new InvalidRequestError(`Actor not found: ${listRes.handle}`) + } const subject = { uri: listRes.uri, diff --git a/packages/pds/src/app-view/api/app/bsky/graph/getListMutes.ts b/packages/pds/src/app-view/api/app/bsky/graph/getListMutes.ts index 89501548..21a64437 100644 --- a/packages/pds/src/app-view/api/app/bsky/graph/getListMutes.ts +++ b/packages/pds/src/app-view/api/app/bsky/graph/getListMutes.ts @@ -1,7 +1,6 @@ import { Server } from '../../../../../lexicon' import { paginate, TimeCidKeyset } from '../../../../../db/pagination' import AppContext from '../../../../../context' -import { ProfileView } from '../../../../../lexicon/types/app/bsky/actor/defs' export default function (server: Server, ctx: AppContext) { server.app.bsky.graph.getListMutes({ @@ -44,17 +43,10 @@ export default function (server: Server, ctx: AppContext) { const listsRes = await listsReq.execute() const actorService = ctx.services.appView.actor(ctx.db) - const profiles = await actorService.views.profile(listsRes, requester) - const profilesMap = profiles.reduce( - (acc, cur) => ({ - ...acc, - [cur.did]: cur, - }), - {} as Record<string, ProfileView>, - ) + const profiles = await actorService.views.profiles(listsRes, requester) const lists = listsRes.map((row) => - graphService.formatListView(row, profilesMap), + graphService.formatListView(row, profiles), ) return { diff --git a/packages/pds/src/app-view/api/app/bsky/graph/getLists.ts b/packages/pds/src/app-view/api/app/bsky/graph/getLists.ts index 7dc77e6c..58b83a20 100644 --- a/packages/pds/src/app-view/api/app/bsky/graph/getLists.ts +++ b/packages/pds/src/app-view/api/app/bsky/graph/getLists.ts @@ -46,6 +46,9 @@ export default function (server: Server, ctx: AppContext) { listsReq.execute(), actorService.views.profile(creatorRes, requester), ]) + if (!creator) { + throw new InvalidRequestError(`Actor not found: ${actor}`) + } const profileMap = { [creator.did]: creator, } diff --git a/packages/pds/src/app-view/api/app/bsky/graph/getMutes.ts b/packages/pds/src/app-view/api/app/bsky/graph/getMutes.ts index 51950515..c26b7b1d 100644 --- a/packages/pds/src/app-view/api/app/bsky/graph/getMutes.ts +++ b/packages/pds/src/app-view/api/app/bsky/graph/getMutes.ts @@ -51,7 +51,7 @@ export default function (server: Server, ctx: AppContext) { encoding: 'application/json', body: { cursor: keyset.packFromResult(mutesRes), - mutes: await actorService.views.profile(mutesRes, requester), + mutes: await actorService.views.hydrateProfiles(mutesRes, requester), }, } }, diff --git a/packages/pds/src/app-view/api/app/bsky/notification/listNotifications.ts b/packages/pds/src/app-view/api/app/bsky/notification/listNotifications.ts index 2967c069..e97f5499 100644 --- a/packages/pds/src/app-view/api/app/bsky/notification/listNotifications.ts +++ b/packages/pds/src/app-view/api/app/bsky/notification/listNotifications.ts @@ -112,7 +112,7 @@ export default function (server: Server, ctx: AppContext) { const recordUris = notifs.map((notif) => notif.uri) const [blocks, authors, labels] = await Promise.all([ blocksQb ? blocksQb.execute() : emptyBlocksResult, - actorService.views.profile( + actorService.views.profiles( notifs.map((notif) => ({ did: notif.authorDid, handle: notif.authorHandle, @@ -127,13 +127,14 @@ export default function (server: Server, ctx: AppContext) { return acc }, {} as Record<string, Uint8Array>) - const notifications = notifs.flatMap((notif, i) => { + const notifications = common.mapDefined(notifs, (notif) => { const bytes = bytesByCid[notif.cid] - if (!bytes) return [] // Filter out + const author = authors[notif.authorDid] + if (!bytes || !author) return undefined return { uri: notif.uri, cid: notif.cid, - author: authors[i], + author: authors[notif.authorDid], reason: notif.reason, reasonSubject: notif.reasonSubject || undefined, record: common.cborBytesToRecord(bytes), diff --git a/packages/pds/src/app-view/services/actor/views.ts b/packages/pds/src/app-view/services/actor/views.ts index a00915b8..6f0e6a8d 100644 --- a/packages/pds/src/app-view/services/actor/views.ts +++ b/packages/pds/src/app-view/services/actor/views.ts @@ -1,4 +1,4 @@ -import { ArrayEl } from '@atproto/common' +import { mapDefined } from '@atproto/common' import { ProfileViewDetailed, ProfileView, @@ -24,23 +24,12 @@ export class ActorViews { graph: GraphService.creator(this.imgUriBuilder)(this.db), } - profileDetailed( - result: ActorResult, + async profilesDetailed( + results: ActorResult[], viewer: string, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileViewDetailed> - profileDetailed( - result: ActorResult[], - viewer: string, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileViewDetailed[]> - async profileDetailed( - result: ActorResult | ActorResult[], - viewer: string, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileViewDetailed | ProfileViewDetailed[]> { - const results = Array.isArray(result) ? result : [result] - if (results.length === 0) return [] + ): Promise<Record<string, ProfileViewDetailed>> { + if (results.length === 0) return {} const { ref } = this.db.db.dynamic const { skipLabels = false, includeSoftDeleted = false } = opts ?? {} @@ -58,6 +47,7 @@ export class ActorViews { ) .select([ 'did_handle.did as did', + 'did_handle.handle as handle', 'profile.uri as profileUri', 'profile.displayName as displayName', 'profile.description as description', @@ -112,72 +102,75 @@ export class ActorViews { this.services.label.getLabelsForSubjects(skipLabels ? [] : dids), ]) - const profileInfoByDid = profileInfos.reduce((acc, info) => { - return Object.assign(acc, { [info.did]: info }) - }, {} as Record<string, ArrayEl<typeof profileInfos>>) - const listUris: string[] = profileInfos .map((a) => a.requesterMutedByList) .filter((list) => !!list) const listViews = await this.services.graph.getListViews(listUris, viewer) - const views = results.map((result) => { - const profileInfo = profileInfoByDid[result.did] - const avatar = profileInfo?.avatarCid - ? this.imgUriBuilder.getCommonSignedUri('avatar', profileInfo.avatarCid) + return profileInfos.reduce((acc, cur) => { + const actorLabels = labels[cur.did] ?? [] + const avatar = cur?.avatarCid + ? this.imgUriBuilder.getCommonSignedUri('avatar', cur.avatarCid) : undefined - const banner = profileInfo?.bannerCid - ? this.imgUriBuilder.getCommonSignedUri('banner', profileInfo.bannerCid) + const banner = cur?.bannerCid + ? this.imgUriBuilder.getCommonSignedUri('banner', cur.bannerCid) : undefined - return { - did: result.did, - handle: result.handle, - displayName: truncateUtf8(profileInfo?.displayName, 64) || undefined, - description: truncateUtf8(profileInfo?.description, 256) || undefined, + const mutedByList = + cur.requesterMutedByList && listViews[cur.requesterMutedByList] + ? this.services.graph.formatListViewBasic( + listViews[cur.requesterMutedByList], + ) + : undefined + const profile = { + did: cur.did, + handle: cur.handle, + displayName: truncateUtf8(cur?.displayName, 64) || undefined, + description: truncateUtf8(cur?.description, 256) || undefined, avatar, banner, - followsCount: profileInfo?.followsCount || 0, - followersCount: profileInfo?.followersCount || 0, - postsCount: profileInfo?.postsCount || 0, - indexedAt: profileInfo?.indexedAt || undefined, + followsCount: cur?.followsCount || 0, + followersCount: cur?.followersCount || 0, + postsCount: cur?.postsCount || 0, + indexedAt: cur?.indexedAt || undefined, viewer: { - muted: - !!profileInfo?.requesterMuted || - !!profileInfo?.requesterMutedByList, - mutedByList: profileInfo.requesterMutedByList - ? this.services.graph.formatListViewBasic( - listViews[profileInfo.requesterMutedByList], - ) - : undefined, - blockedBy: !!profileInfo.requesterBlockedBy, - blocking: profileInfo.requesterBlocking || undefined, - following: profileInfo?.requesterFollowing || undefined, - followedBy: profileInfo?.requesterFollowedBy || undefined, + muted: !!cur?.requesterMuted || !!cur?.requesterMutedByList, + mutedByList, + blockedBy: !!cur.requesterBlockedBy, + blocking: cur.requesterBlocking || undefined, + following: cur?.requesterFollowing || undefined, + followedBy: cur?.requesterFollowedBy || undefined, }, - labels: labels[result.did] ?? [], + labels: skipLabels ? undefined : actorLabels, } - }) - - return Array.isArray(result) ? views : views[0] + acc[cur.did] = profile + return acc + }, {} as Record<string, ProfileViewDetailed>) } - profile( + async hydrateProfilesDetailed( + results: ActorResult[], + viewer: string, + opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, + ): Promise<ProfileViewDetailed[]> { + const profiles = await this.profilesDetailed(results, viewer, opts) + return mapDefined(results, (result) => profiles[result.did]) + } + + async profileDetailed( result: ActorResult, viewer: string, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileView> - profile( - result: ActorResult[], + ): Promise<ProfileViewDetailed | null> { + const profiles = await this.profilesDetailed([result], viewer, opts) + return profiles[result.did] ?? null + } + + async profiles( + results: ActorResult[], viewer: string, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileView[]> - async profile( - result: ActorResult | ActorResult[], - viewer: string, - opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileView | ProfileView[]> { - const results = Array.isArray(result) ? result : [result] - if (results.length === 0) return [] + ): Promise<Record<string, ProfileView>> { + if (results.length === 0) return {} const { ref } = this.db.db.dynamic const { skipLabels = false, includeSoftDeleted = false } = opts ?? {} @@ -193,6 +186,7 @@ export class ActorViews { ) .select([ 'did_handle.did as did', + 'did_handle.handle as handle', 'profile.uri as profileUri', 'profile.displayName as displayName', 'profile.description as description', @@ -243,78 +237,100 @@ export class ActorViews { this.services.label.getLabelsForSubjects(skipLabels ? [] : dids), ]) - const profileInfoByDid = profileInfos.reduce((acc, info) => { - return Object.assign(acc, { [info.did]: info }) - }, {} as Record<string, ArrayEl<typeof profileInfos>>) - const listUris: string[] = profileInfos .map((a) => a.requesterMutedByList) .filter((list) => !!list) const listViews = await this.services.graph.getListViews(listUris, viewer) - const views = results.map((result) => { - const profileInfo = profileInfoByDid[result.did] - const avatar = profileInfo?.avatarCid - ? this.imgUriBuilder.getCommonSignedUri('avatar', profileInfo.avatarCid) + return profileInfos.reduce((acc, cur) => { + const actorLabels = labels[cur.did] ?? [] + const avatar = cur.avatarCid + ? this.imgUriBuilder.getCommonSignedUri('avatar', cur.avatarCid) : undefined - return { - did: result.did, - handle: result.handle, - displayName: truncateUtf8(profileInfo?.displayName, 64) || undefined, - description: truncateUtf8(profileInfo?.description, 256) || undefined, + const mutedByList = + cur.requesterMutedByList && listViews[cur.requesterMutedByList] + ? this.services.graph.formatListViewBasic( + listViews[cur.requesterMutedByList], + ) + : undefined + const profile = { + did: cur.did, + handle: cur.handle, + displayName: truncateUtf8(cur?.displayName, 64) || undefined, + description: truncateUtf8(cur?.description, 256) || undefined, avatar, - indexedAt: profileInfo?.indexedAt || undefined, + indexedAt: cur?.indexedAt || undefined, viewer: { - muted: - !!profileInfo?.requesterMuted || - !!profileInfo?.requesterMutedByList, - mutedByList: profileInfo.requesterMutedByList - ? this.services.graph.formatListViewBasic( - listViews[profileInfo.requesterMutedByList], - ) - : undefined, - blockedBy: !!profileInfo.requesterBlockedBy, - blocking: profileInfo.requesterBlocking || undefined, - following: profileInfo?.requesterFollowing || undefined, - followedBy: profileInfo?.requesterFollowedBy || undefined, + muted: !!cur?.requesterMuted || !!cur?.requesterMutedByList, + mutedByList, + blockedBy: !!cur.requesterBlockedBy, + blocking: cur.requesterBlocking || undefined, + following: cur?.requesterFollowing || undefined, + followedBy: cur?.requesterFollowedBy || undefined, }, - labels: labels[result.did] ?? [], + labels: skipLabels ? undefined : actorLabels, } - }) - - return Array.isArray(result) ? views : views[0] + acc[cur.did] = profile + return acc + }, {} as Record<string, ProfileView>) } - // @NOTE keep in sync with feedService.getActorViews() - profileBasic( + async hydrateProfiles( + results: ActorResult[], + viewer: string, + opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, + ): Promise<ProfileView[]> { + const profiles = await this.profiles(results, viewer, opts) + return mapDefined(results, (result) => profiles[result.did]) + } + + async profile( result: ActorResult, viewer: string, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileViewBasic> - profileBasic( - result: ActorResult[], + ): Promise<ProfileView | null> { + const profiles = await this.profiles([result], viewer, opts) + return profiles[result.did] ?? null + } + + // @NOTE keep in sync with feedService.getActorViews() + async profilesBasic( + results: ActorResult[], viewer: string, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileViewBasic[]> + ): Promise<Record<string, ProfileViewBasic>> { + if (results.length === 0) return {} + const profiles = await this.profiles(results, viewer, opts) + return Object.values(profiles).reduce((acc, cur) => { + const profile = { + did: cur.did, + handle: cur.handle, + displayName: truncateUtf8(cur.displayName, 64) || undefined, + avatar: cur.avatar, + viewer: cur.viewer, + labels: cur.labels, + } + acc[cur.did] = profile + return acc + }, {} as Record<string, ProfileViewBasic>) + } + + async hydrateProfilesBasic( + results: ActorResult[], + viewer: string, + opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, + ): Promise<ProfileViewBasic[]> { + const profiles = await this.profilesBasic(results, viewer, opts) + return mapDefined(results, (result) => profiles[result.did]) + } + async profileBasic( - result: ActorResult | ActorResult[], + result: ActorResult, viewer: string, opts?: { skipLabels?: boolean; includeSoftDeleted?: boolean }, - ): Promise<ProfileViewBasic | ProfileViewBasic[]> { - const results = Array.isArray(result) ? result : [result] - if (results.length === 0) return [] - - const profiles = await this.profile(results, viewer, opts) - const views = profiles.map((view) => ({ - did: view.did, - handle: view.handle, - displayName: truncateUtf8(view.displayName, 64) || undefined, - avatar: view.avatar, - viewer: view.viewer, - labels: view.labels, - })) - - return Array.isArray(result) ? views : views[0] + ): Promise<ProfileViewBasic | null> { + const profiles = await this.profilesBasic([result], viewer, opts) + return profiles[result.did] ?? null } } diff --git a/packages/pds/src/app-view/services/feed/index.ts b/packages/pds/src/app-view/services/feed/index.ts index 3f1fc54a..d26f43d3 100644 --- a/packages/pds/src/app-view/services/feed/index.ts +++ b/packages/pds/src/app-view/services/feed/index.ts @@ -192,22 +192,25 @@ export class FeedService { ) return actors.reduce((acc, cur) => { const actorLabels = labels[cur.did] ?? [] + const avatar = cur.avatarCid + ? this.imgUriBuilder.getCommonSignedUri('avatar', cur.avatarCid) + : undefined + const mutedByList = + cur.requesterMutedByList && listViews[cur.requesterMutedByList] + ? this.services.graph.formatListViewBasic( + listViews[cur.requesterMutedByList], + ) + : undefined return { ...acc, [cur.did]: { did: cur.did, handle: cur.handle, displayName: truncateUtf8(cur.displayName, 64) || undefined, - avatar: cur.avatarCid - ? this.imgUriBuilder.getCommonSignedUri('avatar', cur.avatarCid) - : undefined, + avatar, viewer: { muted: !!cur?.requesterMuted || !!cur?.requesterMutedByList, - mutedByList: cur.requesterMutedByList - ? this.services.graph.formatListViewBasic( - listViews[cur.requesterMutedByList], - ) - : undefined, + mutedByList, blockedBy: !!cur?.requesterBlockedBy, blocking: cur?.requesterBlocking || undefined, following: cur?.requesterFollowing || undefined,