From 9b895e402e9f36a10aeaaaa8c9c124dca785dde2 Mon Sep 17 00:00:00 2001 From: Roscoe Rubin-Rottenberg <118622417+knotbin@users.noreply.github.com> Date: Thu, 20 Nov 2025 23:40:34 -0500 Subject: [PATCH] getfeedgen and prefs (#51) * getfeedgen and prefs * fmt * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * rm did field * fmt --------- Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- api/index.ts | 4 + api/so/sprk/actor/getPreferences.ts | 119 +++++++++++++++++++++-- api/so/sprk/actor/putPreferences.ts | 133 ++++++++++++++++++++++---- api/so/sprk/feed/getFeedGenerator.ts | 48 ++++++++++ api/so/sprk/feed/getFeedGenerators.ts | 37 +++++++ data-plane/db/index.ts | 8 +- data-plane/db/models.ts | 77 ++++++++++++--- data-plane/index.ts | 3 + data-plane/routes/feeds.ts | 25 +++++ data-plane/routes/preferences.ts | 32 +++++++ hydration/feed.ts | 5 +- views/index.ts | 39 ++++++++ 12 files changed, 483 insertions(+), 47 deletions(-) create mode 100644 api/so/sprk/feed/getFeedGenerator.ts create mode 100644 api/so/sprk/feed/getFeedGenerators.ts create mode 100644 data-plane/routes/preferences.ts diff --git a/api/index.ts b/api/index.ts index 8512388..e7c96be 100644 --- a/api/index.ts +++ b/api/index.ts @@ -25,6 +25,8 @@ import getTrendingAudios from "./so/sprk/sound/getTrendingAudios.ts"; import getSuggestedFeeds from "./so/sprk/feed/getSuggestedFeeds.ts"; import getTimeline from "./so/sprk/feed/getTimeline.ts"; import getFeed from "./so/sprk/feed/getFeed.ts"; +import getFeedGenerator from "./so/sprk/feed/getFeedGenerator.ts"; +import getFeedGenerators from "./so/sprk/feed/getFeedGenerators.ts"; export default function (server: Server, ctx: AppContext) { getAccountInfos(server, ctx); @@ -52,4 +54,6 @@ export default function (server: Server, ctx: AppContext) { getSuggestedFeeds(server, ctx); getTimeline(server, ctx); getFeed(server, ctx); + getFeedGenerator(server, ctx); + getFeedGenerators(server, ctx); } diff --git a/api/so/sprk/actor/getPreferences.ts b/api/so/sprk/actor/getPreferences.ts index 37f185f..1f7b2b0 100644 --- a/api/so/sprk/actor/getPreferences.ts +++ b/api/so/sprk/actor/getPreferences.ts @@ -1,6 +1,13 @@ import { Server } from "../../../../lex/index.ts"; import { AppContext } from "../../../../context.ts"; -import { Preferences } from "../../../../lex/types/so/sprk/actor/defs.ts"; +import { + ContentLabelPref, + MutedWord, + PostInteractionSettingsPref, + Preferences, + SavedFeed, + ThreadViewPref, +} from "../../../../lex/types/so/sprk/actor/defs.ts"; export default function (server: Server, ctx: AppContext) { server.so.sprk.actor.getPreferences({ @@ -9,17 +16,115 @@ export default function (server: Server, ctx: AppContext) { const userDid = auth.credentials.iss; try { - const userPref = await ctx.db.models.UserPreference.findOne({ + const userPref = await ctx.dataplane.preferences.getPreferences( userDid, - }); + ); + + const preferences: Preferences = []; + + if (!userPref) { + return { + encoding: "application/json", + body: { preferences }, + }; + } + + if (userPref.contentLabelPrefs?.length) { + for (const pref of userPref.contentLabelPrefs) { + preferences.push({ + $type: "so.sprk.actor.defs#contentLabelPref", + labelerDid: pref.labelerDid, + label: pref.label, + visibility: pref.visibility as ContentLabelPref["visibility"], + }); + } + } + + if (userPref.savedFeeds?.length) { + preferences.push({ + $type: "so.sprk.actor.defs#savedFeedsPref", + items: userPref.savedFeeds.map((item) => ({ + ...item, + type: item.type as SavedFeed["type"], + })), + }); + } + + if (userPref.personalDetailsPref) { + preferences.push({ + $type: "so.sprk.actor.defs#personalDetailsPref", + birthDate: userPref.personalDetailsPref.birthDate, + }); + } + + if (userPref.feedViewPrefs?.length) { + for (const pref of userPref.feedViewPrefs) { + preferences.push({ + $type: "so.sprk.actor.defs#feedViewPref", + feed: pref.feed, + hideReplies: pref.hideReplies, + hideRepliesByUnfollowed: pref.hideRepliesByUnfollowed, + hideRepliesByLikeCount: pref.hideRepliesByLikeCount, + hideRepliesByLookCount: pref.hideRepliesByLookCount, + hideReposts: pref.hideReposts, + hideQuotePosts: pref.hideQuotePosts, + }); + } + } + + if (userPref.threadViewPref) { + preferences.push({ + $type: "so.sprk.actor.defs#threadViewPref", + sort: userPref.threadViewPref.sort as ThreadViewPref["sort"], + }); + } + + if (userPref.interestsPref) { + preferences.push({ + $type: "so.sprk.actor.defs#interestsPref", + tags: userPref.interestsPref.tags, + }); + } + + if (userPref.mutedWordsPref) { + preferences.push({ + $type: "so.sprk.actor.defs#mutedWordsPref", + items: userPref.mutedWordsPref.items.map((item) => ({ + ...item, + targets: item.targets as MutedWord["targets"], + actorTarget: item.actorTarget as MutedWord["actorTarget"], + })), + }); + } + + if (userPref.hiddenPostsPref) { + preferences.push({ + $type: "so.sprk.actor.defs#hiddenPostsPref", + items: userPref.hiddenPostsPref.items, + }); + } + + if (userPref.labelersPref) { + preferences.push({ + $type: "so.sprk.actor.defs#labelersPref", + labelers: userPref.labelersPref.labelers, + }); + } + + if (userPref.postInteractionSettingsPref) { + preferences.push({ + $type: "so.sprk.actor.defs#postInteractionSettingsPref", + threadgateAllowRules: userPref.postInteractionSettingsPref + .threadgateAllowRules as PostInteractionSettingsPref[ + "threadgateAllowRules" + ], + }); + } return { encoding: "application/json", body: { - preferences: [{ - $type: "so.sprk.actor.defs#savedFeedsPref", - items: (userPref?.savedFeeds ?? []), - }] as Preferences, + preferences, }, }; } catch (error) { diff --git a/api/so/sprk/actor/putPreferences.ts b/api/so/sprk/actor/putPreferences.ts index 6016b69..673f18e 100644 --- a/api/so/sprk/actor/putPreferences.ts +++ b/api/so/sprk/actor/putPreferences.ts @@ -1,5 +1,17 @@ import { Server } from "../../../../lex/index.ts"; -import { SavedFeedsPref } from "../../../../lex/types/so/sprk/actor/defs.ts"; +import { + ContentLabelPref, + FeedViewPref, + HiddenPostsPref, + InterestsPref, + LabelersPref, + MutedWordsPref, + PersonalDetailsPref, + PostInteractionSettingsPref, + SavedFeedsPref, + ThreadViewPref, +} from "../../../../lex/types/so/sprk/actor/defs.ts"; +import { PreferenceDocument } from "../../../../data-plane/db/models.ts"; import { AppContext } from "../../../../context.ts"; export default function (server: Server, ctx: AppContext) { @@ -11,34 +23,113 @@ export default function (server: Server, ctx: AppContext) { try { const now = new Date().toISOString(); - let userPref = await ctx.db.models.UserPreference.findOne({ userDid }); - for (const pref of body.preferences) { - if (pref as SavedFeedsPref) { - const savedFeedsPref = pref as SavedFeedsPref; + const updateData: Partial = { + updatedAt: now, + }; - const savedFeeds = savedFeedsPref.items; + // Track which preference types we've seen to collect all entries + const contentLabelPrefs: NonNullable< + PreferenceDocument["contentLabelPrefs"] + > = []; + const feedViewPrefs: NonNullable< + PreferenceDocument["feedViewPrefs"] + > = []; - if (!userPref) { - userPref = await ctx.db.models.UserPreference.create({ - userDid, - savedFeeds: savedFeeds, - createdAt: now, - updatedAt: now, + for (const pref of body.preferences) { + switch (pref.$type) { + case "so.sprk.actor.defs#contentLabelPref": { + const p = pref as ContentLabelPref; + contentLabelPrefs.push({ + labelerDid: p.labelerDid, + label: p.label, + visibility: p.visibility, + }); + break; + } + case "so.sprk.actor.defs#savedFeedsPref": { + const p = pref as SavedFeedsPref; + updateData.savedFeeds = p.items ?? []; + break; + } + case "so.sprk.actor.defs#personalDetailsPref": { + const p = pref as PersonalDetailsPref; + updateData.personalDetailsPref = { + birthDate: p.birthDate, + }; + break; + } + case "so.sprk.actor.defs#feedViewPref": { + const p = pref as FeedViewPref; + feedViewPrefs.push({ + feed: p.feed, + hideReplies: p.hideReplies, + hideRepliesByUnfollowed: p.hideRepliesByUnfollowed, + hideRepliesByLikeCount: p.hideRepliesByLikeCount, + hideRepliesByLookCount: p.hideRepliesByLookCount, + hideReposts: p.hideReposts, + hideQuotePosts: p.hideQuotePosts, }); - } else { - await ctx.db.models.UserPreference.updateOne( - { userDid }, - { - $push: { - savedFeeds: { $each: savedFeeds }, - }, - }, - ); + break; + } + case "so.sprk.actor.defs#threadViewPref": { + const p = pref as ThreadViewPref; + updateData.threadViewPref = { + sort: p.sort, + }; + break; + } + case "so.sprk.actor.defs#interestsPref": { + const p = pref as InterestsPref; + updateData.interestsPref = { + tags: p.tags, + }; + break; + } + case "so.sprk.actor.defs#mutedWordsPref": { + const p = pref as MutedWordsPref; + updateData.mutedWordsPref = { + items: p.items ?? [], + }; + break; + } + case "so.sprk.actor.defs#hiddenPostsPref": { + const p = pref as HiddenPostsPref; + updateData.hiddenPostsPref = { + items: p.items ?? [], + }; + break; + } + case "so.sprk.actor.defs#labelersPref": { + const p = pref as LabelersPref; + updateData.labelersPref = { + labelers: p.labelers ?? [], + }; + break; + } + case "so.sprk.actor.defs#postInteractionSettingsPref": { + const p = pref as PostInteractionSettingsPref; + updateData.postInteractionSettingsPref = { + threadgateAllowRules: p.threadgateAllowRules as Array<{ + $type: string; + [key: string]: unknown; + }>, + }; + break; } } } + // Set array-based preferences if we found any + if (contentLabelPrefs.length > 0) { + updateData.contentLabelPrefs = contentLabelPrefs; + } + if (feedViewPrefs.length > 0) { + updateData.feedViewPrefs = feedViewPrefs; + } + + await ctx.dataplane.preferences.putPreferences(userDid, updateData); + return; } catch (error) { ctx.logger.error("Failed to put preferences", { error, userDid }); diff --git a/api/so/sprk/feed/getFeedGenerator.ts b/api/so/sprk/feed/getFeedGenerator.ts new file mode 100644 index 0000000..3ed9c8f --- /dev/null +++ b/api/so/sprk/feed/getFeedGenerator.ts @@ -0,0 +1,48 @@ +import { AppContext } from "../../../../context.ts"; +import { Server } from "../../../../lex/index.ts"; +import { resHeaders } from "../../../util.ts"; + +export default function (server: Server, ctx: AppContext) { + server.so.sprk.feed.getFeedGenerator({ + auth: ctx.authVerifier.optionalStandardOrRole, + handler: async ({ params, auth }) => { + const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth); + const hydrateCtx = ctx.hydrator.createContext({ + viewer, + includeTakedowns, + }); + + // Hydrate feed generator + const hydrationState = await ctx.hydrator.hydrateFeedGens( + [params.feed], + hydrateCtx, + ); + + // Create generator view + const view = ctx.views.generator(params.feed, hydrationState); + + if (!view) { + throw new Error(`Feed generator not found: ${params.feed}`); + } + + // For now, assume online and valid + // In a real implementation, you might check service health + const isOnline = true; + const isValid = true; + + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer); + + return { + encoding: "application/json", + body: { + view, + isOnline, + isValid, + }, + headers: resHeaders({ + repoRev, + }), + }; + }, + }); +} diff --git a/api/so/sprk/feed/getFeedGenerators.ts b/api/so/sprk/feed/getFeedGenerators.ts new file mode 100644 index 0000000..1f16757 --- /dev/null +++ b/api/so/sprk/feed/getFeedGenerators.ts @@ -0,0 +1,37 @@ +import { AppContext } from "../../../../context.ts"; +import { Server } from "../../../../lex/index.ts"; +import { resHeaders } from "../../../util.ts"; + +export default function (server: Server, ctx: AppContext) { + server.so.sprk.feed.getFeedGenerators({ + auth: ctx.authVerifier.optionalStandardOrRole, + handler: async ({ params, auth }) => { + const { viewer, includeTakedowns } = ctx.authVerifier.parseCreds(auth); + const hydrateCtx = ctx.hydrator.createContext({ + viewer, + includeTakedowns, + }); + + // Hydrate feed generators + const hydrationState = await ctx.hydrator.hydrateFeedGens( + params.feeds, + hydrateCtx, + ); + + // Create generator views + const feeds = params.feeds + .map((uri) => ctx.views.generator(uri, hydrationState)) + .filter((view): view is NonNullable => view !== undefined); + + const repoRev = await ctx.hydrator.actor.getRepoRevSafe(viewer); + + return { + encoding: "application/json", + body: { feeds }, + headers: resHeaders({ + repoRev, + }), + }; + }, + }); +} diff --git a/data-plane/db/index.ts b/data-plane/db/index.ts index b919a66..a67b462 100644 --- a/data-plane/db/index.ts +++ b/data-plane/db/index.ts @@ -97,7 +97,7 @@ export class Database { models.repostSchema, ), Generator: this.connection.model( - "SprkGenerator", + "Generator", models.generatorSchema, ), Takedown: this.connection.model( @@ -120,9 +120,9 @@ export class Database { "ActorSync", models.actorSyncSchema, ), - UserPreference: this.connection.model( - "UserPreference", - models.userPreferenceSchema, + Preference: this.connection.model( + "Preference", + models.preferenceSchema, ), CursorState: this.connection.model( "CursorState", diff --git a/data-plane/db/models.ts b/data-plane/db/models.ts index cd524f2..7bc54f6 100644 --- a/data-plane/db/models.ts +++ b/data-plane/db/models.ts @@ -474,23 +474,76 @@ export const actorSchema = new Schema({ services: { type: String, required: true }, }); -type SavedFeed = { - id: string; - type: "feed" | "list" | "timeline"; - value: string; - pinned: boolean; -}; - -export interface UserPreferenceDocument extends Document { +export interface PreferenceDocument extends Document { userDid: string; - savedFeeds: SavedFeed[]; + contentLabelPrefs?: Array<{ + labelerDid?: string; + label: string; + visibility: string; + }>; + savedFeeds?: Array<{ + id: string; + type: string; + value: string; + pinned: boolean; + }>; + personalDetailsPref?: { + birthDate?: string; + }; + feedViewPrefs?: Array<{ + feed: string; + hideReplies?: boolean; + hideRepliesByUnfollowed: boolean; + hideRepliesByLikeCount?: number; + hideRepliesByLookCount?: number; + hideReposts?: boolean; + hideQuotePosts?: boolean; + }>; + threadViewPref?: { + sort?: string; + }; + interestsPref?: { + tags: string[]; + }; + mutedWordsPref?: { + items: Array<{ + id?: string; + value: string; + targets: string[]; + actorTarget: string; + expiresAt?: string; + }>; + }; + hiddenPostsPref?: { + items: string[]; + }; + labelersPref?: { + labelers: Array<{ + did: string; + }>; + }; + postInteractionSettingsPref?: { + threadgateAllowRules?: Array<{ + $type: string; + [key: string]: unknown; + }>; + }; createdAt: string; updatedAt: string; } -export const userPreferenceSchema = new Schema({ +export const preferenceSchema = new Schema({ userDid: { type: String, required: true, unique: true, index: true }, - savedFeeds: { type: [Object], required: true }, + contentLabelPrefs: { type: [Object], required: false }, + savedFeeds: { type: [Object], required: false }, + personalDetailsPref: { type: Object, required: false }, + feedViewPrefs: { type: [Object], required: false }, + threadViewPref: { type: Object, required: false }, + interestsPref: { type: Object, required: false }, + mutedWordsPref: { type: Object, required: false }, + hiddenPostsPref: { type: Object, required: false }, + labelersPref: { type: Object, required: false }, + postInteractionSettingsPref: { type: Object, required: false }, createdAt: { type: String, required: true }, updatedAt: { type: String, required: true }, }); @@ -539,6 +592,6 @@ export interface DatabaseModels { BlobTakedown: Model; Actor: Model; ActorSync: Model; - UserPreference: Model; + Preference: Model; CursorState: Model; } diff --git a/data-plane/index.ts b/data-plane/index.ts index 2395d54..e6e76d3 100644 --- a/data-plane/index.ts +++ b/data-plane/index.ts @@ -15,6 +15,7 @@ import { Reposts } from "./routes/reposts.ts"; import { Stories } from "./routes/stories.ts"; import { Sync } from "./routes/sync.ts"; import { Threads } from "./routes/threads.ts"; +import { Preferences } from "./routes/preferences.ts"; export { RepoSubscription } from "./subscription.ts"; @@ -43,6 +44,7 @@ export class DataPlane { public stories: Stories; public sync: Sync; public threads: Threads; + public preferences: Preferences; constructor( db: Database, @@ -67,5 +69,6 @@ export class DataPlane { this.stories = new Stories(db); this.sync = new Sync(db); this.threads = new Threads(db); + this.preferences = new Preferences(db); } } diff --git a/data-plane/routes/feeds.ts b/data-plane/routes/feeds.ts index 90bc0fd..0625ab7 100644 --- a/data-plane/routes/feeds.ts +++ b/data-plane/routes/feeds.ts @@ -37,6 +37,31 @@ export class Feeds { this.timeCidKeyset = new TimeCidKeyset(); } + async getFeedGenerators(uris: string[]) { + if (!uris.length) return { generators: [] }; + + const generators = await this.db.models.Generator.find({ + uri: { $in: uris }, + }).populate("actor"); + + return { + generators: generators.map((generator) => ({ + uri: generator.uri, + cid: generator.cid, + authorDid: generator.authorDid, + displayName: generator.displayName, + description: generator.description, + descriptionFacets: generator.descriptionFacets, + avatar: generator.avatar, + acceptsInteractions: generator.acceptsInteractions, + likeCount: generator.likeCount || 0, + createdAt: generator.createdAt, + indexedAt: generator.indexedAt, + actor: generator.actor, + })), + }; + } + async getAuthorFeed( actorDid: string, limit = 50, diff --git a/data-plane/routes/preferences.ts b/data-plane/routes/preferences.ts new file mode 100644 index 0000000..8681e52 --- /dev/null +++ b/data-plane/routes/preferences.ts @@ -0,0 +1,32 @@ +import { Database } from "../db/index.ts"; +import { PreferenceDocument } from "../db/models.ts"; + +export class Preferences { + private db: Database; + + constructor(db: Database) { + this.db = db; + } + + async getPreferences(userDid: string) { + return await this.db.models.Preference.findOne({ userDid }); + } + + async putPreferences(userDid: string, data: Partial) { + const now = new Date().toISOString(); + + const updateData = { + ...data, + updatedAt: now, + }; + + await this.db.models.Preference.findOneAndUpdate( + { userDid }, + { + $set: updateData, + $setOnInsert: { userDid, createdAt: now }, + }, + { upsert: true }, + ); + } +} diff --git a/hydration/feed.ts b/hydration/feed.ts index d8f7f67..4cbe1ab 100644 --- a/hydration/feed.ts +++ b/hydration/feed.ts @@ -1,5 +1,4 @@ import { Record as FeedGenRecord } from "../lex/types/so/sprk/feed/generator.ts"; -import { Record as BskyFeedGenRecord } from "../lex/types/app/bsky/feed/generator.ts"; import { Record as LikeRecord } from "../lex/types/so/sprk/feed/like.ts"; import { Record as PostRecord } from "../lex/types/so/sprk/feed/post.ts"; import { Record as ReplyRecord } from "../lex/types/so/sprk/feed/reply.ts"; @@ -70,7 +69,7 @@ export type FeedGenAgg = { export type FeedGenAggs = HydrationMap; -export type FeedGen = RecordInfo; +export type FeedGen = RecordInfo; export type FeedGens = HydrationMap; export type FeedGenViewerState = { @@ -268,7 +267,7 @@ export class FeedHydrator { if (!uris.length) return new HydrationMap(); const res = await this.dataplane.records.getFeedGeneratorRecords(uris); return uris.reduce((acc, uri, i) => { - const record = parseRecord( + const record = parseRecord( res.records[i], includeTakedowns, ); diff --git a/views/index.ts b/views/index.ts index d7ad7e1..701b197 100644 --- a/views/index.ts +++ b/views/index.ts @@ -27,6 +27,7 @@ import { } from "../lex/types/so/sprk/actor/defs.ts"; import { BlockedPost, + GeneratorView, ImagesMedia, ImagesMediaView, isImagesMedia, @@ -755,6 +756,44 @@ export class Views { new Date().toISOString(), }; } + + generator( + uri: string, + state: HydrationState, + ): Un$Typed | undefined { + const generatorInfo = state.feedgens?.get(uri); + if (!generatorInfo) return; + + const parsedUri = new AtUri(uri); + const authorDid = parsedUri.hostname; + const creator = this.profile(authorDid, state); + if (!creator) return; + + const generatorAgg = state.feedgenAggs?.get(uri); + const viewer = state.feedgenViewers?.get(uri); + + const avatar = generatorInfo.record.avatar + ? `${this.mediaCdn}/avatar/medium/${authorDid}/${ + cidFromBlobJson(generatorInfo.record.avatar as BlobRef) + }/webp` + : undefined; + + return { + uri, + cid: generatorInfo.cid, + did: generatorInfo.record.did, + creator, + displayName: generatorInfo.record.displayName, + description: generatorInfo.record.description, + descriptionFacets: generatorInfo.record.descriptionFacets, + avatar, + likeCount: generatorAgg?.likes ?? 0, + acceptsInteractions: generatorInfo.record.acceptsInteractions, + viewer: viewer?.like ? { like: viewer.like } : undefined, + indexedAt: this.indexedAt(generatorInfo)?.toISOString() ?? + new Date().toISOString(), + }; + } indexedAt({ sortedAt, indexedAt }: { sortedAt: Date; indexedAt: Date }) { if (!this.indexedAtEpoch) return sortedAt; return indexedAt && indexedAt > this.indexedAtEpoch ? indexedAt : sortedAt; -- 2.51.2