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

getProfiles (#29)

authored by

Davi Rodrigues and committed by
GitHub
9a1d518a 5c886465

+325 -218
+2
services/appview/api/index.ts
··· 16 16 import resolveHandle from "./com/atproto/identity/resolveHandle.ts"; 17 17 import getStories from "./so/sprk/feed/getStories.ts"; 18 18 import getStoriesTimeline from "./so/sprk/feed/getStoriesTimeline.ts"; 19 + import getProfiles from "./so/sprk/actor/getProfiles.ts"; 19 20 20 21 export default function (server: Server, ctx: AppContext) { 21 22 getAccountInfos(server, ctx); ··· 23 24 updateSubjectStatus(server, ctx); 24 25 getPosts(server, ctx); 25 26 getProfile(server, ctx); 27 + getProfiles(server, ctx); 26 28 getAuthorFeed(server, ctx); 27 29 getPostThread(server, ctx); 28 30 getFollows(server, ctx);
+2 -217
services/appview/api/so/sprk/actor/getProfile.ts
··· 1 - import { ensureValidDid, isValidHandle } from "@atproto/syntax"; 2 1 import { Server } from "../../../../lexicon/index.ts"; 3 - import type { Label } from "../../../../lexicon/types/com/atproto/label/defs.ts"; 4 - import type * as ComAtprotoRepoStrongRef from "../../../../lexicon/types/com/atproto/repo/strongRef.ts"; 5 - import type * as SoSprkActorDefs from "../../../../lexicon/types/so/sprk/actor/defs.ts"; 6 2 import { AppContext } from "../../../../main.ts"; 7 - import { StoryDocument } from "../../../../data-plane/server/index.ts"; 8 - import { XRPCError } from "@sprk/xrpc-server"; 3 + import { getProfile } from "../../../../utils/profile-helper.ts"; 9 4 10 5 export default function (server: Server, ctx: AppContext) { 11 6 server.so.sprk.actor.getProfile({ ··· 16 11 ? auth.credentials.iss 17 12 : undefined; 18 13 19 - let actorDidDoc; 20 - if (isValidHandle(actorParam)) { 21 - actorDidDoc = await ctx.resolver.resolveHandleToDidDoc(actorParam); 22 - } else { 23 - try { 24 - ensureValidDid(actorParam); 25 - actorDidDoc = await ctx.resolver.resolveDidToDidDoc(actorParam); 26 - } catch (_err) { 27 - throw new XRPCError(400, "Invalid actor"); 28 - } 29 - } 30 - 31 - const actorDid = actorDidDoc.did; 32 - const now = new Date().toISOString(); 33 - 34 - await ctx.indexingService.indexHandle(actorDid, now); 35 - 36 - // First check if actor exists and has profile 37 - let actorDoc = await ctx.db.models.Actor.findOne({ 38 - did: actorDid, 39 - }); 40 - 41 - const profile = await ctx.db.models.Profile.findOne({ 42 - authorDid: actorDid, 43 - }); 44 - 45 - if (!actorDoc) { 46 - try { 47 - ctx.logger.info( 48 - { did: actorDid }, 49 - "No profile found, attempting to index", 50 - ); 51 - await ctx.indexingService.indexHandle(actorDid, now, true); 52 - 53 - // Refetch after indexing 54 - actorDoc = await ctx.db.models.Actor.findOne({ 55 - did: actorDid, 56 - }); 57 - } catch (error) { 58 - ctx.logger.error({ error, did: actorDid }, "Failed to index handle"); 59 - } 60 - } 61 - 62 - if (!actorDoc) { 63 - throw new XRPCError(404, "Actor not found", "NotFound"); 64 - } 65 - 66 - if (!profile) { 67 - throw new XRPCError(404, "Profile not found", "NotFound"); 68 - } 69 - 70 - // Use actor's handle if available, otherwise resolve from DID 71 - const handle = actorDoc.handle || 72 - (await ctx.resolver.resolveDidToHandle(actorDid)); 73 - 74 - // Get actor's preference for follow mode (used for both viewer state and counting) 75 - const actorPref = await ctx.db.models.UserPreference.findOne({ 76 - userDid: actorDid, 77 - }); 78 - const actorFollowMode = actorPref?.followMode || "sprk"; 79 - 80 - // Build viewer state if a user is authenticated 81 - const viewer: SoSprkActorDefs.ViewerState = {}; 82 - 83 - if (viewerDid) { 84 - // Determine follow mode from viewer's preference for checking if viewer follows profile 85 - const viewerPref = await ctx.db.models.UserPreference.findOne({ 86 - userDid: viewerDid, 87 - }); 88 - const viewerFollowMode = viewerPref?.followMode || "sprk"; 89 - 90 - // Check if viewer follows this profile (use viewer's follow mode) 91 - const follow = await ctx.db.models.Follow.findOne({ 92 - subject: actorDid, 93 - authorDid: viewerDid, 94 - type: viewerFollowMode, 95 - }); 96 - if (follow) viewer.following = follow.uri; 97 - 98 - // Check if this profile follows the viewer (use profile owner's follow mode) 99 - const followedBy = await ctx.db.models.Follow.findOne({ 100 - subject: viewerDid, 101 - authorDid: actorDid, 102 - type: actorFollowMode, 103 - }); 104 - if (followedBy) viewer.followedBy = followedBy.uri; 105 - 106 - // Check block relationships 107 - const block = await ctx.db.models.Block.findOne({ 108 - subject: actorDid, 109 - authorDid: viewerDid, 110 - }); 111 - if (block) viewer.blocking = block.uri; 112 - 113 - const blockedBy = await ctx.db.models.Block.findOne({ 114 - subject: viewerDid, 115 - authorDid: actorDid, 116 - }); 117 - if (blockedBy) viewer.blockedBy = true; 118 - } 119 - 120 - // Check for associated services 121 - const associated: SoSprkActorDefs.ProfileAssociated = {}; 122 - 123 - // Check for feed generators 124 - let feedgensCount = 0; 125 - try { 126 - if (ctx.db.models.Generator) { 127 - feedgensCount = await ctx.db.models.Generator.countDocuments({ 128 - authorDid: actorDid, 129 - }); 130 - } 131 - } catch (_error) { 132 - // Ignore if model doesn't exist 133 - } 134 - 135 - if (feedgensCount > 0) { 136 - associated.feedgens = feedgensCount; 137 - } 138 - 139 - // Get avatar and banner URLs 140 - const avatar = profile.avatar 141 - ? `https://media.sprk.so/avatar/tiny/${actorDid}/${profile.avatar.ref.$link}/webp` 142 - : undefined; 143 - const banner = profile.banner 144 - ? `https://media.sprk.so/img/tiny/${actorDid}/${profile.banner.ref.$link}/webp` 145 - : undefined; 146 - 147 - // Convert labels to the correct type if it exists 148 - let labels: Label[] | undefined = undefined; 149 - if (profile.labels) { 150 - labels = Array.isArray(profile.labels) 151 - ? (profile.labels as Label[]) 152 - : undefined; 153 - } 154 - 155 - // Convert pinnedPost to the correct type if it exists 156 - let pinnedPost: ComAtprotoRepoStrongRef.Main | undefined = undefined; 157 - if (profile.pinnedPost) { 158 - pinnedPost = profile 159 - .pinnedPost as unknown as ComAtprotoRepoStrongRef.Main; 160 - } 161 - 162 - const twentyFourHoursAgo = new Date(); 163 - twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24); 164 - 165 - const [recentStories, followersCount, followsCount, postsCount] = 166 - await Promise.all([ 167 - // Fetch recent stories (within 24 hours) 168 - ctx.db.models.Story.find({ 169 - authorDid: actorDid, 170 - indexedAt: { $gte: twentyFourHoursAgo.toISOString() }, 171 - }) 172 - .sort({ indexedAt: -1 }) 173 - .limit(15) 174 - .lean() 175 - .catch((error: Error) => { 176 - ctx.logger.warn( 177 - { error, actorDid }, 178 - "Failed to fetch stories for profile", 179 - ); 180 - return []; 181 - }), 182 - 183 - // Count unique followers across both Sprk and Bsky follow types 184 - ctx.db.models.Follow.aggregate([ 185 - { $match: { subject: actorDid } }, 186 - { $group: { _id: "$authorDid" } }, 187 - { $count: "total" }, 188 - ]).then((result: { total: number }[]) => result[0]?.total || 0), 189 - 190 - // Count follows based on actor's follow mode preference 191 - ctx.db.models.Follow.countDocuments({ 192 - authorDid: actorDid, 193 - type: actorFollowMode, 194 - }), 195 - 196 - // Count posts 197 - ctx.db.models.Post.countDocuments({ 198 - authorDid: actorDid, 199 - reply: null, 200 - }), 201 - ]); 202 - 203 - // Convert recent stories to strongRefs 204 - const stories: ComAtprotoRepoStrongRef.Main[] = recentStories.map( 205 - (story: StoryDocument) => ({ 206 - uri: story.uri, 207 - cid: story.cid, 208 - }), 209 - ); 210 - 211 - // Build the ProfileViewDetailed response 212 - const profileView: SoSprkActorDefs.ProfileViewDetailed = { 213 - did: actorDid, 214 - handle: handle, 215 - displayName: profile.displayName, 216 - description: profile.description, 217 - avatar, 218 - banner, 219 - followersCount, 220 - followsCount, 221 - postsCount, 222 - associated: Object.keys(associated).length > 0 ? associated : undefined, 223 - indexedAt: profile.indexedAt, 224 - createdAt: profile.createdAt, 225 - viewer: Object.keys(viewer).length > 0 ? viewer : undefined, 226 - labels, 227 - pinnedPost, 228 - stories: stories.length > 0 ? stories : undefined, 229 - }; 14 + const profileView = await getProfile(ctx, actorParam, viewerDid); 230 15 231 16 return { 232 17 encoding: "application/json",
+24
services/appview/api/so/sprk/actor/getProfiles.ts
··· 1 + import { Server } from "../../../../lexicon/index.ts"; 2 + import { AppContext } from "../../../../main.ts"; 3 + import { getProfiles } from "../../../../utils/profile-helper.ts"; 4 + 5 + export default function (server: Server, ctx: AppContext) { 6 + server.so.sprk.actor.getProfiles({ 7 + auth: ctx.authVerifier.standardOptional, 8 + handler: async ({ params, auth }) => { 9 + const { actors: actorParams } = params; 10 + const viewerDid = auth.credentials.type === "standard" 11 + ? auth.credentials.iss 12 + : undefined; 13 + 14 + const profiles = await getProfiles(ctx, actorParams, viewerDid); 15 + 16 + return { 17 + encoding: "application/json", 18 + body: { 19 + profiles, 20 + }, 21 + }; 22 + }, 23 + }); 24 + }
+297 -1
services/appview/utils/profile-helper.ts
··· 1 1 import { Database } from "../data-plane/server/index.ts"; 2 - import type { ProfileViewBasic } from "../lexicon/types/so/sprk/actor/defs.ts"; 2 + import type { 3 + ProfileAssociated, 4 + ProfileViewBasic, 5 + ProfileViewDetailed, 6 + ViewerState, 7 + } from "../lexicon/types/so/sprk/actor/defs.ts"; 3 8 import type * as ComAtprotoRepoStrongRef from "../lexicon/types/com/atproto/repo/strongRef.ts"; 4 9 import type { StoryDocument } from "../data-plane/server/index.ts"; 10 + import type { Label } from "../lexicon/types/com/atproto/label/defs.ts"; 11 + import { ensureValidDid, isValidHandle } from "@atproto/syntax"; 12 + import { AppContext } from "../main.ts"; 13 + import { XRPCError } from "@sprk/xrpc-server"; 5 14 6 15 // Helper function to create ProfileViewBasic with stories 7 16 export async function createProfileViewBasic( ··· 53 62 stories: stories.length > 0 ? stories : undefined, 54 63 }; 55 64 } 65 + 66 + /** 67 + * Get a single profile by actor identifier (handle or DID) 68 + */ 69 + export async function getProfile( 70 + ctx: AppContext, 71 + actorParam: string, 72 + viewerDid?: string, 73 + ): Promise<ProfileViewDetailed> { 74 + const profiles = await getProfiles(ctx, [actorParam], viewerDid); 75 + 76 + if (profiles.length === 0) { 77 + throw new XRPCError(404, "Profile not found", "NotFound"); 78 + } 79 + 80 + return profiles[0]; 81 + } 82 + 83 + /** 84 + * Get multiple profiles in parallel by actor identifiers (handles or DIDs) 85 + */ 86 + export async function getProfiles( 87 + ctx: AppContext, 88 + actorParams: string[], 89 + viewerDid?: string, 90 + ): Promise<ProfileViewDetailed[]> { 91 + if (!actorParams || actorParams.length === 0) { 92 + return []; 93 + } 94 + 95 + const now = new Date().toISOString(); 96 + 97 + // Get viewer preferences once for all profiles if viewer is authenticated 98 + let viewerFollowMode = "sprk"; 99 + 100 + if (viewerDid) { 101 + const viewerPref = await ctx.db.models.UserPreference.findOne({ 102 + userDid: viewerDid, 103 + }); 104 + viewerFollowMode = viewerPref?.followMode || "sprk"; 105 + } 106 + 107 + // Helper function to get a single profile data 108 + const getProfileData = async ( 109 + actorParam: string, 110 + ): Promise<ProfileViewDetailed | null> => { 111 + try { 112 + // Resolve actor identifier to DID 113 + let actorDidDoc; 114 + if (isValidHandle(actorParam)) { 115 + actorDidDoc = await ctx.resolver.resolveHandleToDidDoc(actorParam); 116 + } else { 117 + try { 118 + ensureValidDid(actorParam); 119 + actorDidDoc = await ctx.resolver.resolveDidToDidDoc(actorParam); 120 + } catch (_err) { 121 + return null; // Invalid actor, skip 122 + } 123 + } 124 + 125 + const actorDid = actorDidDoc.did; 126 + 127 + // Index the actor 128 + await ctx.indexingService.indexHandle(actorDid, now); 129 + 130 + // Fetch actor and profile documents in parallel 131 + const [actorDoc, profile] = await Promise.all([ 132 + ctx.db.models.Actor.findOne({ did: actorDid }), 133 + ctx.db.models.Profile.findOne({ authorDid: actorDid }), 134 + ]); 135 + 136 + // If actor doesn't exist, try to index and refetch 137 + let finalActorDoc = actorDoc; 138 + if (!actorDoc) { 139 + try { 140 + ctx.logger.info( 141 + { did: actorDid }, 142 + "No actor found, attempting to index", 143 + ); 144 + await ctx.indexingService.indexHandle(actorDid, now, true); 145 + 146 + // Refetch after indexing 147 + finalActorDoc = await ctx.db.models.Actor.findOne({ 148 + did: actorDid, 149 + }); 150 + } catch (error) { 151 + ctx.logger.error({ error, did: actorDid }, "Failed to index handle"); 152 + return null; 153 + } 154 + } 155 + 156 + if (!finalActorDoc || !profile) { 157 + return null; // Actor or profile not found, skip 158 + } 159 + 160 + // Get actor's handle and preferences 161 + const handle = finalActorDoc.handle || 162 + (await ctx.resolver.resolveDidToHandle(actorDid)); 163 + 164 + const actorPref = await ctx.db.models.UserPreference.findOne({ 165 + userDid: actorDid, 166 + }); 167 + const actorFollowMode = actorPref?.followMode || "sprk"; 168 + 169 + // Twenty-four hours ago for recent stories 170 + const twentyFourHoursAgo = new Date(); 171 + twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24); 172 + 173 + const [ 174 + recentStories, 175 + followersCount, 176 + followsCount, 177 + postsCount, 178 + feedgensCount, 179 + follow, 180 + followedBy, 181 + block, 182 + blockedBy, 183 + ] = await Promise.all([ 184 + // Fetch recent stories (within 24 hours) 185 + ctx.db.models.Story.find({ 186 + authorDid: actorDid, 187 + indexedAt: { $gte: twentyFourHoursAgo.toISOString() }, 188 + }) 189 + .sort({ indexedAt: -1 }) 190 + .limit(15) 191 + .lean() 192 + .catch((error: Error) => { 193 + ctx.logger.warn( 194 + { error, actorDid }, 195 + "Failed to fetch stories for profile", 196 + ); 197 + return []; 198 + }), 199 + 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), 206 + 207 + // Count follows based on actor's follow mode preference 208 + ctx.db.models.Follow.countDocuments({ 209 + authorDid: actorDid, 210 + type: actorFollowMode, 211 + }), 212 + 213 + // Count posts 214 + ctx.db.models.Post.countDocuments({ 215 + authorDid: actorDid, 216 + reply: null, 217 + }), 218 + 219 + // Check for feed generators 220 + (async () => { 221 + try { 222 + if (ctx.db.models.Generator) { 223 + return await ctx.db.models.Generator.countDocuments({ 224 + authorDid: actorDid, 225 + }); 226 + } 227 + return 0; 228 + } catch (_error) { 229 + return 0; 230 + } 231 + })(), 232 + 233 + // Viewer state queries (only if viewer is authenticated) 234 + viewerDid 235 + ? ctx.db.models.Follow.findOne({ 236 + subject: actorDid, 237 + authorDid: viewerDid, 238 + type: viewerFollowMode, 239 + }) 240 + : Promise.resolve(null), 241 + 242 + viewerDid 243 + ? ctx.db.models.Follow.findOne({ 244 + subject: viewerDid, 245 + authorDid: actorDid, 246 + type: actorFollowMode, 247 + }) 248 + : Promise.resolve(null), 249 + 250 + viewerDid 251 + ? ctx.db.models.Block.findOne({ 252 + subject: actorDid, 253 + authorDid: viewerDid, 254 + }) 255 + : Promise.resolve(null), 256 + 257 + viewerDid 258 + ? ctx.db.models.Block.findOne({ 259 + subject: viewerDid, 260 + authorDid: actorDid, 261 + }) 262 + : Promise.resolve(null), 263 + ]); 264 + 265 + // Build viewer state 266 + const viewer: ViewerState = {}; 267 + if (viewerDid) { 268 + if (follow) viewer.following = follow.uri; 269 + if (followedBy) viewer.followedBy = followedBy.uri; 270 + if (block) viewer.blocking = block.uri; 271 + if (blockedBy) viewer.blockedBy = true; 272 + } 273 + 274 + // Build associated services 275 + const associated: ProfileAssociated = {}; 276 + if (typeof feedgensCount === "number" && feedgensCount > 0) { 277 + associated.feedgens = feedgensCount; 278 + } 279 + 280 + // Get avatar and banner URLs 281 + const avatar = profile.avatar 282 + ? `https://media.sprk.so/avatar/tiny/${actorDid}/${profile.avatar.ref.$link}/webp` 283 + : undefined; 284 + const banner = profile.banner 285 + ? `https://media.sprk.so/img/tiny/${actorDid}/${profile.banner.ref.$link}/webp` 286 + : undefined; 287 + 288 + // Convert labels to the correct type if it exists 289 + let labels: Label[] | undefined = undefined; 290 + if (profile.labels) { 291 + labels = Array.isArray(profile.labels) 292 + ? (profile.labels as Label[]) 293 + : undefined; 294 + } 295 + 296 + // Convert pinnedPost to the correct type if it exists 297 + let pinnedPost: ComAtprotoRepoStrongRef.Main | undefined = undefined; 298 + if (profile.pinnedPost) { 299 + pinnedPost = profile 300 + .pinnedPost as unknown as ComAtprotoRepoStrongRef.Main; 301 + } 302 + 303 + // Convert recent stories to strongRefs 304 + const stories: ComAtprotoRepoStrongRef.Main[] = 305 + Array.isArray(recentStories) 306 + ? recentStories.map((story: StoryDocument) => ({ 307 + uri: story.uri, 308 + cid: story.cid, 309 + })) 310 + : []; 311 + 312 + // Build the ProfileViewDetailed response 313 + const profileView: ProfileViewDetailed = { 314 + did: actorDid, 315 + handle: handle, 316 + displayName: profile.displayName, 317 + description: profile.description, 318 + avatar, 319 + banner, 320 + followersCount: typeof followersCount === "number" ? followersCount : 0, 321 + followsCount: typeof followsCount === "number" ? followsCount : 0, 322 + postsCount: typeof postsCount === "number" ? postsCount : 0, 323 + associated: Object.keys(associated).length > 0 ? associated : undefined, 324 + indexedAt: profile.indexedAt, 325 + createdAt: profile.createdAt, 326 + viewer: Object.keys(viewer).length > 0 ? viewer : undefined, 327 + labels, 328 + pinnedPost, 329 + stories: stories.length > 0 ? stories : undefined, 330 + }; 331 + 332 + return profileView; 333 + } catch (error) { 334 + ctx.logger.error({ error, actorParam }, "Failed to get profile"); 335 + return null; 336 + } 337 + }; 338 + 339 + // Process all profiles in parallel 340 + const profilePromises = actorParams.map((actorParam) => 341 + getProfileData(actorParam) 342 + ); 343 + const profileResults = await Promise.all(profilePromises); 344 + 345 + // Filter out null results (failed or not found profiles) 346 + const profiles = profileResults.filter(( 347 + profile, 348 + ): profile is ProfileViewDetailed => profile !== null); 349 + 350 + return profiles; 351 + }