feat: user-level instance mute (#7712)

* Update ja-JP.yml

* Added settable config for muted instances

* added psql query for removal of muted notes

* Added filtering and trimming for instance mutes

* cleaned up filtering of bad instance mutes and added a refresh at the end for the list on the client

* Added notification & streaming timeline muting

* Updated changelog

* Added missing semicolon

* Apply japanese string suggestions from robflop

Co-authored-by: Robin B. <robflop98@outlook.com>

* Changed Ja-JP instance mute title string to one suggested by sousuke

Co-authored-by: sousuke0422 <sousuke20xx@gmail.com>

* Update ja-JP instanceMuteDescription based on sousuke's suggestion

Co-authored-by: sousuke0422 <sousuke20xx@gmail.com>

* added notification mute

* added notification and note children muting

* Fixed a bug where local notifications were getting filtered on cold start

* Fixed instance mute imports

* Fixed not saving/loading instance mutes

* removed en-US translations for instance mute

* moved instance mute migration to js

* changed settings index back to spaces

* removed destructuring assignment from notification stream in instance mute check call

Co-authored-by: tamaina <tamaina@hotmail.co.jp>

* added .note accessor for checking note data instead of notification data

* changed note to use Packed<'Note'> instead of any and removed usage of snake case

Co-authored-by: tamaina <tamaina@hotmail.co.jp>

* changed notification mute check to check specifically for notification host

* changed to using single quotes

* moved @click to the end for the linter

* revert unnecessary changes

* restored newlines

* whitespace removal

Co-authored-by: syuilo <syuilotan@yahoo.co.jp>
Co-authored-by: Robin B. <robflop98@outlook.com>
Co-authored-by: sousuke0422 <sousuke20xx@gmail.com>
Co-authored-by: puffaboo <emilis@jigglypuff.club>
Co-authored-by: tamaina <tamaina@hotmail.co.jp>
This commit is contained in:
romaboo 2021-12-09 12:38:56 +00:00 committed by GitHub
parent b9095995eb
commit 054417354c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 214 additions and 0 deletions

View file

@ -234,6 +234,9 @@
## 12.89.1 (2021/08/24)
### Features
- Added a user-level instance mute in user settings
### Improvements
- クライアントのデザインの調整

View file

@ -592,6 +592,7 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する"
smtpSecureInfo: "STARTTLS使用時はオフにします。"
testEmail: "配信テスト"
wordMute: "ワードミュート"
instanceMute: "インスタンスミュート"
userSaysSomething: "{name}が何かを言いました"
makeActive: "アクティブにする"
display: "表示"
@ -1021,6 +1022,12 @@ _wordMute:
hard: "ハード"
mutedNotes: "ミュートされたノート"
_instanceMute:
instanceMuteDescription: "ミュートしたインスタンスのユーザーからの返信を含めて、設定したインスタンスの全てのートとRenoteをミュートします。"
instanceMuteDescription2: "改行で区切って設定します"
title: "設定したインスタンスのノートを隠します。"
heading: "ミュートするインスタンス"
_theme:
explore: "テーマを探す"
install: "テーマのインストール"

View file

@ -0,0 +1,15 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
class userInstanceBlocks1629968054000 {
constructor() {
this.name = 'userInstanceBlocks1629968054000';
}
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "mutedInstances" jsonb NOT NULL DEFAULT '[]'`);
await queryRunner.query(`COMMENT ON COLUMN "user_profile"."mutedInstances" IS 'List of instances muted by the user.'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "mutedInstances"`);
}
}
exports.userInstanceBlocks1629968054000 = userInstanceBlocks1629968054000;

View file

@ -0,0 +1,15 @@
import { Packed } from "./schema";
export function isInstanceMuted(note: Packed<'Note'>, mutedInstances: Set<string>): boolean {
if (mutedInstances.has(note?.user?.host ?? '')) return true;
if (mutedInstances.has(note?.reply?.user?.host ?? '')) return true;
if (mutedInstances.has(note?.renote?.user?.host ?? '')) return true;
return false;
}
export function isUserFromMutedInstance(notif: Packed<'Notification'>, mutedInstances: Set<string>): boolean {
if (mutedInstances.has(notif?.user?.host ?? '')) return true;
return false;
}

View file

@ -189,6 +189,11 @@ export class UserProfile {
})
public mutedWords: string[][];
@Column('jsonb', {
default: []
})
public mutedInstances: string[];
@Column('enum', {
enum: notificationTypes,
array: true,

View file

@ -288,6 +288,7 @@ export class UserRepository extends Repository<User> {
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
integrations: profile!.integrations,
mutedWords: profile!.mutedWords,
mutedInstances: profile!.mutedInstances,
mutingNotificationTypes: profile!.mutingNotificationTypes,
emailNotificationTypes: profile!.emailNotificationTypes,
} : {}),
@ -623,6 +624,10 @@ export const packedUserSchema = {
type: 'array' as const,
nullable: false as const, optional: true as const
},
mutedInstances: {
type: 'array' as const,
nullable: false as const, optional: true as const
},
mutingNotificationTypes: {
type: 'array' as const,
nullable: false as const, optional: true as const

View file

@ -0,0 +1,40 @@
import { User } from '@/models/entities/user';
import { id } from '@/models/id';
import { UserProfiles } from '@/models/index';
import { SelectQueryBuilder, Brackets } from 'typeorm';
function createMutesQuery(id: string) {
return UserProfiles.createQueryBuilder('user_profile')
.select('user_profile.mutedInstances')
.where('user_profile.userId = :muterId', { muterId: id });
}
export function generateMutedInstanceQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) {
const mutingQuery = createMutesQuery(me.id);
q
.andWhere(new Brackets(qb => { qb
.andWhere('note.userHost IS NULL')
.orWhere(`NOT((${ mutingQuery.getQuery() })::jsonb ? note.userHost)`);
}))
.andWhere(new Brackets(qb => { qb
.where(`note.replyUserHost IS NULL`)
.orWhere(`NOT ((${ mutingQuery.getQuery() })::jsonb ? note.replyUserHost)`);
}))
.andWhere(new Brackets(qb => { qb
.where(`note.renoteUserHost IS NULL`)
.orWhere(`NOT ((${ mutingQuery.getQuery() })::jsonb ? note.renoteUserHost)`);
}));
q.setParameters(mutingQuery.getParameters());
}
export function generateMutedInstanceNotificationQuery(q: SelectQueryBuilder<any>, me: { id: User['id'] }) {
const mutingQuery = createMutesQuery(me.id);
q.andWhere(new Brackets(qb => { qb
.andWhere('notifier.host IS NULL')
.orWhere(`NOT (( ${mutingQuery.getQuery()} )::jsonb ? notifier.host)`);
}));
q.setParameters(mutingQuery.getParameters());
}

View file

@ -3,6 +3,7 @@ import { ID } from '@/misc/cafy-id';
import { readNotification } from '../../common/read-notification';
import define from '../../define';
import { makePaginationQuery } from '../../common/make-pagination-query';
import { generateMutedInstanceNotificationQuery } from '../../common/generate-muted-instance-query';
import { Notifications, Followings, Mutings, Users } from '@/models/index';
import { notificationTypes } from '@/types';
import read from '@/services/note/read';
@ -101,6 +102,8 @@ export default define(meta, async (ps, user) => {
}));
query.setParameters(mutingQuery.getParameters());
generateMutedInstanceNotificationQuery(query, user);
query.andWhere(new Brackets(qb => { qb
.where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`)
.orWhere('notification.notifierId IS NULL');

View file

@ -116,6 +116,10 @@ export const meta = {
validator: $.optional.arr($.arr($.str))
},
mutedInstances: {
validator: $.optional.arr($.str)
},
mutingNotificationTypes: {
validator: $.optional.arr($.str.or(notificationTypes as unknown as string[]))
},
@ -185,6 +189,7 @@ export default define(meta, async (ps, _user, token) => {
profileUpdates.mutedWords = ps.mutedWords;
profileUpdates.enableWordMute = ps.mutedWords.length > 0;
}
if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances;
if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][];
if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked;
if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable;

View file

@ -7,6 +7,7 @@ import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
import { Brackets } from 'typeorm';
import { Notes } from '@/models/index';
import { generateBlockedUserQuery } from '../../common/generate-block-query';
import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query';
export const meta = {
tags: ['notes'],
@ -65,6 +66,7 @@ export default define(meta, async (ps, user) => {
generateVisibilityQuery(query, user);
if (user) generateMutedUserQuery(query, user);
if (user) generateBlockedUserQuery(query, user);
if (user) generateMutedInstanceQuery(query, user);
const notes = await query.take(ps.limit!).getMany();

View file

@ -6,6 +6,7 @@ import { ApiError } from '../../error';
import { makePaginationQuery } from '../../common/make-pagination-query';
import { Notes } from '@/models/index';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query';
import { activeUsersChart } from '@/services/chart/index';
import { generateRepliesQuery } from '../../common/generate-replies-query';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
@ -83,6 +84,7 @@ export default define(meta, async (ps, user) => {
if (user) generateMutedUserQuery(query, user);
if (user) generateMutedNoteQuery(query, user);
if (user) generateBlockedUserQuery(query, user);
if (user) generateMutedInstanceQuery(query, user);
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');

View file

@ -8,6 +8,7 @@ import { Followings, Notes } from '@/models/index';
import { Brackets } from 'typeorm';
import { generateVisibilityQuery } from '../../common/generate-visibility-query';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query';
import { activeUsersChart } from '@/services/chart/index';
import { generateRepliesQuery } from '../../common/generate-replies-query';
import { generateMutedNoteQuery } from '../../common/generate-muted-note-query';
@ -108,6 +109,7 @@ export default define(meta, async (ps, user) => {
generateRepliesQuery(query, user);
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateMutedInstanceQuery(query, user);
generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user);

View file

@ -5,6 +5,7 @@ import { makePaginationQuery } from '../../common/make-pagination-query';
import { Notes, Followings } from '@/models/index';
import { generateVisibilityQuery } from '../../common/generate-visibility-query';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query';
import { activeUsersChart } from '@/services/chart/index';
import { Brackets } from 'typeorm';
import { generateRepliesQuery } from '../../common/generate-replies-query';
@ -100,6 +101,7 @@ export default define(meta, async (ps, user) => {
generateRepliesQuery(query, user);
generateVisibilityQuery(query, user);
generateMutedUserQuery(query, user);
generateMutedInstanceQuery(query, user);
generateMutedNoteQuery(query, user);
generateBlockedUserQuery(query, user);

View file

@ -9,6 +9,7 @@ import { Notes } from '@/models/index';
import { generateMutedUserQuery } from '../../common/generate-muted-user-query';
import { Brackets } from 'typeorm';
import { generateBlockedUserQuery } from '../../common/generate-block-query';
import { generateMutedInstanceQuery } from '../../common/generate-muted-instance-query';
export const meta = {
tags: ['users', 'notes'],
@ -102,6 +103,7 @@ export default define(meta, async (ps, me) => {
generateVisibilityQuery(query, me);
if (me) generateMutedUserQuery(query, me, user);
if (me) generateBlockedUserQuery(query, me);
if (me) generateMutedInstanceQuery(query, me);
if (ps.withFiles) {
query.andWhere('note.fileIds != \'{}\'');

View file

@ -5,6 +5,7 @@ import { fetchMeta } from '@/misc/fetch-meta';
import { Notes } from '@/models/index';
import { checkWordMute } from '@/misc/check-word-mute';
import { isBlockerUserRelated } from '@/misc/is-blocker-user-related';
import { isInstanceMuted } from '@/misc/is-instance-muted';
import { Packed } from '@/misc/schema';
export default class extends Channel {
@ -48,6 +49,9 @@ export default class extends Channel {
if (reply.userId !== this.user!.id && note.userId !== this.user!.id && reply.userId !== note.userId) return;
}
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;
// 流れてきたNoteがミュートしているユーザーが関わるものだったら無視する
if (isMutedUserRelated(note, this.muting)) return;
// 流れてきたNoteがブロックされているユーザーが関わるものだったら無視する

View file

@ -4,6 +4,7 @@ import Channel from '../channel';
import { Notes } from '@/models/index';
import { checkWordMute } from '@/misc/check-word-mute';
import { isBlockerUserRelated } from '@/misc/is-blocker-user-related';
import { isInstanceMuted } from '@/misc/is-instance-muted';
import { Packed } from '@/misc/schema';
export default class extends Channel {
@ -26,6 +27,9 @@ export default class extends Channel {
if ((this.user!.id !== note.userId) && !this.following.has(note.userId)) return;
}
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;
if (['followers', 'specified'].includes(note.visibility)) {
note = await Notes.pack(note.id, this.user!, {
detail: true

View file

@ -5,6 +5,7 @@ import { fetchMeta } from '@/misc/fetch-meta';
import { Notes } from '@/models/index';
import { checkWordMute } from '@/misc/check-word-mute';
import { isBlockerUserRelated } from '@/misc/is-blocker-user-related';
import { isInstanceMuted } from '@/misc/is-instance-muted';
import { Packed } from '@/misc/schema';
export default class extends Channel {
@ -57,6 +58,9 @@ export default class extends Channel {
}
}
// Ignore notes from instances the user has muted
if (isInstanceMuted(note, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;
// 関係ない返信は除外
if (note.reply) {
const reply = note.reply;

View file

@ -1,6 +1,7 @@
import autobind from 'autobind-decorator';
import Channel from '../channel';
import { Notes } from '@/models/index';
import { isInstanceMuted, isUserFromMutedInstance } from '@/misc/is-instance-muted';
export default class extends Channel {
public readonly chName = 'main';
@ -13,6 +14,8 @@ export default class extends Channel {
this.subscriber.on(`mainStream:${this.user!.id}`, async data => {
switch (data.type) {
case 'notification': {
// Ignore notifications from instances the user has muted
if (isUserFromMutedInstance(data.body, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;
if (data.body.userId && this.muting.has(data.body.userId)) return;
if (data.body.note && data.body.note.isHidden) {
@ -25,6 +28,8 @@ export default class extends Channel {
break;
}
case 'mention': {
if (isInstanceMuted(data.body, new Set<string>(this.userProfile?.mutedInstances ?? []))) return;
if (this.muting.has(data.body.userId)) return;
if (data.body.isHidden) {
const note = await Notes.pack(data.body.id, this.user, {

View file

@ -136,6 +136,11 @@ export default defineComponent({
text: i18n.locale.importAndExport,
to: '/settings/import-export',
active: page.value === 'import-export',
}, {
icon: 'fas fa-volume-mute',
text: i18n.locale.instanceMute,
to: '/settings/instance-mute',
active: page.value === 'instance-mute',
}, {
icon: 'fas fa-ban',
text: i18n.locale.muteAndBlock,
@ -190,6 +195,7 @@ export default defineComponent({
case 'notifications': return defineAsyncComponent(() => import('./notifications.vue'));
case 'mute-block': return defineAsyncComponent(() => import('./mute-block.vue'));
case 'word-mute': return defineAsyncComponent(() => import('./word-mute.vue'));
case 'instance-mute': return defineAsyncComponent(() => import('./instance-mute.vue'));
case 'integration': return defineAsyncComponent(() => import('./integration.vue'));
case 'security': return defineAsyncComponent(() => import('./security.vue'));
case '2fa': return defineAsyncComponent(() => import('./2fa.vue'));

View file

@ -0,0 +1,83 @@
<template>
<div>
<FormBase>
<div class="_formItem">
<FormInfo>{{ $ts._instanceMute.title}}</FormInfo>
<FormTextarea v-model="instanceMutes">
<span>{{$ts._instanceMute.heading}}</span>
<template #desc>{{ $ts._instanceMute.instanceMuteDescription}}<br>{{$ts._instanceMute.instanceMuteDescription2}}</template>
</FormTextarea>
</div>
<FormButton primary inline :disabled="!changed" @click="save()"><i class="fas fa-save"></i> {{ $ts.save }}</FormButton>
</FormBase>
</div>
</template>
<script>
import { defineComponent } from 'vue'
import FormBase from '@/components/debobigego/base.vue';
import FormTextarea from '@/components/debobigego/textarea.vue';
import FormInfo from '@/components/debobigego/info.vue';
import FormKeyValueView from '@/components/debobigego/key-value-view.vue';
import FormButton from '@/components/debobigego/button.vue';
import * as os from '@/os';
import number from '@/filters/number';
import * as symbols from '@/symbols';
export default defineComponent({
components: {
FormBase,
FormButton,
FormTextarea,
FormKeyValueView,
FormInfo,
},
emits: ['info'],
data() {
return {
[symbols.PAGE_INFO]: {
title: this.$ts.instanceMute,
icon: 'fas fa-volume-mute'
},
tab: 'soft',
instanceMutes: '',
changed: false,
}
},
watch: {
instanceMutes: {
handler() {
this.changed = true;
},
deep: true
},
},
mounted() {
this.$emit('info', this[symbols.PAGE_INFO]);
},
async created() {
this.instanceMutes = this.$i.mutedInstances.join('\n');
},
methods: {
async save() {
let mutes = this.instanceMutes.trim().split('\n').map(el => el.trim()).filter(el => el);
await os.api('i/update', {
mutedInstances: mutes,
});
this.changed = false;
// Refresh filtered list to signal to the user how they've been saved
this.instanceMutes = mutes.join('\n');
},
number //?
}
})
</script>