Skip to content

Commit

Permalink
Muted items prefs (#2195)
Browse files Browse the repository at this point in the history
* Muted items prefs

* Add hidden posts

* Enhance

* Update to use smart objects

* Add 'any'

* Codegen

* Enhance

* Muted words methods

* Dry it up

* Format

* Add hidden posts methods

* Who codegens the codegens

* Sanitize tags, always compare bare strings

* Moar test

* Simplify

* Add test

* Add changeset
  • Loading branch information
estrattonbailey authored Feb 22, 2024
1 parent 9f90203 commit b607194
Show file tree
Hide file tree
Showing 13 changed files with 839 additions and 0 deletions.
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' }])
})
}
56 changes: 56 additions & 0 deletions packages/api/src/client/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5198,6 +5198,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
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)
}
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 / Build & Publish

'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 @@ export interface BskyPreferences {
contentLabels: Record<string, BskyLabelPreference>
birthDate: Date | undefined
interests: BskyInterestsPreference
mutedWords: AppBskyActorDefs.MutedWord[]
hiddenPosts: string[]
}
Loading

0 comments on commit b607194

Please sign in to comment.