Skip to content

Commit

Permalink
Merge pull request #85 from anatawa12/hard-mute
Browse files Browse the repository at this point in the history
Hard mute
  • Loading branch information
anatawa12 authored Nov 26, 2023
2 parents 45d7d7b + 4acb884 commit 870c4e8
Show file tree
Hide file tree
Showing 18 changed files with 172 additions and 34 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
## 2023.11.1-kinel.1

### General
- Feat: TL上からノートが見えなくなるワードミュートであるハードミュートを追加

### Client

Expand Down
1 change: 1 addition & 0 deletions locales/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -639,6 +639,7 @@ export interface Locale {
"smtpSecureInfo": string;
"testEmail": string;
"wordMute": string;
"hardWordMute": string;
"regexpError": string;
"regexpErrorDescription": string;
"instanceMute": string;
Expand Down
1 change: 1 addition & 0 deletions locales/ja-JP.yml
Original file line number Diff line number Diff line change
Expand Up @@ -636,6 +636,7 @@ smtpSecure: "SMTP 接続に暗黙的なSSL/TLSを使用する"
smtpSecureInfo: "STARTTLS使用時はオフにします。"
testEmail: "配信テスト"
wordMute: "ワードミュート"
hardWordMute: "ハードワードミュート"
regexpError: "正規表現エラー"
regexpErrorDescription: "{tab}ワードミュートの{line}行目の正規表現にエラーが発生しました:"
instanceMute: "サーバーミュート"
Expand Down
11 changes: 11 additions & 0 deletions packages/backend/migration/1700383825690-hard-mute.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export class HardMute1700383825690 {
name = 'HardMute1700383825690'

async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" ADD "hardMutedWords" jsonb NOT NULL DEFAULT '[]'`);
}

async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "hardMutedWords"`);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
export class HardMuteAndSoftMuteFromRegistory1700906353915 {
async up(queryRunner) {
// until 2023.9.3-kinel.4, `mutedWords` means hard muted words
// since 2023.11.1-kinel.1, `mutedWords` means soft muted words and `hardMutedWords` means hard muted words
// so migrate hard muted words to `hardMutedWords`
await queryRunner.query(`UPDATE "user_profile" SET "hardMutedWords" = "mutedWords";`);

// then, migrate soft muted words from registry
let entries = await queryRunner.query(
`SELECT "userId", "value" FROM "registry_item"
WHERE "registry_item"."domain" IS NULL
AND "registry_item"."key" = $1
AND "registry_item"."scope" = $2;`,
['mutedWords', ['client', 'base']]);

for (let entry of entries) {
await queryRunner.query(`UPDATE "user_profile" SET "mutedWords" = $1 WHERE "user_profile"."userId" = $2;`,
[JSON.stringify(entry.value), entry.userId]);
}
}

async down(queryRunner) {
const entries = await queryRunner.query(`SELECT "userId", "mutedWords" FROM "user_profile";`);
for (let entry of entries) {
let existingEntry = await queryRunner.query(
`SELECT "id", "userId", "value" FROM "registry_item"
WHERE "registry_item"."domain" IS NULL
AND "registry_item"."key" = $1
AND "registry_item"."scope" = $2
AND "registry_item"."userId" = $3;`,
['mutedWords', ['client', 'base'], entry.userId]);

if (existingEntry.length > 0) {
await queryRunner.connection.createQueryBuilder()
.update('registry_item')
.set({ value: entry.mutedWords })
.where('id = :id', { id: existingEntry[0].id })
.execute();
} else {
await queryRunner.connection.createQueryBuilder()
.insert()
.into('registry_item')
.values({
key: 'mutedWords',
scope: ['client', 'base'],
userId: entry.userId,
domain: null,
value: entry.mutedWords
})
.execute();
}
}

await queryRunner.query(`UPDATE "user_profile" SET "mutedWords" = "hardMutedWords";`);
}
}
1 change: 1 addition & 0 deletions packages/backend/src/core/entities/UserEntityService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,7 @@ export class UserEntityService implements OnModuleInit {
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
unreadNotificationsCount: notificationsInfo?.unreadCount,
mutedWords: profile!.mutedWords,
hardMutedWords: profile!.hardMutedWords,
mutedInstances: profile!.mutedInstances,
mutingNotificationTypes: [], // 後方互換性のため
notificationRecieveConfig: profile!.notificationRecieveConfig,
Expand Down
7 changes: 6 additions & 1 deletion packages/backend/src/models/UserProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,12 @@ export class MiUserProfile {
@Column('jsonb', {
default: [],
})
public mutedWords: string[][];
public mutedWords: (string[] | string)[];

@Column('jsonb', {
default: [],
})
public hardMutedWords: (string[] | string)[];

@Column('jsonb', {
default: [],
Expand Down
12 changes: 12 additions & 0 deletions packages/backend/src/models/json-schema/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,18 @@ export const packedMeDetailedOnlySchema = {
},
},
},
hardMutedWords: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'array',
nullable: false, optional: false,
items: {
type: 'string',
nullable: false, optional: false,
},
},
},
mutedInstances: {
type: 'array',
nullable: true, optional: false,
Expand Down
36 changes: 28 additions & 8 deletions packages/backend/src/server/api/endpoints/i/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,11 @@ export const meta = {
},
} as const;

const muteWords = { type: 'array', items: { oneOf: [
{ type: 'array', items: { type: 'string' } },
{ type: 'string' }
] } } as const;

export const paramDef = {
type: 'object',
properties: {
Expand Down Expand Up @@ -171,7 +176,8 @@ export const paramDef = {
autoSensitive: { type: 'boolean' },
ffVisibility: { type: 'string', enum: ['public', 'followers', 'private'] },
pinnedPageId: { type: 'string', format: 'misskey:id', nullable: true },
mutedWords: { type: 'array' },
mutedWords: muteWords,
hardMutedWords: muteWords,
mutedInstances: { type: 'array', items: {
type: 'string',
} },
Expand Down Expand Up @@ -234,28 +240,42 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
if (ps.location !== undefined) profileUpdates.location = ps.location;
if (ps.birthday !== undefined) profileUpdates.birthday = ps.birthday;
if (ps.ffVisibility !== undefined) profileUpdates.ffVisibility = ps.ffVisibility;
if (ps.mutedWords !== undefined) {

function checkMuteWordCount(mutedWords: (string[] | string)[], limit: number) {
// TODO: ちゃんと数える
const length = JSON.stringify(ps.mutedWords).length;
if (length > (await this.roleService.getUserPolicies(user.id)).wordMuteLimit) {
const length = JSON.stringify(mutedWords).length;
if (length > limit) {
throw new ApiError(meta.errors.tooManyMutedWords);
}
}

function validateMuteWordRegex(mutedWords: (string[] | string)[]) {
for (const mutedWord of mutedWords) {
if (typeof mutedWord !== "string") continue;

// validate regular expression syntax
ps.mutedWords.filter(x => !Array.isArray(x)).forEach(x => {
const regexp = x.match(/^\/(.+)\/(.*)$/);
const regexp = mutedWord.match(/^\/(.+)\/(.*)$/);
if (!regexp) throw new ApiError(meta.errors.invalidRegexp);

try {
new RE2(regexp[1], regexp[2]);
} catch (err) {
throw new ApiError(meta.errors.invalidRegexp);
}
});
}
}

if (ps.mutedWords !== undefined) {
checkMuteWordCount(ps.mutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit);
validateMuteWordRegex(ps.mutedWords);

profileUpdates.mutedWords = ps.mutedWords;
profileUpdates.enableWordMute = ps.mutedWords.length > 0;
}
if (ps.hardMutedWords !== undefined) {
checkMuteWordCount(ps.hardMutedWords, (await this.roleService.getUserPolicies(user.id)).wordMuteLimit);
validateMuteWordRegex(ps.hardMutedWords);
profileUpdates.hardMutedWords = ps.hardMutedWords;
}
if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances;
if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig;
if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked;
Expand Down
1 change: 1 addition & 0 deletions packages/backend/test/e2e/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ describe('ユーザー', () => {
hasPendingReceivedFollowRequest: user.hasPendingReceivedFollowRequest,
unreadAnnouncements: user.unreadAnnouncements,
mutedWords: user.mutedWords,
hardMutedWords: user.hardMutedWords,
mutedInstances: user.mutedInstances,
mutingNotificationTypes: user.mutingNotificationTypes,
notificationRecieveConfig: user.notificationRecieveConfig,
Expand Down
17 changes: 14 additions & 3 deletions packages/frontend/src/components/MkNote.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ SPDX-License-Identifier: AGPL-3.0-only

<template>
<div
v-if="!muted"
v-if="!hardMuted && !muted"
v-show="!isDeleted"
ref="el"
v-hotkey="keymap"
Expand Down Expand Up @@ -133,7 +133,7 @@ SPDX-License-Identifier: AGPL-3.0-only
</div>
</article>
</div>
<div v-else :class="$style.muted" @click="muted = false">
<div v-else-if="!hardMuted" :class="$style.muted" @click="muted = false">
<I18n :src="i18n.ts.userSaysSomething" tag="small">
<template #name>
<MkA v-user-preview="appearNote.userId" :to="userPage(appearNote.user)">
Expand Down Expand Up @@ -183,6 +183,7 @@ const props = withDefaults(defineProps<{
note: Misskey.entities.Note;
pinned?: boolean;
mock?: boolean;
withHardMute?: boolean;
}>(), {
mock: false,
});
Expand Down Expand Up @@ -242,13 +243,23 @@ const urls = $computed(() => parsed ? extractUrlFromMfm(parsed) : null);
const isLong = shouldCollapsed(appearNote, urls ?? []) && !cwExists;
const collapsed = ref(!cwExists && isLong);
const isDeleted = ref(false);
const muted = ref($i ? checkWordMute(appearNote, $i, $i.mutedWords) : false);
const muted = ref(checkMute(appearNote, $i?.mutedWords));
const hardMuted = ref(props.withHardMute && checkMute(appearNote, $i?.hardMutedWords));
const translation = ref<any>(null);
const translating = ref(false);
const showTicker = (defaultStore.state.instanceTicker === 'always') || (defaultStore.state.instanceTicker === 'remote' && appearNote.user.instance);
const canRenote = computed(() => ['public', 'home'].includes(appearNote.visibility) || (appearNote.visibility === 'followers' && appearNote.userId === $i.id));
let renoteCollapsed = $ref(defaultStore.state.collapseRenotes && isRenote && (($i && ($i.id === note.userId || $i.id === appearNote.userId)) || (appearNote.myReaction != null)));
function checkMute(note: Misskey.entities.Note, mutedWords: Array<string | string[]> | undefined | null): boolean {
if (mutedWords == null) return false;
if (checkWordMute(note, $i, mutedWords)) return true;
if (note.reply && checkWordMute(note.reply, $i, mutedWords)) return true;
if (note.renote && checkWordMute(note.renote, $i, mutedWords)) return true;
return false;
}
const keymap = {
'r': () => reply(true),
'e|a|plus': () => react(true),
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/components/MkNotes.vue
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ SPDX-License-Identifier: AGPL-3.0-only
:ad="true"
:class="$style.notes"
>
<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note"/>
<MkNote :key="note._featuredId_ || note._prId_ || note.id" :class="$style.note" :note="note" :withHardMute="true"/>
</MkDateSeparatedList>
</div>
</template>
Expand Down
2 changes: 1 addition & 1 deletion packages/frontend/src/components/MkNotifications.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ SPDX-License-Identifier: AGPL-3.0-only

<template #default="{ items: notifications }">
<MkDateSeparatedList v-slot="{ item: notification }" :class="$style.list" :items="notifications" :noGap="true">
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note"/>
<MkNote v-if="['reply', 'quote', 'mention'].includes(notification.type)" :key="notification.id" :note="notification.note" :withHardMute="true"/>
<XNotification v-else :key="notification.id" :notification="notification" :withTime="true" :full="true" class="_panel"/>
</MkDateSeparatedList>
</template>
Expand Down
18 changes: 17 additions & 1 deletion packages/frontend/src/pages/settings/mute-block.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,14 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #icon><i class="ti ti-message-off"></i></template>
<template #label>{{ i18n.ts.wordMute }}</template>

<XWordMute/>
<XWordMute :muted="$i!.mutedWords" @save="saveMutedWords"/>
</MkFolder>

<MkFolder>
<template #icon><i class="ti ti-message-off"></i></template>
<template #label>{{ i18n.ts.hardWordMute }}</template>

<XWordMute :muted="$i!.hardMutedWords" @save="saveHardMutedWords"/>
</MkFolder>

<MkFolder>
Expand Down Expand Up @@ -129,6 +136,7 @@ import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkUserCardMini from '@/components/MkUserCardMini.vue';
import * as os from '@/os.js';
import { infoImageUrl } from '@/instance.js';
import { $i } from '@/account.js';
import MkFolder from '@/components/MkFolder.vue';
const renoteMutingPagination = {
Expand Down Expand Up @@ -207,6 +215,14 @@ async function toggleBlockItem(item) {
}
}
async function saveMutedWords(mutedWords: (string | string[])[]) {
await os.api('i/update', { mutedWords });
}
async function saveHardMutedWords(hardMutedWords: (string | string[])[]) {
await os.api('i/update', { hardMutedWords });
}
const headerActions = $computed(() => []);
const headerTabs = $computed(() => []);
Expand Down
22 changes: 10 additions & 12 deletions packages/frontend/src/pages/settings/mute-block.word-mute.vue
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,17 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup>
import { ref, watch } from 'vue';
import MkTextarea from '@/components/MkTextarea.vue';
import MkKeyValue from '@/components/MkKeyValue.vue';
import MkButton from '@/components/MkButton.vue';
import MkInfo from '@/components/MkInfo.vue';
import MkTab from '@/components/MkTab.vue';
import * as os from '@/os.js';
import number from '@/filters/number.js';
import { defaultStore } from '@/store.js';
import { $i } from '@/account.js';
import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js';
const props = defineProps<{
muted: (string[] | string)[];
}>();
const emit = defineEmits<{
(ev: 'save', value: (string[] | string)[]): void;
}>();
const render = (mutedWords) => mutedWords.map(x => {
if (Array.isArray(x)) {
Expand All @@ -37,8 +38,7 @@ const render = (mutedWords) => mutedWords.map(x => {
}
}).join('\n');
const tab = ref('soft');
const mutedWords = ref(render($i!.mutedWords));
const mutedWords = ref(render(props.muted));
const changed = ref(false);
watch(mutedWords, () => {
Expand Down Expand Up @@ -85,9 +85,7 @@ async function save() {
return;
}
await os.api('i/update', {
mutedWords: parsed,
});
emit('save', parsed);
changed.value = false;
}
Expand Down
Loading

0 comments on commit 870c4e8

Please sign in to comment.