[READ ONLY MIRROR] Spark Social AppView Server github.com/sprksocial/server
atproto deno hono lexicon
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor(sounds): add dataplane route

+234 -668
+18 -62
api/so/sprk/sound/getActorAudios.ts
··· 1 1 import { mapDefined } from "@atp/common"; 2 - import { decodeBase64, encodeBase64 } from "@std/encoding"; 2 + import { InvalidRequestError } from "@atp/xrpc-server"; 3 3 import { AppContext } from "../../../../context.ts"; 4 + import { DataPlane } from "../../../../data-plane/index.ts"; 4 5 import { 5 6 HydrateCtx, 6 7 HydrationState, ··· 12 13 import { uriToDid as creatorFromUri } from "../../../../utils/uris.ts"; 13 14 import { Views } from "../../../../views/index.ts"; 14 15 import { resHeaders } from "../../../util.ts"; 15 - import { InvalidRequestError } from "@atp/xrpc-server"; 16 16 17 17 export default function (server: Server, ctx: AppContext) { 18 18 const getActorAudios = createPipeline( ··· 48 48 const { actor, limit = 50, cursor } = params; 49 49 50 50 // Resolve handle to DID 51 - let actorDid = actor; 52 - if (!actor.startsWith("did:")) { 53 - try { 54 - const didDoc = await ctx.idResolver.did.resolveAtprotoData(actor); 55 - actorDid = didDoc.did; 56 - } catch { 57 - throw new InvalidRequestError("Could not resolve actor handle"); 58 - } 59 - } 60 - 61 - // Parse cursor 62 - let cursorData: CursorData | undefined; 63 - if (cursor) { 64 - try { 65 - cursorData = parseCursor(cursor); 66 - } catch { 67 - throw new InvalidRequestError("The provided cursor is invalid"); 68 - } 69 - } 51 + const actorDid = actor.startsWith("did:") 52 + ? actor 53 + : (await ctx.hydrator.actor.getDids([actor]))[0]; 70 54 71 - // Build query 72 - const query: Record<string, unknown> = { authorDid: actorDid }; 73 - if (cursorData) { 74 - query.$or = [ 75 - { createdAt: { $lt: cursorData.createdAt } }, 76 - { createdAt: cursorData.createdAt, _id: { $lt: cursorData.id } }, 77 - ]; 55 + if (!actorDid) { 56 + throw new InvalidRequestError("Could not resolve actor handle"); 78 57 } 79 58 80 - const audios = await ctx.db.models.Audio.find(query) 81 - .sort({ createdAt: -1, _id: -1 }) 82 - .limit(limit + 1); 83 - 84 - const hasMore = audios.length > limit; 85 - if (hasMore) audios.pop(); 59 + const result = await ctx.dataplane.sounds.getActorAudios( 60 + actorDid, 61 + limit, 62 + cursor, 63 + ); 86 64 87 - const uris = audios.map((a) => a.uri); 88 - 89 - let nextCursor: string | undefined; 90 - if (hasMore && audios.length > 0) { 91 - const last = audios[audios.length - 1]; 92 - nextCursor = generateCursor(String(last.createdAt), String(last._id)); 93 - } 94 - 95 - return { audios: uris, cursor: nextCursor }; 65 + return { 66 + audios: result.audios.map((a: { uri: string }) => a.uri), 67 + cursor: result.cursor, 68 + }; 96 69 }; 97 70 98 71 const hydration = (inputs: { ··· 126 99 const { ctx, skeleton, hydration } = inputs; 127 100 const audios = mapDefined( 128 101 skeleton.audios, 129 - (uri) => ctx.views.soundView(uri, hydration), 102 + (uri) => ctx.views.sound(uri, hydration), 130 103 ); 131 104 return { audios, cursor: skeleton.cursor }; 132 105 }; 133 106 134 - interface CursorData { 135 - createdAt: string; 136 - id: string; 137 - } 138 - 139 - function parseCursor(cursor: string): CursorData { 140 - const decoded = new TextDecoder().decode(decodeBase64(cursor)); 141 - const [createdAt, id] = decoded.split("::"); 142 - if (!createdAt || !id) throw new Error("Invalid cursor format"); 143 - return { createdAt, id }; 144 - } 145 - 146 - function generateCursor(createdAt: string, id: string): string { 147 - return encodeBase64(new TextEncoder().encode(`${createdAt}::${id}`)); 148 - } 149 - 150 107 type Context = { 151 - db: AppContext["db"]; 152 - idResolver: AppContext["idResolver"]; 108 + dataplane: DataPlane; 153 109 hydrator: Hydrator; 154 110 views: Views; 155 111 };
+9 -70
api/so/sprk/sound/getAudioPosts.ts
··· 1 1 import { mapDefined } from "@atp/common"; 2 - import { decodeBase64, encodeBase64 } from "@std/encoding"; 2 + import { InvalidRequestError } from "@atp/xrpc-server"; 3 3 import { AppContext } from "../../../../context.ts"; 4 + import { DataPlane } from "../../../../data-plane/index.ts"; 4 5 import { 5 6 HydrateCtx, 6 7 HydrationState, ··· 13 14 import { uriToDid as creatorFromUri } from "../../../../utils/uris.ts"; 14 15 import { Views } from "../../../../views/index.ts"; 15 16 import { resHeaders } from "../../../util.ts"; 16 - import { InvalidRequestError } from "@atp/xrpc-server"; 17 - import { RootFilterQuery } from "mongoose"; 18 - import { PostDocument } from "../../../../data-plane/db/models.ts"; 19 17 20 18 export default function (server: Server, ctx: AppContext) { 21 19 const getAudioPosts = createPipeline( ··· 50 48 const { ctx, params } = inputs; 51 49 const { uri, limit = 50, cursor } = params; 52 50 53 - const dbAudio = await ctx.db.models.Audio.findOne({ 54 - uri: uri, 55 - }).exec(); 56 - 57 - if (!dbAudio) { 51 + // Check if audio exists 52 + const audio = await ctx.dataplane.sounds.getAudio(uri); 53 + if (!audio) { 58 54 throw new InvalidRequestError("Audio not found", "NotFound"); 59 55 } 60 56 61 - let cursorData: CursorData | undefined; 62 - if (cursor) { 63 - try { 64 - cursorData = parseCursor(cursor); 65 - } catch { 66 - throw new InvalidRequestError("The provided cursor is invalid"); 67 - } 68 - } 69 - 70 - const query: RootFilterQuery<PostDocument> = { 71 - "sound.uri": uri, 72 - reply: null, 73 - }; 74 - 75 - if (cursorData) { 76 - query.$or = [ 77 - { createdAt: { $lt: cursorData.createdAt } }, 78 - { createdAt: cursorData.createdAt, _id: { $lt: cursorData.id } }, 79 - ]; 80 - } 57 + const result = await ctx.dataplane.sounds.getAudioPosts(uri, limit, cursor); 81 58 82 - const posts = await ctx.db.models.Post 83 - .find(query) 84 - .sort({ createdAt: -1, _id: -1 }) 85 - .limit(limit + 1); 86 - 87 - const hasMore = posts.length > limit; 88 - if (hasMore) posts.pop(); 89 - 90 - const postUris = posts.map((p) => p.uri); 91 - 92 - let nextCursor: string | undefined; 93 - if (hasMore && posts.length > 0) { 94 - const last = posts[posts.length - 1]; 95 - nextCursor = generateCursor(String(last.createdAt), String(last._id)); 96 - } 97 - 98 - return { posts: postUris, audioUri: uri, cursor: nextCursor }; 59 + return { posts: result.posts, audioUri: uri, cursor: result.cursor }; 99 60 }; 100 61 101 62 const hydration = async (inputs: { ··· 138 99 skeleton.posts, 139 100 (uri) => ctx.views.post(uri, hydration), 140 101 ); 141 - const audio = ctx.views.soundView(skeleton.audioUri, hydration); 142 - 143 - // If audio hydration failed, return stub or empty? 144 - // The schema likely requires the audio field. 145 - // If hydration fails, soundView returns undefined. 146 - // We should probably handle this, but since we checked existence in skeleton, it implies DB record exists. 147 - // Views handles it. 102 + const audio = ctx.views.sound(skeleton.audioUri, hydration); 148 103 149 104 return { audio: audio!, posts, cursor: skeleton.cursor }; 150 105 }; 151 106 152 - interface CursorData { 153 - createdAt: string; 154 - id: string; 155 - } 156 - 157 - function parseCursor(cursor: string): CursorData { 158 - const decoded = new TextDecoder().decode(decodeBase64(cursor)); 159 - const [createdAt, id] = decoded.split("::"); 160 - if (!createdAt || !id) throw new Error("Invalid cursor format"); 161 - return { createdAt, id }; 162 - } 163 - 164 - function generateCursor(createdAt: string, id: string): string { 165 - return encodeBase64(new TextEncoder().encode(`${createdAt}::${id}`)); 166 - } 167 - 168 107 type Context = { 169 - db: AppContext["db"]; 108 + dataplane: DataPlane; 170 109 hydrator: Hydrator; 171 110 views: Views; 172 111 };
+1 -1
api/so/sprk/sound/getAudios.ts
··· 71 71 const { ctx, skeleton, hydration } = inputs; 72 72 const audios = mapDefined( 73 73 skeleton.audios, 74 - (uri) => ctx.views.soundView(uri, hydration), 74 + (uri) => ctx.views.sound(uri, hydration), 75 75 ); 76 76 return { audios }; 77 77 };
+8 -21
api/so/sprk/sound/getTrendingAudios.ts
··· 1 1 import { mapDefined } from "@atp/common"; 2 2 import { AppContext } from "../../../../context.ts"; 3 + import { DataPlane } from "../../../../data-plane/index.ts"; 3 4 import { 4 5 HydrateCtx, 5 6 HydrationState, ··· 45 46 const { ctx, params } = inputs; 46 47 const { limit = 25, cursor } = params; 47 48 48 - let skip = 0; 49 - if (cursor) { 50 - const parsed = parseInt(cursor, 10); 51 - if (!isNaN(parsed) && parsed > 0) skip = parsed; 52 - } 49 + const result = await ctx.dataplane.sounds.getTrendingAudios(limit, cursor); 53 50 54 - const docsPage = await ctx.db.models.Audio.find({}) 55 - .sort({ useCount: -1, createdAt: -1 }) 56 - .skip(skip) 57 - .limit(limit) 58 - .lean(); 59 - 60 - const uris = docsPage.map((a) => a.uri); 61 - 62 - let nextCursor: string | undefined; 63 - if (uris.length === limit) { 64 - nextCursor = (skip + limit).toString(); 65 - } 66 - 67 - return { audios: uris, cursor: nextCursor }; 51 + return { 52 + audios: result.audios.map((a: { uri: string }) => a.uri), 53 + cursor: result.cursor, 54 + }; 68 55 }; 69 56 70 57 const hydration = (inputs: { ··· 98 85 const { ctx, skeleton, hydration } = inputs; 99 86 const audios = mapDefined( 100 87 skeleton.audios, 101 - (uri) => ctx.views.soundView(uri, hydration), 88 + (uri) => ctx.views.sound(uri, hydration), 102 89 ); 103 90 return { audios, cursor: skeleton.cursor }; 104 91 }; 105 92 106 93 type Context = { 107 - db: AppContext["db"]; 94 + dataplane: DataPlane; 108 95 hydrator: Hydrator; 109 96 views: Views; 110 97 };
-8
api/so/sprk/story/getStories.ts
··· 63 63 : null; 64 64 const hydrateCtx = ctx.hydrator.createContext({ viewer }); 65 65 66 - // Validate input 67 - if (!params.uris) { 68 - return { 69 - status: 400, 70 - message: "URIs parameter is required", 71 - }; 72 - } 73 - 74 66 // Ensure uris is an array 75 67 const uriArray = Array.isArray(params.uris) ? params.uris : [params.uris]; 76 68
+3
data-plane/index.ts
··· 13 13 import { Relationships } from "./routes/relationships.ts"; 14 14 import { Interactions } from "./routes/interactions.ts"; 15 15 import { Reposts } from "./routes/reposts.ts"; 16 + import { Sounds } from "./routes/sounds.ts"; 16 17 import { Stories } from "./routes/stories.ts"; 17 18 import { Sync } from "./routes/sync.ts"; 18 19 import { Threads } from "./routes/threads.ts"; ··· 44 45 public relationships: Relationships; 45 46 public interactions: Interactions; 46 47 public reposts: Reposts; 48 + public sounds: Sounds; 47 49 public stories: Stories; 48 50 public sync: Sync; 49 51 public threads: Threads; ··· 71 73 this.relationships = new Relationships(db); 72 74 this.interactions = new Interactions(db); 73 75 this.reposts = new Reposts(db); 76 + this.sounds = new Sounds(db); 74 77 this.stories = new Stories(db); 75 78 this.sync = new Sync(db); 76 79 this.threads = new Threads(db);
+193
data-plane/routes/sounds.ts
··· 1 + import { Database } from "../db/index.ts"; 2 + import { TimeCidKeyset } from "../db/pagination.ts"; 3 + import { compositeTime } from "../util.ts"; 4 + 5 + export interface SoundItem { 6 + uri: string; 7 + cid: string; 8 + authorDid: string; 9 + createdAt: string; 10 + indexedAt: string; 11 + sortAt: string; 12 + } 13 + 14 + export class Sounds { 15 + private db: Database; 16 + private timeCidKeyset: TimeCidKeyset; 17 + 18 + constructor(db: Database) { 19 + this.db = db; 20 + this.timeCidKeyset = new TimeCidKeyset(); 21 + } 22 + 23 + /** 24 + * Get audios by URIs 25 + */ 26 + async getAudios(uris: string[]): Promise<SoundItem[]> { 27 + if (!uris.length) return []; 28 + 29 + const audios = await this.db.models.Audio.find({ 30 + uri: { $in: uris }, 31 + }).lean(); 32 + 33 + return audios.map((audio) => ({ 34 + uri: audio.uri, 35 + cid: audio.cid, 36 + authorDid: audio.authorDid, 37 + createdAt: audio.createdAt, 38 + indexedAt: audio.indexedAt, 39 + sortAt: compositeTime(audio.createdAt, audio.indexedAt) || 40 + audio.createdAt, 41 + })); 42 + } 43 + 44 + /** 45 + * Get a single audio by URI 46 + */ 47 + async getAudio(uri: string): Promise<SoundItem | null> { 48 + const audio = await this.db.models.Audio.findOne({ uri }).lean(); 49 + 50 + if (!audio) return null; 51 + 52 + return { 53 + uri: audio.uri, 54 + cid: audio.cid, 55 + authorDid: audio.authorDid, 56 + createdAt: audio.createdAt, 57 + indexedAt: audio.indexedAt, 58 + sortAt: compositeTime(audio.createdAt, audio.indexedAt) || 59 + audio.createdAt, 60 + }; 61 + } 62 + 63 + /** 64 + * Get audios by an actor 65 + */ 66 + async getActorAudios( 67 + actorDid: string, 68 + limit = 50, 69 + cursor?: string, 70 + ): Promise<{ audios: SoundItem[]; cursor?: string }> { 71 + const audiosQuery = this.db.models.Audio.find({ 72 + authorDid: actorDid, 73 + }); 74 + 75 + const paginatedQuery = this.timeCidKeyset.paginate(audiosQuery, { 76 + limit: limit + 1, 77 + cursor, 78 + direction: "desc", 79 + }); 80 + 81 + const audios = await paginatedQuery.exec(); 82 + 83 + const hasMore = audios.length > limit; 84 + const resultAudios = hasMore ? audios.slice(0, limit) : audios; 85 + 86 + const transformedAudios: SoundItem[] = resultAudios.map((audio) => ({ 87 + uri: audio.uri, 88 + cid: audio.cid, 89 + authorDid: audio.authorDid, 90 + createdAt: audio.createdAt, 91 + indexedAt: audio.indexedAt, 92 + sortAt: compositeTime(audio.createdAt, audio.indexedAt) || 93 + audio.createdAt, 94 + })); 95 + 96 + let nextCursor: string | undefined; 97 + if (hasMore && transformedAudios.length > 0) { 98 + const lastAudio = transformedAudios[transformedAudios.length - 1]; 99 + nextCursor = this.timeCidKeyset.pack({ 100 + primary: lastAudio.sortAt, 101 + secondary: lastAudio.cid, 102 + }); 103 + } 104 + 105 + return { 106 + audios: transformedAudios, 107 + cursor: nextCursor, 108 + }; 109 + } 110 + 111 + /** 112 + * Get trending audios sorted by use count 113 + */ 114 + async getTrendingAudios( 115 + limit = 25, 116 + cursor?: string, 117 + ): Promise<{ audios: SoundItem[]; cursor?: string }> { 118 + let skip = 0; 119 + if (cursor) { 120 + const parsed = parseInt(cursor, 10); 121 + if (!isNaN(parsed) && parsed > 0) skip = parsed; 122 + } 123 + 124 + const audios = await this.db.models.Audio.find({}) 125 + .sort({ useCount: -1, createdAt: -1 }) 126 + .skip(skip) 127 + .limit(limit) 128 + .lean(); 129 + 130 + const transformedAudios: SoundItem[] = audios.map((audio) => ({ 131 + uri: audio.uri, 132 + cid: audio.cid, 133 + authorDid: audio.authorDid, 134 + createdAt: audio.createdAt, 135 + indexedAt: audio.indexedAt, 136 + sortAt: compositeTime(audio.createdAt, audio.indexedAt) || 137 + audio.createdAt, 138 + })); 139 + 140 + let nextCursor: string | undefined; 141 + if (transformedAudios.length === limit) { 142 + nextCursor = (skip + limit).toString(); 143 + } 144 + 145 + return { 146 + audios: transformedAudios, 147 + cursor: nextCursor, 148 + }; 149 + } 150 + 151 + /** 152 + * Get posts that use a specific audio 153 + */ 154 + async getAudioPosts( 155 + audioUri: string, 156 + limit = 50, 157 + cursor?: string, 158 + ): Promise<{ posts: string[]; cursor?: string }> { 159 + const postsQuery = this.db.models.Post.find({ 160 + "sound.uri": audioUri, 161 + reply: null, 162 + }); 163 + 164 + const paginatedQuery = this.timeCidKeyset.paginate(postsQuery, { 165 + limit: limit + 1, 166 + cursor, 167 + direction: "desc", 168 + }); 169 + 170 + const posts = await paginatedQuery.exec(); 171 + 172 + const hasMore = posts.length > limit; 173 + const resultPosts = hasMore ? posts.slice(0, limit) : posts; 174 + 175 + const postUris = resultPosts.map((p) => p.uri); 176 + 177 + let nextCursor: string | undefined; 178 + if (hasMore && resultPosts.length > 0) { 179 + const lastPost = resultPosts[resultPosts.length - 1]; 180 + const sortAt = compositeTime(lastPost.createdAt, lastPost.indexedAt) || 181 + lastPost.createdAt; 182 + nextCursor = this.timeCidKeyset.pack({ 183 + primary: sortAt, 184 + secondary: lastPost.cid, 185 + }); 186 + } 187 + 188 + return { 189 + posts: postUris, 190 + cursor: nextCursor, 191 + }; 192 + } 193 + }
-504
utils/profile-helper.ts
··· 1 - import type { 2 - ProfileAssociated, 3 - ProfileView, 4 - ProfileViewBasic, 5 - ProfileViewDetailed, 6 - ViewerState, 7 - } from "../lex/types/so/sprk/actor/defs.ts"; 8 - import type * as ComAtprotoRepoStrongRef from "../lex/types/com/atproto/repo/strongRef.ts"; 9 - import type { StoryDocument } from "../data-plane/db/models.ts"; 10 - import type { Label } from "../lex/types/com/atproto/label/defs.ts"; 11 - import { ensureValidDid, isValidHandle } from "@atp/syntax"; 12 - import { AppContext } from "../context.ts"; 13 - import { 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 17 - export 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 - 81 - export 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 - */ 142 - export 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 - */ 237 - export 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 - */ 254 - export 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 - }
+2 -2
views/index.ts
··· 226 226 author, 227 227 record: recordInfo.record, 228 228 media: mediaRecord ? this.media(uri, mediaRecord as Media) : undefined, 229 - sound: soundRecord ? this.soundView(soundRecord.uri, state) : undefined, 229 + sound: soundRecord ? this.sound(soundRecord.uri, state) : undefined, 230 230 replyCount: repliesCount, 231 231 repostCount, 232 232 likeCount, ··· 764 764 }; 765 765 } 766 766 767 - soundView( 767 + sound( 768 768 uri: string, 769 769 state: HydrationState, 770 770 ): Un$Typed<AudioView> | undefined {