[READ ONLY MIRROR] Spark Social AppView Server
github.com/sprksocial/server
atproto
deno
hono
lexicon
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}