[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: stories in profile

+241 -26
+28
data-plane/routes/stories.ts
··· 135 135 } 136 136 137 137 /** 138 + * Get active stories grouped by actor DID 139 + */ 140 + async getActorStories( 141 + dids: string[], 142 + ): Promise<Map<string, { uri: string; cid: string }[]>> { 143 + if (!dids.length) return new Map(); 144 + 145 + const twentyFourHoursAgo = new Date(); 146 + twentyFourHoursAgo.setHours( 147 + twentyFourHoursAgo.getHours() - STORIES_EXPIRY_HOURS, 148 + ); 149 + const minDate = twentyFourHoursAgo.toISOString(); 150 + 151 + const stories = await this.db.models.Story.find({ 152 + authorDid: { $in: dids }, 153 + indexedAt: { $gte: minDate }, 154 + }).sort({ indexedAt: 1 }).lean(); 155 + 156 + const result = new Map<string, { uri: string; cid: string }[]>(); 157 + for (const story of stories) { 158 + const existing = result.get(story.authorDid) ?? []; 159 + existing.push({ uri: story.uri, cid: story.cid }); 160 + result.set(story.authorDid, existing); 161 + } 162 + return result; 163 + } 164 + 165 + /** 138 166 * Get blocked author DIDs for a viewer 139 167 */ 140 168 async getBlockedAuthors(
+55 -22
hydration/index.ts
··· 108 108 sounds?: Sounds; 109 109 soundAggs?: SoundAggs; 110 110 stories?: Stories; 111 + actorStoryRefs?: ActorStoryRefs; 111 112 112 113 postBlocks?: PostBlocks; 113 114 reposts?: Reposts; ··· 143 144 export type FollowBlocks = HydrationMap<FollowBlock>; 144 145 145 146 export type BidirectionalBlocks = HydrationMap<HydrationMap<boolean>>; 147 + export type ActorStoryRefs = HydrationMap<ItemRef[]>; 146 148 147 149 export class Hydrator { 148 150 actor: ActorHydrator; ··· 190 192 async hydrateProfiles( 191 193 dids: string[], 192 194 ctx: HydrateCtx, 195 + opts: { 196 + includeStories?: boolean; 197 + } = {}, 193 198 ): Promise<HydrationState> { 199 + const includeStories = opts.includeStories ?? true; 194 200 const includeTakedowns = ctx.includeTakedowns || ctx.includeActorTakedowns; 195 - const [actors, labels, profileViewersState] = await Promise.all([ 196 - this.actor.getActors(dids, { 197 - includeTakedowns, 198 - }), 199 - this.label.getLabelsForSubjects(labelSubjectsForDid(dids), ctx.labelers), 200 - this.hydrateProfileViewers(dids, ctx), 201 - ]); 201 + const [actors, labels, profileViewersState, actorStories] = await Promise 202 + .all([ 203 + this.actor.getActors(dids, { 204 + includeTakedowns, 205 + }), 206 + this.label.getLabelsForSubjects( 207 + labelSubjectsForDid(dids), 208 + ctx.labelers, 209 + ), 210 + this.hydrateProfileViewers(dids, ctx), 211 + includeStories 212 + ? this.story.getActorStories(dids) 213 + : Promise.resolve(new HydrationMap<ItemRef[]>()), 214 + ]); 215 + let actorStoryRefs: ActorStoryRefs | undefined; 216 + let storyState: HydrationState = {}; 217 + if (includeStories) { 218 + actorStoryRefs = actorStories; 219 + const storyUris = new Set<string>(); 220 + for (const [_did, stories] of actorStories) { 221 + if (!stories) continue; 222 + for (const story of stories) { 223 + storyUris.add(story.uri); 224 + } 225 + } 226 + if (storyUris.size > 0) { 227 + storyState = await this.hydrateStories(Array.from(storyUris), ctx); 228 + } 229 + } 202 230 if (!includeTakedowns) { 203 231 actionTakedownLabels(dids, actors, labels); 204 232 } 205 - return mergeStates(profileViewersState ?? {}, { 233 + return mergeManyStates(profileViewersState ?? {}, storyState, { 206 234 actors, 207 235 labels, 236 + actorStoryRefs, 208 237 ctx, 209 238 }); 210 239 } ··· 217 246 dids: string[], 218 247 ctx: HydrateCtx, 219 248 ): Promise<HydrationState> { 220 - return this.hydrateProfiles(dids, ctx); 249 + return this.hydrateProfiles(dids, ctx, { includeStories: false }); 221 250 } 222 251 223 252 // so.sprk.actor.defs#profileViewDetailed ··· 254 283 const allKnownFollowerDids = Array.from(knownFollowers.values()) 255 284 .filter(Boolean) 256 285 .flatMap((f) => f!.followers); 257 - const allDids = Array.from(new Set(dids.concat(allKnownFollowerDids))); 258 - const [state, profileAggs, bidirectionalBlocks] = await Promise.all([ 259 - this.hydrateProfiles(allDids, ctx), 260 - this.actor.getProfileAggregates(dids), 261 - this.hydrateBidirectionalBlocks(subjectsToKnownFollowersMap), 262 - ]); 263 - return mergeManyStates(state, { 286 + const [state, knownFollowerState, profileAggs, bidirectionalBlocks] = 287 + await Promise.all([ 288 + this.hydrateProfiles(dids, ctx), 289 + allKnownFollowerDids.length > 0 290 + ? this.hydrateProfilesBasic(allKnownFollowerDids, ctx) 291 + : Promise.resolve<HydrationState>({}), 292 + this.actor.getProfileAggregates(dids), 293 + this.hydrateBidirectionalBlocks(subjectsToKnownFollowersMap), 294 + ]); 295 + return mergeManyStates(state, knownFollowerState, { 264 296 profileAggs, 265 297 knownFollowers, 266 298 ctx, ··· 419 451 : Promise.resolve<PostViewerStates | undefined>(undefined), 420 452 this.label.getLabelsForSubjects(allUris, ctx.labelers), 421 453 this.hydratePostBlocks(state.posts!, state.replies!), 422 - this.hydrateProfiles(allProfileDids, ctx), 454 + this.hydrateProfilesBasic(allProfileDids, ctx), 423 455 this.feed.getThreadContexts(threadRefs), 424 456 this.hydrateSounds(Array.from(soundUris), ctx), 425 457 this.hydrateBidirectionalBlocks(subjectsToInteractorsMap), ··· 645 677 postUris.length > 0 646 678 ? this.hydratePosts(postUris.map((uri) => ({ uri })), ctx) 647 679 : Promise.resolve<HydrationState>({}), 648 - this.hydrateProfiles(profileDids, ctx), 680 + this.hydrateProfilesBasic(profileDids, ctx), 649 681 ]); 650 682 651 683 return mergeManyStates(profileState, postState, { stories, ctx }); ··· 692 724 ): Promise<HydrationState> { 693 725 const [likes, profileState] = await Promise.all([ 694 726 this.feed.getLikes(uris, ctx.includeTakedowns), 695 - this.hydrateProfiles(uris.map(didFromUri), ctx), 727 + this.hydrateProfilesBasic(uris.map(didFromUri), ctx), 696 728 ]); 697 729 698 730 const pairs: RelationshipPair[] = []; ··· 723 755 async hydrateReposts(uris: string[], ctx: HydrateCtx) { 724 756 const [reposts, profileState] = await Promise.all([ 725 757 this.feed.getReposts(uris, ctx.includeTakedowns), 726 - this.hydrateProfiles(uris.map(didFromUri), ctx), 758 + this.hydrateProfilesBasic(uris.map(didFromUri), ctx), 727 759 ]); 728 760 return mergeStates(profileState, { reposts, ctx }); 729 761 } ··· 780 812 this.feed.getReposts(repostUris), // reason: repost 781 813 this.graph.getFollows(followUris), // reason: follow 782 814 this.label.getLabelsForSubjects(uris, ctx.labelers), 783 - this.hydrateProfiles(uris.map(didFromUri), ctx), 815 + this.hydrateProfilesBasic(uris.map(didFromUri), ctx), 784 816 this.feed.getPosts(subjectPostUris), // subjects of likes/reposts 785 817 this.feed.getReplies(subjectReplyUris), // subjects of likes/reposts 786 818 ]); ··· 823 855 const [sounds, soundAggs, profileState] = await Promise.all([ 824 856 this.feed.getSounds(uris, ctx.includeTakedowns), 825 857 this.feed.getSoundAggregates(uris.map((uri) => ({ uri }))), 826 - this.hydrateProfiles(uris.map(didFromUri), ctx), 858 + this.hydrateProfilesBasic(uris.map(didFromUri), ctx), 827 859 ]); 828 860 return mergeStates(profileState, { sounds, soundAggs, ctx }); 829 861 } ··· 1113 1145 sounds: mergeMaps(stateA.sounds, stateB.sounds), 1114 1146 soundAggs: mergeMaps(stateA.soundAggs, stateB.soundAggs), 1115 1147 stories: mergeMaps(stateA.stories, stateB.stories), 1148 + actorStoryRefs: mergeMaps(stateA.actorStoryRefs, stateB.actorStoryRefs), 1116 1149 postBlocks: mergeMaps(stateA.postBlocks, stateB.postBlocks), 1117 1150 reposts: mergeMaps(stateA.reposts, stateB.reposts), 1118 1151 follows: mergeMaps(stateA.follows, stateB.follows),
+18 -1
hydration/story.ts
··· 1 1 import { Record as StoryRecord } from "../lex/types/so/sprk/story/post.ts"; 2 - import { HydrationMap, parseRecord, RecordInfo, split } from "./util.ts"; 2 + import { 3 + HydrationMap, 4 + ItemRef, 5 + parseRecord, 6 + RecordInfo, 7 + split, 8 + } from "./util.ts"; 3 9 import { DataPlane } from "../data-plane/index.ts"; 4 10 5 11 export type Story = RecordInfo<StoryRecord>; ··· 7 13 8 14 export class StoryHydrator { 9 15 constructor(public dataplane: DataPlane) {} 16 + 17 + async getActorStories( 18 + dids: string[], 19 + ): Promise<HydrationMap<ItemRef[]>> { 20 + const refsByActor = await this.dataplane.stories.getActorStories(dids); 21 + const result = new HydrationMap<ItemRef[]>(); 22 + for (const [did, refs] of refsByActor) { 23 + result.set(did, refs); 24 + } 25 + return result; 26 + } 10 27 11 28 async getStories( 12 29 uris: string[],
+9 -1
lex/lexicons.ts
··· 20117 20117 "ref": "lex:com.atproto.label.defs#label", 20118 20118 }, 20119 20119 }, 20120 + "stories": { 20121 + "type": "array", 20122 + "description": "Recent stories from this profile author.", 20123 + "items": { 20124 + "type": "ref", 20125 + "ref": "lex:so.sprk.story.defs#storyView", 20126 + }, 20127 + }, 20120 20128 }, 20121 20129 }, 20122 20130 "profileViewDetailed": { ··· 20193 20201 "description": "Recent stories from this profile author.", 20194 20202 "items": { 20195 20203 "type": "ref", 20196 - "ref": "lex:com.atproto.repo.strongRef", 20204 + "ref": "lex:so.sprk.story.defs#storyView", 20197 20205 }, 20198 20206 }, 20199 20207 },
+4 -1
lex/types/so/sprk/actor/defs.ts
··· 5 5 import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; 6 6 import type * as ComAtprotoLabelDefs from "../../../com/atproto/label/defs.ts"; 7 7 import type * as ComAtprotoRepoStrongRef from "../../../com/atproto/repo/strongRef.ts"; 8 + import type * as SoSprkStoryDefs from "../story/defs.ts"; 8 9 9 10 const is$typed = _is$typed, validate = _validate; 10 11 const id = "so.sprk.actor.defs"; ··· 45 46 createdAt?: string; 46 47 viewer?: ViewerState; 47 48 labels?: (ComAtprotoLabelDefs.Label)[]; 49 + /** Recent stories from this profile author. */ 50 + stories?: (SoSprkStoryDefs.StoryView)[]; 48 51 } 49 52 50 53 const hashProfileView = "profileView"; ··· 75 78 labels?: (ComAtprotoLabelDefs.Label)[]; 76 79 pinnedPost?: ComAtprotoRepoStrongRef.Main; 77 80 /** Recent stories from this profile author. */ 78 - stories?: (ComAtprotoRepoStrongRef.Main)[]; 81 + stories?: (SoSprkStoryDefs.StoryView)[]; 79 82 } 80 83 81 84 const hashProfileViewDetailed = "profileViewDetailed";
+9 -1
lexicons/so/sprk/actor/defs.json
··· 61 61 "labels": { 62 62 "type": "array", 63 63 "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 64 + }, 65 + "stories": { 66 + "type": "array", 67 + "description": "Recent stories from this profile author.", 68 + "items": { 69 + "type": "ref", 70 + "ref": "so.sprk.story.defs#storyView" 71 + } 64 72 } 65 73 } 66 74 }, ··· 105 113 "description": "Recent stories from this profile author.", 106 114 "items": { 107 115 "type": "ref", 108 - "ref": "com.atproto.repo.strongRef" 116 + "ref": "so.sprk.story.defs#storyView" 109 117 } 110 118 } 111 119 }
+113
tests/stories_test.ts
··· 728 728 assertEquals(view?.embeds, undefined); 729 729 }, 730 730 ); 731 + 732 + await t.step( 733 + "hydrateProfiles returns hydrated story views on profiles", 734 + async () => { 735 + const { ctx, cleanup } = await createTestContext({ 736 + actors: true, 737 + profiles: false, 738 + posts: false, 739 + replies: false, 740 + stories: false, 741 + likes: false, 742 + reposts: false, 743 + follows: false, 744 + blocks: false, 745 + audio: false, 746 + generators: false, 747 + preferences: false, 748 + records: false, 749 + actorSync: false, 750 + }); 751 + 752 + try { 753 + const now = new Date().toISOString(); 754 + const did = TEST_USERS[0].did; 755 + const profileUri = `at://${did}/so.sprk.actor.profile/self`; 756 + const storyUri = `at://${did}/so.sprk.story.post/profile-story`; 757 + 758 + await ctx.db.models.Record.create([ 759 + { 760 + uri: profileUri, 761 + cid: `${VALID_BLOB_CID}profile`, 762 + did, 763 + collectionName: "so.sprk.actor.profile", 764 + rkey: "self", 765 + createdAt: now, 766 + indexedAt: now, 767 + json: JSON.stringify({ 768 + $type: "so.sprk.actor.profile", 769 + displayName: "Alice", 770 + createdAt: now, 771 + }), 772 + takedownRef: "", 773 + }, 774 + { 775 + uri: storyUri, 776 + cid: `${VALID_BLOB_CID}story`, 777 + did, 778 + collectionName: "so.sprk.story.post", 779 + rkey: "profile-story", 780 + createdAt: now, 781 + indexedAt: now, 782 + json: JSON.stringify({ 783 + $type: "so.sprk.story.post", 784 + createdAt: now, 785 + media: { 786 + $type: "so.sprk.media.image", 787 + image: { 788 + $type: "blob", 789 + ref: { $link: VALID_BLOB_CID }, 790 + mimeType: "image/jpeg", 791 + size: 12345, 792 + }, 793 + alt: "Profile story", 794 + aspectRatio: { width: 1080, height: 1920 }, 795 + }, 796 + }), 797 + takedownRef: "", 798 + }, 799 + ]); 800 + 801 + await ctx.db.models.Story.create({ 802 + uri: storyUri, 803 + cid: `${VALID_BLOB_CID}story`, 804 + authorDid: did, 805 + createdAt: now, 806 + indexedAt: now, 807 + media: { 808 + $type: "so.sprk.media.image", 809 + image: { 810 + $type: "blob", 811 + ref: { $link: VALID_BLOB_CID }, 812 + mimeType: "image/jpeg", 813 + size: 12345, 814 + }, 815 + alt: "Profile story", 816 + aspectRatio: { width: 1080, height: 1920 }, 817 + }, 818 + labels: [], 819 + }); 820 + 821 + const hydrateCtx = await ctx.hydrator.createContext({ 822 + viewer: null, 823 + labelers: ctx.reqLabelers(new Request("https://example.com")), 824 + }); 825 + 826 + const hydration = await ctx.hydrator.hydrateProfiles( 827 + [did], 828 + hydrateCtx, 829 + ); 830 + const profile = ctx.views.profile(did, hydration); 831 + 832 + assertEquals(profile?.stories?.length, 1); 833 + assertEquals(profile?.stories?.[0].uri, storyUri); 834 + assertEquals(profile?.stories?.[0].author.did, did); 835 + assertEquals( 836 + (profile?.stories?.[0].record as { $type?: string }).$type, 837 + "so.sprk.story.post", 838 + ); 839 + } finally { 840 + await cleanup(); 841 + } 842 + }, 843 + ); 731 844 }, 732 845 });
+5
views/index.ts
··· 727 727 if (!actor) return; 728 728 const basicView = this.profileBasic(did, state); 729 729 if (!basicView) return; 730 + const stories = mapDefined( 731 + state.actorStoryRefs?.get(did) ?? [], 732 + (story) => this.story(story.uri, state), 733 + ); 730 734 return { 731 735 ...basicView, 732 736 $type: "so.sprk.actor.defs#profileView", ··· 737 741 indexedAt: actor.indexedAt, 738 742 }).toISOString() 739 743 : undefined, 744 + stories: stories.length > 0 ? stories : undefined, 740 745 }; 741 746 } 742 747