From cf43dd6ec530ba4a3f589ae917e89533b352f6a3 Mon Sep 17 00:00:00 2001
From: syuilo <Syuilotan@yahoo.co.jp>
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 ffd61bfe41..c34d93dc87 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 0000000000..1a9114d921
--- /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<void> {
+        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<void> {
+        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 0000000000..d68c12740e
--- /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<void> {
+        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<void> {
+        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 3e6439f0d3..376ee7105e 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 dc3cce9e57..9bbf763494 100644
--- a/src/client/components/note.vue
+++ b/src/client/components/note.vue
@@ -1,6 +1,7 @@
 <template>
 <div
 	class="note _panel"
+	v-if="!muted"
 	v-show="!isDeleted"
 	:tabindex="!isDeleted ? '-1' : null"
 	:class="{ renote: isRenote }"
@@ -84,6 +85,13 @@
 	</article>
 	<x-sub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
 </div>
+<div v-else class="_panel muted" @click="muted = false">
+	<i18n path="userSaysSomething" tag="small">
+		<router-link class="name" :to="appearNote.user | userPage" v-user-preview="appearNote.userId" place="name">
+			<mk-user-name :user="appearNote.user"/>
+		</router-link>
+	</i18n>
+</div>
 </template>
 
 <script lang="ts">
@@ -105,6 +113,7 @@ import pleaseLogin from '../scripts/please-login';
 import { focusPrev, focusNext } from '../scripts/focus';
 import { url } from '../config';
 import copyToClipboard from '../scripts/copy-to-clipboard';
+import { checkWordMute } from '../scripts/check-word-mute';
 
 export default Vue.extend({
 	components: {
@@ -142,6 +151,7 @@ export default Vue.extend({
 			replies: [],
 			showContent: false,
 			isDeleted: false,
+			muted: false,
 			myReaction: null,
 			reactions: {},
 			emojis: [],
@@ -227,15 +237,16 @@ export default Vue.extend({
 		}
 	},
 
-	created() {
-		this.emojis = [...this.appearNote.emojis];
-		this.reactions = { ...this.appearNote.reactions };
-		this.myReaction = this.appearNote.myReaction;
-
+	async created() {
 		if (this.$store.getters.isSignedIn) {
 			this.connection = this.$root.stream;
 		}
 
+		this.emojis = [...this.appearNote.emojis];
+		this.reactions = { ...this.appearNote.reactions };
+		this.myReaction = this.appearNote.myReaction;
+		this.muted = await checkWordMute(this.appearNote, this.$store.state.i, this.$store.state.settings.mutedWords);
+
 		if (this.detail) {
 			this.$root.api('notes/children', {
 				noteId: this.appearNote.id,
@@ -976,4 +987,10 @@ export default Vue.extend({
 		}
 	}
 }
+
+.muted {
+	padding: 8px;
+	text-align: center;
+	opacity: 0.7;
+}
 </style>
diff --git a/src/client/components/tab.vue b/src/client/components/tab.vue
new file mode 100644
index 0000000000..3ea63fa59f
--- /dev/null
+++ b/src/client/components/tab.vue
@@ -0,0 +1,42 @@
+<template>
+<div class="pxhvhrfw" v-size="[{ max: 500 }]">
+	<button v-for="item in items" class="_button" @click="$emit('input', item.value)" :class="{ active: value === item.value }" :key="item.value">{{ item.label }}</button>
+</div>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+
+export default Vue.extend({
+	props: {
+		items: {
+			type: Array,
+			required: true,
+		},
+		value: {
+			required: true,
+		},
+	},
+});
+</script>
+
+<style lang="scss" scoped>
+.pxhvhrfw {
+	display: flex;
+
+	> button {
+		flex: 1;
+		padding: 11px 8px 8px 8px;
+		border-bottom: solid 3px transparent;
+
+		&.active {
+			color: var(--accent);
+			border-bottom-color: var(--accent);
+		}
+	}
+
+	&.max-width_500px {
+		font-size: 80%;
+	}
+}
+</style>
diff --git a/src/client/pages/my-settings/index.vue b/src/client/pages/my-settings/index.vue
index 3af896d78e..16e786bfc8 100644
--- a/src/client/pages/my-settings/index.vue
+++ b/src/client/pages/my-settings/index.vue
@@ -27,6 +27,7 @@
 	<x-import-export/>
 	<x-drive/>
 	<x-mute-block/>
+	<x-word-mute/>
 	<x-security/>
 	<x-2fa/>
 	<x-integration/>
@@ -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 0000000000..6b2a372f0b
--- /dev/null
+++ b/src/client/pages/my-settings/word-mute.vue
@@ -0,0 +1,77 @@
+<template>
+<section class="_card">
+	<div class="_title"><fa :icon="faCommentSlash"/> {{ $t('wordMute') }}</div>
+	<div class="_content _noPad">
+		<mk-tab v-model="tab" :items="[{ label: $t('_wordMute.soft'), value: 'soft' }, { label: $t('_wordMute.hard'), value: 'hard' }]"/>
+	</div>
+	<div class="_content" v-show="tab === 'soft'">
+		<mk-info>{{ $t('_wordMute.softDescription') }}</mk-info>
+		<mk-textarea v-model="softMutedWords">
+			<span>{{ $t('_wordMute.muteWords') }}</span>
+			<template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template>
+		</mk-textarea>
+	</div>
+	<div class="_content" v-show="tab === 'hard'">
+		<mk-info>{{ $t('_wordMute.hardDescription') }}</mk-info>
+		<mk-textarea v-model="hardMutedWords">
+			<span>{{ $t('_wordMute.muteWords') }}</span>
+			<template #desc>{{ $t('_wordMute.muteWordsDescription') }}<br>{{ $t('_wordMute.muteWordsDescription2') }}</template>
+		</mk-textarea>
+	</div>
+	<div class="_footer">
+		<mk-button @click="save()" primary inline :disabled="!changed"><fa :icon="faSave"/> {{ $t('save') }}</mk-button>
+	</div>
+</section>
+</template>
+
+<script lang="ts">
+import Vue from 'vue';
+import { faCommentSlash, faSave } from '@fortawesome/free-solid-svg-icons';
+import MkButton from '../../components/ui/button.vue';
+import MkTextarea from '../../components/ui/textarea.vue';
+import MkTab from '../../components/tab.vue';
+import MkInfo from '../../components/ui/info.vue';
+
+export default Vue.extend({
+	components: {
+		MkButton,
+		MkTextarea,
+		MkTab,
+		MkInfo,
+	},
+	
+	data() {
+		return {
+			tab: 'soft',
+			softMutedWords: '',
+			hardMutedWords: '',
+			changed: false,
+			faCommentSlash, faSave,
+		}
+	},
+
+	watch: {
+		softMutedWords() {
+			this.changed = true;
+		},
+		hardMutedWords() {
+			this.changed = true;
+		},
+	},
+
+	created() {
+		this.softMutedWords = this.$store.state.settings.mutedWords.map(x => x.join(' ')).join('\n');
+		this.hardMutedWords = this.$store.state.i.mutedWords.map(x => x.join(' ')).join('\n');
+	},
+
+	methods: {
+		async save() {
+			this.$store.dispatch('settings/set', { key: 'mutedWords', value: this.softMutedWords.trim().split('\n').map(x => x.trim().split(' ')) });
+			await this.$root.api('i/update', {
+				mutedWords: this.hardMutedWords.trim().split('\n').map(x => x.trim().split(' ')),
+			});
+			this.changed = false;
+		},
+	}
+});
+</script>
diff --git a/src/client/scripts/check-word-mute.ts b/src/client/scripts/check-word-mute.ts
new file mode 100644
index 0000000000..3b1fa75b1e
--- /dev/null
+++ b/src/client/scripts/check-word-mute.ts
@@ -0,0 +1,26 @@
+export async function checkWordMute(note: Record<string, any>, me: Record<string, any> | null | undefined, mutedWords: string[][]): Promise<boolean> {
+	// 自分自身
+	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 2cd2c8cf3c..2bf44088af 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 c3d3cf2233..ab0dcf6220 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 81fb92f684..6ffc56ee08 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 0000000000..5af267d75d
--- /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<boolean> {
+	// 自分自身
+	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 0000000000..521876688c
--- /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 a89d7364f3..0a6722aace 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<string, any>;
 
+	@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 e1389e7353..e58d8b551d 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 bbaafc9050..955a70ee60 100644
--- a/src/models/repositories/user.ts
+++ b/src/models/repositories/user.ts
@@ -239,6 +239,7 @@ export class UserRepository extends Repository<User> {
 				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 0000000000..498930476c
--- /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<any>, 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 48b5e48fc2..e1889df22d 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 26b0cb0f5a..4361b8a299 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 b0a73d1d7d..82199e607e 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 a74dc3b15c..9d51b3b48b 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 d60136a9ca..c6929f4a51 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 18fa651820..82a95ad3d7 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 a3ecf8e706..39800fa775 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 3cf57c294c..8504d4547b 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 40686f4b28..bc491934ea 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 4b7f74e4f7..3279912f87 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 b7cefcf5ab..bebf88a7cd 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 7b5e6a92ba..44ec5fda6f 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<N
 	// Increment notes count (user)
 	incNotesCountOfUser(user);
 
+	// Word mute
+	UserProfiles.find({
+		enableWordMute: true
+	}).then(us => {
+		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 30a62412a8..d8eb442810 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 dd1d55b91b..082f8b4dd8 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==