From d70d0f4232a66dea5622ee6a72adbeb6a994a61a Mon Sep 17 00:00:00 2001 From: naskya Date: Fri, 1 Mar 2024 22:52:21 +0900 Subject: [PATCH] refactor (backend): separate muted words and muted patterns Co-authored-by: sup39 --- docs/changelog.md | 1 + docs/downgrade.sql | 5 + locales/en-US.yml | 1 + locales/ja-JP.yml | 3 +- .../src/model/entity/user_profile.rs | 2 + ...9-separate-hard-mute-words-and-patterns.js | 32 ++++++ .../backend/src/misc/check-hit-antenna.ts | 29 +++-- packages/backend/src/misc/check-word-mute.ts | 67 ++++++------ .../src/models/entities/user-profile.ts | 6 ++ .../backend/src/models/repositories/user.ts | 27 ++--- packages/backend/src/models/schema/user.ts | 10 ++ .../server/api/endpoints/admin/show-user.ts | 1 + .../src/server/api/endpoints/i/update.ts | 58 +++++++--- .../api/stream/channels/global-timeline.ts | 7 +- .../api/stream/channels/home-timeline.ts | 7 +- .../api/stream/channels/hybrid-timeline.ts | 7 +- .../api/stream/channels/local-timeline.ts | 7 +- .../stream/channels/recommended-timeline.ts | 7 +- packages/backend/src/services/note/create.ts | 37 ++++--- .../client/src/pages/settings/word-mute.vue | 101 ++++++++++++++++-- packages/firefish-js/src/entities.ts | 1 + 21 files changed, 319 insertions(+), 97 deletions(-) create mode 100644 packages/backend/migration/1706413792769-separate-hard-mute-words-and-patterns.js diff --git a/docs/changelog.md b/docs/changelog.md index 197db641b2..65548e3189 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -5,6 +5,7 @@ Critical security updates are indicated by the :warning: icon. ## Unreleased - Introduce new full-text search engine and post search filters +- Refactoring ## v20240301 diff --git a/docs/downgrade.sql b/docs/downgrade.sql index 7f0921edb6..31fa160299 100644 --- a/docs/downgrade.sql +++ b/docs/downgrade.sql @@ -1,6 +1,7 @@ BEGIN; DELETE FROM "migrations" WHERE name IN ( + 'SeparateHardMuteWordsAndPatterns1706413792769', 'IndexAltTextAndCw1708872574733', 'Pgroonga1698420787202', 'ChangeDefaultConfigs1709251460718', @@ -14,6 +15,10 @@ DELETE FROM "migrations" WHERE name IN ( 'RemoveNativeUtilsMigration1705877093218' ); +-- separate-hard-mute-words-and-patterns +UPDATE "user_profile" SET "mutedWords" = "mutedWords" || array_to_json("mutedPatterns")::jsonb; +ALTER TABLE "user_profile" DROP "mutedPatterns"; + -- index-alt-text-and-cw DROP INDEX "IDX_f4f7b93d05958527300d79ac82"; DROP INDEX "IDX_8e3bbbeb3df04d1a8105da4c8f"; diff --git a/locales/en-US.yml b/locales/en-US.yml index 1c2d210512..0bca5e0c19 100644 --- a/locales/en-US.yml +++ b/locales/en-US.yml @@ -1429,6 +1429,7 @@ _menuDisplay: hide: "Hide" _wordMute: muteWords: "Muted words" + mutePatterns: "Muted patterns" muteLangs: "Muted Languages" muteWordsDescription: "Separate with spaces for an AND condition or with line breaks for an OR condition." diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index 8ddf99aa21..a79cda6706 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -1220,7 +1220,8 @@ _menuDisplay: hide: "隠す" _wordMute: muteWords: "ミュートするワード" - muteLangs: "ミュートされた言語" + mutePatterns: "ミュートするパターン" + muteLangs: "ミュートする言語" muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。" muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。" muteLangsDescription: "OR 条件の場合はスペースまたは改行で区切ります。" diff --git a/packages/backend-rs/src/model/entity/user_profile.rs b/packages/backend-rs/src/model/entity/user_profile.rs index daccf57d1e..c05fd34ed3 100644 --- a/packages/backend-rs/src/model/entity/user_profile.rs +++ b/packages/backend-rs/src/model/entity/user_profile.rs @@ -71,6 +71,8 @@ pub struct Model { pub prevent_ai_learning: bool, #[sea_orm(column_name = "isIndexable")] pub is_indexable: bool, + #[sea_orm(column_name = "mutedPatterns")] + pub muted_patterns: Vec, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/packages/backend/migration/1706413792769-separate-hard-mute-words-and-patterns.js b/packages/backend/migration/1706413792769-separate-hard-mute-words-and-patterns.js new file mode 100644 index 0000000000..ace5734018 --- /dev/null +++ b/packages/backend/migration/1706413792769-separate-hard-mute-words-and-patterns.js @@ -0,0 +1,32 @@ +export class SeparateHardMuteWordsAndPatterns1706413792769 { + name = "SeparateHardMuteWordsAndPatterns1706413792769"; + + async up(queryRunner) { + await queryRunner.query( + `ALTER TABLE "user_profile" ADD "mutedPatterns" text[] DEFAULT '{}'`, + ); + await queryRunner.query(` + UPDATE "user_profile" SET + "mutedPatterns" = ARRAY( + SELECT jsonb_array_elements_text(jsonb_path_query_array( + "mutedWords", + '$ ? (@.type() == "string")' + )) + ), + "mutedWords" = jsonb_path_query_array( + "mutedWords", + '$ ? (@.type() == "array")' + ) + `); + await queryRunner.query( + `ALTER TABLE "user_profile" ALTER "mutedPatterns" SET NOT NULL`, + ); + } + + async down(queryRunner) { + await queryRunner.query( + `UPDATE "user_profile" SET "mutedWords" = "mutedWords" || array_to_json("mutedPatterns")::jsonb`, + ); + await queryRunner.query(`ALTER TABLE "user_profile" DROP "mutedPatterns"`); + } +} diff --git a/packages/backend/src/misc/check-hit-antenna.ts b/packages/backend/src/misc/check-hit-antenna.ts index 1f4f2f2fae..81776ae55e 100644 --- a/packages/backend/src/misc/check-hit-antenna.ts +++ b/packages/backend/src/misc/check-hit-antenna.ts @@ -1,6 +1,7 @@ import type { Antenna } from "@/models/entities/antenna.js"; import type { Note } from "@/models/entities/note.js"; import type { User } from "@/models/entities/user.js"; +import type { UserProfile } from "@/models/entities/user-profile.js"; import { Blockings, UserProfiles } from "@/models/index.js"; import { getFullApAccount } from "@/misc/convert-host.js"; import * as Acct from "@/misc/acct.js"; @@ -9,7 +10,11 @@ import { Cache } from "@/misc/cache.js"; import { getWordHardMute } from "@/misc/check-word-mute.js"; const blockingCache = new Cache("blocking", 60 * 5); -const mutedWordsCache = new Cache("mutedWords", 60 * 5); +const hardMutesCache = new Cache<{ + userId: UserProfile["userId"]; + mutedWords: UserProfile["mutedWords"]; + mutedPatterns: UserProfile["mutedPatterns"]; +}>("hardMutes", 60 * 5); export async function checkHitAntenna( antenna: Antenna, @@ -89,12 +94,24 @@ export async function checkHitAntenna( ); if (blockings.includes(antenna.userId)) return false; - const mutedWords = await mutedWordsCache.fetch(antenna.userId, () => - UserProfiles.findOneBy({ userId: antenna.userId }).then( - (profile) => profile?.mutedWords, - ), + const mutes = await hardMutesCache.fetch(antenna.userId, () => + UserProfiles.findOneByOrFail({ + userId: antenna.userId, + }).then((profile) => { + return { + userId: antenna.userId, + mutedWords: profile.mutedWords, + mutedPatterns: profile.mutedPatterns, + }; + }), ); - if (await getWordHardMute(note, antenna.userId, mutedWords)) return false; + if ( + mutes.mutedWords != null && + mutes.mutedPatterns != null && + antenna.userId !== note.userId && + (await getWordHardMute(note, mutes.mutedWords, mutes.mutedPatterns)) + ) + return false; // TODO: eval expression diff --git a/packages/backend/src/misc/check-word-mute.ts b/packages/backend/src/misc/check-word-mute.ts index 5686aef2f7..f07f2a0fe5 100644 --- a/packages/backend/src/misc/check-word-mute.ts +++ b/packages/backend/src/misc/check-word-mute.ts @@ -6,11 +6,14 @@ type NoteLike = { text: Note["text"]; files?: Note["files"]; cw?: Note["cw"]; + reply?: NoteLike | null; + renote?: NoteLike | null; }; function checkWordMute( - note: NoteLike, - mutedWords: Array, + note: NoteLike | null | undefined, + mutedWords: string[][], + mutedPatterns: string[], ): boolean { if (note == null) return false; @@ -21,33 +24,33 @@ function checkWordMute( if (text === "") return false; - for (const mutePattern of mutedWords) { - if (Array.isArray(mutePattern)) { - // Clean up - const keywords = mutePattern.filter((keyword) => keyword !== ""); + for (const mutedWord of mutedWords) { + // Clean up + const keywords = mutedWord.filter((keyword) => keyword !== ""); - if ( - keywords.length > 0 && - keywords.every((keyword) => - text.toLowerCase().includes(keyword.toLowerCase()), - ) + if ( + keywords.length > 0 && + keywords.every((keyword) => + text.toLowerCase().includes(keyword.toLowerCase()), ) - return true; - } else { - // represents RegExp - const regexp = mutePattern.match(/^\/(.+)\/(.*)$/); + ) + return true; + } + for (const mutedPattern of mutedPatterns) { + // represents RegExp + const regexp = mutedPattern.match(/^\/(.+)\/(.*)$/); + + // This should never happen due to input sanitisation. + if (!regexp) { + console.warn(`Found invalid regex in word mutes: ${mutedPattern}`); + continue; + } + + try { + if (new RE2(regexp[1], regexp[2]).test(text)) return true; + } catch (err) { // This should never happen due to input sanitisation. - if (!regexp) { - console.warn(`Found invalid regex in word mutes: ${mutePattern}`); - continue; - } - - try { - if (new RE2(regexp[1], regexp[2]).test(text)) return true; - } catch (err) { - // This should never happen due to input sanitisation. - } } } @@ -55,17 +58,17 @@ function checkWordMute( } export async function getWordHardMute( - note: NoteLike, - meId: string | null | undefined, - mutedWords?: Array, + note: NoteLike | null, + mutedWords: string[][], + mutedPatterns: string[], ): Promise { - if (note.userId === meId || mutedWords == null) return false; + if (note == null || mutedWords == null || mutedPatterns == null) return false; if (mutedWords.length > 0) { return ( - checkWordMute(note, mutedWords) || - checkWordMute(note.reply, mutedWords) || - checkWordMute(note.renote, mutedWords) + checkWordMute(note, mutedWords, mutedPatterns) || + checkWordMute(note.reply, mutedWords, mutedPatterns) || + checkWordMute(note.renote, mutedWords, mutedPatterns) ); } diff --git a/packages/backend/src/models/entities/user-profile.ts b/packages/backend/src/models/entities/user-profile.ts index 0cdf4e8393..2a99d58226 100644 --- a/packages/backend/src/models/entities/user-profile.ts +++ b/packages/backend/src/models/entities/user-profile.ts @@ -217,6 +217,12 @@ export class UserProfile { }) public mutedWords: string[][]; + @Column("text", { + array: true, + nullable: false, + }) + public mutedPatterns: string[]; + @Column("jsonb", { default: [], comment: "List of instances muted by the user.", diff --git a/packages/backend/src/models/repositories/user.ts b/packages/backend/src/models/repositories/user.ts index 28abe7a9cc..59bba8b44d 100644 --- a/packages/backend/src/models/repositories/user.ts +++ b/packages/backend/src/models/repositories/user.ts @@ -544,13 +544,13 @@ export const UserRepository = db.getRepository(User).extend({ ? { avatarId: user.avatarId, bannerId: user.bannerId, - injectFeaturedNote: profile!.injectFeaturedNote, - receiveAnnouncementEmail: profile!.receiveAnnouncementEmail, - alwaysMarkNsfw: profile!.alwaysMarkNsfw, - carefulBot: profile!.carefulBot, - autoAcceptFollowed: profile!.autoAcceptFollowed, - noCrawle: profile!.noCrawle, - preventAiLearning: profile!.preventAiLearning, + injectFeaturedNote: profile?.injectFeaturedNote, + receiveAnnouncementEmail: profile?.receiveAnnouncementEmail, + alwaysMarkNsfw: profile?.alwaysMarkNsfw, + carefulBot: profile?.carefulBot, + autoAcceptFollowed: profile?.autoAcceptFollowed, + noCrawle: profile?.noCrawle, + preventAiLearning: profile?.preventAiLearning, isExplorable: user.isExplorable, isDeleted: user.isDeleted, hideOnlineStatus: user.hideOnlineStatus, @@ -571,17 +571,18 @@ export const UserRepository = db.getRepository(User).extend({ hasUnreadNotification: this.getHasUnreadNotification(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), - mutedWords: profile!.mutedWords, - mutedInstances: profile!.mutedInstances, - mutingNotificationTypes: profile!.mutingNotificationTypes, - emailNotificationTypes: profile!.emailNotificationTypes, + mutedWords: profile?.mutedWords, + mutedPatterns: profile?.mutedPatterns, + mutedInstances: profile?.mutedInstances, + mutingNotificationTypes: profile?.mutingNotificationTypes, + emailNotificationTypes: profile?.emailNotificationTypes, } : {}), ...(opts.includeSecrets ? { - email: profile!.email, - emailVerified: profile!.emailVerified, + email: profile?.email, + emailVerified: profile?.emailVerified, securityKeysList: UserSecurityKeys.find({ where: { userId: user.id, diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/schema/user.ts index d6a5cd8b8d..bcdd718dd1 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/schema/user.ts @@ -468,6 +468,16 @@ export const packedMeDetailedOnlySchema = { }, }, }, + mutedPatterns: { + type: "array", + nullable: false, + optional: false, + items: { + type: "string", + nullable: false, + optional: false, + }, + }, mutedInstances: { type: "array", nullable: true, diff --git a/packages/backend/src/server/api/endpoints/admin/show-user.ts b/packages/backend/src/server/api/endpoints/admin/show-user.ts index 164d6ae419..3ad255ddde 100644 --- a/packages/backend/src/server/api/endpoints/admin/show-user.ts +++ b/packages/backend/src/server/api/endpoints/admin/show-user.ts @@ -60,6 +60,7 @@ export default define(meta, paramDef, async (ps, me) => { injectFeaturedNote: profile.injectFeaturedNote, receiveAnnouncementEmail: profile.receiveAnnouncementEmail, mutedWords: profile.mutedWords, + mutedPatterns: profile.mutedPatterns, mutedInstances: profile.mutedInstances, mutingNotificationTypes: profile.mutingNotificationTypes, isModerator: user.isModerator, diff --git a/packages/backend/src/server/api/endpoints/i/update.ts b/packages/backend/src/server/api/endpoints/i/update.ts index d7974ce6cc..08bf885d49 100644 --- a/packages/backend/src/server/api/endpoints/i/update.ts +++ b/packages/backend/src/server/api/endpoints/i/update.ts @@ -120,6 +120,7 @@ export const paramDef = { ffVisibility: { type: "string", enum: ["public", "followers", "private"] }, pinnedPageId: { type: "string", format: "misskey:id", nullable: true }, mutedWords: { type: "array" }, + mutedPatterns: { type: "array", items: { type: "string" } }, mutedInstances: { type: "array", items: { @@ -159,23 +160,52 @@ export default define(meta, paramDef, async (ps, _user, token) => { profileUpdates.ffVisibility = ps.ffVisibility; if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; + if (ps.mutedPatterns !== undefined) { + for (const item of ps.mutedPatterns) { + const regexp = item.match(/^\/(.+)\/(.*)$/); + if (!regexp) throw new ApiError(meta.errors.invalidRegexp); + + try { + new RegExp(regexp[1], regexp[2]); + } catch (err) { + throw new ApiError(meta.errors.invalidRegexp); + } + + profileUpdates.mutedPatterns = profileUpdates.mutedPatterns ?? []; + profileUpdates.mutedPatterns.push(item); + } + } if (ps.mutedWords !== undefined) { - // validate regular expression syntax - ps.mutedWords - .filter((x) => !Array.isArray(x)) - .forEach((x) => { - const regexp = x.match(/^\/(.+)\/(.*)$/); - if (!regexp) throw new ApiError(meta.errors.invalidRegexp); + // for backward compatibility + for (const item of ps.mutedWords) { + if (Array.isArray(item)) continue; - try { - new RE2(regexp[1], regexp[2]); - } catch (err) { - throw new ApiError(meta.errors.invalidRegexp); - } - }); + const regexp = item.match(/^\/(.+)\/(.*)$/); + if (!regexp) throw new ApiError(meta.errors.invalidRegexp); - profileUpdates.mutedWords = ps.mutedWords; - profileUpdates.enableWordMute = ps.mutedWords.length > 0; + try { + new RegExp(regexp[1], regexp[2]); + } catch (err) { + throw new ApiError(meta.errors.invalidRegexp); + } + + profileUpdates.mutedPatterns = profileUpdates.mutedPatterns ?? []; + profileUpdates.mutedPatterns.push(item); + } + + profileUpdates.mutedWords = ps.mutedWords.filter((item) => + Array.isArray(item), + ); + } + if ( + profileUpdates.mutedWords !== undefined || + profileUpdates.mutedPatterns !== undefined + ) { + profileUpdates.enableWordMute = + (profileUpdates.mutedWords != null && + profileUpdates.mutedWords.length > 0) || + (profileUpdates.mutedPatterns != null && + profileUpdates.mutedPatterns.length > 0); } if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; diff --git a/packages/backend/src/server/api/stream/channels/global-timeline.ts b/packages/backend/src/server/api/stream/channels/global-timeline.ts index 74345b9395..39d0719dd3 100644 --- a/packages/backend/src/server/api/stream/channels/global-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/global-timeline.ts @@ -69,7 +69,12 @@ export default class extends Channel { // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる if ( this.userProfile && - (await getWordHardMute(note, this.user?.id, this.userProfile.mutedWords)) + this.user?.id !== note.userId && + (await getWordHardMute( + note, + this.userProfile.mutedWords, + this.userProfile.mutedPatterns, + )) ) return; diff --git a/packages/backend/src/server/api/stream/channels/home-timeline.ts b/packages/backend/src/server/api/stream/channels/home-timeline.ts index f24218df92..8f23946259 100644 --- a/packages/backend/src/server/api/stream/channels/home-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/home-timeline.ts @@ -68,7 +68,12 @@ export default class extends Channel { // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる if ( this.userProfile && - (await getWordHardMute(note, this.user?.id, this.userProfile.mutedWords)) + this.user?.id !== note.userId && + (await getWordHardMute( + note, + this.userProfile.mutedWords, + this.userProfile.mutedPatterns, + )) ) return; diff --git a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts index f87d7ab6d2..7f5c662b8c 100644 --- a/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/hybrid-timeline.ts @@ -85,7 +85,12 @@ export default class extends Channel { // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる if ( this.userProfile && - (await getWordHardMute(note, this.user?.id, this.userProfile.mutedWords)) + this.user?.id !== note.userId && + (await getWordHardMute( + note, + this.userProfile.mutedWords, + this.userProfile.mutedPatterns, + )) ) return; diff --git a/packages/backend/src/server/api/stream/channels/local-timeline.ts b/packages/backend/src/server/api/stream/channels/local-timeline.ts index 2cba992b9e..88eabe991e 100644 --- a/packages/backend/src/server/api/stream/channels/local-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/local-timeline.ts @@ -61,7 +61,12 @@ export default class extends Channel { // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる if ( this.userProfile && - (await getWordHardMute(note, this.user?.id, this.userProfile.mutedWords)) + this.user?.id !== note.userId && + (await getWordHardMute( + note, + this.userProfile.mutedWords, + this.userProfile.mutedPatterns, + )) ) return; diff --git a/packages/backend/src/server/api/stream/channels/recommended-timeline.ts b/packages/backend/src/server/api/stream/channels/recommended-timeline.ts index 12df39fca2..a9da732f89 100644 --- a/packages/backend/src/server/api/stream/channels/recommended-timeline.ts +++ b/packages/backend/src/server/api/stream/channels/recommended-timeline.ts @@ -83,7 +83,12 @@ export default class extends Channel { // そのためレコードが存在するかのチェックでは不十分なので、改めてgetWordHardMuteを呼んでいる if ( this.userProfile && - (await getWordHardMute(note, this.user?.id, this.userProfile.mutedWords)) + this.user?.id !== note.userId && + (await getWordHardMute( + note, + this.userProfile.mutedWords, + this.userProfile.mutedPatterns, + )) ) return; diff --git a/packages/backend/src/services/note/create.ts b/packages/backend/src/services/note/create.ts index d2ec137d38..fc7f1265b3 100644 --- a/packages/backend/src/services/note/create.ts +++ b/packages/backend/src/services/note/create.ts @@ -65,9 +65,13 @@ import { inspect } from "node:util"; const logger = new Logger("create-note"); -const mutedWordsCache = new Cache< - { userId: UserProfile["userId"]; mutedWords: UserProfile["mutedWords"] }[] ->("mutedWords", 60 * 5); +const hardMutesCache = new Cache< + { + userId: UserProfile["userId"]; + mutedWords: UserProfile["mutedWords"]; + mutedPatterns: UserProfile["mutedPatterns"]; + }[] +>("hardMutes", 60 * 5); type NotificationType = "reply" | "renote" | "quote" | "mention"; @@ -357,27 +361,30 @@ export default async ( incNotesCountOfUser(user); // Word mute - mutedWordsCache + hardMutesCache .fetch(null, () => UserProfiles.find({ where: { enableWordMute: true, }, - select: ["userId", "mutedWords"], + select: ["userId", "mutedWords", "mutedPatterns"], }), ) .then((us) => { for (const u of us) { - getWordHardMute(data, u.userId, u.mutedWords).then((shouldMute) => { - if (shouldMute) { - MutedNotes.insert({ - id: genId(), - userId: u.userId, - noteId: note.id, - reason: "word", - }); - } - }); + if (u.userId === user.id) return; + getWordHardMute(note, u.mutedWords, u.mutedPatterns).then( + (shouldMute: boolean) => { + if (shouldMute) { + MutedNotes.insert({ + id: genId(), + userId: u.userId, + noteId: note.id, + reason: "word", + }); + } + }, + ); } }); diff --git a/packages/client/src/pages/settings/word-mute.vue b/packages/client/src/pages/settings/word-mute.vue index 68b6b09a45..8a6a0408ac 100644 --- a/packages/client/src/pages/settings/word-mute.vue +++ b/packages/client/src/pages/settings/word-mute.vue @@ -36,11 +36,15 @@ > {{ i18n.ts._wordMute.muteWords }} - + + + + {{ i18n.ts._wordMute.mutePatterns }} + { changed.value = true; }); +watch(hardMutedPatterns, () => { + changed.value = true; +}); + async function save() { - const parseMutes = (mutes, tab) => { + const parseSoftMutes = (mutes, tab) => { // split into lines, remove empty lines and unnecessary whitespace const lines = mutes .trim() @@ -151,11 +160,80 @@ async function save() { return lines; }; - let softMutes, softMLangs, hardMutes; + const parseMutedWords = (mutes) => { + // split into lines, remove empty lines and unnecessary whitespace + return mutes + .trim() + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== "") + .map((line) => line.split(" ")) + .filter((line) => line.length > 0); + }; + + const parseMutedPatterns = (mutes, tab) => { + // split into lines, remove empty lines and unnecessary whitespace + const lines = mutes + .trim() + .split("\n") + .map((line) => line.trim()) + .filter((line) => line !== ""); + + // check each line if it is a RegExp or not + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const regexp = line.match(/^\/(.+)\/(.*)$/); + if (regexp) { + // check that the RegExp is valid + try { + new RegExp(regexp[1], regexp[2]); + // note that regex lines will not be split by spaces! + } catch (err: any) { + // invalid syntax: do not save, do not reset changed flag + os.alert({ + type: "error", + title: i18n.ts.regexpError, + text: + i18n.t("regexpErrorDescription", { + tab, + line: i + 1, + }) + + "\n" + + err.toString(), + }); + // re-throw error so these invalid settings are not saved + throw err; + } + } else { + // invalid syntax: do not save, do not reset changed flag + os.alert({ + type: "error", + title: i18n.ts.regexpError, + text: i18n.t("regexpErrorDescription", { + tab, + line: i + 1, + }), + }); + // re-throw error so these invalid settings are not saved + throw new Error("Invalid regular expression"); + } + } + + return lines; + }; + + let softMutes, softMLangs, hardMWords, hardMPatterns; try { - softMutes = parseMutes(softMutedWords.value, i18n.ts._wordMute.soft); - softMLangs = parseMutes(softMutedLangs.value, i18n.ts._wordMute.lang); - hardMutes = parseMutes(hardMutedWords.value, i18n.ts._wordMute.hard); + softMutes = parseSoftMutes( + softMutedWords.value, + i18n.ts._wordMute.soft, + ); + softMLangs = parseMutedWords(softMutedLangs.value); + hardMWords = parseMutedWords(hardMutedWords.value); + hardMPatterns = parseMutedPatterns( + hardMutedPatterns.value, + i18n.ts._wordMute.hard, + ); } catch (err) { // already displayed error message in parseMutes return; @@ -164,7 +242,8 @@ async function save() { defaultStore.set("mutedWords", softMutes); defaultStore.set("mutedLangs", softMLangs); await os.api("i/update", { - mutedWords: hardMutes, + mutedWords: hardMWords, + mutedPatterns: hardMPatterns, }); changed.value = false; diff --git a/packages/firefish-js/src/entities.ts b/packages/firefish-js/src/entities.ts index 6d4340890f..ed028efa2d 100644 --- a/packages/firefish-js/src/entities.ts +++ b/packages/firefish-js/src/entities.ts @@ -107,6 +107,7 @@ export type MeDetailed = UserDetailed & { isDeleted: boolean; isExplorable: boolean; mutedWords: string[][]; + mutedPatterns: string[]; mutingNotificationTypes: string[]; noCrawle: boolean; preventAiLearning: boolean;