diff --git a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts index ad2de12dee0..259452a36ac 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts @@ -10,6 +10,7 @@ import { Views } from '../../../../views' import { DataPlaneClient } from '../../../../data-plane' import { parseString } from '../../../../hydration/util' import { creatorFromUri } from '../../../../views/util' +import { FeedItem } from '../../../../hydration/feed' export default function (server: Server, ctx: AppContext) { const getActorLikes = createPipeline( @@ -56,10 +57,10 @@ const skeleton = async (inputs: { cursor, }) - const postUris = likesRes.likes.map((l) => l.subject) + const items = likesRes.likes.map((l) => ({ post: { uri: l.subject } })) return { - postUris, + items, cursor: parseString(likesRes.cursor), } } @@ -70,7 +71,7 @@ const hydration = async (inputs: { skeleton: Skeleton }) => { const { ctx, params, skeleton } = inputs - return await ctx.hydrator.hydrateFeedPosts(skeleton.postUris, params.viewer) + return await ctx.hydrator.hydrateFeedItems(skeleton.items, params.viewer) } const noPostBlocks = (inputs: { @@ -79,8 +80,8 @@ const noPostBlocks = (inputs: { hydration: HydrationState }) => { const { ctx, skeleton, hydration } = inputs - skeleton.postUris = skeleton.postUris.filter((uri) => { - const creator = creatorFromUri(uri) + skeleton.items = skeleton.items.filter((item) => { + const creator = creatorFromUri(item.post.uri) return !ctx.views.viewerBlockExists(creator, hydration) }) return skeleton @@ -92,8 +93,8 @@ const presentation = (inputs: { hydration: HydrationState }) => { const { ctx, skeleton, hydration } = inputs - const feed = mapDefined(skeleton.postUris, (uri) => - ctx.views.feedViewPost(uri, hydration), + const feed = mapDefined(skeleton.items, (item) => + ctx.views.feedViewPost(item, hydration), ) return { feed, @@ -110,6 +111,6 @@ type Context = { type Params = QueryParams & { viewer: string | null } type Skeleton = { - postUris: string[] + items: FeedItem[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index fc871351cbd..c8cd851f4d9 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -14,6 +14,7 @@ import { Views } from '../../../../views' import { DataPlaneClient } from '../../../../data-plane' import { parseString } from '../../../../hydration/util' import { Actor } from '../../../../hydration/actor' +import { FeedItem } from '../../../../hydration/feed' export default function (server: Server, ctx: AppContext) { const getAuthorFeed = createPipeline( @@ -66,7 +67,12 @@ export const skeleton = async (inputs: { }) return { actor, - uris: res.items.map((item) => item.repost || item.uri), + items: res.items.map((item) => ({ + post: { uri: item.uri, cid: item.cid || undefined }, + repost: item.repost + ? { uri: item.repost, cid: item.repostCid || undefined } + : undefined, + })), cursor: parseString(res.cursor), } } @@ -78,7 +84,7 @@ const hydration = async (inputs: { }): Promise => { const { ctx, params, skeleton } = inputs const [feedPostState, profileViewerState = {}] = await Promise.all([ - ctx.hydrator.hydrateFeedPosts(skeleton.uris, params.viewer), + ctx.hydrator.hydrateFeedItems(skeleton.items, params.viewer), params.viewer ? ctx.hydrator.actor.getProfileViewerStates( [skeleton.actor.did], @@ -108,8 +114,8 @@ const noBlocksOrMutedReposts = (inputs: { 'BlockedByActor', ) } - skeleton.uris = skeleton.uris.filter((uri) => { - const bam = ctx.views.feedItemBlocksAndMutes(uri, hydration) + skeleton.items = skeleton.items.filter((item) => { + const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) return ( !bam.authorBlocked && !bam.originatorBlocked && @@ -125,8 +131,8 @@ const presentation = (inputs: { hydration: HydrationState }) => { const { ctx, skeleton, hydration } = inputs - const feed = mapDefined(skeleton.uris, (uri) => - ctx.views.feedViewPost(uri, hydration), + const feed = mapDefined(skeleton.items, (item) => + ctx.views.feedViewPost(item, hydration), ) return { feed, cursor: skeleton.cursor } } @@ -141,6 +147,6 @@ type Params = QueryParams & { viewer: string | null } type Skeleton = { actor: Actor - uris: string[] + items: FeedItem[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index 569dc99636a..d7ff3ec801b 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -1,3 +1,4 @@ +import { mapDefined } from '@atproto/common' import { InvalidRequestError, UpstreamFailureError, @@ -15,7 +16,7 @@ import { QueryParams as GetFeedParams } from '../../../../lexicon/types/app/bsky import { OutputSchema as SkeletonOutput } from '../../../../lexicon/types/app/bsky/feed/getFeedSkeleton' import { Server } from '../../../../lexicon' import AppContext from '../../../../context' -import { AlgoResponse, AlgoResponseItem } from '../../../feed-gen/types' +import { AlgoResponse, toFeedItem } from '../../../feed-gen/types' import { HydrationFnInput, PresentationFnInput, @@ -23,7 +24,7 @@ import { SkeletonFnInput, createPipeline, } from '../../../../pipeline' -import { mapDefined } from '@atproto/common' +import { FeedItem } from '../../../../hydration/feed' export default function (server: Server, ctx: AppContext) { const getFeed = createPipeline( @@ -59,13 +60,16 @@ const skeleton = async ( const { ctx, params } = inputs const timerSkele = new ServerTimer('skele').start() const localAlgo = ctx.algos[params.feed] - const { feedItems, cursor, ...passthrough } = - localAlgo !== undefined - ? await localAlgo(ctx, params, params.viewer) - : await skeletonFromFeedGen(ctx, params) + const { + feedItems: algoItems, + cursor, + ...passthrough + } = localAlgo !== undefined + ? await localAlgo(ctx, params, params.viewer) + : await skeletonFromFeedGen(ctx, params) return { cursor, - feedItems, + items: algoItems.map(toFeedItem), timerSkele: timerSkele.stop(), timerHydr: new ServerTimer('hydr').start(), passthrough, @@ -77,9 +81,8 @@ const hydration = async ( ) => { const { ctx, params, skeleton } = inputs const timerHydr = new ServerTimer('hydr').start() - const feedItemUris = skeleton.feedItems.map((item) => item.itemUri) - const hydration = await ctx.hydrator.hydrateFeedPosts( - feedItemUris, + const hydration = await ctx.hydrator.hydrateFeedItems( + skeleton.items, params.viewer, ) skeleton.timerHydr = timerHydr.stop() @@ -88,8 +91,8 @@ const hydration = async ( const noBlocksOrMutes = (inputs: RulesFnInput) => { const { ctx, skeleton, hydration } = inputs - skeleton.feedItems = skeleton.feedItems.filter((item) => { - const bam = ctx.views.feedItemBlocksAndMutes(item.itemUri, hydration) + skeleton.items = skeleton.items.filter((item) => { + const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) return ( !bam.authorBlocked && !bam.authorMuted && @@ -104,13 +107,8 @@ const presentation = ( inputs: PresentationFnInput, ) => { const { ctx, params, skeleton, hydration } = inputs - const feed = mapDefined(skeleton.feedItems, (item) => { - const view = ctx.views.feedViewPost(item.itemUri, hydration) - if (view?.post.uri !== item.postUri) { - return undefined - } else { - return view - } + const feed = mapDefined(skeleton.items, (item) => { + return ctx.views.feedViewPost(item, hydration) }).slice(0, params.limit) return { feed, @@ -126,7 +124,7 @@ type Context = AppContext type Params = GetFeedParams & { viewer: string | null; authorization?: string } type Skeleton = { - feedItems: AlgoResponseItem[] + items: FeedItem[] passthrough: Record // pass through additional items in feedgen response cursor?: string timerSkele: ServerTimer diff --git a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts index 94f3c62e4ed..9f69b5aa375 100644 --- a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts @@ -8,6 +8,7 @@ import { Views } from '../../../../views' import { DataPlaneClient } from '../../../../data-plane' import { mapDefined } from '@atproto/common' import { parseString } from '../../../../hydration/util' +import { FeedItem } from '../../../../hydration/feed' export default function (server: Server, ctx: AppContext) { const getListFeed = createPipeline( @@ -47,7 +48,7 @@ export const skeleton = async (inputs: { cursor: params.cursor, }) return { - uris: res.uris, + items: res.uris.map((uri) => ({ post: { uri } })), cursor: parseString(res.cursor), } } @@ -58,7 +59,7 @@ const hydration = async (inputs: { skeleton: Skeleton }): Promise => { const { ctx, params, skeleton } = inputs - return ctx.hydrator.hydrateFeedPosts(skeleton.uris, params.viewer) + return ctx.hydrator.hydrateFeedItems(skeleton.items, params.viewer) } const noBlocksOrMutes = (inputs: { @@ -67,8 +68,8 @@ const noBlocksOrMutes = (inputs: { hydration: HydrationState }): Skeleton => { const { ctx, skeleton, hydration } = inputs - skeleton.uris = skeleton.uris.filter((uri) => { - const bam = ctx.views.feedItemBlocksAndMutes(uri, hydration) + skeleton.items = skeleton.items.filter((item) => { + const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) return ( !bam.authorBlocked && !bam.authorMuted && @@ -85,8 +86,8 @@ const presentation = (inputs: { hydration: HydrationState }) => { const { ctx, skeleton, hydration } = inputs - const feed = mapDefined(skeleton.uris, (uri) => - ctx.views.feedViewPost(uri, hydration), + const feed = mapDefined(skeleton.items, (item) => + ctx.views.feedViewPost(item, hydration), ) return { feed, cursor: skeleton.cursor } } @@ -100,6 +101,6 @@ type Context = { type Params = QueryParams & { viewer: string | null } type Skeleton = { - uris: string[] + items: FeedItem[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index 92e1c7c79f9..61530452b8a 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -64,7 +64,10 @@ const hydration = async ( inputs: HydrationFnInput, ) => { const { ctx, params, skeleton } = inputs - return ctx.hydrator.hydrateThreadPosts(skeleton.uris, params.viewer) + return ctx.hydrator.hydrateThreadPosts( + skeleton.uris.map((uri) => ({ uri })), + params.viewer, + ) } const presentation = ( diff --git a/packages/bsky/src/api/app/bsky/feed/getPosts.ts b/packages/bsky/src/api/app/bsky/feed/getPosts.ts index 7c11cb78f74..167621c1b33 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPosts.ts @@ -33,7 +33,10 @@ const hydration = async (inputs: { skeleton: Skeleton }) => { const { ctx, params, skeleton } = inputs - return ctx.hydrator.hydratePosts(skeleton.posts, params.viewer) + return ctx.hydrator.hydratePosts( + skeleton.posts.map((uri) => ({ uri })), + params.viewer, + ) } const noBlocks = (inputs: { diff --git a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts index d7bdfa9399f..2c58a28ab58 100644 --- a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts @@ -8,6 +8,7 @@ import { Views } from '../../../../views' import { DataPlaneClient } from '../../../../data-plane' import { parseString } from '../../../../hydration/util' import { mapDefined } from '@atproto/common' +import { FeedItem } from '../../../../hydration/feed' export default function (server: Server, ctx: AppContext) { const getTimeline = createPipeline( @@ -47,7 +48,12 @@ export const skeleton = async (inputs: { cursor: params.cursor, }) return { - uris: res.items.map((item) => item.repost || item.uri), + items: res.items.map((item) => ({ + post: { uri: item.uri, cid: item.cid || undefined }, + repost: item.repost + ? { uri: item.repost, cid: item.repostCid || undefined } + : undefined, + })), cursor: parseString(res.cursor), } } @@ -58,7 +64,7 @@ const hydration = async (inputs: { skeleton: Skeleton }): Promise => { const { ctx, params, skeleton } = inputs - return ctx.hydrator.hydrateFeedPosts(skeleton.uris, params.viewer) + return ctx.hydrator.hydrateFeedItems(skeleton.items, params.viewer) } const noBlocksOrMutes = (inputs: { @@ -67,8 +73,8 @@ const noBlocksOrMutes = (inputs: { hydration: HydrationState }): Skeleton => { const { ctx, skeleton, hydration } = inputs - skeleton.uris = skeleton.uris.filter((uri) => { - const bam = ctx.views.feedItemBlocksAndMutes(uri, hydration) + skeleton.items = skeleton.items.filter((item) => { + const bam = ctx.views.feedItemBlocksAndMutes(item, hydration) return ( !bam.authorBlocked && !bam.authorMuted && @@ -85,8 +91,8 @@ const presentation = (inputs: { hydration: HydrationState }) => { const { ctx, skeleton, hydration } = inputs - const feed = mapDefined(skeleton.uris, (uri) => - ctx.views.feedViewPost(uri, hydration), + const feed = mapDefined(skeleton.items, (item) => + ctx.views.feedViewPost(item, hydration), ) return { feed, cursor: skeleton.cursor } } @@ -100,6 +106,6 @@ type Context = { type Params = QueryParams & { viewer: string } type Skeleton = { - uris: string[] + items: FeedItem[] cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts index 18e12708e2a..879f296d007 100644 --- a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts @@ -52,7 +52,10 @@ const hydration = async ( inputs: HydrationFnInput, ) => { const { ctx, params, skeleton } = inputs - return ctx.hydrator.hydratePosts(skeleton.posts, params.viewer) + return ctx.hydrator.hydratePosts( + skeleton.posts.map((uri) => ({ uri })), + params.viewer, + ) } const noBlocks = (inputs: RulesFnInput) => { diff --git a/packages/bsky/src/api/app/bsky/unspecced/getTimelineSkeleton.ts b/packages/bsky/src/api/app/bsky/unspecced/getTimelineSkeleton.ts index d91fc9a206b..392f717e7a5 100644 --- a/packages/bsky/src/api/app/bsky/unspecced/getTimelineSkeleton.ts +++ b/packages/bsky/src/api/app/bsky/unspecced/getTimelineSkeleton.ts @@ -1,9 +1,7 @@ import { Server } from '../../../../lexicon' -import { ids } from '../../../../lexicon/lexicons' import AppContext from '../../../../context' import { skeleton } from '../feed/getTimeline' import { toSkeletonItem } from '../../../feed-gen/types' -import { urisByCollection } from '../../../../hydration/util' // THIS IS A TEMPORARY UNSPECCED ROUTE export default function (server: Server, ctx: AppContext) { @@ -12,15 +10,10 @@ export default function (server: Server, ctx: AppContext) { handler: async ({ auth, params }) => { const viewer = auth.credentials.did const result = await skeleton({ ctx, params: { ...params, viewer } }) - const collections = urisByCollection(result.uris) - const reposts = await ctx.hydrator.feed.getReposts( - collections.get(ids.AppBskyFeedRepost) ?? [], - ) - const feed = result.uris.map((uri) => { - const repost = reposts.get(uri) + const feed = result.items.map((item) => { return toSkeletonItem({ - itemUri: uri, - postUri: repost ? repost.record.subject.uri : uri, + postUri: item.post.uri, + itemUri: item.repost ? item.repost.uri : item.post.uri, }) }) return { diff --git a/packages/bsky/src/api/feed-gen/types.ts b/packages/bsky/src/api/feed-gen/types.ts index 14b12032074..6ef6af605e8 100644 --- a/packages/bsky/src/api/feed-gen/types.ts +++ b/packages/bsky/src/api/feed-gen/types.ts @@ -1,4 +1,5 @@ import AppContext from '../../context' +import { FeedItem } from '../../hydration/feed' import { SkeletonFeedPost } from '../../lexicon/types/app/bsky/feed/defs' import { QueryParams as SkeletonParams } from '../../lexicon/types/app/bsky/feed/getFeedSkeleton' @@ -33,3 +34,11 @@ export const toSkeletonItem = (feedItem: { repost: feedItem.itemUri, }, }) + +export const toFeedItem = (feedItem: AlgoResponseItem): FeedItem => ({ + post: { uri: feedItem.postUri }, + repost: + feedItem.itemUri === feedItem.postUri + ? undefined + : { uri: feedItem.itemUri }, +}) diff --git a/packages/bsky/src/hydration/actor.ts b/packages/bsky/src/hydration/actor.ts index abd9ff17cc4..669ac6abe80 100644 --- a/packages/bsky/src/hydration/actor.ts +++ b/packages/bsky/src/hydration/actor.ts @@ -50,7 +50,9 @@ export class ActorHydrator { async getDids(handleOrDids: string[]): Promise<(string | undefined)[]> { const handles = handleOrDids.filter((actor) => !actor.startsWith('did:')) - const res = await this.dataplane.getDidsByHandles({ handles }) + const res = handles.length + ? await this.dataplane.getDidsByHandles({ handles }) + : { dids: [] } const didByHandle = handles.reduce((acc, cur, i) => { const did = res.dids[i] if (did && did.length > 0) { @@ -70,6 +72,7 @@ export class ActorHydrator { } async getActors(dids: string[], includeTakedowns = false): Promise { + if (!dids.length) return new HydrationMap() const res = await this.dataplane.getActors({ dids }) return dids.reduce((acc, did, i) => { const actor = res.actors[i] @@ -95,6 +98,7 @@ export class ActorHydrator { dids: string[], viewer: string, ): Promise { + if (!dids.length) return new HydrationMap() const res = await this.dataplane.getRelationships({ actorDid: viewer, targetDids: dids, @@ -119,6 +123,7 @@ export class ActorHydrator { } async getProfileAggregates(dids: string[]): Promise { + if (!dids.length) return new HydrationMap() const counts = await this.dataplane.getCountsForUsers({ dids }) return dids.reduce((acc, did, i) => { return acc.set(did, { diff --git a/packages/bsky/src/hydration/feed.ts b/packages/bsky/src/hydration/feed.ts index 4f9827fde7a..5462b72f3e1 100644 --- a/packages/bsky/src/hydration/feed.ts +++ b/packages/bsky/src/hydration/feed.ts @@ -4,7 +4,13 @@ import { Record as LikeRecord } from '../lexicon/types/app/bsky/feed/like' import { Record as RepostRecord } from '../lexicon/types/app/bsky/feed/repost' import { Record as FeedGenRecord } from '../lexicon/types/app/bsky/feed/generator' import { Record as ThreadgateRecord } from '../lexicon/types/app/bsky/feed/threadgate' -import { HydrationMap, RecordInfo, parseRecord, parseString } from './util' +import { + HydrationMap, + RecordInfo, + parseRecord, + parseString, + split, +} from './util' import { AtUri } from '@atproto/syntax' import { ids } from '../lexicon/lexicons' @@ -50,22 +56,36 @@ export type FeedGenViewerStates = HydrationMap export type Threadgate = RecordInfo export type Threadgates = HydrationMap +export type ItemRef = { uri: string; cid?: string } +export type FeedItem = { post: ItemRef; repost?: ItemRef } + export class FeedHydrator { constructor(public dataplane: DataPlaneClient) {} - async getPosts(uris: string[], includeTakedowns = false): Promise { - const res = await this.dataplane.getPostRecords({ uris }) - return uris.reduce((acc, uri, i) => { + async getPosts( + uris: string[], + includeTakedowns = false, + given = new HydrationMap(), + ): Promise { + const [have, need] = split(uris, (uri) => given.has(uri)) + const base = have.reduce( + (acc, uri) => acc.set(uri, given.get(uri) ?? null), + new HydrationMap(), + ) + if (!need.length) return base + const res = await this.dataplane.getPostRecords({ uris: need }) + return need.reduce((acc, uri, i) => { const record = parseRecord(res.records[i], includeTakedowns) const violatesThreadGate = res.meta[i].violatesThreadGate return acc.set(uri, record ? { ...record, violatesThreadGate } : null) - }, new HydrationMap()) + }, base) } async getPostViewerStates( uris: string[], viewer: string, ): Promise { + if (!uris.length) return new HydrationMap() const [likes, reposts] = await Promise.all([ this.dataplane.getLikesByActorAndSubjects({ actorDid: viewer, @@ -84,10 +104,10 @@ export class FeedHydrator { }, new HydrationMap()) } - async getPostAggregates(uris: string[]): Promise { - const refs = uris.map((uri) => ({ uri })) + async getPostAggregates(refs: ItemRef[]): Promise { + if (!refs.length) return new HydrationMap() const counts = await this.dataplane.getInteractionCounts({ refs }) - return uris.reduce((acc, uri, i) => { + return refs.reduce((acc, { uri }, i) => { return acc.set(uri, { likes: counts.likes[i] ?? 0, reposts: counts.reposts[i] ?? 0, @@ -100,6 +120,7 @@ export class FeedHydrator { uris: string[], includeTakedowns = false, ): Promise { + if (!uris.length) return new HydrationMap() const res = await this.dataplane.getFeedGeneratorRecords({ uris }) return uris.reduce((acc, uri, i) => { const record = parseRecord( @@ -114,6 +135,7 @@ export class FeedHydrator { uris: string[], viewer: string, ): Promise { + if (!uris.length) return new HydrationMap() const likes = await this.dataplane.getLikesByActorAndSubjects({ actorDid: viewer, refs: uris.map((uri) => ({ uri })), @@ -125,10 +147,10 @@ export class FeedHydrator { }, new HydrationMap()) } - async getFeedGenAggregates(uris: string[]): Promise { - const refs = uris.map((uri) => ({ uri })) + async getFeedGenAggregates(refs: ItemRef[]): Promise { + if (!refs.length) return new HydrationMap() const likes = await this.dataplane.getLikeCounts({ refs }) - return uris.reduce((acc, uri, i) => { + return refs.reduce((acc, { uri }, i) => { return acc.set(uri, { likes: likes.counts[i] ?? 0, }) @@ -139,6 +161,7 @@ export class FeedHydrator { postUris: string[], includeTakedowns = false, ): Promise { + if (!postUris.length) return new HydrationMap() const uris = postUris.map((uri) => { const parsed = new AtUri(uri) return AtUri.make( @@ -159,6 +182,7 @@ export class FeedHydrator { // @TODO may not be supported yet by data plane async getLikes(uris: string[], includeTakedowns = false): Promise { + if (!uris.length) return new HydrationMap() const res = await this.dataplane.getLikeRecords({ uris }) return uris.reduce((acc, uri, i) => { const record = parseRecord(res.records[i], includeTakedowns) @@ -167,6 +191,7 @@ export class FeedHydrator { } async getReposts(uris: string[], includeTakedowns = false): Promise { + if (!uris.length) return new HydrationMap() const res = await this.dataplane.getRepostRecords({ uris }) return uris.reduce((acc, uri, i) => { const record = parseRecord(res.records[i], includeTakedowns) diff --git a/packages/bsky/src/hydration/graph.ts b/packages/bsky/src/hydration/graph.ts index 68de79b7632..8d83d3af961 100644 --- a/packages/bsky/src/hydration/graph.ts +++ b/packages/bsky/src/hydration/graph.ts @@ -71,6 +71,7 @@ export class GraphHydrator { constructor(public dataplane: DataPlaneClient) {} async getLists(uris: string[], includeTakedowns = false): Promise { + if (!uris.length) return new HydrationMap() const res = await this.dataplane.getListRecords({ uris }) return uris.reduce((acc, uri, i) => { const record = parseRecord(res.records[i], includeTakedowns) @@ -83,6 +84,7 @@ export class GraphHydrator { uris: string[], includeTakedowns = false, ): Promise { + if (!uris.length) return new HydrationMap() const res = await this.dataplane.getListItemRecords({ uris }) return uris.reduce((acc, uri, i) => { const record = parseRecord( @@ -97,6 +99,7 @@ export class GraphHydrator { uris: string[], viewer: string, ): Promise { + if (!uris.length) return new HydrationMap() const mutesAndBlocks = await Promise.all( uris.map((uri) => this.getMutesAndBlocks(uri, viewer)), ) @@ -131,6 +134,7 @@ export class GraphHydrator { } async getBidirectionalBlocks(pairs: RelationshipPair[]): Promise { + if (!pairs.length) return new Blocks() const deduped = dedupePairs(pairs).map(([a, b]) => ({ a, b })) const res = await this.dataplane.getBlockExistence({ pairs: deduped }) const blocks = new Blocks() @@ -142,6 +146,7 @@ export class GraphHydrator { } async getFollows(uris: string[], includeTakedowns = false): Promise { + if (!uris.length) return new HydrationMap() const res = await this.dataplane.getFollowRecords({ uris }) return uris.reduce((acc, uri, i) => { const record = parseRecord(res.records[i], includeTakedowns) @@ -150,6 +155,7 @@ export class GraphHydrator { } async getBlocks(uris: string[], includeTakedowns = false): Promise { + if (!uris.length) return new HydrationMap() const res = await this.dataplane.getBlockRecords({ uris }) return uris.reduce((acc, uri, i) => { const record = parseRecord(res.records[i], includeTakedowns) diff --git a/packages/bsky/src/hydration/hydrator.ts b/packages/bsky/src/hydration/hydrator.ts index ca1d3218630..b0d8f977a5c 100644 --- a/packages/bsky/src/hydration/hydrator.ts +++ b/packages/bsky/src/hydration/hydrator.ts @@ -36,6 +36,8 @@ import { PostAggs, PostViewerStates, Threadgates, + FeedItem, + ItemRef, } from './feed' export type HydrationState = { @@ -195,11 +197,17 @@ export class Hydrator { // - profile // - list basic async hydratePosts( - uris: string[], + refs: ItemRef[], viewer: string | null, includeTakedowns = false, + state: HydrationState = {}, ): Promise { - const postsLayer0 = await this.feed.getPosts(uris, includeTakedowns) + const uris = refs.map((ref) => ref.uri) + const postsLayer0 = await this.feed.getPosts( + uris, + includeTakedowns, + state.posts, + ) // first level embeds plus thread roots we haven't fetched yet const urisLayer1 = nestedRecordUrisFromPosts(postsLayer0) const additionalRootUris = rootUrisFromPosts(postsLayer0) // supports computing threadgates @@ -245,7 +253,7 @@ export class Hydrator { listState, feedGenState, ] = await Promise.all([ - this.feed.getPostAggregates(uris), + this.feed.getPostAggregates(refs), viewer ? this.feed.getPostViewerStates(uris, viewer) : undefined, this.label.getLabelsForSubjects(allPostUris), this.hydratePostBlocks(posts), @@ -320,14 +328,13 @@ export class Hydrator { // - list basic // - post // - ... - async hydrateFeedPosts( - uris: string[], + async hydrateFeedItems( + items: FeedItem[], viewer: string | null, includeTakedowns = false, ): Promise { - const collectionUris = urisByCollection(uris) - const postUris = collectionUris.get(ids.AppBskyFeedPost) ?? [] - const repostUris = collectionUris.get(ids.AppBskyFeedRepost) ?? [] + const postUris = items.map((item) => item.post.uri) + const repostUris = mapDefined(items, (item) => item.repost?.uri) const [posts, reposts, repostProfileState] = await Promise.all([ this.feed.getPosts(postUris, includeTakedowns), this.feed.getReposts(repostUris), @@ -337,36 +344,19 @@ export class Hydrator { includeTakedowns, ), ]) - const repostPostUris = mapDefined( - [...reposts.values()], - (repost) => repost?.record.subject.uri, - ) - const repostPosts = await this.feed.getPosts( - repostPostUris, - includeTakedowns, - ) - const repostedAndReplyUris: string[] = [] - repostPosts.forEach((post, uri) => { - repostedAndReplyUris.push(uri) - if (post?.record.reply) { - repostedAndReplyUris.push( - post.record.reply.root.uri, - post.record.reply.parent.uri, - ) - } - }) - posts.forEach((post) => { - if (post?.record.reply) { - repostedAndReplyUris.push( - post.record.reply.root.uri, - post.record.reply.parent.uri, - ) + const postAndReplyRefs: ItemRef[] = [] + posts.forEach((post, uri) => { + if (!post) return + postAndReplyRefs.push({ uri, cid: post.cid.toString() }) + if (post.record.reply) { + postAndReplyRefs.push(post.record.reply.root, post.record.reply.parent) } }) const postState = await this.hydratePosts( - [...postUris, ...repostedAndReplyUris], + postAndReplyRefs, viewer, includeTakedowns, + { posts }, ) return mergeManyStates(postState, repostProfileState, { reposts, @@ -385,10 +375,10 @@ export class Hydrator { // - profile // - list basic async hydrateThreadPosts( - uris: string[], + refs: ItemRef[], viewer: string | null, ): Promise { - return this.hydratePosts(uris, viewer) + return this.hydratePosts(refs, viewer) } // app.bsky.feed.defs#generatorView @@ -396,13 +386,13 @@ export class Hydrator { // - profile // - list basic async hydrateFeedGens( - uris: string[], + uris: string[], // @TODO any way to get refs here? viewer: string | null, ): Promise { const [feedgens, feedgenAggs, feedgenViewers, profileState] = await Promise.all([ this.feed.getFeedGens(uris), - this.feed.getFeedGenAggregates(uris), + this.feed.getFeedGenAggregates(uris.map((uri) => ({ uri }))), viewer ? this.feed.getFeedGenViewerStates(uris, viewer) : undefined, this.hydrateProfiles(uris.map(didFromUri), viewer), ]) @@ -619,3 +609,8 @@ const mergeManyStates = (...states: HydrationState[]) => { const mergeManyMaps = (...maps: HydrationMap[]) => { return maps.reduce(mergeMaps, undefined as HydrationMap | undefined) } + +const notIn = (uris: string[], map?: HydrationMap) => { + if (!map) return uris + return uris.filter((uri) => !map.has(uri)) +} diff --git a/packages/bsky/src/hydration/label.ts b/packages/bsky/src/hydration/label.ts index 5910dc77988..79346a69595 100644 --- a/packages/bsky/src/hydration/label.ts +++ b/packages/bsky/src/hydration/label.ts @@ -13,6 +13,7 @@ export class LabelHydrator { subjects: string[], issuers?: string[], ): Promise { + if (!subjects.length) return new HydrationMap() const res = await this.dataplane.getLabels({ subjects, issuers }) return res.labels.reduce((acc, cur) => { const label = parseJsonBytes(cur) as Label | undefined diff --git a/packages/bsky/src/hydration/util.ts b/packages/bsky/src/hydration/util.ts index afbb9250914..dca9e469fe5 100644 --- a/packages/bsky/src/hydration/util.ts +++ b/packages/bsky/src/hydration/util.ts @@ -76,3 +76,19 @@ export const urisByCollection = (uris: string[]): Map => { } return result } + +export const split = ( + items: T[], + predicate: (item: T) => boolean, +): [T[], T[]] => { + const yes: T[] = [] + const no: T[] = [] + for (const item of items) { + if (predicate(item)) { + yes.push(item) + } else { + no.push(item) + } + } + return [yes, no] +} diff --git a/packages/bsky/src/views/index.ts b/packages/bsky/src/views/index.ts index e8d50320e9f..b496c6815ed 100644 --- a/packages/bsky/src/views/index.ts +++ b/packages/bsky/src/views/index.ts @@ -46,7 +46,7 @@ import { isRecordWithMedia, } from './types' import { Label } from '../hydration/label' -import { Repost } from '../hydration/feed' +import { FeedItem, Repost } from '../hydration/feed' import { RecordInfo } from '../hydration/util' import { Notification } from '../data-plane/gen/bsky_pb' @@ -268,7 +268,7 @@ export class Views { // ------------ feedItemBlocksAndMutes( - uri: string, + item: FeedItem, state: HydrationState, ): { originatorMuted: boolean @@ -276,24 +276,15 @@ export class Views { authorMuted: boolean authorBlocked: boolean } { - const parsed = new AtUri(uri) - if (parsed.collection === ids.AppBskyFeedRepost) { - const repost = state.reposts?.get(uri) - const postUri = repost?.record.subject.uri - const postDid = postUri ? creatorFromUri(postUri) : undefined - return { - originatorMuted: this.viewerMuteExists(parsed.hostname, state), - originatorBlocked: this.viewerBlockExists(parsed.hostname, state), - authorMuted: !!postDid && this.viewerMuteExists(postDid, state), - authorBlocked: !!postDid && this.viewerBlockExists(postDid, state), - } - } else { - return { - originatorMuted: this.viewerMuteExists(parsed.hostname, state), - originatorBlocked: this.viewerBlockExists(parsed.hostname, state), - authorMuted: this.viewerMuteExists(parsed.hostname, state), - authorBlocked: this.viewerBlockExists(parsed.hostname, state), - } + const authorDid = creatorFromUri(item.post.uri) + const originatorDid = item.repost + ? creatorFromUri(item.repost.uri) + : authorDid + return { + originatorMuted: this.viewerMuteExists(originatorDid, state), + originatorBlocked: this.viewerBlockExists(originatorDid, state), + authorMuted: this.viewerMuteExists(authorDid, state), + authorBlocked: this.viewerBlockExists(authorDid, state), } } @@ -397,29 +388,28 @@ export class Views { } } - feedViewPost(uri: string, state: HydrationState): FeedViewPost | undefined { + feedViewPost( + item: FeedItem, + state: HydrationState, + ): FeedViewPost | undefined { // no block violating posts in feeds - if (state.postBlocks?.get(uri)?.reply) return undefined - const parsedUri = new AtUri(uri) - const postInfo = state.posts?.get(uri) - let postUri: AtUri + if (state.postBlocks?.get(item.post.uri)?.reply) return undefined + const postInfo = state.posts?.get(item.post.uri) let reason: ReasonRepost | undefined - if (parsedUri.collection === ids.AppBskyFeedRepost) { - const repost = state.reposts?.get(uri) + if (item.repost) { + const repost = state.reposts?.get(item.repost.uri) if (!repost) return - reason = this.reasonRepost(parsedUri.hostname, repost, state) + if (repost.record.subject.uri !== item.post.uri) return + reason = this.reasonRepost(creatorFromUri(item.repost.uri), repost, state) if (!reason) return - postUri = new AtUri(repost.record.subject.uri) - } else { - postUri = parsedUri } - const post = this.post(postUri.toString(), state) + const post = this.post(item.post.uri, state) if (!post) return return { post, reason, reply: !postInfo?.violatesThreadGate - ? this.replyRef(postUri.toString(), state) + ? this.replyRef(item.post.uri, state) : undefined, } }