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

getPostThread route

+296 -135
+3
services/appview/src/index.ts
··· 13 13 import { authMiddleware, optionalAuthMiddleware } from './auth/middleware.js' 14 14 import { createFeedRouter } from './feed/feed.js' 15 15 import { createGetPostsRouter } from './routes/getPosts.js' 16 + import { createGetPostThreadRouter } from './routes/getPostThread.js' 16 17 import wellKnownRouter from './well-known.js' 17 18 import { DidResolver } from '@atproto/identity' 18 19 ··· 71 72 app.route('/', feedRouter) 72 73 73 74 const getPostsRouter = createGetPostsRouter(ctx) 75 + const getPostThreadRouter = createGetPostThreadRouter(ctx) 74 76 app.route('/', getPostsRouter) 77 + app.route('/', getPostThreadRouter) 75 78 76 79 app.route('/', wellKnownRouter()) 77 80
+161
services/appview/src/routes/getPostThread.ts
··· 1 + import { Hono } from 'hono' 2 + import { OutputSchema as GetPostThreadView } from '../lexicon/types/so/sprk/feed/getPostThread.js' 3 + import type * as SoSprkFeedDefs from '../lexicon/types/so/sprk/feed/defs.js' 4 + import { AppContext } from '../index.js' 5 + import { transformPostToPostView } from '../utils/post-transformer.js' 6 + import { optionalAuthMiddleware } from '../auth/middleware.js' 7 + 8 + export const createGetPostThreadRouter = (ctx: AppContext) => { 9 + const router = new Hono() 10 + 11 + router.get('/xrpc/so.sprk.feed.getPostThread', optionalAuthMiddleware, async (c) => { 12 + const uri = c.req.query('uri') 13 + const depth = parseInt(c.req.query('depth') || '6', 10) 14 + const parentHeight = parseInt(c.req.query('parentHeight') || '80', 10) 15 + const userDid = c.get('did') as string | undefined 16 + 17 + if (!uri) { 18 + return c.json({ error: 'URI is required' }, 400) 19 + } 20 + 21 + try { 22 + // Get the requested post 23 + const mainPost = await ctx.db.models.Post.findOne({ uri }).lean() 24 + 25 + if (!mainPost) { 26 + return c.json({ 27 + thread: { 28 + $type: 'so.sprk.feed.defs#notFoundPost', 29 + uri, 30 + notFound: true, 31 + }, 32 + } as GetPostThreadView, 404) 33 + } 34 + 35 + // Convert the main post to a PostView 36 + const mainPostView = await transformPostToPostView(mainPost, ctx.db, ctx.resolver, userDid) 37 + 38 + // Get parent posts if this is a reply 39 + const parentPosts: SoSprkFeedDefs.PostView[] = [] 40 + 41 + if (mainPost.reply) { 42 + // Navigate up to the root collecting parent posts 43 + let currentParentUri = mainPost.reply.parent.uri 44 + let parentsCollected = 0 45 + 46 + while (currentParentUri && parentsCollected < parentHeight) { 47 + const parentPost = await ctx.db.models.Post.findOne({ uri: currentParentUri }).lean() 48 + 49 + if (!parentPost) { 50 + break 51 + } 52 + 53 + const parentPostView = await transformPostToPostView(parentPost, ctx.db, ctx.resolver, userDid) 54 + parentPosts.unshift(parentPostView) // Add at the beginning so root is first 55 + 56 + // If we reached the root, stop 57 + if (!parentPost.reply) { 58 + break 59 + } 60 + 61 + // Move to the next parent 62 + currentParentUri = parentPost.reply.parent.uri 63 + parentsCollected++ 64 + } 65 + } 66 + 67 + // Get replies to the main post (direct children) 68 + const replies = await ctx.db.models.Post.find({ 69 + 'reply.parent.uri': mainPost.uri, 70 + }).sort({ createdAt: 1 }).lean() 71 + 72 + // Convert replies to thread views recursively 73 + const replyThreads = await Promise.all( 74 + replies.map(async (reply) => { 75 + return await buildThreadView(reply, ctx, userDid, depth - 1) 76 + }) 77 + ) 78 + 79 + // Check for user specific thread context 80 + const threadContext: SoSprkFeedDefs.ThreadContext = {} 81 + 82 + // Build the main thread view 83 + const thread = { 84 + $type: 'so.sprk.feed.defs#threadViewPost', 85 + post: mainPostView, 86 + replies: replyThreads, 87 + threadContext, 88 + } as SoSprkFeedDefs.ThreadViewPost 89 + 90 + // Add parent if it exists 91 + if (parentPosts.length > 0) { 92 + // Create a nested parent structure 93 + let currentParent: any = undefined // Will be converted to proper type 94 + 95 + // Build parent thread structure from oldest to newest 96 + for (const parentPost of parentPosts) { 97 + currentParent = { 98 + $type: 'so.sprk.feed.defs#threadViewPost', 99 + post: parentPost, 100 + parent: currentParent, 101 + replies: [], 102 + threadContext: {}, 103 + } 104 + } 105 + 106 + // Set the direct parent of the main post 107 + thread.parent = currentParent 108 + } 109 + 110 + return c.json({ thread } as GetPostThreadView) 111 + } catch (error) { 112 + console.error('Error fetching post thread:', error) 113 + return c.json({ error: 'Failed to get post thread' }, 500) 114 + } 115 + }) 116 + 117 + return router 118 + } 119 + 120 + // Recursive function to build a thread view for a post and its replies 121 + async function buildThreadView( 122 + post: any, 123 + ctx: AppContext, 124 + userDid?: string, 125 + depth = 0, 126 + ): Promise<SoSprkFeedDefs.ThreadViewPost> { 127 + // Convert the post to a post view 128 + const postView = await transformPostToPostView(post, ctx.db, ctx.resolver, userDid) 129 + 130 + // If we've reached the maximum depth, don't fetch replies 131 + if (depth <= 0) { 132 + return { 133 + $type: 'so.sprk.feed.defs#threadViewPost', 134 + post: postView, 135 + replies: [], 136 + threadContext: {}, 137 + } as SoSprkFeedDefs.ThreadViewPost 138 + } 139 + 140 + // Get replies to this post 141 + const replies = await ctx.db.models.Post.find({ 142 + 'reply.parent.uri': post.uri, 143 + }).sort({ createdAt: 1 }).lean() 144 + 145 + // Convert replies to thread views recursively 146 + const replyThreads = await Promise.all( 147 + replies.map(async (reply) => { 148 + return await buildThreadView(reply, ctx, userDid, depth - 1) 149 + }) 150 + ) 151 + 152 + // Check for user specific thread context 153 + const threadContext: SoSprkFeedDefs.ThreadContext = {} 154 + 155 + return { 156 + $type: 'so.sprk.feed.defs#threadViewPost', 157 + post: postView, 158 + replies: replyThreads, 159 + threadContext, 160 + } as SoSprkFeedDefs.ThreadViewPost 161 + }
+4 -135
services/appview/src/routes/getPosts.ts
··· 1 1 import { Hono } from 'hono' 2 2 3 3 import { OutputSchema as GetPostsView } from '../lexicon/types/so/sprk/feed/getPosts.js' 4 - import type * as SoSprkFeedDefs from '../lexicon/types/so/sprk/feed/defs.js' 5 - import type { ProfileViewBasic } from '../lexicon/types/so/sprk/actor/defs.js' 6 - import type { Label } from '../lexicon/types/com/atproto/label/defs.js' 7 - import type * as SoSprkEmbedImages from '../lexicon/types/so/sprk/embed/images.js' 8 - import type * as SoSprkEmbedVideo from '../lexicon/types/so/sprk/embed/video.js' 9 - import { Database, PostDocument } from '../db.js' 10 - import { BidirectionalResolver } from '../id-resolver.js' 11 4 import { AppContext } from '../index.js' 12 - 13 - // Transform DB post to PostView format 14 - async function transformPostToPostView( 15 - post: PostDocument, 16 - db: Database, 17 - resolver: BidirectionalResolver, 18 - userDid?: string, 19 - ): Promise<SoSprkFeedDefs.PostView> { 20 - // Get like count 21 - const likeCount = await db.models.Like.countDocuments({ subject: post.uri }) 22 - 23 - // Get reply count 24 - const replyCount = await db.models.Post.countDocuments({ 25 - 'reply.parent.uri': post.uri, 26 - }) 27 - 28 - // Get repost count 29 - const repostCount = await db.models.Repost.countDocuments({ 30 - 'subject.uri': post.uri, 31 - }) 32 - 33 - // Get quote count - posts that embed this post 34 - // const quoteCount = await db.models.Post.countDocuments({ 35 - // 'embed.uri': post.uri 36 - // }) 37 - 38 - const lookCount = await db.models.Look.countDocuments({ 39 - 'subject.uri': post.uri, 40 - }) 41 - 42 - // Get author profile data 43 - const profile = await db.models.Profile.findOne({ 44 - authorDid: post.authorDid, 45 - }).lean() 46 - 47 - // Create the author object 48 - const author: ProfileViewBasic = { 49 - did: post.authorDid, 50 - handle: post.authorHandle, 51 - displayName: profile?.displayName ?? post.authorHandle, 52 - avatar: `https://cdn.sprk.so/avatar/${post.authorDid}`, 53 - } 54 - 55 - let embed 56 - 57 - if (post.embed?.$type === 'so.sprk.embed.images') { 58 - embed = { 59 - $type: 'so.sprk.embed.images#view', 60 - images: post.embed.images.map((img: any) => ({ 61 - thumb: `https://cdn.sprk.so/image/${post.authorDid}/${img.image.ref.$link}`, 62 - fullsize: `https://cdn.sprk.so/image/${post.authorDid}/${img.image.ref.$link}`, 63 - alt: img.alt, 64 - aspectRatio: img.aspectRatio, 65 - })), 66 - } satisfies SoSprkEmbedImages.View 67 - } else if (post.embed?.$type === 'so.sprk.embed.video') { 68 - const did = await resolver.resolveDidToDidDoc(post.authorDid) 69 - const pdsDomain = did.pds.replace('https://', '') 70 - embed = { 71 - $type: 'so.sprk.embed.video#view', 72 - cid: post.cid, 73 - playlist: `https://videocdn.sprk.so/${pdsDomain}/${post.authorDid}/${post.embed.video.ref.$link}`, 74 - thumbnail: `https://cdn.sprk.so/${post.authorDid}/${post.embed.video.ref.$link}/thumbnail`, 75 - } satisfies SoSprkEmbedVideo.View 76 - } 77 - 78 - // Convert labels if any 79 - const labels = post.labels 80 - ? Array.isArray(post.labels) 81 - ? (post.labels as Label[]) 82 - : undefined 83 - : undefined 84 - 85 - // Build viewer state with information about the current user's interactions with the post 86 - const viewer: SoSprkFeedDefs.ViewerState = { 87 - // $type: 'so.sprk.feed.defs#viewerState', 88 - } 89 - 90 - // Only check user interactions if a userDid is provided 91 - if (userDid) { 92 - // Check if the user has liked this post 93 - const like = await db.models.Like.findOne({ 94 - subject: post.uri, 95 - authorDid: userDid, 96 - }) 97 - if (like) { 98 - viewer.like = like.uri 99 - } 100 - 101 - // Check if the user has reposted this post 102 - const repost = await db.models.Repost.findOne({ 103 - 'subject.uri': post.uri, 104 - authorDid: userDid, 105 - }) 106 - if (repost) { 107 - viewer.repost = repost.uri 108 - } 109 - 110 - // Check if the user has looked at this post 111 - const look = await db.models.Look.findOne({ 112 - 'subject.uri': post.uri, 113 - authorDid: userDid, 114 - }) 115 - if (look) { 116 - viewer.look = look.uri 117 - } 118 - } 119 - 120 - return { 121 - uri: post.uri, 122 - cid: post.cid, 123 - author, 124 - record: { 125 - text: post.text, 126 - facets: post.facets, 127 - langs: post.langs, 128 - tags: post.tags, 129 - }, 130 - embed: embed, 131 - viewer, 132 - replyCount, 133 - repostCount, 134 - likeCount, 135 - lookCount, 136 - indexedAt: post.indexedAt, 137 - labels, 138 - } 139 - } 5 + import { transformPostToPostView } from '../utils/post-transformer.js' 6 + import { BidirectionalResolver } from '../id-resolver.js' 7 + import { Database } from '../db.js' 8 + import type * as SoSprkFeedDefs from '../lexicon/types/so/sprk/feed/defs.js' 140 9 141 10 // Function to fetch posts by URIs 142 11 async function getPosts(
+128
services/appview/src/utils/post-transformer.ts
··· 1 + import type * as SoSprkFeedDefs from '../lexicon/types/so/sprk/feed/defs.js' 2 + import type { ProfileViewBasic } from '../lexicon/types/so/sprk/actor/defs.js' 3 + import type { Label } from '../lexicon/types/com/atproto/label/defs.js' 4 + import type * as SoSprkEmbedImages from '../lexicon/types/so/sprk/embed/images.js' 5 + import type * as SoSprkEmbedVideo from '../lexicon/types/so/sprk/embed/video.js' 6 + import { Database, PostDocument } from '../db.js' 7 + import { BidirectionalResolver } from '../id-resolver.js' 8 + 9 + // Transform DB post to PostView format 10 + export async function transformPostToPostView( 11 + post: PostDocument, 12 + db: Database, 13 + resolver: BidirectionalResolver, 14 + userDid?: string, 15 + ): Promise<SoSprkFeedDefs.PostView> { 16 + // Get like count 17 + const likeCount = await db.models.Like.countDocuments({ subject: post.uri }) 18 + 19 + // Get reply count 20 + const replyCount = await db.models.Post.countDocuments({ 21 + 'reply.parent.uri': post.uri, 22 + }) 23 + 24 + // Get repost count 25 + const repostCount = await db.models.Repost.countDocuments({ 26 + 'subject.uri': post.uri, 27 + }) 28 + 29 + const lookCount = await db.models.Look.countDocuments({ 30 + 'subject.uri': post.uri, 31 + }) 32 + 33 + // Get author profile data 34 + const profile = await db.models.Profile.findOne({ 35 + authorDid: post.authorDid, 36 + }).lean() 37 + 38 + // Create the author object 39 + const author: ProfileViewBasic = { 40 + did: post.authorDid, 41 + handle: post.authorHandle, 42 + displayName: profile?.displayName ?? post.authorHandle, 43 + avatar: `https://cdn.sprk.so/avatar/${post.authorDid}`, 44 + } 45 + 46 + let embed 47 + 48 + if (post.embed?.$type === 'so.sprk.embed.images') { 49 + embed = { 50 + $type: 'so.sprk.embed.images#view', 51 + images: post.embed.images.map((img: any) => ({ 52 + thumb: `https://cdn.sprk.so/image/${post.authorDid}/${img.image.ref.$link}`, 53 + fullsize: `https://cdn.sprk.so/image/${post.authorDid}/${img.image.ref.$link}`, 54 + alt: img.alt, 55 + aspectRatio: img.aspectRatio, 56 + })), 57 + } satisfies SoSprkEmbedImages.View 58 + } else if (post.embed?.$type === 'so.sprk.embed.video') { 59 + const did = await resolver.resolveDidToDidDoc(post.authorDid) 60 + const pdsDomain = did.pds.replace('https://', '') 61 + embed = { 62 + $type: 'so.sprk.embed.video#view', 63 + cid: post.cid, 64 + playlist: `https://videocdn.sprk.so/${pdsDomain}/${post.authorDid}/${post.embed.video.ref.$link}`, 65 + thumbnail: `https://cdn.sprk.so/${post.authorDid}/${post.embed.video.ref.$link}/thumbnail`, 66 + } satisfies SoSprkEmbedVideo.View 67 + } 68 + 69 + // Convert labels if any 70 + const labels = post.labels 71 + ? Array.isArray(post.labels) 72 + ? (post.labels as Label[]) 73 + : undefined 74 + : undefined 75 + 76 + // Build viewer state with information about the current user's interactions with the post 77 + const viewer: SoSprkFeedDefs.ViewerState = {} 78 + 79 + // Only check user interactions if a userDid is provided 80 + if (userDid) { 81 + // Check if the user has liked this post 82 + const like = await db.models.Like.findOne({ 83 + subject: post.uri, 84 + authorDid: userDid, 85 + }) 86 + if (like) { 87 + viewer.like = like.uri 88 + } 89 + 90 + // Check if the user has reposted this post 91 + const repost = await db.models.Repost.findOne({ 92 + 'subject.uri': post.uri, 93 + authorDid: userDid, 94 + }) 95 + if (repost) { 96 + viewer.repost = repost.uri 97 + } 98 + 99 + // Check if the user has looked at this post 100 + const look = await db.models.Look.findOne({ 101 + 'subject.uri': post.uri, 102 + authorDid: userDid, 103 + }) 104 + if (look) { 105 + viewer.look = look.uri 106 + } 107 + } 108 + 109 + return { 110 + uri: post.uri, 111 + cid: post.cid, 112 + author, 113 + record: { 114 + text: post.text, 115 + facets: post.facets, 116 + langs: post.langs, 117 + tags: post.tags, 118 + }, 119 + embed: embed, 120 + viewer, 121 + replyCount, 122 + repostCount, 123 + likeCount, 124 + lookCount, 125 + indexedAt: post.indexedAt, 126 + labels, 127 + } 128 + }