···11# Spark AppView
2233-This AppView provides a view of AT Protocol that encompasses all Spark lexicon and aims to interop with Bluesky lexicon.
33+This AppView provides a view of AT Protocol that encompasses all Spark lexicon
44+and aims to interop with Bluesky lexicon.
4556## Development
67
···6363 }
64646565 // Parse the original record JSON
6666- let recordValue;
6767- try {
6868- recordValue = record.json ? JSON.parse(record.json) : record;
6969- } catch {
7070- throw new InvalidRequestError(`Invalid record JSON: ${uri}`);
7171- }
6666+ const recordValue = record.json;
72677368 // Check if the record is subject to a takedown
7469 const takedown = await ctx.takedownService.getTakedown(uri);
+4
api/index.ts
···1818import getStoriesTimeline from "./so/sprk/feed/getStoriesTimeline.ts";
1919import getProfiles from "./so/sprk/actor/getProfiles.ts";
2020import searchPosts from "./so/sprk/feed/searchPosts.ts";
2121+import getSuggestedFeeds from "./so/sprk/feed/getSuggestedFeeds.ts";
2222+import getTimeline from "./so/sprk/feed/getTimeline.ts";
21232224export default function (server: Server, ctx: AppContext) {
2325 getAccountInfos(server, ctx);
···3840 getStories(server, ctx);
3941 getStoriesTimeline(server, ctx);
4042 searchPosts(server, ctx);
4343+ getSuggestedFeeds(server, ctx);
4444+ getTimeline(server, ctx);
4145}
+5-1
api/so/sprk/actor/getPreferences.ts
···11import { Server } from "../../../../lex/index.ts";
22import { AppContext } from "../../../../main.ts";
33+import { Preferences } from "../../../../lex/types/so/sprk/actor/defs.ts";
3445export default function (server: Server, ctx: AppContext) {
56 server.so.sprk.actor.getPreferences({
···1516 return {
1617 encoding: "application/json",
1718 body: {
1818- followMode: (userPref?.followMode || "sprk") as "sprk" | "bsky",
1919+ preferences: [{
2020+ $type: "so.sprk.actor.defs#savedFeedsPref",
2121+ items: (userPref?.savedFeeds ?? []),
2222+ }] as Preferences,
1923 },
2024 };
2125 } catch (error) {
+24-26
api/so/sprk/actor/putPreferences.ts
···11import { Server } from "../../../../lex/index.ts";
22+import { SavedFeedsPref } from "../../../../lex/types/so/sprk/actor/defs.ts";
23import { AppContext } from "../../../../main.ts";
3445export default function (server: Server, ctx: AppContext) {
···78 handler: async ({ input, auth }) => {
89 const userDid = auth.credentials.iss;
910 const body = input.body;
1010-1111- if (body.followMode && !["bsky", "sprk"].includes(body.followMode)) {
1212- throw new Error(
1313- 'Invalid followMode parameter. Must be "bsky" or "sprk"',
1414- );
1515- }
16111712 try {
1813 const now = new Date().toISOString();
1914 let userPref = await ctx.db.models.UserPreference.findOne({ userDid });
2020- const oldMode = userPref?.followMode;
21152222- if (!userPref) {
2323- userPref = await ctx.db.models.UserPreference.create({
2424- userDid,
2525- createdAt: now,
2626- updatedAt: now,
2727- followMode: body.followMode || "sprk", // Default if not provided
2828- });
2929- } else {
3030- if (body.followMode) {
3131- userPref.followMode = body.followMode;
3232- }
3333- userPref.updatedAt = now;
3434- await userPref.save();
3535- }
1616+ for (const pref of body.preferences) {
1717+ if (pref as SavedFeedsPref) {
1818+ const savedFeedsPref = pref as SavedFeedsPref;
36193737- // Queue indexing of Bsky follows if switched to bsky mode
3838- if (body.followMode === "bsky" && oldMode !== "bsky") {
3939- ctx.sub.indexingSvc.indexRepo(userDid).catch((error) =>
4040- ctx.logger.error("Failed to index repo", { error, userDid })
4141- );
2020+ const savedFeeds = savedFeedsPref.items;
2121+2222+ if (!userPref) {
2323+ userPref = await ctx.db.models.UserPreference.create({
2424+ userDid,
2525+ savedFeeds: savedFeeds,
2626+ createdAt: now,
2727+ updatedAt: now,
2828+ });
2929+ } else {
3030+ await ctx.db.models.UserPreference.updateOne(
3131+ { userDid },
3232+ {
3333+ $push: {
3434+ savedFeeds: { $each: savedFeeds },
3535+ },
3636+ },
3737+ );
3838+ }
3939+ }
4240 }
43414442 return;
+211
api/so/sprk/feed/getSuggestedFeeds.ts
···11+import { Server } from "../../../../lex/index.ts";
22+import { AppContext } from "../../../../main.ts";
33+import {
44+ BskyGeneratorDocument,
55+ SprkGeneratorDocument,
66+} from "../../../../data-plane/server/models.ts";
77+import { getProfileView } from "../../../../utils/profile-helper.ts";
88+import type * as SoSprkFeedDefs from "../../../../lex/types/so/sprk/feed/defs.ts";
99+import { decodeBase64, encodeBase64 } from "jsr:@std/encoding";
1010+1111+interface CursorData {
1212+ likeCount: number;
1313+ id: string;
1414+}
1515+1616+// Helper function to parse cursor
1717+function parseCursor(cursor: string): CursorData {
1818+ try {
1919+ const decodedCursor = new TextDecoder().decode(decodeBase64(cursor));
2020+ const [likeCountStr, id] = decodedCursor.split("::");
2121+2222+ if (!likeCountStr || !id) {
2323+ throw new Error("Invalid cursor format");
2424+ }
2525+2626+ const likeCount = parseInt(likeCountStr, 10);
2727+ if (isNaN(likeCount)) {
2828+ throw new Error("Invalid cursor format");
2929+ }
3030+3131+ return { likeCount, id };
3232+ } catch {
3333+ throw new Error("Invalid cursor format");
3434+ }
3535+}
3636+3737+// Helper function to generate cursor
3838+function generateCursor(likeCount: number, id: string): string {
3939+ return encodeBase64(
4040+ new TextEncoder().encode(`${likeCount}::${id}`),
4141+ );
4242+}
4343+4444+// Transform GeneratorDocument to GeneratorView
4545+async function transformGeneratorToView(
4646+ generator: BskyGeneratorDocument | SprkGeneratorDocument,
4747+ ctx: AppContext,
4848+ viewerDid?: string,
4949+): Promise<SoSprkFeedDefs.GeneratorView> {
5050+ // Create the creator profile view
5151+ const creator = await getProfileView(ctx, generator.authorDid, viewerDid);
5252+5353+ // Handle viewer state if user is authenticated
5454+ let viewer: SoSprkFeedDefs.GeneratorViewerState | undefined;
5555+ if (viewerDid) {
5656+ const like = await ctx.db.models.Like.findOne({
5757+ authorDid: viewerDid,
5858+ subject: generator.uri,
5959+ }).lean();
6060+6161+ if (like) {
6262+ viewer = {
6363+ $type: "so.sprk.feed.defs#generatorViewerState",
6464+ like: like.uri,
6565+ };
6666+ }
6767+ }
6868+6969+ return {
7070+ $type: "so.sprk.feed.defs#generatorView",
7171+ uri: generator.uri,
7272+ cid: generator.cid,
7373+ did: generator.authorDid,
7474+ creator,
7575+ displayName: generator.displayName,
7676+ description: generator.description || undefined,
7777+ descriptionFacets: generator.descriptionFacets || undefined,
7878+ avatar: generator.avatar?.ref?.$link
7979+ ? `https://media.sprk.so/avatar/tiny/${generator.authorDid}/${generator.avatar.ref.$link}/webp`
8080+ : undefined,
8181+ likeCount: generator.likeCount || 0,
8282+ acceptsInteractions: generator.acceptsInteractions || undefined,
8383+ labels: undefined, // Labels will be handled separately if needed
8484+ viewer,
8585+ indexedAt: generator.indexedAt,
8686+ };
8787+}
8888+8989+export default function (server: Server, ctx: AppContext) {
9090+ server.so.sprk.feed.getSuggestedFeeds({
9191+ auth: ctx.authVerifier.standardOptional,
9292+ handler: async ({ params, auth }) => {
9393+ try {
9494+ const { limit = 50, cursor } = params;
9595+ const userDid = auth.credentials.type === "standard"
9696+ ? auth.credentials.iss
9797+ : undefined;
9898+9999+ // Validate limit
100100+ if (limit < 1 || limit > 100) {
101101+ throw new Error("Limit must be between 1 and 100");
102102+ }
103103+104104+ // Parse cursor if provided
105105+ let cursorData: CursorData | undefined;
106106+ if (cursor) {
107107+ cursorData = parseCursor(cursor);
108108+ }
109109+110110+ // Build query for generators sorted by like count
111111+ const query: Record<string, unknown> = {};
112112+113113+ // Add cursor-based pagination
114114+ if (cursorData) {
115115+ query.$or = [
116116+ { likeCount: { $lt: cursorData.likeCount } },
117117+ { likeCount: cursorData.likeCount, _id: { $lt: cursorData.id } },
118118+ ];
119119+ }
120120+121121+ // Get both BskyGenerator and SprkGenerator documents
122122+ const [bskyGenerators, sprkGenerators] = await Promise.all([
123123+ ctx.db.models.BskyGenerator.find(query)
124124+ .sort({ likeCount: -1, _id: -1 })
125125+ .lean(),
126126+ ctx.db.models.SprkGenerator.find(query)
127127+ .sort({ likeCount: -1, _id: -1 })
128128+ .lean(),
129129+ ]);
130130+131131+ // Combine and sort all generators by like count
132132+ const allGenerators = [...bskyGenerators, ...sprkGenerators]
133133+ .sort((a, b) => {
134134+ const aLikes = a.likeCount || 0;
135135+ const bLikes = b.likeCount || 0;
136136+ if (aLikes !== bLikes) {
137137+ return bLikes - aLikes; // Sort by like count descending
138138+ }
139139+ // If like counts are equal, sort by _id descending for consistency
140140+ return String(b._id).localeCompare(String(a._id));
141141+ });
142142+143143+ // Apply limit and check for more results
144144+ const generators = allGenerators.slice(0, limit + 1);
145145+146146+ // Check if there are more results
147147+ const hasMore = generators.length > limit;
148148+ if (hasMore) {
149149+ generators.pop(); // Remove the extra item
150150+ }
151151+152152+ // Transform generators to GeneratorView format
153153+ const generatorViews = await Promise.all(
154154+ generators.map((generator) =>
155155+ transformGeneratorToView(generator, ctx, userDid)
156156+ ),
157157+ );
158158+159159+ // Generate next cursor if there are more results
160160+ let nextCursor: string | undefined;
161161+ if (hasMore && generators.length > 0) {
162162+ const lastGenerator = generators[generators.length - 1];
163163+ nextCursor = generateCursor(
164164+ lastGenerator.likeCount || 0,
165165+ String(lastGenerator._id),
166166+ );
167167+ }
168168+169169+ // Prepare response
170170+ const response: {
171171+ feeds: SoSprkFeedDefs.GeneratorView[];
172172+ cursor?: string;
173173+ } = {
174174+ feeds: generatorViews,
175175+ };
176176+177177+ if (nextCursor) {
178178+ response.cursor = nextCursor;
179179+ }
180180+181181+ return {
182182+ encoding: "application/json",
183183+ body: response,
184184+ };
185185+ } catch (error) {
186186+ // Handle specific error cases
187187+ if (error instanceof Error) {
188188+ const message = error.message;
189189+190190+ if (message.includes("cursor") || message.includes("Cursor")) {
191191+ return {
192192+ status: 400,
193193+ message: "The provided cursor is invalid",
194194+ };
195195+ }
196196+197197+ if (message.includes("limit") || message.includes("Limit")) {
198198+ return {
199199+ status: 400,
200200+ message: "Limit must be between 1 and 100",
201201+ };
202202+ }
203203+ }
204204+205205+ // Log unexpected errors and rethrow
206206+ console.error("Unexpected error in getSuggestedFeeds:", error);
207207+ throw error;
208208+ }
209209+ },
210210+ });
211211+}
+169
api/so/sprk/feed/getTimeline.ts
···11+import { Server } from "../../../../lex/index.ts";
22+import { AppContext } from "../../../../main.ts";
33+import { transformPostsToPostViews } from "../../../../utils/post-transformer.ts";
44+import { decodeBase64, encodeBase64 } from "jsr:@std/encoding";
55+import { OutputSchema } from "../../../../lex/types/so/sprk/feed/getTimeline.ts";
66+77+interface CursorData {
88+ createdAt: string;
99+ id: string;
1010+}
1111+1212+// Helper function to parse cursor
1313+function parseCursor(cursor: string): CursorData {
1414+ try {
1515+ const decodedCursor = new TextDecoder().decode(decodeBase64(cursor));
1616+ const [timestamp, id] = decodedCursor.split("::");
1717+1818+ if (!timestamp || !id) {
1919+ throw new Error("Invalid cursor format");
2020+ }
2121+2222+ return { createdAt: timestamp, id };
2323+ } catch {
2424+ throw new Error("Invalid cursor format");
2525+ }
2626+}
2727+2828+// Helper function to generate cursor
2929+function generateCursor(createdAt: string, id: string): string {
3030+ return encodeBase64(
3131+ new TextEncoder().encode(`${createdAt}::${id}`),
3232+ );
3333+}
3434+3535+// Helper function to get followed user DIDs
3636+async function getFollowedUsers(
3737+ ctx: AppContext,
3838+ userDid: string,
3939+): Promise<string[]> {
4040+ const follows = await ctx.db.models.Follow.find({
4141+ authorDid: userDid,
4242+ }).select("subject").lean();
4343+4444+ return follows.map((follow) => follow.subject);
4545+}
4646+4747+// Helper function to build timeline query
4848+function buildTimelineQuery(
4949+ followedDids: string[],
5050+ cursor?: CursorData,
5151+): Record<string, unknown> {
5252+ const query: Record<string, unknown> = {
5353+ authorDid: { $in: followedDids },
5454+ reply: null, // Only show top-level posts, not replies
5555+ };
5656+5757+ // Add cursor-based pagination
5858+ if (cursor) {
5959+ query.$or = [
6060+ { createdAt: { $lt: cursor.createdAt } },
6161+ { createdAt: cursor.createdAt, _id: { $lt: cursor.id } },
6262+ ];
6363+ }
6464+6565+ return query;
6666+}
6767+6868+export default function (server: Server, ctx: AppContext) {
6969+ server.so.sprk.feed.getTimeline({
7070+ auth: ctx.authVerifier.standard,
7171+ handler: async ({ params, auth }) => {
7272+ try {
7373+ const { limit = 50, cursor } = params;
7474+ const userDid = auth.credentials.iss;
7575+7676+ // Validate limit
7777+ if (limit < 1 || limit > 100) {
7878+ throw new Error("Limit must be between 1 and 100");
7979+ }
8080+8181+ // Parse cursor if provided
8282+ let cursorData: CursorData | undefined;
8383+ if (cursor) {
8484+ cursorData = parseCursor(cursor);
8585+ }
8686+8787+ // Get list of users the authenticated user follows
8888+ const followedDids = await getFollowedUsers(ctx, userDid);
8989+9090+ // If user doesn't follow anyone, return empty feed
9191+ if (followedDids.length === 0) {
9292+ return {
9393+ encoding: "application/json",
9494+ body: {
9595+ feed: [],
9696+ },
9797+ };
9898+ }
9999+100100+ // Build and execute query for posts from followed users
101101+ const query = buildTimelineQuery(followedDids, cursorData);
102102+ const posts = await ctx.db.models.Post.find(query)
103103+ .sort({ createdAt: -1, _id: -1 })
104104+ .limit(limit + 1) // Get one extra for hasMore check
105105+ .lean();
106106+107107+ // Check if there are more results
108108+ const hasMore = posts.length > limit;
109109+ if (hasMore) {
110110+ posts.pop(); // Remove the extra item
111111+ }
112112+113113+ // Transform posts to feed view posts
114114+ const feedViewPosts = await transformPostsToPostViews(
115115+ posts,
116116+ ctx,
117117+ userDid,
118118+ );
119119+120120+ // Generate next cursor if there are more results
121121+ let nextCursor: string | undefined;
122122+ if (hasMore && posts.length > 0) {
123123+ const lastPost = posts[posts.length - 1];
124124+ nextCursor = generateCursor(
125125+ String(lastPost.createdAt),
126126+ String(lastPost._id),
127127+ );
128128+ }
129129+130130+ // Prepare response
131131+ const response: OutputSchema = {
132132+ feed: feedViewPosts.map((post) => ({ post })),
133133+ };
134134+135135+ if (nextCursor) {
136136+ response.cursor = nextCursor;
137137+ }
138138+139139+ return {
140140+ encoding: "application/json",
141141+ body: response,
142142+ };
143143+ } catch (error) {
144144+ // Handle specific error cases
145145+ if (error instanceof Error) {
146146+ const message = error.message;
147147+148148+ if (message.includes("cursor") || message.includes("Cursor")) {
149149+ return {
150150+ status: 400,
151151+ message: "The provided cursor is invalid",
152152+ };
153153+ }
154154+155155+ if (message.includes("limit") || message.includes("Limit")) {
156156+ return {
157157+ status: 400,
158158+ message: "Limit must be between 1 and 100",
159159+ };
160160+ }
161161+ }
162162+163163+ // Log unexpected errors and rethrow
164164+ console.error("Unexpected error in getTimeline:", error);
165165+ throw error;
166166+ }
167167+ },
168168+ });
169169+}