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

at eb947da8a3a8a7d485ff132b3ff0db4a8baaac19 504 lines 15 kB view raw
1import type { 2 ProfileAssociated, 3 ProfileView, 4 ProfileViewBasic, 5 ProfileViewDetailed, 6 ViewerState, 7} from "../lex/types/so/sprk/actor/defs.ts"; 8import type * as ComAtprotoRepoStrongRef from "../lex/types/com/atproto/repo/strongRef.ts"; 9import type { StoryDocument } from "../data-plane/db/models.ts"; 10import type { Label } from "../lex/types/com/atproto/label/defs.ts"; 11import { ensureValidDid, isValidHandle } from "@atp/syntax"; 12import { AppContext } from "../context.ts"; 13import { XRPCError } from "@atp/xrpc-server"; 14 15// Helper function to resolve an actor identifier (handle or DID), 16// fetch profile data, and return a detailed profile view or null if not found 17export async function createProfileViewBasic( 18 authorDid: string, 19 ctx: AppContext, 20 includeStories: boolean = true, 21): Promise<ProfileViewBasic> { 22 // Get author profile data 23 const profile = await ctx.db.models.Profile.findOne({ 24 authorDid: authorDid, 25 }); 26 const actor = await ctx.db.models.Actor.findOne({ 27 did: authorDid, 28 }); 29 const authorHandle = actor?.handle ?? "unknown.invalid"; 30 31 let stories: ComAtprotoRepoStrongRef.Main[] = []; 32 33 // Only fetch stories if requested 34 if (includeStories) { 35 // Fetch recent stories for this author (within 24 hours) 36 const twentyFourHoursAgo = new Date(); 37 twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24); 38 39 try { 40 const recentStories = await ctx.db.models.Story.find({ 41 authorDid: authorDid, 42 indexedAt: { $gte: twentyFourHoursAgo.toISOString() }, 43 }) 44 .sort({ indexedAt: -1 }) 45 .limit(15); 46 47 // Convert recent stories to strongRefs 48 stories = recentStories.map((story: StoryDocument) => ({ 49 uri: story.uri, 50 cid: story.cid, 51 })); 52 } catch (error) { 53 // If story fetching fails, just continue without stories 54 console.warn(`Failed to fetch stories for ${authorDid}:`, error); 55 } 56 } 57 58 // Safely handle avatar URL construction 59 let avatarUrl: string | undefined = undefined; 60 try { 61 if ( 62 profile?.avatar && typeof profile.avatar === "object" && 63 profile.avatar.ref && profile.avatar.ref.$link 64 ) { 65 avatarUrl = 66 `https://media.sprk.so/avatar/tiny/${authorDid}/${profile.avatar.ref.$link}/webp`; 67 } 68 } catch (error) { 69 console.warn(`Failed to construct avatar URL for ${authorDid}:`, error); 70 } 71 72 return { 73 did: authorDid, 74 handle: authorHandle || "unknown", 75 displayName: profile?.displayName ?? authorHandle ?? "Unknown User", 76 avatar: avatarUrl, 77 stories: stories.length > 0 ? stories : undefined, 78 }; 79} 80 81export async function getProfileView( 82 ctx: AppContext, 83 actorDid: string, 84 viewerDid?: string, 85): Promise<ProfileView> { 86 const { db, idResolver } = ctx; 87 88 const profile = await db.models.Profile.findOne({ authorDid: actorDid }); 89 const actor = await db.models.Actor.findOne({ did: actorDid }); 90 91 const handle = actor?.handle ?? 92 (await idResolver.did.resolveAtprotoData(actorDid)).handle ?? 93 "unknown.invalid"; 94 95 const baseView: ProfileView = { 96 $type: "so.sprk.actor.defs#profileView", 97 did: actorDid, 98 handle: handle, 99 }; 100 101 if (viewerDid) { 102 const [following, followedBy] = await Promise.all([ 103 db.models.Follow.findOne({ 104 authorDid: viewerDid, 105 subject: actorDid, 106 }).select("uri").lean(), 107 db.models.Follow.findOne({ 108 authorDid: actorDid, 109 subject: viewerDid, 110 }).select("uri").lean(), 111 ]); 112 113 baseView.viewer = { 114 $type: "so.sprk.actor.defs#viewerState", 115 following: following?.uri, 116 followedBy: followedBy?.uri, 117 }; 118 } 119 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 ...baseView, 127 displayName: profile.displayName, 128 description: profile.description, 129 avatar: avatarUrl, 130 indexedAt: profile.indexedAt, 131 createdAt: profile.createdAt, 132 }; 133 } 134 135 return baseView; 136} 137 138/** 139 * Batch version of getProfileView for better performance 140 * Gets multiple profile views efficiently with minimal database calls 141 */ 142export async function getProfileViews( 143 ctx: AppContext, 144 actorDids: string[], 145 viewerDid?: string, 146): Promise<ProfileView[]> { 147 if (!actorDids || actorDids.length === 0) { 148 return []; 149 } 150 151 const { db } = ctx; 152 153 // Batch fetch all profiles and actors 154 const [profiles, actors] = await Promise.all([ 155 db.models.Profile.find({ authorDid: { $in: actorDids } }).lean(), 156 db.models.Actor.find({ did: { $in: actorDids } }).lean(), 157 ]); 158 159 // Create maps for efficient lookup 160 const profileMap = new Map(profiles.map((p) => [p.authorDid, p])); 161 const actorMap = new Map(actors.map((a) => [a.did, a])); 162 163 let followingMap = new Map(); 164 let followedByMap = new Map(); 165 166 // Batch fetch viewer state if viewerDid is provided 167 if (viewerDid) { 168 const [followingDocs, followedByDocs] = await Promise.all([ 169 db.models.Follow.find({ 170 authorDid: viewerDid, 171 subject: { $in: actorDids }, 172 }).select("subject uri").lean(), 173 db.models.Follow.find({ 174 authorDid: { $in: actorDids }, 175 subject: viewerDid, 176 }).select("authorDid uri").lean(), 177 ]); 178 179 followingMap = new Map(followingDocs.map((f) => [f.subject, f.uri])); 180 followedByMap = new Map(followedByDocs.map((f) => [f.authorDid, f.uri])); 181 } 182 183 // Build profile views 184 const profileViews = await Promise.all( 185 actorDids.map(async (actorDid) => { 186 const profile = profileMap.get(actorDid); 187 const actor = actorMap.get(actorDid); 188 189 const handle = actor?.handle ?? 190 (await ctx.idResolver.did.resolveAtprotoData(actorDid)).handle ?? 191 "unknown.invalid"; 192 193 const baseView: ProfileView = { 194 $type: "so.sprk.actor.defs#profileView", 195 did: actorDid, 196 handle: handle, 197 }; 198 199 if (viewerDid) { 200 const following = followingMap.get(actorDid); 201 const followedBy = followedByMap.get(actorDid); 202 203 if (following || followedBy) { 204 baseView.viewer = { 205 $type: "so.sprk.actor.defs#viewerState", 206 following, 207 followedBy, 208 }; 209 } 210 } 211 212 if (profile) { 213 const avatarUrl = profile.avatar?.ref?.$link 214 ? `https://media.sprk.so/avatar/tiny/${profile.authorDid}/${profile.avatar.ref.$link}/webp` 215 : undefined; 216 217 return { 218 ...baseView, 219 displayName: profile.displayName, 220 description: profile.description, 221 avatar: avatarUrl, 222 indexedAt: profile.indexedAt, 223 createdAt: profile.createdAt, 224 }; 225 } 226 227 return baseView; 228 }), 229 ); 230 231 return profileViews; 232} 233 234/** 235 * Get a single profile by actor identifier (handle or DID) 236 */ 237export async function getProfile( 238 ctx: AppContext, 239 actorParam: string, 240 viewerDid?: string, 241): Promise<ProfileViewDetailed> { 242 const profiles = await getProfiles(ctx, [actorParam], viewerDid); 243 244 if (profiles.length === 0) { 245 throw new XRPCError(404, "Profile not found", "NotFound"); 246 } 247 248 return profiles[0]; 249} 250 251/** 252 * Get multiple profiles in parallel by actor identifiers (handles or DIDs) 253 */ 254export async function getProfiles( 255 ctx: AppContext, 256 actorParams: string[], 257 viewerDid?: string, 258): Promise<ProfileViewDetailed[]> { 259 if (!actorParams || actorParams.length === 0) { 260 return []; 261 } 262 // Helper function to get a single profile data 263 const getProfileData = async ( 264 actorParam: string, 265 ): Promise<ProfileViewDetailed | null> => { 266 try { 267 // Resolve actor identifier to DID 268 let actorDidDoc; 269 if (isValidHandle(actorParam)) { 270 const did = await ctx.idResolver.handle.resolve(actorParam); 271 if (!did) { 272 return null; // Invalid handle, skip 273 } 274 actorDidDoc = await ctx.idResolver.did.resolveAtprotoData(did); 275 } else { 276 try { 277 ensureValidDid(actorParam); 278 actorDidDoc = await ctx.idResolver.did.resolveAtprotoData(actorParam); 279 } catch (_err) { 280 return null; // Invalid actor, skip 281 } 282 } 283 284 const actorDid = actorDidDoc.did; 285 286 // Fetch actor and profile documents in parallel 287 const [actorDoc, profile] = await Promise.all([ 288 ctx.db.models.Actor.findOne({ did: actorDid }), 289 ctx.db.models.Profile.findOne({ authorDid: actorDid }), 290 ]); 291 292 if (!actorDoc) { 293 return null; // Actor not found, skip 294 } 295 296 // Handle case where actor exists but profile doesn't 297 if (!profile) { 298 ctx.logger.info( 299 "Actor found but no profile record, creating basic profile view", 300 { did: actorDid }, 301 ); 302 303 // Get handle 304 const handle = actorDoc.handle || 305 ((await ctx.idResolver.did.resolveAtprotoData(actorDid)).handle); 306 307 // Convert to detailed format with minimal data 308 return { 309 did: actorDid, 310 handle: handle, 311 }; 312 } 313 314 // Get actor's handle and preferences 315 const handle = actorDoc.handle || 316 (await ctx.idResolver.did.resolveAtprotoData(actorDid)).handle; 317 318 // Twenty-four hours ago for recent stories 319 const twentyFourHoursAgo = new Date(); 320 twentyFourHoursAgo.setHours(twentyFourHoursAgo.getHours() - 24); 321 322 const [ 323 recentStories, 324 followersCount, 325 followsCount, 326 postsCount, 327 feedgensCount, 328 follow, 329 followedBy, 330 block, 331 blockedBy, 332 ] = await Promise.all([ 333 // Fetch recent stories (within 24 hours) 334 ctx.db.models.Story.find({ 335 authorDid: actorDid, 336 indexedAt: { $gte: twentyFourHoursAgo.toISOString() }, 337 }) 338 .sort({ indexedAt: -1 }) 339 .limit(15) 340 .catch((error: Error) => { 341 ctx.logger.warn( 342 "Failed to fetch stories for profile", 343 { error, actorDid }, 344 ); 345 return []; 346 }), 347 348 // Count followers based on actor's follow mode preference 349 ctx.db.models.Follow.countDocuments({ 350 subject: actorDid, 351 }), 352 353 // Count follows based on actor's follow mode preference 354 ctx.db.models.Follow.countDocuments({ 355 authorDid: actorDid, 356 }), 357 358 // Count posts 359 await ctx.db.models.Post.countDocuments({ 360 authorDid: actorDid, 361 }), 362 363 // Check for feed generators (bsky + sprk combined) 364 await ctx.db.models.Generator.countDocuments({ 365 authorDid: actorDid, 366 }), 367 368 // Viewer state queries (only if viewer is authenticated) 369 viewerDid 370 ? ctx.db.models.Follow.findOne({ 371 subject: actorDid, 372 authorDid: viewerDid, 373 }) 374 : Promise.resolve(null), 375 376 viewerDid 377 ? ctx.db.models.Follow.findOne({ 378 subject: viewerDid, 379 authorDid: actorDid, 380 }) 381 : Promise.resolve(null), 382 383 viewerDid 384 ? ctx.db.models.Block.findOne({ 385 subject: actorDid, 386 authorDid: viewerDid, 387 }) 388 : Promise.resolve(null), 389 390 viewerDid 391 ? ctx.db.models.Block.findOne({ 392 subject: viewerDid, 393 authorDid: actorDid, 394 }) 395 : Promise.resolve(null), 396 ]); 397 398 // Build viewer state 399 const viewer: ViewerState = {}; 400 if (viewerDid) { 401 if (follow) viewer.following = follow.uri; 402 if (followedBy) viewer.followedBy = followedBy.uri; 403 if (block) viewer.blocking = block.uri; 404 if (blockedBy) viewer.blockedBy = true; 405 } 406 407 // Build associated services 408 const associated: ProfileAssociated = {}; 409 if (typeof feedgensCount === "number" && feedgensCount > 0) { 410 associated.feedgens = feedgensCount; 411 } 412 413 // Get avatar and banner URLs safely 414 let avatar: string | undefined = undefined; 415 let banner: string | undefined = undefined; 416 417 try { 418 if ( 419 profile.avatar && typeof profile.avatar === "object" && 420 profile.avatar.ref && profile.avatar.ref.$link 421 ) { 422 avatar = 423 `https://media.sprk.so/avatar/tiny/${actorDid}/${profile.avatar.ref.$link}/webp`; 424 } 425 } catch (error) { 426 console.warn(`Failed to construct avatar URL for ${actorDid}:`, error); 427 } 428 429 try { 430 if ( 431 profile.banner && typeof profile.banner === "object" && 432 profile.banner.ref && profile.banner.ref.$link 433 ) { 434 banner = 435 `https://media.sprk.so/img/tiny/${actorDid}/${profile.banner.ref.$link}/webp`; 436 } 437 } catch (error) { 438 console.warn(`Failed to construct banner URL for ${actorDid}:`, error); 439 } 440 441 // Convert labels to the correct type if it exists 442 let labels: Label[] | undefined = undefined; 443 if (profile.labels) { 444 labels = Array.isArray(profile.labels) 445 ? (profile.labels as Label[]) 446 : undefined; 447 } 448 449 // Convert pinnedPost to the correct type if it exists 450 let pinnedPost: ComAtprotoRepoStrongRef.Main | undefined = undefined; 451 if (profile.pinnedPost) { 452 pinnedPost = profile 453 .pinnedPost as unknown as ComAtprotoRepoStrongRef.Main; 454 } 455 456 // Convert recent stories to strongRefs 457 const stories: ComAtprotoRepoStrongRef.Main[] = 458 Array.isArray(recentStories) 459 ? recentStories.map((story: StoryDocument) => ({ 460 uri: story.uri, 461 cid: story.cid, 462 })) 463 : []; 464 465 // Build the ProfileViewDetailed response 466 const profileView: ProfileViewDetailed = { 467 did: actorDid, 468 handle: handle, 469 displayName: profile.displayName, 470 description: profile.description, 471 avatar, 472 banner, 473 followersCount: typeof followersCount === "number" ? followersCount : 0, 474 followsCount: typeof followsCount === "number" ? followsCount : 0, 475 postsCount: typeof postsCount === "number" ? postsCount : 0, 476 associated: Object.keys(associated).length > 0 ? associated : undefined, 477 indexedAt: profile.indexedAt, 478 createdAt: profile.createdAt, 479 viewer: Object.keys(viewer).length > 0 ? viewer : undefined, 480 labels, 481 pinnedPost, 482 stories: stories.length > 0 ? stories : undefined, 483 }; 484 485 return profileView; 486 } catch (error) { 487 ctx.logger.error("Failed to get profile", { error, actorParam }); 488 return null; 489 } 490 }; 491 492 // Process all profiles in parallel 493 const profilePromises = actorParams.map((actorParam) => 494 getProfileData(actorParam) 495 ); 496 const profileResults = await Promise.all(profilePromises); 497 498 // Filter out null results (failed or not found profiles) 499 const profiles = profileResults.filter(( 500 profile, 501 ): profile is ProfileViewDetailed => profile !== null); 502 503 return profiles; 504}