[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.

known interactions (#56)

authored by

Roscoe Rubin-Rottenberg and committed by
GitHub
a87b65b1 6a7f743a

+534 -262
+4 -68
api/so/sprk/feed/getAuthorFeed.ts
··· 22 22 const getAuthorFeed = createPipeline( 23 23 skeleton, 24 24 hydration, 25 - noBlocksOrMutedReposts, 25 + noBlocksOrMutes, 26 26 presentation, 27 27 ); 28 28 server.so.sprk.feed.getAuthorFeed({ ··· 94 94 95 95 let items: FeedItem[] = res.items.map((item) => ({ 96 96 post: { uri: item.uri, cid: item.cid || undefined }, 97 - repost: item.repost 98 - ? { uri: item.repost, cid: item.repostCid || undefined } 99 - : undefined, 100 97 })); 101 98 102 99 if (shouldInsertPinnedPost && pinnedPost) { ··· 105 102 uri: pinnedPost.uri, 106 103 cid: pinnedPost.cid, 107 104 }, 108 - authorPinned: true, 109 105 }; 110 106 111 107 items = items.filter((item) => item.post.uri !== pinnedItem.post.uri); ··· 133 129 return mergeStates(feedPostState, profileViewerState); 134 130 }; 135 131 136 - const noBlocksOrMutedReposts = (inputs: { 132 + const noBlocksOrMutes = (inputs: { 137 133 ctx: Context; 138 134 skeleton: Skeleton; 139 135 hydration: HydrationState; ··· 157 153 const bam = ctx.views.feedItemBlocksAndMutes(item, hydration); 158 154 return ( 159 155 !bam.authorBlocked && 160 - !bam.originatorBlocked && 161 - (!bam.authorMuted || bam.originatorMuted) // repost of muted content 156 + !bam.authorMuted 162 157 ); 163 158 }; 164 159 165 - if (skeleton.filter === "posts_and_author_threads") { 166 - // ensure replies are only included if the feed contains all 167 - // replies up to the thread root (i.e. a complete self-thread.) 168 - const selfThread = new SelfThreadTracker(skeleton.items, hydration); 169 - skeleton.items = skeleton.items.filter((item) => { 170 - return ( 171 - checkBlocksAndMutes(item) && 172 - (item.repost || item.authorPinned || selfThread.ok(item.post.uri)) 173 - ); 174 - }); 175 - } else { 176 - skeleton.items = skeleton.items.filter(checkBlocksAndMutes); 177 - } 160 + skeleton.items = skeleton.items.filter(checkBlocksAndMutes); 178 161 179 162 return skeleton; 180 163 }; ··· 208 191 filter: QueryParams["filter"]; 209 192 cursor?: string; 210 193 }; 211 - 212 - class SelfThreadTracker { 213 - feedUris = new Set<string>(); 214 - cache = new Map<string, boolean>(); 215 - 216 - constructor( 217 - items: FeedItem[], 218 - private hydration: HydrationState, 219 - ) { 220 - items.forEach((item) => { 221 - if (!item.repost) { 222 - this.feedUris.add(item.post.uri); 223 - } 224 - }); 225 - } 226 - 227 - ok(uri: string, loop = new Set<string>()) { 228 - // if we've already checked this uri, pull from the cache 229 - if (this.cache.has(uri)) { 230 - return this.cache.get(uri) ?? false; 231 - } 232 - // loop detection 233 - if (loop.has(uri)) { 234 - this.cache.set(uri, false); 235 - return false; 236 - } else { 237 - loop.add(uri); 238 - } 239 - // cache through the result 240 - const result = this._ok(uri); 241 - this.cache.set(uri, result); 242 - return result; 243 - } 244 - 245 - private _ok(uri: string): boolean { 246 - // must be in the feed to be in a self-thread 247 - if (!this.feedUris.has(uri)) { 248 - return false; 249 - } 250 - // must be hydratable to be part of self-thread 251 - const post = this.hydration.posts?.get(uri); 252 - if (!post) { 253 - return false; 254 - } 255 - return true; 256 - } 257 - }
+1 -7
api/so/sprk/feed/getFeed.ts
··· 16 16 import { HydrateCtx } from "../../../../hydration/index.ts"; 17 17 import { Server } from "../../../../lex/index.ts"; 18 18 import { ids, lexicons } from "../../../../lex/lexicons.ts"; 19 - import { isSkeletonReasonRepost } from "../../../../lex/types/so/sprk/feed/defs.ts"; 20 19 import { QueryParams as GetFeedParams } from "../../../../lex/types/so/sprk/feed/getFeed.ts"; 21 20 import { OutputSchema as SkeletonOutput } from "../../../../lex/types/so/sprk/feed/getFeedSkeleton.ts"; 22 21 import { ··· 127 126 const bam = ctx.views.feedItemBlocksAndMutes(item, hydration); 128 127 return ( 129 128 !bam.authorBlocked && 130 - !bam.authorMuted && 131 - !bam.originatorBlocked && 132 - !bam.originatorMuted 129 + !bam.authorMuted 133 130 ); 134 131 }); 135 132 ··· 263 260 const { feed: feedSkele, ...skele } = skeleton; 264 261 const feedItems = feedSkele.slice(0, params.limit).map((item) => ({ 265 262 post: { uri: item.post }, 266 - repost: isSkeletonReasonRepost(item.reason) 267 - ? { uri: item.reason.repost } 268 - : undefined, 269 263 feedContext: item.feedContext, 270 264 })); 271 265
+1 -6
api/so/sprk/feed/getTimeline.ts
··· 66 66 return { 67 67 items: res.items.map((item) => ({ 68 68 post: { uri: item.uri, cid: item.cid || undefined }, 69 - repost: item.repost 70 - ? { uri: item.repost, cid: item.repostCid || undefined } 71 - : undefined, 72 69 })), 73 70 cursor: parseString(res.cursor), 74 71 }; ··· 92 89 skeleton.items = skeleton.items.filter((item) => { 93 90 const bam = ctx.views.feedItemBlocksAndMutes(item, hydration); 94 91 return !bam.authorBlocked && 95 - !bam.authorMuted && 96 - !bam.originatorBlocked && 97 - !bam.originatorMuted; 92 + !bam.authorMuted; 98 93 }); 99 94 return skeleton; 100 95 };
+2 -12
data-plane/routes/feeds.ts
··· 3 3 4 4 // Helper function to format feed items 5 5 function feedItemFromRow( 6 - item: { uri: string; cid: string; repostUri?: string }, 7 - ): { uri: string; cid: string; repost?: string; repostCid?: string } { 6 + item: { uri: string; cid: string }, 7 + ): { uri: string; cid: string } { 8 8 return { 9 9 uri: item.uri, 10 10 cid: item.cid, 11 - repost: item.repostUri && item.repostUri !== item.uri 12 - ? item.repostUri 13 - : undefined, 14 - repostCid: item.repostUri && item.repostUri !== item.uri 15 - ? item.cid 16 - : undefined, 17 11 }; 18 12 } 19 13 ··· 23 17 authorDid: string; 24 18 createdAt: string; 25 19 indexedAt: string; 26 - type: "post" | "repost"; 27 - repostUri?: string; 28 20 } 29 21 30 22 export class Feeds { ··· 87 79 authorDid: p.authorDid, 88 80 createdAt: p.createdAt, 89 81 indexedAt: p.indexedAt, 90 - type: "post" as const, 91 82 })); 92 83 93 84 return { ··· 124 115 authorDid: p.authorDid, 125 116 createdAt: p.createdAt, 126 117 indexedAt: p.indexedAt, 127 - type: "post" as const, 128 118 })); 129 119 130 120 return {
+139
data-plane/routes/interactions.ts
··· 6 6 count: number; 7 7 } 8 8 9 + export interface KnownInteraction { 10 + type: "like" | "repost" | "reply"; 11 + uri: string; 12 + cid: string; 13 + authorDid: string; 14 + indexedAt: string; 15 + text?: string; 16 + } 17 + 9 18 export class Interactions { 10 19 private db: Database; 11 20 ··· 135 144 return { 136 145 uses: uris.map((uri) => usageMap.get(uri) ?? 0), 137 146 }; 147 + } 148 + 149 + /** 150 + * Get interactions (likes, reposts, replies) on subject URIs by users the viewer follows. 151 + * Returns interactions sorted by indexedAt descending (most recent first). 152 + */ 153 + async getKnownInteractions( 154 + viewerDid: string, 155 + subjectUris: string[], 156 + ): Promise<{ results: Map<string, KnownInteraction[]> }> { 157 + if (subjectUris.length === 0) { 158 + return { results: new Map() }; 159 + } 160 + 161 + // Get all DIDs the viewer follows 162 + const viewerFollows = await this.db.models.Follow.find({ 163 + authorDid: viewerDid, 164 + }); 165 + const followedDids = viewerFollows.map((f) => f.subject); 166 + 167 + if (followedDids.length === 0) { 168 + return { results: new Map() }; 169 + } 170 + 171 + // Query likes, reposts, and replies by followed users on the subject URIs 172 + const [likes, reposts, replies] = await Promise.all([ 173 + this.db.models.Like.find({ 174 + subject: { $in: subjectUris }, 175 + authorDid: { $in: followedDids }, 176 + }).sort({ indexedAt: -1 }), 177 + this.db.models.Repost.find({ 178 + subject: { $in: subjectUris }, 179 + authorDid: { $in: followedDids }, 180 + }).sort({ indexedAt: -1 }), 181 + this.db.models.Reply.find({ 182 + "reply.parent.uri": { $in: subjectUris }, 183 + authorDid: { $in: followedDids }, 184 + }).sort({ indexedAt: -1 }), 185 + ]); 186 + 187 + // Build result map keyed by subject URI 188 + const results = new Map<string, KnownInteraction[]>(); 189 + 190 + // Initialize empty arrays for each subject URI 191 + for (const uri of subjectUris) { 192 + results.set(uri, []); 193 + } 194 + 195 + // Add likes 196 + for (const like of likes) { 197 + const interactions = results.get(like.subject); 198 + if (interactions) { 199 + interactions.push({ 200 + type: "like", 201 + uri: like.uri, 202 + cid: like.cid, 203 + authorDid: like.authorDid, 204 + indexedAt: String(like.indexedAt), 205 + }); 206 + } 207 + } 208 + 209 + // Add reposts 210 + for (const repost of reposts) { 211 + const interactions = results.get(repost.subject); 212 + if (interactions) { 213 + interactions.push({ 214 + type: "repost", 215 + uri: repost.uri, 216 + cid: repost.cid, 217 + authorDid: repost.authorDid, 218 + indexedAt: String(repost.indexedAt), 219 + }); 220 + } 221 + } 222 + 223 + // Add replies 224 + for (const reply of replies) { 225 + const parentUri = reply.reply?.parent.uri; 226 + if (!parentUri) continue; 227 + const interactions = results.get(parentUri); 228 + if (interactions) { 229 + interactions.push({ 230 + type: "reply", 231 + uri: reply.uri, 232 + cid: reply.cid, 233 + authorDid: reply.authorDid, 234 + indexedAt: String(reply.indexedAt), 235 + text: reply.text, 236 + }); 237 + } 238 + } 239 + 240 + // Dedupe: keep one interaction per actor with priority: repost > reply > like 241 + // Sort order: repost → like → reply 242 + const keepPriority: Record<KnownInteraction["type"], number> = { 243 + repost: 0, 244 + reply: 1, 245 + like: 2, 246 + }; 247 + 248 + for (const [uri, interactions] of results) { 249 + // Group by author, keep highest priority interaction per author 250 + const byAuthor = new Map<string, KnownInteraction>(); 251 + for (const interaction of interactions) { 252 + const existing = byAuthor.get(interaction.authorDid); 253 + if ( 254 + !existing || 255 + keepPriority[interaction.type] < keepPriority[existing.type] 256 + ) { 257 + byAuthor.set(interaction.authorDid, interaction); 258 + } 259 + } 260 + 261 + // Bucket into 3 arrays by type (avoids sorting) 262 + const repostBucket: KnownInteraction[] = []; 263 + const likeBucket: KnownInteraction[] = []; 264 + const replyBucket: KnownInteraction[] = []; 265 + 266 + for (const interaction of byAuthor.values()) { 267 + if (interaction.type === "repost") repostBucket.push(interaction); 268 + else if (interaction.type === "like") likeBucket.push(interaction); 269 + else replyBucket.push(interaction); 270 + } 271 + 272 + // Concatenate in desired order: repost → like → reply 273 + results.set(uri, [...repostBucket, ...likeBucket, ...replyBucket]); 274 + } 275 + 276 + return { results }; 138 277 } 139 278 }
+45 -8
hydration/feed.ts
··· 78 78 79 79 export type FeedGenViewerStates = HydrationMap<FeedGenViewerState>; 80 80 81 + export type KnownInteractionState = { 82 + type: "like" | "repost" | "reply"; 83 + by: string; // DID of the person who interacted 84 + uri: string; 85 + cid: string; 86 + indexedAt: Date; 87 + text?: string; // Only for replies 88 + }; 89 + 90 + export type KnownInteractionsStates = HydrationMap< 91 + KnownInteractionState[] | undefined 92 + >; 93 + 81 94 export type ThreadRef = ItemRef & { threadRoot: string }; 82 95 83 - // @NOTE the feed item types in the protos for author feeds and timelines 84 - // technically have additional fields, not supported by the mock dataplane. 85 96 export type FeedItem = { 86 97 post: ItemRef; 87 - repost?: ItemRef; 88 - /** 89 - * If true, overrides the `reason` with `so.sprk.feed.defs#reasonPin`. Used 90 - * only in author feeds. 91 - */ 92 - authorPinned?: boolean; 93 98 }; 94 99 95 100 export class FeedHydrator { ··· 322 327 ); 323 328 return acc.set(uri, record ?? null); 324 329 }, new HydrationMap<Repost>()); 330 + } 331 + 332 + async getKnownInteractions( 333 + refs: ItemRef[], 334 + viewer: string | null, 335 + ): Promise<KnownInteractionsStates> { 336 + if (!viewer || !refs.length) { 337 + return new HydrationMap<KnownInteractionState[] | undefined>(); 338 + } 339 + 340 + const subjectUris = refs.map((ref) => ref.uri); 341 + const { results } = await this.dataplane.interactions.getKnownInteractions( 342 + viewer, 343 + subjectUris, 344 + ); 345 + 346 + return refs.reduce((acc, { uri }) => { 347 + const interactions = results.get(uri); 348 + return acc.set( 349 + uri, 350 + interactions && interactions.length > 0 351 + ? interactions.map((i) => ({ 352 + type: i.type, 353 + by: i.authorDid, 354 + uri: i.uri, 355 + cid: i.cid, 356 + indexedAt: new Date(i.indexedAt), 357 + text: i.text, 358 + })) 359 + : undefined, 360 + ); 361 + }, new HydrationMap<KnownInteractionState[] | undefined>()); 325 362 } 326 363 }
+44 -24
hydration/index.ts
··· 1 1 import { assert } from "@std/assert"; 2 - import { mapDefined } from "@atp/common"; 3 2 import { AtUri } from "@atp/syntax"; 4 3 import { DataPlane } from "../data-plane/index.ts"; 5 4 import { ids } from "../lex/lexicons.ts"; ··· 19 18 FeedGenViewerStates, 20 19 FeedHydrator, 21 20 FeedItem, 21 + KnownInteractionsStates, 22 22 Likes, 23 23 Post, 24 24 PostAggs, ··· 115 115 labelerViewers?: LabelerViewerStates; 116 116 labelerAggs?: LabelerAggs; 117 117 knownFollowers?: KnownFollowersStates; 118 + knownInteractions?: KnownInteractionsStates; 118 119 activitySubscriptions?: ActivitySubscriptionStates; 119 120 bidirectionalBlocks?: BidirectionalBlocks; 120 121 }; ··· 362 363 } 363 364 } 364 365 366 + // Fetch known interactions first so we can batch all profile hydration 367 + const knownInteractions = await this.feed.getKnownInteractions( 368 + refs, 369 + ctx.viewer, 370 + ); 371 + 372 + // Gather DIDs from known interactions for profile hydration 373 + const knownInteractionDids = new Set<string>(); 374 + for (const interactions of knownInteractions.values()) { 375 + if (interactions) { 376 + for (const interaction of interactions) { 377 + knownInteractionDids.add(interaction.by); 378 + } 379 + } 380 + } 381 + 382 + // Combine all DIDs for a single batched profile hydration call 383 + const allProfileDids = Array.from( 384 + new Set([...authorDids, ...knownInteractionDids]), 385 + ); 386 + 387 + // Build map for bidirectional block checking between post authors and interactors 388 + const subjectsToInteractorsMap = new Map<string, string[]>(); 389 + for (const [uri, interactions] of knownInteractions) { 390 + if (interactions && interactions.length > 0) { 391 + subjectsToInteractorsMap.set( 392 + didFromUri(uri), 393 + interactions.map((i) => i.by), 394 + ); 395 + } 396 + } 397 + 365 398 const [ 366 399 postAggs, 367 400 replyAggs, ··· 371 404 profileState, 372 405 threadContexts, 373 406 soundState, 407 + interactionBlocks, 374 408 ] = await Promise.all([ 375 409 this.feed.getPostAggregates(postRefs), 376 410 this.feed.getReplyAggregates(replyRefs), ··· 379 413 : Promise.resolve<PostViewerStates | undefined>(undefined), 380 414 this.label.getLabelsForSubjects(allUris, ctx.labelers), 381 415 this.hydratePostBlocks(state.posts!, state.replies!), 382 - this.hydrateProfiles(authorDids, ctx), 416 + this.hydrateProfiles(allProfileDids, ctx), 383 417 this.feed.getThreadContexts(threadRefs), 384 418 this.hydrateSounds(Array.from(soundUris), ctx), 419 + this.hydrateBidirectionalBlocks(subjectsToInteractorsMap), 385 420 ]); 386 421 387 422 return mergeManyStates( ··· 396 431 postBlocks, 397 432 labels, 398 433 threadContexts, 434 + knownInteractions, 399 435 ctx, 436 + bidirectionalBlocks: interactionBlocks, 400 437 }, 401 438 ); 402 439 } ··· 504 541 }); 505 542 506 543 const postAndReplyRefs = Array.from(postAndReplyRefsMap.values()); 507 - const repostUris = mapDefined(items, (item) => item.repost?.uri); 508 544 509 545 const postState = await this.hydratePosts(postAndReplyRefs, ctx, { 510 546 posts, 511 547 replies, 512 548 }); 513 549 514 - const replyParentAuthors = Array.from( 515 - new Set( 516 - replyRefs 517 - .map((ref) => 518 - postState.replies?.get(ref.uri)?.record.reply?.parent.uri 519 - ) 520 - .filter((uri): uri is string => !!uri) 521 - .map(didFromUri), 522 - ), 523 - ); 524 - 525 - const [repostProfileState, reposts] = await Promise.all([ 526 - this.hydrateProfiles( 527 - [...repostUris.map(didFromUri), ...replyParentAuthors], 528 - ctx, 529 - ), 530 - this.feed.getReposts(repostUris, ctx.includeTakedowns), 531 - ]); 532 - 533 - return mergeManyStates(postState, repostProfileState, { 534 - reposts, 550 + return mergeStates(postState, { 535 551 ctx, 536 552 }); 537 553 } ··· 945 961 labelerViewers: mergeMaps(stateA.labelerViewers, stateB.labelerViewers), 946 962 labelerAggs: mergeMaps(stateA.labelerAggs, stateB.labelerAggs), 947 963 knownFollowers: mergeMaps(stateA.knownFollowers, stateB.knownFollowers), 964 + knownInteractions: mergeMaps( 965 + stateA.knownInteractions, 966 + stateB.knownInteractions, 967 + ), 948 968 bidirectionalBlocks: mergeMaps( 949 969 stateA.bidirectionalBlocks, 950 970 stateB.bidirectionalBlocks,
+112 -29
lex/lexicons.ts
··· 17354 17354 }, 17355 17355 "viewer": { 17356 17356 "type": "ref", 17357 - "ref": "lex:so.sprk.feed.defs#viewerState", 17357 + "ref": "lex:so.sprk.feed.defs#viewerStateBasic", 17358 17358 }, 17359 17359 "labels": { 17360 17360 "type": "array", ··· 17365 17365 }, 17366 17366 }, 17367 17367 }, 17368 + "viewerStateBasic": { 17369 + "type": "object", 17370 + "description": 17371 + "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", 17372 + "properties": { 17373 + "like": { 17374 + "type": "string", 17375 + "format": "at-uri", 17376 + }, 17377 + "threadMuted": { 17378 + "type": "boolean", 17379 + }, 17380 + "replyDisabled": { 17381 + "type": "boolean", 17382 + }, 17383 + "embeddingDisabled": { 17384 + "type": "boolean", 17385 + }, 17386 + }, 17387 + }, 17368 17388 "viewerState": { 17369 17389 "type": "object", 17370 17390 "description": ··· 17390 17410 "pinned": { 17391 17411 "type": "boolean", 17392 17412 }, 17413 + "knownInteractions": { 17414 + "type": "array", 17415 + "items": { 17416 + "type": "union", 17417 + "refs": [ 17418 + "lex:so.sprk.feed.defs#knownRepost", 17419 + "lex:so.sprk.feed.defs#knownLike", 17420 + "lex:so.sprk.feed.defs#knownReply", 17421 + ], 17422 + }, 17423 + }, 17424 + }, 17425 + }, 17426 + "knownRepost": { 17427 + "type": "object", 17428 + "required": [ 17429 + "by", 17430 + "indexedAt", 17431 + ], 17432 + "properties": { 17433 + "by": { 17434 + "type": "ref", 17435 + "ref": "lex:so.sprk.actor.defs#profileViewBasic", 17436 + }, 17437 + "uri": { 17438 + "type": "string", 17439 + "format": "at-uri", 17440 + }, 17441 + "cid": { 17442 + "type": "string", 17443 + "format": "cid", 17444 + }, 17445 + "indexedAt": { 17446 + "type": "string", 17447 + "format": "datetime", 17448 + }, 17449 + }, 17450 + }, 17451 + "knownLike": { 17452 + "type": "object", 17453 + "required": [ 17454 + "by", 17455 + "indexedAt", 17456 + ], 17457 + "properties": { 17458 + "by": { 17459 + "type": "ref", 17460 + "ref": "lex:so.sprk.actor.defs#profileViewBasic", 17461 + }, 17462 + "uri": { 17463 + "type": "string", 17464 + "format": "at-uri", 17465 + }, 17466 + "cid": { 17467 + "type": "string", 17468 + "format": "cid", 17469 + }, 17470 + "indexedAt": { 17471 + "type": "string", 17472 + "format": "datetime", 17473 + }, 17474 + }, 17475 + }, 17476 + "knownReply": { 17477 + "type": "object", 17478 + "required": [ 17479 + "by", 17480 + "indexedAt", 17481 + ], 17482 + "properties": { 17483 + "by": { 17484 + "type": "ref", 17485 + "ref": "lex:so.sprk.actor.defs#profileViewBasic", 17486 + }, 17487 + "uri": { 17488 + "type": "string", 17489 + "format": "at-uri", 17490 + }, 17491 + "cid": { 17492 + "type": "string", 17493 + "format": "cid", 17494 + }, 17495 + "indexedAt": { 17496 + "type": "string", 17497 + "format": "datetime", 17498 + }, 17499 + "text": { 17500 + "type": "string", 17501 + "maxLength": 3000, 17502 + "maxGraphemes": 300, 17503 + }, 17393 17504 }, 17394 17505 }, 17395 17506 "threadContext": { ··· 17412 17523 "post": { 17413 17524 "type": "ref", 17414 17525 "ref": "lex:so.sprk.feed.defs#postView", 17415 - }, 17416 - "reason": { 17417 - "type": "union", 17418 - "refs": [ 17419 - "lex:so.sprk.feed.defs#reasonRepost", 17420 - "lex:so.sprk.feed.defs#reasonPin", 17421 - ], 17422 17526 }, 17423 17527 "feedContext": { 17424 17528 "type": "string", ··· 17459 17563 "When parent is a reply to another post, this is the author of that post.", 17460 17564 }, 17461 17565 }, 17462 - }, 17463 - "reasonRepost": { 17464 - "type": "object", 17465 - "required": [ 17466 - "by", 17467 - "indexedAt", 17468 - ], 17469 - "properties": { 17470 - "by": { 17471 - "type": "ref", 17472 - "ref": "lex:so.sprk.actor.defs#profileViewBasic", 17473 - }, 17474 - "indexedAt": { 17475 - "type": "string", 17476 - "format": "datetime", 17477 - }, 17478 - }, 17479 - }, 17480 - "reasonPin": { 17481 - "type": "object", 17482 - "properties": {}, 17483 17566 }, 17484 17567 "threadViewPost": { 17485 17568 "type": "object",
+79 -32
lex/types/so/sprk/feed/defs.ts
··· 53 53 replyCount?: number; 54 54 likeCount?: number; 55 55 indexedAt: string; 56 - viewer?: ViewerState; 56 + viewer?: ViewerStateBasic; 57 57 labels?: (ComAtprotoLabelDefs.Label)[]; 58 58 } 59 59 ··· 68 68 } 69 69 70 70 /** Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests. */ 71 + export interface ViewerStateBasic { 72 + $type?: "so.sprk.feed.defs#viewerStateBasic"; 73 + like?: string; 74 + threadMuted?: boolean; 75 + replyDisabled?: boolean; 76 + embeddingDisabled?: boolean; 77 + } 78 + 79 + const hashViewerStateBasic = "viewerStateBasic"; 80 + 81 + export function isViewerStateBasic<V>(v: V) { 82 + return is$typed(v, id, hashViewerStateBasic); 83 + } 84 + 85 + export function validateViewerStateBasic<V>(v: V) { 86 + return validate<ViewerStateBasic & V>(v, id, hashViewerStateBasic); 87 + } 88 + 89 + /** Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests. */ 71 90 export interface ViewerState { 72 91 $type?: "so.sprk.feed.defs#viewerState"; 73 92 repost?: string; ··· 76 95 replyDisabled?: boolean; 77 96 embeddingDisabled?: boolean; 78 97 pinned?: boolean; 98 + knownInteractions?: 99 + ($Typed<KnownRepost> | $Typed<KnownLike> | $Typed<KnownReply> | { 100 + $type: string; 101 + })[]; 79 102 } 80 103 81 104 const hashViewerState = "viewerState"; ··· 88 111 return validate<ViewerState & V>(v, id, hashViewerState); 89 112 } 90 113 114 + export interface KnownRepost { 115 + $type?: "so.sprk.feed.defs#knownRepost"; 116 + by: SoSprkActorDefs.ProfileViewBasic; 117 + uri?: string; 118 + cid?: string; 119 + indexedAt: string; 120 + } 121 + 122 + const hashKnownRepost = "knownRepost"; 123 + 124 + export function isKnownRepost<V>(v: V) { 125 + return is$typed(v, id, hashKnownRepost); 126 + } 127 + 128 + export function validateKnownRepost<V>(v: V) { 129 + return validate<KnownRepost & V>(v, id, hashKnownRepost); 130 + } 131 + 132 + export interface KnownLike { 133 + $type?: "so.sprk.feed.defs#knownLike"; 134 + by: SoSprkActorDefs.ProfileViewBasic; 135 + uri?: string; 136 + cid?: string; 137 + indexedAt: string; 138 + } 139 + 140 + const hashKnownLike = "knownLike"; 141 + 142 + export function isKnownLike<V>(v: V) { 143 + return is$typed(v, id, hashKnownLike); 144 + } 145 + 146 + export function validateKnownLike<V>(v: V) { 147 + return validate<KnownLike & V>(v, id, hashKnownLike); 148 + } 149 + 150 + export interface KnownReply { 151 + $type?: "so.sprk.feed.defs#knownReply"; 152 + by: SoSprkActorDefs.ProfileViewBasic; 153 + uri?: string; 154 + cid?: string; 155 + indexedAt: string; 156 + text?: string; 157 + } 158 + 159 + const hashKnownReply = "knownReply"; 160 + 161 + export function isKnownReply<V>(v: V) { 162 + return is$typed(v, id, hashKnownReply); 163 + } 164 + 165 + export function validateKnownReply<V>(v: V) { 166 + return validate<KnownReply & V>(v, id, hashKnownReply); 167 + } 168 + 91 169 /** Metadata about this post within the context of the thread it is in. */ 92 170 export interface ThreadContext { 93 171 $type?: "so.sprk.feed.defs#threadContext"; ··· 107 185 export interface FeedViewPost { 108 186 $type?: "so.sprk.feed.defs#feedViewPost"; 109 187 post: PostView; 110 - reason?: $Typed<ReasonRepost> | $Typed<ReasonPin> | { $type: string }; 111 188 /** Context provided by feed generator that may be passed back alongside interactions. */ 112 189 feedContext?: string; 113 190 } ··· 144 221 145 222 export function validateReplyRef<V>(v: V) { 146 223 return validate<ReplyRef & V>(v, id, hashReplyRef); 147 - } 148 - 149 - export interface ReasonRepost { 150 - $type?: "so.sprk.feed.defs#reasonRepost"; 151 - by: SoSprkActorDefs.ProfileViewBasic; 152 - indexedAt: string; 153 - } 154 - 155 - const hashReasonRepost = "reasonRepost"; 156 - 157 - export function isReasonRepost<V>(v: V) { 158 - return is$typed(v, id, hashReasonRepost); 159 - } 160 - 161 - export function validateReasonRepost<V>(v: V) { 162 - return validate<ReasonRepost & V>(v, id, hashReasonRepost); 163 - } 164 - 165 - export interface ReasonPin { 166 - $type?: "so.sprk.feed.defs#reasonPin"; 167 - } 168 - 169 - const hashReasonPin = "reasonPin"; 170 - 171 - export function isReasonPin<V>(v: V) { 172 - return is$typed(v, id, hashReasonPin); 173 - } 174 - 175 - export function validateReasonPin<V>(v: V) { 176 - return validate<ReasonPin & V>(v, id, hashReasonPin); 177 224 } 178 225 179 226 export interface ThreadViewPost {
+51 -35
lexicons/so/sprk/feed/defs.json
··· 48 48 "replyCount": { "type": "integer" }, 49 49 "likeCount": { "type": "integer" }, 50 50 "indexedAt": { "type": "string", "format": "datetime" }, 51 - "viewer": { "type": "ref", "ref": "#viewerState" }, 51 + "viewer": { "type": "ref", "ref": "#viewerStateBasic" }, 52 52 "labels": { 53 53 "type": "array", 54 54 "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } 55 55 } 56 56 } 57 57 }, 58 + "viewerStateBasic": { 59 + "type": "object", 60 + "description": "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", 61 + "properties": { 62 + "like": { "type": "string", "format": "at-uri" }, 63 + "threadMuted": { "type": "boolean" }, 64 + "replyDisabled": { "type": "boolean" }, 65 + "embeddingDisabled": { "type": "boolean" } 66 + } 67 + }, 58 68 "viewerState": { 59 69 "type": "object", 60 70 "description": "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", ··· 64 74 "threadMuted": { "type": "boolean" }, 65 75 "replyDisabled": { "type": "boolean" }, 66 76 "embeddingDisabled": { "type": "boolean" }, 67 - "pinned": { "type": "boolean" } 77 + "pinned": { "type": "boolean" }, 78 + "knownInteractions": { 79 + "type": "array", 80 + "items": { 81 + "type": "union", 82 + "refs": ["#knownRepost", "#knownLike", "#knownReply"] 83 + } 84 + } 85 + } 86 + }, 87 + "knownRepost": { 88 + "type": "object", 89 + "required": ["by", "indexedAt"], 90 + "properties": { 91 + "by": { "type": "ref", "ref": "so.sprk.actor.defs#profileViewBasic" }, 92 + "uri": { "type": "string", "format": "at-uri" }, 93 + "cid": { "type": "string", "format": "cid" }, 94 + "indexedAt": { "type": "string", "format": "datetime" } 95 + } 96 + }, 97 + "knownLike": { 98 + "type": "object", 99 + "required": ["by", "indexedAt"], 100 + "properties": { 101 + "by": { "type": "ref", "ref": "so.sprk.actor.defs#profileViewBasic" }, 102 + "uri": { "type": "string", "format": "at-uri" }, 103 + "cid": { "type": "string", "format": "cid" }, 104 + "indexedAt": { "type": "string", "format": "datetime" } 105 + } 106 + }, 107 + "knownReply": { 108 + "type": "object", 109 + "required": ["by", "indexedAt"], 110 + "properties": { 111 + "by": { "type": "ref", "ref": "so.sprk.actor.defs#profileViewBasic" }, 112 + "uri": { "type": "string", "format": "at-uri" }, 113 + "cid": { "type": "string", "format": "cid" }, 114 + "indexedAt": { "type": "string", "format": "datetime" }, 115 + "text": { "type": "string", "maxLength": 3000, "maxGraphemes": 300 } 68 116 } 69 117 }, 70 118 "threadContext": { ··· 79 127 "required": ["post"], 80 128 "properties": { 81 129 "post": { "type": "ref", "ref": "#postView" }, 82 - "reason": { "type": "union", "refs": ["#reasonRepost", "#reasonPin"] }, 83 130 "feedContext": { 84 131 "type": "string", 85 132 "description": "Context provided by feed generator that may be passed back alongside interactions.", ··· 105 152 "description": "When parent is a reply to another post, this is the author of that post." 106 153 } 107 154 } 108 - }, 109 - "reasonRepost": { 110 - "type": "object", 111 - "required": ["by", "indexedAt"], 112 - "properties": { 113 - "by": { "type": "ref", "ref": "so.sprk.actor.defs#profileViewBasic" }, 114 - "indexedAt": { "type": "string", "format": "datetime" } 115 - } 116 - }, 117 - "reasonPin": { 118 - "type": "object", 119 - "properties": {} 120 155 }, 121 156 "threadViewPost": { 122 157 "type": "object", ··· 125 160 "post": { "type": "union", "refs": ["#postView", "#replyView"] }, 126 161 "parent": { 127 162 "type": "union", 128 - "refs": [ 129 - "#threadViewPost", 130 - "#notFoundPost", 131 - "#blockedPost" 132 - ] 163 + "refs": ["#threadViewPost", "#notFoundPost", "#blockedPost"] 133 164 }, 134 165 "replies": { 135 166 "type": "array", ··· 206 237 "required": ["post"], 207 238 "properties": { 208 239 "post": { "type": "string", "format": "at-uri" }, 209 - "reason": { 210 - "type": "union", 211 - "refs": ["#skeletonReasonRepost", "#skeletonReasonPin"] 212 - }, 213 240 "feedContext": { 214 241 "type": "string", 215 242 "description": "Context that will be passed through to client and may be passed to feed generator back alongside interactions.", 216 243 "maxLength": 2000 217 244 } 218 245 } 219 - }, 220 - "skeletonReasonRepost": { 221 - "type": "object", 222 - "required": ["repost"], 223 - "properties": { 224 - "repost": { "type": "string", "format": "at-uri" } 225 - } 226 - }, 227 - "skeletonReasonPin": { 228 - "type": "object", 229 - "properties": {} 230 246 }, 231 247 "threadgateView": { 232 248 "type": "object",
+56 -41
views/index.ts
··· 15 15 FeedViewPost, 16 16 isPostView, 17 17 isReplyView, 18 + KnownLike, 19 + KnownReply, 20 + KnownRepost, 18 21 PostView, 19 - ReasonPin, 20 - ReasonRepost, 21 22 ReplyRef, 22 23 ReplyView, 23 24 ThreadContext, ··· 65 66 import { cidFromBlobJson } from "./util.ts"; 66 67 import { uriToDid } from "../utils/uris.ts"; 67 68 import { mapDefined } from "@atp/common"; 68 - import { FeedItem, Repost } from "../hydration/feed.ts"; 69 + import { FeedItem } from "../hydration/feed.ts"; 69 70 import { 70 71 QueryParams as GetThreadQueryParams, 71 72 ThreadItem, ··· 360 361 ? { 361 362 repost: viewer.repost, 362 363 like: viewer.like, 364 + knownInteractions: this.knownInteractions(uri, state), 363 365 } 364 366 : undefined, 365 367 }; ··· 395 397 new Date().toISOString(), 396 398 viewer: viewer 397 399 ? { 398 - repost: viewer.repost, 399 400 like: viewer.like, 400 401 } 401 402 : undefined, ··· 502 503 item: FeedItem, 503 504 state: HydrationState, 504 505 ): FeedViewPost | undefined { 505 - let reason; 506 - if (item.authorPinned) { 507 - reason = this.reasonPin(); 508 - } else if (item.repost) { 509 - const repost = state.reposts?.get(item.repost.uri); 510 - if (!repost || !repost?.record.subject) return; 511 - if (repost.record.subject.uri !== item.post.uri) return; 512 - reason = this.reasonRepost(item.repost.uri, repost, state); 513 - if (!reason) return; 514 - } 515 506 const post = this.post(item.post.uri, state); 516 507 if (!post) return; 517 508 return { 518 509 post, 519 - reason, 520 510 }; 521 511 } 522 512 ··· 569 559 }; 570 560 } 571 561 572 - reasonPin(): $Typed<ReasonPin> { 573 - return { 574 - $type: "so.sprk.feed.defs#reasonPin", 575 - }; 576 - } 577 - 578 - reasonRepost( 579 - uri: string, 580 - repost: Repost, 581 - state: HydrationState, 582 - ): $Typed<ReasonRepost> | undefined { 583 - const creatorDid = uriToDid(uri); 584 - const creator = this.profileBasic(creatorDid, state); 585 - if (!creator) return; 586 - return { 587 - $type: "so.sprk.feed.defs#reasonRepost", 588 - by: creator, 589 - indexedAt: this.indexedAt(repost).toISOString(), 590 - }; 591 - } 592 - 593 562 maybePost(uri: string, state: HydrationState): $Typed<MaybePostView> { 594 563 const reply = this.reply(uri, state); 595 564 if (reply) { ··· 637 606 item: FeedItem, 638 607 state: HydrationState, 639 608 ): { 640 - originatorMuted: boolean; 641 - originatorBlocked: boolean; 642 609 authorMuted: boolean; 643 610 authorBlocked: boolean; 644 611 } { 645 612 const authorDid = uriToDid(item.post.uri); 646 - const originatorDid = item.repost ? uriToDid(item.repost.uri) : authorDid; 647 613 648 614 return { 649 - originatorMuted: this.viewerMuteExists(originatorDid, state), 650 - originatorBlocked: this.viewerBlockExists(originatorDid, state), 651 615 authorMuted: this.viewerMuteExists(authorDid, state), 652 616 authorBlocked: this.viewerBlockExists(authorDid, state), 653 617 }; ··· 814 778 return this.profileBasic(followerDid, state); 815 779 }); 816 780 return { count: knownFollowers.count, followers }; 781 + } 782 + 783 + knownInteractions( 784 + uri: string, 785 + state: HydrationState, 786 + ): 787 + | ($Typed<KnownRepost> | $Typed<KnownLike> | $Typed<KnownReply>)[] 788 + | undefined { 789 + const interactions = state.knownInteractions?.get(uri); 790 + if (!interactions || interactions.length === 0) return undefined; 791 + 792 + const postAuthorDid = uriToDid(uri); 793 + const blocks = state.bidirectionalBlocks?.get(postAuthorDid); 794 + 795 + const result = mapDefined(interactions, (interaction) => { 796 + // Filter blocked users 797 + if (this.viewerBlockExists(interaction.by, state)) return undefined; 798 + if (blocks?.get(interaction.by)) return undefined; 799 + if (this.actorIsNoHosted(interaction.by, state)) return undefined; 800 + 801 + const by = this.profileBasic(interaction.by, state); 802 + if (!by) return undefined; 803 + 804 + const base = { 805 + by, 806 + uri: interaction.uri, 807 + cid: interaction.cid, 808 + indexedAt: interaction.indexedAt.toISOString(), 809 + }; 810 + 811 + switch (interaction.type) { 812 + case "like": 813 + return { 814 + $type: "so.sprk.feed.defs#knownLike" as const, 815 + ...base, 816 + }; 817 + case "repost": 818 + return { 819 + $type: "so.sprk.feed.defs#knownRepost" as const, 820 + ...base, 821 + }; 822 + case "reply": 823 + return { 824 + $type: "so.sprk.feed.defs#knownReply" as const, 825 + ...base, 826 + text: interaction.text, 827 + }; 828 + } 829 + }); 830 + 831 + return result.length > 0 ? result : undefined; 817 832 } 818 833 819 834 media(