Skip to content

Commit

Permalink
Update muted words handling, add attributes (#2276)
Browse files Browse the repository at this point in the history
* Sketch proposal for additional muted words attributes

* Rename ttl -> expiresAt

* Feedback

* Codegen

* Refactor muted words methods to integrate new attributes

* Add changeset

* Use datetime format

* Simplify migration

* Fix tests

* Format

* Re-integrate tests

* Let the lock cook

* Fix comments

* Integrate mute words enhancements (#2643)

* Check expiry when comparing mute words

* Check actors when comparing

* Tweak lex, condegen

* Integrate new prop

* Remove fake timers

(cherry picked from commit ad31910)

* Update changeset

* Prevent deleting value when updating

* Include missing test

* Add default

* Apply default 'all' value to existing mute words to satisfy Typescript

* Fix types in tests

* Fix types on new tests
  • Loading branch information
estrattonbailey authored Jul 31, 2024
1 parent 803d1b6 commit 77c5306
Show file tree
Hide file tree
Showing 15 changed files with 1,102 additions and 251 deletions.
8 changes: 8 additions & 0 deletions .changeset/rotten-moose-switch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
'@atproto/ozone': patch
'@atproto/bsky': patch
'@atproto/api': patch
'@atproto/pds': patch
---

Updates muted words lexicons to include new attributes `id`, `actorTarget`, and `expiresAt`. Adds and updates methods in API SDK for better management of muted words.
12 changes: 12 additions & 0 deletions lexicons/app/bsky/actor/defs.json
Original file line number Diff line number Diff line change
Expand Up @@ -330,6 +330,7 @@
"description": "A word that the account owner has muted.",
"required": ["value", "targets"],
"properties": {
"id": { "type": "string" },
"value": {
"type": "string",
"description": "The muted word itself.",
Expand All @@ -343,6 +344,17 @@
"type": "ref",
"ref": "app.bsky.actor.defs#mutedWordTarget"
}
},
"actorTarget": {
"type": "string",
"description": "Groups of users to apply the muted word to. If undefined, applies to all users.",
"knownValues": ["all", "exclude-following"],
"default": "all"
},
"expiresAt": {
"type": "string",
"format": "datetime",
"description": "The date and time at which the muted word will expire and no longer be applied."
}
}
},
Expand Down
184 changes: 139 additions & 45 deletions packages/api/src/bsky-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
AppBskyLabelerDefs,
ComAtprotoRepoPutRecord,
} from './client'
import { MutedWord } from './client/types/app/bsky/actor/defs'
import {
BskyPreferences,
BskyFeedViewPreference,
Expand Down Expand Up @@ -477,6 +478,14 @@ export class BskyAgent extends AtpAgent {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { $type, ...v } = pref
prefs.moderationPrefs.mutedWords = v.items

if (prefs.moderationPrefs.mutedWords.length) {
prefs.moderationPrefs.mutedWords =
prefs.moderationPrefs.mutedWords.map((word) => {
word.actorTarget = word.actorTarget || 'all'
return word
})
}
} else if (
AppBskyActorDefs.isHiddenPostsPref(pref) &&
AppBskyActorDefs.validateHiddenPostsPref(pref).success
Expand Down Expand Up @@ -937,48 +946,47 @@ export class BskyAgent extends AtpAgent {
})
}

async upsertMutedWords(newMutedWords: AppBskyActorDefs.MutedWord[]) {
/**
* Add a muted word to user preferences.
*/
async addMutedWord(
mutedWord: Pick<
MutedWord,
'value' | 'targets' | 'actorTarget' | 'expiresAt'
>,
) {
const sanitizedValue = sanitizeMutedWordValue(mutedWord.value)

if (!sanitizedValue) return

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

if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) {
for (const updatedWord of newMutedWords) {
let foundMatch = false
const sanitizedUpdatedValue = sanitizeMutedWordValue(
updatedWord.value,
)

// was trimmed down to an empty string e.g. single `#`
if (!sanitizedUpdatedValue) continue
const newMutedWord: AppBskyActorDefs.MutedWord = {
id: TID.nextStr(),
value: sanitizedValue,
targets: mutedWord.targets || [],
actorTarget: mutedWord.actorTarget || 'all',
expiresAt: mutedWord.expiresAt || undefined,
}

for (const existingItem of mutedWordsPref.items) {
if (existingItem.value === sanitizedUpdatedValue) {
existingItem.targets = Array.from(
new Set([...existingItem.targets, ...updatedWord.targets]),
)
foundMatch = true
break
}
}
if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) {
mutedWordsPref.items.push(newMutedWord)

if (!foundMatch) {
mutedWordsPref.items.push({
...updatedWord,
value: sanitizedUpdatedValue,
})
}
}
/**
* Migrate any old muted words that don't have an id
*/
mutedWordsPref.items = migrateLegacyMutedWordsItems(
mutedWordsPref.items,
)
} else {
// if the pref doesn't exist, create it
mutedWordsPref = {
items: newMutedWords.map((w) => ({
...w,
value: sanitizeMutedWordValue(w.value),
})),
items: [newMutedWord],
}
}

Expand All @@ -990,6 +998,28 @@ export class BskyAgent extends AtpAgent {
})
}

/**
* Convenience method to add muted words to user preferences
*/
async addMutedWords(newMutedWords: AppBskyActorDefs.MutedWord[]) {
await Promise.all(newMutedWords.map((word) => this.addMutedWord(word)))
}

/**
* @deprecated use `addMutedWords` or `addMutedWord` instead
*/
async upsertMutedWords(
mutedWords: Pick<
MutedWord,
'value' | 'targets' | 'actorTarget' | 'expiresAt'
>[],
) {
await this.addMutedWords(mutedWords)
}

/**
* Update a muted word in user preferences.
*/
async updateMutedWord(mutedWord: AppBskyActorDefs.MutedWord) {
await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => {
const mutedWordsPref = prefs.findLast(
Expand All @@ -999,22 +1029,48 @@ export class BskyAgent extends AtpAgent {
)

if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) {
for (const existingItem of mutedWordsPref.items) {
if (existingItem.value === mutedWord.value) {
existingItem.targets = mutedWord.targets
break
mutedWordsPref.items = mutedWordsPref.items.map((existingItem) => {
const match = matchMutedWord(existingItem, mutedWord)

if (match) {
const updated = {
...existingItem,
...mutedWord,
}
return {
id: existingItem.id || TID.nextStr(),
value:
sanitizeMutedWordValue(updated.value) || existingItem.value,
targets: updated.targets || [],
actorTarget: updated.actorTarget || 'all',
expiresAt: updated.expiresAt || undefined,
}
} else {
return existingItem
}
}
})

/**
* Migrate any old muted words that don't have an id
*/
mutedWordsPref.items = migrateLegacyMutedWordsItems(
mutedWordsPref.items,
)

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

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

/**
* Remove a muted word from user preferences.
*/
async removeMutedWord(mutedWord: AppBskyActorDefs.MutedWord) {
await updatePreferences(this, (prefs: AppBskyActorDefs.Preferences) => {
const mutedWordsPref = prefs.findLast(
Expand All @@ -1025,22 +1081,39 @@ export class BskyAgent extends AtpAgent {

if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) {
for (let i = 0; i < mutedWordsPref.items.length; i++) {
const existing = mutedWordsPref.items[i]
if (existing.value === mutedWord.value) {
const match = matchMutedWord(mutedWordsPref.items[i], mutedWord)

if (match) {
mutedWordsPref.items.splice(i, 1)
break
}
}

/**
* Migrate any old muted words that don't have an id
*/
mutedWordsPref.items = migrateLegacyMutedWordsItems(
mutedWordsPref.items,
)

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

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

/**
* Convenience method to remove muted words from user preferences
*/
async removeMutedWords(mutedWords: AppBskyActorDefs.MutedWord[]) {
await Promise.all(mutedWords.map((word) => this.removeMutedWord(word)))
}

async hidePost(postUri: string) {
await updateHiddenPost(this, postUri, 'hide')
}
Expand Down Expand Up @@ -1369,3 +1442,24 @@ function isBskyPrefs(v: any): v is BskyPreferences {
function isModPrefs(v: any): v is ModerationPrefs {
return v && typeof v === 'object' && 'labelers' in v
}

function migrateLegacyMutedWordsItems(items: AppBskyActorDefs.MutedWord[]) {
return items.map((item) => ({
...item,
id: item.id || TID.nextStr(),
}))
}

function matchMutedWord(
existingWord: AppBskyActorDefs.MutedWord,
newWord: AppBskyActorDefs.MutedWord,
): boolean {
// id is undefined in legacy implementation
const existingId = existingWord.id
// prefer matching based on id
const matchById = existingId && existingId === newWord.id
// handle legacy case where id is not set
const legacyMatchByValue = !existingId && existingWord.value === newWord.value

return matchById || legacyMatchByValue
}
16 changes: 16 additions & 0 deletions packages/api/src/client/lexicons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4341,6 +4341,9 @@ export const schemaDict = {
description: 'A word that the account owner has muted.',
required: ['value', 'targets'],
properties: {
id: {
type: 'string',
},
value: {
type: 'string',
description: 'The muted word itself.',
Expand All @@ -4355,6 +4358,19 @@ export const schemaDict = {
ref: 'lex:app.bsky.actor.defs#mutedWordTarget',
},
},
actorTarget: {
type: 'string',
description:
'Groups of users to apply the muted word to. If undefined, applies to all users.',
knownValues: ['all', 'exclude-following'],
default: 'all',
},
expiresAt: {
type: 'string',
format: 'datetime',
description:
'The date and time at which the muted word will expire and no longer be applied.',
},
},
},
mutedWordsPref: {
Expand Down
5 changes: 5 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 @@ -370,10 +370,15 @@ export type MutedWordTarget = 'content' | 'tag' | (string & {})

/** A word that the account owner has muted. */
export interface MutedWord {
id?: string
/** The muted word itself. */
value: string
/** The intended targets of the muted word. */
targets: MutedWordTarget[]
/** Groups of users to apply the muted word to. If undefined, applies to all users. */
actorTarget: 'all' | 'exclude-following' | (string & {})
/** The date and time at which the muted word will expire and no longer be applied. */
expiresAt?: string
[k: string]: unknown
}

Expand Down
11 changes: 11 additions & 0 deletions packages/api/src/moderation/mutewords.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,14 @@ export function hasMutedWord({
facets,
outlineTags,
languages,
actor,
}: {
mutedWords: AppBskyActorDefs.MutedWord[]
text: string
facets?: AppBskyRichtextFacet.Main[]
outlineTags?: string[]
languages?: string[]
actor?: AppBskyActorDefs.ProfileView
}) {
const exception = LANGUAGE_EXCEPTIONS.includes(languages?.[0] || '')
const tags = ([] as string[])
Expand All @@ -48,6 +50,15 @@ export function hasMutedWord({
const mutedWord = mute.value.toLowerCase()
const postText = text.toLowerCase()

// expired, ignore
if (mute.expiresAt && mute.expiresAt < new Date().toISOString()) continue

if (
mute.actorTarget === 'exclude-following' &&
Boolean(actor?.viewer?.following)
)
continue

// `content` applies to tags as well
if (tags.includes(mutedWord)) return true
// rest of the checks are for `content` only
Expand Down
Loading

0 comments on commit 77c5306

Please sign in to comment.