[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: crosspost reply endpoint

+1395 -37
+2
api/index.ts
··· 7 7 import getProfile from "./so/sprk/actor/getProfile.ts"; 8 8 import getAuthorFeed from "./so/sprk/feed/getAuthorFeed.ts"; 9 9 import getPostThread from "./so/sprk/feed/getPostThread.ts"; 10 + import getCrosspostThread from "./so/sprk/feed/getCrosspostThread.ts"; 10 11 import getActorLikes from "./so/sprk/feed/getActorLikes.ts"; 11 12 import getActorReposts from "./so/sprk/feed/getActorReposts.ts"; 12 13 import getAudios from "./so/sprk/sound/getAudios.ts"; ··· 46 47 getProfiles(server, ctx); 47 48 getAuthorFeed(server, ctx); 48 49 getPostThread(server, ctx); 50 + getCrosspostThread(server, ctx); 49 51 getActorLikes(server, ctx); 50 52 getActorReposts(server, ctx); 51 53 getAudios(server, ctx);
+377
api/so/sprk/feed/getCrosspostThread.ts
··· 1 + import { InvalidRequestError } from "@atp/xrpc-server"; 2 + import { ServerConfig } from "../../../../config.ts"; 3 + import { AppContext } from "../../../../context.ts"; 4 + import { DataPlane } from "../../../../data-plane/index.ts"; 5 + import { CrosspostThreadItem } from "../../../../data-plane/routes/crosspost-threads.ts"; 6 + import { Code, isDataPlaneError } from "../../../../data-plane/util.ts"; 7 + import { 8 + HydrateCtx, 9 + HydrationState, 10 + Hydrator, 11 + PostBlock, 12 + } from "../../../../hydration/index.ts"; 13 + import { HydrationMap } from "../../../../hydration/util.ts"; 14 + import { Server } from "../../../../lex/index.ts"; 15 + import { 16 + OutputSchema, 17 + QueryParams, 18 + ThreadItem, 19 + } from "../../../../lex/types/so/sprk/feed/getCrosspostThread.ts"; 20 + import { createPipeline } from "../../../../pipeline.ts"; 21 + import { uriToDid } from "../../../../utils/uris.ts"; 22 + import { Views } from "../../../../views/index.ts"; 23 + import { ATPROTO_REPO_REV, resHeaders } from "../../../util.ts"; 24 + 25 + export default function (server: Server, ctx: AppContext) { 26 + const getCrosspostThread = createPipeline( 27 + skeleton, 28 + hydration, 29 + noRules, 30 + presentation, 31 + ); 32 + 33 + server.so.sprk.feed.getCrosspostThread({ 34 + auth: ctx.authVerifier.optionalStandardOrRole, 35 + handler: async ({ params, auth, req, res }) => { 36 + const { viewer, includeTakedowns, include3pBlocks } = ctx.authVerifier 37 + .parseCreds(auth); 38 + const labelers = ctx.reqLabelers(req); 39 + const hydrateCtx = await ctx.hydrator.createContext({ 40 + labelers, 41 + viewer, 42 + includeTakedowns, 43 + include3pBlocks, 44 + }); 45 + 46 + const repoRevPromise = ctx.hydrator.actor.getRepoRevSafe(viewer); 47 + let result: OutputSchema; 48 + try { 49 + result = await getCrosspostThread({ ...params, hydrateCtx }, ctx); 50 + } catch (err) { 51 + const repoRev = await repoRevPromise; 52 + if (repoRev) { 53 + res.headers.set(ATPROTO_REPO_REV, repoRev); 54 + } 55 + throw err; 56 + } 57 + 58 + const repoRev = await repoRevPromise; 59 + return { 60 + encoding: "application/json", 61 + body: result, 62 + headers: resHeaders({ 63 + repoRev, 64 + labelers: hydrateCtx.labelers, 65 + }), 66 + }; 67 + }, 68 + }); 69 + } 70 + 71 + const skeleton = async ( 72 + inputs: { ctx: Context; params: Params }, 73 + ): Promise<Skeleton> => { 74 + const { ctx, params } = inputs; 75 + const anchor = await ctx.hydrator.resolveUri(params.anchor); 76 + 77 + try { 78 + const result = await ctx.dataplane.crosspostThread.getThread( 79 + anchor, 80 + params.parentHeight, 81 + getDepth(ctx, anchor, params), 82 + params.sort, 83 + ); 84 + const visibleItems = params.hydrateCtx.includeTakedowns 85 + ? result.items 86 + : await filterTakenDownItems(ctx.dataplane, result.items); 87 + const anchorFound = visibleItems.some((item) => item.uri === anchor); 88 + const page = paginateThreadItems(visibleItems, params.limit, params.cursor); 89 + return { 90 + anchor, 91 + anchorFound, 92 + items: page.items, 93 + cursor: page.cursor, 94 + }; 95 + } catch (err) { 96 + if (isDataPlaneError(err, Code.NotFound)) { 97 + return { 98 + anchor, 99 + anchorFound: false, 100 + items: [], 101 + }; 102 + } 103 + throw err; 104 + } 105 + }; 106 + 107 + const hydration = async ( 108 + inputs: { 109 + ctx: Context; 110 + params: Params; 111 + skeleton: Skeleton; 112 + }, 113 + ) => { 114 + const { ctx, params, skeleton } = inputs; 115 + const authorDids = skeleton.items.map((item) => item.authorDid); 116 + const profileStatePromise = ctx.hydrator.hydrateProfilesBasic( 117 + authorDids, 118 + params.hydrateCtx, 119 + ); 120 + if (params.hydrateCtx.include3pBlocks) { 121 + return await profileStatePromise; 122 + } 123 + const [profileState, postBlocks] = await Promise.all([ 124 + profileStatePromise, 125 + hydrateCrosspostPostBlocks(ctx.hydrator, skeleton.items), 126 + ]); 127 + return { 128 + ...profileState, 129 + postBlocks, 130 + }; 131 + }; 132 + 133 + const noRules = (inputs: { skeleton: Skeleton }) => { 134 + return inputs.skeleton; 135 + }; 136 + 137 + const presentation = ( 138 + inputs: { 139 + ctx: Context; 140 + skeleton: Skeleton; 141 + hydration: HydrationState; 142 + }, 143 + ): OutputSchema => { 144 + const { ctx, skeleton, hydration } = inputs; 145 + 146 + if (!skeleton.anchorFound) { 147 + throw new InvalidRequestError( 148 + `Post not found: ${skeleton.anchor}`, 149 + "NotFound", 150 + ); 151 + } 152 + 153 + const thread = skeleton.items.map((item) => { 154 + return { 155 + $type: "so.sprk.feed.getCrosspostThread#threadItem", 156 + uri: item.uri, 157 + depth: item.depth, 158 + value: toThreadValue(ctx, hydration, item), 159 + } as ThreadItem; 160 + }); 161 + 162 + return skeleton.cursor ? { thread, cursor: skeleton.cursor } : { thread }; 163 + }; 164 + 165 + const toThreadValue = ( 166 + ctx: Context, 167 + hydration: HydrationState, 168 + item: CrosspostThreadItem, 169 + ): ThreadItem["value"] => { 170 + if (!hydration.ctx?.include3pBlocks) { 171 + const blockInfo = hydration.postBlocks?.get(item.uri) ?? undefined; 172 + if (blockInfo && (blockInfo.parent || blockInfo.root)) { 173 + return ctx.views.blockedPost(item.uri, item.authorDid, hydration); 174 + } 175 + } 176 + 177 + if (ctx.views.viewerBlockExists(item.authorDid, hydration)) { 178 + return ctx.views.blockedPost(item.uri, item.authorDid, hydration); 179 + } 180 + 181 + const author = ctx.views.profileBasic(item.authorDid, hydration); 182 + if (!author) { 183 + return ctx.views.notFoundPost(item.uri); 184 + } 185 + 186 + const record = JSON.parse(JSON.stringify(item.record)) as Record< 187 + string, 188 + unknown 189 + >; 190 + 191 + if (item.kind === "post") { 192 + return { 193 + $type: "so.sprk.feed.defs#threadViewPost", 194 + post: { 195 + $type: "so.sprk.feed.defs#postView", 196 + uri: item.uri, 197 + cid: item.cid, 198 + author, 199 + record, 200 + replyCount: item.replyCount, 201 + repostCount: item.repostCount, 202 + likeCount: item.likeCount, 203 + indexedAt: item.indexedAt, 204 + }, 205 + }; 206 + } 207 + 208 + return { 209 + $type: "so.sprk.feed.defs#threadViewPost", 210 + post: { 211 + $type: "so.sprk.feed.defs#replyView", 212 + uri: item.uri, 213 + cid: item.cid, 214 + author, 215 + record, 216 + replyCount: item.replyCount, 217 + likeCount: item.likeCount, 218 + indexedAt: item.indexedAt, 219 + }, 220 + }; 221 + }; 222 + 223 + const getDepth = (ctx: Context, anchor: string, params: Params) => { 224 + let maxDepth = ctx.cfg.maxThreadDepth; 225 + if (ctx.cfg.bigThreadUris.has(anchor) && ctx.cfg.bigThreadDepth) { 226 + maxDepth = ctx.cfg.bigThreadDepth; 227 + } 228 + return maxDepth ? Math.min(maxDepth, params.depth) : params.depth; 229 + }; 230 + 231 + const parseThreadCursor = (cursor?: string): number => { 232 + if (!cursor) { 233 + return 0; 234 + } 235 + if (!/^[0-9a-z]+$/i.test(cursor)) { 236 + throw new InvalidRequestError("Malformed cursor"); 237 + } 238 + const offset = parseInt(cursor, 36); 239 + if (!Number.isInteger(offset) || offset < 0) { 240 + throw new InvalidRequestError("Malformed cursor"); 241 + } 242 + return offset; 243 + }; 244 + 245 + const paginateThreadItems = ( 246 + items: CrosspostThreadItem[], 247 + limit: number, 248 + cursor?: string, 249 + ): { items: CrosspostThreadItem[]; cursor?: string } => { 250 + const start = parseThreadCursor(cursor); 251 + const pageSize = Number.isInteger(limit) && limit > 0 ? limit : 50; 252 + if (start >= items.length) { 253 + return { items: [] }; 254 + } 255 + const end = Math.min(start + pageSize, items.length); 256 + const nextCursor = end < items.length ? end.toString(36) : undefined; 257 + return { items: items.slice(start, end), cursor: nextCursor }; 258 + }; 259 + 260 + const filterTakenDownItems = async ( 261 + dataplane: DataPlane, 262 + items: CrosspostThreadItem[], 263 + ): Promise<CrosspostThreadItem[]> => { 264 + if (items.length === 0) { 265 + return items; 266 + } 267 + const uris = Array.from(new Set(items.map((item) => item.uri))); 268 + const records = await dataplane.records.getRecords(uris); 269 + const takenDownUris = new Set( 270 + records.records 271 + .filter((record) => record.takenDown) 272 + .map((record) => record.uri), 273 + ); 274 + return items.filter((item) => !takenDownUris.has(item.uri)); 275 + }; 276 + 277 + type RelationshipPair = [didA: string, didB: string]; 278 + type PostBlockPairs = { 279 + parent?: RelationshipPair; 280 + root?: RelationshipPair; 281 + }; 282 + type ReplyRef = { uri?: unknown }; 283 + type ReplyInfo = { 284 + parent?: ReplyRef; 285 + root?: ReplyRef; 286 + }; 287 + 288 + const hydrateCrosspostPostBlocks = async ( 289 + hydrator: Hydrator, 290 + items: CrosspostThreadItem[], 291 + ) => { 292 + const postBlocks = new HydrationMap<PostBlock>(); 293 + const postBlocksPairs = new Map<string, PostBlockPairs>(); 294 + const relationships: RelationshipPair[] = []; 295 + 296 + for (const item of items) { 297 + const creator = item.authorDid; 298 + const pairs: PostBlockPairs = {}; 299 + const replyInfo = getReplyInfo(item.record); 300 + 301 + const parentUri = getRefUri(replyInfo?.parent); 302 + const parentDid = parentUri ? uriToDid(parentUri) : undefined; 303 + if (parentDid && parentDid !== creator) { 304 + const pair: RelationshipPair = [creator, parentDid]; 305 + relationships.push(pair); 306 + pairs.parent = pair; 307 + } 308 + 309 + const rootUri = getRefUri(replyInfo?.root); 310 + const rootDid = rootUri ? uriToDid(rootUri) : undefined; 311 + if (rootDid && rootDid !== creator) { 312 + const pair: RelationshipPair = [creator, rootDid]; 313 + relationships.push(pair); 314 + pairs.root = pair; 315 + } 316 + 317 + postBlocksPairs.set(item.uri, pairs); 318 + } 319 + 320 + const blocks = relationships.length > 0 321 + ? await hydrator.hydrateBidirectionalBlocks(pairsToMap(relationships)) 322 + : undefined; 323 + 324 + for (const [uri, { parent, root }] of postBlocksPairs) { 325 + postBlocks.set(uri, { 326 + embed: false, 327 + parent: !!parent && !!blocks?.get(parent[0])?.get(parent[1]), 328 + root: !!root && !!blocks?.get(root[0])?.get(root[1]), 329 + }); 330 + } 331 + 332 + return postBlocks; 333 + }; 334 + 335 + const getReplyInfo = ( 336 + record: Record<string, unknown>, 337 + ): ReplyInfo | undefined => { 338 + const reply = record.reply; 339 + return isObject(reply) ? (reply as ReplyInfo) : undefined; 340 + }; 341 + 342 + const getRefUri = (ref: ReplyRef | undefined): string | undefined => { 343 + if (!ref || !isObject(ref)) { 344 + return undefined; 345 + } 346 + return typeof ref.uri === "string" ? ref.uri : undefined; 347 + }; 348 + 349 + const pairsToMap = (pairs: RelationshipPair[]): Map<string, string[]> => { 350 + const map = new Map<string, string[]>(); 351 + for (const [source, target] of pairs) { 352 + const targets = map.get(source) ?? []; 353 + targets.push(target); 354 + map.set(source, targets); 355 + } 356 + return map; 357 + }; 358 + 359 + const isObject = (value: unknown): value is Record<string, unknown> => { 360 + return !!value && typeof value === "object"; 361 + }; 362 + 363 + type Context = { 364 + dataplane: DataPlane; 365 + hydrator: Hydrator; 366 + views: Views; 367 + cfg: ServerConfig; 368 + }; 369 + 370 + type Params = QueryParams & { hydrateCtx: HydrateCtx }; 371 + 372 + type Skeleton = { 373 + anchor: string; 374 + anchorFound: boolean; 375 + items: CrosspostThreadItem[]; 376 + cursor?: string; 377 + };
+2 -7
api/so/sprk/story/getStories.ts
··· 152 152 }; 153 153 154 154 const rules = (inputs: RulesFnInput<Context, Params, Skeleton>): Skeleton => { 155 - const { ctx, params, skeleton, hydration } = inputs; 156 - const viewer = params.viewer; 155 + const { ctx, skeleton, hydration } = inputs; 157 156 158 - // Filter out expired stories (24 hours, except for owner's stories) 157 + // Filter out expired stories (24 hours) 159 158 const activeStories = skeleton.stories.filter((uri) => { 160 159 const storyInfo = hydration.stories?.get(uri); 161 160 if (!storyInfo) return false; 162 - 163 - // If the authenticated user is the author, don't apply the 24h expiration filter 164 - const authorDid = uriToDid(uri); 165 - if (viewer && authorDid === viewer) return true; 166 161 167 162 // Check if story is expired (older than 24 hours) 168 163 const twentyFourHoursAgo = new Date();
+4 -9
api/so/sprk/story/getTimeline.ts
··· 128 128 }; 129 129 130 130 const rules = (inputs: RulesFnInput<Context, Params, Skeleton>): Skeleton => { 131 - const { ctx, params, skeleton, hydration } = inputs; 132 - const viewer = params.hydrateCtx.viewer!; 131 + const { ctx, skeleton, hydration } = inputs; 133 132 134 - // Filter out expired stories (24 hours, except for owner's stories) 135 - // Note: The dataplane already filters expired stories, but we do an additional 136 - // check here for stories from the viewer (which shouldn't be filtered) 133 + // Filter out expired stories (24 hours) 134 + // Note: The dataplane already filters expired stories, so we only ensure 135 + // records still exist after hydration. 137 136 const activeStories = skeleton.stories.filter((uri) => { 138 137 const storyInfo = hydration.stories?.get(uri); 139 138 if (!storyInfo) return false; 140 - 141 - // If the authenticated user is the author, don't apply the 24h expiration filter 142 - const authorDid = uriToDid(uri); 143 - if (authorDid === viewer) return true; 144 139 145 140 // The dataplane already filtered expired stories, so we just check if it exists 146 141 return true;
+3
data-plane/index.ts
··· 21 21 import { Search } from "./routes/search.ts"; 22 22 import { Labels } from "./routes/labels.ts"; 23 23 import { PushTokens } from "./routes/push-tokens.ts"; 24 + import { CrosspostThread } from "./routes/crosspost-threads.ts"; 24 25 25 26 export { RepoSubscription } from "./subscription/index.ts"; 26 27 ··· 55 56 public search: Search; 56 57 public labels: Labels; 57 58 public pushTokens: PushTokens; 59 + public crosspostThread: CrosspostThread; 58 60 59 61 constructor( 60 62 db: Database, ··· 85 87 this.search = new Search(db); 86 88 this.labels = new Labels(db); 87 89 this.pushTokens = new PushTokens(db); 90 + this.crosspostThread = new CrosspostThread(db); 88 91 } 89 92 }
+1 -5
data-plane/indexing/plugins/crosspost/reply.ts
··· 279 279 parentReply, 280 280 parentCrosspostReply, 281 281 nativeReplyCount, 282 - crosspostReplyCount, 283 282 ] = await Promise.all([ 284 283 db.models.Reply.findOne({ 285 284 uri: replyIdx.reply.reply?.parent.uri, ··· 290 289 db.models.Reply.countDocuments({ 291 290 "reply.parent.uri": replyIdx.reply.reply.parent.uri, 292 291 }), 293 - db.models.CrosspostReply.countDocuments({ 294 - "reply.parent.uri": replyIdx.reply.reply.parent.uri, 295 - }), 296 292 ]); 297 - const replyCount = nativeReplyCount + crosspostReplyCount; 293 + const replyCount = nativeReplyCount; 298 294 299 295 if (parentPost) { 300 296 await db.models.Post.findOneAndUpdate(
+2 -5
data-plane/indexing/plugins/reply.ts
··· 273 273 const parentPost = await db.models.Post.findOne({ 274 274 uri: replyIdx.reply.reply?.parent.uri, 275 275 }); 276 - const [parentReply, parentCrosspostReply, nativeReplyCount, crosspostReplyCount] = await Promise.all([ 276 + const [parentReply, parentCrosspostReply, nativeReplyCount] = await Promise.all([ 277 277 db.models.Reply.findOne({ 278 278 uri: replyIdx.reply.reply?.parent.uri, 279 279 }), ··· 283 283 db.models.Reply.countDocuments({ 284 284 "reply.parent.uri": replyIdx.reply.reply.parent.uri, 285 285 }), 286 - db.models.CrosspostReply.countDocuments({ 287 - "reply.parent.uri": replyIdx.reply.reply.parent.uri, 288 - }), 289 286 ]); 290 - const replyCount = nativeReplyCount + crosspostReplyCount; 287 + const replyCount = nativeReplyCount; 291 288 292 289 if (parentPost) { 293 290 await db.models.Post.findOneAndUpdate(
+276
data-plane/routes/crosspost-threads.ts
··· 1 + import { 2 + CrosspostReplyDocument, 3 + PostDocument, 4 + ReplyDocument, 5 + } from "../db/models.ts"; 6 + import { Database } from "../db/index.ts"; 7 + import { Code, DataPlaneError } from "../util.ts"; 8 + 9 + type NodeKind = "post" | "reply" | "crosspostReply"; 10 + 11 + type PostThreadNode = { 12 + kind: "post"; 13 + doc: PostDocument; 14 + }; 15 + 16 + type ReplyThreadNode = { 17 + kind: "reply"; 18 + doc: ReplyDocument; 19 + }; 20 + 21 + type CrosspostReplyThreadNode = { 22 + kind: "crosspostReply"; 23 + doc: CrosspostReplyDocument; 24 + }; 25 + 26 + type ThreadNode = PostThreadNode | ReplyThreadNode | CrosspostReplyThreadNode; 27 + type ThreadSort = "oldest" | "newest" | "top"; 28 + 29 + export type CrosspostThreadItem = { 30 + uri: string; 31 + depth: number; 32 + kind: NodeKind; 33 + cid: string; 34 + authorDid: string; 35 + record: Record<string, unknown>; 36 + createdAt: string; 37 + indexedAt: string; 38 + likeCount: number; 39 + replyCount: number; 40 + repostCount?: number; 41 + }; 42 + 43 + function validateThreadParams(above: number, below: number) { 44 + if (!Number.isInteger(above) || above < 0 || above > 1000) { 45 + throw new Error( 46 + "Invalid parentHeight: must be an integer between 0 and 1000", 47 + ); 48 + } 49 + 50 + if (!Number.isInteger(below) || below < 0 || below > 1000) { 51 + throw new Error("Invalid depth: must be an integer between 0 and 1000"); 52 + } 53 + } 54 + 55 + function getDescendantSort( 56 + sort: string | undefined, 57 + ): Record<string, 1 | -1> { 58 + const threadSort: ThreadSort = sort === "newest" || sort === "top" 59 + ? sort 60 + : "oldest"; 61 + if (threadSort === "newest") { 62 + return { createdAt: -1 }; 63 + } 64 + if (threadSort === "top") { 65 + return { likeCount: -1, createdAt: -1 }; 66 + } 67 + return { createdAt: 1 }; 68 + } 69 + 70 + function toThreadItem(node: ThreadNode, depth: number): CrosspostThreadItem { 71 + if (node.kind === "post") { 72 + return { 73 + uri: node.doc.uri, 74 + depth, 75 + kind: "post", 76 + cid: node.doc.cid, 77 + authorDid: node.doc.authorDid, 78 + record: { 79 + caption: node.doc.caption, 80 + media: node.doc.media, 81 + sound: node.doc.sound, 82 + langs: node.doc.langs, 83 + tags: node.doc.tags, 84 + crossposts: node.doc.crossposts, 85 + createdAt: node.doc.createdAt, 86 + }, 87 + createdAt: node.doc.createdAt, 88 + indexedAt: node.doc.indexedAt, 89 + likeCount: node.doc.likeCount ?? 0, 90 + replyCount: node.doc.replyCount ?? 0, 91 + repostCount: node.doc.repostCount ?? 0, 92 + }; 93 + } 94 + 95 + if (node.kind === "reply") { 96 + return { 97 + uri: node.doc.uri, 98 + depth, 99 + kind: "reply", 100 + cid: node.doc.cid, 101 + authorDid: node.doc.authorDid, 102 + record: { 103 + text: node.doc.text, 104 + facets: node.doc.facets, 105 + reply: node.doc.reply, 106 + media: node.doc.media, 107 + langs: node.doc.langs, 108 + createdAt: node.doc.createdAt, 109 + }, 110 + createdAt: node.doc.createdAt, 111 + indexedAt: node.doc.indexedAt, 112 + likeCount: node.doc.likeCount ?? 0, 113 + replyCount: node.doc.replyCount ?? 0, 114 + }; 115 + } 116 + 117 + return { 118 + uri: node.doc.uri, 119 + depth, 120 + kind: "crosspostReply", 121 + cid: node.doc.cid, 122 + authorDid: node.doc.authorDid, 123 + record: { 124 + $type: "app.bsky.feed.post", 125 + text: node.doc.text, 126 + facets: node.doc.facets, 127 + reply: node.doc.reply, 128 + langs: node.doc.langs, 129 + tags: node.doc.tags, 130 + createdAt: node.doc.createdAt, 131 + }, 132 + createdAt: node.doc.createdAt, 133 + indexedAt: node.doc.indexedAt, 134 + likeCount: node.doc.likeCount ?? 0, 135 + replyCount: node.doc.replyCount ?? 0, 136 + }; 137 + } 138 + 139 + const parentUriFromNode = (node: ThreadNode): string | undefined => { 140 + if (node.kind === "post") return undefined; 141 + return node.doc.reply?.parent?.uri; 142 + }; 143 + 144 + export class CrosspostThread { 145 + private db: Database; 146 + 147 + constructor(db: Database) { 148 + this.db = db; 149 + } 150 + 151 + private async getNodeByUri( 152 + uri: string, 153 + cache: Map<string, ThreadNode | null>, 154 + ): Promise<ThreadNode | null> { 155 + if (cache.has(uri)) { 156 + return cache.get(uri) ?? null; 157 + } 158 + 159 + const [post, reply, crosspostReply] = await Promise.all([ 160 + this.db.models.Post.findOne({ uri }), 161 + this.db.models.Reply.findOne({ uri }), 162 + this.db.models.CrosspostReply.findOne({ uri }), 163 + ]); 164 + 165 + const node: ThreadNode | null = post 166 + ? { kind: "post", doc: post } 167 + : reply 168 + ? { kind: "reply", doc: reply } 169 + : crosspostReply 170 + ? { kind: "crosspostReply", doc: crosspostReply } 171 + : null; 172 + 173 + cache.set(uri, node); 174 + return node; 175 + } 176 + 177 + async getThread( 178 + anchorUri: string, 179 + parentHeight = 80, 180 + depth = 6, 181 + sort: string = "oldest", 182 + ): Promise<{ items: CrosspostThreadItem[] }> { 183 + validateThreadParams(parentHeight, depth); 184 + 185 + try { 186 + const cache = new Map<string, ThreadNode | null>(); 187 + const anchorNode = await this.getNodeByUri(anchorUri, cache); 188 + if (!anchorNode) { 189 + throw new DataPlaneError(Code.NotFound); 190 + } 191 + 192 + const items: CrosspostThreadItem[] = []; 193 + const seenUris = new Set<string>(); 194 + 195 + // Ancestors are depth -N..-1 so anchor can stay depth 0. 196 + const ancestorNodes: ThreadNode[] = []; 197 + const ancestorWalkSeenUris = new Set<string>([anchorNode.doc.uri]); 198 + if (anchorNode.kind !== "post") { 199 + let currentNode: ThreadNode = anchorNode; 200 + for (let i = 0; i < parentHeight; i++) { 201 + const parentUri = parentUriFromNode(currentNode); 202 + if (!parentUri || ancestorWalkSeenUris.has(parentUri)) { 203 + break; 204 + } 205 + const parentNode = await this.getNodeByUri(parentUri, cache); 206 + if (!parentNode) { 207 + break; 208 + } 209 + ancestorWalkSeenUris.add(parentUri); 210 + ancestorNodes.unshift(parentNode); 211 + if (parentNode.kind === "post") { 212 + break; 213 + } 214 + currentNode = parentNode; 215 + } 216 + } 217 + 218 + for (let i = 0; i < ancestorNodes.length; i++) { 219 + const ancestor = ancestorNodes[i]; 220 + const ancestorDepth = i - ancestorNodes.length; 221 + if (!seenUris.has(ancestor.doc.uri)) { 222 + seenUris.add(ancestor.doc.uri); 223 + items.push(toThreadItem(ancestor, ancestorDepth)); 224 + } 225 + } 226 + 227 + if (!seenUris.has(anchorNode.doc.uri)) { 228 + seenUris.add(anchorNode.doc.uri); 229 + items.push(toThreadItem(anchorNode, 0)); 230 + } 231 + 232 + // Descendants only follow CrosspostReply edges to keep this flow isolated. 233 + const descendantSort = getDescendantSort(sort); 234 + const queue: Array<{ uri: string; depth: number }> = [{ 235 + uri: anchorNode.doc.uri, 236 + depth: 0, 237 + }]; 238 + 239 + while (queue.length > 0) { 240 + const current = queue.shift()!; 241 + if (current.depth >= depth) { 242 + continue; 243 + } 244 + 245 + const children = await this.db.models.CrosspostReply.find({ 246 + "reply.parent.uri": current.uri, 247 + }) 248 + .sort(descendantSort); 249 + 250 + for (const child of children) { 251 + if (seenUris.has(child.uri)) { 252 + continue; 253 + } 254 + seenUris.add(child.uri); 255 + const childNode: CrosspostReplyThreadNode = { 256 + kind: "crosspostReply", 257 + doc: child, 258 + }; 259 + const childDepth = current.depth + 1; 260 + items.push(toThreadItem(childNode, childDepth)); 261 + if (childDepth < depth) { 262 + queue.push({ uri: child.uri, depth: childDepth }); 263 + } 264 + } 265 + } 266 + 267 + return { items }; 268 + } catch (error) { 269 + if (error instanceof DataPlaneError) { 270 + throw error; 271 + } 272 + console.error("Error fetching crosspost thread:", error); 273 + throw new DataPlaneError(Code.InternalError); 274 + } 275 + } 276 + }
+1 -11
data-plane/routes/stories.ts
··· 77 77 // Build query with expiry filter 78 78 const storiesQuery = this.db.models.Story.find({ 79 79 authorDid: { $in: timelineDids }, 80 - // Keep this nested to avoid merging with keyset cursor $or filter. 81 - $and: [ 82 - { 83 - $or: [ 84 - { authorDid: actorDid }, 85 - { indexedAt: { $gte: minDate } }, 86 - ], 87 - }, 88 - ], 80 + indexedAt: { $gte: minDate }, 89 81 }); 90 82 91 83 // Apply pagination ··· 130 122 */ 131 123 filterExpiredStories( 132 124 stories: StoryItem[], 133 - ownerDid?: string, 134 125 ): StoryItem[] { 135 126 const twentyFourHoursAgo = new Date(); 136 127 twentyFourHoursAgo.setHours( ··· 138 129 ); 139 130 140 131 return stories.filter((story) => { 141 - if (ownerDid && story.authorDid === ownerDid) return true; 142 132 const storyDate = new Date(story.indexedAt); 143 133 return storyDate >= twentyFourHoursAgo; 144 134 });
+13
lex/index.ts
··· 199 199 import type * as SoSprkFeedDescribeFeedGenerator from "./types/so/sprk/feed/describeFeedGenerator.ts"; 200 200 import type * as SoSprkFeedSearchPosts from "./types/so/sprk/feed/searchPosts.ts"; 201 201 import type * as SoSprkFeedGetPosts from "./types/so/sprk/feed/getPosts.ts"; 202 + import type * as SoSprkFeedGetCrosspostThread from "./types/so/sprk/feed/getCrosspostThread.ts"; 202 203 import type * as SoSprkFeedGetFeed from "./types/so/sprk/feed/getFeed.ts"; 203 204 import type * as SoSprkFeedGetFeedSkeleton from "./types/so/sprk/feed/getFeedSkeleton.ts"; 204 205 import type * as SoSprkFeedGetSuggestedFeeds from "./types/so/sprk/feed/getSuggestedFeeds.ts"; ··· 3077 3078 >, 3078 3079 ) { 3079 3080 const nsid = "so.sprk.feed.getPosts"; // @ts-ignore - dynamically generated 3081 + return this._server.xrpc.method(nsid, cfg); 3082 + } 3083 + 3084 + getCrosspostThread<A extends Auth = void>( 3085 + cfg: MethodConfigOrHandler< 3086 + A, 3087 + SoSprkFeedGetCrosspostThread.QueryParams, 3088 + SoSprkFeedGetCrosspostThread.HandlerInput, 3089 + SoSprkFeedGetCrosspostThread.HandlerOutput 3090 + >, 3091 + ) { 3092 + const nsid = "so.sprk.feed.getCrosspostThread"; // @ts-ignore - dynamically generated 3080 3093 return this._server.xrpc.method(nsid, cfg); 3081 3094 } 3082 3095
+120
lex/lexicons.ts
··· 18820 18820 }, 18821 18821 }, 18822 18822 }, 18823 + "SoSprkFeedGetCrosspostThread": { 18824 + "lexicon": 1, 18825 + "id": "so.sprk.feed.getCrosspostThread", 18826 + "defs": { 18827 + "main": { 18828 + "type": "query", 18829 + "description": 18830 + "Get crosspost thread items for an anchor. Mirrors getPostThread shape but uses isolated crosspost-thread traversal.", 18831 + "parameters": { 18832 + "type": "params", 18833 + "required": [ 18834 + "anchor", 18835 + ], 18836 + "properties": { 18837 + "anchor": { 18838 + "type": "string", 18839 + "format": "at-uri", 18840 + "description": 18841 + "Reference (AT-URI) to anchor post or reply record.", 18842 + }, 18843 + "limit": { 18844 + "type": "integer", 18845 + "minimum": 1, 18846 + "maximum": 100, 18847 + "default": 50, 18848 + }, 18849 + "cursor": { 18850 + "type": "string", 18851 + }, 18852 + "depth": { 18853 + "type": "integer", 18854 + "description": 18855 + "How many levels of descendant depth should be included in response.", 18856 + "default": 6, 18857 + "minimum": 0, 18858 + "maximum": 1000, 18859 + }, 18860 + "parentHeight": { 18861 + "type": "integer", 18862 + "description": 18863 + "How many levels of parent (and grandparent, etc) items to include.", 18864 + "default": 80, 18865 + "minimum": 0, 18866 + "maximum": 1000, 18867 + }, 18868 + "sort": { 18869 + "type": "string", 18870 + "description": "Sorting for thread replies.", 18871 + "knownValues": [ 18872 + "newest", 18873 + "oldest", 18874 + "top", 18875 + ], 18876 + "default": "oldest", 18877 + }, 18878 + }, 18879 + }, 18880 + "output": { 18881 + "encoding": "application/json", 18882 + "schema": { 18883 + "type": "object", 18884 + "required": [ 18885 + "thread", 18886 + ], 18887 + "properties": { 18888 + "cursor": { 18889 + "type": "string", 18890 + }, 18891 + "thread": { 18892 + "type": "array", 18893 + "description": 18894 + "A flat list of thread items. The depth of each item is indicated by the depth property inside the item.", 18895 + "items": { 18896 + "type": "ref", 18897 + "ref": "lex:so.sprk.feed.getCrosspostThread#threadItem", 18898 + }, 18899 + }, 18900 + "threadgate": { 18901 + "type": "ref", 18902 + "ref": "lex:so.sprk.feed.defs#threadgateView", 18903 + }, 18904 + }, 18905 + }, 18906 + }, 18907 + "errors": [ 18908 + { 18909 + "name": "NotFound", 18910 + }, 18911 + ], 18912 + }, 18913 + "threadItem": { 18914 + "type": "object", 18915 + "required": [ 18916 + "uri", 18917 + "depth", 18918 + "value", 18919 + ], 18920 + "properties": { 18921 + "uri": { 18922 + "type": "string", 18923 + "format": "at-uri", 18924 + }, 18925 + "depth": { 18926 + "type": "integer", 18927 + "description": 18928 + "The nesting level of this item in the thread. Depth 0 means the anchor item.", 18929 + }, 18930 + "value": { 18931 + "type": "union", 18932 + "refs": [ 18933 + "lex:so.sprk.feed.defs#threadViewPost", 18934 + "lex:so.sprk.feed.defs#notFoundPost", 18935 + "lex:so.sprk.feed.defs#blockedPost", 18936 + ], 18937 + }, 18938 + }, 18939 + }, 18940 + }, 18941 + }, 18823 18942 "SoSprkFeedGetFeed": { 18824 18943 "lexicon": 1, 18825 18944 "id": "so.sprk.feed.getFeed", ··· 26873 26992 SoSprkFeedDescribeFeedGenerator: "so.sprk.feed.describeFeedGenerator", 26874 26993 SoSprkFeedSearchPosts: "so.sprk.feed.searchPosts", 26875 26994 SoSprkFeedGetPosts: "so.sprk.feed.getPosts", 26995 + SoSprkFeedGetCrosspostThread: "so.sprk.feed.getCrosspostThread", 26876 26996 SoSprkFeedGetFeed: "so.sprk.feed.getFeed", 26877 26997 SoSprkFeedReply: "so.sprk.feed.reply", 26878 26998 SoSprkFeedGetFeedSkeleton: "so.sprk.feed.getFeedSkeleton",
+72
lex/types/so/sprk/feed/getCrosspostThread.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { validate as _validate } from "../../../../lexicons.ts"; 5 + import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; 6 + import type * as SoSprkFeedDefs from "./defs.ts"; 7 + 8 + const is$typed = _is$typed, validate = _validate; 9 + const id = "so.sprk.feed.getCrosspostThread"; 10 + 11 + export type QueryParams = { 12 + /** Reference (AT-URI) to anchor post or reply record. */ 13 + anchor: string; 14 + limit: number; 15 + cursor?: string; 16 + /** How many levels of descendant depth should be included in response. */ 17 + depth: number; 18 + /** How many levels of parent (and grandparent, etc) items to include. */ 19 + parentHeight: number; 20 + /** Sorting for thread replies. */ 21 + sort: 22 + | "newest" 23 + | "oldest" 24 + | "top" 25 + | (string & globalThis.Record<PropertyKey, never>); 26 + }; 27 + export type InputSchema = undefined; 28 + 29 + export interface OutputSchema { 30 + cursor?: string; 31 + /** A flat list of thread items. The depth of each item is indicated by the depth property inside the item. */ 32 + thread: (ThreadItem)[]; 33 + threadgate?: SoSprkFeedDefs.ThreadgateView; 34 + } 35 + 36 + export type HandlerInput = void; 37 + 38 + export interface HandlerSuccess { 39 + encoding: "application/json"; 40 + body: OutputSchema; 41 + headers?: { [key: string]: string }; 42 + } 43 + 44 + export interface HandlerError { 45 + status: number; 46 + message?: string; 47 + error?: "NotFound"; 48 + } 49 + 50 + export type HandlerOutput = HandlerError | HandlerSuccess; 51 + 52 + export interface ThreadItem { 53 + $type?: "so.sprk.feed.getCrosspostThread#threadItem"; 54 + uri: string; 55 + /** The nesting level of this item in the thread. Depth 0 means the anchor item. */ 56 + depth: number; 57 + value: 58 + | $Typed<SoSprkFeedDefs.ThreadViewPost> 59 + | $Typed<SoSprkFeedDefs.NotFoundPost> 60 + | $Typed<SoSprkFeedDefs.BlockedPost> 61 + | { $type: string }; 62 + } 63 + 64 + const hashThreadItem = "threadItem"; 65 + 66 + export function isThreadItem<V>(v: V) { 67 + return is$typed(v, id, hashThreadItem); 68 + } 69 + 70 + export function validateThreadItem<V>(v: V) { 71 + return validate<ThreadItem & V>(v, id, hashThreadItem); 72 + }
+93
lexicons/so/sprk/feed/getCrosspostThread.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "so.sprk.feed.getCrosspostThread", 4 + "defs": { 5 + "main": { 6 + "type": "query", 7 + "description": "Get crosspost thread items for an anchor. Mirrors getPostThread shape but uses isolated crosspost-thread traversal.", 8 + "parameters": { 9 + "type": "params", 10 + "required": ["anchor"], 11 + "properties": { 12 + "anchor": { 13 + "type": "string", 14 + "format": "at-uri", 15 + "description": "Reference (AT-URI) to anchor post or reply record." 16 + }, 17 + "limit": { 18 + "type": "integer", 19 + "minimum": 1, 20 + "maximum": 100, 21 + "default": 50 22 + }, 23 + "cursor": { "type": "string" }, 24 + "depth": { 25 + "type": "integer", 26 + "description": "How many levels of descendant depth should be included in response.", 27 + "default": 6, 28 + "minimum": 0, 29 + "maximum": 1000 30 + }, 31 + "parentHeight": { 32 + "type": "integer", 33 + "description": "How many levels of parent (and grandparent, etc) items to include.", 34 + "default": 80, 35 + "minimum": 0, 36 + "maximum": 1000 37 + }, 38 + "sort": { 39 + "type": "string", 40 + "description": "Sorting for thread replies.", 41 + "knownValues": ["newest", "oldest", "top"], 42 + "default": "oldest" 43 + } 44 + } 45 + }, 46 + "output": { 47 + "encoding": "application/json", 48 + "schema": { 49 + "type": "object", 50 + "required": ["thread"], 51 + "properties": { 52 + "cursor": { "type": "string" }, 53 + "thread": { 54 + "type": "array", 55 + "description": "A flat list of thread items. The depth of each item is indicated by the depth property inside the item.", 56 + "items": { 57 + "type": "ref", 58 + "ref": "#threadItem" 59 + } 60 + }, 61 + "threadgate": { 62 + "type": "ref", 63 + "ref": "so.sprk.feed.defs#threadgateView" 64 + } 65 + } 66 + } 67 + }, 68 + "errors": [{ "name": "NotFound" }] 69 + }, 70 + "threadItem": { 71 + "type": "object", 72 + "required": ["uri", "depth", "value"], 73 + "properties": { 74 + "uri": { 75 + "type": "string", 76 + "format": "at-uri" 77 + }, 78 + "depth": { 79 + "type": "integer", 80 + "description": "The nesting level of this item in the thread. Depth 0 means the anchor item." 81 + }, 82 + "value": { 83 + "type": "union", 84 + "refs": [ 85 + "so.sprk.feed.defs#threadViewPost", 86 + "so.sprk.feed.defs#notFoundPost", 87 + "so.sprk.feed.defs#blockedPost" 88 + ] 89 + } 90 + } 91 + } 92 + } 93 + }
+344
tests/crosspost_thread_test.ts
··· 1 + import { assertEquals } from "@std/assert"; 2 + import { createTestApp, TEST_USERS } from "./util.ts"; 3 + import { OutputSchema } from "../lex/types/so/sprk/feed/getCrosspostThread.ts"; 4 + 5 + Deno.test({ 6 + name: "Crosspost thread endpoint", 7 + sanitizeOps: false, 8 + sanitizeResources: false, 9 + fn: async (t) => { 10 + const { app, ctx, cleanup } = await createTestApp({ 11 + actors: true, 12 + profiles: false, 13 + posts: false, 14 + replies: false, 15 + stories: false, 16 + likes: false, 17 + reposts: false, 18 + follows: false, 19 + blocks: false, 20 + audio: false, 21 + generators: false, 22 + preferences: false, 23 + records: false, 24 + actorSync: false, 25 + }); 26 + 27 + try { 28 + const parentUri = `at://${TEST_USERS[0].did}/so.sprk.feed.post/post1`; 29 + const validCid = 30 + "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyku"; 31 + const parentCid = validCid; 32 + const reply1Uri = `at://${TEST_USERS[1].did}/app.bsky.feed.post/cross1`; 33 + const reply2Uri = `at://${TEST_USERS[2].did}/app.bsky.feed.post/cross2`; 34 + const reply3Uri = `at://${TEST_USERS[2].did}/app.bsky.feed.post/cross3`; 35 + const reply4Uri = `at://${TEST_USERS[3].did}/app.bsky.feed.post/cross4`; 36 + const cycleAUri = `at://${TEST_USERS[1].did}/app.bsky.feed.post/cycleA`; 37 + const cycleBUri = `at://${TEST_USERS[2].did}/app.bsky.feed.post/cycleB`; 38 + const reply1Cid = validCid; 39 + const reply2Cid = validCid; 40 + const reply3Cid = validCid; 41 + const reply4Cid = validCid; 42 + const cycleACid = validCid; 43 + const cycleBCid = validCid; 44 + 45 + const time0 = new Date("2026-01-01T00:00:00.000Z").toISOString(); 46 + const time1 = new Date("2026-01-01T00:01:00.000Z").toISOString(); 47 + const time2 = new Date("2026-01-01T00:02:00.000Z").toISOString(); 48 + const time3 = new Date("2026-01-01T00:03:00.000Z").toISOString(); 49 + const time4 = new Date("2026-01-01T00:04:00.000Z").toISOString(); 50 + const time5 = new Date("2026-01-01T00:05:00.000Z").toISOString(); 51 + const time6 = new Date("2026-01-01T00:06:00.000Z").toISOString(); 52 + 53 + await ctx.db.models.Post.create({ 54 + uri: parentUri, 55 + cid: parentCid, 56 + authorDid: TEST_USERS[0].did, 57 + caption: { text: "root" }, 58 + media: { 59 + $type: "so.sprk.media.images", 60 + images: [], 61 + }, 62 + createdAt: time0, 63 + indexedAt: time0, 64 + likeCount: 1, 65 + replyCount: 2, 66 + repostCount: 0, 67 + }); 68 + 69 + await ctx.db.models.CrosspostReply.create([ 70 + { 71 + uri: reply1Uri, 72 + cid: reply1Cid, 73 + authorDid: TEST_USERS[1].did, 74 + text: "reply-1", 75 + reply: { 76 + root: { uri: parentUri, cid: parentCid }, 77 + parent: { uri: parentUri, cid: parentCid }, 78 + }, 79 + createdAt: time1, 80 + indexedAt: time1, 81 + likeCount: 2, 82 + replyCount: 1, 83 + }, 84 + { 85 + uri: reply2Uri, 86 + cid: reply2Cid, 87 + authorDid: TEST_USERS[2].did, 88 + text: "reply-2", 89 + reply: { 90 + root: { uri: parentUri, cid: parentCid }, 91 + parent: { uri: reply1Uri, cid: reply1Cid }, 92 + }, 93 + createdAt: time2, 94 + indexedAt: time2, 95 + likeCount: 3, 96 + replyCount: 0, 97 + }, 98 + { 99 + uri: reply3Uri, 100 + cid: reply3Cid, 101 + authorDid: TEST_USERS[2].did, 102 + text: "reply-3", 103 + reply: { 104 + root: { uri: parentUri, cid: parentCid }, 105 + parent: { uri: parentUri, cid: parentCid }, 106 + }, 107 + createdAt: time3, 108 + indexedAt: time3, 109 + likeCount: 1, 110 + replyCount: 0, 111 + }, 112 + { 113 + uri: reply4Uri, 114 + cid: reply4Cid, 115 + authorDid: TEST_USERS[3].did, 116 + text: "reply-4", 117 + reply: { 118 + root: { uri: parentUri, cid: parentCid }, 119 + parent: { uri: parentUri, cid: parentCid }, 120 + }, 121 + createdAt: time4, 122 + indexedAt: time4, 123 + likeCount: 10, 124 + replyCount: 0, 125 + }, 126 + { 127 + uri: cycleAUri, 128 + cid: cycleACid, 129 + authorDid: TEST_USERS[1].did, 130 + text: "cycle-a", 131 + reply: { 132 + root: { uri: parentUri, cid: parentCid }, 133 + parent: { uri: cycleBUri, cid: cycleBCid }, 134 + }, 135 + createdAt: time5, 136 + indexedAt: time5, 137 + likeCount: 0, 138 + replyCount: 0, 139 + }, 140 + { 141 + uri: cycleBUri, 142 + cid: cycleBCid, 143 + authorDid: TEST_USERS[2].did, 144 + text: "cycle-b", 145 + reply: { 146 + root: { uri: parentUri, cid: parentCid }, 147 + parent: { uri: cycleAUri, cid: cycleACid }, 148 + }, 149 + createdAt: time6, 150 + indexedAt: time6, 151 + likeCount: 0, 152 + replyCount: 0, 153 + }, 154 + ]); 155 + 156 + const blockUri = `at://${TEST_USERS[1].did}/so.sprk.graph.block/block1`; 157 + await ctx.db.models.Block.create({ 158 + uri: blockUri, 159 + cid: validCid, 160 + authorDid: TEST_USERS[1].did, 161 + subject: TEST_USERS[2].did, 162 + createdAt: time6, 163 + indexedAt: time6, 164 + }); 165 + 166 + await t.step( 167 + "returns thread-style descendants from a post anchor", 168 + async () => { 169 + const res = await app.request( 170 + `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${ 171 + encodeURIComponent(parentUri) 172 + }&depth=5&parentHeight=5&sort=oldest&limit=50`, 173 + ); 174 + assertEquals(res.status, 200); 175 + 176 + const body = await res.json() as OutputSchema; 177 + assertEquals(body.thread.length, 5); 178 + assertEquals(body.thread[0].uri, parentUri); 179 + assertEquals(body.thread[0].depth, 0); 180 + assertEquals(body.thread[1].uri, reply1Uri); 181 + assertEquals(body.thread[1].depth, 1); 182 + assertEquals(body.thread[2].uri, reply3Uri); 183 + assertEquals(body.thread[2].depth, 1); 184 + assertEquals(body.thread[3].uri, reply4Uri); 185 + assertEquals(body.thread[3].depth, 1); 186 + assertEquals(body.thread[4].uri, reply2Uri); 187 + assertEquals(body.thread[4].depth, 2); 188 + }, 189 + ); 190 + 191 + await t.step("applies limit and cursor pagination", async () => { 192 + const firstRes = await app.request( 193 + `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${ 194 + encodeURIComponent(parentUri) 195 + }&depth=5&parentHeight=5&sort=oldest&limit=2`, 196 + ); 197 + assertEquals(firstRes.status, 200); 198 + const firstBody = await firstRes.json() as OutputSchema; 199 + assertEquals(firstBody.thread.length, 2); 200 + assertEquals(firstBody.thread[0].uri, parentUri); 201 + assertEquals(firstBody.thread[1].uri, reply1Uri); 202 + assertEquals(firstBody.cursor, "2"); 203 + 204 + const secondRes = await app.request( 205 + `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${ 206 + encodeURIComponent(parentUri) 207 + }&depth=5&parentHeight=5&sort=oldest&limit=2&cursor=${ 208 + firstBody.cursor 209 + }`, 210 + ); 211 + assertEquals(secondRes.status, 200); 212 + const secondBody = await secondRes.json() as OutputSchema; 213 + assertEquals(secondBody.thread.length, 2); 214 + assertEquals(secondBody.thread[0].uri, reply3Uri); 215 + assertEquals(secondBody.thread[1].uri, reply4Uri); 216 + assertEquals(secondBody.cursor, "4"); 217 + 218 + const thirdRes = await app.request( 219 + `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${ 220 + encodeURIComponent(parentUri) 221 + }&depth=5&parentHeight=5&sort=oldest&limit=2&cursor=${ 222 + secondBody.cursor 223 + }`, 224 + ); 225 + assertEquals(thirdRes.status, 200); 226 + const thirdBody = await thirdRes.json() as OutputSchema; 227 + assertEquals(thirdBody.thread.length, 1); 228 + assertEquals(thirdBody.thread[0].uri, reply2Uri); 229 + assertEquals(thirdBody.cursor, undefined); 230 + }); 231 + 232 + await t.step("respects newest sibling ordering", async () => { 233 + const res = await app.request( 234 + `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${ 235 + encodeURIComponent(parentUri) 236 + }&depth=1&parentHeight=5&sort=newest&limit=50`, 237 + ); 238 + assertEquals(res.status, 200); 239 + 240 + const body = await res.json() as OutputSchema; 241 + assertEquals(body.thread.length, 4); 242 + assertEquals(body.thread[0].uri, parentUri); 243 + assertEquals(body.thread[1].uri, reply4Uri); 244 + assertEquals(body.thread[2].uri, reply3Uri); 245 + assertEquals(body.thread[3].uri, reply1Uri); 246 + }); 247 + 248 + await t.step("respects top sibling ordering", async () => { 249 + const res = await app.request( 250 + `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${ 251 + encodeURIComponent(parentUri) 252 + }&depth=1&parentHeight=5&sort=top&limit=50`, 253 + ); 254 + assertEquals(res.status, 200); 255 + 256 + const body = await res.json() as OutputSchema; 257 + assertEquals(body.thread.length, 4); 258 + assertEquals(body.thread[0].uri, parentUri); 259 + assertEquals(body.thread[1].uri, reply4Uri); 260 + assertEquals(body.thread[2].uri, reply1Uri); 261 + assertEquals(body.thread[3].uri, reply3Uri); 262 + }); 263 + 264 + await t.step("includes ancestors for reply anchor", async () => { 265 + const res = await app.request( 266 + `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${ 267 + encodeURIComponent(reply2Uri) 268 + }&depth=0&parentHeight=5&sort=oldest&limit=50`, 269 + ); 270 + assertEquals(res.status, 200); 271 + 272 + const body = await res.json() as OutputSchema; 273 + assertEquals(body.thread.length, 3); 274 + assertEquals(body.thread[0].uri, parentUri); 275 + assertEquals(body.thread[0].depth, -2); 276 + assertEquals(body.thread[1].uri, reply1Uri); 277 + assertEquals(body.thread[1].depth, -1); 278 + assertEquals(body.thread[2].uri, reply2Uri); 279 + assertEquals(body.thread[2].depth, 0); 280 + }); 281 + 282 + await t.step("applies parent/root 3p-block moderation", async () => { 283 + const res = await app.request( 284 + `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${ 285 + encodeURIComponent(parentUri) 286 + }&depth=5&parentHeight=5&sort=oldest&limit=50`, 287 + ); 288 + assertEquals(res.status, 200); 289 + 290 + const body = await res.json() as OutputSchema; 291 + const blocked = body.thread.find((item) => item.uri === reply2Uri); 292 + assertEquals( 293 + blocked?.value.$type, 294 + "so.sprk.feed.defs#blockedPost", 295 + ); 296 + }); 297 + 298 + await t.step("hides taken-down thread records for standard viewers", async () => { 299 + await ctx.db.models.Record.create({ 300 + uri: reply4Uri, 301 + cid: reply4Cid, 302 + did: TEST_USERS[3].did, 303 + collectionName: "app.bsky.feed.post", 304 + rkey: "cross4", 305 + createdAt: time4, 306 + indexedAt: time4, 307 + json: JSON.stringify({ 308 + $type: "app.bsky.feed.post", 309 + text: "reply-4", 310 + createdAt: time4, 311 + }), 312 + takedownRef: "TAKEDOWN", 313 + }); 314 + 315 + const res = await app.request( 316 + `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${ 317 + encodeURIComponent(parentUri) 318 + }&depth=5&parentHeight=5&sort=oldest&limit=50`, 319 + ); 320 + assertEquals(res.status, 200); 321 + const body = await res.json() as OutputSchema; 322 + assertEquals(body.thread.some((item) => item.uri === reply4Uri), false); 323 + }); 324 + 325 + await t.step("stops on cyclic ancestors and keeps anchor at depth 0", async () => { 326 + const res = await app.request( 327 + `/xrpc/so.sprk.feed.getCrosspostThread?anchor=${ 328 + encodeURIComponent(cycleAUri) 329 + }&depth=0&parentHeight=10&sort=oldest&limit=50`, 330 + ); 331 + assertEquals(res.status, 200); 332 + 333 + const body = await res.json() as OutputSchema; 334 + assertEquals(body.thread.length, 2); 335 + assertEquals(body.thread[0].uri, cycleBUri); 336 + assertEquals(body.thread[0].depth, -1); 337 + assertEquals(body.thread[1].uri, cycleAUri); 338 + assertEquals(body.thread[1].depth, 0); 339 + }); 340 + } finally { 341 + await cleanup(); 342 + } 343 + }, 344 + });
+85
tests/stories_test.ts
··· 98 98 } 99 99 }, 100 100 ); 101 + 102 + await t.step( 103 + "getTimeline excludes expired stories from the viewer", 104 + async () => { 105 + const { ctx, cleanup } = await createTestContext({ 106 + actors: false, 107 + profiles: false, 108 + posts: false, 109 + replies: false, 110 + stories: false, 111 + likes: false, 112 + reposts: false, 113 + follows: false, 114 + blocks: false, 115 + audio: false, 116 + generators: false, 117 + preferences: false, 118 + records: false, 119 + actorSync: false, 120 + }); 121 + 122 + try { 123 + const viewerDid = TEST_USERS[0].did; 124 + const expiredUri = `at://${viewerDid}/so.sprk.story.post/expired`; 125 + const activeUri = `at://${viewerDid}/so.sprk.story.post/active`; 126 + 127 + const expiredDate = new Date(); 128 + expiredDate.setHours(expiredDate.getHours() - 25); 129 + 130 + await ctx.db.models.Story.create({ 131 + uri: expiredUri, 132 + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqexpired", 133 + authorDid: viewerDid, 134 + createdAt: expiredDate.toISOString(), 135 + indexedAt: expiredDate.toISOString(), 136 + media: { 137 + $type: "so.sprk.media.image", 138 + image: { 139 + $type: "blob", 140 + ref: { 141 + $link: VALID_BLOB_CID, 142 + }, 143 + alt: "Expired viewer story image", 144 + aspectRatio: { width: 1080, height: 1920 }, 145 + mimeType: "image/jpeg", 146 + size: 250000, 147 + }, 148 + }, 149 + labels: [], 150 + }); 151 + 152 + await ctx.db.models.Story.create({ 153 + uri: activeUri, 154 + cid: "bafyreihivhfhv6rh4x4a4znkqrvqwp5xw4xvqjqactive", 155 + authorDid: viewerDid, 156 + createdAt: new Date().toISOString(), 157 + indexedAt: new Date().toISOString(), 158 + media: { 159 + $type: "so.sprk.media.image", 160 + image: { 161 + $type: "blob", 162 + ref: { 163 + $link: VALID_BLOB_CID, 164 + }, 165 + alt: "Active viewer story image", 166 + aspectRatio: { width: 1080, height: 1920 }, 167 + mimeType: "image/jpeg", 168 + size: 250000, 169 + }, 170 + }, 171 + labels: [], 172 + }); 173 + 174 + const timeline = await ctx.dataplane.stories.getTimeline( 175 + viewerDid, 176 + [], 177 + ); 178 + 179 + assertEquals(timeline.stories.length, 1); 180 + assertEquals(timeline.stories[0].uri, activeUri); 181 + } finally { 182 + await cleanup(); 183 + } 184 + }, 185 + ); 101 186 }, 102 187 });