diff --git a/data-plane/routes/stories.ts b/data-plane/routes/stories.ts index fa38186..7b06a3b 100644 --- a/data-plane/routes/stories.ts +++ b/data-plane/routes/stories.ts @@ -134,6 +134,34 @@ export class Stories { }); } + /** + * Get active stories grouped by actor DID + */ + async getActorStories( + dids: string[], + ): Promise> { + if (!dids.length) return new Map(); + + const twentyFourHoursAgo = new Date(); + twentyFourHoursAgo.setHours( + twentyFourHoursAgo.getHours() - STORIES_EXPIRY_HOURS, + ); + const minDate = twentyFourHoursAgo.toISOString(); + + const stories = await this.db.models.Story.find({ + authorDid: { $in: dids }, + indexedAt: { $gte: minDate }, + }).sort({ indexedAt: 1 }).lean(); + + const result = new Map(); + for (const story of stories) { + const existing = result.get(story.authorDid) ?? []; + existing.push({ uri: story.uri, cid: story.cid }); + result.set(story.authorDid, existing); + } + return result; + } + /** * Get blocked author DIDs for a viewer */ diff --git a/hydration/index.ts b/hydration/index.ts index bc67e2a..4bde50d 100644 --- a/hydration/index.ts +++ b/hydration/index.ts @@ -108,6 +108,7 @@ export type HydrationState = { sounds?: Sounds; soundAggs?: SoundAggs; stories?: Stories; + actorStoryRefs?: ActorStoryRefs; postBlocks?: PostBlocks; reposts?: Reposts; @@ -143,6 +144,7 @@ export type FollowBlock = boolean; export type FollowBlocks = HydrationMap; export type BidirectionalBlocks = HydrationMap>; +export type ActorStoryRefs = HydrationMap; export class Hydrator { actor: ActorHydrator; @@ -190,21 +192,48 @@ export class Hydrator { async hydrateProfiles( dids: string[], ctx: HydrateCtx, + opts: { + includeStories?: boolean; + } = {}, ): Promise { + const includeStories = opts.includeStories ?? true; const includeTakedowns = ctx.includeTakedowns || ctx.includeActorTakedowns; - const [actors, labels, profileViewersState] = await Promise.all([ - this.actor.getActors(dids, { - includeTakedowns, - }), - this.label.getLabelsForSubjects(labelSubjectsForDid(dids), ctx.labelers), - this.hydrateProfileViewers(dids, ctx), - ]); + const [actors, labels, profileViewersState, actorStories] = await Promise + .all([ + this.actor.getActors(dids, { + includeTakedowns, + }), + this.label.getLabelsForSubjects( + labelSubjectsForDid(dids), + ctx.labelers, + ), + this.hydrateProfileViewers(dids, ctx), + includeStories + ? this.story.getActorStories(dids) + : Promise.resolve(new HydrationMap()), + ]); + let actorStoryRefs: ActorStoryRefs | undefined; + let storyState: HydrationState = {}; + if (includeStories) { + actorStoryRefs = actorStories; + const storyUris = new Set(); + for (const [_did, stories] of actorStories) { + if (!stories) continue; + for (const story of stories) { + storyUris.add(story.uri); + } + } + if (storyUris.size > 0) { + storyState = await this.hydrateStories(Array.from(storyUris), ctx); + } + } if (!includeTakedowns) { actionTakedownLabels(dids, actors, labels); } - return mergeStates(profileViewersState ?? {}, { + return mergeManyStates(profileViewersState ?? {}, storyState, { actors, labels, + actorStoryRefs, ctx, }); } @@ -217,7 +246,7 @@ export class Hydrator { dids: string[], ctx: HydrateCtx, ): Promise { - return this.hydrateProfiles(dids, ctx); + return this.hydrateProfiles(dids, ctx, { includeStories: false }); } // so.sprk.actor.defs#profileViewDetailed @@ -254,13 +283,16 @@ export class Hydrator { const allKnownFollowerDids = Array.from(knownFollowers.values()) .filter(Boolean) .flatMap((f) => f!.followers); - const allDids = Array.from(new Set(dids.concat(allKnownFollowerDids))); - const [state, profileAggs, bidirectionalBlocks] = await Promise.all([ - this.hydrateProfiles(allDids, ctx), - this.actor.getProfileAggregates(dids), - this.hydrateBidirectionalBlocks(subjectsToKnownFollowersMap), - ]); - return mergeManyStates(state, { + const [state, knownFollowerState, profileAggs, bidirectionalBlocks] = + await Promise.all([ + this.hydrateProfiles(dids, ctx), + allKnownFollowerDids.length > 0 + ? this.hydrateProfilesBasic(allKnownFollowerDids, ctx) + : Promise.resolve({}), + this.actor.getProfileAggregates(dids), + this.hydrateBidirectionalBlocks(subjectsToKnownFollowersMap), + ]); + return mergeManyStates(state, knownFollowerState, { profileAggs, knownFollowers, ctx, @@ -419,7 +451,7 @@ export class Hydrator { : Promise.resolve(undefined), this.label.getLabelsForSubjects(allUris, ctx.labelers), this.hydratePostBlocks(state.posts!, state.replies!), - this.hydrateProfiles(allProfileDids, ctx), + this.hydrateProfilesBasic(allProfileDids, ctx), this.feed.getThreadContexts(threadRefs), this.hydrateSounds(Array.from(soundUris), ctx), this.hydrateBidirectionalBlocks(subjectsToInteractorsMap), @@ -645,7 +677,7 @@ export class Hydrator { postUris.length > 0 ? this.hydratePosts(postUris.map((uri) => ({ uri })), ctx) : Promise.resolve({}), - this.hydrateProfiles(profileDids, ctx), + this.hydrateProfilesBasic(profileDids, ctx), ]); return mergeManyStates(profileState, postState, { stories, ctx }); @@ -692,7 +724,7 @@ export class Hydrator { ): Promise { const [likes, profileState] = await Promise.all([ this.feed.getLikes(uris, ctx.includeTakedowns), - this.hydrateProfiles(uris.map(didFromUri), ctx), + this.hydrateProfilesBasic(uris.map(didFromUri), ctx), ]); const pairs: RelationshipPair[] = []; @@ -723,7 +755,7 @@ export class Hydrator { async hydrateReposts(uris: string[], ctx: HydrateCtx) { const [reposts, profileState] = await Promise.all([ this.feed.getReposts(uris, ctx.includeTakedowns), - this.hydrateProfiles(uris.map(didFromUri), ctx), + this.hydrateProfilesBasic(uris.map(didFromUri), ctx), ]); return mergeStates(profileState, { reposts, ctx }); } @@ -780,7 +812,7 @@ export class Hydrator { this.feed.getReposts(repostUris), // reason: repost this.graph.getFollows(followUris), // reason: follow this.label.getLabelsForSubjects(uris, ctx.labelers), - this.hydrateProfiles(uris.map(didFromUri), ctx), + this.hydrateProfilesBasic(uris.map(didFromUri), ctx), this.feed.getPosts(subjectPostUris), // subjects of likes/reposts this.feed.getReplies(subjectReplyUris), // subjects of likes/reposts ]); @@ -823,7 +855,7 @@ export class Hydrator { const [sounds, soundAggs, profileState] = await Promise.all([ this.feed.getSounds(uris, ctx.includeTakedowns), this.feed.getSoundAggregates(uris.map((uri) => ({ uri }))), - this.hydrateProfiles(uris.map(didFromUri), ctx), + this.hydrateProfilesBasic(uris.map(didFromUri), ctx), ]); return mergeStates(profileState, { sounds, soundAggs, ctx }); } @@ -1113,6 +1145,7 @@ export const mergeStates = ( sounds: mergeMaps(stateA.sounds, stateB.sounds), soundAggs: mergeMaps(stateA.soundAggs, stateB.soundAggs), stories: mergeMaps(stateA.stories, stateB.stories), + actorStoryRefs: mergeMaps(stateA.actorStoryRefs, stateB.actorStoryRefs), postBlocks: mergeMaps(stateA.postBlocks, stateB.postBlocks), reposts: mergeMaps(stateA.reposts, stateB.reposts), follows: mergeMaps(stateA.follows, stateB.follows), diff --git a/hydration/story.ts b/hydration/story.ts index 8c151bf..4bc2499 100644 --- a/hydration/story.ts +++ b/hydration/story.ts @@ -1,5 +1,11 @@ import { Record as StoryRecord } from "../lex/types/so/sprk/story/post.ts"; -import { HydrationMap, parseRecord, RecordInfo, split } from "./util.ts"; +import { + HydrationMap, + ItemRef, + parseRecord, + RecordInfo, + split, +} from "./util.ts"; import { DataPlane } from "../data-plane/index.ts"; export type Story = RecordInfo; @@ -8,6 +14,17 @@ export type Stories = HydrationMap; export class StoryHydrator { constructor(public dataplane: DataPlane) {} + async getActorStories( + dids: string[], + ): Promise> { + const refsByActor = await this.dataplane.stories.getActorStories(dids); + const result = new HydrationMap(); + for (const [did, refs] of refsByActor) { + result.set(did, refs); + } + return result; + } + async getStories( uris: string[], includeTakedowns = false, diff --git a/lex/lexicons.ts b/lex/lexicons.ts index 6ecf01f..ebc845d 100644 --- a/lex/lexicons.ts +++ b/lex/lexicons.ts @@ -20117,6 +20117,14 @@ export const schemaDict = { "ref": "lex:com.atproto.label.defs#label", }, }, + "stories": { + "type": "array", + "description": "Recent stories from this profile author.", + "items": { + "type": "ref", + "ref": "lex:so.sprk.story.defs#storyView", + }, + }, }, }, "profileViewDetailed": { @@ -20193,7 +20201,7 @@ export const schemaDict = { "description": "Recent stories from this profile author.", "items": { "type": "ref", - "ref": "lex:com.atproto.repo.strongRef", + "ref": "lex:so.sprk.story.defs#storyView", }, }, }, diff --git a/lex/types/so/sprk/actor/defs.ts b/lex/types/so/sprk/actor/defs.ts index e11831d..f840d7a 100644 --- a/lex/types/so/sprk/actor/defs.ts +++ b/lex/types/so/sprk/actor/defs.ts @@ -5,6 +5,7 @@ import { validate as _validate } from "../../../../lexicons.ts"; import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; import type * as ComAtprotoLabelDefs from "../../../com/atproto/label/defs.ts"; import type * as ComAtprotoRepoStrongRef from "../../../com/atproto/repo/strongRef.ts"; +import type * as SoSprkStoryDefs from "../story/defs.ts"; const is$typed = _is$typed, validate = _validate; const id = "so.sprk.actor.defs"; @@ -45,6 +46,8 @@ export interface ProfileView { createdAt?: string; viewer?: ViewerState; labels?: (ComAtprotoLabelDefs.Label)[]; + /** Recent stories from this profile author. */ + stories?: (SoSprkStoryDefs.StoryView)[]; } const hashProfileView = "profileView"; @@ -75,7 +78,7 @@ export interface ProfileViewDetailed { labels?: (ComAtprotoLabelDefs.Label)[]; pinnedPost?: ComAtprotoRepoStrongRef.Main; /** Recent stories from this profile author. */ - stories?: (ComAtprotoRepoStrongRef.Main)[]; + stories?: (SoSprkStoryDefs.StoryView)[]; } const hashProfileViewDetailed = "profileViewDetailed"; diff --git a/lexicons/so/sprk/actor/defs.json b/lexicons/so/sprk/actor/defs.json index 85ba8d9..4ed5a24 100644 --- a/lexicons/so/sprk/actor/defs.json +++ b/lexicons/so/sprk/actor/defs.json @@ -61,6 +61,14 @@ "labels": { "type": "array", "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } + }, + "stories": { + "type": "array", + "description": "Recent stories from this profile author.", + "items": { + "type": "ref", + "ref": "so.sprk.story.defs#storyView" + } } } }, @@ -105,7 +113,7 @@ "description": "Recent stories from this profile author.", "items": { "type": "ref", - "ref": "com.atproto.repo.strongRef" + "ref": "so.sprk.story.defs#storyView" } } } diff --git a/tests/stories_test.ts b/tests/stories_test.ts index 7e9dea9..a658911 100644 --- a/tests/stories_test.ts +++ b/tests/stories_test.ts @@ -728,5 +728,118 @@ Deno.test({ assertEquals(view?.embeds, undefined); }, ); + + await t.step( + "hydrateProfiles returns hydrated story views on profiles", + async () => { + const { ctx, cleanup } = await createTestContext({ + actors: true, + profiles: false, + posts: false, + replies: false, + stories: false, + likes: false, + reposts: false, + follows: false, + blocks: false, + audio: false, + generators: false, + preferences: false, + records: false, + actorSync: false, + }); + + try { + const now = new Date().toISOString(); + const did = TEST_USERS[0].did; + const profileUri = `at://${did}/so.sprk.actor.profile/self`; + const storyUri = `at://${did}/so.sprk.story.post/profile-story`; + + await ctx.db.models.Record.create([ + { + uri: profileUri, + cid: `${VALID_BLOB_CID}profile`, + did, + collectionName: "so.sprk.actor.profile", + rkey: "self", + createdAt: now, + indexedAt: now, + json: JSON.stringify({ + $type: "so.sprk.actor.profile", + displayName: "Alice", + createdAt: now, + }), + takedownRef: "", + }, + { + uri: storyUri, + cid: `${VALID_BLOB_CID}story`, + did, + collectionName: "so.sprk.story.post", + rkey: "profile-story", + createdAt: now, + indexedAt: now, + json: JSON.stringify({ + $type: "so.sprk.story.post", + createdAt: now, + media: { + $type: "so.sprk.media.image", + image: { + $type: "blob", + ref: { $link: VALID_BLOB_CID }, + mimeType: "image/jpeg", + size: 12345, + }, + alt: "Profile story", + aspectRatio: { width: 1080, height: 1920 }, + }, + }), + takedownRef: "", + }, + ]); + + await ctx.db.models.Story.create({ + uri: storyUri, + cid: `${VALID_BLOB_CID}story`, + authorDid: did, + createdAt: now, + indexedAt: now, + media: { + $type: "so.sprk.media.image", + image: { + $type: "blob", + ref: { $link: VALID_BLOB_CID }, + mimeType: "image/jpeg", + size: 12345, + }, + alt: "Profile story", + aspectRatio: { width: 1080, height: 1920 }, + }, + labels: [], + }); + + const hydrateCtx = await ctx.hydrator.createContext({ + viewer: null, + labelers: ctx.reqLabelers(new Request("https://example.com")), + }); + + const hydration = await ctx.hydrator.hydrateProfiles( + [did], + hydrateCtx, + ); + const profile = ctx.views.profile(did, hydration); + + assertEquals(profile?.stories?.length, 1); + assertEquals(profile?.stories?.[0].uri, storyUri); + assertEquals(profile?.stories?.[0].author.did, did); + assertEquals( + (profile?.stories?.[0].record as { $type?: string }).$type, + "so.sprk.story.post", + ); + } finally { + await cleanup(); + } + }, + ); }, }); diff --git a/views/index.ts b/views/index.ts index b14f54a..5e0436a 100644 --- a/views/index.ts +++ b/views/index.ts @@ -727,6 +727,10 @@ export class Views { if (!actor) return; const basicView = this.profileBasic(did, state); if (!basicView) return; + const stories = mapDefined( + state.actorStoryRefs?.get(did) ?? [], + (story) => this.story(story.uri, state), + ); return { ...basicView, $type: "so.sprk.actor.defs#profileView", @@ -737,6 +741,7 @@ export class Views { indexedAt: actor.indexedAt, }).toISOString() : undefined, + stories: stories.length > 0 ? stories : undefined, }; }