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

Update muted words handling, add attributes #2276

Merged
merged 24 commits into from
Jul 31, 2024
Merged
Show file tree
Hide file tree
Changes from 19 commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
429ea11
Sketch proposal for additional muted words attributes
estrattonbailey Mar 5, 2024
d1226ca
Rename ttl -> expiresAt
estrattonbailey Mar 5, 2024
c5ed433
Feedback
estrattonbailey Mar 5, 2024
452bace
Codegen
estrattonbailey Mar 5, 2024
8759cb6
Refactor muted words methods to integrate new attributes
estrattonbailey Mar 6, 2024
83b7087
Add changeset
estrattonbailey Mar 6, 2024
03d8a70
Use datetime format
estrattonbailey Mar 6, 2024
07a1553
Simplify migration
estrattonbailey Mar 6, 2024
ca359ca
Fix tests
estrattonbailey Mar 13, 2024
a7eb5bb
Format
estrattonbailey Mar 13, 2024
bbb1246
Merge remote-tracking branch 'origin/main' into eric/mute-words-enhan…
estrattonbailey Jul 16, 2024
8a98dda
Re-integrate tests
estrattonbailey Jul 16, 2024
69d5f21
Let the lock cook
estrattonbailey Jul 16, 2024
e46566a
Fix comments
estrattonbailey Jul 16, 2024
6c5ba0d
Integrate mute words enhancements (#2643)
estrattonbailey Jul 17, 2024
90e8343
Remove fake timers
estrattonbailey Jul 19, 2024
424df67
Update changeset
estrattonbailey Jul 19, 2024
194a1fd
Prevent deleting value when updating
estrattonbailey Jul 19, 2024
df20225
Include missing test
estrattonbailey Jul 23, 2024
c3124bb
Add default
estrattonbailey Jul 26, 2024
ef736cc
Apply default 'all' value to existing mute words to satisfy Typescript
estrattonbailey Jul 26, 2024
e59c3d1
Fix types in tests
estrattonbailey Jul 26, 2024
395c1ae
Merge remote-tracking branch 'origin/main' into eric/mute-words-enhan…
estrattonbailey Jul 26, 2024
3c2c06b
Fix types on new tests
estrattonbailey Jul 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.
11 changes: 11 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,16 @@
"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"]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

IIRC we have a "default" field that might be useful here to indicate what it does if nothing is specified

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah yeah we do! So interesting thing though: this removes the ? optionality of the prop actorTarget. Technically on first read, mute words aren't migrated, so actorTarget for old mute words will always be undefined.

In my latest commit, I opted to map over existing words and insert that default value if actorTarget is undefined.

Cool with that? If not, I think the move would be to remove the default to keep the type optional.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Huh. I dont recall why we wouldve done it that way, other than perhaps because we expected the default value to always get filled in.

I'm totally good w/what you did but up to you

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I like that it's a little tighter like this so gonna roll with it 👍

},
"expiresAt": {
"type": "string",
"format": "datetime",
"description": "The date and time at which the muted word will expire and no longer be applied."
}
}
},
Expand Down
176 changes: 131 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 @@ -937,48 +938,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 +990,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 +1021,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 +1073,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 +1434,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
}
15 changes: 15 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,18 @@ 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'],
},
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 @@ -52,6 +54,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