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

reasons, plural

+340 -108
+3 -62
api/so/sprk/feed/getAuthorFeed.ts
··· 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 } 97 + reposts: item.repost 98 + ? [{ uri: item.repost, cid: item.repostCid || undefined }] 99 99 : undefined, 100 100 })); 101 101 ··· 162 162 ); 163 163 }; 164 164 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 - } 165 + skeleton.items = skeleton.items.filter(checkBlocksAndMutes); 178 166 179 167 return skeleton; 180 168 }; ··· 208 196 filter: QueryParams["filter"]; 209 197 cursor?: string; 210 198 }; 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 - }
+15 -8
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"; 19 + import { 20 + isSkeletonReasonRepost, 21 + SkeletonReasonRepost, 22 + } from "../../../../lex/types/so/sprk/feed/defs.ts"; 20 23 import { QueryParams as GetFeedParams } from "../../../../lex/types/so/sprk/feed/getFeed.ts"; 21 24 import { OutputSchema as SkeletonOutput } from "../../../../lex/types/so/sprk/feed/getFeedSkeleton.ts"; 22 25 import { ··· 261 264 } 262 265 263 266 const { feed: feedSkele, ...skele } = skeleton; 264 - const feedItems = feedSkele.slice(0, params.limit).map((item) => ({ 265 - post: { uri: item.post }, 266 - repost: isSkeletonReasonRepost(item.reason) 267 - ? { uri: item.reason.repost } 268 - : undefined, 269 - feedContext: item.feedContext, 270 - })); 267 + const feedItems = feedSkele.slice(0, params.limit).map((item) => { 268 + const reposts = item.reasons 269 + ?.filter(isSkeletonReasonRepost) 270 + .map((reason) => ({ uri: (reason as SkeletonReasonRepost).repost })); 271 + 272 + return { 273 + post: { uri: item.post }, 274 + reposts: reposts && reposts.length > 0 ? reposts : undefined, 275 + feedContext: item.feedContext, 276 + }; 277 + }); 271 278 272 279 return { ...skele, resHeaders, feedItems }; 273 280 };
+2 -2
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 } 69 + reposts: item.repost 70 + ? [{ uri: item.repost, cid: item.repostCid || undefined }] 71 71 : undefined, 72 72 })), 73 73 cursor: parseString(res.cursor),
+4 -2
hydration/feed.ts
··· 84 84 // technically have additional fields, not supported by the mock dataplane. 85 85 export type FeedItem = { 86 86 post: ItemRef; 87 - repost?: ItemRef; 87 + reposts?: ItemRef[]; 88 + likes?: ItemRef[]; 89 + replies?: ItemRef[]; 88 90 /** 89 - * If true, overrides the `reason` with `so.sprk.feed.defs#reasonPin`. Used 91 + * If true, adds `so.sprk.feed.defs#reasonPin` to reasons. Used 90 92 * only in author feeds. 91 93 */ 92 94 authorPinned?: boolean;
+3 -1
hydration/index.ts
··· 504 504 }); 505 505 506 506 const postAndReplyRefs = Array.from(postAndReplyRefsMap.values()); 507 - const repostUris = mapDefined(items, (item) => item.repost?.uri); 507 + const repostUris = items.flatMap((item) => 508 + item.reposts?.map((repost) => repost.uri) ?? [] 509 + ); 508 510 509 511 const postState = await this.hydratePosts(postAndReplyRefs, ctx, { 510 512 posts,
+86 -12
lex/lexicons.ts
··· 17413 17413 "type": "ref", 17414 17414 "ref": "lex:so.sprk.feed.defs#postView", 17415 17415 }, 17416 - "reason": { 17417 - "type": "union", 17418 - "refs": [ 17419 - "lex:so.sprk.feed.defs#reasonRepost", 17420 - "lex:so.sprk.feed.defs#reasonPin", 17421 - ], 17416 + "reasons": { 17417 + "type": "array", 17418 + "description": 17419 + "Reasons/context signals for why this item is in the feed.", 17420 + "maxLength": 5, 17421 + "items": { 17422 + "type": "union", 17423 + "refs": [ 17424 + "lex:so.sprk.feed.defs#reasonRepost", 17425 + "lex:so.sprk.feed.defs#reasonPin", 17426 + "lex:so.sprk.feed.defs#reasonLike", 17427 + "lex:so.sprk.feed.defs#reasonReply", 17428 + ], 17429 + }, 17422 17430 }, 17423 17431 "feedContext": { 17424 17432 "type": "string", ··· 17480 17488 "reasonPin": { 17481 17489 "type": "object", 17482 17490 "properties": {}, 17491 + }, 17492 + "reasonLike": { 17493 + "type": "object", 17494 + "required": [ 17495 + "by", 17496 + "indexedAt", 17497 + ], 17498 + "properties": { 17499 + "by": { 17500 + "type": "ref", 17501 + "ref": "lex:so.sprk.actor.defs#profileViewBasic", 17502 + }, 17503 + "indexedAt": { 17504 + "type": "string", 17505 + "format": "datetime", 17506 + }, 17507 + }, 17508 + }, 17509 + "reasonReply": { 17510 + "type": "object", 17511 + "required": [ 17512 + "by", 17513 + "indexedAt", 17514 + ], 17515 + "properties": { 17516 + "by": { 17517 + "type": "ref", 17518 + "ref": "lex:so.sprk.actor.defs#profileViewBasic", 17519 + }, 17520 + "indexedAt": { 17521 + "type": "string", 17522 + "format": "datetime", 17523 + }, 17524 + }, 17483 17525 }, 17484 17526 "threadViewPost": { 17485 17527 "type": "object", ··· 17663 17705 "type": "string", 17664 17706 "format": "at-uri", 17665 17707 }, 17666 - "reason": { 17667 - "type": "union", 17668 - "refs": [ 17669 - "lex:so.sprk.feed.defs#skeletonReasonRepost", 17670 - "lex:so.sprk.feed.defs#skeletonReasonPin", 17671 - ], 17708 + "reasons": { 17709 + "type": "array", 17710 + "description": 17711 + "Reasons/context signals for why this item is in the feed.", 17712 + "maxLength": 5, 17713 + "items": { 17714 + "type": "union", 17715 + "refs": [ 17716 + "lex:so.sprk.feed.defs#skeletonReasonRepost", 17717 + "lex:so.sprk.feed.defs#skeletonReasonPin", 17718 + "lex:so.sprk.feed.defs#skeletonReasonLike", 17719 + "lex:so.sprk.feed.defs#skeletonReasonReply", 17720 + ], 17721 + }, 17672 17722 }, 17673 17723 "feedContext": { 17674 17724 "type": "string", ··· 17693 17743 "skeletonReasonPin": { 17694 17744 "type": "object", 17695 17745 "properties": {}, 17746 + }, 17747 + "skeletonReasonLike": { 17748 + "type": "object", 17749 + "required": [ 17750 + "like", 17751 + ], 17752 + "properties": { 17753 + "like": { 17754 + "type": "string", 17755 + "format": "at-uri", 17756 + }, 17757 + }, 17758 + }, 17759 + "skeletonReasonReply": { 17760 + "type": "object", 17761 + "required": [ 17762 + "reply", 17763 + ], 17764 + "properties": { 17765 + "reply": { 17766 + "type": "string", 17767 + "format": "at-uri", 17768 + }, 17769 + }, 17696 17770 }, 17697 17771 "threadgateView": { 17698 17772 "type": "object",
+78 -4
lex/types/so/sprk/feed/defs.ts
··· 107 107 export interface FeedViewPost { 108 108 $type?: "so.sprk.feed.defs#feedViewPost"; 109 109 post: PostView; 110 - reason?: $Typed<ReasonRepost> | $Typed<ReasonPin> | { $type: string }; 110 + /** Reasons/context signals for why this item is in the feed. */ 111 + reasons?: ( 112 + | $Typed<ReasonRepost> 113 + | $Typed<ReasonPin> 114 + | $Typed<ReasonLike> 115 + | $Typed<ReasonReply> 116 + | { $type: string } 117 + )[]; 111 118 /** Context provided by feed generator that may be passed back alongside interactions. */ 112 119 feedContext?: string; 113 120 } ··· 174 181 175 182 export function validateReasonPin<V>(v: V) { 176 183 return validate<ReasonPin & V>(v, id, hashReasonPin); 184 + } 185 + 186 + export interface ReasonLike { 187 + $type?: "so.sprk.feed.defs#reasonLike"; 188 + by: SoSprkActorDefs.ProfileViewBasic; 189 + indexedAt: string; 190 + } 191 + 192 + const hashReasonLike = "reasonLike"; 193 + 194 + export function isReasonLike<V>(v: V) { 195 + return is$typed(v, id, hashReasonLike); 196 + } 197 + 198 + export function validateReasonLike<V>(v: V) { 199 + return validate<ReasonLike & V>(v, id, hashReasonLike); 200 + } 201 + 202 + export interface ReasonReply { 203 + $type?: "so.sprk.feed.defs#reasonReply"; 204 + by: SoSprkActorDefs.ProfileViewBasic; 205 + indexedAt: string; 206 + } 207 + 208 + const hashReasonReply = "reasonReply"; 209 + 210 + export function isReasonReply<V>(v: V) { 211 + return is$typed(v, id, hashReasonReply); 212 + } 213 + 214 + export function validateReasonReply<V>(v: V) { 215 + return validate<ReasonReply & V>(v, id, hashReasonReply); 177 216 } 178 217 179 218 export interface ThreadViewPost { ··· 295 334 export interface SkeletonFeedPost { 296 335 $type?: "so.sprk.feed.defs#skeletonFeedPost"; 297 336 post: string; 298 - reason?: $Typed<SkeletonReasonRepost> | $Typed<SkeletonReasonPin> | { 299 - $type: string; 300 - }; 337 + /** Reasons/context signals for why this item is in the feed. */ 338 + reasons?: ( 339 + | $Typed<SkeletonReasonRepost> 340 + | $Typed<SkeletonReasonPin> 341 + | $Typed<SkeletonReasonLike> 342 + | $Typed<SkeletonReasonReply> 343 + | { $type: string } 344 + )[]; 301 345 /** Context that will be passed through to client and may be passed to feed generator back alongside interactions. */ 302 346 feedContext?: string; 303 347 } ··· 339 383 340 384 export function validateSkeletonReasonPin<V>(v: V) { 341 385 return validate<SkeletonReasonPin & V>(v, id, hashSkeletonReasonPin); 386 + } 387 + 388 + export interface SkeletonReasonLike { 389 + $type?: "so.sprk.feed.defs#skeletonReasonLike"; 390 + like: string; 391 + } 392 + 393 + const hashSkeletonReasonLike = "skeletonReasonLike"; 394 + 395 + export function isSkeletonReasonLike<V>(v: V) { 396 + return is$typed(v, id, hashSkeletonReasonLike); 397 + } 398 + 399 + export function validateSkeletonReasonLike<V>(v: V) { 400 + return validate<SkeletonReasonLike & V>(v, id, hashSkeletonReasonLike); 401 + } 402 + 403 + export interface SkeletonReasonReply { 404 + $type?: "so.sprk.feed.defs#skeletonReasonReply"; 405 + reply: string; 406 + } 407 + 408 + const hashSkeletonReasonReply = "skeletonReasonReply"; 409 + 410 + export function isSkeletonReasonReply<V>(v: V) { 411 + return is$typed(v, id, hashSkeletonReasonReply); 412 + } 413 + 414 + export function validateSkeletonReasonReply<V>(v: V) { 415 + return validate<SkeletonReasonReply & V>(v, id, hashSkeletonReasonReply); 342 416 } 343 417 344 418 export interface ThreadgateView {
+57 -4
lexicons/so/sprk/feed/defs.json
··· 79 79 "required": ["post"], 80 80 "properties": { 81 81 "post": { "type": "ref", "ref": "#postView" }, 82 - "reason": { "type": "union", "refs": ["#reasonRepost", "#reasonPin"] }, 82 + "reasons": { 83 + "type": "array", 84 + "description": "Reasons/context signals for why this item is in the feed.", 85 + "maxLength": 5, 86 + "items": { 87 + "type": "union", 88 + "refs": [ 89 + "#reasonRepost", 90 + "#reasonPin", 91 + "#reasonLike", 92 + "#reasonReply" 93 + ] 94 + } 95 + }, 83 96 "feedContext": { 84 97 "type": "string", 85 98 "description": "Context provided by feed generator that may be passed back alongside interactions.", ··· 117 130 "reasonPin": { 118 131 "type": "object", 119 132 "properties": {} 133 + }, 134 + "reasonLike": { 135 + "type": "object", 136 + "required": ["by", "indexedAt"], 137 + "properties": { 138 + "by": { "type": "ref", "ref": "so.sprk.actor.defs#profileViewBasic" }, 139 + "indexedAt": { "type": "string", "format": "datetime" } 140 + } 141 + }, 142 + "reasonReply": { 143 + "type": "object", 144 + "required": ["by", "indexedAt"], 145 + "properties": { 146 + "by": { "type": "ref", "ref": "so.sprk.actor.defs#profileViewBasic" }, 147 + "indexedAt": { "type": "string", "format": "datetime" } 148 + } 120 149 }, 121 150 "threadViewPost": { 122 151 "type": "object", ··· 206 235 "required": ["post"], 207 236 "properties": { 208 237 "post": { "type": "string", "format": "at-uri" }, 209 - "reason": { 210 - "type": "union", 211 - "refs": ["#skeletonReasonRepost", "#skeletonReasonPin"] 238 + "reasons": { 239 + "type": "array", 240 + "description": "Reasons/context signals for why this item is in the feed.", 241 + "maxLength": 5, 242 + "items": { 243 + "type": "union", 244 + "refs": [ 245 + "#skeletonReasonRepost", 246 + "#skeletonReasonPin", 247 + "#skeletonReasonLike", 248 + "#skeletonReasonReply" 249 + ] 250 + } 212 251 }, 213 252 "feedContext": { 214 253 "type": "string", ··· 227 266 "skeletonReasonPin": { 228 267 "type": "object", 229 268 "properties": {} 269 + }, 270 + "skeletonReasonLike": { 271 + "type": "object", 272 + "required": ["like"], 273 + "properties": { 274 + "like": { "type": "string", "format": "at-uri" } 275 + } 276 + }, 277 + "skeletonReasonReply": { 278 + "type": "object", 279 + "required": ["reply"], 280 + "properties": { 281 + "reply": { "type": "string", "format": "at-uri" } 282 + } 230 283 }, 231 284 "threadgateView": { 232 285 "type": "object",
+92 -13
views/index.ts
··· 16 16 isPostView, 17 17 isReplyView, 18 18 PostView, 19 + ReasonLike, 19 20 ReasonPin, 21 + ReasonReply, 20 22 ReasonRepost, 21 23 ReplyRef, 22 24 ReplyView, ··· 65 67 import { cidFromBlobJson } from "./util.ts"; 66 68 import { uriToDid } from "../utils/uris.ts"; 67 69 import { mapDefined } from "@atp/common"; 68 - import { FeedItem, Repost } from "../hydration/feed.ts"; 70 + import { FeedItem, Like, Reply, Repost } from "../hydration/feed.ts"; 69 71 import { 70 72 QueryParams as GetThreadQueryParams, 71 73 ThreadItem, ··· 502 504 item: FeedItem, 503 505 state: HydrationState, 504 506 ): FeedViewPost | undefined { 505 - let reason; 507 + const reasons = []; 508 + 506 509 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; 510 + reasons.push(this.reasonPin()); 511 + } 512 + 513 + if (item.reposts) { 514 + for (const repostRef of item.reposts) { 515 + const repost = state.reposts?.get(repostRef.uri); 516 + if (!repost || !repost.record.subject) continue; 517 + if (repost.record.subject.uri !== item.post.uri) continue; 518 + const reason = this.reasonRepost(repostRef.uri, repost, state); 519 + if (reason) { 520 + reasons.push(reason); 521 + } 522 + } 514 523 } 524 + 525 + if (item.likes) { 526 + for (const likeRef of item.likes) { 527 + const like = state.likes?.get(likeRef.uri); 528 + if (!like || !like.record.subject) continue; 529 + if (like.record.subject.uri !== item.post.uri) continue; 530 + const reason = this.reasonLike(likeRef.uri, like, state); 531 + if (reason) { 532 + reasons.push(reason); 533 + } 534 + } 535 + } 536 + 537 + if (item.replies) { 538 + for (const replyRef of item.replies) { 539 + const reply = state.replies?.get(replyRef.uri); 540 + if (!reply || !reply.record.reply) continue; 541 + if (reply.record.reply.parent.uri !== item.post.uri) continue; 542 + const reason = this.reasonReply(replyRef.uri, reply, state); 543 + if (reason) { 544 + reasons.push(reason); 545 + } 546 + } 547 + } 548 + 515 549 const post = this.post(item.post.uri, state); 516 550 if (!post) return; 517 551 return { 518 552 post, 519 - reason, 553 + reasons: reasons.length > 0 ? reasons : undefined, 520 554 }; 521 555 } 522 556 ··· 590 624 }; 591 625 } 592 626 627 + reasonLike( 628 + uri: string, 629 + like: Like, 630 + state: HydrationState, 631 + ): $Typed<ReasonLike> | undefined { 632 + const creatorDid = uriToDid(uri); 633 + const creator = this.profileBasic(creatorDid, state); 634 + if (!creator) return; 635 + return { 636 + $type: "so.sprk.feed.defs#reasonLike", 637 + by: creator, 638 + indexedAt: this.indexedAt(like).toISOString(), 639 + }; 640 + } 641 + 642 + reasonReply( 643 + uri: string, 644 + reply: Reply, 645 + state: HydrationState, 646 + ): $Typed<ReasonReply> | undefined { 647 + const creatorDid = uriToDid(uri); 648 + const creator = this.profileBasic(creatorDid, state); 649 + if (!creator) return; 650 + return { 651 + $type: "so.sprk.feed.defs#reasonReply", 652 + by: creator, 653 + indexedAt: this.indexedAt(reply).toISOString(), 654 + }; 655 + } 656 + 593 657 maybePost(uri: string, state: HydrationState): $Typed<MaybePostView> { 594 658 const reply = this.reply(uri, state); 595 659 if (reply) { ··· 643 707 authorBlocked: boolean; 644 708 } { 645 709 const authorDid = uriToDid(item.post.uri); 646 - const originatorDid = item.repost ? uriToDid(item.repost.uri) : authorDid; 710 + 711 + // Check if any repost originator is muted or blocked 712 + let originatorMuted = false; 713 + let originatorBlocked = false; 714 + 715 + if (item.reposts && item.reposts.length > 0) { 716 + for (const repost of item.reposts) { 717 + const originatorDid = uriToDid(repost.uri); 718 + if (this.viewerMuteExists(originatorDid, state)) { 719 + originatorMuted = true; 720 + } 721 + if (this.viewerBlockExists(originatorDid, state)) { 722 + originatorBlocked = true; 723 + } 724 + } 725 + } 647 726 648 727 return { 649 - originatorMuted: this.viewerMuteExists(originatorDid, state), 650 - originatorBlocked: this.viewerBlockExists(originatorDid, state), 728 + originatorMuted, 729 + originatorBlocked, 651 730 authorMuted: this.viewerMuteExists(authorDid, state), 652 731 authorBlocked: this.viewerBlockExists(authorDid, state), 653 732 };