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

Revert "reasons, plural"

This reverts commit 935df4594e79eaa2f609199338a8b94f9d34c26d.

+108 -340
+62 -3
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 - reposts: item.repost 98 - ? [{ uri: item.repost, cid: item.repostCid || undefined }] 97 + repost: item.repost 98 + ? { uri: item.repost, cid: item.repostCid || undefined } 99 99 : undefined, 100 100 })); 101 101 ··· 162 162 ); 163 163 }; 164 164 165 - skeleton.items = skeleton.items.filter(checkBlocksAndMutes); 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 + } 166 178 167 179 return skeleton; 168 180 }; ··· 196 208 filter: QueryParams["filter"]; 197 209 cursor?: string; 198 210 }; 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 + }
+8 -15
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 { 20 - isSkeletonReasonRepost, 21 - SkeletonReasonRepost, 22 - } from "../../../../lex/types/so/sprk/feed/defs.ts"; 19 + import { isSkeletonReasonRepost } from "../../../../lex/types/so/sprk/feed/defs.ts"; 23 20 import { QueryParams as GetFeedParams } from "../../../../lex/types/so/sprk/feed/getFeed.ts"; 24 21 import { OutputSchema as SkeletonOutput } from "../../../../lex/types/so/sprk/feed/getFeedSkeleton.ts"; 25 22 import { ··· 264 261 } 265 262 266 263 const { feed: feedSkele, ...skele } = skeleton; 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 - }); 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 + })); 278 271 279 272 return { ...skele, resHeaders, feedItems }; 280 273 };
+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 - reposts: item.repost 70 - ? [{ uri: item.repost, cid: item.repostCid || undefined }] 69 + repost: item.repost 70 + ? { uri: item.repost, cid: item.repostCid || undefined } 71 71 : undefined, 72 72 })), 73 73 cursor: parseString(res.cursor),
+2 -4
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 - reposts?: ItemRef[]; 88 - likes?: ItemRef[]; 89 - replies?: ItemRef[]; 87 + repost?: ItemRef; 90 88 /** 91 - * If true, adds `so.sprk.feed.defs#reasonPin` to reasons. Used 89 + * If true, overrides the `reason` with `so.sprk.feed.defs#reasonPin`. Used 92 90 * only in author feeds. 93 91 */ 94 92 authorPinned?: boolean;
+1 -3
hydration/index.ts
··· 504 504 }); 505 505 506 506 const postAndReplyRefs = Array.from(postAndReplyRefsMap.values()); 507 - const repostUris = items.flatMap((item) => 508 - item.reposts?.map((repost) => repost.uri) ?? [] 509 - ); 507 + const repostUris = mapDefined(items, (item) => item.repost?.uri); 510 508 511 509 const postState = await this.hydratePosts(postAndReplyRefs, ctx, { 512 510 posts,
+12 -86
lex/lexicons.ts
··· 17413 17413 "type": "ref", 17414 17414 "ref": "lex:so.sprk.feed.defs#postView", 17415 17415 }, 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 - }, 17416 + "reason": { 17417 + "type": "union", 17418 + "refs": [ 17419 + "lex:so.sprk.feed.defs#reasonRepost", 17420 + "lex:so.sprk.feed.defs#reasonPin", 17421 + ], 17430 17422 }, 17431 17423 "feedContext": { 17432 17424 "type": "string", ··· 17488 17480 "reasonPin": { 17489 17481 "type": "object", 17490 17482 "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 - }, 17525 17483 }, 17526 17484 "threadViewPost": { 17527 17485 "type": "object", ··· 17705 17663 "type": "string", 17706 17664 "format": "at-uri", 17707 17665 }, 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 - }, 17666 + "reason": { 17667 + "type": "union", 17668 + "refs": [ 17669 + "lex:so.sprk.feed.defs#skeletonReasonRepost", 17670 + "lex:so.sprk.feed.defs#skeletonReasonPin", 17671 + ], 17722 17672 }, 17723 17673 "feedContext": { 17724 17674 "type": "string", ··· 17743 17693 "skeletonReasonPin": { 17744 17694 "type": "object", 17745 17695 "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 - }, 17770 17696 }, 17771 17697 "threadgateView": { 17772 17698 "type": "object",
+4 -78
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 - /** 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 - )[]; 110 + reason?: $Typed<ReasonRepost> | $Typed<ReasonPin> | { $type: string }; 118 111 /** Context provided by feed generator that may be passed back alongside interactions. */ 119 112 feedContext?: string; 120 113 } ··· 181 174 182 175 export function validateReasonPin<V>(v: V) { 183 176 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); 216 177 } 217 178 218 179 export interface ThreadViewPost { ··· 334 295 export interface SkeletonFeedPost { 335 296 $type?: "so.sprk.feed.defs#skeletonFeedPost"; 336 297 post: string; 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 - )[]; 298 + reason?: $Typed<SkeletonReasonRepost> | $Typed<SkeletonReasonPin> | { 299 + $type: string; 300 + }; 345 301 /** Context that will be passed through to client and may be passed to feed generator back alongside interactions. */ 346 302 feedContext?: string; 347 303 } ··· 383 339 384 340 export function validateSkeletonReasonPin<V>(v: V) { 385 341 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); 416 342 } 417 343 418 344 export interface ThreadgateView {
+4 -57
lexicons/so/sprk/feed/defs.json
··· 79 79 "required": ["post"], 80 80 "properties": { 81 81 "post": { "type": "ref", "ref": "#postView" }, 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 - }, 82 + "reason": { "type": "union", "refs": ["#reasonRepost", "#reasonPin"] }, 96 83 "feedContext": { 97 84 "type": "string", 98 85 "description": "Context provided by feed generator that may be passed back alongside interactions.", ··· 130 117 "reasonPin": { 131 118 "type": "object", 132 119 "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 - } 149 120 }, 150 121 "threadViewPost": { 151 122 "type": "object", ··· 235 206 "required": ["post"], 236 207 "properties": { 237 208 "post": { "type": "string", "format": "at-uri" }, 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 - } 209 + "reason": { 210 + "type": "union", 211 + "refs": ["#skeletonReasonRepost", "#skeletonReasonPin"] 251 212 }, 252 213 "feedContext": { 253 214 "type": "string", ··· 266 227 "skeletonReasonPin": { 267 228 "type": "object", 268 229 "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 - } 283 230 }, 284 231 "threadgateView": { 285 232 "type": "object",
+13 -92
views/index.ts
··· 16 16 isPostView, 17 17 isReplyView, 18 18 PostView, 19 - ReasonLike, 20 19 ReasonPin, 21 - ReasonReply, 22 20 ReasonRepost, 23 21 ReplyRef, 24 22 ReplyView, ··· 67 65 import { cidFromBlobJson } from "./util.ts"; 68 66 import { uriToDid } from "../utils/uris.ts"; 69 67 import { mapDefined } from "@atp/common"; 70 - import { FeedItem, Like, Reply, Repost } from "../hydration/feed.ts"; 68 + import { FeedItem, Repost } from "../hydration/feed.ts"; 71 69 import { 72 70 QueryParams as GetThreadQueryParams, 73 71 ThreadItem, ··· 504 502 item: FeedItem, 505 503 state: HydrationState, 506 504 ): FeedViewPost | undefined { 507 - const reasons = []; 508 - 505 + let reason; 509 506 if (item.authorPinned) { 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 - } 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 - } 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; 535 514 } 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 - 549 515 const post = this.post(item.post.uri, state); 550 516 if (!post) return; 551 517 return { 552 518 post, 553 - reasons: reasons.length > 0 ? reasons : undefined, 519 + reason, 554 520 }; 555 521 } 556 522 ··· 624 590 }; 625 591 } 626 592 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 - 657 593 maybePost(uri: string, state: HydrationState): $Typed<MaybePostView> { 658 594 const reply = this.reply(uri, state); 659 595 if (reply) { ··· 707 643 authorBlocked: boolean; 708 644 } { 709 645 const authorDid = uriToDid(item.post.uri); 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 - } 646 + const originatorDid = item.repost ? uriToDid(item.repost.uri) : authorDid; 726 647 727 648 return { 728 - originatorMuted, 729 - originatorBlocked, 649 + originatorMuted: this.viewerMuteExists(originatorDid, state), 650 + originatorBlocked: this.viewerBlockExists(originatorDid, state), 730 651 authorMuted: this.viewerMuteExists(authorDid, state), 731 652 authorBlocked: this.viewerBlockExists(authorDid, state), 732 653 };