[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.

feat: story embeds

+1238 -291
-11
api/so/sprk/actor/getPreferences.ts
··· 3 3 import { 4 4 ContentLabelPref, 5 5 MutedWord, 6 - PostInteractionSettingsPref, 7 6 Preferences, 8 7 SavedFeed, 9 8 ThreadViewPref, ··· 107 106 preferences.push({ 108 107 $type: "so.sprk.actor.defs#labelersPref", 109 108 labelers: userPref.labelersPref.labelers, 110 - }); 111 - } 112 - 113 - if (userPref.postInteractionSettingsPref) { 114 - preferences.push({ 115 - $type: "so.sprk.actor.defs#postInteractionSettingsPref", 116 - threadgateAllowRules: userPref.postInteractionSettingsPref 117 - .threadgateAllowRules as PostInteractionSettingsPref[ 118 - "threadgateAllowRules" 119 - ], 120 109 }); 121 110 } 122 111
-11
api/so/sprk/actor/putPreferences.ts
··· 7 7 LabelersPref, 8 8 MutedWordsPref, 9 9 PersonalDetailsPref, 10 - PostInteractionSettingsPref, 11 10 SavedFeedsPref, 12 11 ThreadViewPref, 13 12 } from "../../../../lex/types/so/sprk/actor/defs.ts"; ··· 103 102 const p = pref as LabelersPref; 104 103 updateData.labelersPref = { 105 104 labelers: p.labelers ?? [], 106 - }; 107 - break; 108 - } 109 - case "so.sprk.actor.defs#postInteractionSettingsPref": { 110 - const p = pref as PostInteractionSettingsPref; 111 - updateData.postInteractionSettingsPref = { 112 - threadgateAllowRules: p.threadgateAllowRules as Array<{ 113 - $type: string; 114 - [key: string]: unknown; 115 - }>, 116 105 }; 117 106 break; 118 107 }
+1 -23
api/so/sprk/story/getStories.ts
··· 126 126 inputs: HydrationFnInput<Context, Params, Skeleton>, 127 127 ): Promise<HydrationState> => { 128 128 const { ctx, params, skeleton } = inputs; 129 - // Hydrate stories 130 - const stories = await ctx.hydrator.story.getStories( 131 - skeleton.stories, 132 - params.hydrateCtx.includeTakedowns || false, 133 - ); 134 - 135 - // Get author DIDs for actor hydration 136 - const authorDids = [ 137 - ...new Set( 138 - skeleton.stories.map((uri) => uriToDid(uri)), 139 - ), 140 - ]; 141 - 142 - // Hydrate actors (profiles) 143 - const actors = await ctx.hydrator.actor.getActors( 144 - authorDids, 145 - params.hydrateCtx, 146 - ); 147 - 148 - return { 149 - stories, 150 - actors, 151 - }; 129 + return await ctx.hydrator.hydrateStories(skeleton.stories, params.hydrateCtx); 152 130 }; 153 131 154 132 const rules = (inputs: RulesFnInput<Context, Params, Skeleton>): Skeleton => {
+1 -21
api/so/sprk/story/getTimeline.ts
··· 104 104 inputs: HydrationFnInput<Context, Params, Skeleton>, 105 105 ): Promise<HydrationState> => { 106 106 const { ctx, params, skeleton } = inputs; 107 - 108 - // Get author DIDs for actor hydration (can be computed before fetching) 109 - const authorDids = [ 110 - ...new Set( 111 - skeleton.stories.map((uri) => uriToDid(uri)), 112 - ), 113 - ]; 114 - 115 - // Parallelize stories and actors hydration 116 - const [stories, actors] = await Promise.all([ 117 - ctx.hydrator.story.getStories( 118 - skeleton.stories, 119 - params.hydrateCtx.includeTakedowns || false, 120 - ), 121 - ctx.hydrator.actor.getActors(authorDids, params.hydrateCtx), 122 - ]); 123 - 124 - return { 125 - stories, 126 - actors, 127 - }; 107 + return await ctx.hydrator.hydrateStories(skeleton.stories, params.hydrateCtx); 128 108 }; 129 109 130 110 const rules = (inputs: RulesFnInput<Context, Params, Skeleton>): Skeleton => {
+2
data-plane/db/models.ts
··· 413 413 export interface StoryDocument extends AuthoredDocument { 414 414 media: StoryMedia; 415 415 sound?: RecordRef; 416 + embeds?: StoryEmbed[]; 416 417 labels?: Label[]; 417 418 } 418 419 export const storySchema = new Schema<StoryDocument>({ ··· 425 426 }, 426 427 required: false, 427 428 }, 429 + embeds: { type: [Object], required: false, default: [] }, 428 430 labels: { type: [Object], required: false, default: [] }, 429 431 }) 430 432 .index({ authorDid: 1, createdAt: -1 });
+1 -1
data-plane/indexing/plugins/story.ts
··· 23 23 authorDid: uri.host, 24 24 media: obj.media, 25 25 sound: obj.sound, 26 + embeds: obj.embeds || [], 26 27 labels: obj.labels || null, 27 - tags: obj.tags || [], 28 28 createdAt: normalizeDatetimeAlways(obj.createdAt), 29 29 indexedAt: timestamp, 30 30 };
+77
hydration/index.ts
··· 3 3 import { DataPlane } from "../data-plane/index.ts"; 4 4 import { ids } from "../lex/lexicons.ts"; 5 5 import { Record as ProfileRecord } from "../lex/types/so/sprk/actor/profile.ts"; 6 + import { Record as StoryRecord } from "../lex/types/so/sprk/story/post.ts"; 6 7 import { uriToDid as didFromUri } from "../utils/uris.ts"; 7 8 import { 8 9 ActivitySubscriptionStates, ··· 572 573 ctx: HydrateCtx, 573 574 ): Promise<HydrationState> { 574 575 return this.hydratePosts(refs, ctx); 576 + } 577 + 578 + // so.sprk.story.defs#storyView 579 + // - story 580 + // - profile 581 + // - list basic 582 + // - embeds (story record) 583 + // - mention 584 + // - profile 585 + // - list basic 586 + // - post 587 + // - postView / blockedPost / notFoundPost 588 + // - profile 589 + // - list basic 590 + async hydrateStories( 591 + uris: string[], 592 + ctx: HydrateCtx, 593 + ): Promise<HydrationState> { 594 + const stories = await this.story.getStories(uris, ctx.includeTakedowns); 595 + 596 + const storyAuthorDids = uris.map(didFromUri); 597 + const embedPostUris = new Set<string>(); 598 + const mentionDids = new Set<string>(); 599 + 600 + for (const story of stories.values()) { 601 + if (!story) continue; 602 + const record = story.record as StoryRecord; 603 + for (const embed of record.embeds ?? []) { 604 + if ( 605 + embed && 606 + typeof embed === "object" && 607 + "$type" in embed && 608 + (embed as { $type?: string }).$type === "so.sprk.embed.post" 609 + ) { 610 + const postUri = (embed as { post?: { uri?: string } }).post?.uri; 611 + if (postUri) { 612 + embedPostUris.add(postUri); 613 + } 614 + } else if ( 615 + embed && 616 + typeof embed === "object" && 617 + "$type" in embed && 618 + (embed as { $type?: string }).$type === "so.sprk.embed.mention" 619 + ) { 620 + const did = (embed as { did?: string }).did; 621 + if (did) { 622 + mentionDids.add(did); 623 + } 624 + } 625 + } 626 + } 627 + 628 + const postUris: string[] = []; 629 + for (const postUri of embedPostUris) { 630 + try { 631 + didFromUri(postUri); 632 + postUris.push(postUri); 633 + } catch { 634 + continue; 635 + } 636 + } 637 + const profileDids = Array.from( 638 + new Set<string>([ 639 + ...storyAuthorDids, 640 + ...mentionDids, 641 + ]), 642 + ); 643 + 644 + const [postState, profileState] = await Promise.all([ 645 + postUris.length > 0 646 + ? this.hydratePosts(postUris.map((uri) => ({ uri })), ctx) 647 + : Promise.resolve<HydrationState>({}), 648 + this.hydrateProfiles(profileDids, ctx), 649 + ]); 650 + 651 + return mergeManyStates(profileState, postState, { stories, ctx }); 575 652 } 576 653 577 654 // so.sprk.feed.defs#generatorView
+10
lex/index.ts
··· 2641 2641 export class SoSprkNS { 2642 2642 _server: Server; 2643 2643 video: SoSprkVideoNS; 2644 + embed: SoSprkEmbedNS; 2644 2645 notification: SoSprkNotificationNS; 2645 2646 graph: SoSprkGraphNS; 2646 2647 feed: SoSprkFeedNS; ··· 2654 2655 constructor(server: Server) { 2655 2656 this._server = server; 2656 2657 this.video = new SoSprkVideoNS(server); 2658 + this.embed = new SoSprkEmbedNS(server); 2657 2659 this.notification = new SoSprkNotificationNS(server); 2658 2660 this.graph = new SoSprkGraphNS(server); 2659 2661 this.feed = new SoSprkFeedNS(server); ··· 2707 2709 ) { 2708 2710 const nsid = "so.sprk.video.getUploadLimits"; // @ts-ignore - dynamically generated 2709 2711 return this._server.xrpc.method(nsid, cfg); 2712 + } 2713 + } 2714 + 2715 + export class SoSprkEmbedNS { 2716 + _server: Server; 2717 + 2718 + constructor(server: Server) { 2719 + this._server = server; 2710 2720 } 2711 2721 } 2712 2722
+202 -81
lex/lexicons.ts
··· 16281 16281 }, 16282 16282 }, 16283 16283 }, 16284 + "SoSprkEmbedDefs": { 16285 + "lexicon": 1, 16286 + "id": "so.sprk.embed.defs", 16287 + "description": "Shared definitions for Spark interactive embeds.", 16288 + "defs": { 16289 + "embeds": { 16290 + "type": "array", 16291 + "items": { 16292 + "type": "union", 16293 + "refs": [ 16294 + "lex:so.sprk.embed.mention", 16295 + "lex:so.sprk.embed.post", 16296 + ], 16297 + }, 16298 + }, 16299 + "views": { 16300 + "type": "array", 16301 + "items": { 16302 + "type": "union", 16303 + "refs": [ 16304 + "lex:so.sprk.embed.mention#view", 16305 + "lex:so.sprk.embed.post#view", 16306 + ], 16307 + }, 16308 + }, 16309 + "placement": { 16310 + "type": "object", 16311 + "description": 16312 + "Placement and layer metadata for an embed on a media canvas.", 16313 + "required": [ 16314 + "frame", 16315 + ], 16316 + "properties": { 16317 + "frame": { 16318 + "type": "ref", 16319 + "ref": "lex:so.sprk.embed.defs#frame", 16320 + }, 16321 + "mediaRef": { 16322 + "type": "ref", 16323 + "ref": "lex:so.sprk.embed.defs#mediaRef", 16324 + }, 16325 + "zIndex": { 16326 + "type": "integer", 16327 + "minimum": 0, 16328 + }, 16329 + "rotation": { 16330 + "type": "integer", 16331 + "minimum": 0, 16332 + "maximum": 359, 16333 + }, 16334 + }, 16335 + }, 16336 + "frame": { 16337 + "type": "object", 16338 + "description": 16339 + "Bounding box in 10,000-based normalized coordinates relative to media canvas dimensions.", 16340 + "required": [ 16341 + "x", 16342 + "y", 16343 + "w", 16344 + "h", 16345 + ], 16346 + "properties": { 16347 + "x": { 16348 + "type": "integer", 16349 + "minimum": 0, 16350 + "maximum": 10000, 16351 + }, 16352 + "y": { 16353 + "type": "integer", 16354 + "minimum": 0, 16355 + "maximum": 10000, 16356 + }, 16357 + "w": { 16358 + "type": "integer", 16359 + "minimum": 1, 16360 + "maximum": 10000, 16361 + }, 16362 + "h": { 16363 + "type": "integer", 16364 + "minimum": 1, 16365 + "maximum": 10000, 16366 + }, 16367 + }, 16368 + }, 16369 + "mediaRef": { 16370 + "type": "object", 16371 + "description": 16372 + "Optional media locator for records containing multiple media items.", 16373 + "required": [ 16374 + "index", 16375 + ], 16376 + "properties": { 16377 + "index": { 16378 + "type": "integer", 16379 + "minimum": 0, 16380 + }, 16381 + }, 16382 + }, 16383 + }, 16384 + }, 16385 + "SoSprkEmbedMention": { 16386 + "lexicon": 1, 16387 + "id": "so.sprk.embed.mention", 16388 + "description": "Interactive mention embed.", 16389 + "defs": { 16390 + "main": { 16391 + "type": "object", 16392 + "required": [ 16393 + "placement", 16394 + "did", 16395 + ], 16396 + "properties": { 16397 + "placement": { 16398 + "type": "ref", 16399 + "ref": "lex:so.sprk.embed.defs#placement", 16400 + }, 16401 + "did": { 16402 + "type": "string", 16403 + "format": "did", 16404 + }, 16405 + }, 16406 + }, 16407 + "view": { 16408 + "type": "object", 16409 + "required": [ 16410 + "placement", 16411 + "did", 16412 + ], 16413 + "properties": { 16414 + "placement": { 16415 + "type": "ref", 16416 + "ref": "lex:so.sprk.embed.defs#placement", 16417 + }, 16418 + "did": { 16419 + "type": "string", 16420 + "format": "did", 16421 + }, 16422 + "actor": { 16423 + "type": "ref", 16424 + "ref": "lex:so.sprk.actor.defs#profileViewBasic", 16425 + }, 16426 + }, 16427 + }, 16428 + }, 16429 + }, 16430 + "SoSprkEmbedPost": { 16431 + "lexicon": 1, 16432 + "id": "so.sprk.embed.post", 16433 + "description": "Interactive post embed.", 16434 + "defs": { 16435 + "main": { 16436 + "type": "object", 16437 + "required": [ 16438 + "placement", 16439 + "post", 16440 + ], 16441 + "properties": { 16442 + "placement": { 16443 + "type": "ref", 16444 + "ref": "lex:so.sprk.embed.defs#placement", 16445 + }, 16446 + "post": { 16447 + "type": "ref", 16448 + "ref": "lex:com.atproto.repo.strongRef", 16449 + }, 16450 + }, 16451 + }, 16452 + "view": { 16453 + "type": "object", 16454 + "required": [ 16455 + "placement", 16456 + "post", 16457 + ], 16458 + "properties": { 16459 + "placement": { 16460 + "type": "ref", 16461 + "ref": "lex:so.sprk.embed.defs#placement", 16462 + }, 16463 + "post": { 16464 + "type": "union", 16465 + "refs": [ 16466 + "lex:so.sprk.feed.defs#postView", 16467 + "lex:so.sprk.feed.defs#notFoundPost", 16468 + "lex:so.sprk.feed.defs#blockedPost", 16469 + ], 16470 + }, 16471 + }, 16472 + }, 16473 + }, 16474 + }, 16284 16475 "SoSprkNotificationRegisterPush": { 16285 16476 "lexicon": 1, 16286 16477 "id": "so.sprk.notification.registerPush", ··· 18199 18390 }, 18200 18391 }, 18201 18392 }, 18202 - "SoSprkFeedPostgate": { 18203 - "lexicon": 1, 18204 - "id": "so.sprk.feed.postgate", 18205 - "defs": { 18206 - "main": { 18207 - "type": "record", 18208 - "key": "tid", 18209 - "description": 18210 - "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.", 18211 - "record": { 18212 - "type": "object", 18213 - "required": [ 18214 - "post", 18215 - "createdAt", 18216 - ], 18217 - "properties": { 18218 - "createdAt": { 18219 - "type": "string", 18220 - "format": "datetime", 18221 - }, 18222 - "post": { 18223 - "type": "string", 18224 - "format": "at-uri", 18225 - "description": "Reference (AT-URI) to the post record.", 18226 - }, 18227 - "detachedEmbeddingUris": { 18228 - "type": "array", 18229 - "maxLength": 50, 18230 - "items": { 18231 - "type": "string", 18232 - "format": "at-uri", 18233 - }, 18234 - "description": 18235 - "List of AT-URIs embedding this post that the author has detached from.", 18236 - }, 18237 - "embeddingRules": { 18238 - "description": 18239 - "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.", 18240 - "type": "array", 18241 - "maxLength": 5, 18242 - "items": { 18243 - "type": "union", 18244 - "refs": [ 18245 - "lex:so.sprk.feed.postgate#disableRule", 18246 - ], 18247 - }, 18248 - }, 18249 - }, 18250 - }, 18251 - }, 18252 - "disableRule": { 18253 - "type": "object", 18254 - "description": "Disables embedding of this post.", 18255 - "properties": {}, 18256 - }, 18257 - }, 18258 - }, 18259 18393 "SoSprkFeedThreadgate": { 18260 18394 "lexicon": 1, 18261 18395 "id": "so.sprk.feed.threadgate", ··· 20160 20294 "lex:so.sprk.actor.defs#mutedWordsPref", 20161 20295 "lex:so.sprk.actor.defs#hiddenPostsPref", 20162 20296 "lex:so.sprk.actor.defs#labelersPref", 20163 - "lex:so.sprk.actor.defs#postInteractionSettingsPref", 20164 20297 ], 20165 20298 }, 20166 20299 }, ··· 20426 20559 }, 20427 20560 }, 20428 20561 }, 20429 - "postInteractionSettingsPref": { 20430 - "type": "object", 20431 - "description": 20432 - "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.", 20433 - "required": [], 20434 - "properties": { 20435 - "threadgateAllowRules": { 20436 - "description": 20437 - "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.", 20438 - "type": "array", 20439 - "maxLength": 5, 20440 - "items": { 20441 - "type": "union", 20442 - "refs": [ 20443 - "lex:so.sprk.feed.threadgate#mentionRule", 20444 - "lex:so.sprk.feed.threadgate#followerRule", 20445 - "lex:so.sprk.feed.threadgate#followingRule", 20446 - ], 20447 - }, 20448 - }, 20449 - }, 20450 - }, 20451 20562 }, 20452 20563 }, 20453 20564 "SoSprkActorPutPreferences": { ··· 20785 20896 "lex:so.sprk.media.video#view", 20786 20897 ], 20787 20898 }, 20899 + "embeds": { 20900 + "type": "ref", 20901 + "ref": "lex:so.sprk.embed.defs#views", 20902 + }, 20788 20903 "indexedAt": { 20789 20904 "type": "string", 20790 20905 "format": "datetime", ··· 20952 21067 "sound": { 20953 21068 "type": "ref", 20954 21069 "ref": "lex:com.atproto.repo.strongRef", 21070 + }, 21071 + "embeds": { 21072 + "type": "ref", 21073 + "ref": "lex:so.sprk.embed.defs#embeds", 20955 21074 }, 20956 21075 "labels": { 20957 21076 "type": "union", ··· 26953 27072 SoSprkVideoDefs: "so.sprk.video.defs", 26954 27073 SoSprkVideoGetJobStatus: "so.sprk.video.getJobStatus", 26955 27074 SoSprkVideoGetUploadLimits: "so.sprk.video.getUploadLimits", 27075 + SoSprkEmbedDefs: "so.sprk.embed.defs", 27076 + SoSprkEmbedMention: "so.sprk.embed.mention", 27077 + SoSprkEmbedPost: "so.sprk.embed.post", 26956 27078 SoSprkNotificationRegisterPush: "so.sprk.notification.registerPush", 26957 27079 SoSprkNotificationPutPreferences: "so.sprk.notification.putPreferences", 26958 27080 SoSprkNotificationUpdateSeen: "so.sprk.notification.updateSeen", ··· 26982 27104 SoSprkFeedGetFeedGenerator: "so.sprk.feed.getFeedGenerator", 26983 27105 SoSprkFeedGetAuthorFeed: "so.sprk.feed.getAuthorFeed", 26984 27106 SoSprkFeedGetLikes: "so.sprk.feed.getLikes", 26985 - SoSprkFeedPostgate: "so.sprk.feed.postgate", 26986 27107 SoSprkFeedThreadgate: "so.sprk.feed.threadgate", 26987 27108 SoSprkFeedGetPostThread: "so.sprk.feed.getPostThread", 26988 27109 SoSprkFeedGetActorLikes: "so.sprk.feed.getActorLikes",
-28
lex/types/so/sprk/actor/defs.ts
··· 5 5 import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; 6 6 import type * as ComAtprotoLabelDefs from "../../../com/atproto/label/defs.ts"; 7 7 import type * as ComAtprotoRepoStrongRef from "../../../com/atproto/repo/strongRef.ts"; 8 - import type * as SoSprkFeedThreadgate from "../feed/threadgate.ts"; 9 8 10 9 const is$typed = _is$typed, validate = _validate; 11 10 const id = "so.sprk.actor.defs"; ··· 173 172 | $Typed<MutedWordsPref> 174 173 | $Typed<HiddenPostsPref> 175 174 | $Typed<LabelersPref> 176 - | $Typed<PostInteractionSettingsPref> 177 175 | { $type: string } 178 176 )[]; 179 177 ··· 406 404 export function validateLabelerPrefItem<V>(v: V) { 407 405 return validate<LabelerPrefItem & V>(v, id, hashLabelerPrefItem); 408 406 } 409 - 410 - /** 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. */ 411 - export interface PostInteractionSettingsPref { 412 - $type?: "so.sprk.actor.defs#postInteractionSettingsPref"; 413 - /** 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. */ 414 - threadgateAllowRules?: ( 415 - | $Typed<SoSprkFeedThreadgate.MentionRule> 416 - | $Typed<SoSprkFeedThreadgate.FollowerRule> 417 - | $Typed<SoSprkFeedThreadgate.FollowingRule> 418 - | { $type: string } 419 - )[]; 420 - } 421 - 422 - const hashPostInteractionSettingsPref = "postInteractionSettingsPref"; 423 - 424 - export function isPostInteractionSettingsPref<V>(v: V) { 425 - return is$typed(v, id, hashPostInteractionSettingsPref); 426 - } 427 - 428 - export function validatePostInteractionSettingsPref<V>(v: V) { 429 - return validate<PostInteractionSettingsPref & V>( 430 - v, 431 - id, 432 - hashPostInteractionSettingsPref, 433 - ); 434 - }
+73
lex/types/so/sprk/embed/defs.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { validate as _validate } from "../../../../lexicons.ts"; 5 + import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; 6 + import type * as SoSprkEmbedMention from "./mention.ts"; 7 + import type * as SoSprkEmbedPost from "./post.ts"; 8 + 9 + const is$typed = _is$typed, validate = _validate; 10 + const id = "so.sprk.embed.defs"; 11 + 12 + export type Embeds = 13 + ($Typed<SoSprkEmbedMention.Main> | $Typed<SoSprkEmbedPost.Main> | { 14 + $type: string; 15 + })[]; 16 + export type Views = 17 + ($Typed<SoSprkEmbedMention.View> | $Typed<SoSprkEmbedPost.View> | { 18 + $type: string; 19 + })[]; 20 + 21 + /** Placement and layer metadata for an embed on a media canvas. */ 22 + export interface Placement { 23 + $type?: "so.sprk.embed.defs#placement"; 24 + frame: Frame; 25 + mediaRef?: MediaRef; 26 + zIndex?: number; 27 + rotation?: number; 28 + } 29 + 30 + const hashPlacement = "placement"; 31 + 32 + export function isPlacement<V>(v: V) { 33 + return is$typed(v, id, hashPlacement); 34 + } 35 + 36 + export function validatePlacement<V>(v: V) { 37 + return validate<Placement & V>(v, id, hashPlacement); 38 + } 39 + 40 + /** Bounding box in 10,000-based normalized coordinates relative to media canvas dimensions. */ 41 + export interface Frame { 42 + $type?: "so.sprk.embed.defs#frame"; 43 + x: number; 44 + y: number; 45 + w: number; 46 + h: number; 47 + } 48 + 49 + const hashFrame = "frame"; 50 + 51 + export function isFrame<V>(v: V) { 52 + return is$typed(v, id, hashFrame); 53 + } 54 + 55 + export function validateFrame<V>(v: V) { 56 + return validate<Frame & V>(v, id, hashFrame); 57 + } 58 + 59 + /** Optional media locator for records containing multiple media items. */ 60 + export interface MediaRef { 61 + $type?: "so.sprk.embed.defs#mediaRef"; 62 + index: number; 63 + } 64 + 65 + const hashMediaRef = "mediaRef"; 66 + 67 + export function isMediaRef<V>(v: V) { 68 + return is$typed(v, id, hashMediaRef); 69 + } 70 + 71 + export function validateMediaRef<V>(v: V) { 72 + return validate<MediaRef & V>(v, id, hashMediaRef); 73 + }
+43
lex/types/so/sprk/embed/mention.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { validate as _validate } from "../../../../lexicons.ts"; 5 + import { is$typed as _is$typed } from "../../../../util.ts"; 6 + import type * as SoSprkEmbedDefs from "./defs.ts"; 7 + import type * as SoSprkActorDefs from "../actor/defs.ts"; 8 + 9 + const is$typed = _is$typed, validate = _validate; 10 + const id = "so.sprk.embed.mention"; 11 + 12 + export interface Main { 13 + $type?: "so.sprk.embed.mention"; 14 + placement: SoSprkEmbedDefs.Placement; 15 + did: string; 16 + } 17 + 18 + const hashMain = "main"; 19 + 20 + export function isMain<V>(v: V) { 21 + return is$typed(v, id, hashMain); 22 + } 23 + 24 + export function validateMain<V>(v: V) { 25 + return validate<Main & V>(v, id, hashMain); 26 + } 27 + 28 + export interface View { 29 + $type?: "so.sprk.embed.mention#view"; 30 + placement: SoSprkEmbedDefs.Placement; 31 + did: string; 32 + actor?: SoSprkActorDefs.ProfileViewBasic; 33 + } 34 + 35 + const hashView = "view"; 36 + 37 + export function isView<V>(v: V) { 38 + return is$typed(v, id, hashView); 39 + } 40 + 41 + export function validateView<V>(v: V) { 42 + return validate<View & V>(v, id, hashView); 43 + }
+47
lex/types/so/sprk/embed/post.ts
··· 1 + /** 2 + * GENERATED CODE - DO NOT MODIFY 3 + */ 4 + import { validate as _validate } from "../../../../lexicons.ts"; 5 + import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; 6 + import type * as SoSprkEmbedDefs from "./defs.ts"; 7 + import type * as ComAtprotoRepoStrongRef from "../../../com/atproto/repo/strongRef.ts"; 8 + import type * as SoSprkFeedDefs from "../feed/defs.ts"; 9 + 10 + const is$typed = _is$typed, validate = _validate; 11 + const id = "so.sprk.embed.post"; 12 + 13 + export interface Main { 14 + $type?: "so.sprk.embed.post"; 15 + placement: SoSprkEmbedDefs.Placement; 16 + post: ComAtprotoRepoStrongRef.Main; 17 + } 18 + 19 + const hashMain = "main"; 20 + 21 + export function isMain<V>(v: V) { 22 + return is$typed(v, id, hashMain); 23 + } 24 + 25 + export function validateMain<V>(v: V) { 26 + return validate<Main & V>(v, id, hashMain); 27 + } 28 + 29 + export interface View { 30 + $type?: "so.sprk.embed.post#view"; 31 + placement: SoSprkEmbedDefs.Placement; 32 + post: 33 + | $Typed<SoSprkFeedDefs.PostView> 34 + | $Typed<SoSprkFeedDefs.NotFoundPost> 35 + | $Typed<SoSprkFeedDefs.BlockedPost> 36 + | { $type: string }; 37 + } 38 + 39 + const hashView = "view"; 40 + 41 + export function isView<V>(v: V) { 42 + return is$typed(v, id, hashView); 43 + } 44 + 45 + export function validateView<V>(v: V) { 46 + return validate<View & V>(v, id, hashView); 47 + }
-47
lex/types/so/sprk/feed/postgate.ts
··· 1 - /** 2 - * GENERATED CODE - DO NOT MODIFY 3 - */ 4 - import { validate as _validate } from "../../../../lexicons.ts"; 5 - import { type $Typed, is$typed as _is$typed } from "../../../../util.ts"; 6 - 7 - const is$typed = _is$typed, validate = _validate; 8 - const id = "so.sprk.feed.postgate"; 9 - 10 - export interface Record { 11 - $type: "so.sprk.feed.postgate"; 12 - createdAt: string; 13 - /** Reference (AT-URI) to the post record. */ 14 - post: string; 15 - /** List of AT-URIs embedding this post that the author has detached from. */ 16 - detachedEmbeddingUris?: (string)[]; 17 - /** 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. */ 18 - embeddingRules?: ($Typed<DisableRule> | { $type: string })[]; 19 - [k: string]: unknown; 20 - } 21 - 22 - const hashRecord = "main"; 23 - 24 - export function isRecord<V>(v: V) { 25 - return is$typed(v, id, hashRecord); 26 - } 27 - 28 - export function validateRecord<V>(v: V) { 29 - return validate<Record & V>(v, id, hashRecord, true); 30 - } 31 - 32 - export type Main = Record; 33 - 34 - /** Disables embedding of this post. */ 35 - export interface DisableRule { 36 - $type?: "so.sprk.feed.postgate#disableRule"; 37 - } 38 - 39 - const hashDisableRule = "disableRule"; 40 - 41 - export function isDisableRule<V>(v: V) { 42 - return is$typed(v, id, hashDisableRule); 43 - } 44 - 45 - export function validateDisableRule<V>(v: V) { 46 - return validate<DisableRule & V>(v, id, hashDisableRule); 47 - }
+2
lex/types/so/sprk/story/defs.ts
··· 6 6 import type * as SoSprkActorDefs from "../actor/defs.ts"; 7 7 import type * as SoSprkMediaImage from "../media/image.ts"; 8 8 import type * as SoSprkMediaVideo from "../media/video.ts"; 9 + import type * as SoSprkEmbedDefs from "../embed/defs.ts"; 9 10 10 11 const is$typed = _is$typed, validate = _validate; 11 12 const id = "so.sprk.story.defs"; ··· 19 20 media?: $Typed<SoSprkMediaImage.View> | $Typed<SoSprkMediaVideo.View> | { 20 21 $type: string; 21 22 }; 23 + embeds?: SoSprkEmbedDefs.Views; 22 24 indexedAt: string; 23 25 } 24 26
+2
lex/types/so/sprk/story/post.ts
··· 6 6 import type * as SoSprkMediaImage from "../media/image.ts"; 7 7 import type * as SoSprkMediaVideo from "../media/video.ts"; 8 8 import type * as ComAtprotoRepoStrongRef from "../../../com/atproto/repo/strongRef.ts"; 9 + import type * as SoSprkEmbedDefs from "../embed/defs.ts"; 9 10 import type * as ComAtprotoLabelDefs from "../../../com/atproto/label/defs.ts"; 10 11 11 12 const is$typed = _is$typed, validate = _validate; ··· 17 18 $type: string; 18 19 }; 19 20 sound?: ComAtprotoRepoStrongRef.Main; 21 + embeds?: SoSprkEmbedDefs.Embeds; 20 22 labels?: $Typed<ComAtprotoLabelDefs.SelfLabels> | { $type: string }; 21 23 /** Client-declared timestamp when this story was originally created. */ 22 24 createdAt: string;
+1 -22
lexicons/so/sprk/actor/defs.json
··· 173 173 "#interestsPref", 174 174 "#mutedWordsPref", 175 175 "#hiddenPostsPref", 176 - "#labelersPref", 177 - "#postInteractionSettingsPref" 176 + "#labelersPref" 178 177 ] 179 178 } 180 179 }, ··· 373 372 "did": { 374 373 "type": "string", 375 374 "format": "did" 376 - } 377 - } 378 - }, 379 - "postInteractionSettingsPref": { 380 - "type": "object", 381 - "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.", 382 - "required": [], 383 - "properties": { 384 - "threadgateAllowRules": { 385 - "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.", 386 - "type": "array", 387 - "maxLength": 5, 388 - "items": { 389 - "type": "union", 390 - "refs": [ 391 - "so.sprk.feed.threadgate#mentionRule", 392 - "so.sprk.feed.threadgate#followerRule", 393 - "so.sprk.feed.threadgate#followingRule" 394 - ] 395 - } 396 375 } 397 376 } 398 377 }
+83
lexicons/so/sprk/embed/defs.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "so.sprk.embed.defs", 4 + "description": "Shared definitions for Spark interactive embeds.", 5 + "defs": { 6 + "embeds": { 7 + "type": "array", 8 + "items": { 9 + "type": "union", 10 + "refs": ["so.sprk.embed.mention", "so.sprk.embed.post"] 11 + } 12 + }, 13 + "views": { 14 + "type": "array", 15 + "items": { 16 + "type": "union", 17 + "refs": ["so.sprk.embed.mention#view", "so.sprk.embed.post#view"] 18 + } 19 + }, 20 + "placement": { 21 + "type": "object", 22 + "description": "Placement and layer metadata for an embed on a media canvas.", 23 + "required": ["frame"], 24 + "properties": { 25 + "frame": { 26 + "type": "ref", 27 + "ref": "#frame" 28 + }, 29 + "mediaRef": { 30 + "type": "ref", 31 + "ref": "#mediaRef" 32 + }, 33 + "zIndex": { 34 + "type": "integer", 35 + "minimum": 0 36 + }, 37 + "rotation": { 38 + "type": "integer", 39 + "minimum": 0, 40 + "maximum": 359 41 + } 42 + } 43 + }, 44 + "frame": { 45 + "type": "object", 46 + "description": "Bounding box in 10,000-based normalized coordinates relative to media canvas dimensions.", 47 + "required": ["x", "y", "w", "h"], 48 + "properties": { 49 + "x": { 50 + "type": "integer", 51 + "minimum": 0, 52 + "maximum": 10000 53 + }, 54 + "y": { 55 + "type": "integer", 56 + "minimum": 0, 57 + "maximum": 10000 58 + }, 59 + "w": { 60 + "type": "integer", 61 + "minimum": 1, 62 + "maximum": 10000 63 + }, 64 + "h": { 65 + "type": "integer", 66 + "minimum": 1, 67 + "maximum": 10000 68 + } 69 + } 70 + }, 71 + "mediaRef": { 72 + "type": "object", 73 + "description": "Optional media locator for records containing multiple media items.", 74 + "required": ["index"], 75 + "properties": { 76 + "index": { 77 + "type": "integer", 78 + "minimum": 0 79 + } 80 + } 81 + } 82 + } 83 + }
+39
lexicons/so/sprk/embed/mention.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "so.sprk.embed.mention", 4 + "description": "Interactive mention embed.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": ["placement", "did"], 9 + "properties": { 10 + "placement": { 11 + "type": "ref", 12 + "ref": "so.sprk.embed.defs#placement" 13 + }, 14 + "did": { 15 + "type": "string", 16 + "format": "did" 17 + } 18 + } 19 + }, 20 + "view": { 21 + "type": "object", 22 + "required": ["placement", "did"], 23 + "properties": { 24 + "placement": { 25 + "type": "ref", 26 + "ref": "so.sprk.embed.defs#placement" 27 + }, 28 + "did": { 29 + "type": "string", 30 + "format": "did" 31 + }, 32 + "actor": { 33 + "type": "ref", 34 + "ref": "so.sprk.actor.defs#profileViewBasic" 35 + } 36 + } 37 + } 38 + } 39 + }
+39
lexicons/so/sprk/embed/post.json
··· 1 + { 2 + "lexicon": 1, 3 + "id": "so.sprk.embed.post", 4 + "description": "Interactive post embed.", 5 + "defs": { 6 + "main": { 7 + "type": "object", 8 + "required": ["placement", "post"], 9 + "properties": { 10 + "placement": { 11 + "type": "ref", 12 + "ref": "so.sprk.embed.defs#placement" 13 + }, 14 + "post": { 15 + "type": "ref", 16 + "ref": "com.atproto.repo.strongRef" 17 + } 18 + } 19 + }, 20 + "view": { 21 + "type": "object", 22 + "required": ["placement", "post"], 23 + "properties": { 24 + "placement": { 25 + "type": "ref", 26 + "ref": "so.sprk.embed.defs#placement" 27 + }, 28 + "post": { 29 + "type": "union", 30 + "refs": [ 31 + "so.sprk.feed.defs#postView", 32 + "so.sprk.feed.defs#notFoundPost", 33 + "so.sprk.feed.defs#blockedPost" 34 + ] 35 + } 36 + } 37 + } 38 + } 39 + }
-46
lexicons/so/sprk/feed/postgate.json
··· 1 - { 2 - "lexicon": 1, 3 - "id": "so.sprk.feed.postgate", 4 - "defs": { 5 - "main": { 6 - "type": "record", 7 - "key": "tid", 8 - "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.", 9 - "record": { 10 - "type": "object", 11 - "required": ["post", "createdAt"], 12 - "properties": { 13 - "createdAt": { "type": "string", "format": "datetime" }, 14 - "post": { 15 - "type": "string", 16 - "format": "at-uri", 17 - "description": "Reference (AT-URI) to the post record." 18 - }, 19 - "detachedEmbeddingUris": { 20 - "type": "array", 21 - "maxLength": 50, 22 - "items": { 23 - "type": "string", 24 - "format": "at-uri" 25 - }, 26 - "description": "List of AT-URIs embedding this post that the author has detached from." 27 - }, 28 - "embeddingRules": { 29 - "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.", 30 - "type": "array", 31 - "maxLength": 5, 32 - "items": { 33 - "type": "union", 34 - "refs": ["#disableRule"] 35 - } 36 - } 37 - } 38 - } 39 - }, 40 - "disableRule": { 41 - "type": "object", 42 - "description": "Disables embedding of this post.", 43 - "properties": {} 44 - } 45 - } 46 - }
+4
lexicons/so/sprk/story/defs.json
··· 17 17 "type": "union", 18 18 "refs": ["so.sprk.media.image#view", "so.sprk.media.video#view"] 19 19 }, 20 + "embeds": { 21 + "type": "ref", 22 + "ref": "so.sprk.embed.defs#views" 23 + }, 20 24 "indexedAt": { "type": "string", "format": "datetime" } 21 25 } 22 26 },
+4
lexicons/so/sprk/story/post.json
··· 18 18 "type": "ref", 19 19 "ref": "com.atproto.repo.strongRef" 20 20 }, 21 + "embeds": { 22 + "type": "ref", 23 + "ref": "so.sprk.embed.defs#embeds" 24 + }, 21 25 "labels": { 22 26 "type": "union", 23 27 "description": "Self-label values for this story. Effectively content warnings.",
+545
tests/stories_test.ts
··· 1 1 import { assertEquals } from "@std/assert"; 2 + import { HydrationState } from "../hydration/index.ts"; 3 + import { Actor } from "../hydration/actor.ts"; 4 + import { HydrationMap, RecordInfo } from "../hydration/util.ts"; 5 + import { Record as PostRecord } from "../lex/types/so/sprk/feed/post.ts"; 6 + import { Record as StoryRecord } from "../lex/types/so/sprk/story/post.ts"; 7 + import { Views } from "../views/index.ts"; 2 8 import { createTestContext, TEST_USERS } from "./util.ts"; 3 9 4 10 const VALID_BLOB_CID = ··· 181 187 } finally { 182 188 await cleanup(); 183 189 } 190 + }, 191 + ); 192 + 193 + await t.step( 194 + "hydrateStories fully hydrates embedded post metadata", 195 + async () => { 196 + const { ctx, cleanup } = await createTestContext({ 197 + actors: false, 198 + profiles: false, 199 + posts: false, 200 + replies: false, 201 + stories: false, 202 + likes: false, 203 + reposts: false, 204 + follows: false, 205 + blocks: false, 206 + audio: false, 207 + generators: false, 208 + preferences: false, 209 + records: false, 210 + actorSync: false, 211 + }); 212 + 213 + try { 214 + const now = new Date(); 215 + const nowIso = now.toISOString(); 216 + const storyAuthorDid = TEST_USERS[0].did; 217 + const postAuthorDid = TEST_USERS[1].did; 218 + const storyUri = 219 + `at://${storyAuthorDid}/so.sprk.story.post/story-hydrated`; 220 + const postUri = 221 + `at://${postAuthorDid}/so.sprk.feed.post/post-hydrated`; 222 + const soundUri = 223 + `at://${postAuthorDid}/so.sprk.sound.audio/sound-hydrated`; 224 + 225 + await ctx.db.models.Actor.create([ 226 + { 227 + did: storyAuthorDid, 228 + handle: "story-author.test", 229 + indexedAt: nowIso, 230 + keys: [], 231 + services: "[]", 232 + }, 233 + { 234 + did: postAuthorDid, 235 + handle: "post-author.test", 236 + indexedAt: nowIso, 237 + keys: [], 238 + services: "[]", 239 + }, 240 + ]); 241 + 242 + const soundRecord = { 243 + $type: "so.sprk.sound.audio", 244 + sound: { 245 + $type: "blob", 246 + ref: { $link: VALID_BLOB_CID }, 247 + mimeType: "audio/mpeg", 248 + size: 12345, 249 + }, 250 + title: "Hydrated Sound", 251 + createdAt: nowIso, 252 + }; 253 + const postRecord = { 254 + $type: "so.sprk.feed.post", 255 + media: { 256 + $type: "so.sprk.media.images", 257 + images: [{ 258 + $type: "so.sprk.media.image", 259 + image: { 260 + $type: "blob", 261 + ref: { $link: VALID_BLOB_CID }, 262 + mimeType: "image/jpeg", 263 + size: 22222, 264 + }, 265 + alt: "Hydrated post image", 266 + }], 267 + }, 268 + sound: { uri: soundUri, cid: VALID_BLOB_CID }, 269 + createdAt: nowIso, 270 + }; 271 + const storyRecord = { 272 + $type: "so.sprk.story.post", 273 + media: { 274 + $type: "so.sprk.media.image", 275 + image: { 276 + $type: "blob", 277 + ref: { $link: VALID_BLOB_CID }, 278 + mimeType: "image/jpeg", 279 + size: 11111, 280 + }, 281 + alt: "Hydrated story image", 282 + aspectRatio: { width: 1080, height: 1920 }, 283 + }, 284 + embeds: [ 285 + { 286 + $type: "so.sprk.embed.post", 287 + placement: { 288 + frame: { x: 1000, y: 1000, w: 7000, h: 2500 }, 289 + }, 290 + post: { uri: postUri, cid: VALID_BLOB_CID }, 291 + }, 292 + ], 293 + createdAt: nowIso, 294 + }; 295 + 296 + await ctx.db.models.Audio.create({ 297 + uri: soundUri, 298 + cid: VALID_BLOB_CID, 299 + authorDid: postAuthorDid, 300 + createdAt: nowIso, 301 + indexedAt: nowIso, 302 + sound: soundRecord.sound, 303 + title: soundRecord.title, 304 + useCount: 8, 305 + }); 306 + 307 + await ctx.db.models.Post.create({ 308 + uri: postUri, 309 + cid: VALID_BLOB_CID, 310 + authorDid: postAuthorDid, 311 + createdAt: nowIso, 312 + indexedAt: nowIso, 313 + caption: { text: "Hydrated post caption" }, 314 + media: postRecord.media, 315 + sound: postRecord.sound, 316 + labels: [], 317 + tags: [], 318 + likeCount: 17, 319 + replyCount: 4, 320 + repostCount: 2, 321 + }); 322 + 323 + await ctx.db.models.Record.create([ 324 + { 325 + uri: soundUri, 326 + cid: VALID_BLOB_CID, 327 + did: postAuthorDid, 328 + collectionName: "so.sprk.sound.audio", 329 + rkey: "sound-hydrated", 330 + createdAt: nowIso, 331 + indexedAt: nowIso, 332 + json: JSON.stringify(soundRecord), 333 + takenDown: false, 334 + }, 335 + { 336 + uri: postUri, 337 + cid: VALID_BLOB_CID, 338 + did: postAuthorDid, 339 + collectionName: "so.sprk.feed.post", 340 + rkey: "post-hydrated", 341 + createdAt: nowIso, 342 + indexedAt: nowIso, 343 + json: JSON.stringify(postRecord), 344 + takenDown: false, 345 + }, 346 + { 347 + uri: storyUri, 348 + cid: VALID_BLOB_CID, 349 + did: storyAuthorDid, 350 + collectionName: "so.sprk.story.post", 351 + rkey: "story-hydrated", 352 + createdAt: nowIso, 353 + indexedAt: nowIso, 354 + json: JSON.stringify(storyRecord), 355 + takenDown: false, 356 + }, 357 + ]); 358 + 359 + const hydrateCtx = await ctx.hydrator.createContext({ 360 + viewer: storyAuthorDid, 361 + labelers: ctx.reqLabelers(new Request("https://example.com")), 362 + }); 363 + 364 + const hydration = await ctx.hydrator.hydrateStories( 365 + [storyUri], 366 + hydrateCtx, 367 + ); 368 + const storyView = ctx.views.story(storyUri, hydration); 369 + const embed = storyView?.embeds?.[0] as { 370 + post: { 371 + $type?: string; 372 + likeCount?: number; 373 + replyCount?: number; 374 + repostCount?: number; 375 + viewer?: unknown; 376 + sound?: { uri: string }; 377 + }; 378 + } | undefined; 379 + 380 + assertEquals(embed?.post.$type, "so.sprk.feed.defs#postView"); 381 + assertEquals(embed?.post.likeCount, 17); 382 + assertEquals(embed?.post.replyCount, 4); 383 + assertEquals(embed?.post.repostCount, 2); 384 + assertEquals(embed?.post.viewer !== undefined, true); 385 + assertEquals(embed?.post.sound?.uri, soundUri); 386 + } finally { 387 + await cleanup(); 388 + } 389 + }, 390 + ); 391 + 392 + await t.step( 393 + "story view hydrates mention and post embeds", 394 + () => { 395 + const now = new Date(); 396 + const storyAuthorDid = TEST_USERS[0].did; 397 + const mentionDid = TEST_USERS[1].did; 398 + const postAuthorDid = TEST_USERS[2].did; 399 + const storyUri = 400 + `at://${storyAuthorDid}/so.sprk.story.post/embed-story`; 401 + const postUri = `at://${postAuthorDid}/so.sprk.feed.post/embed-post`; 402 + 403 + const storyRecord = { 404 + $type: "so.sprk.story.post", 405 + createdAt: now.toISOString(), 406 + media: { 407 + $type: "so.sprk.media.image", 408 + image: { 409 + ref: { $link: VALID_BLOB_CID }, 410 + mimeType: "image/jpeg", 411 + size: 12345, 412 + }, 413 + alt: "Story image", 414 + aspectRatio: { width: 1080, height: 1920 }, 415 + }, 416 + embeds: [ 417 + { 418 + $type: "so.sprk.embed.mention", 419 + placement: { 420 + frame: { x: 1000, y: 1000, w: 3000, h: 1000 }, 421 + }, 422 + did: mentionDid, 423 + }, 424 + { 425 + $type: "so.sprk.embed.post", 426 + placement: { 427 + frame: { x: 800, y: 5000, w: 8400, h: 3000 }, 428 + }, 429 + post: { 430 + uri: postUri, 431 + cid: 432 + "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyaa", 433 + }, 434 + }, 435 + ], 436 + } as unknown as StoryRecord; 437 + 438 + const postRecord = { 439 + $type: "so.sprk.feed.post", 440 + createdAt: now.toISOString(), 441 + media: { 442 + $type: "so.sprk.media.images", 443 + images: [{ 444 + $type: "so.sprk.media.image", 445 + image: { 446 + ref: { $link: VALID_BLOB_CID }, 447 + mimeType: "image/jpeg", 448 + size: 22222, 449 + }, 450 + alt: "Embedded post image", 451 + }], 452 + }, 453 + } as unknown as PostRecord; 454 + 455 + const stories = new HydrationMap<RecordInfo<StoryRecord>>(); 456 + stories.set(storyUri, { 457 + record: storyRecord, 458 + cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvybb", 459 + sortedAt: now, 460 + indexedAt: now, 461 + takedownRef: undefined, 462 + }); 463 + 464 + const posts = new HydrationMap<RecordInfo<PostRecord>>(); 465 + posts.set(postUri, { 466 + record: postRecord, 467 + cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvycc", 468 + sortedAt: now, 469 + indexedAt: now, 470 + takedownRef: undefined, 471 + }); 472 + 473 + const actors = new HydrationMap<Actor>(); 474 + actors.set(storyAuthorDid, { 475 + did: storyAuthorDid, 476 + handle: "story-author.test", 477 + createdAt: now, 478 + sortedAt: now, 479 + }); 480 + actors.set(mentionDid, { 481 + did: mentionDid, 482 + handle: "mentioned-user.test", 483 + createdAt: now, 484 + sortedAt: now, 485 + }); 486 + actors.set(postAuthorDid, { 487 + did: postAuthorDid, 488 + handle: "post-author.test", 489 + createdAt: now, 490 + sortedAt: now, 491 + }); 492 + 493 + const state: HydrationState = { 494 + stories, 495 + posts, 496 + actors, 497 + }; 498 + 499 + const views = new Views({ 500 + mediaCdn: "https://media.example.com", 501 + thumbCdn: "https://thumb.example.com", 502 + videoCdn: "https://video.example.com", 503 + }); 504 + 505 + const view = views.story(storyUri, state); 506 + assertEquals(view?.embeds?.length, 2); 507 + 508 + const mentionView = view?.embeds?.[0] as { 509 + $type: string; 510 + did: string; 511 + actor?: { did: string }; 512 + }; 513 + assertEquals(mentionView.$type, "so.sprk.embed.mention#view"); 514 + assertEquals(mentionView.did, mentionDid); 515 + assertEquals(mentionView.actor?.did, mentionDid); 516 + 517 + const postView = view?.embeds?.[1] as { 518 + $type: string; 519 + post: { $type?: string; uri: string }; 520 + }; 521 + assertEquals(postView.$type, "so.sprk.embed.post#view"); 522 + assertEquals(postView.post.$type, "so.sprk.feed.defs#postView"); 523 + assertEquals(postView.post.uri, postUri); 524 + }, 525 + ); 526 + 527 + await t.step( 528 + "story view ignores malformed post embeds", 529 + () => { 530 + const now = new Date(); 531 + const storyAuthorDid = TEST_USERS[0].did; 532 + const storyUri = 533 + `at://${storyAuthorDid}/so.sprk.story.post/bad-embed-story`; 534 + 535 + const storyRecord = { 536 + $type: "so.sprk.story.post", 537 + createdAt: now.toISOString(), 538 + media: { 539 + $type: "so.sprk.media.image", 540 + image: { 541 + ref: { $link: VALID_BLOB_CID }, 542 + mimeType: "image/jpeg", 543 + size: 12345, 544 + }, 545 + alt: "Story image", 546 + aspectRatio: { width: 1080, height: 1920 }, 547 + }, 548 + embeds: [ 549 + { 550 + $type: "so.sprk.embed.post", 551 + placement: { 552 + frame: { x: 800, y: 5000, w: 8400, h: 3000 }, 553 + }, 554 + }, 555 + ], 556 + } as unknown as StoryRecord; 557 + 558 + const stories = new HydrationMap<RecordInfo<StoryRecord>>(); 559 + stories.set(storyUri, { 560 + record: storyRecord, 561 + cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvydd", 562 + sortedAt: now, 563 + indexedAt: now, 564 + takedownRef: undefined, 565 + }); 566 + 567 + const actors = new HydrationMap<Actor>(); 568 + actors.set(storyAuthorDid, { 569 + did: storyAuthorDid, 570 + handle: "story-author.test", 571 + createdAt: now, 572 + sortedAt: now, 573 + }); 574 + 575 + const state: HydrationState = { 576 + stories, 577 + actors, 578 + }; 579 + 580 + const views = new Views({ 581 + mediaCdn: "https://media.example.com", 582 + thumbCdn: "https://thumb.example.com", 583 + videoCdn: "https://video.example.com", 584 + }); 585 + 586 + const view = views.story(storyUri, state); 587 + assertEquals(view?.embeds, undefined); 588 + }, 589 + ); 590 + 591 + await t.step( 592 + "story view ignores mention embed missing did", 593 + () => { 594 + const now = new Date(); 595 + const storyAuthorDid = TEST_USERS[0].did; 596 + const storyUri = 597 + `at://${storyAuthorDid}/so.sprk.story.post/no-did-mention-story`; 598 + 599 + const storyRecord = { 600 + $type: "so.sprk.story.post", 601 + createdAt: now.toISOString(), 602 + media: { 603 + $type: "so.sprk.media.image", 604 + image: { 605 + ref: { $link: VALID_BLOB_CID }, 606 + mimeType: "image/jpeg", 607 + size: 12345, 608 + }, 609 + alt: "Story image", 610 + aspectRatio: { width: 1080, height: 1920 }, 611 + }, 612 + embeds: [ 613 + { 614 + $type: "so.sprk.embed.mention", 615 + placement: { 616 + frame: { x: 1000, y: 1000, w: 3000, h: 1000 }, 617 + }, 618 + // did is intentionally absent 619 + }, 620 + ], 621 + } as unknown as StoryRecord; 622 + 623 + const stories = new HydrationMap<RecordInfo<StoryRecord>>(); 624 + stories.set(storyUri, { 625 + record: storyRecord, 626 + cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyee", 627 + sortedAt: now, 628 + indexedAt: now, 629 + takedownRef: undefined, 630 + }); 631 + 632 + const actors = new HydrationMap<Actor>(); 633 + actors.set(storyAuthorDid, { 634 + did: storyAuthorDid, 635 + handle: "story-author.test", 636 + createdAt: now, 637 + sortedAt: now, 638 + }); 639 + 640 + const state: HydrationState = { stories, actors }; 641 + 642 + const views = new Views({ 643 + mediaCdn: "https://media.example.com", 644 + thumbCdn: "https://thumb.example.com", 645 + videoCdn: "https://video.example.com", 646 + }); 647 + 648 + const view = views.story(storyUri, state); 649 + assertEquals(view?.embeds, undefined); 650 + }, 651 + ); 652 + 653 + await t.step( 654 + "story view ignores embeds missing placement", 655 + () => { 656 + const now = new Date(); 657 + const storyAuthorDid = TEST_USERS[0].did; 658 + const mentionDid = TEST_USERS[1].did; 659 + const postAuthorDid = TEST_USERS[2].did; 660 + const storyUri = 661 + `at://${storyAuthorDid}/so.sprk.story.post/no-placement-story`; 662 + const postUri = 663 + `at://${postAuthorDid}/so.sprk.feed.post/no-placement-post`; 664 + 665 + const storyRecord = { 666 + $type: "so.sprk.story.post", 667 + createdAt: now.toISOString(), 668 + media: { 669 + $type: "so.sprk.media.image", 670 + image: { 671 + ref: { $link: VALID_BLOB_CID }, 672 + mimeType: "image/jpeg", 673 + size: 12345, 674 + }, 675 + alt: "Story image", 676 + aspectRatio: { width: 1080, height: 1920 }, 677 + }, 678 + embeds: [ 679 + { 680 + $type: "so.sprk.embed.mention", 681 + // placement is intentionally absent 682 + did: mentionDid, 683 + }, 684 + { 685 + $type: "so.sprk.embed.post", 686 + // placement is intentionally absent 687 + post: { 688 + uri: postUri, 689 + cid: 690 + "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvyff", 691 + }, 692 + }, 693 + ], 694 + } as unknown as StoryRecord; 695 + 696 + const stories = new HydrationMap<RecordInfo<StoryRecord>>(); 697 + stories.set(storyUri, { 698 + record: storyRecord, 699 + cid: "bafyreihdwdcefgh4dqkjv67uzcmw7ojee6xedzdetojuzjevtenxquvygg", 700 + sortedAt: now, 701 + indexedAt: now, 702 + takedownRef: undefined, 703 + }); 704 + 705 + const actors = new HydrationMap<Actor>(); 706 + actors.set(storyAuthorDid, { 707 + did: storyAuthorDid, 708 + handle: "story-author.test", 709 + createdAt: now, 710 + sortedAt: now, 711 + }); 712 + actors.set(mentionDid, { 713 + did: mentionDid, 714 + handle: "mentioned-user.test", 715 + createdAt: now, 716 + sortedAt: now, 717 + }); 718 + 719 + const state: HydrationState = { stories, actors }; 720 + 721 + const views = new Views({ 722 + mediaCdn: "https://media.example.com", 723 + thumbCdn: "https://thumb.example.com", 724 + videoCdn: "https://video.example.com", 725 + }); 726 + 727 + const view = views.story(storyUri, state); 728 + assertEquals(view?.embeds, undefined); 184 729 }, 185 730 ); 186 731 },
+62
views/index.ts
··· 25 25 ThreadViewPost, 26 26 } from "../lex/types/so/sprk/feed/defs.ts"; 27 27 import { StoriesByAuthor, StoryView } from "../lex/types/so/sprk/story/defs.ts"; 28 + import { Main as MentionEmbed } from "../lex/types/so/sprk/embed/mention.ts"; 29 + import { Main as PostEmbed } from "../lex/types/so/sprk/embed/post.ts"; 28 30 import { 29 31 isRecord as isReplyRecord, 30 32 Record as ReplyRecord, ··· 427 429 author, 428 430 record: storyInfo.record, 429 431 media: mediaRecord ? this.storyMedia(uri, mediaRecord) : undefined, 432 + embeds: this.storyEmbeds(storyInfo.record.embeds, state), 430 433 indexedAt: this.indexedAt(storyInfo)?.toISOString() ?? 431 434 new Date().toISOString(), 432 435 }; 436 + } 437 + 438 + private isMentionEmbedRecord(embed: unknown): embed is MentionEmbed { 439 + if (!embed || typeof embed !== "object") return false; 440 + const e = embed as Record<string, unknown>; 441 + return e["$type"] === "so.sprk.embed.mention" && 442 + typeof e["did"] === "string" && 443 + (e["did"] as string).length > 0 && 444 + !!e["placement"] && 445 + typeof e["placement"] === "object" && 446 + !!(e["placement"] as Record<string, unknown>)["frame"] && 447 + typeof (e["placement"] as Record<string, unknown>)["frame"] === "object"; 448 + } 449 + 450 + private isPostEmbedRecord(embed: unknown): embed is PostEmbed { 451 + if (!embed || typeof embed !== "object") return false; 452 + const e = embed as Record<string, unknown>; 453 + const post = e["post"] as Record<string, unknown> | undefined; 454 + return e["$type"] === "so.sprk.embed.post" && 455 + typeof post?.["uri"] === "string" && 456 + !!e["placement"] && 457 + typeof e["placement"] === "object" && 458 + !!(e["placement"] as Record<string, unknown>)["frame"] && 459 + typeof (e["placement"] as Record<string, unknown>)["frame"] === "object"; 460 + } 461 + 462 + storyEmbeds( 463 + embeds: unknown, 464 + state: HydrationState, 465 + ): StoryView["embeds"] | undefined { 466 + if (!Array.isArray(embeds) || embeds.length === 0) { 467 + return undefined; 468 + } 469 + 470 + const views = mapDefined(embeds, (embed) => { 471 + if (this.isMentionEmbedRecord(embed)) { 472 + return { 473 + $type: "so.sprk.embed.mention#view", 474 + placement: embed.placement, 475 + did: embed.did, 476 + actor: this.profileBasic(embed.did, state), 477 + }; 478 + } 479 + 480 + if (this.isPostEmbedRecord(embed)) { 481 + const embedded = this.maybePost(embed.post.uri, state); 482 + return { 483 + $type: "so.sprk.embed.post#view", 484 + placement: embed.placement, 485 + post: isReplyView(embedded) 486 + ? this.notFoundPost(embed.post.uri) 487 + : embedded, 488 + }; 489 + } 490 + 491 + return undefined; 492 + }); 493 + 494 + return views.length > 0 ? views as StoryView["embeds"] : undefined; 433 495 } 434 496 435 497 storiesByAuthor(