diff --git a/api/so/sprk/actor/getPreferences.ts b/api/so/sprk/actor/getPreferences.ts index e7d3284..2d78ad3 100644 --- a/api/so/sprk/actor/getPreferences.ts +++ b/api/so/sprk/actor/getPreferences.ts @@ -3,7 +3,6 @@ import { AppContext } from "../../../../context.ts"; import { ContentLabelPref, MutedWord, - PostInteractionSettingsPref, Preferences, SavedFeed, ThreadViewPref, @@ -110,16 +109,6 @@ export default function (server: Server, ctx: AppContext) { }); } - if (userPref.postInteractionSettingsPref) { - preferences.push({ - $type: "so.sprk.actor.defs#postInteractionSettingsPref", - threadgateAllowRules: userPref.postInteractionSettingsPref - .threadgateAllowRules as PostInteractionSettingsPref[ - "threadgateAllowRules" - ], - }); - } - return { encoding: "application/json", body: { diff --git a/api/so/sprk/actor/putPreferences.ts b/api/so/sprk/actor/putPreferences.ts index dd089c7..5350cfc 100644 --- a/api/so/sprk/actor/putPreferences.ts +++ b/api/so/sprk/actor/putPreferences.ts @@ -7,7 +7,6 @@ import { LabelersPref, MutedWordsPref, PersonalDetailsPref, - PostInteractionSettingsPref, SavedFeedsPref, ThreadViewPref, } from "../../../../lex/types/so/sprk/actor/defs.ts"; @@ -106,16 +105,6 @@ export default function (server: Server, ctx: AppContext) { }; 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; - } } } diff --git a/api/so/sprk/story/getStories.ts b/api/so/sprk/story/getStories.ts index f1dd752..a0b4435 100644 --- a/api/so/sprk/story/getStories.ts +++ b/api/so/sprk/story/getStories.ts @@ -126,29 +126,7 @@ const hydration = async ( inputs: HydrationFnInput, ): Promise => { const { ctx, params, skeleton } = inputs; - // Hydrate stories - const stories = await ctx.hydrator.story.getStories( - skeleton.stories, - params.hydrateCtx.includeTakedowns || false, - ); - - // Get author DIDs for actor hydration - const authorDids = [ - ...new Set( - skeleton.stories.map((uri) => uriToDid(uri)), - ), - ]; - - // Hydrate actors (profiles) - const actors = await ctx.hydrator.actor.getActors( - authorDids, - params.hydrateCtx, - ); - - return { - stories, - actors, - }; + return await ctx.hydrator.hydrateStories(skeleton.stories, params.hydrateCtx); }; const rules = (inputs: RulesFnInput): Skeleton => { diff --git a/api/so/sprk/story/getTimeline.ts b/api/so/sprk/story/getTimeline.ts index 764191b..9974837 100644 --- a/api/so/sprk/story/getTimeline.ts +++ b/api/so/sprk/story/getTimeline.ts @@ -104,27 +104,7 @@ const hydration = async ( inputs: HydrationFnInput, ): Promise => { const { ctx, params, skeleton } = inputs; - - // Get author DIDs for actor hydration (can be computed before fetching) - const authorDids = [ - ...new Set( - skeleton.stories.map((uri) => uriToDid(uri)), - ), - ]; - - // Parallelize stories and actors hydration - const [stories, actors] = await Promise.all([ - ctx.hydrator.story.getStories( - skeleton.stories, - params.hydrateCtx.includeTakedowns || false, - ), - ctx.hydrator.actor.getActors(authorDids, params.hydrateCtx), - ]); - - return { - stories, - actors, - }; + return await ctx.hydrator.hydrateStories(skeleton.stories, params.hydrateCtx); }; const rules = (inputs: RulesFnInput): Skeleton => { diff --git a/data-plane/db/models.ts b/data-plane/db/models.ts index a437b7d..94e2f42 100644 --- a/data-plane/db/models.ts +++ b/data-plane/db/models.ts @@ -413,6 +413,7 @@ export const crosspostReplySchema = new Schema({ export interface StoryDocument extends AuthoredDocument { media: StoryMedia; sound?: RecordRef; + embeds?: StoryEmbed[]; labels?: Label[]; } export const storySchema = new Schema({ @@ -425,6 +426,7 @@ export const storySchema = new Schema({ }, required: false, }, + embeds: { type: [Object], required: false, default: [] }, labels: { type: [Object], required: false, default: [] }, }) .index({ authorDid: 1, createdAt: -1 }); diff --git a/data-plane/indexing/plugins/story.ts b/data-plane/indexing/plugins/story.ts index 17af823..8392a67 100644 --- a/data-plane/indexing/plugins/story.ts +++ b/data-plane/indexing/plugins/story.ts @@ -23,8 +23,8 @@ const insertFn = async ( authorDid: uri.host, media: obj.media, sound: obj.sound, + embeds: obj.embeds || [], labels: obj.labels || null, - tags: obj.tags || [], createdAt: normalizeDatetimeAlways(obj.createdAt), indexedAt: timestamp, }; diff --git a/hydration/index.ts b/hydration/index.ts index 7791698..bc67e2a 100644 --- a/hydration/index.ts +++ b/hydration/index.ts @@ -3,6 +3,7 @@ import { AtUri } from "@atp/syntax"; import { DataPlane } from "../data-plane/index.ts"; import { ids } from "../lex/lexicons.ts"; import { Record as ProfileRecord } from "../lex/types/so/sprk/actor/profile.ts"; +import { Record as StoryRecord } from "../lex/types/so/sprk/story/post.ts"; import { uriToDid as didFromUri } from "../utils/uris.ts"; import { ActivitySubscriptionStates, @@ -574,6 +575,82 @@ export class Hydrator { return this.hydratePosts(refs, ctx); } + // so.sprk.story.defs#storyView + // - story + // - profile + // - list basic + // - embeds (story record) + // - mention + // - profile + // - list basic + // - post + // - postView / blockedPost / notFoundPost + // - profile + // - list basic + async hydrateStories( + uris: string[], + ctx: HydrateCtx, + ): Promise { + const stories = await this.story.getStories(uris, ctx.includeTakedowns); + + const storyAuthorDids = uris.map(didFromUri); + const embedPostUris = new Set(); + const mentionDids = new Set(); + + for (const story of stories.values()) { + if (!story) continue; + const record = story.record as StoryRecord; + for (const embed of record.embeds ?? []) { + if ( + embed && + typeof embed === "object" && + "$type" in embed && + (embed as { $type?: string }).$type === "so.sprk.embed.post" + ) { + const postUri = (embed as { post?: { uri?: string } }).post?.uri; + if (postUri) { + embedPostUris.add(postUri); + } + } else if ( + embed && + typeof embed === "object" && + "$type" in embed && + (embed as { $type?: string }).$type === "so.sprk.embed.mention" + ) { + const did = (embed as { did?: string }).did; + if (did) { + mentionDids.add(did); + } + } + } + } + + const postUris: string[] = []; + for (const postUri of embedPostUris) { + try { + didFromUri(postUri); + postUris.push(postUri); + } catch { + continue; + } + } + const profileDids = Array.from( + new Set([ + ...storyAuthorDids, + ...mentionDids, + ]), + ); + + const [postState, profileState] = await Promise.all([ + postUris.length > 0 + ? this.hydratePosts(postUris.map((uri) => ({ uri })), ctx) + : Promise.resolve({}), + this.hydrateProfiles(profileDids, ctx), + ]); + + return mergeManyStates(profileState, postState, { stories, ctx }); + } + // so.sprk.feed.defs#generatorView // - feedgen // - profile diff --git a/lex/index.ts b/lex/index.ts index 295e915..babb48f 100644 --- a/lex/index.ts +++ b/lex/index.ts @@ -2641,6 +2641,7 @@ export class SoNS { export class SoSprkNS { _server: Server; video: SoSprkVideoNS; + embed: SoSprkEmbedNS; notification: SoSprkNotificationNS; graph: SoSprkGraphNS; feed: SoSprkFeedNS; @@ -2654,6 +2655,7 @@ export class SoSprkNS { constructor(server: Server) { this._server = server; this.video = new SoSprkVideoNS(server); + this.embed = new SoSprkEmbedNS(server); this.notification = new SoSprkNotificationNS(server); this.graph = new SoSprkGraphNS(server); this.feed = new SoSprkFeedNS(server); @@ -2710,6 +2712,14 @@ export class SoSprkVideoNS { } } +export class SoSprkEmbedNS { + _server: Server; + + constructor(server: Server) { + this._server = server; + } +} + export class SoSprkNotificationNS { _server: Server; diff --git a/lex/lexicons.ts b/lex/lexicons.ts index 47f9150..6ecf01f 100644 --- a/lex/lexicons.ts +++ b/lex/lexicons.ts @@ -16281,6 +16281,197 @@ export const schemaDict = { }, }, }, + "SoSprkEmbedDefs": { + "lexicon": 1, + "id": "so.sprk.embed.defs", + "description": "Shared definitions for Spark interactive embeds.", + "defs": { + "embeds": { + "type": "array", + "items": { + "type": "union", + "refs": [ + "lex:so.sprk.embed.mention", + "lex:so.sprk.embed.post", + ], + }, + }, + "views": { + "type": "array", + "items": { + "type": "union", + "refs": [ + "lex:so.sprk.embed.mention#view", + "lex:so.sprk.embed.post#view", + ], + }, + }, + "placement": { + "type": "object", + "description": + "Placement and layer metadata for an embed on a media canvas.", + "required": [ + "frame", + ], + "properties": { + "frame": { + "type": "ref", + "ref": "lex:so.sprk.embed.defs#frame", + }, + "mediaRef": { + "type": "ref", + "ref": "lex:so.sprk.embed.defs#mediaRef", + }, + "zIndex": { + "type": "integer", + "minimum": 0, + }, + "rotation": { + "type": "integer", + "minimum": 0, + "maximum": 359, + }, + }, + }, + "frame": { + "type": "object", + "description": + "Bounding box in 10,000-based normalized coordinates relative to media canvas dimensions.", + "required": [ + "x", + "y", + "w", + "h", + ], + "properties": { + "x": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + }, + "y": { + "type": "integer", + "minimum": 0, + "maximum": 10000, + }, + "w": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + }, + "h": { + "type": "integer", + "minimum": 1, + "maximum": 10000, + }, + }, + }, + "mediaRef": { + "type": "object", + "description": + "Optional media locator for records containing multiple media items.", + "required": [ + "index", + ], + "properties": { + "index": { + "type": "integer", + "minimum": 0, + }, + }, + }, + }, + }, + "SoSprkEmbedMention": { + "lexicon": 1, + "id": "so.sprk.embed.mention", + "description": "Interactive mention embed.", + "defs": { + "main": { + "type": "object", + "required": [ + "placement", + "did", + ], + "properties": { + "placement": { + "type": "ref", + "ref": "lex:so.sprk.embed.defs#placement", + }, + "did": { + "type": "string", + "format": "did", + }, + }, + }, + "view": { + "type": "object", + "required": [ + "placement", + "did", + ], + "properties": { + "placement": { + "type": "ref", + "ref": "lex:so.sprk.embed.defs#placement", + }, + "did": { + "type": "string", + "format": "did", + }, + "actor": { + "type": "ref", + "ref": "lex:so.sprk.actor.defs#profileViewBasic", + }, + }, + }, + }, + }, + "SoSprkEmbedPost": { + "lexicon": 1, + "id": "so.sprk.embed.post", + "description": "Interactive post embed.", + "defs": { + "main": { + "type": "object", + "required": [ + "placement", + "post", + ], + "properties": { + "placement": { + "type": "ref", + "ref": "lex:so.sprk.embed.defs#placement", + }, + "post": { + "type": "ref", + "ref": "lex:com.atproto.repo.strongRef", + }, + }, + }, + "view": { + "type": "object", + "required": [ + "placement", + "post", + ], + "properties": { + "placement": { + "type": "ref", + "ref": "lex:so.sprk.embed.defs#placement", + }, + "post": { + "type": "union", + "refs": [ + "lex:so.sprk.feed.defs#postView", + "lex:so.sprk.feed.defs#notFoundPost", + "lex:so.sprk.feed.defs#blockedPost", + ], + }, + }, + }, + }, + }, "SoSprkNotificationRegisterPush": { "lexicon": 1, "id": "so.sprk.notification.registerPush", @@ -18199,63 +18390,6 @@ export const schemaDict = { }, }, }, - "SoSprkFeedPostgate": { - "lexicon": 1, - "id": "so.sprk.feed.postgate", - "defs": { - "main": { - "type": "record", - "key": "tid", - "description": - "Record defining interaction rules for a post. The record key (rkey) of the postgate record must match the record key of the post, and that record must be in the same repository.", - "record": { - "type": "object", - "required": [ - "post", - "createdAt", - ], - "properties": { - "createdAt": { - "type": "string", - "format": "datetime", - }, - "post": { - "type": "string", - "format": "at-uri", - "description": "Reference (AT-URI) to the post record.", - }, - "detachedEmbeddingUris": { - "type": "array", - "maxLength": 50, - "items": { - "type": "string", - "format": "at-uri", - }, - "description": - "List of AT-URIs embedding this post that the author has detached from.", - }, - "embeddingRules": { - "description": - "List of rules defining who can embed this post. If value is an empty array or is undefined, no particular rules apply and anyone can embed.", - "type": "array", - "maxLength": 5, - "items": { - "type": "union", - "refs": [ - "lex:so.sprk.feed.postgate#disableRule", - ], - }, - }, - }, - }, - }, - "disableRule": { - "type": "object", - "description": "Disables embedding of this post.", - "properties": {}, - }, - }, - }, "SoSprkFeedThreadgate": { "lexicon": 1, "id": "so.sprk.feed.threadgate", @@ -20160,7 +20294,6 @@ export const schemaDict = { "lex:so.sprk.actor.defs#mutedWordsPref", "lex:so.sprk.actor.defs#hiddenPostsPref", "lex:so.sprk.actor.defs#labelersPref", - "lex:so.sprk.actor.defs#postInteractionSettingsPref", ], }, }, @@ -20426,28 +20559,6 @@ export const schemaDict = { }, }, }, - "postInteractionSettingsPref": { - "type": "object", - "description": - "Default post interaction settings for the account. These values should be applied as default values when creating new posts. These refs should mirror the threadgate and postgate records exactly.", - "required": [], - "properties": { - "threadgateAllowRules": { - "description": - "Matches threadgate record. List of rules defining who can reply to this users posts. If value is an empty array, no one can reply. If value is undefined, anyone can reply.", - "type": "array", - "maxLength": 5, - "items": { - "type": "union", - "refs": [ - "lex:so.sprk.feed.threadgate#mentionRule", - "lex:so.sprk.feed.threadgate#followerRule", - "lex:so.sprk.feed.threadgate#followingRule", - ], - }, - }, - }, - }, }, }, "SoSprkActorPutPreferences": { @@ -20785,6 +20896,10 @@ export const schemaDict = { "lex:so.sprk.media.video#view", ], }, + "embeds": { + "type": "ref", + "ref": "lex:so.sprk.embed.defs#views", + }, "indexedAt": { "type": "string", "format": "datetime", @@ -20953,6 +21068,10 @@ export const schemaDict = { "type": "ref", "ref": "lex:com.atproto.repo.strongRef", }, + "embeds": { + "type": "ref", + "ref": "lex:so.sprk.embed.defs#embeds", + }, "labels": { "type": "union", "description": @@ -26953,6 +27072,9 @@ export const ids = { SoSprkVideoDefs: "so.sprk.video.defs", SoSprkVideoGetJobStatus: "so.sprk.video.getJobStatus", SoSprkVideoGetUploadLimits: "so.sprk.video.getUploadLimits", + SoSprkEmbedDefs: "so.sprk.embed.defs", + SoSprkEmbedMention: "so.sprk.embed.mention", + SoSprkEmbedPost: "so.sprk.embed.post", SoSprkNotificationRegisterPush: "so.sprk.notification.registerPush", SoSprkNotificationPutPreferences: "so.sprk.notification.putPreferences", SoSprkNotificationUpdateSeen: "so.sprk.notification.updateSeen", @@ -26982,7 +27104,6 @@ export const ids = { SoSprkFeedGetFeedGenerator: "so.sprk.feed.getFeedGenerator", SoSprkFeedGetAuthorFeed: "so.sprk.feed.getAuthorFeed", SoSprkFeedGetLikes: "so.sprk.feed.getLikes", - SoSprkFeedPostgate: "so.sprk.feed.postgate", SoSprkFeedThreadgate: "so.sprk.feed.threadgate", SoSprkFeedGetPostThread: "so.sprk.feed.getPostThread", SoSprkFeedGetActorLikes: "so.sprk.feed.getActorLikes", diff --git a/lex/types/so/sprk/actor/defs.ts b/lex/types/so/sprk/actor/defs.ts index 2bc4f71..e11831d 100644 --- a/lex/types/so/sprk/actor/defs.ts +++ b/lex/types/so/sprk/actor/defs.ts @@ -5,7 +5,6 @@ import { validate as _validate } from "../../../../lexicons.ts"; import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; import type * as ComAtprotoLabelDefs from "../../../com/atproto/label/defs.ts"; import type * as ComAtprotoRepoStrongRef from "../../../com/atproto/repo/strongRef.ts"; -import type * as SoSprkFeedThreadgate from "../feed/threadgate.ts"; const is$typed = _is$typed, validate = _validate; const id = "so.sprk.actor.defs"; @@ -173,7 +172,6 @@ export type Preferences = ( | $Typed | $Typed | $Typed - | $Typed | { $type: string } )[]; @@ -406,29 +404,3 @@ export function isLabelerPrefItem(v: V) { export function validateLabelerPrefItem(v: V) { return validate(v, id, hashLabelerPrefItem); } - -/** Default post interaction settings for the account. These values should be applied as default values when creating new posts. These refs should mirror the threadgate and postgate records exactly. */ -export interface PostInteractionSettingsPref { - $type?: "so.sprk.actor.defs#postInteractionSettingsPref"; - /** Matches threadgate record. List of rules defining who can reply to this users posts. If value is an empty array, no one can reply. If value is undefined, anyone can reply. */ - threadgateAllowRules?: ( - | $Typed - | $Typed - | $Typed - | { $type: string } - )[]; -} - -const hashPostInteractionSettingsPref = "postInteractionSettingsPref"; - -export function isPostInteractionSettingsPref(v: V) { - return is$typed(v, id, hashPostInteractionSettingsPref); -} - -export function validatePostInteractionSettingsPref(v: V) { - return validate( - v, - id, - hashPostInteractionSettingsPref, - ); -} diff --git a/lex/types/so/sprk/embed/defs.ts b/lex/types/so/sprk/embed/defs.ts new file mode 100644 index 0000000..3b689c0 --- /dev/null +++ b/lex/types/so/sprk/embed/defs.ts @@ -0,0 +1,73 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { validate as _validate } from "../../../../lexicons.ts"; +import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; +import type * as SoSprkEmbedMention from "./mention.ts"; +import type * as SoSprkEmbedPost from "./post.ts"; + +const is$typed = _is$typed, validate = _validate; +const id = "so.sprk.embed.defs"; + +export type Embeds = + ($Typed | $Typed | { + $type: string; + })[]; +export type Views = + ($Typed | $Typed | { + $type: string; + })[]; + +/** Placement and layer metadata for an embed on a media canvas. */ +export interface Placement { + $type?: "so.sprk.embed.defs#placement"; + frame: Frame; + mediaRef?: MediaRef; + zIndex?: number; + rotation?: number; +} + +const hashPlacement = "placement"; + +export function isPlacement(v: V) { + return is$typed(v, id, hashPlacement); +} + +export function validatePlacement(v: V) { + return validate(v, id, hashPlacement); +} + +/** Bounding box in 10,000-based normalized coordinates relative to media canvas dimensions. */ +export interface Frame { + $type?: "so.sprk.embed.defs#frame"; + x: number; + y: number; + w: number; + h: number; +} + +const hashFrame = "frame"; + +export function isFrame(v: V) { + return is$typed(v, id, hashFrame); +} + +export function validateFrame(v: V) { + return validate(v, id, hashFrame); +} + +/** Optional media locator for records containing multiple media items. */ +export interface MediaRef { + $type?: "so.sprk.embed.defs#mediaRef"; + index: number; +} + +const hashMediaRef = "mediaRef"; + +export function isMediaRef(v: V) { + return is$typed(v, id, hashMediaRef); +} + +export function validateMediaRef(v: V) { + return validate(v, id, hashMediaRef); +} diff --git a/lex/types/so/sprk/embed/mention.ts b/lex/types/so/sprk/embed/mention.ts new file mode 100644 index 0000000..d858950 --- /dev/null +++ b/lex/types/so/sprk/embed/mention.ts @@ -0,0 +1,43 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { validate as _validate } from "../../../../lexicons.ts"; +import { is$typed as _is$typed } from "../../../../util.ts"; +import type * as SoSprkEmbedDefs from "./defs.ts"; +import type * as SoSprkActorDefs from "../actor/defs.ts"; + +const is$typed = _is$typed, validate = _validate; +const id = "so.sprk.embed.mention"; + +export interface Main { + $type?: "so.sprk.embed.mention"; + placement: SoSprkEmbedDefs.Placement; + did: string; +} + +const hashMain = "main"; + +export function isMain(v: V) { + return is$typed(v, id, hashMain); +} + +export function validateMain(v: V) { + return validate
(v, id, hashMain); +} + +export interface View { + $type?: "so.sprk.embed.mention#view"; + placement: SoSprkEmbedDefs.Placement; + did: string; + actor?: SoSprkActorDefs.ProfileViewBasic; +} + +const hashView = "view"; + +export function isView(v: V) { + return is$typed(v, id, hashView); +} + +export function validateView(v: V) { + return validate(v, id, hashView); +} diff --git a/lex/types/so/sprk/embed/post.ts b/lex/types/so/sprk/embed/post.ts new file mode 100644 index 0000000..043e2b0 --- /dev/null +++ b/lex/types/so/sprk/embed/post.ts @@ -0,0 +1,47 @@ +/** + * GENERATED CODE - DO NOT MODIFY + */ +import { validate as _validate } from "../../../../lexicons.ts"; +import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; +import type * as SoSprkEmbedDefs from "./defs.ts"; +import type * as ComAtprotoRepoStrongRef from "../../../com/atproto/repo/strongRef.ts"; +import type * as SoSprkFeedDefs from "../feed/defs.ts"; + +const is$typed = _is$typed, validate = _validate; +const id = "so.sprk.embed.post"; + +export interface Main { + $type?: "so.sprk.embed.post"; + placement: SoSprkEmbedDefs.Placement; + post: ComAtprotoRepoStrongRef.Main; +} + +const hashMain = "main"; + +export function isMain(v: V) { + return is$typed(v, id, hashMain); +} + +export function validateMain(v: V) { + return validate
(v, id, hashMain); +} + +export interface View { + $type?: "so.sprk.embed.post#view"; + placement: SoSprkEmbedDefs.Placement; + post: + | $Typed + | $Typed + | $Typed + | { $type: string }; +} + +const hashView = "view"; + +export function isView(v: V) { + return is$typed(v, id, hashView); +} + +export function validateView(v: V) { + return validate(v, id, hashView); +} diff --git a/lex/types/so/sprk/feed/postgate.ts b/lex/types/so/sprk/feed/postgate.ts deleted file mode 100644 index dec90da..0000000 --- a/lex/types/so/sprk/feed/postgate.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * GENERATED CODE - DO NOT MODIFY - */ -import { validate as _validate } from "../../../../lexicons.ts"; -import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; - -const is$typed = _is$typed, validate = _validate; -const id = "so.sprk.feed.postgate"; - -export interface Record { - $type: "so.sprk.feed.postgate"; - createdAt: string; - /** Reference (AT-URI) to the post record. */ - post: string; - /** List of AT-URIs embedding this post that the author has detached from. */ - detachedEmbeddingUris?: (string)[]; - /** List of rules defining who can embed this post. If value is an empty array or is undefined, no particular rules apply and anyone can embed. */ - embeddingRules?: ($Typed | { $type: string })[]; - [k: string]: unknown; -} - -const hashRecord = "main"; - -export function isRecord(v: V) { - return is$typed(v, id, hashRecord); -} - -export function validateRecord(v: V) { - return validate(v, id, hashRecord, true); -} - -export type Main = Record; - -/** Disables embedding of this post. */ -export interface DisableRule { - $type?: "so.sprk.feed.postgate#disableRule"; -} - -const hashDisableRule = "disableRule"; - -export function isDisableRule(v: V) { - return is$typed(v, id, hashDisableRule); -} - -export function validateDisableRule(v: V) { - return validate(v, id, hashDisableRule); -} diff --git a/lex/types/so/sprk/story/defs.ts b/lex/types/so/sprk/story/defs.ts index 79caafe..f9268d2 100644 --- a/lex/types/so/sprk/story/defs.ts +++ b/lex/types/so/sprk/story/defs.ts @@ -6,6 +6,7 @@ import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; import type * as SoSprkActorDefs from "../actor/defs.ts"; import type * as SoSprkMediaImage from "../media/image.ts"; import type * as SoSprkMediaVideo from "../media/video.ts"; +import type * as SoSprkEmbedDefs from "../embed/defs.ts"; const is$typed = _is$typed, validate = _validate; const id = "so.sprk.story.defs"; @@ -19,6 +20,7 @@ export interface StoryView { media?: $Typed | $Typed | { $type: string; }; + embeds?: SoSprkEmbedDefs.Views; indexedAt: string; } diff --git a/lex/types/so/sprk/story/post.ts b/lex/types/so/sprk/story/post.ts index 5e4a404..d39d20f 100644 --- a/lex/types/so/sprk/story/post.ts +++ b/lex/types/so/sprk/story/post.ts @@ -6,6 +6,7 @@ import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; import type * as SoSprkMediaImage from "../media/image.ts"; import type * as SoSprkMediaVideo from "../media/video.ts"; import type * as ComAtprotoRepoStrongRef from "../../../com/atproto/repo/strongRef.ts"; +import type * as SoSprkEmbedDefs from "../embed/defs.ts"; import type * as ComAtprotoLabelDefs from "../../../com/atproto/label/defs.ts"; const is$typed = _is$typed, validate = _validate; @@ -17,6 +18,7 @@ export interface Record { $type: string; }; sound?: ComAtprotoRepoStrongRef.Main; + embeds?: SoSprkEmbedDefs.Embeds; labels?: $Typed | { $type: string }; /** Client-declared timestamp when this story was originally created. */ createdAt: string; diff --git a/lexicons/so/sprk/actor/defs.json b/lexicons/so/sprk/actor/defs.json index 8467244..85ba8d9 100644 --- a/lexicons/so/sprk/actor/defs.json +++ b/lexicons/so/sprk/actor/defs.json @@ -173,8 +173,7 @@ "#interestsPref", "#mutedWordsPref", "#hiddenPostsPref", - "#labelersPref", - "#postInteractionSettingsPref" + "#labelersPref" ] } }, @@ -375,26 +374,6 @@ "format": "did" } } - }, - "postInteractionSettingsPref": { - "type": "object", - "description": "Default post interaction settings for the account. These values should be applied as default values when creating new posts. These refs should mirror the threadgate and postgate records exactly.", - "required": [], - "properties": { - "threadgateAllowRules": { - "description": "Matches threadgate record. List of rules defining who can reply to this users posts. If value is an empty array, no one can reply. If value is undefined, anyone can reply.", - "type": "array", - "maxLength": 5, - "items": { - "type": "union", - "refs": [ - "so.sprk.feed.threadgate#mentionRule", - "so.sprk.feed.threadgate#followerRule", - "so.sprk.feed.threadgate#followingRule" - ] - } - } - } } } } diff --git a/lexicons/so/sprk/embed/defs.json b/lexicons/so/sprk/embed/defs.json new file mode 100644 index 0000000..f2c858e --- /dev/null +++ b/lexicons/so/sprk/embed/defs.json @@ -0,0 +1,83 @@ +{ + "lexicon": 1, + "id": "so.sprk.embed.defs", + "description": "Shared definitions for Spark interactive embeds.", + "defs": { + "embeds": { + "type": "array", + "items": { + "type": "union", + "refs": ["so.sprk.embed.mention", "so.sprk.embed.post"] + } + }, + "views": { + "type": "array", + "items": { + "type": "union", + "refs": ["so.sprk.embed.mention#view", "so.sprk.embed.post#view"] + } + }, + "placement": { + "type": "object", + "description": "Placement and layer metadata for an embed on a media canvas.", + "required": ["frame"], + "properties": { + "frame": { + "type": "ref", + "ref": "#frame" + }, + "mediaRef": { + "type": "ref", + "ref": "#mediaRef" + }, + "zIndex": { + "type": "integer", + "minimum": 0 + }, + "rotation": { + "type": "integer", + "minimum": 0, + "maximum": 359 + } + } + }, + "frame": { + "type": "object", + "description": "Bounding box in 10,000-based normalized coordinates relative to media canvas dimensions.", + "required": ["x", "y", "w", "h"], + "properties": { + "x": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "y": { + "type": "integer", + "minimum": 0, + "maximum": 10000 + }, + "w": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + }, + "h": { + "type": "integer", + "minimum": 1, + "maximum": 10000 + } + } + }, + "mediaRef": { + "type": "object", + "description": "Optional media locator for records containing multiple media items.", + "required": ["index"], + "properties": { + "index": { + "type": "integer", + "minimum": 0 + } + } + } + } +} diff --git a/lexicons/so/sprk/embed/mention.json b/lexicons/so/sprk/embed/mention.json new file mode 100644 index 0000000..aec10d8 --- /dev/null +++ b/lexicons/so/sprk/embed/mention.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "so.sprk.embed.mention", + "description": "Interactive mention embed.", + "defs": { + "main": { + "type": "object", + "required": ["placement", "did"], + "properties": { + "placement": { + "type": "ref", + "ref": "so.sprk.embed.defs#placement" + }, + "did": { + "type": "string", + "format": "did" + } + } + }, + "view": { + "type": "object", + "required": ["placement", "did"], + "properties": { + "placement": { + "type": "ref", + "ref": "so.sprk.embed.defs#placement" + }, + "did": { + "type": "string", + "format": "did" + }, + "actor": { + "type": "ref", + "ref": "so.sprk.actor.defs#profileViewBasic" + } + } + } + } +} diff --git a/lexicons/so/sprk/embed/post.json b/lexicons/so/sprk/embed/post.json new file mode 100644 index 0000000..2a590c4 --- /dev/null +++ b/lexicons/so/sprk/embed/post.json @@ -0,0 +1,39 @@ +{ + "lexicon": 1, + "id": "so.sprk.embed.post", + "description": "Interactive post embed.", + "defs": { + "main": { + "type": "object", + "required": ["placement", "post"], + "properties": { + "placement": { + "type": "ref", + "ref": "so.sprk.embed.defs#placement" + }, + "post": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + } + } + }, + "view": { + "type": "object", + "required": ["placement", "post"], + "properties": { + "placement": { + "type": "ref", + "ref": "so.sprk.embed.defs#placement" + }, + "post": { + "type": "union", + "refs": [ + "so.sprk.feed.defs#postView", + "so.sprk.feed.defs#notFoundPost", + "so.sprk.feed.defs#blockedPost" + ] + } + } + } + } +} diff --git a/lexicons/so/sprk/feed/postgate.json b/lexicons/so/sprk/feed/postgate.json deleted file mode 100644 index 95704c7..0000000 --- a/lexicons/so/sprk/feed/postgate.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "lexicon": 1, - "id": "so.sprk.feed.postgate", - "defs": { - "main": { - "type": "record", - "key": "tid", - "description": "Record defining interaction rules for a post. The record key (rkey) of the postgate record must match the record key of the post, and that record must be in the same repository.", - "record": { - "type": "object", - "required": ["post", "createdAt"], - "properties": { - "createdAt": { "type": "string", "format": "datetime" }, - "post": { - "type": "string", - "format": "at-uri", - "description": "Reference (AT-URI) to the post record." - }, - "detachedEmbeddingUris": { - "type": "array", - "maxLength": 50, - "items": { - "type": "string", - "format": "at-uri" - }, - "description": "List of AT-URIs embedding this post that the author has detached from." - }, - "embeddingRules": { - "description": "List of rules defining who can embed this post. If value is an empty array or is undefined, no particular rules apply and anyone can embed.", - "type": "array", - "maxLength": 5, - "items": { - "type": "union", - "refs": ["#disableRule"] - } - } - } - } - }, - "disableRule": { - "type": "object", - "description": "Disables embedding of this post.", - "properties": {} - } - } -} diff --git a/lexicons/so/sprk/story/defs.json b/lexicons/so/sprk/story/defs.json index 57085d3..215727a 100644 --- a/lexicons/so/sprk/story/defs.json +++ b/lexicons/so/sprk/story/defs.json @@ -17,6 +17,10 @@ "type": "union", "refs": ["so.sprk.media.image#view", "so.sprk.media.video#view"] }, + "embeds": { + "type": "ref", + "ref": "so.sprk.embed.defs#views" + }, "indexedAt": { "type": "string", "format": "datetime" } } }, diff --git a/lexicons/so/sprk/story/post.json b/lexicons/so/sprk/story/post.json index 6ee9fd9..67e7071 100644 --- a/lexicons/so/sprk/story/post.json +++ b/lexicons/so/sprk/story/post.json @@ -18,6 +18,10 @@ "type": "ref", "ref": "com.atproto.repo.strongRef" }, + "embeds": { + "type": "ref", + "ref": "so.sprk.embed.defs#embeds" + }, "labels": { "type": "union", "description": "Self-label values for this story. Effectively content warnings.", diff --git a/tests/stories_test.ts b/tests/stories_test.ts index 4e43b12..7e9dea9 100644 --- a/tests/stories_test.ts +++ b/tests/stories_test.ts @@ -1,4 +1,10 @@ import { assertEquals } from "@std/assert"; +import { HydrationState } from "../hydration/index.ts"; +import { Actor } from "../hydration/actor.ts"; +import { HydrationMap, RecordInfo } from "../hydration/util.ts"; +import { Record as PostRecord } from "../lex/types/so/sprk/feed/post.ts"; +import { Record as StoryRecord } from "../lex/types/so/sprk/story/post.ts"; +import { Views } from "../views/index.ts"; import { createTestContext, TEST_USERS } from "./util.ts"; const VALID_BLOB_CID = @@ -183,5 +189,544 @@ Deno.test({ } }, ); + + await t.step( + "hydrateStories fully hydrates embedded post metadata", + async () => { + const { ctx, cleanup } = await createTestContext({ + actors: false, + profiles: false, + posts: false, + replies: false, + stories: false, + likes: false, + reposts: false, + follows: false, + blocks: false, + audio: false, + generators: false, + preferences: false, + records: false, + actorSync: false, + }); + + try { + const now = new Date(); + const nowIso = now.toISOString(); + const storyAuthorDid = TEST_USERS[0].did; + const postAuthorDid = TEST_USERS[1].did; + const storyUri = + `at://${storyAuthorDid}/so.sprk.story.post/story-hydrated`; + const postUri = + `at://${postAuthorDid}/so.sprk.feed.post/post-hydrated`; + const soundUri = + `at://${postAuthorDid}/so.sprk.sound.audio/sound-hydrated`; + + await ctx.db.models.Actor.create([ + { + did: storyAuthorDid, + handle: "story-author.test", + indexedAt: nowIso, + keys: [], + services: "[]", + }, + { + did: postAuthorDid, + handle: "post-author.test", + indexedAt: nowIso, + keys: [], + services: "[]", + }, + ]); + + const soundRecord = { + $type: "so.sprk.sound.audio", + sound: { + $type: "blob", + ref: { $link: VALID_BLOB_CID }, + mimeType: "audio/mpeg", + size: 12345, + }, + title: "Hydrated Sound", + createdAt: nowIso, + }; + const postRecord = { + $type: "so.sprk.feed.post", + media: { + $type: "so.sprk.media.images", + images: [{ + $type: "so.sprk.media.image", + image: { + $type: "blob", + ref: { $link: VALID_BLOB_CID }, + mimeType: "image/jpeg", + size: 22222, + }, + alt: "Hydrated post image", + }], + }, + sound: { uri: soundUri, cid: VALID_BLOB_CID }, + createdAt: nowIso, + }; + const storyRecord = { + $type: "so.sprk.story.post", + media: { + $type: "so.sprk.media.image", + image: { + $type: "blob", + ref: { $link: VALID_BLOB_CID }, + mimeType: "image/jpeg", + size: 11111, + }, + alt: "Hydrated story image", + aspectRatio: { width: 1080, height: 1920 }, + }, + embeds: [ + { + $type: "so.sprk.embed.post", + placement: { + frame: { x: 1000, y: 1000, w: 7000, h: 2500 }, + }, + post: { uri: postUri, cid: VALID_BLOB_CID }, + }, + ], + createdAt: nowIso, + }; + + await ctx.db.models.Audio.create({ + uri: soundUri, + cid: VALID_BLOB_CID, + authorDid: postAuthorDid, + createdAt: nowIso, + indexedAt: nowIso, + sound: soundRecord.sound, + title: soundRecord.title, + useCount: 8, + }); + + await ctx.db.models.Post.create({ + uri: postUri, + cid: VALID_BLOB_CID, + authorDid: postAuthorDid, + createdAt: nowIso, + indexedAt: nowIso, + caption: { text: "Hydrated post caption" }, + media: postRecord.media, + sound: postRecord.sound, + labels: [], + tags: [], + likeCount: 17, + replyCount: 4, + repostCount: 2, + }); + + await ctx.db.models.Record.create([ + { + uri: soundUri, + cid: VALID_BLOB_CID, + did: postAuthorDid, + collectionName: "so.sprk.sound.audio", + rkey: "sound-hydrated", + createdAt: nowIso, + indexedAt: nowIso, + json: JSON.stringify(soundRecord), + takenDown: false, + }, + { + uri: postUri, + cid: VALID_BLOB_CID, + did: postAuthorDid, + collectionName: "so.sprk.feed.post", + rkey: "post-hydrated", + createdAt: nowIso, + indexedAt: nowIso, + json: JSON.stringify(postRecord), + takenDown: false, + }, + { + uri: storyUri, + cid: VALID_BLOB_CID, + did: storyAuthorDid, + collectionName: "so.sprk.story.post", + rkey: "story-hydrated", + createdAt: nowIso, + indexedAt: nowIso, + json: JSON.stringify(storyRecord), + takenDown: false, + }, + ]); + + const hydrateCtx = await ctx.hydrator.createContext({ + viewer: storyAuthorDid, + labelers: ctx.reqLabelers(new Request("https://example.com")), + }); + + const hydration = await ctx.hydrator.hydrateStories( + [storyUri], + hydrateCtx, + ); + const storyView = ctx.views.story(storyUri, hydration); + const embed = storyView?.embeds?.[0] as { + post: { + $type?: string; + likeCount?: number; + replyCount?: number; + repostCount?: number; + viewer?: unknown; + sound?: { uri: string }; + }; + } | undefined; + + assertEquals(embed?.post.$type, "so.sprk.feed.defs#postView"); + assertEquals(embed?.post.likeCount, 17); + assertEquals(embed?.post.replyCount, 4); + assertEquals(embed?.post.repostCount, 2); + assertEquals(embed?.post.viewer !== undefined, true); + assertEquals(embed?.post.sound?.uri, soundUri); + } finally { + await cleanup(); + } + }, + ); + + await t.step( + "story view hydrates mention and post embeds", + () => { + const now = new Date(); + const storyAuthorDid = TEST_USERS[0].did; + const mentionDid = TEST_USERS[1].did; + const postAuthorDid = TEST_USERS[2].did; + const storyUri = + `at://${storyAuthorDid}/so.sprk.story.post/embed-story`; + const postUri = `at://${postAuthorDid}/so.sprk.feed.post/embed-post`; + + const storyRecord = { + $type: "so.sprk.story.post", + createdAt: now.toISOString(), + media: { + $type: "so.sprk.media.image", + image: { + ref: { $link: VALID_BLOB_CID }, + mimeType: "image/jpeg", + size: 12345, + }, + alt: "Story image", + aspectRatio: { width: 1080, height: 1920 }, + }, + embeds: [ + { + $type: "so.sprk.embed.mention", + placement: { + frame: { x: 1000, y: 1000, w: 3000, h: 1000 }, + }, + did: mentionDid, + }, + { + $type: "so.sprk.embed.post", + placement: { + frame: { x: 800, y: 5000, w: 8400, h: 3000 }, + }, + post: { + uri: postUri, + cid: + "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyaa", + }, + }, + ], + } as unknown as StoryRecord; + + const postRecord = { + $type: "so.sprk.feed.post", + createdAt: now.toISOString(), + media: { + $type: "so.sprk.media.images", + images: [{ + $type: "so.sprk.media.image", + image: { + ref: { $link: VALID_BLOB_CID }, + mimeType: "image/jpeg", + size: 22222, + }, + alt: "Embedded post image", + }], + }, + } as unknown as PostRecord; + + const stories = new HydrationMap>(); + stories.set(storyUri, { + record: storyRecord, + cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvybb", + sortedAt: now, + indexedAt: now, + takedownRef: undefined, + }); + + const posts = new HydrationMap>(); + posts.set(postUri, { + record: postRecord, + cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvycc", + sortedAt: now, + indexedAt: now, + takedownRef: undefined, + }); + + const actors = new HydrationMap(); + actors.set(storyAuthorDid, { + did: storyAuthorDid, + handle: "story-author.test", + createdAt: now, + sortedAt: now, + }); + actors.set(mentionDid, { + did: mentionDid, + handle: "mentioned-user.test", + createdAt: now, + sortedAt: now, + }); + actors.set(postAuthorDid, { + did: postAuthorDid, + handle: "post-author.test", + createdAt: now, + sortedAt: now, + }); + + const state: HydrationState = { + stories, + posts, + actors, + }; + + const views = new Views({ + mediaCdn: "https://media.example.com", + thumbCdn: "https://thumb.example.com", + videoCdn: "https://video.example.com", + }); + + const view = views.story(storyUri, state); + assertEquals(view?.embeds?.length, 2); + + const mentionView = view?.embeds?.[0] as { + $type: string; + did: string; + actor?: { did: string }; + }; + assertEquals(mentionView.$type, "so.sprk.embed.mention#view"); + assertEquals(mentionView.did, mentionDid); + assertEquals(mentionView.actor?.did, mentionDid); + + const postView = view?.embeds?.[1] as { + $type: string; + post: { $type?: string; uri: string }; + }; + assertEquals(postView.$type, "so.sprk.embed.post#view"); + assertEquals(postView.post.$type, "so.sprk.feed.defs#postView"); + assertEquals(postView.post.uri, postUri); + }, + ); + + await t.step( + "story view ignores malformed post embeds", + () => { + const now = new Date(); + const storyAuthorDid = TEST_USERS[0].did; + const storyUri = + `at://${storyAuthorDid}/so.sprk.story.post/bad-embed-story`; + + const storyRecord = { + $type: "so.sprk.story.post", + createdAt: now.toISOString(), + media: { + $type: "so.sprk.media.image", + image: { + ref: { $link: VALID_BLOB_CID }, + mimeType: "image/jpeg", + size: 12345, + }, + alt: "Story image", + aspectRatio: { width: 1080, height: 1920 }, + }, + embeds: [ + { + $type: "so.sprk.embed.post", + placement: { + frame: { x: 800, y: 5000, w: 8400, h: 3000 }, + }, + }, + ], + } as unknown as StoryRecord; + + const stories = new HydrationMap>(); + stories.set(storyUri, { + record: storyRecord, + cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvydd", + sortedAt: now, + indexedAt: now, + takedownRef: undefined, + }); + + const actors = new HydrationMap(); + actors.set(storyAuthorDid, { + did: storyAuthorDid, + handle: "story-author.test", + createdAt: now, + sortedAt: now, + }); + + const state: HydrationState = { + stories, + actors, + }; + + const views = new Views({ + mediaCdn: "https://media.example.com", + thumbCdn: "https://thumb.example.com", + videoCdn: "https://video.example.com", + }); + + const view = views.story(storyUri, state); + assertEquals(view?.embeds, undefined); + }, + ); + + await t.step( + "story view ignores mention embed missing did", + () => { + const now = new Date(); + const storyAuthorDid = TEST_USERS[0].did; + const storyUri = + `at://${storyAuthorDid}/so.sprk.story.post/no-did-mention-story`; + + const storyRecord = { + $type: "so.sprk.story.post", + createdAt: now.toISOString(), + media: { + $type: "so.sprk.media.image", + image: { + ref: { $link: VALID_BLOB_CID }, + mimeType: "image/jpeg", + size: 12345, + }, + alt: "Story image", + aspectRatio: { width: 1080, height: 1920 }, + }, + embeds: [ + { + $type: "so.sprk.embed.mention", + placement: { + frame: { x: 1000, y: 1000, w: 3000, h: 1000 }, + }, + // did is intentionally absent + }, + ], + } as unknown as StoryRecord; + + const stories = new HydrationMap>(); + stories.set(storyUri, { + record: storyRecord, + cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyee", + sortedAt: now, + indexedAt: now, + takedownRef: undefined, + }); + + const actors = new HydrationMap(); + actors.set(storyAuthorDid, { + did: storyAuthorDid, + handle: "story-author.test", + createdAt: now, + sortedAt: now, + }); + + const state: HydrationState = { stories, actors }; + + const views = new Views({ + mediaCdn: "https://media.example.com", + thumbCdn: "https://thumb.example.com", + videoCdn: "https://video.example.com", + }); + + const view = views.story(storyUri, state); + assertEquals(view?.embeds, undefined); + }, + ); + + await t.step( + "story view ignores embeds missing placement", + () => { + const now = new Date(); + const storyAuthorDid = TEST_USERS[0].did; + const mentionDid = TEST_USERS[1].did; + const postAuthorDid = TEST_USERS[2].did; + const storyUri = + `at://${storyAuthorDid}/so.sprk.story.post/no-placement-story`; + const postUri = + `at://${postAuthorDid}/so.sprk.feed.post/no-placement-post`; + + const storyRecord = { + $type: "so.sprk.story.post", + createdAt: now.toISOString(), + media: { + $type: "so.sprk.media.image", + image: { + ref: { $link: VALID_BLOB_CID }, + mimeType: "image/jpeg", + size: 12345, + }, + alt: "Story image", + aspectRatio: { width: 1080, height: 1920 }, + }, + embeds: [ + { + $type: "so.sprk.embed.mention", + // placement is intentionally absent + did: mentionDid, + }, + { + $type: "so.sprk.embed.post", + // placement is intentionally absent + post: { + uri: postUri, + cid: + "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyff", + }, + }, + ], + } as unknown as StoryRecord; + + const stories = new HydrationMap>(); + stories.set(storyUri, { + record: storyRecord, + cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvygg", + sortedAt: now, + indexedAt: now, + takedownRef: undefined, + }); + + const actors = new HydrationMap(); + actors.set(storyAuthorDid, { + did: storyAuthorDid, + handle: "story-author.test", + createdAt: now, + sortedAt: now, + }); + actors.set(mentionDid, { + did: mentionDid, + handle: "mentioned-user.test", + createdAt: now, + sortedAt: now, + }); + + const state: HydrationState = { stories, actors }; + + const views = new Views({ + mediaCdn: "https://media.example.com", + thumbCdn: "https://thumb.example.com", + videoCdn: "https://video.example.com", + }); + + const view = views.story(storyUri, state); + assertEquals(view?.embeds, undefined); + }, + ); }, }); diff --git a/views/index.ts b/views/index.ts index 88909cd..b14f54a 100644 --- a/views/index.ts +++ b/views/index.ts @@ -25,6 +25,8 @@ import { ThreadViewPost, } from "../lex/types/so/sprk/feed/defs.ts"; import { StoriesByAuthor, StoryView } from "../lex/types/so/sprk/story/defs.ts"; +import { Main as MentionEmbed } from "../lex/types/so/sprk/embed/mention.ts"; +import { Main as PostEmbed } from "../lex/types/so/sprk/embed/post.ts"; import { isRecord as isReplyRecord, Record as ReplyRecord, @@ -427,11 +429,71 @@ export class Views { author, record: storyInfo.record, media: mediaRecord ? this.storyMedia(uri, mediaRecord) : undefined, + embeds: this.storyEmbeds(storyInfo.record.embeds, state), indexedAt: this.indexedAt(storyInfo)?.toISOString() ?? new Date().toISOString(), }; } + private isMentionEmbedRecord(embed: unknown): embed is MentionEmbed { + if (!embed || typeof embed !== "object") return false; + const e = embed as Record; + return e["$type"] === "so.sprk.embed.mention" && + typeof e["did"] === "string" && + (e["did"] as string).length > 0 && + !!e["placement"] && + typeof e["placement"] === "object" && + !!(e["placement"] as Record)["frame"] && + typeof (e["placement"] as Record)["frame"] === "object"; + } + + private isPostEmbedRecord(embed: unknown): embed is PostEmbed { + if (!embed || typeof embed !== "object") return false; + const e = embed as Record; + const post = e["post"] as Record | undefined; + return e["$type"] === "so.sprk.embed.post" && + typeof post?.["uri"] === "string" && + !!e["placement"] && + typeof e["placement"] === "object" && + !!(e["placement"] as Record)["frame"] && + typeof (e["placement"] as Record)["frame"] === "object"; + } + + storyEmbeds( + embeds: unknown, + state: HydrationState, + ): StoryView["embeds"] | undefined { + if (!Array.isArray(embeds) || embeds.length === 0) { + return undefined; + } + + const views = mapDefined(embeds, (embed) => { + if (this.isMentionEmbedRecord(embed)) { + return { + $type: "so.sprk.embed.mention#view", + placement: embed.placement, + did: embed.did, + actor: this.profileBasic(embed.did, state), + }; + } + + if (this.isPostEmbedRecord(embed)) { + const embedded = this.maybePost(embed.post.uri, state); + return { + $type: "so.sprk.embed.post#view", + placement: embed.placement, + post: isReplyView(embedded) + ? this.notFoundPost(embed.post.uri) + : embedded, + }; + } + + return undefined; + }); + + return views.length > 0 ? views as StoryView["embeds"] : undefined; + } + storiesByAuthor( stories: StoryView[], ): StoriesByAuthor[] {