ワードミュート (#6594)

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip
This commit is contained in:
syuilo 2020-07-27 13:34:20 +09:00 committed by GitHub
parent b5a1fdd4c7
commit cf43dd6ec5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 485 additions and 12 deletions

View File

@ -553,6 +553,17 @@ emptyToDisableSmtpAuth: "ユーザー名とパスワードを空欄にするこ
smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する" smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する"
smtpSecureInfo: "STARTTLS使用時はオフにします。" smtpSecureInfo: "STARTTLS使用時はオフにします。"
testEmail: "配信テスト" testEmail: "配信テスト"
wordMute: "ワードミュート"
userSaysSomething: "{name}が何かを言いました"
_wordMute:
muteWords: "ミュートするワード"
muteWordsDescription: "スペースで区切るとAND指定になり、改行で区切るとOR指定になります。"
muteWordsDescription2: "キーワードをスラッシュで囲むと正規表現になります。"
softDescription: "指定した条件のノートをタイムラインから隠します。"
hardDescription: "指定した条件のノートをタイムラインに追加しないようにします。追加されなかったノートは、条件を変更しても除外されたままになります。"
soft: "ソフト"
hard: "ハード"
_theme: _theme:
explore: "テーマを探す" explore: "テーマを探す"

View File

@ -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"`);
}
}

View File

@ -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"`);
}
}

View File

@ -204,6 +204,7 @@
"random-seed": "0.3.0", "random-seed": "0.3.0",
"randomcolor": "0.5.4", "randomcolor": "0.5.4",
"ratelimiter": "3.4.1", "ratelimiter": "3.4.1",
"re2": "1.15.4",
"recaptcha-promise": "0.1.3", "recaptcha-promise": "0.1.3",
"reconnecting-websocket": "4.4.0", "reconnecting-websocket": "4.4.0",
"redis": "3.0.2", "redis": "3.0.2",

View File

@ -1,6 +1,7 @@
<template> <template>
<div <div
class="note _panel" class="note _panel"
v-if="!muted"
v-show="!isDeleted" v-show="!isDeleted"
:tabindex="!isDeleted ? '-1' : null" :tabindex="!isDeleted ? '-1' : null"
:class="{ renote: isRenote }" :class="{ renote: isRenote }"
@ -84,6 +85,13 @@
</article> </article>
<x-sub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/> <x-sub v-for="note in replies" :key="note.id" :note="note" class="reply" :detail="true"/>
</div> </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> </template>
<script lang="ts"> <script lang="ts">
@ -105,6 +113,7 @@ import pleaseLogin from '../scripts/please-login';
import { focusPrev, focusNext } from '../scripts/focus'; import { focusPrev, focusNext } from '../scripts/focus';
import { url } from '../config'; import { url } from '../config';
import copyToClipboard from '../scripts/copy-to-clipboard'; import copyToClipboard from '../scripts/copy-to-clipboard';
import { checkWordMute } from '../scripts/check-word-mute';
export default Vue.extend({ export default Vue.extend({
components: { components: {
@ -142,6 +151,7 @@ export default Vue.extend({
replies: [], replies: [],
showContent: false, showContent: false,
isDeleted: false, isDeleted: false,
muted: false,
myReaction: null, myReaction: null,
reactions: {}, reactions: {},
emojis: [], emojis: [],
@ -227,15 +237,16 @@ export default Vue.extend({
} }
}, },
created() { async created() {
this.emojis = [...this.appearNote.emojis];
this.reactions = { ...this.appearNote.reactions };
this.myReaction = this.appearNote.myReaction;
if (this.$store.getters.isSignedIn) { if (this.$store.getters.isSignedIn) {
this.connection = this.$root.stream; 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) { if (this.detail) {
this.$root.api('notes/children', { this.$root.api('notes/children', {
noteId: this.appearNote.id, noteId: this.appearNote.id,
@ -976,4 +987,10 @@ export default Vue.extend({
} }
} }
} }
.muted {
padding: 8px;
text-align: center;
opacity: 0.7;
}
</style> </style>

View File

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

View File

@ -27,6 +27,7 @@
<x-import-export/> <x-import-export/>
<x-drive/> <x-drive/>
<x-mute-block/> <x-mute-block/>
<x-word-mute/>
<x-security/> <x-security/>
<x-2fa/> <x-2fa/>
<x-integration/> <x-integration/>
@ -47,6 +48,7 @@ import XImportExport from './import-export.vue';
import XDrive from './drive.vue'; import XDrive from './drive.vue';
import XReactionSetting from './reaction.vue'; import XReactionSetting from './reaction.vue';
import XMuteBlock from './mute-block.vue'; import XMuteBlock from './mute-block.vue';
import XWordMute from './word-mute.vue';
import XSecurity from './security.vue'; import XSecurity from './security.vue';
import X2fa from './2fa.vue'; import X2fa from './2fa.vue';
import XIntegration from './integration.vue'; import XIntegration from './integration.vue';
@ -68,6 +70,7 @@ export default Vue.extend({
XDrive, XDrive,
XReactionSetting, XReactionSetting,
XMuteBlock, XMuteBlock,
XWordMute,
XSecurity, XSecurity,
X2fa, X2fa,
XIntegration, XIntegration,

View File

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

View File

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

View File

@ -18,6 +18,7 @@ export const defaultSettings = {
pastedFileName: 'yyyy-MM-dd HH-mm-ss [{{number}}]', pastedFileName: 'yyyy-MM-dd HH-mm-ss [{{number}}]',
memo: null, memo: null,
reactions: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'], reactions: ['👍', '❤️', '😆', '🤔', '😮', '🎉', '💢', '😥', '😇', '🍮'],
mutedWords: [],
}; };
export const defaultDeviceUserSettings = { export const defaultDeviceUserSettings = {

View File

@ -355,6 +355,10 @@ hr {
padding: 16px; padding: 16px;
} }
&._noPad {
padding: 0 !important;
}
& + ._content { & + ._content {
border-top: solid 1px var(--divider); border-top: solid 1px var(--divider);
} }

View File

@ -59,6 +59,7 @@ import { PromoNote } from '../models/entities/promo-note';
import { PromoRead } from '../models/entities/promo-read'; import { PromoRead } from '../models/entities/promo-read';
import { program } from '../argv'; import { program } from '../argv';
import { Relay } from '../models/entities/relay'; import { Relay } from '../models/entities/relay';
import { MutedNote } from '../models/entities/muted-note';
const sqlLogger = dbLogger.createSubLogger('sql', 'white', false); const sqlLogger = dbLogger.createSubLogger('sql', 'white', false);
@ -151,6 +152,7 @@ export const entities = [
ReversiGame, ReversiGame,
ReversiMatching, ReversiMatching,
Relay, Relay,
MutedNote,
...charts as any ...charts as any
]; ];

View File

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

View File

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

View File

@ -147,6 +147,17 @@ export class UserProfile {
}) })
public integrations: Record<string, any>; public integrations: Record<string, any>;
@Index()
@Column('boolean', {
default: false,
})
public enableWordMute: boolean;
@Column('jsonb', {
default: []
})
public mutedWords: string[][];
//#region Denormalized fields //#region Denormalized fields
@Index() @Index()
@Column('varchar', { @Column('varchar', {

View File

@ -53,6 +53,7 @@ import { PromoNote } from './entities/promo-note';
import { PromoRead } from './entities/promo-read'; import { PromoRead } from './entities/promo-read';
import { EmojiRepository } from './repositories/emoji'; import { EmojiRepository } from './repositories/emoji';
import { RelayRepository } from './repositories/relay'; import { RelayRepository } from './repositories/relay';
import { MutedNote } from './entities/muted-note';
export const Announcements = getRepository(Announcement); export const Announcements = getRepository(Announcement);
export const AnnouncementReads = getRepository(AnnouncementRead); export const AnnouncementReads = getRepository(AnnouncementRead);
@ -108,3 +109,4 @@ export const AntennaNotes = getRepository(AntennaNote);
export const PromoNotes = getRepository(PromoNote); export const PromoNotes = getRepository(PromoNote);
export const PromoReads = getRepository(PromoRead); export const PromoReads = getRepository(PromoRead);
export const Relays = getCustomRepository(RelayRepository); export const Relays = getCustomRepository(RelayRepository);
export const MutedNotes = getRepository(MutedNote);

View File

@ -239,6 +239,7 @@ export class UserRepository extends Repository<User> {
hasUnreadNotification: this.getHasUnreadNotification(user.id), hasUnreadNotification: this.getHasUnreadNotification(user.id),
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
integrations: profile!.integrations, integrations: profile!.integrations,
mutedWords: profile!.mutedWords,
} : {}), } : {}),
...(opts.includeSecrets ? { ...(opts.includeSecrets ? {

View File

@ -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());
}

View File

@ -142,7 +142,11 @@ export const meta = {
desc: { desc: {
'ja-JP': 'ピン留めするページID' 'ja-JP': 'ピン留めするページID'
} }
} },
mutedWords: {
validator: $.optional.arr($.arr($.str))
},
}, },
errors: { errors: {
@ -193,6 +197,10 @@ export default define(meta, async (ps, user, token) => {
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday; if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId; if (ps.avatarId !== undefined) updates.avatarId = ps.avatarId;
if (ps.bannerId !== undefined) updates.bannerId = ps.bannerId; 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.isLocked === 'boolean') updates.isLocked = ps.isLocked;
if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot; if (typeof ps.isBot === 'boolean') updates.isBot = ps.isBot;
if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot; if (typeof ps.carefulBot === 'boolean') profileUpdates.carefulBot = ps.carefulBot;

View File

@ -10,6 +10,7 @@ import { activeUsersChart } from '../../../../services/chart';
import { generateRepliesQuery } from '../../common/generate-replies-query'; import { generateRepliesQuery } from '../../common/generate-replies-query';
import { injectPromo } from '../../common/inject-promo'; import { injectPromo } from '../../common/inject-promo';
import { injectFeatured } from '../../common/inject-featured'; import { injectFeatured } from '../../common/inject-featured';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
export const meta = { export const meta = {
desc: { desc: {
@ -83,6 +84,7 @@ export default define(meta, async (ps, user) => {
generateRepliesQuery(query, user); generateRepliesQuery(query, user);
if (user) generateMuteQuery(query, user); if (user) generateMuteQuery(query, user);
if (user) generateMutedNoteQuery(query, user);
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');

View File

@ -12,6 +12,7 @@ import { activeUsersChart } from '../../../../services/chart';
import { generateRepliesQuery } from '../../common/generate-replies-query'; import { generateRepliesQuery } from '../../common/generate-replies-query';
import { injectPromo } from '../../common/inject-promo'; import { injectPromo } from '../../common/inject-promo';
import { injectFeatured } from '../../common/inject-featured'; import { injectFeatured } from '../../common/inject-featured';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
export const meta = { export const meta = {
desc: { desc: {
@ -133,6 +134,7 @@ export default define(meta, async (ps, user) => {
generateRepliesQuery(query, user); generateRepliesQuery(query, user);
generateVisibilityQuery(query, user); generateVisibilityQuery(query, user);
generateMuteQuery(query, user); generateMuteQuery(query, user);
generateMutedNoteQuery(query, user);
if (ps.includeMyRenotes === false) { if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => { query.andWhere(new Brackets(qb => {

View File

@ -12,6 +12,7 @@ import { Brackets } from 'typeorm';
import { generateRepliesQuery } from '../../common/generate-replies-query'; import { generateRepliesQuery } from '../../common/generate-replies-query';
import { injectPromo } from '../../common/inject-promo'; import { injectPromo } from '../../common/inject-promo';
import { injectFeatured } from '../../common/inject-featured'; import { injectFeatured } from '../../common/inject-featured';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
export const meta = { export const meta = {
desc: { desc: {
@ -101,6 +102,7 @@ export default define(meta, async (ps, user) => {
generateRepliesQuery(query, user); generateRepliesQuery(query, user);
generateVisibilityQuery(query, user); generateVisibilityQuery(query, user);
if (user) generateMuteQuery(query, user); if (user) generateMuteQuery(query, user);
if (user) generateMutedNoteQuery(query, user);
if (ps.withFiles) { if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\''); query.andWhere('note.fileIds != \'{}\'');

View File

@ -10,6 +10,7 @@ import { Brackets } from 'typeorm';
import { generateRepliesQuery } from '../../common/generate-replies-query'; import { generateRepliesQuery } from '../../common/generate-replies-query';
import { injectPromo } from '../../common/inject-promo'; import { injectPromo } from '../../common/inject-promo';
import { injectFeatured } from '../../common/inject-featured'; import { injectFeatured } from '../../common/inject-featured';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
export const meta = { export const meta = {
desc: { desc: {
@ -126,6 +127,7 @@ export default define(meta, async (ps, user) => {
generateRepliesQuery(query, user); generateRepliesQuery(query, user);
generateVisibilityQuery(query, user); generateVisibilityQuery(query, user);
generateMuteQuery(query, user); generateMuteQuery(query, user);
generateMutedNoteQuery(query, user);
if (ps.includeMyRenotes === false) { if (ps.includeMyRenotes === false) {
query.andWhere(new Brackets(qb => { query.andWhere(new Brackets(qb => {

View File

@ -15,6 +15,10 @@ export default abstract class Channel {
return this.connection.user; return this.connection.user;
} }
protected get userProfile() {
return this.connection.userProfile;
}
protected get following() { protected get following() {
return this.connection.following; return this.connection.following;
} }

View File

@ -4,6 +4,7 @@ import Channel from '../channel';
import { fetchMeta } from '../../../../misc/fetch-meta'; import { fetchMeta } from '../../../../misc/fetch-meta';
import { Notes } from '../../../../models'; import { Notes } from '../../../../models';
import { PackedNote } from '../../../../models/repositories/note'; import { PackedNote } from '../../../../models/repositories/note';
import { checkWordMute } from '../../../../misc/check-word-mute';
export default class extends Channel { export default class extends Channel {
public readonly chName = 'globalTimeline'; public readonly chName = 'globalTimeline';
@ -47,6 +48,13 @@ export default class extends Channel {
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (shouldMuteThisNote(note, this.muting)) return; 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); this.send('note', note);
} }

View File

@ -3,6 +3,7 @@ import shouldMuteThisNote from '../../../../misc/should-mute-this-note';
import Channel from '../channel'; import Channel from '../channel';
import { Notes } from '../../../../models'; import { Notes } from '../../../../models';
import { PackedNote } from '../../../../models/repositories/note'; import { PackedNote } from '../../../../models/repositories/note';
import { checkWordMute } from '../../../../misc/check-word-mute';
export default class extends Channel { export default class extends Channel {
public readonly chName = 'homeTimeline'; public readonly chName = 'homeTimeline';
@ -52,6 +53,13 @@ export default class extends Channel {
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (shouldMuteThisNote(note, this.muting)) return; 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); this.send('note', note);
} }

View File

@ -5,6 +5,7 @@ import { fetchMeta } from '../../../../misc/fetch-meta';
import { Notes } from '../../../../models'; import { Notes } from '../../../../models';
import { PackedNote } from '../../../../models/repositories/note'; import { PackedNote } from '../../../../models/repositories/note';
import { PackedUser } from '../../../../models/repositories/user'; import { PackedUser } from '../../../../models/repositories/user';
import { checkWordMute } from '../../../../misc/check-word-mute';
export default class extends Channel { export default class extends Channel {
public readonly chName = 'hybridTimeline'; public readonly chName = 'hybridTimeline';
@ -61,6 +62,13 @@ export default class extends Channel {
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (shouldMuteThisNote(note, this.muting)) return; 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); this.send('note', note);
} }

View File

@ -5,6 +5,7 @@ import { fetchMeta } from '../../../../misc/fetch-meta';
import { Notes } from '../../../../models'; import { Notes } from '../../../../models';
import { PackedNote } from '../../../../models/repositories/note'; import { PackedNote } from '../../../../models/repositories/note';
import { PackedUser } from '../../../../models/repositories/user'; import { PackedUser } from '../../../../models/repositories/user';
import { checkWordMute } from '../../../../misc/check-word-mute';
export default class extends Channel { export default class extends Channel {
public readonly chName = 'localTimeline'; public readonly chName = 'localTimeline';
@ -49,6 +50,13 @@ export default class extends Channel {
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する // 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (shouldMuteThisNote(note, this.muting)) return; 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); this.send('note', note);
} }

View File

@ -7,15 +7,17 @@ import Channel from './channel';
import channels from './channels'; import channels from './channels';
import { EventEmitter } from 'events'; import { EventEmitter } from 'events';
import { User } from '../../../models/entities/user'; import { User } from '../../../models/entities/user';
import { Users, Followings, Mutings } from '../../../models'; import { Users, Followings, Mutings, UserProfiles } from '../../../models';
import { ApiError } from '../error'; import { ApiError } from '../error';
import { AccessToken } from '../../../models/entities/access-token'; import { AccessToken } from '../../../models/entities/access-token';
import { UserProfile } from '../../../models/entities/user-profile';
/** /**
* Main stream connection * Main stream connection
*/ */
export default class Connection { export default class Connection {
public user?: User; public user?: User;
public userProfile?: UserProfile;
public following: User['id'][] = []; public following: User['id'][] = [];
public muting: User['id'][] = []; public muting: User['id'][] = [];
public token?: AccessToken; public token?: AccessToken;
@ -25,6 +27,7 @@ export default class Connection {
private subscribingNotes: any = {}; private subscribingNotes: any = {};
private followingClock: NodeJS.Timer; private followingClock: NodeJS.Timer;
private mutingClock: NodeJS.Timer; private mutingClock: NodeJS.Timer;
private userProfileClock: NodeJS.Timer;
constructor( constructor(
wsConnection: websocket.connection, wsConnection: websocket.connection,
@ -49,6 +52,9 @@ export default class Connection {
this.updateMuting(); this.updateMuting();
this.mutingClock = setInterval(this.updateMuting, 5000); 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); 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.followingClock) clearInterval(this.followingClock);
if (this.mutingClock) clearInterval(this.mutingClock); if (this.mutingClock) clearInterval(this.mutingClock);
if (this.userProfileClock) clearInterval(this.userProfileClock);
} }
} }

View File

@ -17,7 +17,7 @@ import extractMentions from '../../misc/extract-mentions';
import extractEmojis from '../../misc/extract-emojis'; import extractEmojis from '../../misc/extract-emojis';
import extractHashtags from '../../misc/extract-hashtags'; import extractHashtags from '../../misc/extract-hashtags';
import { Note, IMentionedRemoteUsers } from '../../models/entities/note'; 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 { DriveFile } from '../../models/entities/drive-file';
import { App } from '../../models/entities/app'; import { App } from '../../models/entities/app';
import { Not, getConnection, In } from 'typeorm'; 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 { isDuplicateKeyValueError } from '../../misc/is-duplicate-key-value-error';
import { ensure } from '../../prelude/ensure'; import { ensure } from '../../prelude/ensure';
import { checkHitAntenna } from '../../misc/check-hit-antenna'; import { checkHitAntenna } from '../../misc/check-hit-antenna';
import { checkWordMute } from '../../misc/check-word-mute';
import { addNoteToAntenna } from '../add-note-to-antenna'; import { addNoteToAntenna } from '../add-note-to-antenna';
import { countSameRenotes } from '../../misc/count-same-renotes'; import { countSameRenotes } from '../../misc/count-same-renotes';
import { deliverToRelays } from '../relay'; import { deliverToRelays } from '../relay';
@ -219,6 +220,24 @@ export default async (user: User, data: Option, silent = false) => new Promise<N
// Increment notes count (user) // Increment notes count (user)
incNotesCountOfUser(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 // Antenna
Antennas.find().then(async antennas => { Antennas.find().then(async antennas => {
const followings = await Followings.createQueryBuilder('following') const followings = await Followings.createQueryBuilder('following')

View File

@ -1,3 +1,5 @@
export const notificationTypes = ['follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollVote', 'receiveFollowRequest', 'followRequestAccepted', 'groupInvited', 'app'] as const; 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 noteVisibilities = ['public', 'home', 'followers', 'specified'] as const;
export const mutedNoteReasons = ['word', 'manual', 'spam', 'other'] as const;

View File

@ -3245,6 +3245,11 @@ entities@^2.0.0, entities@~2.0.0:
resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4" resolved "https://registry.yarnpkg.com/entities/-/entities-2.0.0.tgz#68d6084cab1b079767540d80e56a39b423e4abf4"
integrity sha512-D9f7V0JSRwIxlRI2mjMqufDrRDnx8p+eEOz7aUM9SuvF8gsBzra0/6tbjl1m8eQHrZlYj6PxqE00hZ1SAIKPLw== 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: errno@^0.1.3:
version "0.1.7" version "0.1.7"
resolved "https://registry.yarnpkg.com/errno/-/errno-0.1.7.tgz#4684d71779ad39af177e3f007996f7c67c852618" 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" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.3.tgz#4a12ff1b60376ef09862c2093edd908328be8423"
integrity sha512-a30VEBm4PEdx1dRB7MFK7BejejvCvBronbLjht+sHuGYj8PHs7M/5Z+rt5lw551vZ7yfTCj4Vuyy3mSJytDWRQ== 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: growl@1.10.5:
version "1.10.5" version "1.10.5"
resolved "https://registry.yarnpkg.com/growl/-/growl-1.10.5.tgz#f2735dc2283674fa67478b10181059355c369e5e" 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" 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== 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: interpret@^1.1.0:
version "1.2.0" version "1.2.0"
resolved "https://registry.yarnpkg.com/interpret/-/interpret-1.2.0.tgz#d5061a6224be58e8083985f5014d844359576296" 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" object-assign "^4.0.1"
thenify-all "^1.0.0" thenify-all "^1.0.0"
nan@^2.14.0: nan@^2.14.0, nan@^2.14.1:
version "2.14.1" version "2.14.1"
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01" resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw== 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" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-0.9.1.tgz#775368e6846558ab6676858a4d8c6e8d16c677b5"
integrity sha512-G6RlQt5Sb4GMBzXvhfkeFmbqR6MzhtnT7VTHuLadjkii3rdYHNdw0m8zA4BTxVIh68FicCQ2NSUANpsqkr9jvQ== 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: node-object-hash@^1.2.0:
version "1.4.2" version "1.4.2"
resolved "https://registry.yarnpkg.com/node-object-hash/-/node-object-hash-1.4.2.tgz#385833d85b229902b75826224f6077be969a9e94" 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" node-forge "^0.9.1"
semver "^6.3.0" 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: read-pkg-up@^1.0.1:
version "1.0.1" version "1.0.1"
resolved "https://registry.yarnpkg.com/read-pkg-up/-/read-pkg-up-1.0.1.tgz#9d63c13276c065918d57f002a57f40a1b643fb02" 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: dependencies:
glob "^7.1.3" glob "^7.1.3"
rimraf@^2.6.2: rimraf@^2.6.2, rimraf@^2.6.3:
version "2.7.1" version "2.7.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec" resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w== integrity sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==
@ -9088,7 +9128,7 @@ tar-stream@^2.0.0:
inherits "^2.0.3" inherits "^2.0.3"
readable-stream "^3.1.1" readable-stream "^3.1.1"
tar@^6.0.2: tar@^6.0.1, tar@^6.0.2:
version "6.0.2" version "6.0.2"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.2.tgz#5df17813468a6264ff14f766886c622b84ae2f39" resolved "https://registry.yarnpkg.com/tar/-/tar-6.0.2.tgz#5df17813468a6264ff14f766886c622b84ae2f39"
integrity sha512-Glo3jkRtPcvpDlAs/0+hozav78yoXKFr+c4wgw62NNMO3oo4AaJdCo21Uu7lcwr55h39W2XD1LMERc64wtbItg== 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" resolved "https://registry.yarnpkg.com/which-pm-runs/-/which-pm-runs-1.0.0.tgz#670b3afbc552e0b55df6b7780ca74615f23ad1cb"
integrity sha1-Zws6+8VS4LVd9rd4DKdGFfI60cs= 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" version "2.0.2"
resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1"
integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA== integrity sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==