diff --git a/api/so/sprk/feed/getAuthorFeed.ts b/api/so/sprk/feed/getAuthorFeed.ts index e3a8644..a439afc 100644 --- a/api/so/sprk/feed/getAuthorFeed.ts +++ b/api/so/sprk/feed/getAuthorFeed.ts @@ -22,7 +22,7 @@ export default function (server: Server, ctx: AppContext) { const getAuthorFeed = createPipeline( skeleton, hydration, - noBlocksOrMutedReposts, + noBlocksOrMutes, presentation, ); server.so.sprk.feed.getAuthorFeed({ @@ -94,9 +94,6 @@ export const skeleton = async (inputs: { let items: FeedItem[] = res.items.map((item) => ({ post: { uri: item.uri, cid: item.cid || undefined }, - repost: item.repost - ? { uri: item.repost, cid: item.repostCid || undefined } - : undefined, })); if (shouldInsertPinnedPost && pinnedPost) { @@ -105,7 +102,6 @@ export const skeleton = async (inputs: { uri: pinnedPost.uri, cid: pinnedPost.cid, }, - authorPinned: true, }; items = items.filter((item) => item.post.uri !== pinnedItem.post.uri); @@ -133,7 +129,7 @@ const hydration = async (inputs: { return mergeStates(feedPostState, profileViewerState); }; -const noBlocksOrMutedReposts = (inputs: { +const noBlocksOrMutes = (inputs: { ctx: Context; skeleton: Skeleton; hydration: HydrationState; @@ -157,24 +153,11 @@ const noBlocksOrMutedReposts = (inputs: { const bam = ctx.views.feedItemBlocksAndMutes(item, hydration); return ( !bam.authorBlocked && - !bam.originatorBlocked && - (!bam.authorMuted || bam.originatorMuted) // repost of muted content + !bam.authorMuted ); }; - if (skeleton.filter === "posts_and_author_threads") { - // ensure replies are only included if the feed contains all - // replies up to the thread root (i.e. a complete self-thread.) - const selfThread = new SelfThreadTracker(skeleton.items, hydration); - skeleton.items = skeleton.items.filter((item) => { - return ( - checkBlocksAndMutes(item) && - (item.repost || item.authorPinned || selfThread.ok(item.post.uri)) - ); - }); - } else { - skeleton.items = skeleton.items.filter(checkBlocksAndMutes); - } + skeleton.items = skeleton.items.filter(checkBlocksAndMutes); return skeleton; }; @@ -208,50 +191,3 @@ type Skeleton = { filter: QueryParams["filter"]; cursor?: string; }; - -class SelfThreadTracker { - feedUris = new Set(); - cache = new Map(); - - constructor( - items: FeedItem[], - private hydration: HydrationState, - ) { - items.forEach((item) => { - if (!item.repost) { - this.feedUris.add(item.post.uri); - } - }); - } - - ok(uri: string, loop = new Set()) { - // if we've already checked this uri, pull from the cache - if (this.cache.has(uri)) { - return this.cache.get(uri) ?? false; - } - // loop detection - if (loop.has(uri)) { - this.cache.set(uri, false); - return false; - } else { - loop.add(uri); - } - // cache through the result - const result = this._ok(uri); - this.cache.set(uri, result); - return result; - } - - private _ok(uri: string): boolean { - // must be in the feed to be in a self-thread - if (!this.feedUris.has(uri)) { - return false; - } - // must be hydratable to be part of self-thread - const post = this.hydration.posts?.get(uri); - if (!post) { - return false; - } - return true; - } -} diff --git a/api/so/sprk/feed/getFeed.ts b/api/so/sprk/feed/getFeed.ts index 702fe13..aa83d0f 100644 --- a/api/so/sprk/feed/getFeed.ts +++ b/api/so/sprk/feed/getFeed.ts @@ -16,7 +16,6 @@ import { FeedItem } from "../../../../hydration/feed.ts"; import { HydrateCtx } from "../../../../hydration/index.ts"; import { Server } from "../../../../lex/index.ts"; import { ids, lexicons } from "../../../../lex/lexicons.ts"; -import { isSkeletonReasonRepost } from "../../../../lex/types/so/sprk/feed/defs.ts"; import { QueryParams as GetFeedParams } from "../../../../lex/types/so/sprk/feed/getFeed.ts"; import { OutputSchema as SkeletonOutput } from "../../../../lex/types/so/sprk/feed/getFeedSkeleton.ts"; import { @@ -127,9 +126,7 @@ const noBlocksOrMutes = (inputs: RulesFnInput) => { const bam = ctx.views.feedItemBlocksAndMutes(item, hydration); return ( !bam.authorBlocked && - !bam.authorMuted && - !bam.originatorBlocked && - !bam.originatorMuted + !bam.authorMuted ); }); @@ -263,9 +260,6 @@ const skeletonFromFeedGen = async ( const { feed: feedSkele, ...skele } = skeleton; const feedItems = feedSkele.slice(0, params.limit).map((item) => ({ post: { uri: item.post }, - repost: isSkeletonReasonRepost(item.reason) - ? { uri: item.reason.repost } - : undefined, feedContext: item.feedContext, })); diff --git a/api/so/sprk/feed/getTimeline.ts b/api/so/sprk/feed/getTimeline.ts index 772799e..efa0345 100644 --- a/api/so/sprk/feed/getTimeline.ts +++ b/api/so/sprk/feed/getTimeline.ts @@ -66,9 +66,6 @@ export const skeleton = async (inputs: { return { items: res.items.map((item) => ({ post: { uri: item.uri, cid: item.cid || undefined }, - repost: item.repost - ? { uri: item.repost, cid: item.repostCid || undefined } - : undefined, })), cursor: parseString(res.cursor), }; @@ -92,9 +89,7 @@ const noBlocksOrMutes = (inputs: { skeleton.items = skeleton.items.filter((item) => { const bam = ctx.views.feedItemBlocksAndMutes(item, hydration); return !bam.authorBlocked && - !bam.authorMuted && - !bam.originatorBlocked && - !bam.originatorMuted; + !bam.authorMuted; }); return skeleton; }; diff --git a/data-plane/routes/feeds.ts b/data-plane/routes/feeds.ts index 88f0ca6..900e386 100644 --- a/data-plane/routes/feeds.ts +++ b/data-plane/routes/feeds.ts @@ -3,17 +3,11 @@ import { TimeCidKeyset } from "../db/pagination.ts"; // Helper function to format feed items function feedItemFromRow( - item: { uri: string; cid: string; repostUri?: string }, -): { uri: string; cid: string; repost?: string; repostCid?: string } { + item: { uri: string; cid: string }, +): { uri: string; cid: string } { return { uri: item.uri, cid: item.cid, - repost: item.repostUri && item.repostUri !== item.uri - ? item.repostUri - : undefined, - repostCid: item.repostUri && item.repostUri !== item.uri - ? item.cid - : undefined, }; } @@ -23,8 +17,6 @@ interface FeedItem { authorDid: string; createdAt: string; indexedAt: string; - type: "post" | "repost"; - repostUri?: string; } export class Feeds { @@ -87,7 +79,6 @@ export class Feeds { authorDid: p.authorDid, createdAt: p.createdAt, indexedAt: p.indexedAt, - type: "post" as const, })); return { @@ -124,7 +115,6 @@ export class Feeds { authorDid: p.authorDid, createdAt: p.createdAt, indexedAt: p.indexedAt, - type: "post" as const, })); return { diff --git a/data-plane/routes/interactions.ts b/data-plane/routes/interactions.ts index abcf633..2fe82a1 100644 --- a/data-plane/routes/interactions.ts +++ b/data-plane/routes/interactions.ts @@ -6,6 +6,15 @@ interface AggregationResult { count: number; } +export interface KnownInteraction { + type: "like" | "repost" | "reply"; + uri: string; + cid: string; + authorDid: string; + indexedAt: string; + text?: string; +} + export class Interactions { private db: Database; @@ -136,4 +145,134 @@ export class Interactions { uses: uris.map((uri) => usageMap.get(uri) ?? 0), }; } + + /** + * Get interactions (likes, reposts, replies) on subject URIs by users the viewer follows. + * Returns interactions sorted by indexedAt descending (most recent first). + */ + async getKnownInteractions( + viewerDid: string, + subjectUris: string[], + ): Promise<{ results: Map }> { + if (subjectUris.length === 0) { + return { results: new Map() }; + } + + // Get all DIDs the viewer follows + const viewerFollows = await this.db.models.Follow.find({ + authorDid: viewerDid, + }); + const followedDids = viewerFollows.map((f) => f.subject); + + if (followedDids.length === 0) { + return { results: new Map() }; + } + + // Query likes, reposts, and replies by followed users on the subject URIs + const [likes, reposts, replies] = await Promise.all([ + this.db.models.Like.find({ + subject: { $in: subjectUris }, + authorDid: { $in: followedDids }, + }).sort({ indexedAt: -1 }), + this.db.models.Repost.find({ + subject: { $in: subjectUris }, + authorDid: { $in: followedDids }, + }).sort({ indexedAt: -1 }), + this.db.models.Reply.find({ + "reply.parent.uri": { $in: subjectUris }, + authorDid: { $in: followedDids }, + }).sort({ indexedAt: -1 }), + ]); + + // Build result map keyed by subject URI + const results = new Map(); + + // Initialize empty arrays for each subject URI + for (const uri of subjectUris) { + results.set(uri, []); + } + + // Add likes + for (const like of likes) { + const interactions = results.get(like.subject); + if (interactions) { + interactions.push({ + type: "like", + uri: like.uri, + cid: like.cid, + authorDid: like.authorDid, + indexedAt: String(like.indexedAt), + }); + } + } + + // Add reposts + for (const repost of reposts) { + const interactions = results.get(repost.subject); + if (interactions) { + interactions.push({ + type: "repost", + uri: repost.uri, + cid: repost.cid, + authorDid: repost.authorDid, + indexedAt: String(repost.indexedAt), + }); + } + } + + // Add replies + for (const reply of replies) { + const parentUri = reply.reply?.parent.uri; + if (!parentUri) continue; + const interactions = results.get(parentUri); + if (interactions) { + interactions.push({ + type: "reply", + uri: reply.uri, + cid: reply.cid, + authorDid: reply.authorDid, + indexedAt: String(reply.indexedAt), + text: reply.text, + }); + } + } + + // Dedupe: keep one interaction per actor with priority: repost > reply > like + // Sort order: repost → like → reply + const keepPriority: Record = { + repost: 0, + reply: 1, + like: 2, + }; + + for (const [uri, interactions] of results) { + // Group by author, keep highest priority interaction per author + const byAuthor = new Map(); + for (const interaction of interactions) { + const existing = byAuthor.get(interaction.authorDid); + if ( + !existing || + keepPriority[interaction.type] < keepPriority[existing.type] + ) { + byAuthor.set(interaction.authorDid, interaction); + } + } + + // Bucket into 3 arrays by type (avoids sorting) + const repostBucket: KnownInteraction[] = []; + const likeBucket: KnownInteraction[] = []; + const replyBucket: KnownInteraction[] = []; + + for (const interaction of byAuthor.values()) { + if (interaction.type === "repost") repostBucket.push(interaction); + else if (interaction.type === "like") likeBucket.push(interaction); + else replyBucket.push(interaction); + } + + // Concatenate in desired order: repost → like → reply + results.set(uri, [...repostBucket, ...likeBucket, ...replyBucket]); + } + + return { results }; + } } diff --git a/hydration/feed.ts b/hydration/feed.ts index 4cbe1ab..128e23b 100644 --- a/hydration/feed.ts +++ b/hydration/feed.ts @@ -78,18 +78,23 @@ export type FeedGenViewerState = { export type FeedGenViewerStates = HydrationMap; +export type KnownInteractionState = { + type: "like" | "repost" | "reply"; + by: string; // DID of the person who interacted + uri: string; + cid: string; + indexedAt: Date; + text?: string; // Only for replies +}; + +export type KnownInteractionsStates = HydrationMap< + KnownInteractionState[] | undefined +>; + export type ThreadRef = ItemRef & { threadRoot: string }; -// @NOTE the feed item types in the protos for author feeds and timelines -// technically have additional fields, not supported by the mock dataplane. export type FeedItem = { post: ItemRef; - repost?: ItemRef; - /** - * If true, overrides the `reason` with `so.sprk.feed.defs#reasonPin`. Used - * only in author feeds. - */ - authorPinned?: boolean; }; export class FeedHydrator { @@ -323,4 +328,36 @@ export class FeedHydrator { return acc.set(uri, record ?? null); }, new HydrationMap()); } + + async getKnownInteractions( + refs: ItemRef[], + viewer: string | null, + ): Promise { + if (!viewer || !refs.length) { + return new HydrationMap(); + } + + const subjectUris = refs.map((ref) => ref.uri); + const { results } = await this.dataplane.interactions.getKnownInteractions( + viewer, + subjectUris, + ); + + return refs.reduce((acc, { uri }) => { + const interactions = results.get(uri); + return acc.set( + uri, + interactions && interactions.length > 0 + ? interactions.map((i) => ({ + type: i.type, + by: i.authorDid, + uri: i.uri, + cid: i.cid, + indexedAt: new Date(i.indexedAt), + text: i.text, + })) + : undefined, + ); + }, new HydrationMap()); + } } diff --git a/hydration/index.ts b/hydration/index.ts index 7782b89..e8a9820 100644 --- a/hydration/index.ts +++ b/hydration/index.ts @@ -1,5 +1,4 @@ import { assert } from "@std/assert"; -import { mapDefined } from "@atp/common"; import { AtUri } from "@atp/syntax"; import { DataPlane } from "../data-plane/index.ts"; import { ids } from "../lex/lexicons.ts"; @@ -19,6 +18,7 @@ import { FeedGenViewerStates, FeedHydrator, FeedItem, + KnownInteractionsStates, Likes, Post, PostAggs, @@ -115,6 +115,7 @@ export type HydrationState = { labelerViewers?: LabelerViewerStates; labelerAggs?: LabelerAggs; knownFollowers?: KnownFollowersStates; + knownInteractions?: KnownInteractionsStates; activitySubscriptions?: ActivitySubscriptionStates; bidirectionalBlocks?: BidirectionalBlocks; }; @@ -362,6 +363,38 @@ export class Hydrator { } } + // Fetch known interactions first so we can batch all profile hydration + const knownInteractions = await this.feed.getKnownInteractions( + refs, + ctx.viewer, + ); + + // Gather DIDs from known interactions for profile hydration + const knownInteractionDids = new Set(); + for (const interactions of knownInteractions.values()) { + if (interactions) { + for (const interaction of interactions) { + knownInteractionDids.add(interaction.by); + } + } + } + + // Combine all DIDs for a single batched profile hydration call + const allProfileDids = Array.from( + new Set([...authorDids, ...knownInteractionDids]), + ); + + // Build map for bidirectional block checking between post authors and interactors + const subjectsToInteractorsMap = new Map(); + for (const [uri, interactions] of knownInteractions) { + if (interactions && interactions.length > 0) { + subjectsToInteractorsMap.set( + didFromUri(uri), + interactions.map((i) => i.by), + ); + } + } + const [ postAggs, replyAggs, @@ -371,6 +404,7 @@ export class Hydrator { profileState, threadContexts, soundState, + interactionBlocks, ] = await Promise.all([ this.feed.getPostAggregates(postRefs), this.feed.getReplyAggregates(replyRefs), @@ -379,9 +413,10 @@ export class Hydrator { : Promise.resolve(undefined), this.label.getLabelsForSubjects(allUris, ctx.labelers), this.hydratePostBlocks(state.posts!, state.replies!), - this.hydrateProfiles(authorDids, ctx), + this.hydrateProfiles(allProfileDids, ctx), this.feed.getThreadContexts(threadRefs), this.hydrateSounds(Array.from(soundUris), ctx), + this.hydrateBidirectionalBlocks(subjectsToInteractorsMap), ]); return mergeManyStates( @@ -396,7 +431,9 @@ export class Hydrator { postBlocks, labels, threadContexts, + knownInteractions, ctx, + bidirectionalBlocks: interactionBlocks, }, ); } @@ -504,34 +541,13 @@ export class Hydrator { }); const postAndReplyRefs = Array.from(postAndReplyRefsMap.values()); - const repostUris = mapDefined(items, (item) => item.repost?.uri); const postState = await this.hydratePosts(postAndReplyRefs, ctx, { posts, replies, }); - const replyParentAuthors = Array.from( - new Set( - replyRefs - .map((ref) => - postState.replies?.get(ref.uri)?.record.reply?.parent.uri - ) - .filter((uri): uri is string => !!uri) - .map(didFromUri), - ), - ); - - const [repostProfileState, reposts] = await Promise.all([ - this.hydrateProfiles( - [...repostUris.map(didFromUri), ...replyParentAuthors], - ctx, - ), - this.feed.getReposts(repostUris, ctx.includeTakedowns), - ]); - - return mergeManyStates(postState, repostProfileState, { - reposts, + return mergeStates(postState, { ctx, }); } @@ -945,6 +961,10 @@ export const mergeStates = ( labelerViewers: mergeMaps(stateA.labelerViewers, stateB.labelerViewers), labelerAggs: mergeMaps(stateA.labelerAggs, stateB.labelerAggs), knownFollowers: mergeMaps(stateA.knownFollowers, stateB.knownFollowers), + knownInteractions: mergeMaps( + stateA.knownInteractions, + stateB.knownInteractions, + ), bidirectionalBlocks: mergeMaps( stateA.bidirectionalBlocks, stateB.bidirectionalBlocks, diff --git a/lex/lexicons.ts b/lex/lexicons.ts index 89f2e44..a44f0c1 100644 --- a/lex/lexicons.ts +++ b/lex/lexicons.ts @@ -17354,7 +17354,7 @@ export const schemaDict = { }, "viewer": { "type": "ref", - "ref": "lex:so.sprk.feed.defs#viewerState", + "ref": "lex:so.sprk.feed.defs#viewerStateBasic", }, "labels": { "type": "array", @@ -17365,6 +17365,26 @@ export const schemaDict = { }, }, }, + "viewerStateBasic": { + "type": "object", + "description": + "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", + "properties": { + "like": { + "type": "string", + "format": "at-uri", + }, + "threadMuted": { + "type": "boolean", + }, + "replyDisabled": { + "type": "boolean", + }, + "embeddingDisabled": { + "type": "boolean", + }, + }, + }, "viewerState": { "type": "object", "description": @@ -17390,6 +17410,97 @@ export const schemaDict = { "pinned": { "type": "boolean", }, + "knownInteractions": { + "type": "array", + "items": { + "type": "union", + "refs": [ + "lex:so.sprk.feed.defs#knownRepost", + "lex:so.sprk.feed.defs#knownLike", + "lex:so.sprk.feed.defs#knownReply", + ], + }, + }, + }, + }, + "knownRepost": { + "type": "object", + "required": [ + "by", + "indexedAt", + ], + "properties": { + "by": { + "type": "ref", + "ref": "lex:so.sprk.actor.defs#profileViewBasic", + }, + "uri": { + "type": "string", + "format": "at-uri", + }, + "cid": { + "type": "string", + "format": "cid", + }, + "indexedAt": { + "type": "string", + "format": "datetime", + }, + }, + }, + "knownLike": { + "type": "object", + "required": [ + "by", + "indexedAt", + ], + "properties": { + "by": { + "type": "ref", + "ref": "lex:so.sprk.actor.defs#profileViewBasic", + }, + "uri": { + "type": "string", + "format": "at-uri", + }, + "cid": { + "type": "string", + "format": "cid", + }, + "indexedAt": { + "type": "string", + "format": "datetime", + }, + }, + }, + "knownReply": { + "type": "object", + "required": [ + "by", + "indexedAt", + ], + "properties": { + "by": { + "type": "ref", + "ref": "lex:so.sprk.actor.defs#profileViewBasic", + }, + "uri": { + "type": "string", + "format": "at-uri", + }, + "cid": { + "type": "string", + "format": "cid", + }, + "indexedAt": { + "type": "string", + "format": "datetime", + }, + "text": { + "type": "string", + "maxLength": 3000, + "maxGraphemes": 300, + }, }, }, "threadContext": { @@ -17413,13 +17524,6 @@ export const schemaDict = { "type": "ref", "ref": "lex:so.sprk.feed.defs#postView", }, - "reason": { - "type": "union", - "refs": [ - "lex:so.sprk.feed.defs#reasonRepost", - "lex:so.sprk.feed.defs#reasonPin", - ], - }, "feedContext": { "type": "string", "description": @@ -17460,27 +17564,6 @@ export const schemaDict = { }, }, }, - "reasonRepost": { - "type": "object", - "required": [ - "by", - "indexedAt", - ], - "properties": { - "by": { - "type": "ref", - "ref": "lex:so.sprk.actor.defs#profileViewBasic", - }, - "indexedAt": { - "type": "string", - "format": "datetime", - }, - }, - }, - "reasonPin": { - "type": "object", - "properties": {}, - }, "threadViewPost": { "type": "object", "required": [ diff --git a/lex/types/so/sprk/feed/defs.ts b/lex/types/so/sprk/feed/defs.ts index f244a90..11d628d 100644 --- a/lex/types/so/sprk/feed/defs.ts +++ b/lex/types/so/sprk/feed/defs.ts @@ -53,7 +53,7 @@ export interface ReplyView { replyCount?: number; likeCount?: number; indexedAt: string; - viewer?: ViewerState; + viewer?: ViewerStateBasic; labels?: (ComAtprotoLabelDefs.Label)[]; } @@ -67,6 +67,25 @@ export function validateReplyView(v: V) { return validate(v, id, hashReplyView); } +/** Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests. */ +export interface ViewerStateBasic { + $type?: "so.sprk.feed.defs#viewerStateBasic"; + like?: string; + threadMuted?: boolean; + replyDisabled?: boolean; + embeddingDisabled?: boolean; +} + +const hashViewerStateBasic = "viewerStateBasic"; + +export function isViewerStateBasic(v: V) { + return is$typed(v, id, hashViewerStateBasic); +} + +export function validateViewerStateBasic(v: V) { + return validate(v, id, hashViewerStateBasic); +} + /** Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests. */ export interface ViewerState { $type?: "so.sprk.feed.defs#viewerState"; @@ -76,6 +95,10 @@ export interface ViewerState { replyDisabled?: boolean; embeddingDisabled?: boolean; pinned?: boolean; + knownInteractions?: + ($Typed | $Typed | $Typed | { + $type: string; + })[]; } const hashViewerState = "viewerState"; @@ -88,6 +111,61 @@ export function validateViewerState(v: V) { return validate(v, id, hashViewerState); } +export interface KnownRepost { + $type?: "so.sprk.feed.defs#knownRepost"; + by: SoSprkActorDefs.ProfileViewBasic; + uri?: string; + cid?: string; + indexedAt: string; +} + +const hashKnownRepost = "knownRepost"; + +export function isKnownRepost(v: V) { + return is$typed(v, id, hashKnownRepost); +} + +export function validateKnownRepost(v: V) { + return validate(v, id, hashKnownRepost); +} + +export interface KnownLike { + $type?: "so.sprk.feed.defs#knownLike"; + by: SoSprkActorDefs.ProfileViewBasic; + uri?: string; + cid?: string; + indexedAt: string; +} + +const hashKnownLike = "knownLike"; + +export function isKnownLike(v: V) { + return is$typed(v, id, hashKnownLike); +} + +export function validateKnownLike(v: V) { + return validate(v, id, hashKnownLike); +} + +export interface KnownReply { + $type?: "so.sprk.feed.defs#knownReply"; + by: SoSprkActorDefs.ProfileViewBasic; + uri?: string; + cid?: string; + indexedAt: string; + text?: string; +} + +const hashKnownReply = "knownReply"; + +export function isKnownReply(v: V) { + return is$typed(v, id, hashKnownReply); +} + +export function validateKnownReply(v: V) { + return validate(v, id, hashKnownReply); +} + /** Metadata about this post within the context of the thread it is in. */ export interface ThreadContext { $type?: "so.sprk.feed.defs#threadContext"; @@ -107,7 +185,6 @@ export function validateThreadContext(v: V) { export interface FeedViewPost { $type?: "so.sprk.feed.defs#feedViewPost"; post: PostView; - reason?: $Typed | $Typed | { $type: string }; /** Context provided by feed generator that may be passed back alongside interactions. */ feedContext?: string; } @@ -146,36 +223,6 @@ export function validateReplyRef(v: V) { return validate(v, id, hashReplyRef); } -export interface ReasonRepost { - $type?: "so.sprk.feed.defs#reasonRepost"; - by: SoSprkActorDefs.ProfileViewBasic; - indexedAt: string; -} - -const hashReasonRepost = "reasonRepost"; - -export function isReasonRepost(v: V) { - return is$typed(v, id, hashReasonRepost); -} - -export function validateReasonRepost(v: V) { - return validate(v, id, hashReasonRepost); -} - -export interface ReasonPin { - $type?: "so.sprk.feed.defs#reasonPin"; -} - -const hashReasonPin = "reasonPin"; - -export function isReasonPin(v: V) { - return is$typed(v, id, hashReasonPin); -} - -export function validateReasonPin(v: V) { - return validate(v, id, hashReasonPin); -} - export interface ThreadViewPost { $type?: "so.sprk.feed.defs#threadViewPost"; post: $Typed | $Typed | { $type: string }; diff --git a/lexicons/so/sprk/feed/defs.json b/lexicons/so/sprk/feed/defs.json index 55fa44b..af62c95 100644 --- a/lexicons/so/sprk/feed/defs.json +++ b/lexicons/so/sprk/feed/defs.json @@ -48,13 +48,23 @@ "replyCount": { "type": "integer" }, "likeCount": { "type": "integer" }, "indexedAt": { "type": "string", "format": "datetime" }, - "viewer": { "type": "ref", "ref": "#viewerState" }, + "viewer": { "type": "ref", "ref": "#viewerStateBasic" }, "labels": { "type": "array", "items": { "type": "ref", "ref": "com.atproto.label.defs#label" } } } }, + "viewerStateBasic": { + "type": "object", + "description": "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", + "properties": { + "like": { "type": "string", "format": "at-uri" }, + "threadMuted": { "type": "boolean" }, + "replyDisabled": { "type": "boolean" }, + "embeddingDisabled": { "type": "boolean" } + } + }, "viewerState": { "type": "object", "description": "Metadata about the requesting account's relationship with the subject content. Only has meaningful content for authed requests.", @@ -64,7 +74,45 @@ "threadMuted": { "type": "boolean" }, "replyDisabled": { "type": "boolean" }, "embeddingDisabled": { "type": "boolean" }, - "pinned": { "type": "boolean" } + "pinned": { "type": "boolean" }, + "knownInteractions": { + "type": "array", + "items": { + "type": "union", + "refs": ["#knownRepost", "#knownLike", "#knownReply"] + } + } + } + }, + "knownRepost": { + "type": "object", + "required": ["by", "indexedAt"], + "properties": { + "by": { "type": "ref", "ref": "so.sprk.actor.defs#profileViewBasic" }, + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { "type": "string", "format": "datetime" } + } + }, + "knownLike": { + "type": "object", + "required": ["by", "indexedAt"], + "properties": { + "by": { "type": "ref", "ref": "so.sprk.actor.defs#profileViewBasic" }, + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { "type": "string", "format": "datetime" } + } + }, + "knownReply": { + "type": "object", + "required": ["by", "indexedAt"], + "properties": { + "by": { "type": "ref", "ref": "so.sprk.actor.defs#profileViewBasic" }, + "uri": { "type": "string", "format": "at-uri" }, + "cid": { "type": "string", "format": "cid" }, + "indexedAt": { "type": "string", "format": "datetime" }, + "text": { "type": "string", "maxLength": 3000, "maxGraphemes": 300 } } }, "threadContext": { @@ -79,7 +127,6 @@ "required": ["post"], "properties": { "post": { "type": "ref", "ref": "#postView" }, - "reason": { "type": "union", "refs": ["#reasonRepost", "#reasonPin"] }, "feedContext": { "type": "string", "description": "Context provided by feed generator that may be passed back alongside interactions.", @@ -106,18 +153,6 @@ } } }, - "reasonRepost": { - "type": "object", - "required": ["by", "indexedAt"], - "properties": { - "by": { "type": "ref", "ref": "so.sprk.actor.defs#profileViewBasic" }, - "indexedAt": { "type": "string", "format": "datetime" } - } - }, - "reasonPin": { - "type": "object", - "properties": {} - }, "threadViewPost": { "type": "object", "required": ["post"], @@ -125,11 +160,7 @@ "post": { "type": "union", "refs": ["#postView", "#replyView"] }, "parent": { "type": "union", - "refs": [ - "#threadViewPost", - "#notFoundPost", - "#blockedPost" - ] + "refs": ["#threadViewPost", "#notFoundPost", "#blockedPost"] }, "replies": { "type": "array", @@ -206,10 +237,6 @@ "required": ["post"], "properties": { "post": { "type": "string", "format": "at-uri" }, - "reason": { - "type": "union", - "refs": ["#skeletonReasonRepost", "#skeletonReasonPin"] - }, "feedContext": { "type": "string", "description": "Context that will be passed through to client and may be passed to feed generator back alongside interactions.", @@ -217,17 +244,6 @@ } } }, - "skeletonReasonRepost": { - "type": "object", - "required": ["repost"], - "properties": { - "repost": { "type": "string", "format": "at-uri" } - } - }, - "skeletonReasonPin": { - "type": "object", - "properties": {} - }, "threadgateView": { "type": "object", "properties": { diff --git a/views/index.ts b/views/index.ts index c43bf39..e63f413 100644 --- a/views/index.ts +++ b/views/index.ts @@ -15,9 +15,10 @@ import { FeedViewPost, isPostView, isReplyView, + KnownLike, + KnownReply, + KnownRepost, PostView, - ReasonPin, - ReasonRepost, ReplyRef, ReplyView, ThreadContext, @@ -65,7 +66,7 @@ import { Main as StrongRef } from "../lex/types/com/atproto/repo/strongRef.ts"; import { cidFromBlobJson } from "./util.ts"; import { uriToDid } from "../utils/uris.ts"; import { mapDefined } from "@atp/common"; -import { FeedItem, Repost } from "../hydration/feed.ts"; +import { FeedItem } from "../hydration/feed.ts"; import { QueryParams as GetThreadQueryParams, ThreadItem, @@ -360,6 +361,7 @@ export class Views { ? { repost: viewer.repost, like: viewer.like, + knownInteractions: this.knownInteractions(uri, state), } : undefined, }; @@ -395,7 +397,6 @@ export class Views { new Date().toISOString(), viewer: viewer ? { - repost: viewer.repost, like: viewer.like, } : undefined, @@ -502,21 +503,10 @@ export class Views { item: FeedItem, state: HydrationState, ): FeedViewPost | undefined { - let reason; - if (item.authorPinned) { - reason = this.reasonPin(); - } else if (item.repost) { - const repost = state.reposts?.get(item.repost.uri); - if (!repost || !repost?.record.subject) return; - if (repost.record.subject.uri !== item.post.uri) return; - reason = this.reasonRepost(item.repost.uri, repost, state); - if (!reason) return; - } const post = this.post(item.post.uri, state); if (!post) return; return { post, - reason, }; } @@ -569,27 +559,6 @@ export class Views { }; } - reasonPin(): $Typed { - return { - $type: "so.sprk.feed.defs#reasonPin", - }; - } - - reasonRepost( - uri: string, - repost: Repost, - state: HydrationState, - ): $Typed | undefined { - const creatorDid = uriToDid(uri); - const creator = this.profileBasic(creatorDid, state); - if (!creator) return; - return { - $type: "so.sprk.feed.defs#reasonRepost", - by: creator, - indexedAt: this.indexedAt(repost).toISOString(), - }; - } - maybePost(uri: string, state: HydrationState): $Typed { const reply = this.reply(uri, state); if (reply) { @@ -637,17 +606,12 @@ export class Views { item: FeedItem, state: HydrationState, ): { - originatorMuted: boolean; - originatorBlocked: boolean; authorMuted: boolean; authorBlocked: boolean; } { const authorDid = uriToDid(item.post.uri); - const originatorDid = item.repost ? uriToDid(item.repost.uri) : authorDid; return { - originatorMuted: this.viewerMuteExists(originatorDid, state), - originatorBlocked: this.viewerBlockExists(originatorDid, state), authorMuted: this.viewerMuteExists(authorDid, state), authorBlocked: this.viewerBlockExists(authorDid, state), }; @@ -816,6 +780,57 @@ export class Views { return { count: knownFollowers.count, followers }; } + knownInteractions( + uri: string, + state: HydrationState, + ): + | ($Typed | $Typed | $Typed)[] + | undefined { + const interactions = state.knownInteractions?.get(uri); + if (!interactions || interactions.length === 0) return undefined; + + const postAuthorDid = uriToDid(uri); + const blocks = state.bidirectionalBlocks?.get(postAuthorDid); + + const result = mapDefined(interactions, (interaction) => { + // Filter blocked users + if (this.viewerBlockExists(interaction.by, state)) return undefined; + if (blocks?.get(interaction.by)) return undefined; + if (this.actorIsNoHosted(interaction.by, state)) return undefined; + + const by = this.profileBasic(interaction.by, state); + if (!by) return undefined; + + const base = { + by, + uri: interaction.uri, + cid: interaction.cid, + indexedAt: interaction.indexedAt.toISOString(), + }; + + switch (interaction.type) { + case "like": + return { + $type: "so.sprk.feed.defs#knownLike" as const, + ...base, + }; + case "repost": + return { + $type: "so.sprk.feed.defs#knownRepost" as const, + ...base, + }; + case "reply": + return { + $type: "so.sprk.feed.defs#knownReply" as const, + ...base, + text: interaction.text, + }; + } + }); + + return result.length > 0 ? result : undefined; + } + media( postUri: string, media: Media | { $type: string },