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

feedgen pipeline

+234 -186
+13 -8
api/so/sprk/feed/getFeed.ts
··· 212 212 let resHeaders: Record<string, string> | undefined = undefined; 213 213 try { 214 214 // @TODO currently passthrough auth headers from pds 215 - const result = await client.call("so.sprk.feed.getFeedSkeleton", { 216 - feed: params.feed, 217 - // The feedgen is not guaranteed to honor the limit, but we try it. 218 - limit: params.limit, 219 - cursor: params.cursor, 220 - }, undefined, { 221 - headers 222 - }); 215 + const result = await client.call( 216 + "so.sprk.feed.getFeedSkeleton", 217 + { 218 + feed: params.feed, 219 + // The feedgen is not guaranteed to honor the limit, but we try it. 220 + limit: params.limit, 221 + cursor: params.cursor, 222 + }, 223 + undefined, 224 + { 225 + headers, 226 + }, 227 + ); 223 228 224 229 skeleton = result.data; 225 230
+26 -176
api/so/sprk/feed/getSuggestedFeeds.ts
··· 1 - import { Server } from "../../../../lex/index.ts"; 1 + import { mapDefined } from "@atp/common"; 2 2 import { AppContext } from "../../../../context.ts"; 3 - import { GeneratorDocument } from "../../../../data-plane/db/models.ts"; 4 - import { getProfileView } from "../../../../utils/profile-helper.ts"; 5 - import type * as SoSprkFeedDefs from "../../../../lex/types/so/sprk/feed/defs.ts"; 6 - import { decodeBase64, encodeBase64 } from "@std/encoding"; 7 - 8 - interface CursorData { 9 - likeCount: number; 10 - id: string; 11 - } 12 - 13 - // Helper function to parse cursor 14 - function parseCursor(cursor: string): CursorData { 15 - try { 16 - const decodedCursor = new TextDecoder().decode(decodeBase64(cursor)); 17 - const [likeCountStr, id] = decodedCursor.split("::"); 18 - 19 - if (!likeCountStr || !id) { 20 - throw new Error("Invalid cursor format"); 21 - } 22 - 23 - const likeCount = parseInt(likeCountStr, 10); 24 - if (isNaN(likeCount)) { 25 - throw new Error("Invalid cursor format"); 26 - } 27 - 28 - return { likeCount, id }; 29 - } catch { 30 - throw new Error("Invalid cursor format"); 31 - } 32 - } 33 - 34 - // Helper function to generate cursor 35 - function generateCursor(likeCount: number, id: string): string { 36 - return encodeBase64( 37 - new TextEncoder().encode(`${likeCount}::${id}`), 38 - ); 39 - } 40 - 41 - // Transform GeneratorDocument to GeneratorView 42 - async function transformGeneratorToView( 43 - generator: GeneratorDocument, 44 - ctx: AppContext, 45 - viewerDid?: string, 46 - ): Promise<SoSprkFeedDefs.GeneratorView> { 47 - // Create the creator profile view 48 - const creator = await getProfileView(ctx, generator.authorDid, viewerDid); 49 - 50 - // Handle viewer state if user is authenticated 51 - let viewer: SoSprkFeedDefs.GeneratorViewerState | undefined; 52 - if (viewerDid) { 53 - const like = await ctx.db.models.Like.findOne({ 54 - authorDid: viewerDid, 55 - subject: generator.uri, 56 - }).lean(); 57 - 58 - if (like) { 59 - viewer = { 60 - $type: "so.sprk.feed.defs#generatorViewerState", 61 - like: like.uri, 62 - }; 63 - } 64 - } 65 - 66 - return { 67 - $type: "so.sprk.feed.defs#generatorView", 68 - uri: generator.uri, 69 - cid: generator.cid, 70 - did: generator.authorDid, 71 - creator, 72 - displayName: generator.displayName, 73 - description: generator.description || undefined, 74 - descriptionFacets: generator.descriptionFacets || undefined, 75 - avatar: generator.avatar?.ref?.$link 76 - ? `https://media.sprk.so/avatar/tiny/${generator.authorDid}/${generator.avatar.ref.$link}/webp` 77 - : undefined, 78 - likeCount: generator.likeCount || 0, 79 - acceptsInteractions: generator.acceptsInteractions || undefined, 80 - labels: undefined, // Labels will be handled separately if needed 81 - viewer, 82 - indexedAt: generator.indexedAt, 83 - }; 84 - } 3 + import { parseString } from "../../../../hydration/util.ts"; 4 + import { Server } from "../../../../lex/index.ts"; 5 + import { resHeaders } from "../../../util.ts"; 85 6 86 7 export default function (server: Server, ctx: AppContext) { 87 8 server.so.sprk.feed.getSuggestedFeeds({ 88 9 auth: ctx.authVerifier.standardOptional, 89 - handler: async ({ params, auth }) => { 90 - try { 91 - const { limit = 50, cursor } = params; 92 - const userDid = auth.credentials.type === "standard" 93 - ? auth.credentials.iss 94 - : undefined; 95 - 96 - // Validate limit 97 - if (limit < 1 || limit > 100) { 98 - throw new Error("Limit must be between 1 and 100"); 99 - } 100 - 101 - // Parse cursor if provided 102 - let cursorData: CursorData | undefined; 103 - if (cursor) { 104 - cursorData = parseCursor(cursor); 105 - } 10 + handler: async ({ auth, params }) => { 11 + const viewer = auth.credentials.iss; 106 12 107 - // Build query for generators sorted by like count 108 - const query: Record<string, unknown> = {}; 13 + // @NOTE no need to coordinate the cursor for appview swap, as v1 doesn't use the cursor 14 + const suggestedRes = await ctx.dataplane.feedGens.getSuggestedFeeds( 15 + params.limit, 16 + params.cursor, 17 + ); 18 + const uris = suggestedRes.uris; 19 + const hydrateCtx = ctx.hydrator.createContext({ viewer }); 20 + const hydration = await ctx.hydrator.hydrateFeedGens(uris, hydrateCtx); 21 + const feedViews = mapDefined( 22 + uris, 23 + (uri) => ctx.views.feedGenerator(uri, hydration), 24 + ); 109 25 110 - // Add cursor-based pagination 111 - if (cursorData) { 112 - query.$or = [ 113 - { likeCount: { $lt: cursorData.likeCount } }, 114 - { likeCount: cursorData.likeCount, _id: { $lt: cursorData.id } }, 115 - ]; 116 - } 117 - 118 - const generators = await ctx.db.models.Generator.find(query) 119 - .sort({ likeCount: -1, _id: -1 }); 120 - 121 - // Check if there are more results 122 - const hasMore = generators.length > limit; 123 - if (hasMore) { 124 - generators.pop(); // Remove the extra item 125 - } 126 - 127 - // Transform generators to GeneratorView format 128 - const generatorViews = await Promise.all( 129 - generators.map((generator) => 130 - transformGeneratorToView(generator, ctx, userDid) 131 - ), 132 - ); 133 - 134 - // Generate next cursor if there are more results 135 - let nextCursor: string | undefined; 136 - if (hasMore && generators.length > 0) { 137 - const lastGenerator = generators[generators.length - 1]; 138 - nextCursor = generateCursor( 139 - lastGenerator.likeCount || 0, 140 - String(lastGenerator._id), 141 - ); 142 - } 143 - 144 - // Prepare response 145 - const response: { 146 - feeds: SoSprkFeedDefs.GeneratorView[]; 147 - cursor?: string; 148 - } = { 149 - feeds: generatorViews, 150 - }; 151 - 152 - if (nextCursor) { 153 - response.cursor = nextCursor; 154 - } 155 - 156 - return { 157 - encoding: "application/json", 158 - body: response, 159 - }; 160 - } catch (error) { 161 - // Handle specific error cases 162 - if (error instanceof Error) { 163 - const message = error.message; 164 - 165 - if (message.includes("cursor") || message.includes("Cursor")) { 166 - return { 167 - status: 400, 168 - message: "The provided cursor is invalid", 169 - }; 170 - } 171 - 172 - if (message.includes("limit") || message.includes("Limit")) { 173 - return { 174 - status: 400, 175 - message: "Limit must be between 1 and 100", 176 - }; 177 - } 178 - } 179 - 180 - // Log unexpected errors and rethrow 181 - console.error("Unexpected error in getSuggestedFeeds:", error); 182 - throw error; 183 - } 26 + return { 27 + encoding: "application/json", 28 + body: { 29 + feeds: feedViews, 30 + cursor: parseString(suggestedRes.cursor), 31 + }, 32 + headers: resHeaders({}), 33 + }; 184 34 }, 185 35 }); 186 36 }
+41
data-plane/db/pagination.ts
··· 397 397 return { primary: result.key }; 398 398 } 399 399 } 400 + 401 + type LikeCountCidResult = { likeCount: number; cid: string }; 402 + type LikeCountCidLabeledResult = KeysetCursor; 403 + 404 + /** 405 + * Custom keyset for paginating by like count with cid as tie-breaker. 406 + * Useful for sorting items by popularity or engagement metrics. 407 + */ 408 + export class LikeCountCidKeyset extends GenericKeyset< 409 + LikeCountCidResult, 410 + LikeCountCidLabeledResult 411 + > { 412 + constructor() { 413 + super("likeCount", "cid"); 414 + } 415 + 416 + labelResult(result: LikeCountCidResult): LikeCountCidLabeledResult { 417 + return { 418 + primary: result.likeCount.toString(), 419 + secondary: result.cid, 420 + }; 421 + } 422 + 423 + labeledResultToCursor(labeled: LikeCountCidLabeledResult) { 424 + return { 425 + primary: labeled.primary, 426 + secondary: labeled.secondary, 427 + }; 428 + } 429 + 430 + cursorToLabeledResult(cursor: KeysetCursor) { 431 + const likeCount = parseInt(cursor.primary, 10); 432 + if (isNaN(likeCount)) { 433 + throw new InvalidRequestError("Malformed cursor: invalid like count"); 434 + } 435 + return { 436 + primary: cursor.primary, 437 + secondary: cursor.secondary, 438 + }; 439 + } 440 + }
+3
data-plane/index.ts
··· 2 2 import { Database } from "./db/index.ts"; 3 3 import { getLogger, Logger } from "@logtape/logtape"; 4 4 import { Blocks } from "./routes/blocks.ts"; 5 + import { FeedGens } from "./routes/feed-gens.ts"; 5 6 import { Feeds } from "./routes/feeds.ts"; 6 7 import { Follows } from "./routes/follows.ts"; 7 8 import { Likes } from "./routes/likes.ts"; ··· 32 33 33 34 // Route handlers as root-level properties 34 35 public blocks: Blocks; 36 + public feedGens: FeedGens; 35 37 public feeds: Feeds; 36 38 public follows: Follows; 37 39 public likes: Likes; ··· 58 60 59 61 // Initialize all route handlers 60 62 this.blocks = new Blocks(db); 63 + this.feedGens = new FeedGens(db); 61 64 this.feeds = new Feeds(db); 62 65 this.follows = new Follows(db); 63 66 this.likes = new Likes(db);
+113
data-plane/routes/feed-gens.ts
··· 1 + import { Database } from "../db/index.ts"; 2 + import { LikeCountCidKeyset, TimeCidKeyset } from "../db/pagination.ts"; 3 + 4 + export class FeedGens { 5 + private db: Database; 6 + private timeCidKeyset: TimeCidKeyset; 7 + private likeCountCidKeyset: LikeCountCidKeyset; 8 + 9 + constructor(db: Database) { 10 + this.db = db; 11 + this.timeCidKeyset = new TimeCidKeyset(); 12 + this.likeCountCidKeyset = new LikeCountCidKeyset(); 13 + } 14 + 15 + async getActorFeeds(actorDid: string, limit = 50, cursor?: string) { 16 + // Build query for feeds created by this actor 17 + const feedsQuery = this.db.models.Generator.find({ 18 + authorDid: actorDid, 19 + }); 20 + 21 + // Apply pagination using TimeCidKeyset 22 + const paginatedQuery = this.timeCidKeyset.paginate(feedsQuery, { 23 + limit, 24 + cursor, 25 + direction: "desc", 26 + }); 27 + 28 + const feeds = await paginatedQuery.exec(); 29 + 30 + // Generate cursor from the last item if we have a full page 31 + let nextCursor: string | undefined; 32 + if (feeds.length === limit && feeds.length > 0) { 33 + const lastFeed = feeds[feeds.length - 1]; 34 + nextCursor = this.timeCidKeyset.pack({ 35 + primary: lastFeed.createdAt, 36 + secondary: lastFeed.cid, 37 + }); 38 + } 39 + 40 + return { 41 + uris: feeds.map((f) => f.uri), 42 + cursor: nextCursor, 43 + }; 44 + } 45 + 46 + async getSuggestedFeeds(limit = 50, cursor?: string) { 47 + // Get feeds sorted by like count (most liked feeds first) 48 + const feedsQuery = this.db.models.Generator.find(); 49 + 50 + // Apply pagination using LikeCountCidKeyset 51 + const paginatedQuery = this.likeCountCidKeyset.paginate(feedsQuery, { 52 + limit, 53 + cursor, 54 + direction: "desc", 55 + }); 56 + 57 + const feeds = await paginatedQuery.exec(); 58 + 59 + // Generate cursor from the last item if we have a full page 60 + let nextCursor: string | undefined; 61 + if (feeds.length === limit && feeds.length > 0) { 62 + const lastFeed = feeds[feeds.length - 1]; 63 + nextCursor = this.likeCountCidKeyset.pack({ 64 + primary: lastFeed.likeCount.toString(), 65 + secondary: lastFeed.cid, 66 + }); 67 + } 68 + 69 + return { 70 + uris: feeds.map((f) => f.uri), 71 + cursor: nextCursor, 72 + }; 73 + } 74 + 75 + async searchFeedGenerators(query: string, limit = 50, cursor?: string) { 76 + const trimmedQuery = query.trim(); 77 + 78 + // Build query for feed generators matching the search query 79 + const feedsQuery = this.db.models.Generator.find( 80 + trimmedQuery 81 + ? { displayName: { $regex: trimmedQuery, $options: "i" } } 82 + : {}, 83 + ); 84 + 85 + // Apply pagination using TimeCidKeyset 86 + const paginatedQuery = this.timeCidKeyset.paginate(feedsQuery, { 87 + limit, 88 + cursor, 89 + direction: "desc", 90 + }); 91 + 92 + const feeds = await paginatedQuery.exec(); 93 + 94 + // Generate cursor from the last item if we have a full page 95 + let nextCursor: string | undefined; 96 + if (feeds.length === limit && feeds.length > 0) { 97 + const lastFeed = feeds[feeds.length - 1]; 98 + nextCursor = this.timeCidKeyset.pack({ 99 + primary: lastFeed.createdAt, 100 + secondary: lastFeed.cid, 101 + }); 102 + } 103 + 104 + return { 105 + uris: feeds.map((f) => f.uri), 106 + cursor: nextCursor, 107 + }; 108 + } 109 + 110 + getFeedGeneratorStatus() { 111 + throw new Error("unimplemented"); 112 + } 113 + }
+1 -1
data-plane/routes/records.ts
··· 2 2 import { AtUri } from "@atp/syntax"; 3 3 import { ids } from "../../lex/lexicons.ts"; 4 4 import { keyBy } from "@atp/common"; 5 - import { Code, DataPlaneError, compositeTime } from "../util.ts"; 5 + import { Code, compositeTime, DataPlaneError } from "../util.ts"; 6 6 7 7 export type Record = { 8 8 record: string;
+1 -1
data-plane/util.ts
··· 287 287 if (!ts1) return ts2; 288 288 if (!ts2) return ts1; 289 289 return new Date(ts1) < new Date(ts2) ? ts1 : ts2; 290 - } 290 + }
+36
views/index.ts
··· 534 534 }; 535 535 } 536 536 537 + feedGenerator( 538 + uri: string, 539 + state: HydrationState, 540 + ): Un$Typed<GeneratorView> | undefined { 541 + const feedgen = state.feedgens?.get(uri); 542 + if (!feedgen) return; 543 + const creatorDid = uriToDid(uri); 544 + const creator = this.profile(creatorDid, state); 545 + if (!creator) return; 546 + const viewer = state.feedgenViewers?.get(uri); 547 + const aggs = state.feedgenAggs?.get(uri); 548 + 549 + return { 550 + uri, 551 + cid: feedgen.cid, 552 + did: feedgen.record.did, 553 + creator, 554 + displayName: feedgen.record.displayName, 555 + description: feedgen.record.description, 556 + descriptionFacets: feedgen.record.descriptionFacets, 557 + avatar: feedgen.record?.avatar 558 + ? `${this.mediaCdn}/avatar/medium/${creatorDid}/${ 559 + cidFromBlobJson(feedgen.record.avatar) 560 + }/webp` 561 + : undefined, 562 + likeCount: aggs?.likes ?? 0, 563 + acceptsInteractions: feedgen.record.acceptsInteractions, 564 + viewer: viewer 565 + ? { 566 + like: viewer.like, 567 + } 568 + : undefined, 569 + indexedAt: this.indexedAt(feedgen).toISOString(), 570 + }; 571 + } 572 + 537 573 profile( 538 574 did: string, 539 575 state: HydrationState,