From 7edad62c12038de09452f8e005be5a1a75e18873 Mon Sep 17 00:00:00 2001 From: Daniel Holmgren Date: Mon, 27 Nov 2023 20:14:20 -0600 Subject: [PATCH] Put canReply state on post viewer state instead of thread viewer state (#1882) * switch canReply from thread to post view * tweaks & fix up tests * update snaps * fix more snaps * hydrate feed items for getPosts & searchPosts * fix another snapshot * getPosts test * canReply -> blockedByGate & DRY up code * blockedByGate -> excludedByGate * replyDisabled --- lexicons/app/bsky/feed/defs.json | 12 +--- packages/api/src/client/lexicons.ts | 15 +--- .../src/client/types/app/bsky/feed/defs.ts | 19 +---- .../src/api/app/bsky/feed/getActorLikes.ts | 4 +- .../src/api/app/bsky/feed/getAuthorFeed.ts | 4 +- .../bsky/src/api/app/bsky/feed/getFeed.ts | 4 +- .../bsky/src/api/app/bsky/feed/getListFeed.ts | 4 +- .../src/api/app/bsky/feed/getPostThread.ts | 69 ++++-------------- .../bsky/src/api/app/bsky/feed/getPosts.ts | 39 ++++++----- .../bsky/src/api/app/bsky/feed/getTimeline.ts | 4 +- .../bsky/src/api/app/bsky/feed/searchPosts.ts | 37 +++++----- .../bsky/src/api/app/bsky/graph/getList.ts | 2 +- .../src/api/app/bsky/graph/getListBlocks.ts | 2 +- packages/bsky/src/lexicon/lexicons.ts | 15 +--- .../src/lexicon/types/app/bsky/feed/defs.ts | 19 +---- packages/bsky/src/services/actor/views.ts | 19 ++--- packages/bsky/src/services/feed/index.ts | 9 ++- packages/bsky/src/services/feed/util.ts | 66 ++++++++++++----- packages/bsky/src/services/feed/views.ts | 67 +++++++++++++++++- packages/bsky/src/services/graph/index.ts | 6 ++ packages/bsky/src/services/graph/types.ts | 1 + .../src/services/indexing/plugins/post.ts | 2 +- .../tests/__snapshots__/indexing.test.ts.snap | 6 -- .../__snapshots__/block-lists.test.ts.snap | 9 --- .../views/__snapshots__/blocks.test.ts.snap | 9 --- .../__snapshots__/mute-lists.test.ts.snap | 3 - .../views/__snapshots__/mutes.test.ts.snap | 3 - .../views/__snapshots__/thread.test.ts.snap | 30 -------- .../bsky/tests/views/threadgating.test.ts | 70 ++++++++++++++----- packages/pds/src/lexicon/lexicons.ts | 15 +--- .../src/lexicon/types/app/bsky/feed/defs.ts | 19 +---- 31 files changed, 261 insertions(+), 322 deletions(-) diff --git a/lexicons/app/bsky/feed/defs.json b/lexicons/app/bsky/feed/defs.json index 10f2812ce24..15a7cb7a719 100644 --- a/lexicons/app/bsky/feed/defs.json +++ b/lexicons/app/bsky/feed/defs.json @@ -38,7 +38,8 @@ "type": "object", "properties": { "repost": { "type": "string", "format": "at-uri" }, - "like": { "type": "string", "format": "at-uri" } + "like": { "type": "string", "format": "at-uri" }, + "replyDisabled": { "type": "boolean" } } }, "feedViewPost": { @@ -87,8 +88,7 @@ "type": "union", "refs": ["#threadViewPost", "#notFoundPost", "#blockedPost"] } - }, - "viewer": { "type": "ref", "ref": "#viewerThreadState" } + } } }, "notFoundPost": { @@ -116,12 +116,6 @@ "viewer": { "type": "ref", "ref": "app.bsky.actor.defs#viewerState" } } }, - "viewerThreadState": { - "type": "object", - "properties": { - "canReply": { "type": "boolean" } - } - }, "generatorView": { "type": "object", "required": ["uri", "cid", "did", "creator", "displayName", "indexedAt"], diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index 14c1be9f477..cc3f09f0c4e 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -4892,6 +4892,9 @@ export const schemaDict = { type: 'string', format: 'at-uri', }, + replyDisabled: { + type: 'boolean', + }, }, }, feedViewPost: { @@ -4975,10 +4978,6 @@ export const schemaDict = { ], }, }, - viewer: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#viewerThreadState', - }, }, }, notFoundPost: { @@ -5027,14 +5026,6 @@ export const schemaDict = { }, }, }, - viewerThreadState: { - type: 'object', - properties: { - canReply: { - type: 'boolean', - }, - }, - }, generatorView: { type: 'object', required: ['uri', 'cid', 'did', 'creator', 'displayName', 'indexedAt'], diff --git a/packages/api/src/client/types/app/bsky/feed/defs.ts b/packages/api/src/client/types/app/bsky/feed/defs.ts index 944fd34b072..82cbfd9951a 100644 --- a/packages/api/src/client/types/app/bsky/feed/defs.ts +++ b/packages/api/src/client/types/app/bsky/feed/defs.ts @@ -48,6 +48,7 @@ export function validatePostView(v: unknown): ValidationResult { export interface ViewerState { repost?: string like?: string + replyDisabled?: boolean [k: string]: unknown } @@ -137,7 +138,6 @@ export interface ThreadViewPost { | BlockedPost | { $type: string; [k: string]: unknown } )[] - viewer?: ViewerThreadState [k: string]: unknown } @@ -208,23 +208,6 @@ export function validateBlockedAuthor(v: unknown): ValidationResult { return lexicons.validate('app.bsky.feed.defs#blockedAuthor', v) } -export interface ViewerThreadState { - canReply?: boolean - [k: string]: unknown -} - -export function isViewerThreadState(v: unknown): v is ViewerThreadState { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'app.bsky.feed.defs#viewerThreadState' - ) -} - -export function validateViewerThreadState(v: unknown): ValidationResult { - return lexicons.validate('app.bsky.feed.defs#viewerThreadState', v) -} - export interface GeneratorView { uri: string cid: string diff --git a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts index 3da1b0d042a..36e36b0100b 100644 --- a/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts +++ b/packages/bsky/src/api/app/bsky/feed/getActorLikes.ts @@ -107,9 +107,7 @@ const noPostBlocks = (state: HydrationState) => { const presentation = (state: HydrationState, ctx: Context) => { const { feedService } = ctx const { feedItems, cursor, params } = state - const feed = feedService.views.formatFeed(feedItems, state, { - viewer: params.viewer, - }) + const feed = feedService.views.formatFeed(feedItems, state, params.viewer) return { feed, cursor } } diff --git a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts index c71ddd45791..26b945f3ecd 100644 --- a/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getAuthorFeed.ts @@ -147,9 +147,7 @@ const noBlocksOrMutedReposts = (state: HydrationState) => { const presentation = (state: HydrationState, ctx: Context) => { const { feedService } = ctx const { feedItems, cursor, params } = state - const feed = feedService.views.formatFeed(feedItems, state, { - viewer: params.viewer, - }) + const feed = feedService.views.formatFeed(feedItems, state, params.viewer) return { feed, cursor } } diff --git a/packages/bsky/src/api/app/bsky/feed/getFeed.ts b/packages/bsky/src/api/app/bsky/feed/getFeed.ts index bfae2caa2f5..a09258c3163 100644 --- a/packages/bsky/src/api/app/bsky/feed/getFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getFeed.ts @@ -113,9 +113,7 @@ const noBlocksOrMutes = (state: HydrationState) => { const presentation = (state: HydrationState, ctx: Context) => { const { feedService } = ctx const { feedItems, cursor, passthrough, params } = state - const feed = feedService.views.formatFeed(feedItems, state, { - viewer: params.viewer, - }) + const feed = feedService.views.formatFeed(feedItems, state, params.viewer) return { feed, cursor, diff --git a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts index f166c8abb99..fd3f0360ef3 100644 --- a/packages/bsky/src/api/app/bsky/feed/getListFeed.ts +++ b/packages/bsky/src/api/app/bsky/feed/getListFeed.ts @@ -105,9 +105,7 @@ const noBlocksOrMutes = (state: HydrationState) => { const presentation = (state: HydrationState, ctx: Context) => { const { feedService } = ctx const { feedItems, cursor, params } = state - const feed = feedService.views.formatFeed(feedItems, state, { - viewer: params.viewer, - }) + const feed = feedService.views.formatFeed(feedItems, state, params.viewer) return { feed, cursor } } diff --git a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts index 0e31107d052..873dd311ba0 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPostThread.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPostThread.ts @@ -6,27 +6,21 @@ import { NotFoundPost, ThreadViewPost, isNotFoundPost, - isThreadViewPost, } from '../../../../lexicon/types/app/bsky/feed/defs' -import { Record as PostRecord } from '../../../../lexicon/types/app/bsky/feed/post' -import { Record as ThreadgateRecord } from '../../../../lexicon/types/app/bsky/feed/threadgate' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getPostThread' import AppContext from '../../../../context' import { FeedService, FeedRow, FeedHydrationState, - PostInfo, } from '../../../../services/feed' import { getAncestorsAndSelfQb, getDescendentsQb, } from '../../../../services/util/post' import { Database } from '../../../../db' -import DatabaseSchema from '../../../../db/database-schema' import { setRepoRev } from '../../../util' import { ActorInfoMap, ActorService } from '../../../../services/actor' -import { violatesThreadGate } from '../../../../services/feed/util' import { createPipeline, noRules } from '../../../../pipeline' export default function (server: Server, ctx: AppContext) { @@ -80,21 +74,7 @@ const hydration = async (state: SkeletonState, ctx: Context) => { } = state const relevant = getRelevantIds(threadData) const hydrated = await feedService.feedHydration({ ...relevant, viewer }) - // check root reply interaction rules - const anchorPostUri = threadData.post.postUri - const rootUri = threadData.post.replyRoot || anchorPostUri - const anchor = hydrated.posts[anchorPostUri] - const root = hydrated.posts[rootUri] - const gate = hydrated.threadgates[rootUri]?.record - const viewerCanReply = await checkViewerCanReply( - ctx.db.db, - anchor ?? null, - viewer, - new AtUri(rootUri).host, - (root?.record ?? null) as PostRecord | null, - gate ?? null, - ) - return { ...state, ...hydrated, viewerCanReply } + return { ...state, ...hydrated } } const presentation = (state: HydrationState, ctx: Context) => { @@ -103,16 +83,19 @@ const presentation = (state: HydrationState, ctx: Context) => { const actors = actorService.views.profileBasicPresentation( Object.keys(profiles), state, - { viewer: params.viewer }, + params.viewer, + ) + const thread = composeThread( + state.threadData, + actors, + state, + ctx, + params.viewer, ) - const thread = composeThread(state.threadData, actors, state, ctx) if (isNotFoundPost(thread)) { // @TODO technically this could be returned as a NotFoundPost based on lexicon throw new InvalidRequestError(`Post not found: ${params.uri}`, 'NotFound') } - if (isThreadViewPost(thread) && params.viewer) { - thread.viewer = { canReply: state.viewerCanReply } - } return { thread } } @@ -121,6 +104,7 @@ const composeThread = ( actors: ActorInfoMap, state: HydrationState, ctx: Context, + viewer: string | null, ) => { const { feedService } = ctx const { posts, threadgates, embeds, blocks, labels, lists } = state @@ -133,6 +117,7 @@ const composeThread = ( embeds, labels, lists, + viewer, ) // replies that are invalid due to reply-gating: @@ -179,14 +164,14 @@ const composeThread = ( notFound: true, } } else { - parent = composeThread(threadData.parent, actors, state, ctx) + parent = composeThread(threadData.parent, actors, state, ctx, viewer) } } let replies: (ThreadViewPost | NotFoundPost | BlockedPost)[] | undefined if (threadData.replies && !badReply) { replies = threadData.replies.flatMap((reply) => { - const thread = composeThread(reply, actors, state, ctx) + const thread = composeThread(reply, actors, state, ctx, viewer) // e.g. don't bother including #postNotFound reply placeholders for takedowns. either way matches api contract. const skip = [] return isNotFoundPost(thread) ? skip : thread @@ -223,6 +208,7 @@ const getRelevantIds = ( if (thread.post.replyRoot) { // ensure root is included for checking interactions uris.add(thread.post.replyRoot) + dids.add(new AtUri(thread.post.replyRoot).hostname) } return { dids, uris } } @@ -317,28 +303,6 @@ const getChildrenData = ( })) } -const checkViewerCanReply = async ( - db: DatabaseSchema, - anchor: PostInfo | null, - viewer: string | null, - owner: string, - root: PostRecord | null, - threadgate: ThreadgateRecord | null, -) => { - if (!viewer) return false - // @TODO re-enable invalidReplyRoot check - // if (anchor?.invalidReplyRoot || anchor?.violatesThreadGate) return false - if (anchor?.violatesThreadGate) return false - const viewerViolatesThreadGate = await violatesThreadGate( - db, - viewer, - owner, - root, - threadgate, - ) - return !viewerViolatesThreadGate -} - class ParentNotFoundError extends Error { constructor(public uri: string) { super(`Parent not found: ${uri}`) @@ -364,7 +328,4 @@ type SkeletonState = { threadData: PostThread } -type HydrationState = SkeletonState & - FeedHydrationState & { - viewerCanReply: boolean - } +type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getPosts.ts b/packages/bsky/src/api/app/bsky/feed/getPosts.ts index 90268e5f161..5ec4807accb 100644 --- a/packages/bsky/src/api/app/bsky/feed/getPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/getPosts.ts @@ -1,10 +1,13 @@ import { dedupeStrs } from '@atproto/common' -import { AtUri } from '@atproto/syntax' import { Server } from '../../../../lexicon' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/getPosts' import AppContext from '../../../../context' import { Database } from '../../../../db' -import { FeedHydrationState, FeedService } from '../../../../services/feed' +import { + FeedHydrationState, + FeedRow, + FeedService, +} from '../../../../services/feed' import { createPipeline } from '../../../../pipeline' import { ActorService } from '../../../../services/actor' @@ -31,18 +34,18 @@ export default function (server: Server, ctx: AppContext) { }) } -const skeleton = async (params: Params) => { - return { params, postUris: dedupeStrs(params.uris) } +const skeleton = async (params: Params, ctx: Context) => { + const deduped = dedupeStrs(params.uris) + const feedItems = await ctx.feedService.postUrisToFeedItems(deduped) + return { params, feedItems } } const hydration = async (state: SkeletonState, ctx: Context) => { const { feedService } = ctx - const { params, postUris } = state - const uris = new Set(postUris) - const dids = new Set(postUris.map((uri) => new AtUri(uri).hostname)) + const { params, feedItems } = state + const refs = feedService.feedItemRefs(feedItems) const hydrated = await feedService.feedHydration({ - uris, - dids, + ...refs, viewer: params.viewer, }) return { ...state, ...hydrated } @@ -50,32 +53,32 @@ const hydration = async (state: SkeletonState, ctx: Context) => { const noBlocks = (state: HydrationState) => { const { viewer } = state.params - state.postUris = state.postUris.filter((uri) => { - const post = state.posts[uri] - if (!viewer || !post) return true - return !state.bam.block([viewer, post.creator]) + state.feedItems = state.feedItems.filter((item) => { + if (!viewer) return true + return !state.bam.block([viewer, item.postAuthorDid]) }) return state } const presentation = (state: HydrationState, ctx: Context) => { const { feedService, actorService } = ctx - const { postUris, profiles, params } = state + const { feedItems, profiles, params } = state const SKIP = [] const actors = actorService.views.profileBasicPresentation( Object.keys(profiles), state, - { viewer: params.viewer }, + params.viewer, ) - const postViews = postUris.flatMap((uri) => { + const postViews = feedItems.flatMap((item) => { const postView = feedService.views.formatPostView( - uri, + item.postUri, actors, state.posts, state.threadgates, state.embeds, state.labels, state.lists, + params.viewer, ) return postView ?? SKIP }) @@ -92,7 +95,7 @@ type Params = QueryParams & { viewer: string | null } type SkeletonState = { params: Params - postUris: string[] + feedItems: FeedRow[] } type HydrationState = SkeletonState & FeedHydrationState diff --git a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts index 9609ed6db42..18cc5c2629a 100644 --- a/packages/bsky/src/api/app/bsky/feed/getTimeline.ts +++ b/packages/bsky/src/api/app/bsky/feed/getTimeline.ts @@ -146,9 +146,7 @@ const noBlocksOrMutes = (state: HydrationState): HydrationState => { const presentation = (state: HydrationState, ctx: Context) => { const { feedService } = ctx const { feedItems, cursor, params } = state - const feed = feedService.views.formatFeed(feedItems, state, { - viewer: params.viewer, - }) + const feed = feedService.views.formatFeed(feedItems, state, params.viewer) return { feed, cursor } } diff --git a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts index 718bfa7afa4..db143fc5b8c 100644 --- a/packages/bsky/src/api/app/bsky/feed/searchPosts.ts +++ b/packages/bsky/src/api/app/bsky/feed/searchPosts.ts @@ -2,11 +2,14 @@ import AppContext from '../../../../context' import { Server } from '../../../../lexicon' import { InvalidRequestError } from '@atproto/xrpc-server' import AtpAgent from '@atproto/api' -import { AtUri } from '@atproto/syntax' import { mapDefined } from '@atproto/common' import { QueryParams } from '../../../../lexicon/types/app/bsky/feed/searchPosts' import { Database } from '../../../../db' -import { FeedHydrationState, FeedService } from '../../../../services/feed' +import { + FeedHydrationState, + FeedRow, + FeedService, +} from '../../../../services/feed' import { ActorService } from '../../../../services/actor' import { createPipeline } from '../../../../pipeline' @@ -51,9 +54,11 @@ const skeleton = async ( cursor: params.cursor, limit: params.limit, }) + const postUris = res.data.posts.map((a) => a.uri) + const feedItems = await ctx.feedService.postUrisToFeedItems(postUris) return { params, - postUris: res.data.posts.map((a) => a.uri), + feedItems, cursor: res.data.cursor, hitsTotal: res.data.hitsTotal, } @@ -64,12 +69,10 @@ const hydration = async ( ctx: Context, ): Promise => { const { feedService } = ctx - const { params, postUris } = state - const uris = new Set(postUris) - const dids = new Set(postUris.map((uri) => new AtUri(uri).hostname)) + const { params, feedItems } = state + const refs = feedService.feedItemRefs(feedItems) const hydrated = await feedService.feedHydration({ - uris, - dids, + ...refs, viewer: params.viewer, }) return { ...state, ...hydrated } @@ -77,32 +80,32 @@ const hydration = async ( const noBlocks = (state: HydrationState): HydrationState => { const { viewer } = state.params - state.postUris = state.postUris.filter((uri) => { - const post = state.posts[uri] - if (!viewer || !post) return true - return !state.bam.block([viewer, post.creator]) + state.feedItems = state.feedItems.filter((item) => { + if (!viewer) return true + return !state.bam.block([viewer, item.postAuthorDid]) }) return state } const presentation = (state: HydrationState, ctx: Context) => { const { feedService, actorService } = ctx - const { postUris, profiles, params } = state + const { feedItems, profiles, params } = state const actors = actorService.views.profileBasicPresentation( Object.keys(profiles), state, - { viewer: params.viewer }, + params.viewer, ) - const postViews = mapDefined(postUris, (uri) => + const postViews = mapDefined(feedItems, (item) => feedService.views.formatPostView( - uri, + item.postUri, actors, state.posts, state.threadgates, state.embeds, state.labels, state.lists, + params.viewer, ), ) return { posts: postViews, cursor: state.cursor, hitsTotal: state.hitsTotal } @@ -119,7 +122,7 @@ type Params = QueryParams & { viewer: string | null } type SkeletonState = { params: Params - postUris: string[] + feedItems: FeedRow[] hitsTotal?: number cursor?: string } diff --git a/packages/bsky/src/api/app/bsky/graph/getList.ts b/packages/bsky/src/api/app/bsky/graph/getList.ts index 1e6775d01cb..82963183a74 100644 --- a/packages/bsky/src/api/app/bsky/graph/getList.ts +++ b/packages/bsky/src/api/app/bsky/graph/getList.ts @@ -91,7 +91,7 @@ const presentation = (state: HydrationState, ctx: Context) => { const actors = actorService.views.profilePresentation( Object.keys(profileState.profiles), profileState, - { viewer: params.viewer }, + params.viewer, ) const creator = actors[list.creator] if (!creator) { diff --git a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts index 0884005b244..a41c952508b 100644 --- a/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts +++ b/packages/bsky/src/api/app/bsky/graph/getListBlocks.ts @@ -87,7 +87,7 @@ const presentation = (state: HydrationState, ctx: Context) => { const actors = actorService.views.profilePresentation( Object.keys(profileState.profiles), profileState, - { viewer: params.viewer }, + params.viewer, ) const lists = listInfos.map((list) => graphService.formatListView(list, actors), diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index 14c1be9f477..cc3f09f0c4e 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -4892,6 +4892,9 @@ export const schemaDict = { type: 'string', format: 'at-uri', }, + replyDisabled: { + type: 'boolean', + }, }, }, feedViewPost: { @@ -4975,10 +4978,6 @@ export const schemaDict = { ], }, }, - viewer: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#viewerThreadState', - }, }, }, notFoundPost: { @@ -5027,14 +5026,6 @@ export const schemaDict = { }, }, }, - viewerThreadState: { - type: 'object', - properties: { - canReply: { - type: 'boolean', - }, - }, - }, generatorView: { type: 'object', required: ['uri', 'cid', 'did', 'creator', 'displayName', 'indexedAt'], diff --git a/packages/bsky/src/lexicon/types/app/bsky/feed/defs.ts b/packages/bsky/src/lexicon/types/app/bsky/feed/defs.ts index 08d34d88ebb..382d3f58ecf 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/feed/defs.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/feed/defs.ts @@ -48,6 +48,7 @@ export function validatePostView(v: unknown): ValidationResult { export interface ViewerState { repost?: string like?: string + replyDisabled?: boolean [k: string]: unknown } @@ -137,7 +138,6 @@ export interface ThreadViewPost { | BlockedPost | { $type: string; [k: string]: unknown } )[] - viewer?: ViewerThreadState [k: string]: unknown } @@ -208,23 +208,6 @@ export function validateBlockedAuthor(v: unknown): ValidationResult { return lexicons.validate('app.bsky.feed.defs#blockedAuthor', v) } -export interface ViewerThreadState { - canReply?: boolean - [k: string]: unknown -} - -export function isViewerThreadState(v: unknown): v is ViewerThreadState { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'app.bsky.feed.defs#viewerThreadState' - ) -} - -export function validateViewerThreadState(v: unknown): ValidationResult { - return lexicons.validate('app.bsky.feed.defs#viewerThreadState', v) -} - export interface GeneratorView { uri: string cid: string diff --git a/packages/bsky/src/services/actor/views.ts b/packages/bsky/src/services/actor/views.ts index 7118671bd04..5c40eac308b 100644 --- a/packages/bsky/src/services/actor/views.ts +++ b/packages/bsky/src/services/actor/views.ts @@ -45,10 +45,7 @@ export class ActorViews { viewer, ...opts, }) - return this.profilePresentation(dids, hydrated, { - viewer, - ...opts, - }) + return this.profilePresentation(dids, hydrated, viewer) } async profilesBasic( @@ -62,10 +59,7 @@ export class ActorViews { viewer, includeSoftDeleted: opts?.includeSoftDeleted, }) - return this.profileBasicPresentation(dids, hydrated, { - viewer, - omitLabels: opts?.omitLabels, - }) + return this.profileBasicPresentation(dids, hydrated, viewer, opts) } async profilesList( @@ -293,11 +287,8 @@ export class ActorViews { labels: Labels bam: BlockAndMuteState }, - opts?: { - viewer?: string | null - }, + viewer: string | null, ): ProfileViewMap { - const { viewer } = opts ?? {} const { profiles, lists, labels, bam } = state return dids.reduce((acc, did) => { const prof = profiles[did] @@ -357,12 +348,12 @@ export class ActorViews { profileBasicPresentation( dids: string[], state: ProfileHydrationState, + viewer: string | null, opts?: { - viewer?: string | null omitLabels?: boolean }, ): ProfileViewMap { - const result = this.profilePresentation(dids, state, opts) + const result = this.profilePresentation(dids, state, viewer) return Object.values(result).reduce((acc, prof) => { const profileBasic = { did: prof.did, diff --git a/packages/bsky/src/services/feed/index.ts b/packages/bsky/src/services/feed/index.ts index dab9673d9db..f7fe7b2d817 100644 --- a/packages/bsky/src/services/feed/index.ts +++ b/packages/bsky/src/services/feed/index.ts @@ -44,6 +44,7 @@ import { import { FeedViews } from './views' import { LabelCache } from '../../label-cache' import { threadgateToPostUri, postToThreadgateUri } from './util' +import { mapDefined } from '@atproto/common' export * from './types' @@ -205,6 +206,11 @@ export class FeedService { }, {} as Record) } + async postUrisToFeedItems(uris: string[]): Promise { + const feedItems = await this.getFeedItems(uris) + return mapDefined(uris, (uri) => feedItems[uri]) + } + feedItemRefs(items: FeedRow[]) { const actorDids = new Set() const postUris = new Set() @@ -399,7 +405,7 @@ export class FeedService { const actorInfos = this.services.actor.views.profileBasicPresentation( [...nestedDids], feedState, - { viewer }, + viewer, ) const recordEmbedViews: RecordEmbedViewRecordMap = {} for (const uri of nestedUris) { @@ -423,6 +429,7 @@ export class FeedService { feedState.embeds, feedState.labels, feedState.lists, + viewer, ) recordEmbedViews[uri] = this.views.getRecordEmbedView( uri, diff --git a/packages/bsky/src/services/feed/util.ts b/packages/bsky/src/services/feed/util.ts index b2e2ce8d92d..83b5e59d705 100644 --- a/packages/bsky/src/services/feed/util.ts +++ b/packages/bsky/src/services/feed/util.ts @@ -36,43 +36,72 @@ export const invalidReplyRoot = ( return parent.record.reply?.root.uri !== replyRoot } -export const violatesThreadGate = async ( - db: DatabaseSchema, - did: string, - owner: string, - root: PostRecord | null, +type ParsedThreadGate = { + canReply?: boolean + allowMentions?: boolean + allowFollowing?: boolean + allowListUris?: string[] +} + +export const parseThreadGate = ( + replierDid: string, + ownerDid: string, + rootPost: PostRecord | null, gate: GateRecord | null, -) => { - if (did === owner) return false - if (!gate?.allow) return false +): ParsedThreadGate => { + if (replierDid === ownerDid) { + return { canReply: true } + } + // if gate.allow is unset then *any* reply is allowed, if it is an empty array then *no* reply is allowed + if (!gate || !gate.allow) { + return { canReply: true } + } - const allowMentions = gate.allow.find(isMentionRule) - const allowFollowing = gate.allow.find(isFollowingRule) + const allowMentions = !!gate.allow.find(isMentionRule) + const allowFollowing = !!gate.allow.find(isFollowingRule) const allowListUris = gate.allow?.filter(isListRule).map((item) => item.list) // check mentions first since it's quick and synchronous if (allowMentions) { - const isMentioned = root?.facets?.some((facet) => { - return facet.features.some((item) => isMention(item) && item.did === did) + const isMentioned = rootPost?.facets?.some((facet) => { + return facet.features.some( + (item) => isMention(item) && item.did === replierDid, + ) }) if (isMentioned) { - return false + return { canReply: true, allowMentions, allowFollowing, allowListUris } } } + return { allowMentions, allowFollowing, allowListUris } +} - // check follows and list containment - if (!allowFollowing && !allowListUris.length) { +export const violatesThreadGate = async ( + db: DatabaseSchema, + replierDid: string, + ownerDid: string, + rootPost: PostRecord | null, + gate: GateRecord | null, +) => { + const { + canReply, + allowFollowing, + allowListUris = [], + } = parseThreadGate(replierDid, ownerDid, rootPost, gate) + if (canReply) { + return false + } + if (!allowFollowing && !allowListUris?.length) { return true } const { ref } = db.dynamic const nullResult = sql`${null}` const check = await db - .selectFrom(valuesList([did]).as(sql`subject (did)`)) + .selectFrom(valuesList([replierDid]).as(sql`subject (did)`)) .select([ allowFollowing ? db .selectFrom('follow') - .where('creator', '=', owner) + .where('creator', '=', ownerDid) .whereRef('subjectDid', '=', ref('subject.did')) .select('creator') .as('isFollowed') @@ -91,8 +120,7 @@ export const violatesThreadGate = async ( if (allowFollowing && check?.isFollowed) { return false - } - if (allowListUris.length && check?.isInList) { + } else if (allowListUris.length && check?.isInList) { return false } diff --git a/packages/bsky/src/services/feed/views.ts b/packages/bsky/src/services/feed/views.ts index dc5878db6cd..19dada6dfb7 100644 --- a/packages/bsky/src/services/feed/views.ts +++ b/packages/bsky/src/services/feed/views.ts @@ -5,7 +5,6 @@ import { GeneratorView, PostView, } from '../../lexicon/types/app/bsky/feed/defs' -import { isListRule } from '../../lexicon/types/app/bsky/feed/threadgate' import { Main as EmbedImages, isMain as isEmbedImages, @@ -22,6 +21,8 @@ import { ViewNotFound, ViewRecord, } from '../../lexicon/types/app/bsky/embed/record' +import { Record as PostRecord } from '../../lexicon/types/app/bsky/feed/post' +import { isListRule } from '../../lexicon/types/app/bsky/feed/threadgate' import { PostEmbedViews, FeedGenInfo, @@ -39,6 +40,8 @@ import { ImageUriBuilder } from '../../image/uri' import { LabelCache } from '../../label-cache' import { ActorInfoMap, ActorService } from '../actor' import { ListInfoMap, GraphService } from '../graph' +import { AtUri } from '@atproto/syntax' +import { parseThreadGate } from './util' export class FeedViews { constructor( @@ -91,8 +94,8 @@ export class FeedViews { formatFeed( items: FeedRow[], state: FeedHydrationState, + viewer: string | null, opts?: { - viewer?: string | null usePostViewUnion?: boolean }, ): FeedViewPost[] { @@ -101,7 +104,7 @@ export class FeedViews { const actors = this.services.actor.views.profileBasicPresentation( Object.keys(profiles), state, - opts, + viewer, ) const feed: FeedViewPost[] = [] for (const item of items) { @@ -114,6 +117,7 @@ export class FeedViews { embeds, labels, lists, + viewer, ) // skip over not found & blocked posts if (!post || blocks[post.uri]?.reply) { @@ -149,6 +153,7 @@ export class FeedViews { labels, lists, blocks, + viewer, opts, ) const replyRoot = this.formatMaybePostView( @@ -160,6 +165,7 @@ export class FeedViews { labels, lists, blocks, + viewer, opts, ) if (replyRoot && replyParent) { @@ -182,6 +188,7 @@ export class FeedViews { embeds: PostEmbedViews, labels: Labels, lists: ListInfoMap, + viewer: string | null, ): PostView | undefined { const post = posts[uri] const gate = threadgates[uri] @@ -207,6 +214,14 @@ export class FeedViews { ? { repost: post.requesterRepost ?? undefined, like: post.requesterLike ?? undefined, + replyDisabled: this.userReplyDisabled( + uri, + actors, + posts, + threadgates, + lists, + viewer, + ), } : undefined, labels: [...postLabels, ...postSelfLabels], @@ -217,6 +232,50 @@ export class FeedViews { } } + userReplyDisabled( + uri: string, + actors: ActorInfoMap, + posts: PostInfoMap, + threadgates: ThreadgateInfoMap, + lists: ListInfoMap, + viewer: string | null, + ): boolean | undefined { + if (viewer === null) { + return undefined + } else if (posts[uri]?.violatesThreadGate) { + return true + } + + const rootUriStr: string = + posts[uri]?.record?.['reply']?.['root']?.['uri'] ?? uri + const gate = threadgates[rootUriStr]?.record + if (!gate) { + return undefined + } + const rootPost = posts[rootUriStr]?.record as PostRecord | undefined + const ownerDid = new AtUri(rootUriStr).hostname + + const { + canReply, + allowFollowing, + allowListUris = [], + } = parseThreadGate(viewer, ownerDid, rootPost ?? null, gate ?? null) + + if (canReply) { + return false + } + if (allowFollowing && actors[ownerDid]?.viewer?.followedBy) { + return false + } + for (const listUri of allowListUris) { + const list = lists[listUri] + if (list?.viewerInList) { + return false + } + } + return true + } + formatMaybePostView( uri: string, actors: ActorInfoMap, @@ -226,6 +285,7 @@ export class FeedViews { labels: Labels, lists: ListInfoMap, blocks: PostBlocksMap, + viewer: string | null, opts?: { usePostViewUnion?: boolean }, @@ -238,6 +298,7 @@ export class FeedViews { embeds, labels, lists, + viewer, ) if (!post) { if (!opts?.usePostViewUnion) return diff --git a/packages/bsky/src/services/graph/index.ts b/packages/bsky/src/services/graph/index.ts index eadf035db1a..4d3a117b0f4 100644 --- a/packages/bsky/src/services/graph/index.ts +++ b/packages/bsky/src/services/graph/index.ts @@ -91,6 +91,12 @@ export class GraphService { .whereRef('list_block.subjectUri', '=', ref('list.uri')) .select('list_block.uri') .as('viewerListBlockUri'), + this.db.db + .selectFrom('list_item') + .whereRef('list_item.listUri', '=', ref('list.uri')) + .where('list_item.subjectDid', '=', viewer ?? '') + .select('list_item.uri') + .as('viewerInList'), ]) } diff --git a/packages/bsky/src/services/graph/types.ts b/packages/bsky/src/services/graph/types.ts index f5ee0c13026..5ff254dc383 100644 --- a/packages/bsky/src/services/graph/types.ts +++ b/packages/bsky/src/services/graph/types.ts @@ -4,6 +4,7 @@ import { List } from '../../db/tables/list' export type ListInfo = Selectable & { viewerMuted: string | null viewerListBlockUri: string | null + viewerInList: string | null } export type ListInfoMap = Record diff --git a/packages/bsky/src/services/indexing/plugins/post.ts b/packages/bsky/src/services/indexing/plugins/post.ts index 7173c04a991..396544b8f26 100644 --- a/packages/bsky/src/services/indexing/plugins/post.ts +++ b/packages/bsky/src/services/indexing/plugins/post.ts @@ -420,7 +420,7 @@ async function validateReply( const violatesThreadGate = await feedutil.violatesThreadGate( db, creator, - new AtUri(reply.root.uri).host, + new AtUri(reply.root.uri).hostname, replyRefs.root?.record ?? null, replyRefs.gate?.record ?? null, ) diff --git a/packages/bsky/tests/__snapshots__/indexing.test.ts.snap b/packages/bsky/tests/__snapshots__/indexing.test.ts.snap index 0ed4aeb4d02..88c02c6e3e0 100644 --- a/packages/bsky/tests/__snapshots__/indexing.test.ts.snap +++ b/packages/bsky/tests/__snapshots__/indexing.test.ts.snap @@ -518,9 +518,6 @@ Object { "viewer": Object {}, }, "replies": Array [], - "viewer": Object { - "canReply": true, - }, }, } `; @@ -587,9 +584,6 @@ Object { "viewer": Object {}, }, "replies": Array [], - "viewer": Object { - "canReply": true, - }, }, } `; diff --git a/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap b/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap index 7843adb6cc8..86fe23283c4 100644 --- a/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/block-lists.test.ts.snap @@ -126,9 +126,6 @@ Object { "uri": "record(0)", "viewer": Object {}, }, - "viewer": Object { - "canReply": true, - }, }, } `; @@ -204,9 +201,6 @@ Object { "viewer": Object {}, }, "replies": Array [], - "viewer": Object { - "canReply": true, - }, }, } `; @@ -288,9 +282,6 @@ Object { "uri": "record(7)", }, ], - "viewer": Object { - "canReply": true, - }, }, } `; diff --git a/packages/bsky/tests/views/__snapshots__/blocks.test.ts.snap b/packages/bsky/tests/views/__snapshots__/blocks.test.ts.snap index ba5c00182de..2a27fcf4955 100644 --- a/packages/bsky/tests/views/__snapshots__/blocks.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/blocks.test.ts.snap @@ -126,9 +126,6 @@ Object { "uri": "record(0)", "viewer": Object {}, }, - "viewer": Object { - "canReply": true, - }, }, } `; @@ -204,9 +201,6 @@ Object { "viewer": Object {}, }, "replies": Array [], - "viewer": Object { - "canReply": true, - }, }, } `; @@ -359,9 +353,6 @@ Object { }, }, ], - "viewer": Object { - "canReply": true, - }, }, } `; diff --git a/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap b/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap index b58e7a3734f..438b48b4fdd 100644 --- a/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/mute-lists.test.ts.snap @@ -292,9 +292,6 @@ Object { }, }, ], - "viewer": Object { - "canReply": true, - }, } `; diff --git a/packages/bsky/tests/views/__snapshots__/mutes.test.ts.snap b/packages/bsky/tests/views/__snapshots__/mutes.test.ts.snap index ca8b664ec91..0e1c14c2696 100644 --- a/packages/bsky/tests/views/__snapshots__/mutes.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/mutes.test.ts.snap @@ -269,8 +269,5 @@ Object { }, }, ], - "viewer": Object { - "canReply": true, - }, } `; diff --git a/packages/bsky/tests/views/__snapshots__/thread.test.ts.snap b/packages/bsky/tests/views/__snapshots__/thread.test.ts.snap index 6bc84753951..fb0fd6a3224 100644 --- a/packages/bsky/tests/views/__snapshots__/thread.test.ts.snap +++ b/packages/bsky/tests/views/__snapshots__/thread.test.ts.snap @@ -195,9 +195,6 @@ Object { }, }, "replies": Array [], - "viewer": Object { - "canReply": true, - }, } `; @@ -437,9 +434,6 @@ Object { ], }, ], - "viewer": Object { - "canReply": true, - }, } `; @@ -615,9 +609,6 @@ Object { }, }, ], - "viewer": Object { - "canReply": true, - }, } `; @@ -771,9 +762,6 @@ Object { ], }, ], - "viewer": Object { - "canReply": true, - }, } `; @@ -826,9 +814,6 @@ Object { "viewer": Object {}, }, "replies": Array [], - "viewer": Object { - "canReply": true, - }, } `; @@ -896,9 +881,6 @@ Object { "viewer": Object {}, }, "replies": Array [], - "viewer": Object { - "canReply": true, - }, } `; @@ -968,9 +950,6 @@ Object { }, }, "replies": Array [], - "viewer": Object { - "canReply": true, - }, } `; @@ -1040,9 +1019,6 @@ Object { }, }, "replies": Array [], - "viewer": Object { - "canReply": true, - }, } `; @@ -1243,9 +1219,6 @@ Object { ], }, ], - "viewer": Object { - "canReply": true, - }, } `; @@ -1384,8 +1357,5 @@ Object { "replies": Array [], }, ], - "viewer": Object { - "canReply": true, - }, } `; diff --git a/packages/bsky/tests/views/threadgating.test.ts b/packages/bsky/tests/views/threadgating.test.ts index 8cfaedba44e..53e5961b595 100644 --- a/packages/bsky/tests/views/threadgating.test.ts +++ b/packages/bsky/tests/views/threadgating.test.ts @@ -29,6 +29,19 @@ describe('views with thread gating', () => { await network.close() }) + // check that replyDisabled state is applied correctly in a simple method like getPosts + const checkReplyDisabled = async ( + uri: string, + user: string, + blocked: boolean | undefined, + ) => { + const res = await agent.api.app.bsky.feed.getPosts( + { uris: [uri] }, + { headers: await network.serviceHeaders(user) }, + ) + expect(res.data.posts[0].viewer?.replyDisabled).toBe(blocked) + } + it('applies gate for empty rules.', async () => { const post = await sc.post(sc.dids.carol, 'empty rules') await pdsAgent.api.app.bsky.feed.threadgate.create( @@ -46,8 +59,9 @@ describe('views with thread gating', () => { ) assert(isThreadViewPost(thread)) expect(forSnapshot(thread.post.threadgate)).toMatchSnapshot() - expect(thread.viewer).toEqual({ canReply: false }) + expect(thread.post.viewer).toEqual({ replyDisabled: true }) expect(thread.replies?.length).toEqual(0) + await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, true) }) it('applies gate for mention rule.', async () => { @@ -98,7 +112,8 @@ describe('views with thread gating', () => { { headers: await network.serviceHeaders(sc.dids.alice) }, ) assert(isThreadViewPost(aliceThread)) - expect(aliceThread.viewer).toEqual({ canReply: false }) + expect(aliceThread.post.viewer).toEqual({ replyDisabled: true }) + await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, true) const { data: { thread: danThread }, } = await agent.api.app.bsky.feed.getPostThread( @@ -107,7 +122,8 @@ describe('views with thread gating', () => { ) assert(isThreadViewPost(danThread)) expect(forSnapshot(danThread.post.threadgate)).toMatchSnapshot() - expect(danThread.viewer).toEqual({ canReply: true }) + expect(danThread.post.viewer).toEqual({ replyDisabled: false }) + await checkReplyDisabled(post.ref.uriStr, sc.dids.dan, false) const [reply, ...otherReplies] = danThread.replies ?? [] assert(isThreadViewPost(reply)) expect(otherReplies.length).toEqual(0) @@ -146,7 +162,8 @@ describe('views with thread gating', () => { { headers: await network.serviceHeaders(sc.dids.dan) }, ) assert(isThreadViewPost(danThread)) - expect(danThread.viewer).toEqual({ canReply: false }) + expect(danThread.post.viewer).toEqual({ replyDisabled: true }) + await checkReplyDisabled(post.ref.uriStr, sc.dids.dan, true) const { data: { thread: aliceThread }, } = await agent.api.app.bsky.feed.getPostThread( @@ -155,7 +172,8 @@ describe('views with thread gating', () => { ) assert(isThreadViewPost(aliceThread)) expect(forSnapshot(aliceThread.post.threadgate)).toMatchSnapshot() - expect(aliceThread.viewer).toEqual({ canReply: true }) + expect(aliceThread.post.viewer).toEqual({ replyDisabled: false }) + await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, false) const [reply, ...otherReplies] = aliceThread.replies ?? [] assert(isThreadViewPost(reply)) expect(otherReplies.length).toEqual(0) @@ -235,7 +253,8 @@ describe('views with thread gating', () => { { headers: await network.serviceHeaders(sc.dids.bob) }, ) assert(isThreadViewPost(bobThread)) - expect(bobThread.viewer).toEqual({ canReply: false }) + expect(bobThread.post.viewer).toEqual({ replyDisabled: true }) + await checkReplyDisabled(post.ref.uriStr, sc.dids.bob, true) const { data: { thread: aliceThread }, } = await agent.api.app.bsky.feed.getPostThread( @@ -243,7 +262,8 @@ describe('views with thread gating', () => { { headers: await network.serviceHeaders(sc.dids.alice) }, ) assert(isThreadViewPost(aliceThread)) - expect(aliceThread.viewer).toEqual({ canReply: true }) + expect(aliceThread.post.viewer).toEqual({ replyDisabled: false }) + await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, false) const { data: { thread: danThread }, } = await agent.api.app.bsky.feed.getPostThread( @@ -252,7 +272,8 @@ describe('views with thread gating', () => { ) assert(isThreadViewPost(danThread)) expect(forSnapshot(danThread.post.threadgate)).toMatchSnapshot() - expect(danThread.viewer).toEqual({ canReply: true }) + expect(danThread.post.viewer).toEqual({ replyDisabled: false }) + await checkReplyDisabled(post.ref.uriStr, sc.dids.dan, false) const [reply1, reply2, ...otherReplies] = aliceThread.replies ?? [] assert(isThreadViewPost(reply1)) assert(isThreadViewPost(reply2)) @@ -292,8 +313,9 @@ describe('views with thread gating', () => { ) assert(isThreadViewPost(thread)) expect(forSnapshot(thread.post.threadgate)).toMatchSnapshot() - expect(thread.viewer).toEqual({ canReply: false }) + expect(thread.post.viewer).toEqual({ replyDisabled: true }) expect(thread.replies?.length).toEqual(0) + await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, true) }) it('applies gate for multiple rules.', async () => { @@ -339,7 +361,8 @@ describe('views with thread gating', () => { { headers: await network.serviceHeaders(sc.dids.bob) }, ) assert(isThreadViewPost(bobThread)) - expect(bobThread.viewer).toEqual({ canReply: false }) + expect(bobThread.post.viewer).toEqual({ replyDisabled: true }) + await checkReplyDisabled(post.ref.uriStr, sc.dids.bob, true) const { data: { thread: aliceThread }, } = await agent.api.app.bsky.feed.getPostThread( @@ -347,7 +370,8 @@ describe('views with thread gating', () => { { headers: await network.serviceHeaders(sc.dids.alice) }, ) assert(isThreadViewPost(aliceThread)) - expect(aliceThread.viewer).toEqual({ canReply: true }) + expect(aliceThread.post.viewer).toEqual({ replyDisabled: false }) + await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, false) const { data: { thread: danThread }, } = await agent.api.app.bsky.feed.getPostThread( @@ -356,7 +380,8 @@ describe('views with thread gating', () => { ) assert(isThreadViewPost(danThread)) expect(forSnapshot(danThread.post.threadgate)).toMatchSnapshot() - expect(danThread.viewer).toEqual({ canReply: true }) + expect(danThread.post.viewer).toEqual({ replyDisabled: false }) + await checkReplyDisabled(post.ref.uriStr, sc.dids.dan, false) const [reply1, reply2, ...otherReplies] = aliceThread.replies ?? [] assert(isThreadViewPost(reply1)) assert(isThreadViewPost(reply2)) @@ -387,7 +412,8 @@ describe('views with thread gating', () => { ) assert(isThreadViewPost(thread)) expect(forSnapshot(thread.post.threadgate)).toMatchSnapshot() - expect(thread.viewer).toEqual({ canReply: true }) + expect(thread.post.viewer).toEqual({ replyDisabled: false }) + await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, false) const [reply, ...otherReplies] = thread.replies ?? [] assert(isThreadViewPost(reply)) expect(otherReplies.length).toEqual(0) @@ -438,7 +464,8 @@ describe('views with thread gating', () => { { headers: await network.serviceHeaders(sc.dids.dan) }, ) assert(isThreadViewPost(danThread)) - expect(danThread.viewer).toEqual({ canReply: false }) + expect(danThread.post.viewer).toEqual({ replyDisabled: true }) + await checkReplyDisabled(orphanedReply.ref.uriStr, sc.dids.dan, true) const { data: { thread: aliceThread }, } = await agent.api.app.bsky.feed.getPostThread( @@ -451,7 +478,8 @@ describe('views with thread gating', () => { aliceThread.parent.uri === post.ref.uriStr, ) expect(aliceThread.post.threadgate).toMatchSnapshot() - expect(aliceThread.viewer).toEqual({ canReply: true }) + expect(aliceThread.post.viewer).toEqual({ replyDisabled: false }) + await checkReplyDisabled(orphanedReply.ref.uriStr, sc.dids.alice, false) const [reply, ...otherReplies] = aliceThread.replies ?? [] assert(isThreadViewPost(reply)) expect(otherReplies.length).toEqual(0) @@ -480,7 +508,8 @@ describe('views with thread gating', () => { ) assert(isThreadViewPost(thread)) expect(forSnapshot(thread.post.threadgate)).toMatchSnapshot() - expect(thread.viewer).toEqual({ canReply: true }) + expect(thread.post.viewer).toEqual({ replyDisabled: false }) + await checkReplyDisabled(post.ref.uriStr, sc.dids.carol, false) const [reply, ...otherReplies] = thread.replies ?? [] assert(isThreadViewPost(reply)) expect(otherReplies.length).toEqual(0) @@ -516,10 +545,11 @@ describe('views with thread gating', () => { { headers: await network.serviceHeaders(sc.dids.alice) }, ) assert(isThreadViewPost(thread)) - expect(thread.viewer).toEqual({ canReply: false }) // nobody can reply to this, not even alice. + expect(thread.post.viewer).toEqual({ replyDisabled: true }) // nobody can reply to this, not even alice. expect(thread.replies).toBeUndefined() expect(thread.parent).toBeUndefined() expect(thread.post.threadgate).toBeUndefined() + await checkReplyDisabled(badReply.ref.uriStr, sc.dids.alice, true) // check feed view const { data: { feed }, @@ -552,8 +582,9 @@ describe('views with thread gating', () => { ) assert(isThreadViewPost(threadA)) expect(threadA.post.threadgate).toBeUndefined() - expect(threadA.viewer).toEqual({ canReply: true }) + expect(threadA.post.viewer).toEqual({}) expect(threadA.replies?.length).toEqual(1) + await checkReplyDisabled(postA.ref.uriStr, sc.dids.alice, undefined) const { data: { thread: threadB }, } = await agent.api.app.bsky.feed.getPostThread( @@ -562,7 +593,8 @@ describe('views with thread gating', () => { ) assert(isThreadViewPost(threadB)) expect(threadB.post.threadgate).toBeUndefined() - expect(threadB.viewer).toEqual({ canReply: true }) + expect(threadB.post.viewer).toEqual({}) + await checkReplyDisabled(postB.ref.uriStr, sc.dids.alice, undefined) expect(threadB.replies?.length).toEqual(1) }) }) diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index 14c1be9f477..cc3f09f0c4e 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -4892,6 +4892,9 @@ export const schemaDict = { type: 'string', format: 'at-uri', }, + replyDisabled: { + type: 'boolean', + }, }, }, feedViewPost: { @@ -4975,10 +4978,6 @@ export const schemaDict = { ], }, }, - viewer: { - type: 'ref', - ref: 'lex:app.bsky.feed.defs#viewerThreadState', - }, }, }, notFoundPost: { @@ -5027,14 +5026,6 @@ export const schemaDict = { }, }, }, - viewerThreadState: { - type: 'object', - properties: { - canReply: { - type: 'boolean', - }, - }, - }, generatorView: { type: 'object', required: ['uri', 'cid', 'did', 'creator', 'displayName', 'indexedAt'], diff --git a/packages/pds/src/lexicon/types/app/bsky/feed/defs.ts b/packages/pds/src/lexicon/types/app/bsky/feed/defs.ts index 08d34d88ebb..382d3f58ecf 100644 --- a/packages/pds/src/lexicon/types/app/bsky/feed/defs.ts +++ b/packages/pds/src/lexicon/types/app/bsky/feed/defs.ts @@ -48,6 +48,7 @@ export function validatePostView(v: unknown): ValidationResult { export interface ViewerState { repost?: string like?: string + replyDisabled?: boolean [k: string]: unknown } @@ -137,7 +138,6 @@ export interface ThreadViewPost { | BlockedPost | { $type: string; [k: string]: unknown } )[] - viewer?: ViewerThreadState [k: string]: unknown } @@ -208,23 +208,6 @@ export function validateBlockedAuthor(v: unknown): ValidationResult { return lexicons.validate('app.bsky.feed.defs#blockedAuthor', v) } -export interface ViewerThreadState { - canReply?: boolean - [k: string]: unknown -} - -export function isViewerThreadState(v: unknown): v is ViewerThreadState { - return ( - isObj(v) && - hasProp(v, '$type') && - v.$type === 'app.bsky.feed.defs#viewerThreadState' - ) -} - -export function validateViewerThreadState(v: unknown): ValidationResult { - return lexicons.validate('app.bsky.feed.defs#viewerThreadState', v) -} - export interface GeneratorView { uri: string cid: string