feat: notification muting

This commit is contained in:
オスカー、 2024-11-06 20:32:41 +09:00
parent ab84c9afa0
commit 03c64c296b
Signed by: SWREI
GPG Key ID: 139D6573F92DA9F7
17 changed files with 253 additions and 10 deletions

View File

@ -1331,6 +1331,9 @@ normalize: "Normalize"
normalizeConfirm: "After normalization, the account will be irreversible. Are you sure you want to do this?"
normalizeDescription: "Normalization is a feature for bulk data wiping and account suspension of users, which has a similar effect to deleting an account and closes all reports after it is run. Please note that this is an irreversible action."
useNormalization: "Show the Normalize menu"
alsoMuteNotification: "Also mute notifications from this user"
muteNotification: "Mute notifications"
unmuteNotification: "Unmute notifications"
_bubbleGame:
howToPlay: "How to play"
hold: "Hold"

12
locales/index.d.ts vendored
View File

@ -5496,6 +5496,18 @@ export interface Locale extends ILocale {
*
*/
"consentSelected": string;
/**
*
*/
"alsoMuteNotification": string;
/**
*
*/
"muteNotification": string;
/**
*
*/
"unmuteNotification": string;
"_bubbleGame": {
/**
*

View File

@ -1367,6 +1367,9 @@ pleaseConsentToTracking: "{host}は[プライバシーポリシー]({privacyPoli
consentEssential: "必須項目のみ許可"
consentAll: "全て許可"
consentSelected: "選択した項目のみ許可"
alsoMuteNotification: "このユーザーが送信する通知も一緒にミュートする"
muteNotification: "通知をミュートする"
unmuteNotification: "通知をミュート解除する"
_bubbleGame:
howToPlay: "遊び方"

View File

@ -1356,6 +1356,9 @@ pleaseConsentToTracking: "{host}는 [개인정보 처리방침]({privacyPolicyUr
consentEssential: "필수 항목만 허용"
consentAll: "모두 허용"
consentSelected: "선택한 항목만 허용"
alsoMuteNotification: "이 유저가 보내는 알림도 같이 뮤트하기"
muteNotification: "알림을 뮤트하기"
unmuteNotification: "알림 뮤트를 해제하기"
_bubbleGame:
howToPlay: "설명"

View File

@ -21,6 +21,8 @@ export class CacheService implements OnApplicationShutdown {
public localUserByIdCache: MemoryKVCache<MiLocalUser>;
public uriPersonCache: MemoryKVCache<MiUser | null>;
public userProfileCache: RedisKVCache<MiUserProfile>;
public userMutingsWithNotificationCache: RedisKVCache<Set<string>>;
public userMutingsWithoutNotificationCache: RedisKVCache<Set<string>>;
public userMutingsCache: RedisKVCache<Set<string>>;
public userBlockingCache: RedisKVCache<Set<string>>;
public userBlockedCache: RedisKVCache<Set<string>>; // NOTE: 「被」Blockキャッシュ
@ -77,6 +79,22 @@ export class CacheService implements OnApplicationShutdown {
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});
this.userMutingsWithoutNotificationCache = new RedisKVCache<Set<string>>(this.redisClient, 'userMutings', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key, withNotification: false }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});
this.userMutingsWithNotificationCache = new RedisKVCache<Set<string>>(this.redisClient, 'userMutings', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (key) => this.mutingsRepository.find({ where: { muterId: key, withNotification: true }, select: ['muteeId'] }).then(xs => new Set(xs.map(x => x.muteeId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});
this.userBlockingCache = new RedisKVCache<Set<string>>(this.redisClient, 'userBlocking', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
@ -188,6 +206,8 @@ export class CacheService implements OnApplicationShutdown {
this.uriPersonCache.dispose();
this.userProfileCache.dispose();
this.userMutingsCache.dispose();
this.userMutingsWithoutNotificationCache.dispose();
this.userMutingsWithNotificationCache.dispose();
this.userBlockingCache.dispose();
this.userBlockedCache.dispose();
this.renoteMutingsCache.dispose();

View File

@ -106,7 +106,7 @@ export class NotificationService implements OnApplicationShutdown {
return null;
}
const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId);
const mutings = await this.cacheService.userMutingsWithNotificationCache.fetch(notifieeId);
if (mutings.has(notifierId)) {
return null;
}

View File

@ -24,15 +24,18 @@ export class UserMutingService {
}
@bindThis
public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null): Promise<void> {
public async mute(user: MiUser, target: MiUser, expiresAt: Date | null = null, withNotification = true): Promise<void> {
await this.mutingsRepository.insert({
id: this.idService.gen(),
expiresAt: expiresAt ?? null,
muterId: user.id,
muteeId: target.id,
withNotification: withNotification,
});
this.cacheService.userMutingsCache.refresh(user.id);
this.cacheService.userMutingsWithNotificationCache.refresh(user.id);
this.cacheService.userMutingsWithoutNotificationCache.refresh(user.id);
}
@bindThis
@ -46,6 +49,17 @@ export class UserMutingService {
const muterIds = [...new Set(mutings.map(m => m.muterId))];
for (const muterId of muterIds) {
this.cacheService.userMutingsCache.refresh(muterId);
this.cacheService.userMutingsWithNotificationCache.refresh(muterId);
this.cacheService.userMutingsWithoutNotificationCache.refresh(muterId);
}
}
@bindThis
public async editMute(muting: MiMuting, withNotification: boolean): Promise<void> {
await this.mutingsRepository.update(muting.id, { withNotification: withNotification });
this.cacheService.userMutingsCache.refresh(muting.muterId);
this.cacheService.userMutingsWithNotificationCache.refresh(muting.muterId);
this.cacheService.userMutingsWithoutNotificationCache.refresh(muting.muterId);
}
}

View File

@ -269,12 +269,12 @@ export class NotificationEntityService implements OnModuleInit {
*/
#validateNotifier <T extends MiNotification | MiGroupedNotification> (
notification: T,
userIdsWhoMeMuting: Set<MiUser['id']>,
userIdsWhoMeMutingWithNotification: Set<MiUser['id']>,
userMutedInstances: Set<string>,
notifiers: MiUser[],
): boolean {
if (!('notifierId' in notification)) return true;
if (userIdsWhoMeMuting.has(notification.notifierId)) return false;
if (userIdsWhoMeMutingWithNotification.has(notification.notifierId)) return false;
const notifier = notifiers.find(x => x.id === notification.notifierId) ?? null;
@ -303,10 +303,10 @@ export class NotificationEntityService implements OnModuleInit {
meId: MiUser['id'],
): Promise<T[]> {
const [
userIdsWhoMeMuting,
userIdsWhoMeMutingWithNotification,
userMutedInstances,
] = (await Promise.allSettled([
this.cacheService.userMutingsCache.fetch(meId),
this.cacheService.userMutingsWithNotificationCache.fetch(meId),
this.cacheService.userProfileCache.fetch(meId).then(p => new Set(p.mutedInstances)),
])).map(result => result.status === 'fulfilled' ? result.value : new Set<string>());
@ -316,7 +316,7 @@ export class NotificationEntityService implements OnModuleInit {
}) : [];
return ((await Promise.allSettled(notifications.map(async (notification) => {
const isValid = this.#validateNotifier(notification, userIdsWhoMeMuting, userMutedInstances, notifiers);
const isValid = this.#validateNotifier(notification, userIdsWhoMeMutingWithNotification, userMutedInstances, notifiers);
return isValid ? notification : null;
}))).filter(result => result.status === 'fulfilled' && isNotNull(result.value))
.map(result => (result as PromiseFulfilledResult<T>).value));

View File

@ -281,6 +281,7 @@ import * as ep___emoji from './endpoints/emoji.js';
import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js';
import * as ep___mute_create from './endpoints/mute/create.js';
import * as ep___mute_delete from './endpoints/mute/delete.js';
import * as ep___mute_edit from './endpoints/mute/edit.js';
import * as ep___mute_list from './endpoints/mute/list.js';
import * as ep___renoteMute_create from './endpoints/renote-mute/create.js';
import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js';
@ -680,6 +681,7 @@ const $emoji: Provider = { provide: 'ep:emoji', useClass: ep___emoji.default };
const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default };
const $mute_create: Provider = { provide: 'ep:mute/create', useClass: ep___mute_create.default };
const $mute_delete: Provider = { provide: 'ep:mute/delete', useClass: ep___mute_delete.default };
const $mute_edit: Provider = { provide: 'ep:mute/edit', useClass: ep___mute_edit.default };
const $mute_list: Provider = { provide: 'ep:mute/list', useClass: ep___mute_list.default };
const $renoteMute_create: Provider = { provide: 'ep:renote-mute/create', useClass: ep___renoteMute_create.default };
const $renoteMute_delete: Provider = { provide: 'ep:renote-mute/delete', useClass: ep___renoteMute_delete.default };
@ -1083,6 +1085,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$miauth_genToken,
$mute_create,
$mute_delete,
$mute_edit,
$mute_list,
$renoteMute_create,
$renoteMute_delete,
@ -1480,6 +1483,7 @@ const $reversi_verify: Provider = { provide: 'ep:reversi/verify', useClass: ep__
$miauth_genToken,
$mute_create,
$mute_delete,
$mute_edit,
$mute_list,
$renoteMute_create,
$renoteMute_delete,

View File

@ -281,6 +281,7 @@ import * as ep___emoji from './endpoints/emoji.js';
import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js';
import * as ep___mute_create from './endpoints/mute/create.js';
import * as ep___mute_delete from './endpoints/mute/delete.js';
import * as ep___mute_edit from './endpoints/mute/edit.js';
import * as ep___mute_list from './endpoints/mute/list.js';
import * as ep___renoteMute_create from './endpoints/renote-mute/create.js';
import * as ep___renoteMute_delete from './endpoints/renote-mute/delete.js';
@ -678,6 +679,7 @@ const eps = [
['miauth/gen-token', ep___miauth_genToken],
['mute/create', ep___mute_create],
['mute/delete', ep___mute_delete],
['mute/edit', ep___mute_edit],
['mute/list', ep___mute_list],
['renote-mute/create', ep___renoteMute_create],
['renote-mute/delete', ep___renoteMute_delete],

View File

@ -0,0 +1,88 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import type { MutingsRepository } from '@/models/_.js';
import { DI } from '@/di-symbols.js';
import { GetterService } from '@/server/api/GetterService.js';
import { UserMutingService } from '@/core/UserMutingService.js';
import { ApiError } from '../../error.js';
export const meta = {
tags: ['account'],
requireCredential: true,
requireRolePolicy: 'canUpdateContent',
kind: 'write:mutes',
errors: {
noSuchUser: {
message: 'No such user.',
code: 'NO_SUCH_USER',
id: 'b851d00b-8ab1-4a56-8b1b-e24187cb48ef',
},
muteeIsYourself: {
message: 'Mutee is yourself.',
code: 'MUTEE_IS_YOURSELF',
id: 'f428b029-6b39-4d48-a1d2-cc1ae6dd5cf9',
},
notMuting: {
message: 'You are not muting that user.',
code: 'NOT_MUTING',
id: '5467d020-daa9-4553-81e1-135c0c35a96d',
},
},
} as const;
export const paramDef = {
type: 'object',
properties: {
userId: { type: 'string', format: 'misskey:id' },
withNotification: { type: 'boolean', nullable: false },
},
required: ['userId', 'withNotification'],
} as const;
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-disable-line import/no-default-export
constructor(
@Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository,
private userMutingService: UserMutingService,
private getterService: GetterService,
) {
super(meta, paramDef, async (ps, me) => {
const muter = me;
// Check if the mutee is yourself
if (me.id === ps.userId) {
throw new ApiError(meta.errors.muteeIsYourself);
}
// Get mutee
const mutee = await this.getterService.getUser(ps.userId).catch(err => {
if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser);
throw err;
});
// Check not muting
const exist = await this.mutingsRepository.findOneBy({
muterId: muter.id,
muteeId: mutee.id,
});
if (exist == null) {
throw new ApiError(meta.errors.notMuting);
}
await this.userMutingService.editMute(exist, ps.withNotification);
});
}
}

View File

@ -87,7 +87,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<MkUserCardMini :user="item.mutee"/>
</MkA>
<button class="_button" :class="$style.userToggle" @click="toggleMuteItem(item)"><i :class="$style.chevron" class="ti ti-chevron-down"></i></button>
<button class="_button" :class="$style.remove" @click="unmute(item.mutee, $event)"><i class="ti ti-x"></i></button>
<button class="_button" :class="$style.remove" @click="editMute(item, $event)"><i class="ti ti-dots"></i></button>
</div>
<div v-if="expandedMuteItems.includes(item.id)" :class="$style.userItemSub">
<div>Muted at: <MkTime :time="item.createdAt" mode="detail"/></div>
@ -216,8 +216,20 @@ async function unrenoteMute(user, ev) {
}], ev.currentTarget ?? ev.target);
}
async function unmute(user, ev) {
os.popupMenu([{
async function editMute(muting, ev) {
os.popupMenu([...(muting.withNotification === false ? [{
icon: 'ti ti-bell',
text: i18n.ts.muteNotification,
action: async () => {
await os.apiWithDialog('mute/edit', { userId: muting.mutee.id, withNotification: true });
},
}] : [{
icon: 'ti ti-bell-off',
text: i18n.ts.unmuteNotification,
action: async () => {
await os.apiWithDialog('mute/edit', { userId: muting.mutee.id, withNotification: false });
},
}]), {
text: i18n.ts.unmute,
icon: 'ti ti-x',
action: async () => {

View File

@ -1623,6 +1623,7 @@ declare namespace entities {
MiauthGenTokenResponse,
MuteCreateRequest,
MuteDeleteRequest,
MuteEditRequest,
MuteListRequest,
MuteListResponse,
RenoteMuteCreateRequest,
@ -2565,6 +2566,9 @@ type MuteDeleteRequest = operations['mute___delete']['requestBody']['content']['
// @public (undocumented)
export const mutedNoteReasons: readonly ["word", "manual", "spam", "other"];
// @public (undocumented)
type MuteEditRequest = operations['mute___edit']['requestBody']['content']['application/json'];
// @public (undocumented)
type MuteListRequest = operations['mute___list']['requestBody']['content']['application/json'];

View File

@ -3067,6 +3067,17 @@ declare module '../api.js' {
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:mutes*
*/
request<E extends 'mute/edit', P extends Endpoints[E]['req']>(
endpoint: E,
params: P,
credential?: string | null,
): Promise<SwitchCaseResponseType<E, P>>;
/**
* No description provided.
*

View File

@ -403,6 +403,7 @@ import type {
MiauthGenTokenResponse,
MuteCreateRequest,
MuteDeleteRequest,
MuteEditRequest,
MuteListRequest,
MuteListResponse,
RenoteMuteCreateRequest,
@ -868,6 +869,7 @@ export type Endpoints = {
'miauth/gen-token': { req: MiauthGenTokenRequest; res: MiauthGenTokenResponse };
'mute/create': { req: MuteCreateRequest; res: EmptyResponse };
'mute/delete': { req: MuteDeleteRequest; res: EmptyResponse };
'mute/edit': { req: MuteEditRequest; res: EmptyResponse };
'mute/list': { req: MuteListRequest; res: MuteListResponse };
'renote-mute/create': { req: RenoteMuteCreateRequest; res: EmptyResponse };
'renote-mute/delete': { req: RenoteMuteDeleteRequest; res: EmptyResponse };

View File

@ -406,6 +406,7 @@ export type MiauthGenTokenRequest = operations['miauth___gen-token']['requestBod
export type MiauthGenTokenResponse = operations['miauth___gen-token']['responses']['200']['content']['application/json'];
export type MuteCreateRequest = operations['mute___create']['requestBody']['content']['application/json'];
export type MuteDeleteRequest = operations['mute___delete']['requestBody']['content']['application/json'];
export type MuteEditRequest = operations['mute___edit']['requestBody']['content']['application/json'];
export type MuteListRequest = operations['mute___list']['requestBody']['content']['application/json'];
export type MuteListResponse = operations['mute___list']['responses']['200']['content']['application/json'];
export type RenoteMuteCreateRequest = operations['renote-mute___create']['requestBody']['content']['application/json'];

View File

@ -2652,6 +2652,15 @@ export type paths = {
*/
post: operations['mute___delete'];
};
'/mute/edit': {
/**
* mute/edit
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:mutes*
*/
post: operations['mute___edit'];
};
'/mute/list': {
/**
* mute/list
@ -22915,6 +22924,8 @@ export type operations = {
userId: string;
/** @description A Unix Epoch timestamp that must lie in the future. `null` means an indefinite mute. */
expiresAt?: number | null;
/** @default true */
withNotification?: boolean;
};
};
};
@ -23013,6 +23024,59 @@ export type operations = {
};
};
};
/**
* mute/edit
* @description No description provided.
*
* **Credential required**: *Yes* / **Permission**: *write:mutes*
*/
mute___edit: {
requestBody: {
content: {
'application/json': {
/** Format: misskey:id */
userId: string;
withNotification: boolean;
};
};
};
responses: {
/** @description OK (without any results) */
204: {
content: never;
};
/** @description Client error */
400: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Authentication error */
401: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Forbidden error */
403: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description I'm Ai */
418: {
content: {
'application/json': components['schemas']['Error'];
};
};
/** @description Internal server error */
500: {
content: {
'application/json': components['schemas']['Error'];
};
};
};
};
/**
* mute/list
* @description No description provided.