From 77c5306d2a40d7edd20def73163b8f93f3a30ee7 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 31 Jul 2024 16:22:19 -0500 Subject: [PATCH] Update muted words handling, add attributes (#2276) * 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 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 --- .changeset/rotten-moose-switch.md | 8 + lexicons/app/bsky/actor/defs.json | 12 + packages/api/src/bsky-agent.ts | 184 +++-- packages/api/src/client/lexicons.ts | 16 + .../src/client/types/app/bsky/actor/defs.ts | 5 + packages/api/src/moderation/mutewords.ts | 11 + packages/api/src/moderation/subjects/post.ts | 15 + packages/api/tests/bsky-agent.test.ts | 681 ++++++++++++++---- .../api/tests/moderation-mutewords.test.ts | 358 +++++++-- packages/bsky/src/lexicon/lexicons.ts | 16 + .../src/lexicon/types/app/bsky/actor/defs.ts | 5 + packages/ozone/src/lexicon/lexicons.ts | 16 + .../src/lexicon/types/app/bsky/actor/defs.ts | 5 + packages/pds/src/lexicon/lexicons.ts | 16 + .../src/lexicon/types/app/bsky/actor/defs.ts | 5 + 15 files changed, 1102 insertions(+), 251 deletions(-) create mode 100644 .changeset/rotten-moose-switch.md diff --git a/.changeset/rotten-moose-switch.md b/.changeset/rotten-moose-switch.md new file mode 100644 index 00000000000..0104fc08ec6 --- /dev/null +++ b/.changeset/rotten-moose-switch.md @@ -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. diff --git a/lexicons/app/bsky/actor/defs.json b/lexicons/app/bsky/actor/defs.json index f228fabadef..6ba7aaa734a 100644 --- a/lexicons/app/bsky/actor/defs.json +++ b/lexicons/app/bsky/actor/defs.json @@ -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." } } }, diff --git a/packages/api/src/bsky-agent.ts b/packages/api/src/bsky-agent.ts index 14510a7da36..b32390f6f66 100644 --- a/packages/api/src/bsky-agent.ts +++ b/packages/api/src/bsky-agent.ts @@ -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, ) - 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], } } @@ -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 +} diff --git a/packages/api/src/client/lexicons.ts b/packages/api/src/client/lexicons.ts index e4a3c8b90f8..b7783a15e8b 100644 --- a/packages/api/src/client/lexicons.ts +++ b/packages/api/src/client/lexicons.ts @@ -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: { diff --git a/packages/api/src/client/types/app/bsky/actor/defs.ts b/packages/api/src/client/types/app/bsky/actor/defs.ts index 07c28c366d6..d6c0de137b0 100644 --- a/packages/api/src/client/types/app/bsky/actor/defs.ts +++ b/packages/api/src/client/types/app/bsky/actor/defs.ts @@ -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 } diff --git a/packages/api/src/moderation/mutewords.ts b/packages/api/src/moderation/mutewords.ts index 7bdf202dbbf..143e9c0bb52 100644 --- a/packages/api/src/moderation/mutewords.ts +++ b/packages/api/src/moderation/mutewords.ts @@ -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 diff --git a/packages/api/src/moderation/subjects/post.ts b/packages/api/src/moderation/subjects/post.ts index 1274a453e29..5d6e5c765a9 100644 --- a/packages/api/src/moderation/subjects/post.ts +++ b/packages/api/src/moderation/subjects/post.ts @@ -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 diff --git a/packages/api/tests/bsky-agent.test.ts b/packages/api/tests/bsky-agent.test.ts index 0afdfa6403e..925c06e542a 100644 --- a/packages/api/tests/bsky-agent.test.ts +++ b/packages/api/tests/bsky-agent.test.ts @@ -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) }) - // 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'], + 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) }) - await agent.updateMutedWord({ - value: 'tag_then_both', - targets: ['content', 'tag'], + + 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() }) - await agent.updateMutedWord({ value: 'tag_then_none', targets: [] }) - await agent.updateMutedWord({ value: 'no_exist', targets: ['tag'] }) - const { mutedWords } = (await agent.getPreferences()).moderationPrefs - 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() - }) + 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('updateMutedWord with #, does not update', async () => { - await agent.upsertMutedWords([ - { - value: '#just_a_tag', - targets: ['tag'], - }, - ]) - await agent.updateMutedWord({ - value: '#just_a_tag', - targets: ['tag', 'content'], + 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() }) - const { mutedWords } = (await agent.getPreferences()).moderationPrefs - expect(mutedWords.find((m) => m.value === 'just_a_tag')).toStrictEqual({ - value: 'just_a_tag', - targets: ['tag'], + + 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('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 + 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() + }) - 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() - }) + 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() + }) - it('removeMutedWord with #, no match, no removal', async () => { - await agent.removeMutedWord({ value: '#hashtag', targets: [] }) - const { mutedWords } = (await agent.getPreferences()).moderationPrefs + describe(`invalid characters`, () => { + it('#, no insert', async () => { + await agent.addMutedWord({ + value: '#​', + targets: [], + actorTarget: 'all', + }) + const { moderationPrefs } = await agent.getPreferences() + expect(moderationPrefs.mutedWords.length).toEqual(0) + }) - // was inserted with #hashtag, but we don't sanitize on remove - expect(mutedWords.find((m) => m.value === 'hashtag')).toBeTruthy() + it('#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() + }) + }) }) - 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 + 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' }, + ]) + + const { moderationPrefs } = await agent.getPreferences() - // sanitized to empty string, not inserted - expect(end.mutedWords.length).toEqual(length) + expect(moderationPrefs.mutedWords.length).toEqual(3) + }) }) - it('multi-hash ##', async () => { - await agent.upsertMutedWords([{ value: '##', targets: [] }]) - const { mutedWords } = (await agent.getPreferences()).moderationPrefs + 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(mutedWords.find((m) => m.value === '#')).toBeTruthy() + expect(moderationPrefs.mutedWords.length).toEqual(2) + }) }) - it('multi-hash ##hashtag', async () => { - await agent.upsertMutedWords([{ value: '##hashtag', targets: [] }]) - const a = (await agent.getPreferences()).moderationPrefs + 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) + }) - expect(a.mutedWords.find((w) => w.value === '#hashtag')).toBeTruthy() + it('updates and sanitizes new value', async () => { + await agent.addMutedWord({ + value: 'value', + targets: ['content'], + actorTarget: 'all', + }) - await agent.removeMutedWord({ value: '#hashtag', targets: [] }) - const b = (await agent.getPreferences()).moderationPrefs + const a = await agent.getPreferences() + const word = a.moderationPrefs.mutedWords.find( + (m) => m.value === 'value', + ) - expect(b.mutedWords.find((w) => w.value === '#hashtag')).toBeFalsy() - }) + await agent.updateMutedWord({ + ...word!, + value: '#new value', + }) - it('hash emoji #️⃣', async () => { - await agent.upsertMutedWords([{ value: '#️⃣', targets: [] }]) - const { mutedWords } = (await agent.getPreferences()).moderationPrefs + const b = await agent.getPreferences() + const updatedWord = b.moderationPrefs.mutedWords.find( + (m) => m.id === word!.id, + ) - expect(mutedWords.find((m) => m.value === '#️⃣')).toBeTruthy() + expect(updatedWord!.value).toEqual('new value') + expect(updatedWord).toHaveProperty('targets', ['content']) + }) - await agent.removeMutedWord({ value: '#️⃣', targets: [] }) - const end = (await agent.getPreferences()).moderationPrefs + it('updates targets', async () => { + await agent.addMutedWord({ + value: 'word', + targets: ['tag'], + actorTarget: 'all', + }) - expect(end.mutedWords.find((m) => m.value === '#️⃣')).toBeFalsy() - }) + const a = await agent.getPreferences() + const word = a.moderationPrefs.mutedWords.find( + (m) => m.value === 'word', + ) - it('hash emoji ##️⃣', async () => { - await agent.upsertMutedWords([{ value: '##️⃣', targets: [] }]) - const { mutedWords } = (await agent.getPreferences()).moderationPrefs + await agent.updateMutedWord({ + ...word!, + targets: ['content'], + }) - expect(mutedWords.find((m) => m.value === '#️⃣')).toBeTruthy() + const b = await agent.getPreferences() - await agent.removeMutedWord({ value: '#️⃣', targets: [] }) - const end = (await agent.getPreferences()).moderationPrefs + expect( + b.moderationPrefs.mutedWords.find((m) => m.id === word!.id), + ).toHaveProperty('targets', ['content']) + }) - expect(end.mutedWords.find((m) => m.value === '#️⃣')).toBeFalsy() - }) + it('updates actorTarget', async () => { + await agent.addMutedWord({ + value: 'value', + targets: ['content'], + actorTarget: 'all', + }) - it('hash emoji ###️⃣', async () => { - await agent.upsertMutedWords([{ value: '###️⃣', targets: [] }]) - const { mutedWords } = (await agent.getPreferences()).moderationPrefs + const a = await agent.getPreferences() + const word = a.moderationPrefs.mutedWords.find( + (m) => m.value === 'value', + ) - expect(mutedWords.find((m) => m.value === '##️⃣')).toBeTruthy() + await agent.updateMutedWord({ + ...word!, + actorTarget: 'exclude-following', + }) - await agent.removeMutedWord({ value: '##️⃣', targets: [] }) - const end = (await agent.getPreferences()).moderationPrefs + const b = await agent.getPreferences() - expect(end.mutedWords.find((m) => m.value === '##️⃣')).toBeFalsy() - }) + expect( + 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, + }) - it(`apostrophe: Bluesky's`, async () => { - await agent.upsertMutedWords([{ value: `Bluesky's`, targets: [] }]) - const { mutedWords } = (await agent.getPreferences()).moderationPrefs + const b = await agent.getPreferences() - expect(mutedWords.find((m) => m.value === `Bluesky's`)).toBeTruthy() + 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(`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 + 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!) - expect(mutedWords.length).toEqual(length) + const b = await agent.getPreferences() + + expect( + b.moderationPrefs.mutedWords.find((m) => m.id === word!.id), + ).toBeFalsy() }) - it('newline', async () => { - await agent.upsertMutedWords([ - { value: 'test value\n with newline', targets: [] }, - ]) - const { mutedWords } = (await agent.getPreferences()).moderationPrefs + 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( - mutedWords.find((m) => m.value === 'test value with newline'), + b.moderationPrefs.mutedWords.find((m) => m.id === word!.id), ).toBeTruthy() }) + }) + + 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 ' }, + ]) + + const a = await agent.getPreferences() + await agent.removeMutedWords(a.moderationPrefs.mutedWords) + const b = await agent.getPreferences() + + expect(b.moderationPrefs.mutedWords.length).toEqual(0) + }) + }) + }) + + describe('legacy muted words', () => { + let agent: BskyAgent + + 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() + } - it('newline(s)', async () => { await agent.upsertMutedWords([ - { value: 'test value\n\r with newline', targets: [] }, + { value: 'word2', targets: ['tag'], actorTarget: 'all' }, ]) - const { mutedWords } = (await agent.getPreferences()).moderationPrefs - expect( - mutedWords.find((m) => m.value === 'test value with newline'), - ).toBeTruthy() + { + 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() + } }) + }) - it('empty space', async () => { - await agent.upsertMutedWords([{ value: ' ', targets: [] }]) - const { mutedWords } = (await agent.getPreferences()).moderationPrefs + 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', + }) - expect(mutedWords.find((m) => m.value === ' ')).toBeFalsy() + 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() + } }) + }) - it('leading/trailing space', async () => { - await agent.upsertMutedWords([{ value: ' trim ', targets: [] }]) - const { mutedWords } = (await agent.getPreferences()).moderationPrefs + 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', + }) - expect(mutedWords.find((m) => m.value === 'trim')).toBeTruthy() + { + 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() + } }) }) }) diff --git a/packages/api/tests/moderation-mutewords.test.ts b/packages/api/tests/moderation-mutewords.test.ts index 6f24fd2eda7..681dddc73d8 100644 --- a/packages/api/tests/moderation-mutewords.test.ts +++ b/packages/api/tests/moderation-mutewords.test.ts @@ -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,13 +931,151 @@ describe(`hasMutedWord`, () => { adultContentEnabled: false, labels: {}, labelers: [], - mutedWords: [{ value: 'words', targets: ['tags'] }], + mutedWords: [ + { value: 'words', targets: ['tags'], actorTarget: 'all' }, + ], + hiddenPosts: [], + }, + labelDefs: {}, + }, + ) + 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') + }) + }) }) diff --git a/packages/bsky/src/lexicon/lexicons.ts b/packages/bsky/src/lexicon/lexicons.ts index bb109a5847a..82346d7601c 100644 --- a/packages/bsky/src/lexicon/lexicons.ts +++ b/packages/bsky/src/lexicon/lexicons.ts @@ -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: { diff --git a/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts b/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts index 0b7e0e43251..c7eadff70d7 100644 --- a/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/bsky/src/lexicon/types/app/bsky/actor/defs.ts @@ -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 } diff --git a/packages/ozone/src/lexicon/lexicons.ts b/packages/ozone/src/lexicon/lexicons.ts index e4a3c8b90f8..b7783a15e8b 100644 --- a/packages/ozone/src/lexicon/lexicons.ts +++ b/packages/ozone/src/lexicon/lexicons.ts @@ -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: { diff --git a/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts b/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts index 0b7e0e43251..c7eadff70d7 100644 --- a/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/ozone/src/lexicon/types/app/bsky/actor/defs.ts @@ -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 } diff --git a/packages/pds/src/lexicon/lexicons.ts b/packages/pds/src/lexicon/lexicons.ts index e4a3c8b90f8..b7783a15e8b 100644 --- a/packages/pds/src/lexicon/lexicons.ts +++ b/packages/pds/src/lexicon/lexicons.ts @@ -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: { diff --git a/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts b/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts index 0b7e0e43251..c7eadff70d7 100644 --- a/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts +++ b/packages/pds/src/lexicon/types/app/bsky/actor/defs.ts @@ -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 }