From cf43dd6ec530ba4a3f589ae917e89533b352f6a3 Mon Sep 17 00:00:00 2001 From: syuilo Date: Mon, 27 Jul 2020 13:34:20 +0900 Subject: [PATCH] =?UTF-8?q?=E3=83=AF=E3=83=BC=E3=83=89=E3=83=9F=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=83=88=20(#6594)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * wip * wip * wip * wip * wip * wip * wip * wip * wip --- locales/ja-JP.yml | 11 +++ migration/1595771249699-word-mute.ts | 30 ++++++++ migration/1595782306083-word-mute2.ts | 18 +++++ package.json | 1 + src/client/components/note.vue | 27 +++++-- src/client/components/tab.vue | 42 ++++++++++ src/client/pages/my-settings/index.vue | 3 + src/client/pages/my-settings/word-mute.vue | 77 +++++++++++++++++++ src/client/scripts/check-word-mute.ts | 26 +++++++ src/client/store.ts | 1 + src/client/style.scss | 4 + src/db/postgre.ts | 2 + src/misc/check-word-mute.ts | 39 ++++++++++ src/models/entities/muted-note.ts | 48 ++++++++++++ src/models/entities/user-profile.ts | 11 +++ src/models/index.ts | 2 + src/models/repositories/user.ts | 1 + .../api/common/generate-muted-note-query.ts | 13 ++++ src/server/api/endpoints/i/update.ts | 10 ++- .../api/endpoints/notes/global-timeline.ts | 2 + .../api/endpoints/notes/hybrid-timeline.ts | 2 + .../api/endpoints/notes/local-timeline.ts | 2 + src/server/api/endpoints/notes/timeline.ts | 2 + src/server/api/stream/channel.ts | 4 + .../api/stream/channels/global-timeline.ts | 8 ++ .../api/stream/channels/home-timeline.ts | 8 ++ .../api/stream/channels/hybrid-timeline.ts | 8 ++ .../api/stream/channels/local-timeline.ts | 8 ++ src/server/api/stream/index.ts | 16 +++- src/services/note/create.ts | 21 ++++- src/types.ts | 2 + yarn.lock | 48 +++++++++++- 32 files changed, 485 insertions(+), 12 deletions(-) create mode 100644 migration/1595771249699-word-mute.ts create mode 100644 migration/1595782306083-word-mute2.ts create mode 100644 src/client/components/tab.vue create mode 100644 src/client/pages/my-settings/word-mute.vue create mode 100644 src/client/scripts/check-word-mute.ts create mode 100644 src/misc/check-word-mute.ts create mode 100644 src/models/entities/muted-note.ts create mode 100644 src/server/api/common/generate-muted-note-query.ts diff --git a/locales/ja-JP.yml b/locales/ja-JP.yml index ffd61bfe4..c34d93dc8 100644 --- a/locales/ja-JP.yml +++ b/locales/ja-JP.yml @@ -553,6 +553,17 @@ emptyToDisableSmtpAuth: "ユーザー名とパスワードを空欄にするこ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" smtpSecureInfo: "STARTTLS使用時はオフにします。" testEmail: "配信テスト" +wordMute: "ワードミュート" +userSaysSomething: "{name}が何かを言いました" + +_wordMute: + muteWords: "ミュートするワード" + muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。" + muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。" + softDescription: "指定した条件のノートをタイムラインから隠します。" + hardDescription: "指定した条件のノートをタイムラインに追加しないようにします。追加されなかったノートは、条件を変更しても除外されたままになります。" + soft: "ソフト" + hard: "ハード" _theme: explore: "テーマを探す" diff --git a/migration/1595771249699-word-mute.ts b/migration/1595771249699-word-mute.ts new file mode 100644 index 000000000..1a9114d92 --- /dev/null +++ b/migration/1595771249699-word-mute.ts @@ -0,0 +1,30 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class wordMute1595771249699 implements MigrationInterface { + name = 'wordMute1595771249699' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE "muted_note" ("id" character varying(32) NOT NULL, "noteId" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, CONSTRAINT "PK_897e2eff1c0b9b64e55ca1418a4" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_70ab9786313d78e4201d81cdb8" ON "muted_note" ("noteId") `); + await queryRunner.query(`CREATE INDEX "IDX_d8e07aa18c2d64e86201601aec" ON "muted_note" ("userId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_a8c6bfd637d3f1d67a27c48e27" ON "muted_note" ("noteId", "userId") `); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "enableWordMute" boolean NOT NULL DEFAULT false`); + await queryRunner.query(`ALTER TABLE "user_profile" ADD "mutedWords" jsonb NOT NULL DEFAULT '[]'`); + await queryRunner.query(`CREATE INDEX "IDX_3befe6f999c86aff06eb0257b4" ON "user_profile" ("enableWordMute") `); + await queryRunner.query(`ALTER TABLE "muted_note" ADD CONSTRAINT "FK_70ab9786313d78e4201d81cdb89" FOREIGN KEY ("noteId") REFERENCES "note"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "muted_note" ADD CONSTRAINT "FK_d8e07aa18c2d64e86201601aec1" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "muted_note" DROP CONSTRAINT "FK_d8e07aa18c2d64e86201601aec1"`); + await queryRunner.query(`ALTER TABLE "muted_note" DROP CONSTRAINT "FK_70ab9786313d78e4201d81cdb89"`); + await queryRunner.query(`DROP INDEX "IDX_3befe6f999c86aff06eb0257b4"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "mutedWords"`); + await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "enableWordMute"`); + await queryRunner.query(`DROP INDEX "IDX_a8c6bfd637d3f1d67a27c48e27"`); + await queryRunner.query(`DROP INDEX "IDX_d8e07aa18c2d64e86201601aec"`); + await queryRunner.query(`DROP INDEX "IDX_70ab9786313d78e4201d81cdb8"`); + await queryRunner.query(`DROP TABLE "muted_note"`); + } + +} diff --git a/migration/1595782306083-word-mute2.ts b/migration/1595782306083-word-mute2.ts new file mode 100644 index 000000000..d68c12740 --- /dev/null +++ b/migration/1595782306083-word-mute2.ts @@ -0,0 +1,18 @@ +import {MigrationInterface, QueryRunner} from "typeorm"; + +export class wordMute21595782306083 implements MigrationInterface { + name = 'wordMute21595782306083' + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TYPE "muted_note_reason_enum" AS ENUM('word', 'manual', 'spam', 'other')`); + await queryRunner.query(`ALTER TABLE "muted_note" ADD "reason" "muted_note_reason_enum" NOT NULL`); + await queryRunner.query(`CREATE INDEX "IDX_636e977ff90b23676fb5624b25" ON "muted_note" ("reason") `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP INDEX "IDX_636e977ff90b23676fb5624b25"`); + await queryRunner.query(`ALTER TABLE "muted_note" DROP COLUMN "reason"`); + await queryRunner.query(`DROP TYPE "muted_note_reason_enum"`); + } + +} diff --git a/package.json b/package.json index 3e6439f0d..376ee7105 100644 --- a/package.json +++ b/package.json @@ -204,6 +204,7 @@ "random-seed": "0.3.0", "randomcolor": "0.5.4", "ratelimiter": "3.4.1", + "re2": "1.15.4", "recaptcha-promise": "0.1.3", "reconnecting-websocket": "4.4.0", "redis": "3.0.2", diff --git a/src/client/components/note.vue b/src/client/components/note.vue index dc3cce9e5..9bbf76349 100644 --- a/src/client/components/note.vue +++ b/src/client/components/note.vue @@ -1,6 +1,7 @@ + + diff --git a/src/client/pages/my-settings/index.vue b/src/client/pages/my-settings/index.vue index 3af896d78..16e786bfc 100644 --- a/src/client/pages/my-settings/index.vue +++ b/src/client/pages/my-settings/index.vue @@ -27,6 +27,7 @@ + @@ -47,6 +48,7 @@ import XImportExport from './import-export.vue'; import XDrive from './drive.vue'; import XReactionSetting from './reaction.vue'; import XMuteBlock from './mute-block.vue'; +import XWordMute from './word-mute.vue'; import XSecurity from './security.vue'; import X2fa from './2fa.vue'; import XIntegration from './integration.vue'; @@ -68,6 +70,7 @@ export default Vue.extend({ XDrive, XReactionSetting, XMuteBlock, + XWordMute, XSecurity, X2fa, XIntegration, diff --git a/src/client/pages/my-settings/word-mute.vue b/src/client/pages/my-settings/word-mute.vue new file mode 100644 index 000000000..6b2a372f0 --- /dev/null +++ b/src/client/pages/my-settings/word-mute.vue @@ -0,0 +1,77 @@ + + + diff --git a/src/client/scripts/check-word-mute.ts b/src/client/scripts/check-word-mute.ts new file mode 100644 index 000000000..3b1fa75b1 --- /dev/null +++ b/src/client/scripts/check-word-mute.ts @@ -0,0 +1,26 @@ +export async function checkWordMute(note: Record, me: Record | null | undefined, mutedWords: string[][]): Promise { + // 自分自身 + if (me && (note.userId === me.id)) return false; + + const words = mutedWords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (words.length > 0) { + if (note.text == null) return false; + + const matched = words.some(and => + and.every(keyword => { + const regexp = keyword.match(/^\/(.+)\/(.*)$/); + if (regexp) { + return new RegExp(regexp[1], regexp[2]).test(note.text!); + } + return note.text!.includes(keyword); + })); + + if (matched) return true; + } + + return false; +} diff --git a/src/client/store.ts b/src/client/store.ts index 2cd2c8cf3..2bf44088a 100644 --- a/src/client/store.ts +++ b/src/client/store.ts @@ -18,6 +18,7 @@ export const defaultSettings = { pastedFileName: 'yyyy-MM-dd HH-mm-ss [{{number}}]', memo: null, reactions: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], + mutedWords: [], }; export const defaultDeviceUserSettings = { diff --git a/src/client/style.scss b/src/client/style.scss index c3d3cf223..ab0dcf622 100644 --- a/src/client/style.scss +++ b/src/client/style.scss @@ -355,6 +355,10 @@ hr { padding: 16px; } + &._noPad { + padding: 0 !important; + } + & + ._content { border-top: solid 1px var(--divider); } diff --git a/src/db/postgre.ts b/src/db/postgre.ts index 81fb92f68..6ffc56ee0 100644 --- a/src/db/postgre.ts +++ b/src/db/postgre.ts @@ -59,6 +59,7 @@ import { PromoNote } from '../models/entities/promo-note'; import { PromoRead } from '../models/entities/promo-read'; import { program } from '../argv'; import { Relay } from '../models/entities/relay'; +import { MutedNote } from '../models/entities/muted-note'; const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); @@ -151,6 +152,7 @@ export const entities = [ ReversiGame, ReversiMatching, Relay, + MutedNote, ...charts as any ]; diff --git a/src/misc/check-word-mute.ts b/src/misc/check-word-mute.ts new file mode 100644 index 000000000..5af267d75 --- /dev/null +++ b/src/misc/check-word-mute.ts @@ -0,0 +1,39 @@ +const RE2 = require('re2'); +import { Note } from '../models/entities/note'; +import { User } from '../models/entities/user'; + +type NoteLike = { + userId: Note['userId']; + text: Note['text']; +}; + +type UserLike = { + id: User['id']; +}; + +export async function checkWordMute(note: NoteLike, me: UserLike | null | undefined, mutedWords: string[][]): Promise { + // 自分自身 + if (me && (note.userId === me.id)) return false; + + const words = mutedWords + // Clean up + .map(xs => xs.filter(x => x !== '')) + .filter(xs => xs.length > 0); + + if (words.length > 0) { + if (note.text == null) return false; + + const matched = words.some(and => + and.every(keyword => { + const regexp = keyword.match(/^\/(.+)\/(.*)$/); + if (regexp) { + return new RE2(regexp[1], regexp[2]).test(note.text!); + } + return note.text!.includes(keyword); + })); + + if (matched) return true; + } + + return false; +} diff --git a/src/models/entities/muted-note.ts b/src/models/entities/muted-note.ts new file mode 100644 index 000000000..521876688 --- /dev/null +++ b/src/models/entities/muted-note.ts @@ -0,0 +1,48 @@ +import { Entity, Index, JoinColumn, Column, ManyToOne, PrimaryColumn } from 'typeorm'; +import { Note } from './note'; +import { User } from './user'; +import { id } from '../id'; +import { mutedNoteReasons } from '../../types'; + +@Entity() +@Index(['noteId', 'userId'], { unique: true }) +export class MutedNote { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + comment: 'The note ID.' + }) + public noteId: Note['id']; + + @ManyToOne(type => Note, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public note: Note | null; + + @Index() + @Column({ + ...id(), + comment: 'The user ID.' + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE' + }) + @JoinColumn() + public user: User | null; + + /** + * ミュートされた理由。 + */ + @Index() + @Column('enum', { + enum: mutedNoteReasons, + comment: 'The reason of the MutedNote.' + }) + public reason: typeof mutedNoteReasons[number]; +} diff --git a/src/models/entities/user-profile.ts b/src/models/entities/user-profile.ts index a89d7364f..0a6722aac 100644 --- a/src/models/entities/user-profile.ts +++ b/src/models/entities/user-profile.ts @@ -147,6 +147,17 @@ export class UserProfile { }) public integrations: Record; + @Index() + @Column('boolean', { + default: false, + }) + public enableWordMute: boolean; + + @Column('jsonb', { + default: [] + }) + public mutedWords: string[][]; + //#region Denormalized fields @Index() @Column('varchar', { diff --git a/src/models/index.ts b/src/models/index.ts index e1389e735..e58d8b551 100644 --- a/src/models/index.ts +++ b/src/models/index.ts @@ -53,6 +53,7 @@ import { PromoNote } from './entities/promo-note'; import { PromoRead } from './entities/promo-read'; import { EmojiRepository } from './repositories/emoji'; import { RelayRepository } from './repositories/relay'; +import { MutedNote } from './entities/muted-note'; export const Announcements = getRepository(Announcement); export const AnnouncementReads = getRepository(AnnouncementRead); @@ -108,3 +109,4 @@ export const AntennaNotes = getRepository(AntennaNote); export const PromoNotes = getRepository(PromoNote); export const PromoReads = getRepository(PromoRead); export const Relays = getCustomRepository(RelayRepository); +export const MutedNotes = getRepository(MutedNote); diff --git a/src/models/repositories/user.ts b/src/models/repositories/user.ts index bbaafc905..955a70ee6 100644 --- a/src/models/repositories/user.ts +++ b/src/models/repositories/user.ts @@ -239,6 +239,7 @@ export class UserRepository extends Repository { hasUnreadNotification: this.getHasUnreadNotification(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), integrations: profile!.integrations, + mutedWords: profile!.mutedWords, } : {}), ...(opts.includeSecrets ? { diff --git a/src/server/api/common/generate-muted-note-query.ts b/src/server/api/common/generate-muted-note-query.ts new file mode 100644 index 000000000..498930476 --- /dev/null +++ b/src/server/api/common/generate-muted-note-query.ts @@ -0,0 +1,13 @@ +import { User } from '../../../models/entities/user'; +import { MutedNotes } from '../../../models'; +import { SelectQueryBuilder } from 'typeorm'; + +export function generateMutedNoteQuery(q: SelectQueryBuilder, me: User) { + const mutedQuery = MutedNotes.createQueryBuilder('muted') + .select('muted.noteId') + .where('muted.userId = :userId', { userId: me.id }); + + q.andWhere(`note.id NOT IN (${ mutedQuery.getQuery() })`); + + q.setParameters(mutedQuery.getParameters()); +} diff --git a/src/server/api/endpoints/i/update.ts b/src/server/api/endpoints/i/update.ts index 48b5e48fc..e1889df22 100644 --- a/src/server/api/endpoints/i/update.ts +++ b/src/server/api/endpoints/i/update.ts @@ -142,7 +142,11 @@ export const meta = { desc: { 'ja-JP': 'ピン留めするページID' } - } + }, + + mutedWords: { + validator: $.optional.arr($.arr($.str)) + }, }, errors: { @@ -193,6 +197,10 @@ export default define(meta, async (ps, user, token) => { if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; + if (ps.mutedWords !== undefined) { + profileUpdates.mutedWords = ps.mutedWords; + profileUpdates.enableWordMute = ps.mutedWords.length > 0; + } if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; diff --git a/src/server/api/endpoints/notes/global-timeline.ts b/src/server/api/endpoints/notes/global-timeline.ts index 26b0cb0f5..4361b8a29 100644 --- a/src/server/api/endpoints/notes/global-timeline.ts +++ b/src/server/api/endpoints/notes/global-timeline.ts @@ -10,6 +10,7 @@ import { activeUsersChart } from '../../../../services/chart'; import { generateRepliesQuery } from '../../common/generate-replies-query'; import { injectPromo } from '../../common/inject-promo'; import { injectFeatured } from '../../common/inject-featured'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; export const meta = { desc: { @@ -83,6 +84,7 @@ export default define(meta, async (ps, user) => { generateRepliesQuery(query, user); if (user) generateMuteQuery(query, user); + if (user) generateMutedNoteQuery(query, user); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/src/server/api/endpoints/notes/hybrid-timeline.ts b/src/server/api/endpoints/notes/hybrid-timeline.ts index b0a73d1d7..82199e607 100644 --- a/src/server/api/endpoints/notes/hybrid-timeline.ts +++ b/src/server/api/endpoints/notes/hybrid-timeline.ts @@ -12,6 +12,7 @@ import { activeUsersChart } from '../../../../services/chart'; import { generateRepliesQuery } from '../../common/generate-replies-query'; import { injectPromo } from '../../common/inject-promo'; import { injectFeatured } from '../../common/inject-featured'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; export const meta = { desc: { @@ -133,6 +134,7 @@ export default define(meta, async (ps, user) => { generateRepliesQuery(query, user); generateVisibilityQuery(query, user); generateMuteQuery(query, user); + generateMutedNoteQuery(query, user); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/src/server/api/endpoints/notes/local-timeline.ts b/src/server/api/endpoints/notes/local-timeline.ts index a74dc3b15..9d51b3b48 100644 --- a/src/server/api/endpoints/notes/local-timeline.ts +++ b/src/server/api/endpoints/notes/local-timeline.ts @@ -12,6 +12,7 @@ import { Brackets } from 'typeorm'; import { generateRepliesQuery } from '../../common/generate-replies-query'; import { injectPromo } from '../../common/inject-promo'; import { injectFeatured } from '../../common/inject-featured'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; export const meta = { desc: { @@ -101,6 +102,7 @@ export default define(meta, async (ps, user) => { generateRepliesQuery(query, user); generateVisibilityQuery(query, user); if (user) generateMuteQuery(query, user); + if (user) generateMutedNoteQuery(query, user); if (ps.withFiles) { query.andWhere('note.fileIds != \'{}\''); diff --git a/src/server/api/endpoints/notes/timeline.ts b/src/server/api/endpoints/notes/timeline.ts index d60136a9c..c6929f4a5 100644 --- a/src/server/api/endpoints/notes/timeline.ts +++ b/src/server/api/endpoints/notes/timeline.ts @@ -10,6 +10,7 @@ import { Brackets } from 'typeorm'; import { generateRepliesQuery } from '../../common/generate-replies-query'; import { injectPromo } from '../../common/inject-promo'; import { injectFeatured } from '../../common/inject-featured'; +import { generateMutedNoteQuery } from '../../common/generate-muted-note-query'; export const meta = { desc: { @@ -126,6 +127,7 @@ export default define(meta, async (ps, user) => { generateRepliesQuery(query, user); generateVisibilityQuery(query, user); generateMuteQuery(query, user); + generateMutedNoteQuery(query, user); if (ps.includeMyRenotes === false) { query.andWhere(new Brackets(qb => { diff --git a/src/server/api/stream/channel.ts b/src/server/api/stream/channel.ts index 18fa65182..82a95ad3d 100644 --- a/src/server/api/stream/channel.ts +++ b/src/server/api/stream/channel.ts @@ -15,6 +15,10 @@ export default abstract class Channel { return this.connection.user; } + protected get userProfile() { + return this.connection.userProfile; + } + protected get following() { return this.connection.following; } diff --git a/src/server/api/stream/channels/global-timeline.ts b/src/server/api/stream/channels/global-timeline.ts index a3ecf8e70..39800fa77 100644 --- a/src/server/api/stream/channels/global-timeline.ts +++ b/src/server/api/stream/channels/global-timeline.ts @@ -4,6 +4,7 @@ import Channel from '../channel'; import { fetchMeta } from '../../../../misc/fetch-meta'; import { Notes } from '../../../../models'; import { PackedNote } from '../../../../models/repositories/note'; +import { checkWordMute } from '../../../../misc/check-word-mute'; export default class extends Channel { public readonly chName = 'globalTimeline'; @@ -47,6 +48,13 @@ export default class extends Channel { // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (shouldMuteThisNote(note, this.muting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + this.send('note', note); } diff --git a/src/server/api/stream/channels/home-timeline.ts b/src/server/api/stream/channels/home-timeline.ts index 3cf57c294..8504d4547 100644 --- a/src/server/api/stream/channels/home-timeline.ts +++ b/src/server/api/stream/channels/home-timeline.ts @@ -3,6 +3,7 @@ import shouldMuteThisNote from '../../../../misc/should-mute-this-note'; import Channel from '../channel'; import { Notes } from '../../../../models'; import { PackedNote } from '../../../../models/repositories/note'; +import { checkWordMute } from '../../../../misc/check-word-mute'; export default class extends Channel { public readonly chName = 'homeTimeline'; @@ -52,6 +53,13 @@ export default class extends Channel { // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (shouldMuteThisNote(note, this.muting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + this.send('note', note); } diff --git a/src/server/api/stream/channels/hybrid-timeline.ts b/src/server/api/stream/channels/hybrid-timeline.ts index 40686f4b2..bc491934e 100644 --- a/src/server/api/stream/channels/hybrid-timeline.ts +++ b/src/server/api/stream/channels/hybrid-timeline.ts @@ -5,6 +5,7 @@ import { fetchMeta } from '../../../../misc/fetch-meta'; import { Notes } from '../../../../models'; import { PackedNote } from '../../../../models/repositories/note'; import { PackedUser } from '../../../../models/repositories/user'; +import { checkWordMute } from '../../../../misc/check-word-mute'; export default class extends Channel { public readonly chName = 'hybridTimeline'; @@ -61,6 +62,13 @@ export default class extends Channel { // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (shouldMuteThisNote(note, this.muting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + this.send('note', note); } diff --git a/src/server/api/stream/channels/local-timeline.ts b/src/server/api/stream/channels/local-timeline.ts index 4b7f74e4f..3279912f8 100644 --- a/src/server/api/stream/channels/local-timeline.ts +++ b/src/server/api/stream/channels/local-timeline.ts @@ -5,6 +5,7 @@ import { fetchMeta } from '../../../../misc/fetch-meta'; import { Notes } from '../../../../models'; import { PackedNote } from '../../../../models/repositories/note'; import { PackedUser } from '../../../../models/repositories/user'; +import { checkWordMute } from '../../../../misc/check-word-mute'; export default class extends Channel { public readonly chName = 'localTimeline'; @@ -49,6 +50,13 @@ export default class extends Channel { // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する if (shouldMuteThisNote(note, this.muting)) return; + // 流れてきたNoteがミュートすべきNoteだったら無視する + // TODO: 将来的には、単にMutedNoteテーブルにレコードがあるかどうかで判定したい(以下の理由により難しそうではある) + // 現状では、ワードミュートにおけるMutedNoteレコードの追加処理はストリーミングに流す処理と並列で行われるため、 + // レコードが追加されるNoteでも追加されるより先にここのストリーミングの処理に到達することが起こる。 + // そのためレコードが存在するかのチェックでは不十分なので、改めてcheckWordMuteを呼んでいる + if (this.userProfile && await checkWordMute(note, this.user, this.userProfile.mutedWords)) return; + this.send('note', note); } diff --git a/src/server/api/stream/index.ts b/src/server/api/stream/index.ts index b7cefcf5a..bebf88a7c 100644 --- a/src/server/api/stream/index.ts +++ b/src/server/api/stream/index.ts @@ -7,15 +7,17 @@ import Channel from './channel'; import channels from './channels'; import { EventEmitter } from 'events'; import { User } from '../../../models/entities/user'; -import { Users, Followings, Mutings } from '../../../models'; +import { Users, Followings, Mutings, UserProfiles } from '../../../models'; import { ApiError } from '../error'; import { AccessToken } from '../../../models/entities/access-token'; +import { UserProfile } from '../../../models/entities/user-profile'; /** * Main stream connection */ export default class Connection { public user?: User; + public userProfile?: UserProfile; public following: User['id'][] = []; public muting: User['id'][] = []; public token?: AccessToken; @@ -25,6 +27,7 @@ export default class Connection { private subscribingNotes: any = {}; private followingClock: NodeJS.Timer; private mutingClock: NodeJS.Timer; + private userProfileClock: NodeJS.Timer; constructor( wsConnection: websocket.connection, @@ -49,6 +52,9 @@ export default class Connection { this.updateMuting(); this.mutingClock = setInterval(this.updateMuting, 5000); + + this.updateUserProfile(); + this.userProfileClock = setInterval(this.updateUserProfile, 5000); } } @@ -262,6 +268,13 @@ export default class Connection { this.muting = mutings.map(x => x.muteeId); } + @autobind + private async updateUserProfile() { + this.userProfile = await UserProfiles.findOne({ + userId: this.user!.id + }); + } + /** * ストリームが切れたとき */ @@ -273,5 +286,6 @@ export default class Connection { if (this.followingClock) clearInterval(this.followingClock); if (this.mutingClock) clearInterval(this.mutingClock); + if (this.userProfileClock) clearInterval(this.userProfileClock); } } diff --git a/src/services/note/create.ts b/src/services/note/create.ts index 7b5e6a92b..44ec5fda6 100644 --- a/src/services/note/create.ts +++ b/src/services/note/create.ts @@ -17,7 +17,7 @@ import extractMentions from '../../misc/extract-mentions'; import extractEmojis from '../../misc/extract-emojis'; import extractHashtags from '../../misc/extract-hashtags'; import { Note, IMentionedRemoteUsers } from '../../models/entities/note'; -import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings } from '../../models'; +import { Mutings, Users, NoteWatchings, Notes, Instances, UserProfiles, Antennas, Followings, MutedNotes } from '../../models'; import { DriveFile } from '../../models/entities/drive-file'; import { App } from '../../models/entities/app'; import { Not, getConnection, In } from 'typeorm'; @@ -29,6 +29,7 @@ import { createNotification } from '../create-notification'; import { isDuplicateKeyValueError } from '../../misc/is-duplicate-key-value-error'; import { ensure } from '../../prelude/ensure'; import { checkHitAntenna } from '../../misc/check-hit-antenna'; +import { checkWordMute } from '../../misc/check-word-mute'; import { addNoteToAntenna } from '../add-note-to-antenna'; import { countSameRenotes } from '../../misc/count-same-renotes'; import { deliverToRelays } from '../relay'; @@ -219,6 +220,24 @@ export default async (user: User, data: Option, silent = false) => new Promise { + for (const u of us) { + checkWordMute(note, { id: u.userId }, u.mutedWords).then(shouldMute => { + if (shouldMute) { + MutedNotes.save({ + id: genId(), + userId: u.userId, + noteId: note.id, + reason: 'word', + }); + } + }); + } + }); + // Antenna Antennas.find().then(async antennas => { const followings = await Followings.createQueryBuilder('following') diff --git a/src/types.ts b/src/types.ts index 30a62412a..d8eb44281 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,5 @@ export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const; export const noteVisibilities = ['public', 'home', 'followers', 'specified'] as const; + +export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const; diff --git a/yarn.lock b/yarn.lock index dd1d55b91..082f8b4dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3245,6 +3245,11 @@ entities@^2.0.0, entities@~2.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4" integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw== +env-paths@^2.2.0: + version "2.2.0" + resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.0.tgz#cdca557dc009152917d6166e2febe1f039685e43" + integrity sha512-6u0VYSCo/OW6IoD5WCLLy9JUGARbamfSavcNXry/eu8aHVFei6CD3Sw+VGX5alea1i9pgPHW0mbu6Xj0uBh7gA== + errno@^0.1.3: version "0.1.7" resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" @@ -4129,6 +4134,11 @@ graceful-fs@4.X, graceful-fs@^4.0.0, graceful-fs@^4.1.11, graceful-fs@^4.1.15, g resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423" integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== +graceful-fs@^4.2.3: + version "4.2.4" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.4.tgz#2256bde14d3632958c465ebc96dc467ca07a29fb" + integrity sha512-WjKPNJF79dtJAVniUlGGWHYGz2jWxT6VhN/4m1NdkbZ2nOsEF+cI1Edgql5zCRhs/VsQYRvrXctxktVXZUkixw== + growl@1.10.5: version "1.10.5" resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" @@ -4658,6 +4668,11 @@ insert-text-at-cursor@0.3.0: resolved "https://registry.yarnpkg.com/insert-text-at-cursor/-/insert-text-at-cursor-0.3.0.tgz#1819607680ec1570618347c4cd475e791faa25da" integrity sha512-/nPtyeX9xPUvxZf+r0518B7uqNKlP+LqNJqSiXFEaa2T71rWIwTVXGH7hB9xO/EVdwa5/pWlFCPwShOW81XIxQ== +install-artifact-from-github@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/install-artifact-from-github/-/install-artifact-from-github-1.0.2.tgz#e1e478dd29880b9112ecd684a84029603e234a9d" + integrity sha512-yuMFBSVIP3vD0SDBGUqeIpgOAIlFx8eQFknQObpkYEM5gsl9hy6R9Ms3aV+Vw9MMyYsoPMeex0XDnfgY7uzc+Q== + interpret@^1.1.0: version "1.2.0" resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" @@ -6187,7 +6202,7 @@ mz@^2.4.0, mz@^2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nan@^2.14.0: +nan@^2.14.0, nan@^2.14.1: version "2.14.1" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== @@ -6283,6 +6298,22 @@ node-forge@^0.9.1: resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.1.tgz#775368e6846558ab6676858a4d8c6e8d16c677b5" integrity sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ== +node-gyp@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/node-gyp/-/node-gyp-7.0.0.tgz#2e88425ce84e9b1a4433958ed55d74c70fffb6be" + integrity sha512-ZW34qA3CJSPKDz2SJBHKRvyNQN0yWO5EGKKksJc+jElu9VA468gwJTyTArC1iOXU7rN3Wtfg/CMt/dBAOFIjvg== + dependencies: + env-paths "^2.2.0" + glob "^7.1.4" + graceful-fs "^4.2.3" + nopt "^4.0.3" + npmlog "^4.1.2" + request "^2.88.2" + rimraf "^2.6.3" + semver "^7.3.2" + tar "^6.0.1" + which "^2.0.2" + node-object-hash@^1.2.0: version "1.4.2" resolved "https://registry.yarnpkg.com/node-object-hash/-/node-object-hash-1.4.2.tgz#385833d85b229902b75826224f6077be969a9e94" @@ -7775,6 +7806,15 @@ rdf-canonize@^1.0.2: node-forge "^0.9.1" semver "^6.3.0" +re2@1.15.4: + version "1.15.4" + resolved "https://registry.yarnpkg.com/re2/-/re2-1.15.4.tgz#2ffc3e4894fb60430393459978197648be01a0a9" + integrity sha512-7w3K+Daq/JjbX/dz5voMt7B9wlprVBQnMiypyCojAZ99kcAL+3LiJ5uBoX/u47l8eFTVq3Wj+V0pmvU+CT8tOg== + dependencies: + install-artifact-from-github "^1.0.2" + nan "^2.14.1" + node-gyp "^7.0.0" + read-pkg-up@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" @@ -8183,7 +8223,7 @@ rimraf@3.0.2, rimraf@^3.0.0, rimraf@^3.0.2: dependencies: glob "^7.1.3" -rimraf@^2.6.2: +rimraf@^2.6.2, rimraf@^2.6.3: version "2.7.1" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== @@ -9088,7 +9128,7 @@ tar-stream@^2.0.0: inherits "^2.0.3" readable-stream "^3.1.1" -tar@^6.0.2: +tar@^6.0.1, tar@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.2.tgz#5df17813468a6264ff14f766886c622b84ae2f39" integrity sha512-Glo3jkRtPcvpDlAs/0+hozav78yoXKFr+c4wgw62NNMO3oo4AaJdCo21Uu7lcwr55h39W2XD1LMERc64wtbItg== @@ -10138,7 +10178,7 @@ which-pm-runs@^1.0.0: resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb" integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs= -which@2.0.2, which@^2.0.1: +which@2.0.2, which@^2.0.1, which@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==