Add followerRule threadgate ()

* Add followerRule threadgate

* changeset
This commit is contained in:
rafael 2025-02-06 06:52:21 -08:00 committed by GitHub
parent 799dd925e9
commit dc8a7842e6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 273 additions and 12 deletions
.changeset
lexicons/app/bsky/feed
packages
api/src/client
lexicons.ts
types/app/bsky/feed
bsky
src
data-plane/server
lexicon
lexicons.ts
types/app/bsky/feed
views
tests/views
ozone/src/lexicon
lexicons.ts
types/app/bsky/feed
pds/src/lexicon
lexicons.ts
types/app/bsky/feed

@ -0,0 +1,5 @@
---
"@atproto/api": patch
---
Add followerRule threadgate

@ -0,0 +1,5 @@
---
"@atproto/bsky": patch
---
Add followerRule threadgate

@ -21,7 +21,12 @@
"maxLength": 5,
"items": {
"type": "union",
"refs": ["#mentionRule", "#followingRule", "#listRule"]
"refs": [
"#mentionRule",
"#followerRule",
"#followingRule",
"#listRule"
]
}
},
"createdAt": { "type": "string", "format": "datetime" },
@ -42,6 +47,11 @@
"description": "Allow replies from actors mentioned in your post.",
"properties": {}
},
"followerRule": {
"type": "object",
"description": "Allow replies from actors who follow you.",
"properties": {}
},
"followingRule": {
"type": "object",
"description": "Allow replies from actors you follow.",

@ -7376,6 +7376,7 @@ export const schemaDict = {
type: 'union',
refs: [
'lex:app.bsky.feed.threadgate#mentionRule',
'lex:app.bsky.feed.threadgate#followerRule',
'lex:app.bsky.feed.threadgate#followingRule',
'lex:app.bsky.feed.threadgate#listRule',
],
@ -7402,6 +7403,11 @@ export const schemaDict = {
description: 'Allow replies from actors mentioned in your post.',
properties: {},
},
followerRule: {
type: 'object',
description: 'Allow replies from actors who follow you.',
properties: {},
},
followingRule: {
type: 'object',
description: 'Allow replies from actors you follow.',

@ -12,6 +12,7 @@ export interface Record {
/** List of rules defining who can reply to this post. If value is an empty array, no one can reply. If value is undefined, anyone can reply. */
allow?: (
| MentionRule
| FollowerRule
| FollowingRule
| ListRule
| { $type: string; [k: string]: unknown }
@ -52,6 +53,23 @@ export function validateMentionRule(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.feed.threadgate#mentionRule', v)
}
/** Allow replies from actors who follow you. */
export interface FollowerRule {
[k: string]: unknown
}
export function isFollowerRule(v: unknown): v is FollowerRule {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.feed.threadgate#followerRule'
)
}
export function validateFollowerRule(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.feed.threadgate#followerRule', v)
}
/** Allow replies from actors you follow. */
export interface FollowingRule {
[k: string]: unknown

@ -100,13 +100,14 @@ export const violatesThreadGate = async (
) => {
const {
canReply,
allowFollower,
allowFollowing,
allowListUris = [],
} = parseThreadGate(replierDid, ownerDid, rootPost, gate)
if (canReply) {
return false
}
if (!allowFollowing && !allowListUris?.length) {
if (!allowFollower && !allowFollowing && !allowListUris?.length) {
return true
}
const { ref } = db.dynamic
@ -114,6 +115,14 @@ export const violatesThreadGate = async (
const check = await db
.selectFrom(valuesList([replierDid]).as(sql`subject (did)`))
.select([
allowFollower
? db
.selectFrom('follow')
.where('subjectDid', '=', ownerDid)
.whereRef('creator', '=', ref('subject.did'))
.select('subjectDid')
.as('isFollower')
: nullResult.as('isFollower'),
allowFollowing
? db
.selectFrom('follow')
@ -136,6 +145,8 @@ export const violatesThreadGate = async (
if (allowFollowing && check?.isFollowed) {
return false
} else if (allowFollower && check?.isFollower) {
return false
} else if (allowListUris.length && check?.isInList) {
return false
}

@ -7376,6 +7376,7 @@ export const schemaDict = {
type: 'union',
refs: [
'lex:app.bsky.feed.threadgate#mentionRule',
'lex:app.bsky.feed.threadgate#followerRule',
'lex:app.bsky.feed.threadgate#followingRule',
'lex:app.bsky.feed.threadgate#listRule',
],
@ -7402,6 +7403,11 @@ export const schemaDict = {
description: 'Allow replies from actors mentioned in your post.',
properties: {},
},
followerRule: {
type: 'object',
description: 'Allow replies from actors who follow you.',
properties: {},
},
followingRule: {
type: 'object',
description: 'Allow replies from actors you follow.',

@ -12,6 +12,7 @@ export interface Record {
/** List of rules defining who can reply to this post. If value is an empty array, no one can reply. If value is undefined, anyone can reply. */
allow?: (
| MentionRule
| FollowerRule
| FollowingRule
| ListRule
| { $type: string; [k: string]: unknown }
@ -52,6 +53,23 @@ export function validateMentionRule(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.feed.threadgate#mentionRule', v)
}
/** Allow replies from actors who follow you. */
export interface FollowerRule {
[k: string]: unknown
}
export function isFollowerRule(v: unknown): v is FollowerRule {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.feed.threadgate#followerRule'
)
}
export function validateFollowerRule(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.feed.threadgate#followerRule', v)
}
/** Allow replies from actors you follow. */
export interface FollowingRule {
[k: string]: unknown

@ -1163,12 +1163,16 @@ export class Views {
const ownerDid = creatorFromUri(rootUriStr)
const {
canReply,
allowFollower,
allowFollowing,
allowListUris = [],
} = parseThreadGate(viewer, ownerDid, rootPost ?? null, gate)
if (canReply) {
return false
}
if (allowFollower && state.profileViewers?.get(ownerDid)?.following) {
return false
}
if (allowFollowing && state.profileViewers?.get(ownerDid)?.followedBy) {
return false
}

@ -7,6 +7,7 @@ import {
} from '../lexicon/types/app/bsky/feed/postgate'
import {
Record as GateRecord,
isFollowerRule,
isFollowingRule,
isListRule,
isMentionRule,
@ -28,6 +29,7 @@ export const parseThreadGate = (
}
const allowMentions = gate.allow.some(isMentionRule)
const allowFollower = gate.allow.some(isFollowerRule)
const allowFollowing = gate.allow.some(isFollowingRule)
const allowListUris = gate.allow?.filter(isListRule).map((item) => item.list)
@ -39,15 +41,22 @@ export const parseThreadGate = (
)
})
if (isMentioned) {
return { canReply: true, allowMentions, allowFollowing, allowListUris }
return {
canReply: true,
allowMentions,
allowFollower,
allowFollowing,
allowListUris,
}
}
}
return { allowMentions, allowFollowing, allowListUris }
return { allowMentions, allowFollower, allowFollowing, allowListUris }
}
type ParsedThreadGate = {
canReply?: boolean
allowMentions?: boolean
allowFollower?: boolean
allowFollowing?: boolean
allowListUris?: string[]
}

@ -16,6 +16,24 @@ Object {
}
`;
exports[`views with thread gating applies gate for follower rule. 1`] = `
Object {
"cid": "cids(0)",
"lists": Array [],
"record": Object {
"$type": "app.bsky.feed.threadgate",
"allow": Array [
Object {
"$type": "app.bsky.feed.threadgate#followerRule",
},
],
"createdAt": "1970-01-01T00:00:00.000Z",
"post": "record(1)",
},
"uri": "record(0)",
}
`;
exports[`views with thread gating applies gate for following rule. 1`] = `
Object {
"cid": "cids(0)",
@ -123,6 +141,9 @@ Object {
Object {
"$type": "app.bsky.feed.threadgate#mentionRule",
},
Object {
"$type": "app.bsky.feed.threadgate#followerRule",
},
Object {
"$type": "app.bsky.feed.threadgate#followingRule",
},

@ -22,6 +22,11 @@ describe('views with thread gating', () => {
pdsAgent = network.pds.getClient()
sc = network.getSeedClient()
await basicSeed(sc)
await sc.createAccount('eve', {
handle: 'eve.test',
email: 'eve@eve.com',
password: 'hunter2',
})
await network.processAll()
})
@ -242,8 +247,72 @@ describe('views with thread gating', () => {
expect(reply.post.uri).toEqual(aliceReply.ref.uriStr)
})
it('applies gate for follower rule.', async () => {
const post = await sc.post(sc.dids.carol, 'follower 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#followerRule' }],
},
sc.getHeaders(sc.dids.carol),
)
await network.processAll()
// dan does not follow carol, can't reply
await sc.reply(
sc.dids.dan,
post.ref,
post.ref,
'follower rule reply disallow',
)
// alice follows carol, can reply
const aliceReply = await sc.reply(
sc.dids.alice,
post.ref,
post.ref,
'follower 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')
const post = await sc.post(sc.dids.carol, 'list rule')
// setup lists to allow alice and dan
const listA = await pdsAgent.api.app.bsky.graph.list.create(
{ repo: sc.dids.carol },
@ -419,14 +488,21 @@ describe('views with thread gating', () => {
createdAt: iso(),
allow: [
{ $type: 'app.bsky.feed.threadgate#mentionRule' },
{ $type: 'app.bsky.feed.threadgate#followerRule' },
{ $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')
await sc.reply(sc.dids.eve, post.ref, post.ref, 'multi rule reply disallow')
const bobReply = await sc.reply(
sc.dids.bob,
post.ref,
post.ref,
'multi rule reply allow (follower)',
)
const aliceReply = await sc.reply(
sc.dids.alice,
post.ref,
@ -440,6 +516,23 @@ describe('views with thread gating', () => {
'multi rule reply allow (mention)',
)
await network.processAll()
const {
data: { thread: eveThread },
} = await agent.api.app.bsky.feed.getPostThread(
{ uri: post.ref.uriStr },
{
headers: await network.serviceHeaders(
sc.dids.eve,
ids.AppBskyFeedGetPostThread,
),
},
)
assert(isThreadViewPost(eveThread))
// eve cannot interact
expect(eveThread.post.viewer?.replyDisabled).toBe(true)
await checkReplyDisabled(post.ref.uriStr, sc.dids.eve, true)
const {
data: { thread: bobThread },
} = await agent.api.app.bsky.feed.getPostThread(
@ -452,8 +545,10 @@ describe('views with thread gating', () => {
},
)
assert(isThreadViewPost(bobThread))
expect(bobThread.post.viewer?.replyDisabled).toBe(true)
await checkReplyDisabled(post.ref.uriStr, sc.dids.bob, true)
// bob follows carol, followers can reply
expect(bobThread.post.viewer?.replyDisabled).toBe(false)
await checkReplyDisabled(post.ref.uriStr, sc.dids.bob, false)
const {
data: { thread: aliceThread },
} = await agent.api.app.bsky.feed.getPostThread(
@ -466,8 +561,10 @@ describe('views with thread gating', () => {
},
)
assert(isThreadViewPost(aliceThread))
// carol follows alice, followed users can reply
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(
@ -481,14 +578,17 @@ describe('views with thread gating', () => {
)
assert(isThreadViewPost(danThread))
expect(forSnapshot(danThread.post.threadgate)).toMatchSnapshot()
// dan was mentioned, mentioned users can reply
expect(danThread.post.viewer?.replyDisabled).toBe(false)
await checkReplyDisabled(post.ref.uriStr, sc.dids.dan, false)
const [reply1, reply2, ...otherReplies] = aliceThread.replies ?? []
const [reply1, reply2, reply3, ...otherReplies] = aliceThread.replies ?? []
assert(isThreadViewPost(reply1))
assert(isThreadViewPost(reply2))
assert(isThreadViewPost(reply3))
expect(otherReplies.length).toEqual(0)
expect([reply1.post.uri, reply2.post.uri].sort()).toEqual(
[aliceReply.ref.uriStr, danReply.ref.uriStr].sort(),
expect([reply1.post.uri, reply2.post.uri, reply3.post.uri].sort()).toEqual(
[aliceReply.ref.uriStr, danReply.ref.uriStr, bobReply.ref.uriStr].sort(),
)
})

@ -7376,6 +7376,7 @@ export const schemaDict = {
type: 'union',
refs: [
'lex:app.bsky.feed.threadgate#mentionRule',
'lex:app.bsky.feed.threadgate#followerRule',
'lex:app.bsky.feed.threadgate#followingRule',
'lex:app.bsky.feed.threadgate#listRule',
],
@ -7402,6 +7403,11 @@ export const schemaDict = {
description: 'Allow replies from actors mentioned in your post.',
properties: {},
},
followerRule: {
type: 'object',
description: 'Allow replies from actors who follow you.',
properties: {},
},
followingRule: {
type: 'object',
description: 'Allow replies from actors you follow.',

@ -12,6 +12,7 @@ export interface Record {
/** List of rules defining who can reply to this post. If value is an empty array, no one can reply. If value is undefined, anyone can reply. */
allow?: (
| MentionRule
| FollowerRule
| FollowingRule
| ListRule
| { $type: string; [k: string]: unknown }
@ -52,6 +53,23 @@ export function validateMentionRule(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.feed.threadgate#mentionRule', v)
}
/** Allow replies from actors who follow you. */
export interface FollowerRule {
[k: string]: unknown
}
export function isFollowerRule(v: unknown): v is FollowerRule {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.feed.threadgate#followerRule'
)
}
export function validateFollowerRule(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.feed.threadgate#followerRule', v)
}
/** Allow replies from actors you follow. */
export interface FollowingRule {
[k: string]: unknown

@ -7376,6 +7376,7 @@ export const schemaDict = {
type: 'union',
refs: [
'lex:app.bsky.feed.threadgate#mentionRule',
'lex:app.bsky.feed.threadgate#followerRule',
'lex:app.bsky.feed.threadgate#followingRule',
'lex:app.bsky.feed.threadgate#listRule',
],
@ -7402,6 +7403,11 @@ export const schemaDict = {
description: 'Allow replies from actors mentioned in your post.',
properties: {},
},
followerRule: {
type: 'object',
description: 'Allow replies from actors who follow you.',
properties: {},
},
followingRule: {
type: 'object',
description: 'Allow replies from actors you follow.',

@ -12,6 +12,7 @@ export interface Record {
/** List of rules defining who can reply to this post. If value is an empty array, no one can reply. If value is undefined, anyone can reply. */
allow?: (
| MentionRule
| FollowerRule
| FollowingRule
| ListRule
| { $type: string; [k: string]: unknown }
@ -52,6 +53,23 @@ export function validateMentionRule(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.feed.threadgate#mentionRule', v)
}
/** Allow replies from actors who follow you. */
export interface FollowerRule {
[k: string]: unknown
}
export function isFollowerRule(v: unknown): v is FollowerRule {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.feed.threadgate#followerRule'
)
}
export function validateFollowerRule(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.feed.threadgate#followerRule', v)
}
/** Allow replies from actors you follow. */
export interface FollowingRule {
[k: string]: unknown