Add followerRule threadgate (#3496)
* Add followerRule threadgate * changeset
This commit is contained in:
parent
799dd925e9
commit
dc8a7842e6
.changeset
lexicons/app/bsky/feed
packages
api/src/client
bsky
src
tests/views
ozone/src/lexicon
pds/src/lexicon
5
.changeset/angry-glasses-sin.md
Normal file
5
.changeset/angry-glasses-sin.md
Normal file
@ -0,0 +1,5 @@
|
||||
---
|
||||
"@atproto/api": patch
|
||||
---
|
||||
|
||||
Add followerRule threadgate
|
5
.changeset/silver-horses-rush.md
Normal file
5
.changeset/silver-horses-rush.md
Normal file
@ -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
|
||||
|
Loading…
x
Reference in New Issue
Block a user