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

Configure Feed

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

at main 383 lines 11 kB view raw
1import * as so from "../lex/so.ts"; 2import { uriToDid as didFromUri } from "../utils/uris.ts"; 3import { 4 HydrationMap, 5 ItemRef, 6 parseRecord, 7 parseString, 8 RecordInfo, 9 split, 10} from "./util.ts"; 11import { DataPlane } from "../data-plane/index.ts"; 12 13export type FeedGenRecord = so.sprk.feed.generator.Main; 14export type LikeRecord = so.sprk.feed.like.Main; 15export type PostRecord = so.sprk.feed.post.Main; 16export type ReplyRecord = so.sprk.feed.reply.Main; 17export type RepostRecord = so.sprk.feed.repost.Main; 18export type AudioRecord = so.sprk.sound.audio.Main; 19 20export type Post = RecordInfo<PostRecord>; 21export type Posts = HydrationMap<Post>; 22export type Reply = RecordInfo<ReplyRecord>; 23export type Replies = HydrationMap<Reply>; 24export type Sound = RecordInfo<AudioRecord>; 25export type Sounds = HydrationMap<Sound>; 26 27export type SoundAgg = { 28 uses: number; 29}; 30 31export type SoundAggs = HydrationMap<SoundAgg>; 32 33export type PostViewerState = { 34 like?: string; 35 repost?: string; 36}; 37 38export type PostViewerStates = HydrationMap<PostViewerState>; 39 40export type ThreadContext = { 41 // Whether the root author has liked the post. 42 like?: string; 43}; 44 45export type ThreadContexts = HydrationMap<ThreadContext>; 46 47export type PostAgg = { 48 likes: number; 49 replies: number; 50 reposts: number; 51}; 52 53export type PostAggs = HydrationMap<PostAgg>; 54 55export type ReplyAgg = { 56 likes: number; 57 replies: number; 58}; 59 60export type ReplyAggs = HydrationMap<ReplyAgg>; 61 62export type Like = RecordInfo<LikeRecord>; 63export type Likes = HydrationMap<Like>; 64 65export type Repost = RecordInfo<RepostRecord>; 66export type Reposts = HydrationMap<Repost>; 67 68export type FeedGenAgg = { 69 likes: number; 70}; 71 72export type FeedGenAggs = HydrationMap<FeedGenAgg>; 73 74export type FeedGen = RecordInfo<FeedGenRecord>; 75export type FeedGens = HydrationMap<FeedGen>; 76 77export type FeedGenViewerState = { 78 like?: string; 79}; 80 81export type FeedGenViewerStates = HydrationMap<FeedGenViewerState>; 82 83export type KnownInteractionState = { 84 type: "like" | "repost" | "reply"; 85 by: string; // DID of the person who interacted 86 uri: string; 87 cid: string; 88 indexedAt: Date; 89 text?: string; // Only for replies 90}; 91 92export type KnownInteractionsStates = HydrationMap< 93 KnownInteractionState[] | undefined 94>; 95 96export type ThreadRef = ItemRef & { threadRoot: string }; 97 98export type FeedItem = { 99 post: ItemRef; 100}; 101 102export class FeedHydrator { 103 constructor(public dataplane: DataPlane) {} 104 105 async getPosts( 106 uris: string[], 107 includeTakedowns = false, 108 given = new HydrationMap<Post>(), 109 ): Promise<Posts> { 110 const [have, need] = split(uris, (uri) => given.has(uri)); 111 const base = have.reduce( 112 (acc, uri) => acc.set(uri, given.get(uri) ?? null), 113 new HydrationMap<Post>(), 114 ); 115 if (!need.length) return base; 116 const res = await this.dataplane.records.getPostRecords(need); 117 118 return need.reduce((acc, uri, i) => { 119 const record = parseRecord<PostRecord>( 120 so.sprk.feed.post.main, 121 res.records[i], 122 includeTakedowns, 123 ); 124 return acc.set( 125 uri, 126 record ? record : null, 127 ); 128 }, base); 129 } 130 131 async getReplies( 132 uris: string[], 133 includeTakedowns = false, 134 given = new HydrationMap<Reply>(), 135 ): Promise<Replies> { 136 const [have, need] = split(uris, (uri) => given.has(uri)); 137 const base = have.reduce( 138 (acc, uri) => acc.set(uri, given.get(uri) ?? null), 139 new HydrationMap<Reply>(), 140 ); 141 if (!need.length) return base; 142 const res = await this.dataplane.records.getReplyRecords(need); 143 144 return need.reduce((acc, uri, i) => { 145 const record = parseRecord<ReplyRecord>( 146 so.sprk.feed.reply.main, 147 res.records[i], 148 includeTakedowns, 149 ); 150 return acc.set( 151 uri, 152 record ? record : null, 153 ); 154 }, base); 155 } 156 157 async getSounds( 158 uris: string[], 159 includeTakedowns = false, 160 given = new HydrationMap<Sound>(), 161 ): Promise<Sounds> { 162 const [have, need] = split(uris, (uri) => given.has(uri)); 163 const base = have.reduce( 164 (acc, uri) => acc.set(uri, given.get(uri) ?? null), 165 new HydrationMap<Sound>(), 166 ); 167 if (!need.length) return base; 168 const res = await this.dataplane.records.getRecords(need); 169 170 return need.reduce((acc, uri, i) => { 171 const record = parseRecord<AudioRecord>( 172 so.sprk.sound.audio.main, 173 res.records[i], 174 includeTakedowns, 175 ); 176 return acc.set( 177 uri, 178 record ? record : null, 179 ); 180 }, base); 181 } 182 183 async getPostViewerStates( 184 refs: ThreadRef[], 185 viewer: string, 186 ): Promise<PostViewerStates> { 187 if (!refs.length) return new HydrationMap<PostViewerState>(); 188 const [likes, reposts] = await Promise.all([ 189 await this.dataplane.likes.byActorAndSubjects(viewer, refs), 190 await this.dataplane.reposts.byActorAndSubjects( 191 viewer, 192 refs, 193 ), 194 ]); 195 return refs.reduce((acc, { uri }, i) => { 196 return acc.set(uri, { 197 like: parseString(likes.uris[i]), 198 repost: parseString(reposts.uris[i]), 199 }); 200 }, new HydrationMap<PostViewerState>()); 201 } 202 203 async getThreadContexts(refs: ThreadRef[]): Promise<ThreadContexts> { 204 if (!refs.length) return new HydrationMap<ThreadContext>(); 205 206 const refsByRootAuthor = refs.reduce((acc, ref) => { 207 const { threadRoot } = ref; 208 const rootAuthor = didFromUri(threadRoot); 209 const existingValue = acc.get(rootAuthor) ?? []; 210 return acc.set(rootAuthor, [...existingValue, ref]); 211 }, new Map<string, ThreadRef[]>()); 212 const refsByRootAuthorEntries = Array.from(refsByRootAuthor.entries()); 213 214 const likesPromises = refsByRootAuthorEntries.map( 215 ([rootAuthor, refsForAuthor]) => 216 this.dataplane.likes.byActorAndSubjects( 217 rootAuthor, 218 refsForAuthor.map(({ uri, cid }) => ({ uri, cid })), 219 ), 220 ); 221 222 const rootAuthorsLikes = await Promise.all(likesPromises); 223 224 const likesByUri = refsByRootAuthorEntries.reduce( 225 (acc, [_rootAuthor, refsForAuthor], i) => { 226 const likesForRootAuthor = rootAuthorsLikes[i]; 227 refsForAuthor.forEach(({ uri }, j) => { 228 acc.set(uri, likesForRootAuthor.uris[j]); 229 }); 230 return acc; 231 }, 232 new Map<string, string>(), 233 ); 234 235 return refs.reduce((acc, { uri }) => { 236 return acc.set(uri, { 237 like: parseString(likesByUri.get(uri)), 238 }); 239 }, new HydrationMap<ThreadContext>()); 240 } 241 242 async getPostAggregates( 243 refs: ItemRef[], 244 ): Promise<PostAggs> { 245 if (!refs.length) return new HydrationMap<PostAgg>(); 246 const counts = await this.dataplane.interactions.getInteractionCounts(refs); 247 return refs.reduce((acc, { uri }, i) => { 248 return acc.set(uri, { 249 likes: counts.likes[i] ?? 0, 250 reposts: counts.reposts[i] ?? 0, 251 replies: counts.replies[i] ?? 0, 252 }); 253 }, new HydrationMap<PostAgg>()); 254 } 255 256 async getReplyAggregates( 257 refs: ItemRef[], 258 ): Promise<ReplyAggs> { 259 if (!refs.length) return new HydrationMap<ReplyAgg>(); 260 const counts = await this.dataplane.interactions.getInteractionCounts(refs); 261 return refs.reduce((acc, { uri }, i) => { 262 return acc.set(uri, { 263 likes: counts.likes[i] ?? 0, 264 replies: counts.replies[i] ?? 0, 265 }); 266 }, new HydrationMap<ReplyAgg>()); 267 } 268 269 async getSoundAggregates( 270 refs: ItemRef[], 271 ): Promise<SoundAggs> { 272 if (!refs.length) return new HydrationMap<SoundAgg>(); 273 const uris = refs.map((ref) => ref.uri); 274 const counts = await this.dataplane.interactions.getSoundUsageCounts(uris); 275 return refs.reduce((acc, { uri }, i) => { 276 return acc.set(uri, { 277 uses: counts.uses[i] ?? 0, 278 }); 279 }, new HydrationMap<SoundAgg>()); 280 } 281 282 async getFeedGens( 283 uris: string[], 284 includeTakedowns = false, 285 ): Promise<FeedGens> { 286 if (!uris.length) return new HydrationMap<FeedGen>(); 287 const res = await this.dataplane.records.getFeedGeneratorRecords(uris); 288 return uris.reduce((acc, uri, i) => { 289 const record = parseRecord<FeedGenRecord>( 290 so.sprk.feed.generator.main, 291 res.records[i], 292 includeTakedowns, 293 ); 294 return acc.set(uri, record ?? null); 295 }, new HydrationMap<FeedGen>()); 296 } 297 298 async getFeedGenViewerStates( 299 uris: string[], 300 viewer: string, 301 ): Promise<FeedGenViewerStates> { 302 if (!uris.length) return new HydrationMap<FeedGenViewerState>(); 303 const likes = await this.dataplane.likes.byActorAndSubjects( 304 viewer, 305 uris.map((uri) => ({ uri })), 306 ); 307 return uris.reduce((acc, uri, i) => { 308 return acc.set(uri, { 309 like: parseString(likes.uris[i]), 310 }); 311 }, new HydrationMap<FeedGenViewerState>()); 312 } 313 314 async getFeedGenAggregates( 315 refs: ItemRef[], 316 ): Promise<FeedGenAggs> { 317 if (!refs.length) return new HydrationMap<FeedGenAgg>(); 318 const counts = await this.dataplane.interactions.getInteractionCounts(refs); 319 return refs.reduce((acc, { uri }, i) => { 320 return acc.set(uri, { 321 likes: counts.likes[i] ?? 0, 322 }); 323 }, new HydrationMap<FeedGenAgg>()); 324 } 325 326 async getLikes(uris: string[], includeTakedowns = false): Promise<Likes> { 327 if (!uris.length) return new HydrationMap<Like>(); 328 const res = await this.dataplane.records.getLikeRecords(uris); 329 return uris.reduce((acc, uri, i) => { 330 const record = parseRecord<LikeRecord>( 331 so.sprk.feed.like.main, 332 res.records[i], 333 includeTakedowns, 334 ); 335 return acc.set(uri, record ?? null); 336 }, new HydrationMap<Like>()); 337 } 338 339 async getReposts(uris: string[], includeTakedowns = false): Promise<Reposts> { 340 if (!uris.length) return new HydrationMap<Repost>(); 341 const res = await this.dataplane.records.getRepostRecords(uris); 342 return uris.reduce((acc, uri, i) => { 343 const record = parseRecord<RepostRecord>( 344 so.sprk.feed.repost.main, 345 res.records[i], 346 includeTakedowns, 347 ); 348 return acc.set(uri, record ?? null); 349 }, new HydrationMap<Repost>()); 350 } 351 352 async getKnownInteractions( 353 refs: ItemRef[], 354 viewer: string | null, 355 ): Promise<KnownInteractionsStates> { 356 if (!viewer || !refs.length) { 357 return new HydrationMap<KnownInteractionState[] | undefined>(); 358 } 359 360 const subjectUris = refs.map((ref) => ref.uri); 361 const { results } = await this.dataplane.interactions.getKnownInteractions( 362 viewer, 363 subjectUris, 364 ); 365 366 return refs.reduce((acc, { uri }) => { 367 const interactions = results.get(uri); 368 return acc.set( 369 uri, 370 interactions && interactions.length > 0 371 ? interactions.map((i) => ({ 372 type: i.type, 373 by: i.authorDid, 374 uri: i.uri, 375 cid: i.cid, 376 indexedAt: new Date(i.indexedAt), 377 text: i.text, 378 })) 379 : undefined, 380 ); 381 }, new HydrationMap<KnownInteractionState[] | undefined>()); 382 } 383}