Update muted words handling, add attributes ()

* 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 ()

* Check expiry when comparing mute words

* Check actors when comparing

* Tweak lex, condegen

* Integrate new prop

* Remove fake timers

(cherry picked from commit ad31910560ce938e3ff64944d46355c64635ebf8)

* 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
This commit is contained in:
Eric Bailey 2024-07-31 16:22:19 -05:00 committed by GitHub
parent 803d1b6c0d
commit 77c5306d2a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 1135 additions and 284 deletions
.changeset
lexicons/app/bsky/actor
packages
api
bsky/src/lexicon
lexicons.ts
types/app/bsky/actor
ozone/src/lexicon
lexicons.ts
types/app/bsky/actor
pds/src/lexicon
lexicons.ts
types/app/bsky/actor

@ -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.

@ -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.",
@ -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."
}
}
},

@ -9,6 +9,7 @@ import {
AppBskyLabelerDefs,
ComAtprotoRepoPutRecord,
} from './client'
import { MutedWord } from './client/types/app/bsky/actor/defs'
import {
BskyPreferences,
BskyFeedViewPreference,
@ -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
@ -937,7 +946,19 @@ 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) =>
@ -945,40 +966,27 @@ export class BskyAgent extends AtpAgent {
AppBskyActorDefs.validateMutedWordsPref(pref).success,
)
const newMutedWord: AppBskyActorDefs.MutedWord = {
id: TID.nextStr(),
value: sanitizedValue,
targets: mutedWord.targets || [],
actorTarget: mutedWord.actorTarget || 'all',
expiresAt: mutedWord.expiresAt || undefined,
}
if (mutedWordsPref && AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)) {
for (const updatedWord of newMutedWords) {
let foundMatch = false
const sanitizedUpdatedValue = sanitizeMutedWordValue(
updatedWord.value,
)
mutedWordsPref.items.push(newMutedWord)
// was trimmed down to an empty string e.g. single `#`
if (!sanitizedUpdatedValue) continue
for (const existingItem of mutedWordsPref.items) {
if (existingItem.value === sanitizedUpdatedValue) {
existingItem.targets = Array.from(
new Set([...existingItem.targets, ...updatedWord.targets]),
)
foundMatch = true
break
}
}
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],
}
}
@ -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(
@ -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(
@ -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')
}
@ -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
}

@ -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.',
@ -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: {

@ -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
}

@ -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[])
@ -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

@ -141,6 +141,8 @@ function checkMutedWords(
return false
}
const postAuthor = subject.author
if (AppBskyFeedPost.isRecord(subject.record)) {
// post text
if (
@ -150,6 +152,7 @@ function checkMutedWords(
facets: subject.record.facets,
outlineTags: subject.record.tags,
languages: subject.record.langs,
actor: postAuthor,
})
) {
return true
@ -166,6 +169,7 @@ function checkMutedWords(
mutedWords,
text: image.alt,
languages: subject.record.langs,
actor: postAuthor,
})
) {
return true
@ -179,6 +183,7 @@ function checkMutedWords(
if (AppBskyEmbedRecord.isViewRecord(subject.embed.record)) {
if (AppBskyFeedPost.isRecord(subject.embed.record.value)) {
const embeddedPost = subject.embed.record.value
const embedAuthor = subject.embed.record.author
// quoted post text
if (
@ -188,6 +193,7 @@ function checkMutedWords(
facets: embeddedPost.facets,
outlineTags: embeddedPost.tags,
languages: embeddedPost.langs,
actor: embedAuthor,
})
) {
return true
@ -201,6 +207,7 @@ function checkMutedWords(
mutedWords,
text: image.alt,
languages: embeddedPost.langs,
actor: embedAuthor,
})
) {
return true
@ -216,6 +223,7 @@ function checkMutedWords(
mutedWords,
text: external.title + ' ' + external.description,
languages: [],
actor: embedAuthor,
})
) {
return true
@ -231,6 +239,7 @@ function checkMutedWords(
mutedWords,
text: external.title + ' ' + external.description,
languages: [],
actor: embedAuthor,
})
) {
return true
@ -247,6 +256,7 @@ function checkMutedWords(
languages: AppBskyFeedPost.isRecord(embeddedPost.record)
? embeddedPost.langs
: [],
actor: embedAuthor,
})
) {
return true
@ -264,6 +274,7 @@ function checkMutedWords(
mutedWords,
text: external.title + ' ' + external.description,
languages: [],
actor: postAuthor,
})
) {
return true
@ -274,6 +285,8 @@ function checkMutedWords(
AppBskyEmbedRecordWithMedia.isView(subject.embed) &&
AppBskyEmbedRecord.isViewRecord(subject.embed.record.record)
) {
const embedAuthor = subject.embed.record.record.author
// quoted post text
if (AppBskyFeedPost.isRecord(subject.embed.record.record.value)) {
const post = subject.embed.record.record.value
@ -284,6 +297,7 @@ function checkMutedWords(
facets: post.facets,
outlineTags: post.tags,
languages: post.langs,
actor: embedAuthor,
})
) {
return true
@ -300,6 +314,7 @@ function checkMutedWords(
languages: AppBskyFeedPost.isRecord(subject.record)
? subject.record.langs
: [],
actor: embedAuthor,
})
) {
return true

@ -1751,14 +1751,6 @@ describe('agent', () => {
describe('muted words', () => {
let agent: BskyAgent
const mutedWords = [
{ value: 'both', targets: ['content', 'tag'] },
{ value: 'content', targets: ['content'] },
{ value: 'tag', targets: ['tag'] },
{ value: 'tag_then_both', targets: ['tag'] },
{ value: 'tag_then_content', targets: ['tag'] },
{ value: 'tag_then_none', targets: ['tag'] },
]
beforeAll(async () => {
agent = new BskyAgent({ service: network.pds.url })
@ -1769,214 +1761,591 @@ describe('agent', () => {
})
})
it('upsertMutedWords', async () => {
await agent.upsertMutedWords(mutedWords)
await agent.upsertMutedWords(mutedWords) // double
await expect(agent.getPreferences()).resolves.toHaveProperty(
'moderationPrefs.mutedWords',
mutedWords,
)
afterEach(async () => {
const { moderationPrefs } = await agent.getPreferences()
await agent.removeMutedWords(moderationPrefs.mutedWords)
})
it('upsertMutedWords with #', async () => {
await agent.upsertMutedWords([
{ value: 'hashtag', targets: ['content'] },
])
// is sanitized to `hashtag`
await agent.upsertMutedWords([{ value: '#hashtag', targets: ['tag'] }])
describe('addMutedWord', () => {
it('inserts', async () => {
const expiresAt = new Date(Date.now() + 6e3).toISOString()
await agent.addMutedWord({
value: 'word',
targets: ['content'],
actorTarget: 'all',
expiresAt,
})
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
const { moderationPrefs } = await agent.getPreferences()
const word = moderationPrefs.mutedWords.find(
(m) => m.value === 'word',
)
expect(mutedWords.find((m) => m.value === '#hashtag')).toBeFalsy()
// merged with existing
expect(mutedWords.find((m) => m.value === 'hashtag')).toStrictEqual({
value: 'hashtag',
targets: ['content', 'tag'],
expect(word!.id).toBeTruthy()
expect(word!.targets).toEqual(['content'])
expect(word!.actorTarget).toEqual('all')
expect(word!.expiresAt).toEqual(expiresAt)
})
it('single-hash #, no insert', async () => {
await agent.addMutedWord({
value: '#',
targets: [],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
// sanitized to empty string, not inserted
expect(moderationPrefs.mutedWords.length).toEqual(0)
})
it('multi-hash ##, inserts #', async () => {
await agent.addMutedWord({
value: '##',
targets: [],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
expect(
moderationPrefs.mutedWords.find((m) => m.value === '#'),
).toBeTruthy()
})
it('multi-hash ##hashtag, inserts #hashtag', async () => {
await agent.addMutedWord({
value: '##hashtag',
targets: [],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
expect(
moderationPrefs.mutedWords.find((w) => w.value === '#hashtag'),
).toBeTruthy()
})
it('hash emoji #️⃣, inserts #️⃣', async () => {
await agent.addMutedWord({
value: '#️⃣',
targets: [],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
expect(
moderationPrefs.mutedWords.find((m) => m.value === '#️⃣'),
).toBeTruthy()
})
it('hash emoji w/leading hash ##️⃣, inserts #️⃣', async () => {
await agent.addMutedWord({
value: '##️⃣',
targets: [],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
expect(
moderationPrefs.mutedWords.find((m) => m.value === '#️⃣'),
).toBeTruthy()
})
it('hash emoji with double leading hash ###️⃣, inserts ##️⃣', async () => {
await agent.addMutedWord({
value: '###️⃣',
targets: [],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
expect(
moderationPrefs.mutedWords.find((m) => m.value === '##️⃣'),
).toBeTruthy()
})
it(`includes apostrophes e.g. Bluesky's`, async () => {
await agent.addMutedWord({
value: `Bluesky's`,
targets: [],
actorTarget: 'all',
})
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === `Bluesky's`)).toBeTruthy()
})
describe(`invalid characters`, () => {
it('#<zws>, no insert', async () => {
await agent.addMutedWord({
value: '#',
targets: [],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
expect(moderationPrefs.mutedWords.length).toEqual(0)
})
it('#<zws>ab, inserts ab', async () => {
await agent.addMutedWord({
value: '#ab',
targets: [],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
expect(moderationPrefs.mutedWords.length).toEqual(1)
})
it('phrase with newline, inserts phrase without newline', async () => {
await agent.addMutedWord({
value: 'test value\n with newline',
targets: [],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
expect(
moderationPrefs.mutedWords.find(
(m) => m.value === 'test value with newline',
),
).toBeTruthy()
})
it('phrase with newlines, inserts phrase without newlines', async () => {
await agent.addMutedWord({
value: 'test value\n\r with newline',
targets: [],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
expect(
moderationPrefs.mutedWords.find(
(m) => m.value === 'test value with newline',
),
).toBeTruthy()
})
it('empty space, no insert', async () => {
await agent.addMutedWord({
value: ' ',
targets: [],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
expect(moderationPrefs.mutedWords.length).toEqual(0)
})
it(`' trim ', inserts 'trim'`, async () => {
await agent.addMutedWord({
value: ' trim ',
targets: [],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
expect(
moderationPrefs.mutedWords.find((m) => m.value === 'trim'),
).toBeTruthy()
})
})
// only one added
expect(mutedWords.filter((m) => m.value === 'hashtag').length).toBe(1)
})
it('updateMutedWord', async () => {
await agent.updateMutedWord({
value: 'tag_then_content',
targets: ['content'],
})
await agent.updateMutedWord({
value: 'tag_then_both',
targets: ['content', 'tag'],
})
await agent.updateMutedWord({ value: 'tag_then_none', targets: [] })
await agent.updateMutedWord({ value: 'no_exist', targets: ['tag'] })
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
describe('addMutedWords', () => {
it('inserts happen sequentially, no clobbering', async () => {
await agent.addMutedWords([
{ value: 'a', targets: ['content'], actorTarget: 'all' },
{ value: 'b', targets: ['content'], actorTarget: 'all' },
{ value: 'c', targets: ['content'], actorTarget: 'all' },
])
expect(
mutedWords.find((m) => m.value === 'tag_then_content'),
).toHaveProperty('targets', ['content'])
expect(
mutedWords.find((m) => m.value === 'tag_then_both'),
).toHaveProperty('targets', ['content', 'tag'])
expect(
mutedWords.find((m) => m.value === 'tag_then_none'),
).toHaveProperty('targets', [])
expect(mutedWords.find((m) => m.value === 'no_exist')).toBeFalsy()
const { moderationPrefs } = await agent.getPreferences()
expect(moderationPrefs.mutedWords.length).toEqual(3)
})
})
it('updateMutedWord with #, does not update', async () => {
await agent.upsertMutedWords([
{
value: '#just_a_tag',
describe('upsertMutedWords (deprecated)', () => {
it('no longer upserts, calls addMutedWords', async () => {
await agent.upsertMutedWords([
{ value: 'both', targets: ['content'], actorTarget: 'all' },
])
await agent.upsertMutedWords([
{ value: 'both', targets: ['tag'], actorTarget: 'all' },
])
const { moderationPrefs } = await agent.getPreferences()
expect(moderationPrefs.mutedWords.length).toEqual(2)
})
})
describe('updateMutedWord', () => {
it(`word doesn't exist, no update or insert`, async () => {
await agent.updateMutedWord({
value: 'word',
targets: ['tag', 'content'],
actorTarget: 'all',
})
const { moderationPrefs } = await agent.getPreferences()
expect(moderationPrefs.mutedWords.length).toEqual(0)
})
it('updates and sanitizes new value', async () => {
await agent.addMutedWord({
value: 'value',
targets: ['content'],
actorTarget: 'all',
})
const a = await agent.getPreferences()
const word = a.moderationPrefs.mutedWords.find(
(m) => m.value === 'value',
)
await agent.updateMutedWord({
...word!,
value: '#new value',
})
const b = await agent.getPreferences()
const updatedWord = b.moderationPrefs.mutedWords.find(
(m) => m.id === word!.id,
)
expect(updatedWord!.value).toEqual('new value')
expect(updatedWord).toHaveProperty('targets', ['content'])
})
it('updates targets', async () => {
await agent.addMutedWord({
value: 'word',
targets: ['tag'],
},
])
await agent.updateMutedWord({
value: '#just_a_tag',
targets: ['tag', 'content'],
})
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === 'just_a_tag')).toStrictEqual({
value: 'just_a_tag',
targets: ['tag'],
})
})
actorTarget: 'all',
})
it('removeMutedWord', async () => {
await agent.removeMutedWord({ value: 'tag_then_content', targets: [] })
await agent.removeMutedWord({ value: 'tag_then_both', targets: [] })
await agent.removeMutedWord({ value: 'tag_then_none', targets: [] })
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
const a = await agent.getPreferences()
const word = a.moderationPrefs.mutedWords.find(
(m) => m.value === 'word',
)
expect(
mutedWords.find((m) => m.value === 'tag_then_content'),
).toBeFalsy()
expect(mutedWords.find((m) => m.value === 'tag_then_both')).toBeFalsy()
expect(mutedWords.find((m) => m.value === 'tag_then_none')).toBeFalsy()
})
await agent.updateMutedWord({
...word!,
targets: ['content'],
})
it('removeMutedWord with #, no match, no removal', async () => {
await agent.removeMutedWord({ value: '#hashtag', targets: [] })
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
// was inserted with #hashtag, but we don't sanitize on remove
expect(mutedWords.find((m) => m.value === 'hashtag')).toBeTruthy()
})
it('single-hash #', async () => {
const prev = (await agent.getPreferences()).moderationPrefs
const length = prev.mutedWords.length
await agent.upsertMutedWords([{ value: '#', targets: [] }])
const end = (await agent.getPreferences()).moderationPrefs
// sanitized to empty string, not inserted
expect(end.mutedWords.length).toEqual(length)
})
it('multi-hash ##', async () => {
await agent.upsertMutedWords([{ value: '##', targets: [] }])
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === '#')).toBeTruthy()
})
it('multi-hash ##hashtag', async () => {
await agent.upsertMutedWords([{ value: '##hashtag', targets: [] }])
const a = (await agent.getPreferences()).moderationPrefs
expect(a.mutedWords.find((w) => w.value === '#hashtag')).toBeTruthy()
await agent.removeMutedWord({ value: '#hashtag', targets: [] })
const b = (await agent.getPreferences()).moderationPrefs
expect(b.mutedWords.find((w) => w.value === '#hashtag')).toBeFalsy()
})
it('hash emoji #️⃣', async () => {
await agent.upsertMutedWords([{ value: '#️⃣', targets: [] }])
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === '#️⃣')).toBeTruthy()
await agent.removeMutedWord({ value: '#️⃣', targets: [] })
const end = (await agent.getPreferences()).moderationPrefs
expect(end.mutedWords.find((m) => m.value === '#️⃣')).toBeFalsy()
})
it('hash emoji ##️⃣', async () => {
await agent.upsertMutedWords([{ value: '##️⃣', targets: [] }])
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === '#️⃣')).toBeTruthy()
await agent.removeMutedWord({ value: '#️⃣', targets: [] })
const end = (await agent.getPreferences()).moderationPrefs
expect(end.mutedWords.find((m) => m.value === '#️⃣')).toBeFalsy()
})
it('hash emoji ###️⃣', async () => {
await agent.upsertMutedWords([{ value: '###️⃣', targets: [] }])
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === '##️⃣')).toBeTruthy()
await agent.removeMutedWord({ value: '##️⃣', targets: [] })
const end = (await agent.getPreferences()).moderationPrefs
expect(end.mutedWords.find((m) => m.value === '##️⃣')).toBeFalsy()
})
it(`apostrophe: Bluesky's`, async () => {
await agent.upsertMutedWords([{ value: `Bluesky's`, targets: [] }])
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.find((m) => m.value === `Bluesky's`)).toBeTruthy()
})
describe(`invalid characters`, () => {
it('zero width space', async () => {
const prev = (await agent.getPreferences()).moderationPrefs
const length = prev.mutedWords.length
await agent.upsertMutedWords([{ value: '#', targets: [] }])
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
expect(mutedWords.length).toEqual(length)
})
it('newline', async () => {
await agent.upsertMutedWords([
{ value: 'test value\n with newline', targets: [] },
])
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
const b = await agent.getPreferences()
expect(
mutedWords.find((m) => m.value === 'test value with newline'),
).toBeTruthy()
b.moderationPrefs.mutedWords.find((m) => m.id === word!.id),
).toHaveProperty('targets', ['content'])
})
it('newline(s)', async () => {
await agent.upsertMutedWords([
{ value: 'test value\n\r with newline', targets: [] },
])
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
it('updates actorTarget', async () => {
await agent.addMutedWord({
value: 'value',
targets: ['content'],
actorTarget: 'all',
})
const a = await agent.getPreferences()
const word = a.moderationPrefs.mutedWords.find(
(m) => m.value === 'value',
)
await agent.updateMutedWord({
...word!,
actorTarget: 'exclude-following',
})
const b = await agent.getPreferences()
expect(
mutedWords.find((m) => m.value === 'test value with newline'),
b.moderationPrefs.mutedWords.find((m) => m.id === word!.id),
).toHaveProperty('actorTarget', 'exclude-following')
})
it('updates expiresAt', async () => {
const expiresAt = new Date(Date.now() + 6e3).toISOString()
const expiresAt2 = new Date(Date.now() + 10e3).toISOString()
await agent.addMutedWord({
value: 'value',
targets: ['content'],
expiresAt,
actorTarget: 'all',
})
const a = await agent.getPreferences()
const word = a.moderationPrefs.mutedWords.find(
(m) => m.value === 'value',
)
await agent.updateMutedWord({
...word!,
expiresAt: expiresAt2,
})
const b = await agent.getPreferences()
expect(
b.moderationPrefs.mutedWords.find((m) => m.id === word!.id),
).toHaveProperty('expiresAt', expiresAt2)
})
it(`doesn't update if value is sanitized to be falsy`, async () => {
await agent.addMutedWord({
value: 'rug',
targets: ['content'],
actorTarget: 'all',
})
const a = await agent.getPreferences()
const word = a.moderationPrefs.mutedWords.find(
(m) => m.value === 'rug',
)
await agent.updateMutedWord({
...word!,
value: '',
})
const b = await agent.getPreferences()
expect(
b.moderationPrefs.mutedWords.find((m) => m.id === word!.id),
).toHaveProperty('value', 'rug')
})
})
describe('removeMutedWord', () => {
it('removes word', async () => {
await agent.addMutedWord({
value: 'word',
targets: ['tag'],
actorTarget: 'all',
})
const a = await agent.getPreferences()
const word = a.moderationPrefs.mutedWords.find(
(m) => m.value === 'word',
)
await agent.removeMutedWord(word!)
const b = await agent.getPreferences()
expect(
b.moderationPrefs.mutedWords.find((m) => m.id === word!.id),
).toBeFalsy()
})
it(`word doesn't exist, no action`, async () => {
await agent.addMutedWord({
value: 'word',
targets: ['tag'],
actorTarget: 'all',
})
const a = await agent.getPreferences()
const word = a.moderationPrefs.mutedWords.find(
(m) => m.value === 'word',
)
await agent.removeMutedWord({
value: 'another',
targets: [],
actorTarget: 'all',
})
const b = await agent.getPreferences()
expect(
b.moderationPrefs.mutedWords.find((m) => m.id === word!.id),
).toBeTruthy()
})
})
it('empty space', async () => {
await agent.upsertMutedWords([{ value: ' ', targets: [] }])
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
describe('removeMutedWords', () => {
it(`removes sequentially, no clobbering`, async () => {
await agent.addMutedWords([
{ value: 'a', targets: ['content'], actorTarget: 'all ' },
{ value: 'b', targets: ['content'], actorTarget: 'all ' },
{ value: 'c', targets: ['content'], actorTarget: 'all ' },
])
expect(mutedWords.find((m) => m.value === ' ')).toBeFalsy()
const a = await agent.getPreferences()
await agent.removeMutedWords(a.moderationPrefs.mutedWords)
const b = await agent.getPreferences()
expect(b.moderationPrefs.mutedWords.length).toEqual(0)
})
})
})
it('leading/trailing space', async () => {
await agent.upsertMutedWords([{ value: ' trim ', targets: [] }])
const { mutedWords } = (await agent.getPreferences()).moderationPrefs
describe('legacy muted words', () => {
let agent: BskyAgent
expect(mutedWords.find((m) => m.value === 'trim')).toBeTruthy()
async function updatePreferences(
agent: BskyAgent,
cb: (
prefs: AppBskyActorDefs.Preferences,
) => AppBskyActorDefs.Preferences | false,
) {
const res = await agent.app.bsky.actor.getPreferences({})
const newPrefs = cb(res.data.preferences)
if (newPrefs === false) {
return
}
await agent.app.bsky.actor.putPreferences({
preferences: newPrefs,
})
}
async function addLegacyMutedWord(mutedWord: AppBskyActorDefs.MutedWord) {
await updatePreferences(agent, (prefs) => {
let mutedWordsPref = prefs.findLast(
(pref) =>
AppBskyActorDefs.isMutedWordsPref(pref) &&
AppBskyActorDefs.validateMutedWordsPref(pref).success,
)
const newMutedWord: AppBskyActorDefs.MutedWord = {
value: mutedWord.value,
targets: mutedWord.targets,
actorTarget: 'all',
}
if (
mutedWordsPref &&
AppBskyActorDefs.isMutedWordsPref(mutedWordsPref)
) {
mutedWordsPref.items.push(newMutedWord)
} else {
// if the pref doesn't exist, create it
mutedWordsPref = {
items: [newMutedWord],
}
}
return prefs
.filter((p) => !AppBskyActorDefs.isMutedWordsPref(p))
.concat([
{
...mutedWordsPref,
$type: 'app.bsky.actor.defs#mutedWordsPref',
},
])
})
}
beforeAll(async () => {
agent = new BskyAgent({ service: network.pds.url })
await agent.createAccount({
handle: 'user7-1.test',
email: 'user7-1@test.com',
password: 'password',
})
})
afterEach(async () => {
const { moderationPrefs } = await agent.getPreferences()
await agent.removeMutedWords(moderationPrefs.mutedWords)
})
describe(`upsertMutedWords (and addMutedWord)`, () => {
it(`adds new word, migrates old words`, async () => {
await addLegacyMutedWord({
value: 'word',
targets: ['content'],
actorTarget: 'all',
})
{
const { moderationPrefs } = await agent.getPreferences()
const word = moderationPrefs.mutedWords.find(
(w) => w.value === 'word',
)
expect(word).toBeTruthy()
expect(word!.id).toBeFalsy()
}
await agent.upsertMutedWords([
{ value: 'word2', targets: ['tag'], actorTarget: 'all' },
])
{
const { moderationPrefs } = await agent.getPreferences()
const word = moderationPrefs.mutedWords.find(
(w) => w.value === 'word',
)
const word2 = moderationPrefs.mutedWords.find(
(w) => w.value === 'word2',
)
expect(word!.id).toBeTruthy()
expect(word2!.id).toBeTruthy()
}
})
})
describe(`updateMutedWord`, () => {
it(`updates legacy word, migrates old words`, async () => {
await addLegacyMutedWord({
value: 'word',
targets: ['content'],
actorTarget: 'all',
})
await addLegacyMutedWord({
value: 'word2',
targets: ['tag'],
actorTarget: 'all',
})
await agent.updateMutedWord({
value: 'word',
targets: ['tag'],
actorTarget: 'all',
})
{
const { moderationPrefs } = await agent.getPreferences()
const word = moderationPrefs.mutedWords.find(
(w) => w.value === 'word',
)
const word2 = moderationPrefs.mutedWords.find(
(w) => w.value === 'word2',
)
expect(moderationPrefs.mutedWords.length).toEqual(2)
expect(word!.id).toBeTruthy()
expect(word!.targets).toEqual(['tag'])
expect(word2!.id).toBeTruthy()
}
})
})
describe(`removeMutedWord`, () => {
it(`removes legacy word, migrates old words`, async () => {
await addLegacyMutedWord({
value: 'word',
targets: ['content'],
actorTarget: 'all',
})
await addLegacyMutedWord({
value: 'word2',
targets: ['tag'],
actorTarget: 'all',
})
await agent.removeMutedWord({
value: 'word',
targets: ['tag'],
actorTarget: 'all',
})
{
const { moderationPrefs } = await agent.getPreferences()
const word = moderationPrefs.mutedWords.find(
(w) => w.value === 'word',
)
const word2 = moderationPrefs.mutedWords.find(
(w) => w.value === 'word2',
)
expect(moderationPrefs.mutedWords.length).toEqual(1)
expect(word).toBeFalsy()
expect(word2!.id).toBeTruthy()
}
})
})
})

@ -11,7 +11,9 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: 'outlineTag', targets: ['tag'] }],
mutedWords: [
{ value: 'outlineTag', targets: ['tag'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: ['outlineTag'],
@ -27,7 +29,9 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: 'inlineTag', targets: ['tag'] }],
mutedWords: [
{ value: 'inlineTag', targets: ['tag'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: ['outlineTag'],
@ -43,7 +47,9 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: 'inlineTag', targets: ['content'] }],
mutedWords: [
{ value: 'inlineTag', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: ['outlineTag'],
@ -59,7 +65,9 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: 'inlineTag', targets: ['tag'] }],
mutedWords: [
{ value: 'inlineTag', targets: ['tag'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -80,7 +88,7 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: '希', targets: ['content'] }],
mutedWords: [{ value: '希', targets: ['content'], actorTarget: 'all' }],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -96,7 +104,9 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: '☠︎', targets: ['content'] }],
mutedWords: [
{ value: '☠︎', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -112,7 +122,9 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: 'politics', targets: ['content'] }],
mutedWords: [
{ value: 'politics', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -128,7 +140,9 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: 'javascript', targets: ['content'] }],
mutedWords: [
{ value: 'javascript', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -146,7 +160,9 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: 'javascript', targets: ['content'] }],
mutedWords: [
{ value: 'javascript', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -162,7 +178,7 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: 'ai', targets: ['content'] }],
mutedWords: [{ value: 'ai', targets: ['content'], actorTarget: 'all' }],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -178,7 +194,9 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: 'brain', targets: ['content'] }],
mutedWords: [
{ value: 'brain', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -194,7 +212,7 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: `:)`, targets: ['content'] }],
mutedWords: [{ value: `:)`, targets: ['content'], actorTarget: 'all' }],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -213,7 +231,9 @@ describe(`hasMutedWord`, () => {
it(`match: yay!`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'yay!', targets: ['content'] }],
mutedWords: [
{ value: 'yay!', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -224,7 +244,9 @@ describe(`hasMutedWord`, () => {
it(`match: yay`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'yay', targets: ['content'] }],
mutedWords: [
{ value: 'yay', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -242,7 +264,9 @@ describe(`hasMutedWord`, () => {
it(`match: y!ppee`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'y!ppee', targets: ['content'] }],
mutedWords: [
{ value: 'y!ppee', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -254,7 +278,9 @@ describe(`hasMutedWord`, () => {
// single exclamation point, source has double
it(`no match: y!ppee!`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'y!ppee!', targets: ['content'] }],
mutedWords: [
{ value: 'y!ppee!', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -272,7 +298,9 @@ describe(`hasMutedWord`, () => {
it(`match: Bluesky's`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `Bluesky's`, targets: ['content'] }],
mutedWords: [
{ value: `Bluesky's`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -283,7 +311,9 @@ describe(`hasMutedWord`, () => {
it(`match: Bluesky`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'Bluesky', targets: ['content'] }],
mutedWords: [
{ value: 'Bluesky', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -294,7 +324,9 @@ describe(`hasMutedWord`, () => {
it(`match: bluesky`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'bluesky', targets: ['content'] }],
mutedWords: [
{ value: 'bluesky', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -305,7 +337,9 @@ describe(`hasMutedWord`, () => {
it(`match: blueskys`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'blueskys', targets: ['content'] }],
mutedWords: [
{ value: 'blueskys', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -323,7 +357,9 @@ describe(`hasMutedWord`, () => {
it(`match: S@assy`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'S@assy', targets: ['content'] }],
mutedWords: [
{ value: 'S@assy', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -334,7 +370,9 @@ describe(`hasMutedWord`, () => {
it(`match: s@assy`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 's@assy', targets: ['content'] }],
mutedWords: [
{ value: 's@assy', targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -353,7 +391,13 @@ describe(`hasMutedWord`, () => {
// case insensitive
it(`match: new york times`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'new york times', targets: ['content'] }],
mutedWords: [
{
value: 'new york times',
targets: ['content'],
actorTarget: 'all',
},
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -371,7 +415,9 @@ describe(`hasMutedWord`, () => {
it(`match: !command`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `!command`, targets: ['content'] }],
mutedWords: [
{ value: `!command`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -382,7 +428,9 @@ describe(`hasMutedWord`, () => {
it(`match: command`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `command`, targets: ['content'] }],
mutedWords: [
{ value: `command`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -398,7 +446,9 @@ describe(`hasMutedWord`, () => {
rt.detectFacetsWithoutResolution()
const match = hasMutedWord({
mutedWords: [{ value: `!command`, targets: ['content'] }],
mutedWords: [
{ value: `!command`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -416,7 +466,9 @@ describe(`hasMutedWord`, () => {
it(`match: e/acc`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `e/acc`, targets: ['content'] }],
mutedWords: [
{ value: `e/acc`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -427,7 +479,9 @@ describe(`hasMutedWord`, () => {
it(`match: acc`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `acc`, targets: ['content'] }],
mutedWords: [
{ value: `acc`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -445,7 +499,9 @@ describe(`hasMutedWord`, () => {
it(`match: super-bad`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `super-bad`, targets: ['content'] }],
mutedWords: [
{ value: `super-bad`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -456,7 +512,9 @@ describe(`hasMutedWord`, () => {
it(`match: super`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `super`, targets: ['content'] }],
mutedWords: [
{ value: `super`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -467,7 +525,9 @@ describe(`hasMutedWord`, () => {
it(`match: bad`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `bad`, targets: ['content'] }],
mutedWords: [
{ value: `bad`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -478,7 +538,9 @@ describe(`hasMutedWord`, () => {
it(`match: super bad`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `super bad`, targets: ['content'] }],
mutedWords: [
{ value: `super bad`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -489,7 +551,9 @@ describe(`hasMutedWord`, () => {
it(`match: superbad`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `superbad`, targets: ['content'] }],
mutedWords: [
{ value: `superbad`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -508,7 +572,11 @@ describe(`hasMutedWord`, () => {
it(`match: idk what this would be`, () => {
const match = hasMutedWord({
mutedWords: [
{ value: `idk what this would be`, targets: ['content'] },
{
value: `idk what this would be`,
targets: ['content'],
actorTarget: 'all',
},
],
text: rt.text,
facets: rt.facets,
@ -522,7 +590,11 @@ describe(`hasMutedWord`, () => {
// extra word
const match = hasMutedWord({
mutedWords: [
{ value: `idk what this would be for`, targets: ['content'] },
{
value: `idk what this would be for`,
targets: ['content'],
actorTarget: 'all',
},
],
text: rt.text,
facets: rt.facets,
@ -535,7 +607,9 @@ describe(`hasMutedWord`, () => {
it(`match: idk`, () => {
// extra word
const match = hasMutedWord({
mutedWords: [{ value: `idk`, targets: ['content'] }],
mutedWords: [
{ value: `idk`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -546,7 +620,13 @@ describe(`hasMutedWord`, () => {
it(`match: idkwhatthiswouldbe`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `idkwhatthiswouldbe`, targets: ['content'] }],
mutedWords: [
{
value: `idkwhatthiswouldbe`,
targets: ['content'],
actorTarget: 'all',
},
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -564,7 +644,13 @@ describe(`hasMutedWord`, () => {
it(`match: context(iykyk)`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `context(iykyk)`, targets: ['content'] }],
mutedWords: [
{
value: `context(iykyk)`,
targets: ['content'],
actorTarget: 'all',
},
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -575,7 +661,9 @@ describe(`hasMutedWord`, () => {
it(`match: context`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `context`, targets: ['content'] }],
mutedWords: [
{ value: `context`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -586,7 +674,9 @@ describe(`hasMutedWord`, () => {
it(`match: iykyk`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `iykyk`, targets: ['content'] }],
mutedWords: [
{ value: `iykyk`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -597,7 +687,9 @@ describe(`hasMutedWord`, () => {
it(`match: (iykyk)`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `(iykyk)`, targets: ['content'] }],
mutedWords: [
{ value: `(iykyk)`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -615,7 +707,9 @@ describe(`hasMutedWord`, () => {
it(`match: 🦋`, () => {
const match = hasMutedWord({
mutedWords: [{ value: `🦋`, targets: ['content'] }],
mutedWords: [
{ value: `🦋`, targets: ['content'], actorTarget: 'all' },
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -635,7 +729,13 @@ describe(`hasMutedWord`, () => {
it(`match: stop worrying`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'stop worrying', targets: ['content'] }],
mutedWords: [
{
value: 'stop worrying',
targets: ['content'],
actorTarget: 'all',
},
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -646,7 +746,13 @@ describe(`hasMutedWord`, () => {
it(`match: turtles, or how`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'turtles, or how', targets: ['content'] }],
mutedWords: [
{
value: 'turtles, or how',
targets: ['content'],
actorTarget: 'all',
},
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -668,7 +774,13 @@ describe(`hasMutedWord`, () => {
// internet
it(`match: インターネット`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'インターネット', targets: ['content'] }],
mutedWords: [
{
value: 'インターネット',
targets: ['content'],
actorTarget: 'all',
},
],
text: rt.text,
facets: rt.facets,
outlineTags: [],
@ -683,7 +795,9 @@ describe(`hasMutedWord`, () => {
describe(`facet with multiple features`, () => {
it(`multiple tags`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'bad', targets: ['content'] }],
mutedWords: [
{ value: 'bad', targets: ['content'], actorTarget: 'all' },
],
text: 'tags',
facets: [
{
@ -709,7 +823,9 @@ describe(`hasMutedWord`, () => {
it(`other features`, () => {
const match = hasMutedWord({
mutedWords: [{ value: 'bad', targets: ['content'] }],
mutedWords: [
{ value: 'bad', targets: ['content'], actorTarget: 'all' },
],
text: 'test',
facets: [
{
@ -753,7 +869,9 @@ describe(`hasMutedWord`, () => {
adultContentEnabled: false,
labels: {},
labelers: [],
mutedWords: [{ value: 'words', targets: ['content'] }],
mutedWords: [
{ value: 'words', targets: ['content'], actorTarget: 'all' },
],
hiddenPosts: [],
},
labelDefs: {},
@ -780,7 +898,9 @@ describe(`hasMutedWord`, () => {
adultContentEnabled: false,
labels: {},
labelers: [],
mutedWords: [{ value: 'words', targets: ['content'] }],
mutedWords: [
{ value: 'words', targets: ['content'], actorTarget: 'all' },
],
hiddenPosts: [],
},
labelDefs: {},
@ -811,7 +931,9 @@ describe(`hasMutedWord`, () => {
adultContentEnabled: false,
labels: {},
labelers: [],
mutedWords: [{ value: 'words', targets: ['tags'] }],
mutedWords: [
{ value: 'words', targets: ['tags'], actorTarget: 'all' },
],
hiddenPosts: [],
},
labelDefs: {},
@ -820,4 +942,140 @@ describe(`hasMutedWord`, () => {
expect(res.causes.length).toBe(0)
})
})
describe(`timed mute words`, () => {
it(`non-expired word`, () => {
const now = Date.now()
const res = moderatePost(
mock.postView({
record: mock.post({
text: 'Mute words!',
}),
author: mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
}),
labels: [],
}),
{
userDid: 'did:web:alice.test',
prefs: {
adultContentEnabled: false,
labels: {},
labelers: [],
mutedWords: [
{
value: 'words',
targets: ['content'],
expiresAt: new Date(now + 1e3).toISOString(),
actorTarget: 'all',
},
],
hiddenPosts: [],
},
labelDefs: {},
},
)
expect(res.causes[0].type).toBe('mute-word')
})
it(`expired word`, () => {
const now = Date.now()
const res = moderatePost(
mock.postView({
record: mock.post({
text: 'Mute words!',
}),
author: mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
}),
labels: [],
}),
{
userDid: 'did:web:alice.test',
prefs: {
adultContentEnabled: false,
labels: {},
labelers: [],
mutedWords: [
{
value: 'words',
targets: ['content'],
expiresAt: new Date(now - 1e3).toISOString(),
actorTarget: 'all',
},
],
hiddenPosts: [],
},
labelDefs: {},
},
)
expect(res.causes.length).toBe(0)
})
})
describe(`actor-based mute words`, () => {
const viewer = {
userDid: 'did:web:alice.test',
prefs: {
adultContentEnabled: false,
labels: {},
labelers: [],
mutedWords: [
{
value: 'words',
targets: ['content'],
actorTarget: 'exclude-following',
},
],
hiddenPosts: [],
},
labelDefs: {},
}
it(`followed actor`, () => {
const res = moderatePost(
mock.postView({
record: mock.post({
text: 'Mute words!',
}),
author: mock.profileViewBasic({
handle: 'bob.test',
displayName: 'Bob',
viewer: {
following: 'true',
},
}),
labels: [],
}),
viewer,
)
expect(res.causes.length).toBe(0)
})
it(`non-followed actor`, () => {
const res = moderatePost(
mock.postView({
record: mock.post({
text: 'Mute words!',
}),
author: mock.profileViewBasic({
handle: 'carla.test',
displayName: 'Carla',
viewer: {
following: undefined,
},
}),
labels: [],
}),
viewer,
)
expect(res.causes[0].type).toBe('mute-word')
})
})
})

@ -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.',
@ -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: {

@ -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
}

@ -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.',
@ -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: {

@ -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
}

@ -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.',
@ -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: {

@ -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
}