import assert from 'assert' import { AtpAgent } from '@atproto/api' import { TestNetwork, SeedClient, basicSeed } from '@atproto/dev-env' import { isNotFoundPost, isThreadViewPost, } from '../../src/lexicon/types/app/bsky/feed/defs' import { forSnapshot } from '../_util' import { ids } from '../../src/lexicon/lexicons' describe('views with thread gating', () => { let network: TestNetwork let agent: AtpAgent let pdsAgent: AtpAgent let sc: SeedClient beforeAll(async () => { network = await TestNetwork.create({ dbPostgresSchema: 'bsky_views_thread_gating', }) agent = network.bsky.getClient() pdsAgent = network.pds.getClient() sc = network.getSeedClient() await basicSeed(sc) await network.processAll() }) afterAll(async () => { 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, ids.AppBskyFeedGetPosts) }, ) 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') const { uri: threadgateUri } = await pdsAgent.api.app.bsky.feed.threadgate.create( { repo: sc.dids.carol, rkey: post.ref.uri.rkey }, { post: post.ref.uriStr, createdAt: iso(), allow: [] }, sc.getHeaders(sc.dids.carol), ) await network.processAll() await sc.reply(sc.dids.alice, post.ref, post.ref, 'empty rules reply') await network.processAll() const { data: { thread, threadgate }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.alice, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(thread)) expect(forSnapshot(thread.post.threadgate)).toMatchSnapshot() expect(thread.post.viewer?.replyDisabled).toBe(true) expect(thread.replies?.length).toEqual(0) expect(threadgate?.uri).toEqual(threadgateUri) await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, true) }) it('does not generate notifications when post violates threadgate.', async () => { const post = await sc.post(sc.dids.carol, 'notifications') await pdsAgent.api.app.bsky.feed.threadgate.create( { repo: sc.dids.carol, rkey: post.ref.uri.rkey }, { post: post.ref.uriStr, createdAt: iso(), allow: [] }, sc.getHeaders(sc.dids.carol), ) await network.processAll() const reply = await sc.reply( sc.dids.alice, post.ref, post.ref, 'notifications reply', ) await network.processAll() const { data: { notifications }, } = await agent.api.app.bsky.notification.listNotifications( {}, { headers: await network.serviceHeaders( sc.dids.carol, ids.AppBskyNotificationListNotifications, ), }, ) const notificationFromReply = notifications.find( (notif) => notif.uri === reply.ref.uriStr, ) expect(notificationFromReply).toBeUndefined() }) it('applies gate for mention rule.', async () => { const post = await sc.post( sc.dids.carol, 'mention rules @carol.test @dan.test', [ { index: { byteStart: 14, byteEnd: 25 }, features: [ { $type: 'app.bsky.richtext.facet#mention', did: sc.dids.carol }, ], }, { index: { byteStart: 26, byteEnd: 35 }, features: [ { $type: 'app.bsky.richtext.facet#mention', did: sc.dids.dan }, ], }, ], ) await pdsAgent.api.app.bsky.feed.threadgate.create( { repo: sc.dids.carol, rkey: post.ref.uri.rkey }, { post: post.ref.uriStr, createdAt: iso(), allow: [{ $type: 'app.bsky.feed.threadgate#mentionRule' }], }, sc.getHeaders(sc.dids.carol), ) await network.processAll() await sc.reply( sc.dids.alice, post.ref, post.ref, 'mention rule reply disallow', ) const danReply = await sc.reply( sc.dids.dan, post.ref, post.ref, 'mention rule reply allow', ) await network.processAll() const { data: { thread: aliceThread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.alice, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(aliceThread)) expect(aliceThread.post.viewer?.replyDisabled).toBe(true) await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, true) const { data: { thread: danThread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.dan, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(danThread)) expect(forSnapshot(danThread.post.threadgate)).toMatchSnapshot() expect(danThread.post.viewer?.replyDisabled).toBe(false) await checkReplyDisabled(post.ref.uriStr, sc.dids.dan, false) const [reply, ...otherReplies] = danThread.replies ?? [] assert(isThreadViewPost(reply)) expect(otherReplies.length).toEqual(0) expect(reply.post.uri).toEqual(danReply.ref.uriStr) }) it('applies gate for following rule.', async () => { const post = await sc.post(sc.dids.carol, 'following rule') await pdsAgent.api.app.bsky.feed.threadgate.create( { repo: sc.dids.carol, rkey: post.ref.uri.rkey }, { post: post.ref.uriStr, createdAt: iso(), allow: [{ $type: 'app.bsky.feed.threadgate#followingRule' }], }, sc.getHeaders(sc.dids.carol), ) await network.processAll() // carol only follows alice await sc.reply( sc.dids.dan, post.ref, post.ref, 'following rule reply disallow', ) const aliceReply = await sc.reply( sc.dids.alice, post.ref, post.ref, 'following rule reply allow', ) await network.processAll() const { data: { thread: danThread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.dan, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(danThread)) expect(danThread.post.viewer?.replyDisabled).toBe(true) await checkReplyDisabled(post.ref.uriStr, sc.dids.dan, true) const { data: { thread: aliceThread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.alice, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(aliceThread)) expect(forSnapshot(aliceThread.post.threadgate)).toMatchSnapshot() expect(aliceThread.post.viewer?.replyDisabled).toBe(false) await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, false) const [reply, ...otherReplies] = aliceThread.replies ?? [] assert(isThreadViewPost(reply)) expect(otherReplies.length).toEqual(0) expect(reply.post.uri).toEqual(aliceReply.ref.uriStr) }) it('applies gate for list rule.', async () => { const post = await sc.post(sc.dids.carol, 'following rule') // setup lists to allow alice and dan const listA = await pdsAgent.api.app.bsky.graph.list.create( { repo: sc.dids.carol }, { name: 'list a', purpose: 'app.bsky.graph.defs#modlist', createdAt: iso(), }, sc.getHeaders(sc.dids.carol), ) await pdsAgent.api.app.bsky.graph.listitem.create( { repo: sc.dids.carol }, { list: listA.uri, subject: sc.dids.alice, createdAt: iso(), }, sc.getHeaders(sc.dids.carol), ) const listB = await pdsAgent.api.app.bsky.graph.list.create( { repo: sc.dids.carol }, { name: 'list b', purpose: 'app.bsky.graph.defs#modlist', createdAt: iso(), }, sc.getHeaders(sc.dids.carol), ) await pdsAgent.api.app.bsky.graph.listitem.create( { repo: sc.dids.carol }, { list: listB.uri, subject: sc.dids.dan, createdAt: iso(), }, sc.getHeaders(sc.dids.carol), ) await pdsAgent.api.app.bsky.feed.threadgate.create( { repo: sc.dids.carol, rkey: post.ref.uri.rkey }, { post: post.ref.uriStr, createdAt: iso(), allow: [ { $type: 'app.bsky.feed.threadgate#listRule', list: listA.uri }, { $type: 'app.bsky.feed.threadgate#listRule', list: listB.uri }, ], }, sc.getHeaders(sc.dids.carol), ) await network.processAll() // await sc.reply(sc.dids.bob, post.ref, post.ref, 'list rule reply disallow') const aliceReply = await sc.reply( sc.dids.alice, post.ref, post.ref, 'list rule reply allow (list a)', ) const danReply = await sc.reply( sc.dids.dan, post.ref, post.ref, 'list rule reply allow (list b)', ) await network.processAll() const { data: { thread: bobThread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.bob, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(bobThread)) expect(bobThread.post.viewer?.replyDisabled).toBe(true) await checkReplyDisabled(post.ref.uriStr, sc.dids.bob, true) const { data: { thread: aliceThread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.alice, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(aliceThread)) expect(aliceThread.post.viewer?.replyDisabled).toBe(false) await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, false) const { data: { thread: danThread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.dan, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(danThread)) expect(forSnapshot(danThread.post.threadgate)).toMatchSnapshot() expect(danThread.post.viewer?.replyDisabled).toBe(false) await checkReplyDisabled(post.ref.uriStr, sc.dids.dan, false) const [reply1, reply2, ...otherReplies] = aliceThread.replies ?? [] assert(isThreadViewPost(reply1)) assert(isThreadViewPost(reply2)) expect(otherReplies.length).toEqual(0) expect([reply1.post.uri, reply2.post.uri].sort()).toEqual( [danReply.ref.uriStr, aliceReply.ref.uriStr].sort(), ) }) it('applies gate for unknown list rule.', async () => { const post = await sc.post(sc.dids.carol, 'unknown list rules') await pdsAgent.api.app.bsky.feed.threadgate.create( { repo: sc.dids.carol, rkey: post.ref.uri.rkey }, { post: post.ref.uriStr, createdAt: iso(), allow: [ { $type: 'app.bsky.feed.threadgate#listRule', list: post.ref.uriStr, // bad list link, references a post }, ], }, sc.getHeaders(sc.dids.carol), ) await network.processAll() await sc.reply( sc.dids.alice, post.ref, post.ref, 'unknown list rules reply', ) await network.processAll() const { data: { thread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.alice, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(thread)) expect(forSnapshot(thread.post.threadgate)).toMatchSnapshot() expect(thread.post.viewer?.replyDisabled).toBe(true) expect(thread.replies?.length).toEqual(0) await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, true) }) it('applies gate for multiple rules.', async () => { const post = await sc.post(sc.dids.carol, 'multi rules @dan.test', [ { index: { byteStart: 12, byteEnd: 21 }, features: [ { $type: 'app.bsky.richtext.facet#mention', did: sc.dids.dan }, ], }, ]) await pdsAgent.api.app.bsky.feed.threadgate.create( { repo: sc.dids.carol, rkey: post.ref.uri.rkey }, { post: post.ref.uriStr, createdAt: iso(), allow: [ { $type: 'app.bsky.feed.threadgate#mentionRule' }, { $type: 'app.bsky.feed.threadgate#followingRule' }, ], }, sc.getHeaders(sc.dids.carol), ) await network.processAll() // carol only follows alice, and the post mentions dan. await sc.reply(sc.dids.bob, post.ref, post.ref, 'multi rule reply disallow') const aliceReply = await sc.reply( sc.dids.alice, post.ref, post.ref, 'multi rule reply allow (following)', ) const danReply = await sc.reply( sc.dids.dan, post.ref, post.ref, 'multi rule reply allow (mention)', ) await network.processAll() const { data: { thread: bobThread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.bob, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(bobThread)) expect(bobThread.post.viewer?.replyDisabled).toBe(true) await checkReplyDisabled(post.ref.uriStr, sc.dids.bob, true) const { data: { thread: aliceThread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.alice, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(aliceThread)) expect(aliceThread.post.viewer?.replyDisabled).toBe(false) await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, false) const { data: { thread: danThread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.dan, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(danThread)) expect(forSnapshot(danThread.post.threadgate)).toMatchSnapshot() expect(danThread.post.viewer?.replyDisabled).toBe(false) await checkReplyDisabled(post.ref.uriStr, sc.dids.dan, false) const [reply1, reply2, ...otherReplies] = aliceThread.replies ?? [] assert(isThreadViewPost(reply1)) assert(isThreadViewPost(reply2)) expect(otherReplies.length).toEqual(0) expect([reply1.post.uri, reply2.post.uri].sort()).toEqual( [aliceReply.ref.uriStr, danReply.ref.uriStr].sort(), ) }) it('applies gate for missing rules, takes no action.', async () => { const post = await sc.post(sc.dids.carol, 'missing rules') await pdsAgent.api.app.bsky.feed.threadgate.create( { repo: sc.dids.carol, rkey: post.ref.uri.rkey }, { post: post.ref.uriStr, createdAt: iso() }, sc.getHeaders(sc.dids.carol), ) await network.processAll() const aliceReply = await sc.reply( sc.dids.alice, post.ref, post.ref, 'missing rules reply', ) await network.processAll() const { data: { thread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.alice, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(thread)) expect(forSnapshot(thread.post.threadgate)).toMatchSnapshot() expect(thread.post.viewer?.replyDisabled).toBe(false) await checkReplyDisabled(post.ref.uriStr, sc.dids.alice, false) const [reply, ...otherReplies] = thread.replies ?? [] assert(isThreadViewPost(reply)) expect(otherReplies.length).toEqual(0) expect(reply.post.uri).toEqual(aliceReply.ref.uriStr) }) it('applies gate after root post is deleted.', async () => { // @NOTE also covers rule application more than one level deep const post = await sc.post(sc.dids.carol, 'following rule w/ post deletion') await pdsAgent.api.app.bsky.feed.threadgate.create( { repo: sc.dids.carol, rkey: post.ref.uri.rkey }, { post: post.ref.uriStr, createdAt: iso(), allow: [{ $type: 'app.bsky.feed.threadgate#followingRule' }], }, sc.getHeaders(sc.dids.carol), ) await network.processAll() // carol only follows alice const orphanedReply = await sc.reply( sc.dids.alice, post.ref, post.ref, 'following rule reply allow', ) await pdsAgent.api.app.bsky.feed.post.delete( { repo: sc.dids.carol, rkey: post.ref.uri.rkey }, sc.getHeaders(sc.dids.carol), ) await network.processAll() await sc.reply( sc.dids.dan, post.ref, orphanedReply.ref, 'following rule reply disallow', ) const aliceReply = await sc.reply( sc.dids.alice, post.ref, orphanedReply.ref, 'following rule reply allow', ) await network.processAll() const { data: { thread: danThread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: orphanedReply.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.dan, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(danThread)) expect(danThread.post.viewer?.replyDisabled).toBe(true) await checkReplyDisabled(orphanedReply.ref.uriStr, sc.dids.dan, true) const { data: { thread: aliceThread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: orphanedReply.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.alice, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(aliceThread)) assert( isNotFoundPost(aliceThread.parent) && aliceThread.parent.uri === post.ref.uriStr, ) expect(aliceThread.post.threadgate).toMatchSnapshot() expect(aliceThread.post.viewer?.replyDisabled).toBe(false) await checkReplyDisabled(orphanedReply.ref.uriStr, sc.dids.alice, false) const [reply, ...otherReplies] = aliceThread.replies ?? [] assert(isThreadViewPost(reply)) expect(otherReplies.length).toEqual(0) expect(reply.post.uri).toEqual(aliceReply.ref.uriStr) }) it('does not apply gate to original poster.', async () => { const post = await sc.post(sc.dids.carol, 'empty rules') await pdsAgent.api.app.bsky.feed.threadgate.create( { repo: sc.dids.carol, rkey: post.ref.uri.rkey }, { post: post.ref.uriStr, createdAt: iso(), allow: [] }, sc.getHeaders(sc.dids.carol), ) await network.processAll() const selfReply = await sc.reply( sc.dids.carol, post.ref, post.ref, 'empty rules reply allow', ) await network.processAll() const { data: { thread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: post.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.carol, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(thread)) expect(forSnapshot(thread.post.threadgate)).toMatchSnapshot() expect(thread.post.viewer?.replyDisabled).toBe(false) await checkReplyDisabled(post.ref.uriStr, sc.dids.carol, false) const [reply, ...otherReplies] = thread.replies ?? [] assert(isThreadViewPost(reply)) expect(otherReplies.length).toEqual(0) expect(reply.post.uri).toEqual(selfReply.ref.uriStr) }) it('displays gated posts in feed and thread anchor without reply context.', async () => { const post = await sc.post(sc.dids.carol, 'following rule') await pdsAgent.api.app.bsky.feed.threadgate.create( { repo: sc.dids.carol, rkey: post.ref.uri.rkey }, { post: post.ref.uriStr, createdAt: iso(), allow: [{ $type: 'app.bsky.feed.threadgate#followingRule' }], }, sc.getHeaders(sc.dids.carol), ) await network.processAll() // carol only follows alice const badReply = await sc.reply( sc.dids.dan, post.ref, post.ref, 'following rule reply disallow', ) // going to ensure this one doesn't appear in badReply's thread await sc.reply(sc.dids.alice, post.ref, badReply.ref, 'reply to disallowed') await network.processAll() // check thread view const { data: { thread }, } = await agent.api.app.bsky.feed.getPostThread( { uri: badReply.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.alice, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(thread)) expect(thread.post.viewer?.replyDisabled).toBe(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 }, } = await agent.api.app.bsky.feed.getAuthorFeed( { actor: sc.dids.dan }, { headers: await network.serviceHeaders( sc.dids.alice, ids.AppBskyFeedGetAuthorFeed, ), }, ) const [feedItem] = feed expect(feedItem.post.uri).toEqual(badReply.ref.uriStr) expect(feedItem.post.threadgate).toBeUndefined() expect(feedItem.reply).toBeUndefined() }) it('does not apply gate unless it matches post rkey.', async () => { const postA = await sc.post(sc.dids.carol, 'ungated a') const postB = await sc.post(sc.dids.carol, 'ungated b') await pdsAgent.api.app.bsky.feed.threadgate.create( { repo: sc.dids.carol, rkey: postA.ref.uri.rkey }, { post: postB.ref.uriStr, createdAt: iso(), allow: [] }, sc.getHeaders(sc.dids.carol), ) await network.processAll() await sc.reply(sc.dids.alice, postA.ref, postA.ref, 'ungated reply') await sc.reply(sc.dids.alice, postB.ref, postB.ref, 'ungated reply') await network.processAll() const { data: { thread: threadA }, } = await agent.api.app.bsky.feed.getPostThread( { uri: postA.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.alice, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(threadA)) expect(threadA.post.threadgate).toBeUndefined() expect(threadA.post.viewer?.replyDisabled).toBeUndefined() 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( { uri: postB.ref.uriStr }, { headers: await network.serviceHeaders( sc.dids.alice, ids.AppBskyFeedGetPostThread, ), }, ) assert(isThreadViewPost(threadB)) expect(threadB.post.threadgate).toBeUndefined() expect(threadB.post.viewer?.replyDisabled).toBe(undefined) await checkReplyDisabled(postB.ref.uriStr, sc.dids.alice, undefined) expect(threadB.replies?.length).toEqual(1) }) }) const iso = (date = new Date()) => date.toISOString()