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

fix following lists (#30)

authored by

Davi Rodrigues and committed by
GitHub
be74e1d9 573c51dc

+160 -244
+43 -86
services/appview/api/so/sprk/graph/getFollowers.ts
··· 1 1 import { Server } from "../../../../lexicon/index.ts"; 2 2 import { AppContext } from "../../../../main.ts"; 3 - import type * as SoSprkActorDefs from "../../../../lexicon/types/so/sprk/actor/defs.ts"; 4 3 import { FollowDocument } from "../../../../data-plane/server/index.ts"; 4 + import { ensureValidDid, isValidHandle } from "@atproto/syntax"; 5 + import { RootFilterQuery } from "mongoose"; 6 + import { XRPCError } from "@sprk/xrpc-server"; 7 + import { OutputSchema } from "../../../../lexicon/types/so/sprk/graph/getFollowers.ts"; 8 + import { getProfileView } from "../../../../utils/profile-helper.ts"; 5 9 6 10 export default function (server: Server, ctx: AppContext) { 7 11 server.so.sprk.graph.getFollowers({ 8 12 auth: ctx.authVerifier.standardOptional, 9 - handler: async ({ params }) => { 13 + handler: async ({ params, auth }) => { 10 14 const { actor } = params; 11 - const limit = params.limit ?? 50; 15 + const limit = params.limit; 12 16 const cursor = params.cursor; 17 + const viewerDid = auth.credentials.type === "standard" 18 + ? auth.credentials.iss 19 + : undefined; 13 20 14 - if (!actor) { 15 - throw new Error("Actor is required"); 21 + let actorDid; 22 + 23 + if (isValidHandle(actor)) { 24 + const actorDidDoc = await ctx.resolver.resolveHandleToDidDoc(actor); 25 + actorDid = actorDidDoc.did; 26 + } else { 27 + try { 28 + ensureValidDid(actor); 29 + actorDid = actor; 30 + } catch (error) { 31 + ctx.logger.warn( 32 + { did: actor, error: (error as Error).message }, 33 + "Failed to ensure valid DID", 34 + ); 35 + throw new XRPCError(400, "Invalid actor DID"); 36 + } 16 37 } 17 38 18 - // Validate limit 19 - if (limit < 1 || limit > 100) { 20 - throw new Error("Limit must be between 1 and 100"); 21 - } 39 + const actorPref = await ctx.db.models.UserPreference.findOne({ 40 + userDid: actorDid, 41 + }); 42 + const actorFollowMode = actorPref?.followMode || "sprk"; 22 43 23 44 // Build query 24 - const query: { subject: string; _id?: { $gt: string } } = { 25 - subject: actor, 45 + const query: RootFilterQuery<FollowDocument> = { 46 + subject: actorDid, 47 + type: actorFollowMode, 26 48 }; 49 + 27 50 if (cursor) { 28 51 query._id = { $gt: cursor }; 29 52 } ··· 36 59 37 60 // Get profile views for each follower 38 61 const profileViews = await Promise.all( 39 - followers.map(async (follow: FollowDocument) => { 40 - const profile = await ctx.db.models.Profile.findOne({ 41 - authorDid: follow.authorDid, 42 - }); 43 - 44 - // Basic profile view with just DID and handle 45 - const basicProfileView: SoSprkActorDefs.ProfileView = { 46 - $type: "so.sprk.actor.defs#profileView", 47 - did: follow.authorDid, 48 - handle: follow.authorHandle, 49 - viewer: { 50 - $type: "so.sprk.actor.defs#viewerState", 51 - following: follow.uri, 52 - }, 53 - }; 54 - 55 - // If we found a profile, add the additional fields 56 - if (profile) { 57 - const avatarUrl = profile.avatar?.ref?.$link 58 - ? `https://media.sprk.so/avatar/tiny/${profile.authorDid}/${profile.avatar.ref.$link}/webp` 59 - : undefined; 60 - 61 - return { 62 - ...basicProfileView, 63 - displayName: profile.displayName, 64 - description: profile.description, 65 - avatar: avatarUrl, 66 - indexedAt: profile.indexedAt, 67 - createdAt: profile.createdAt, 68 - }; 69 - } 70 - 71 - return basicProfileView; 72 - }), 62 + followers.map((follow: FollowDocument) => 63 + getProfileView(ctx, follow.authorDid, viewerDid) 64 + ), 73 65 ); 74 66 75 67 // Get next cursor ··· 78 70 : undefined; 79 71 80 72 // Get subject profile if it exists 81 - const subjectProfile = await ctx.db.models.Profile.findOne({ 82 - authorDid: actor, 83 - }); 84 - 85 - // Basic subject profile view with just DID and handle 86 - let handle = null; 87 - try { 88 - if (actor) { 89 - const didData = await ctx.resolver.resolveDidToDidDoc(actor); 90 - handle = didData.handle; 91 - } 92 - } catch (error) { 93 - ctx.logger.warn( 94 - { did: actor, error: (error as Error).message }, 95 - "Failed to resolve DID to handle", 96 - ); 97 - } 98 - const subjectProfileView: SoSprkActorDefs.ProfileView = { 99 - $type: "so.sprk.actor.defs#profileView", 100 - did: actor, 101 - handle: handle ?? "unknown", 102 - }; 103 - 104 - // If we found the subject profile, add the additional fields 105 - if (subjectProfile) { 106 - const avatarUrl = subjectProfile.avatar?.ref?.$link 107 - ? `https://media.sprk.so/avatar/tiny/${subjectProfile.authorDid}/${subjectProfile.avatar.ref.$link}/webp` 108 - : undefined; 109 - 110 - Object.assign(subjectProfileView, { 111 - handle: subjectProfile.authorHandle, 112 - displayName: subjectProfile.displayName, 113 - description: subjectProfile.description, 114 - avatar: avatarUrl, 115 - indexedAt: subjectProfile.indexedAt, 116 - createdAt: subjectProfile.createdAt, 117 - }); 118 - } 73 + const subjectProfileView = await getProfileView(ctx, actorDid, viewerDid); 119 74 120 - return { 75 + const res = { 121 76 encoding: "application/json", 122 77 body: { 123 78 subject: subjectProfileView, 124 79 followers: profileViews, 125 80 cursor: nextCursor, 126 - }, 127 - }; 81 + } satisfies OutputSchema, 82 + } as const; 83 + 84 + return res; 128 85 }, 129 86 }); 130 87 }
+48 -151
services/appview/api/so/sprk/graph/getFollows.ts
··· 1 1 import { Server } from "../../../../lexicon/index.ts"; 2 2 import { FollowDocument } from "../../../../data-plane/server/index.ts"; 3 - import { PipelineStage } from "mongoose"; 4 3 import { AppContext } from "../../../../main.ts"; 5 - import type * as SoSprkActorDefs from "../../../../lexicon/types/so/sprk/actor/defs.ts"; 4 + import { ensureValidDid, isValidHandle } from "@atproto/syntax"; 5 + import { RootFilterQuery } from "mongoose"; 6 + import { XRPCError } from "@sprk/xrpc-server"; 7 + import { OutputSchema } from "../../../../lexicon/types/so/sprk/graph/getFollows.ts"; 8 + import { getProfileView } from "../../../../utils/profile-helper.ts"; 6 9 7 10 export default function (server: Server, ctx: AppContext) { 8 11 server.so.sprk.graph.getFollows({ 9 12 auth: ctx.authVerifier.standardOptional, 10 13 handler: async ({ params, auth }) => { 11 14 const { actor } = params; 12 - const limit = params.limit ?? 50; 15 + const limit = params.limit; 13 16 const cursor = params.cursor; 14 - const userDid = auth.credentials.type === "standard" 17 + const viewerDid = auth.credentials.type === "standard" 15 18 ? auth.credentials.iss 16 19 : undefined; 17 20 18 - if (!actor) { 19 - throw new Error("Actor is required"); 20 - } 21 - 22 - // Validate limit 23 - if (limit < 1 || limit > 100) { 24 - throw new Error("Limit must be between 1 and 100"); 25 - } 26 - 27 - let follows = []; 28 - 29 - // If user is authenticated, respect their follow preferences 30 - if (userDid) { 31 - const viewerPref = await ctx.db.models.UserPreference.findOne({ 32 - userDid, 33 - }); 34 - const followType = viewerPref?.followMode || "sprk"; 21 + let actorDid; 35 22 36 - // Build query with the user's preferred follow type 37 - const query: { 38 - authorDid: string; 39 - type: string; 40 - _id?: { $gt: string }; 41 - } = { 42 - authorDid: actor, 43 - type: followType, 44 - }; 45 - 46 - if (cursor) { 47 - query._id = { $gt: cursor }; 48 - } 49 - 50 - follows = await ctx.db.models.Follow.find(query) 51 - .sort({ _id: 1 }) 52 - .limit(limit) 53 - .lean(); 23 + if (isValidHandle(actor)) { 24 + const actorDidDoc = await ctx.resolver.resolveHandleToDidDoc(actor); 25 + actorDid = actorDidDoc.did; 54 26 } else { 55 - // For unauthenticated users, get all follow types without duplicates 56 - // We use aggregation to get distinct follows by subject 57 - const pipelineStages: PipelineStage[] = [ 58 - { $match: { authorDid: actor } }, 59 - ]; 60 - 61 - if (cursor) { 62 - pipelineStages.push({ $match: { _id: { $gt: cursor } } }); 27 + try { 28 + ensureValidDid(actor); 29 + actorDid = actor; 30 + } catch (error) { 31 + ctx.logger.warn( 32 + { did: actor, error: (error as Error).message }, 33 + "Failed to ensure valid DID", 34 + ); 35 + throw new XRPCError(400, "Invalid actor DID"); 63 36 } 37 + } 64 38 65 - // Group by subject to avoid duplicates 66 - pipelineStages.push( 67 - { $sort: { _id: 1 } }, 68 - { 69 - $group: { 70 - _id: "$subject", 71 - doc: { $first: "$$ROOT" }, 72 - }, 73 - }, 74 - { $replaceRoot: { newRoot: "$doc" } }, 75 - { $sort: { _id: 1 } }, 76 - { $limit: limit }, 77 - ); 39 + const actorPref = await ctx.db.models.UserPreference.findOne({ 40 + userDid: actorDid, 41 + }); 42 + const actorFollowMode = actorPref?.followMode || "sprk"; 43 + 44 + // Build query 45 + const query: RootFilterQuery<FollowDocument> = { 46 + authorDid: actorDid, 47 + type: actorFollowMode, 48 + }; 78 49 79 - follows = await ctx.db.models.Follow.aggregate(pipelineStages); 50 + if (cursor) { 51 + query._id = { $gt: cursor }; 80 52 } 53 + 54 + // Get follows with pagination 55 + const follows = await ctx.db.models.Follow.find(query) 56 + .sort({ _id: 1 }) 57 + .limit(limit) 58 + .lean(); 81 59 82 60 // Get next cursor 83 61 const nextCursor = follows.length === limit ··· 86 64 87 65 // Get profile views for each follow 88 66 const profileViews = await Promise.all( 89 - follows.map(async (follow: FollowDocument) => { 90 - const profile = await ctx.db.models.Profile.findOne({ 91 - authorDid: follow.subject, 92 - }); 93 - 94 - // Get handle through DID resolution 95 - let handle = null; 96 - try { 97 - const didData = await ctx.resolver.resolveDidToDidDoc( 98 - follow.subject, 99 - ); 100 - handle = didData.handle; 101 - } catch (error) { 102 - ctx.logger.warn( 103 - { did: follow.subject, error: (error as Error).message }, 104 - "Failed to resolve DID to handle", 105 - ); 106 - } 107 - 108 - // Basic profile view with just DID and handle 109 - const basicProfileView: SoSprkActorDefs.ProfileView = { 110 - $type: "so.sprk.actor.defs#profileView", 111 - did: follow.subject, 112 - handle: handle ?? "unknown.bsky.social", 113 - viewer: { 114 - $type: "so.sprk.actor.defs#viewerState", 115 - followedBy: follow.uri, 116 - }, 117 - }; 118 - 119 - // If we found a profile, add the additional fields 120 - if (profile) { 121 - const avatarUrl = profile.avatar?.ref?.$link 122 - ? `https://media.sprk.so/avatar/tiny/${profile.authorDid}/${profile.avatar.ref.$link}/webp` 123 - : undefined; 124 - 125 - return { 126 - ...basicProfileView, 127 - displayName: profile.displayName, 128 - description: profile.description, 129 - avatar: avatarUrl, 130 - indexedAt: profile.indexedAt, 131 - createdAt: profile.createdAt, 132 - }; 133 - } 134 - 135 - return basicProfileView; 136 - }), 67 + follows.map((follow: FollowDocument) => 68 + getProfileView(ctx, follow.subject, viewerDid) 69 + ), 137 70 ); 138 71 139 72 // Get subject profile if it exists 140 - const subjectProfile = await ctx.db.models.Profile.findOne({ 141 - authorDid: actor, 142 - }); 143 - const subjectProfileView: SoSprkActorDefs.ProfileView = { 144 - $type: "so.sprk.actor.defs#profileView", 145 - did: actor, 146 - handle: "unknown", 147 - }; 148 - // If we found the subject profile, add the additional fields 149 - if (subjectProfile) { 150 - const avatarUrl = subjectProfile.avatar?.ref?.$link 151 - ? `https://media.sprk.so/avatar/tiny/${subjectProfile.authorDid}/${subjectProfile.avatar.ref.$link}/webp` 152 - : undefined; 73 + const subjectProfileView = await getProfileView(ctx, actorDid, viewerDid); 153 74 154 - Object.assign(subjectProfileView, { 155 - handle: subjectProfile.authorHandle, 156 - displayName: subjectProfile.displayName, 157 - description: subjectProfile.description, 158 - avatar: avatarUrl, 159 - indexedAt: subjectProfile.indexedAt, 160 - createdAt: subjectProfile.createdAt, 161 - }); 162 - } else { 163 - let handle = null; 164 - try { 165 - if (actor) { 166 - const didData = await ctx.resolver.resolveDidToDidDoc(actor); 167 - handle = didData.handle; 168 - } 169 - } catch (error) { 170 - ctx.logger.warn( 171 - { did: actor, error: (error as Error).message }, 172 - "Failed to resolve DID to handle", 173 - ); 174 - } 175 - Object.assign(subjectProfileView, { 176 - handle: handle ?? "unknown", 177 - }); 178 - } 179 - 180 - return { 75 + const res = { 181 76 encoding: "application/json", 182 77 body: { 183 78 subject: subjectProfileView, 184 79 follows: profileViews, 185 80 cursor: nextCursor, 186 - }, 187 - }; 81 + } satisfies OutputSchema, 82 + } as const; 83 + 84 + return res; 188 85 }, 189 86 }); 190 87 }
+1 -1
services/appview/deno.json
··· 1 1 { 2 2 "tasks": { 3 - "dev": "deno run --allow-net --allow-env --allow-sys --allow-read --allow-ffi --watch main.ts", 3 + "dev": "deno run --allow-net --allow-env --allow-sys --allow-read --allow-ffi --inspect --watch main.ts", 4 4 "codegen": "deno run -A jsr:@sprk/lex-cli@^0.1.5 gen-server --hono-import hono --yes ./lexicon ../../lexicons", 5 5 "start": "deno run --allow-net --allow-env --allow-sys --allow-read --allow-ffi main.ts", 6 6 "docker-dev": "docker compose -f compose.dev.yaml up --build --watch"
+8
services/appview/main.ts
··· 45 45 export function createApp(ctx: AppContext): Hono<AppEnv> { 46 46 const app = new Hono<AppEnv>(); 47 47 48 + app.use("*", async (c, next) => { 49 + await next(); 50 + if (c.res.status === 500) { 51 + ctx.logger.error(`Internal server error`, c.error); 52 + console.log(c.error); 53 + } 54 + }); 55 + 48 56 app.use("*", cors()); 49 57 app.use("*", logger()); 50 58 app.use("*", async (c, next) => {
+60 -6
services/appview/utils/profile-helper.ts
··· 1 1 import { Database } from "../data-plane/server/index.ts"; 2 2 import type { 3 3 ProfileAssociated, 4 + ProfileView, 4 5 ProfileViewBasic, 5 6 ProfileViewDetailed, 6 7 ViewerState, ··· 61 62 : undefined, 62 63 stories: stories.length > 0 ? stories : undefined, 63 64 }; 65 + } 66 + 67 + export async function getProfileView( 68 + ctx: AppContext, 69 + actorDid: string, 70 + viewerDid?: string, 71 + ): Promise<ProfileView> { 72 + const { db, resolver } = ctx; 73 + 74 + const profile = await db.models.Profile.findOne({ authorDid: actorDid }); 75 + const handle = profile?.authorHandle ?? 76 + (await resolver.resolveDidToHandle(actorDid)) ?? "unknown.invalid"; 77 + 78 + const baseView: ProfileView = { 79 + $type: "so.sprk.actor.defs#profileView", 80 + did: actorDid, 81 + handle: handle, 82 + }; 83 + 84 + if (viewerDid) { 85 + const [following, followedBy] = await Promise.all([ 86 + db.models.Follow.findOne({ 87 + authorDid: viewerDid, 88 + subject: actorDid, 89 + }).select("uri").lean(), 90 + db.models.Follow.findOne({ 91 + authorDid: actorDid, 92 + subject: viewerDid, 93 + }).select("uri").lean(), 94 + ]); 95 + 96 + baseView.viewer = { 97 + $type: "so.sprk.actor.defs#viewerState", 98 + following: following?.uri, 99 + followedBy: followedBy?.uri, 100 + }; 101 + } 102 + 103 + if (profile) { 104 + const avatarUrl = profile.avatar?.ref?.$link 105 + ? `https://media.sprk.so/avatar/tiny/${profile.authorDid}/${profile.avatar.ref.$link}/webp` 106 + : undefined; 107 + 108 + return { 109 + ...baseView, 110 + displayName: profile.displayName, 111 + description: profile.description, 112 + avatar: avatarUrl, 113 + indexedAt: profile.indexedAt, 114 + createdAt: profile.createdAt, 115 + }; 116 + } 117 + 118 + return baseView; 64 119 } 65 120 66 121 /** ··· 197 252 return []; 198 253 }), 199 254 200 - // Count unique followers across both Sprk and Bsky follow types 201 - ctx.db.models.Follow.aggregate([ 202 - { $match: { subject: actorDid } }, 203 - { $group: { _id: "$authorDid" } }, 204 - { $count: "total" }, 205 - ]).then((result: { total: number }[]) => result[0]?.total || 0), 255 + // Count followers based on actor's follow mode preference 256 + ctx.db.models.Follow.countDocuments({ 257 + subject: actorDid, 258 + type: actorFollowMode, 259 + }), 206 260 207 261 // Count follows based on actor's follow mode preference 208 262 ctx.db.models.Follow.countDocuments({