Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Muted items prefs #2195

Merged
merged 17 commits into from
Feb 22, 2024
5 changes: 5 additions & 0 deletions .changeset/funny-elephants-listen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@atproto/api': patch
---

Add muted words/tags and hidden posts prefs and methods"
52 changes: 52 additions & 0 deletions lexicons/app/bsky/actor/defs.json
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,58 @@
"description": "A list of tags which describe the account owner's interests gathered during onboarding."
}
}
},
"mutedWordTarget": {
"type": "string",
"knownValues": ["content", "tag"],
"maxLength": 640,
"maxGraphemes": 64
},
"mutedWord": {
"type": "object",
"description": "A word that the account owner has muted.",
"required": ["value", "targets"],
"properties": {
"value": {
"type": "string",
"description": "The muted word itself.",
"maxLength": 10000,
"maxGraphemes": 1000
},
"targets": {
"type": "array",
"description": "The intended targets of the muted word.",
"items": {
"type": "ref",
"ref": "app.bsky.actor.defs#mutedWordTarget"
}
}
}
},
"mutedWordsPref": {
"type": "object",
"required": ["items"],
"properties": {
"items": {
"type": "array",
"items": {
"type": "ref",
"ref": "app.bsky.actor.defs#mutedWord"
},
"description": "A list of words the account owner has muted."
}
}
},
"hiddenPostsPref": {
"type": "object",
"required": ["items"],
"properties": {
"items": {
"type": "array",
"items": { "type": "string", "format": "at-uri" },
"description": "A list of URIs of posts the account owner has hidden."
}
}
}
}
}
136 changes: 136 additions & 0 deletions packages/api/src/bsky-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,8 @@ export class BskyAgent extends AtpAgent {
interests: {
tags: [],
},
mutedWords: [],
hiddenPosts: [],
}
const res = await this.app.bsky.actor.getPreferences({})
for (const pref of res.data.preferences) {
Expand Down Expand Up @@ -380,6 +382,20 @@ export class BskyAgent extends AtpAgent {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { $type, ...v } = pref
prefs.interests = { ...prefs.interests, ...v }
} else if (
AppBskyActorDefs.isMutedWordsPref(pref) &&
AppBskyActorDefs.validateMutedWordsPref(pref).success
) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { $type, ...v } = pref
prefs.mutedWords = v.items
} else if (
AppBskyActorDefs.isHiddenPostsPref(pref) &&
AppBskyActorDefs.validateHiddenPostsPref(pref).success
) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { $type, ...v } = pref
prefs.hiddenPosts = v.items
}
}
return prefs
Expand Down Expand Up @@ -548,6 +564,26 @@ export class BskyAgent extends AtpAgent {
.concat([{ ...pref, $type: 'app.bsky.actor.defs#interestsPref' }])
})
}

async upsertMutedWords(mutedWords: AppBskyActorDefs.MutedWord[]) {
await updateMutedWords(this, mutedWords, 'upsert')
}

async updateMutedWord(mutedWord: AppBskyActorDefs.MutedWord) {
await updateMutedWords(this, [mutedWord], 'update')
}

async removeMutedWord(mutedWord: AppBskyActorDefs.MutedWord) {
await updateMutedWords(this, [mutedWord], 'remove')
}

async hidePost(postUri: string) {
await updateHiddenPost(this, postUri, 'hide')
}

async unhidePost(postUri: string) {
await updateHiddenPost(this, postUri, 'unhide')
}
}

/**
Expand Down Expand Up @@ -609,3 +645,103 @@ async function updateFeedPreferences(
})
return res
}

/**
* A helper specifically for updating muted words preferences
*/
async function updateMutedWords(
agent: BskyAgent,
mutedWords: AppBskyActorDefs.MutedWord[],
action: 'upsert' | 'update' | 'remove',
) {
const sanitizeMutedWord = (word: AppBskyActorDefs.MutedWord) => ({
value: word.value.replace(/^#/, ''),
targets: word.targets,
})

await updatePreferences(agent, (prefs: AppBskyActorDefs.Preferences) => {
let mutedWordsPref = prefs.findLast(
(pref) =>
AppBskyActorDefs.isMutedWordsPref(pref) &&
AppBskyActorDefs.validateMutedWordsPref(pref).success,
)

if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) {
if (action === 'upsert' || action === 'update') {
for (const newItem of mutedWords) {
let foundMatch = false

for (const existingItem of mutedWordsPref.items) {
if (existingItem.value === newItem.value) {
existingItem.targets =
action === 'upsert'
? Array.from(
new Set([...existingItem.targets, ...newItem.targets]),
)
: newItem.targets
foundMatch = true
break
}
}

if (action === 'upsert' && !foundMatch) {
mutedWordsPref.items.push(sanitizeMutedWord(newItem))
}
}
} else if (action === 'remove') {
for (const word of mutedWords) {
for (let i = 0; i < mutedWordsPref.items.length; i++) {
const existing = mutedWordsPref.items[i]
if (existing.value === sanitizeMutedWord(word).value) {
mutedWordsPref.items.splice(i, 1)
break
}
}
}
}
} else {
// if the pref doesn't exist, create it
if (action === 'upsert') {
mutedWordsPref = {
items: mutedWords.map(sanitizeMutedWord),
}
}
}

return prefs
.filter((p) => !AppBskyActorDefs.isMutedWordsPref(p))
.concat([
{ ...mutedWordsPref, $type: 'app.bsky.actor.defs#mutedWordsPref' },
])
})
}

async function updateHiddenPost(
agent: BskyAgent,
postUri: string,
action: 'hide' | 'unhide',
) {
await updatePreferences(agent, (prefs: AppBskyActorDefs.Preferences) => {
let pref = prefs.findLast(
(pref) =>
AppBskyActorDefs.isHiddenPostsPref(pref) &&
AppBskyActorDefs.validateHiddenPostsPref(pref).success,
)
if (pref && AppBskyActorDefs.isHiddenPostsPref(pref)) {
pref.items =
action === 'hide'
? Array.from(new Set([...pref.items, postUri]))
: pref.items.filter((uri) => uri !== postUri)
} else {
if (action === 'hide') {
pref = {
$type: 'app.bsky.actor.defs#hiddenPostsPref',
items: [postUri],
}
}
}
return prefs
.filter((p) => !AppBskyActorDefs.isInterestsPref(p))
.concat([{ ...pref, $type: 'app.bsky.actor.defs#hiddenPostsPref' }])
})
}
59 changes: 58 additions & 1 deletion packages/api/src/client/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4974,6 +4974,62 @@ export const schemaDict = {
},
},
},
mutedWordTarget: {
type: 'string',
knownValues: ['content', 'tag'],
maxLength: 640,
maxGraphemes: 64,
},
mutedWord: {
type: 'object',
description: 'A word that the account owner has muted.',
required: ['value', 'targets'],
properties: {
value: {
type: 'string',
description: 'The muted word itself.',
maxLength: 10000,
maxGraphemes: 1000,
},
targets: {
type: 'array',
description: 'The intended targets of the muted word.',
items: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#mutedWordTarget',
},
},
},
},
mutedWordsPref: {
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: {
type: 'ref',
ref: 'lex:app.bsky.actor.defs#mutedWord',
},
description: 'A list of words the account owner has muted.',
},
},
},
hiddenPostsPref: {
type: 'object',
required: ['items'],
properties: {
items: {
type: 'array',
items: {
type: 'string',
format: 'at-uri',
},
description:
'A list of URIs of posts the account owner has hidden.',
},
},
},
},
},
AppBskyActorGetPreferences: {
Expand Down Expand Up @@ -6884,7 +6940,8 @@ export const schemaDict = {
},
tags: {
type: 'array',
description: 'Additional non-inline tags describing this post.',
description:
'Additional hashtags, in addition to any included in post text and facets.',
maxLength: 8,
items: {
type: 'string',
Expand Down
59 changes: 59 additions & 0 deletions packages/api/src/client/types/app/bsky/actor/defs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,3 +254,62 @@ export function isInterestsPref(v: unknown): v is InterestsPref {
export function validateInterestsPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#interestsPref', v)
}

export type MutedWordTarget = 'content' | 'tag' | (string & {})

/** A word that the account owner has muted. */
export interface MutedWord {
/** The muted word itself. */
value: string
/** The intended targets of the muted word. */
targets: MutedWordTarget[]
[k: string]: unknown
}

export function isMutedWord(v: unknown): v is MutedWord {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.actor.defs#mutedWord'
)
}

export function validateMutedWord(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#mutedWord', v)
}

export interface MutedWordsPref {
/** A list of words the account owner has muted. */
items: MutedWord[]
[k: string]: unknown
}

export function isMutedWordsPref(v: unknown): v is MutedWordsPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.actor.defs#mutedWordsPref'
)
}

export function validateMutedWordsPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#mutedWordsPref', v)
}

export interface HiddenPostsPref {
/** A list of URIs of posts the account owner has hidden. */
items: string[]
[k: string]: unknown
}

export function isHiddenPostsPref(v: unknown): v is HiddenPostsPref {
return (
isObj(v) &&
hasProp(v, '$type') &&
v.$type === 'app.bsky.actor.defs#hiddenPostsPref'
)
}

export function validateHiddenPostsPref(v: unknown): ValidationResult {
return lexicons.validate('app.bsky.actor.defs#hiddenPostsPref', v)
}
2 changes: 1 addition & 1 deletion packages/api/src/client/types/app/bsky/feed/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export interface Record {
labels?:
| ComAtprotoLabelDefs.SelfLabels
| { $type: string; [k: string]: unknown }
/** Additional non-inline tags describing this post. */
/** Additional hashtags, in addition to any included in post text and facets. */
tags?: string[]
/** Client-declared timestamp when this post was originally created. */
createdAt: string
Expand Down
3 changes: 3 additions & 0 deletions packages/api/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { AppBskyActorNS, AppBskyActorDefs } from './client'

Check warning on line 1 in packages/api/src/types.ts

View workflow job for this annotation

GitHub Actions / Verify

'AppBskyActorNS' is defined but never used. Allowed unused vars must match /^_/u
import { LabelPreference } from './moderation/types'

/**
Expand Down Expand Up @@ -119,4 +120,6 @@
contentLabels: Record<string, BskyLabelPreference>
birthDate: Date | undefined
interests: BskyInterestsPreference
mutedWords: AppBskyActorDefs.MutedWord[]
hiddenPosts: string[]
}
Loading
Loading