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

add so.sprk.feed.getAuthorFeed

+183 -1
+4 -1
services/appview/src/index.ts
··· 13 13 createBidirectionalResolver, 14 14 createIdResolver, 15 15 } from './id-resolver.js' 16 + import { createGetProfileRouter } from './routes/actor/getProfile.js' 17 + import { createGetAuthorFeedRouter } from './routes/feed/getAuthorFeed.js' 16 18 import { createGetPostsRouter } from './routes/feed/getPosts.js' 17 19 import { createGetPostThreadRouter } from './routes/feed/getPostThread.js' 18 - import { createGetProfileRouter } from './routes/actor/getProfile.js' 19 20 import { createGetFollowersRouter } from './routes/graph/getFollowers.js' 20 21 import { createGetFollowsRouter } from './routes/graph/getFollows.js' 21 22 import wellKnownRouter from './well-known.js' ··· 79 80 const getProfileRouter = createGetProfileRouter(ctx) 80 81 const getFollowersRouter = createGetFollowersRouter(ctx) 81 82 const getFollowsRouter = createGetFollowsRouter(ctx) 83 + const getAuthorFeedRouter = createGetAuthorFeedRouter(ctx) 82 84 app.route('/', getPostsRouter) 83 85 app.route('/', getPostThreadRouter) 84 86 app.route('/', getProfileRouter) 85 87 app.route('/', getFollowersRouter) 86 88 app.route('/', getFollowsRouter) 89 + app.route('/', getAuthorFeedRouter) 87 90 88 91 app.route('/', wellKnownRouter()) 89 92
+179
services/appview/src/routes/feed/getAuthorFeed.ts
··· 1 + import { Hono } from 'hono' 2 + import { HTTPException } from 'hono/http-exception' 3 + import { optionalAuthMiddleware } from '../../auth/middleware.js' 4 + import { AppContext } from '../../index.js' 5 + import { transformPostToPostView } from '../../utils/post-transformer.js' 6 + 7 + export const createGetAuthorFeedRouter = (ctx: AppContext) => { 8 + const router = new Hono() 9 + 10 + router.get( 11 + '/xrpc/so.sprk.feed.getAuthorFeed', 12 + optionalAuthMiddleware, 13 + async (c) => { 14 + // Get query parameters 15 + const actor = c.req.query('actor') 16 + const limit = parseInt(c.req.query('limit') || '50', 10) 17 + const cursor = c.req.query('cursor') 18 + const filter = c.req.query('filter') || 'posts_with_replies' 19 + const includePins = c.req.query('includePins') === 'true' 20 + const viewerDid = c.get('did') as string | undefined 21 + 22 + // Validate required parameters 23 + if (!actor) { 24 + throw new HTTPException(400, { message: 'Actor is required' }) 25 + } 26 + 27 + // Validate limit 28 + if (isNaN(limit) || limit < 1 || limit > 100) { 29 + throw new HTTPException(400, { 30 + message: 'Invalid limit: must be between 1 and 100', 31 + }) 32 + } 33 + 34 + try { 35 + // Resolve DID if handle is provided 36 + let actorDid = actor 37 + if (!actorDid.startsWith('did:')) { 38 + try { 39 + const didDoc = await ctx.resolver.resolveHandleToDidDoc(actor) 40 + actorDid = didDoc.did 41 + } catch (error) { 42 + throw new HTTPException(400, { 43 + message: 'Invalid actor: could not resolve handle', 44 + }) 45 + } 46 + } 47 + 48 + // Check if user is blocked or blocking the actor 49 + if (viewerDid) { 50 + const userIsBlocked = await ctx.db.models.Block.findOne({ 51 + authorDid: actorDid, 52 + subject: viewerDid, 53 + }) 54 + 55 + if (userIsBlocked) { 56 + throw new HTTPException(403, { message: 'BlockedByActor' }) 57 + } 58 + 59 + const userIsBlocking = await ctx.db.models.Block.findOne({ 60 + authorDid: viewerDid, 61 + subject: actorDid, 62 + }) 63 + 64 + if (userIsBlocking) { 65 + throw new HTTPException(403, { message: 'BlockedActor' }) 66 + } 67 + } 68 + 69 + // Build query based on filter 70 + const query: any = { authorDid: actorDid, reply: null } 71 + 72 + if (filter === 'posts_with_media') { 73 + query.$or = [ 74 + { 'embed.$type': 'so.sprk.embed.images' }, 75 + { 'embed.$type': 'so.sprk.embed.video' }, 76 + ] 77 + } else if (filter === 'posts_with_video') { 78 + query['embed.$type'] = 'so.sprk.embed.video' 79 + } 80 + 81 + // Parse cursor if provided 82 + let createdAtCursor 83 + let idCursor 84 + 85 + if (cursor) { 86 + try { 87 + const decodedCursor = Buffer.from(cursor, 'base64').toString( 88 + 'utf-8', 89 + ) 90 + const [timestamp, id] = decodedCursor.split('::') 91 + createdAtCursor = timestamp 92 + idCursor = id 93 + } catch (error) { 94 + throw new HTTPException(400, { message: 'Invalid cursor format' }) 95 + } 96 + } 97 + 98 + // Add cursor-based pagination 99 + if (createdAtCursor && idCursor) { 100 + query.$or = [ 101 + { createdAt: { $lt: createdAtCursor } }, 102 + { createdAt: createdAtCursor, _id: { $lt: idCursor } }, 103 + ] 104 + } 105 + 106 + // Get posts from database 107 + const posts = await ctx.db.models.Post.find(query) 108 + .sort({ createdAt: -1, _id: -1 }) 109 + .limit(limit + 1) // Get one extra for cursor 110 + .lean() 111 + 112 + // Check if we have more results (for cursor) 113 + const hasMore = posts.length > limit 114 + if (hasMore) { 115 + posts.pop() // Remove the extra item 116 + } 117 + 118 + // Include pinned posts if requested 119 + let pinnedPosts = [] 120 + if (includePins) { 121 + // Get profile to find pinned posts 122 + const profile = await ctx.db.models.Profile.findOne({ 123 + authorDid: actorDid, 124 + }).lean() 125 + 126 + if (profile?.pinnedPost) { 127 + const pinnedPostUri = profile.pinnedPost.uri 128 + const pinnedPost = await ctx.db.models.Post.findOne({ 129 + uri: pinnedPostUri, 130 + }).lean() 131 + 132 + if (pinnedPost) { 133 + pinnedPosts.push(pinnedPost) 134 + } 135 + } 136 + } 137 + 138 + // Transform posts to feed view posts 139 + const feedViewPosts = await Promise.all( 140 + [...pinnedPosts, ...posts].map(async (post) => { 141 + const postView = await transformPostToPostView( 142 + post, 143 + ctx.db, 144 + ctx.resolver, 145 + viewerDid, 146 + ) 147 + 148 + return { 149 + post: postView, 150 + } 151 + }), 152 + ) 153 + 154 + // Generate next cursor if there are more results 155 + let nextCursor 156 + if (hasMore && posts.length > 0) { 157 + const lastPost = posts[posts.length - 1] 158 + nextCursor = Buffer.from( 159 + `${lastPost.createdAt}::${lastPost._id}`, 160 + ).toString('base64') 161 + } 162 + 163 + return c.json({ 164 + cursor: nextCursor, 165 + feed: feedViewPosts, 166 + }) 167 + } catch (error) { 168 + if (error instanceof HTTPException) { 169 + throw error 170 + } 171 + 172 + console.error('Error fetching author feed:', error) 173 + throw new HTTPException(500, { message: 'Failed to fetch author feed' }) 174 + } 175 + }, 176 + ) 177 + 178 + return router 179 + }