1
0
mirror of https://github.com/MisskeyIO/misskey synced 2024-11-27 06:18:40 +09:00

feat: 通知の受信設定を強化

This commit is contained in:
syuilo 2023-09-29 11:29:54 +09:00
parent 4216a67462
commit b9da1415a5
44 changed files with 676 additions and 503 deletions

View File

@ -17,6 +17,7 @@
### General ### General
- Feat: ノートの編集をできるように - Feat: ノートの編集をできるように
- ロールで編集可否を設定可能 - ロールで編集可否を設定可能
- Feat: 通知を種類ごとに 全員から受け取る/フォロー中のユーザーのみ受け取る/フォロワーのみ受け取る/相互のみ受け取る/指定したリストのメンバーのみ受け取る/受け取らない から選べるように
- Enhance: タイムラインからRenoteを除外するオプションを追加 - Enhance: タイムラインからRenoteを除外するオプションを追加
- Enhance: ユーザーページのート一覧でRenoteを除外できるように - Enhance: ユーザーページのート一覧でRenoteを除外できるように

2
locales/index.d.ts vendored
View File

@ -1126,6 +1126,8 @@ export interface Locale {
"dateAndTime": string; "dateAndTime": string;
"showRenotes": string; "showRenotes": string;
"edited": string; "edited": string;
"notificationRecieveConfig": string;
"mutualFollow": string;
"_announcement": { "_announcement": {
"forExistingUsers": string; "forExistingUsers": string;
"forExistingUsersDescription": string; "forExistingUsersDescription": string;

View File

@ -1123,6 +1123,8 @@ authenticationRequiredToContinue: "続けるには認証を行ってください
dateAndTime: "日時" dateAndTime: "日時"
showRenotes: "リノートを表示" showRenotes: "リノートを表示"
edited: "編集済み" edited: "編集済み"
notificationRecieveConfig: "通知の受信設定"
mutualFollow: "相互フォロー"
_announcement: _announcement:
forExistingUsers: "既存ユーザーのみ" forExistingUsers: "既存ユーザーのみ"

View File

@ -0,0 +1,18 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
export class NotificationRecieveConfig1695944637565 {
name = 'NotificationRecieveConfig1695944637565'
async up(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "mutingNotificationTypes"`);
await queryRunner.query(`ALTER TABLE "user_profile" ADD "notificationRecieveConfig" jsonb NOT NULL DEFAULT '{}'`);
}
async down(queryRunner) {
await queryRunner.query(`ALTER TABLE "user_profile" DROP COLUMN "notificationRecieveConfig"`);
await queryRunner.query(`ALTER TABLE "user_profile" ADD "mutingNotificationTypes" "public"."user_profile_notificationrecieveconfig_enum" array NOT NULL DEFAULT '{}'`);
}
}

View File

@ -15,7 +15,7 @@ import { DI } from '@/di-symbols.js';
import type { AntennasRepository, UserListJoiningsRepository } from '@/models/_.js'; import type { AntennasRepository, UserListJoiningsRepository } from '@/models/_.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { StreamMessages } from '@/server/api/stream/types.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable() @Injectable()
@ -50,7 +50,7 @@ export class AntennaService implements OnApplicationShutdown {
const obj = JSON.parse(data); const obj = JSON.parse(data);
if (obj.channel === 'internal') { if (obj.channel === 'internal') {
const { type, body } = obj.message as StreamMessages['internal']['payload']; const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) { switch (type) {
case 'antennaCreated': case 'antennaCreated':
this.antennas.push({ this.antennas.push({

View File

@ -11,7 +11,7 @@ import type { MiLocalUser, MiUser } from '@/models/User.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { StreamMessages } from '@/server/api/stream/types.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable() @Injectable()
@ -160,7 +160,7 @@ export class CacheService implements OnApplicationShutdown {
const obj = JSON.parse(data); const obj = JSON.parse(data);
if (obj.channel === 'internal') { if (obj.channel === 'internal') {
const { type, body } = obj.message as StreamMessages['internal']['payload']; const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) { switch (type) {
case 'userChangeSuspendedState': case 'userChangeSuspendedState':
case 'remoteUserUpdated': { case 'remoteUserUpdated': {

View File

@ -17,7 +17,7 @@ import { bindThis } from '@/decorators.js';
import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js'; import { MemoryKVCache, RedisSingleCache } from '@/misc/cache.js';
import { UtilityService } from '@/core/UtilityService.js'; import { UtilityService } from '@/core/UtilityService.js';
import { query } from '@/misc/prelude/url.js'; import { query } from '@/misc/prelude/url.js';
import type { Serialized } from '@/server/api/stream/types.js'; import type { Serialized } from '@/types.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/; const parseEmojiStrRegexp = /^(\w+)(?:@([\w.-]+))?$/;

View File

@ -5,27 +5,254 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import * as Redis from 'ioredis'; import * as Redis from 'ioredis';
import type { MiChannel } from '@/models/Channel.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiUserProfile } from '@/models/UserProfile.js';
import type { MiNote } from '@/models/Note.js'; import type { MiNote } from '@/models/Note.js';
import type { MiUserList } from '@/models/UserList.js';
import type { MiAntenna } from '@/models/Antenna.js'; import type { MiAntenna } from '@/models/Antenna.js';
import type { import type { MiDriveFile } from '@/models/DriveFile.js';
StreamChannels, import type { MiDriveFolder } from '@/models/DriveFolder.js';
AdminStreamTypes, import type { MiUserList } from '@/models/UserList.js';
AntennaStreamTypes, import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
BroadcastTypes, import type { MiSignin } from '@/models/Signin.js';
DriveStreamTypes, import type { MiPage } from '@/models/Page.js';
InternalStreamTypes, import type { MiWebhook } from '@/models/Webhook.js';
MainStreamTypes, import type { MiMeta } from '@/models/Meta.js';
NoteStreamTypes, import { MiRole, MiRoleAssignment } from '@/models/_.js';
UserListStreamTypes,
RoleTimelineStreamTypes,
} from '@/server/api/stream/types.js';
import type { Packed } from '@/misc/json-schema.js'; import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { MiRole } from '@/models/_.js'; import { Serialized } from '@/types.js';
import type Emitter from 'strict-event-emitter-types';
import type { EventEmitter } from 'events';
//#region Stream type-body definitions
export interface BroadcastTypes {
emojiAdded: {
emoji: Packed<'EmojiDetailed'>;
};
emojiUpdated: {
emojis: Packed<'EmojiDetailed'>[];
};
emojiDeleted: {
emojis: {
id?: string;
name: string;
[other: string]: any;
}[];
};
announcementCreated: {
announcement: Packed<'Announcement'>;
};
}
export interface MainEventTypes {
notification: Packed<'Notification'>;
mention: Packed<'Note'>;
reply: Packed<'Note'>;
renote: Packed<'Note'>;
follow: Packed<'UserDetailedNotMe'>;
followed: Packed<'User'>;
unfollow: Packed<'User'>;
meUpdated: Packed<'User'>;
pageEvent: {
pageId: MiPage['id'];
event: string;
var: any;
userId: MiUser['id'];
user: Packed<'User'>;
};
urlUploadFinished: {
marker?: string | null;
file: Packed<'DriveFile'>;
};
readAllNotifications: undefined;
unreadNotification: Packed<'Notification'>;
unreadMention: MiNote['id'];
readAllUnreadMentions: undefined;
unreadSpecifiedNote: MiNote['id'];
readAllUnreadSpecifiedNotes: undefined;
readAllAntennas: undefined;
unreadAntenna: MiAntenna;
readAllAnnouncements: undefined;
myTokenRegenerated: undefined;
signin: MiSignin;
registryUpdated: {
scope?: string[];
key: string;
value: any | null;
};
driveFileCreated: Packed<'DriveFile'>;
readAntenna: MiAntenna;
receiveFollowRequest: Packed<'User'>;
announcementCreated: {
announcement: Packed<'Announcement'>;
};
}
export interface DriveEventTypes {
fileCreated: Packed<'DriveFile'>;
fileDeleted: MiDriveFile['id'];
fileUpdated: Packed<'DriveFile'>;
folderCreated: Packed<'DriveFolder'>;
folderDeleted: MiDriveFolder['id'];
folderUpdated: Packed<'DriveFolder'>;
}
export interface NoteEventTypes {
pollVoted: {
choice: number;
userId: MiUser['id'];
};
deleted: {
deletedAt: Date;
};
updated: {
cw: string | null;
text: string;
};
reacted: {
reaction: string;
emoji?: {
name: string;
url: string;
} | null;
userId: MiUser['id'];
};
unreacted: {
reaction: string;
userId: MiUser['id'];
};
}
type NoteStreamEventTypes = {
[key in keyof NoteEventTypes]: {
id: MiNote['id'];
body: NoteEventTypes[key];
};
};
export interface UserListEventTypes {
userAdded: Packed<'User'>;
userRemoved: Packed<'User'>;
}
export interface AntennaEventTypes {
note: MiNote;
}
export interface RoleTimelineEventTypes {
note: Packed<'Note'>;
}
export interface AdminEventTypes {
newAbuseUserReport: {
id: MiAbuseUserReport['id'];
targetUserId: MiUser['id'],
reporterId: MiUser['id'],
comment: string;
};
}
//#endregion
// 辞書(interface or type)から{ type, body }ユニオンを定義
// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type
// VS Codeの展開を防止するためにEvents型を定義
type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } };
type EventUnionFromDictionary<
T extends object,
U = Events<T>
> = U[keyof U];
type SerializedAll<T> = {
[K in keyof T]: Serialized<T[K]>;
};
export interface InternalEventTypes {
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
remoteUserUpdated: { id: MiUser['id']; };
follow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
blockingDeleted: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
policiesUpdated: MiRole['policies'];
roleCreated: MiRole;
roleDeleted: MiRole;
roleUpdated: MiRole;
userRoleAssigned: MiRoleAssignment;
userRoleUnassigned: MiRoleAssignment;
webhookCreated: MiWebhook;
webhookDeleted: MiWebhook;
webhookUpdated: MiWebhook;
antennaCreated: MiAntenna;
antennaDeleted: MiAntenna;
antennaUpdated: MiAntenna;
metaUpdated: MiMeta;
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
updateUserProfile: MiUserProfile;
mute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
userListMemberAdded: { userListId: MiUserList['id']; memberId: MiUser['id']; };
userListMemberRemoved: { userListId: MiUserList['id']; memberId: MiUser['id']; };
}
// name/messages(spec) pairs dictionary
export type GlobalEvents = {
internal: {
name: 'internal';
payload: EventUnionFromDictionary<SerializedAll<InternalEventTypes>>;
};
broadcast: {
name: 'broadcast';
payload: EventUnionFromDictionary<SerializedAll<BroadcastTypes>>;
};
main: {
name: `mainStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<MainEventTypes>>;
};
drive: {
name: `driveStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<DriveEventTypes>>;
};
note: {
name: `noteStream:${MiNote['id']}`;
payload: EventUnionFromDictionary<SerializedAll<NoteStreamEventTypes>>;
};
userList: {
name: `userListStream:${MiUserList['id']}`;
payload: EventUnionFromDictionary<SerializedAll<UserListEventTypes>>;
};
roleTimeline: {
name: `roleTimelineStream:${MiRole['id']}`;
payload: EventUnionFromDictionary<SerializedAll<RoleTimelineEventTypes>>;
};
antenna: {
name: `antennaStream:${MiAntenna['id']}`;
payload: EventUnionFromDictionary<SerializedAll<AntennaEventTypes>>;
};
admin: {
name: `adminStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<AdminEventTypes>>;
};
notes: {
name: 'notesStream';
payload: Serialized<Packed<'Note'>>;
};
};
// API event definitions
// ストリームごとのEmitterの辞書を用意
type EventEmitterDictionary = { [x in keyof GlobalEvents]: Emitter.default<EventEmitter, { [y in GlobalEvents[x]['name']]: (e: GlobalEvents[x]['payload']) => void }> };
// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする
export type StreamEventEmitter = UnionToIntersection<EventEmitterDictionary[keyof GlobalEvents]>;
// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる
// provide stream channels union
export type StreamChannels = GlobalEvents[keyof GlobalEvents]['name'];
@Injectable() @Injectable()
export class GlobalEventService { export class GlobalEventService {
@ -51,7 +278,7 @@ export class GlobalEventService {
} }
@bindThis @bindThis
public publishInternalEvent<K extends keyof InternalStreamTypes>(type: K, value?: InternalStreamTypes[K]): void { public publishInternalEvent<K extends keyof InternalEventTypes>(type: K, value?: InternalEventTypes[K]): void {
this.publish('internal', type, typeof value === 'undefined' ? null : value); this.publish('internal', type, typeof value === 'undefined' ? null : value);
} }
@ -61,17 +288,17 @@ export class GlobalEventService {
} }
@bindThis @bindThis
public publishMainStream<K extends keyof MainStreamTypes>(userId: MiUser['id'], type: K, value?: MainStreamTypes[K]): void { public publishMainStream<K extends keyof MainEventTypes>(userId: MiUser['id'], type: K, value?: MainEventTypes[K]): void {
this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value); this.publish(`mainStream:${userId}`, type, typeof value === 'undefined' ? null : value);
} }
@bindThis @bindThis
public publishDriveStream<K extends keyof DriveStreamTypes>(userId: MiUser['id'], type: K, value?: DriveStreamTypes[K]): void { public publishDriveStream<K extends keyof DriveEventTypes>(userId: MiUser['id'], type: K, value?: DriveEventTypes[K]): void {
this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value); this.publish(`driveStream:${userId}`, type, typeof value === 'undefined' ? null : value);
} }
@bindThis @bindThis
public publishNoteStream<K extends keyof NoteStreamTypes>(noteId: MiNote['id'], type: K, value?: NoteStreamTypes[K]): void { public publishNoteStream<K extends keyof NoteEventTypes>(noteId: MiNote['id'], type: K, value?: NoteEventTypes[K]): void {
this.publish(`noteStream:${noteId}`, type, { this.publish(`noteStream:${noteId}`, type, {
id: noteId, id: noteId,
body: value, body: value,
@ -79,17 +306,17 @@ export class GlobalEventService {
} }
@bindThis @bindThis
public publishUserListStream<K extends keyof UserListStreamTypes>(listId: MiUserList['id'], type: K, value?: UserListStreamTypes[K]): void { public publishUserListStream<K extends keyof UserListEventTypes>(listId: MiUserList['id'], type: K, value?: UserListEventTypes[K]): void {
this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value);
} }
@bindThis @bindThis
public publishAntennaStream<K extends keyof AntennaStreamTypes>(antennaId: MiAntenna['id'], type: K, value?: AntennaStreamTypes[K]): void { public publishAntennaStream<K extends keyof AntennaEventTypes>(antennaId: MiAntenna['id'], type: K, value?: AntennaEventTypes[K]): void {
this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value);
} }
@bindThis @bindThis
public publishRoleTimelineStream<K extends keyof RoleTimelineStreamTypes>(roleId: MiRole['id'], type: K, value?: RoleTimelineStreamTypes[K]): void { public publishRoleTimelineStream<K extends keyof RoleTimelineEventTypes>(roleId: MiRole['id'], type: K, value?: RoleTimelineEventTypes[K]): void {
this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value); this.publish(`roleTimelineStream:${roleId}`, type, typeof value === 'undefined' ? null : value);
} }
@ -99,7 +326,7 @@ export class GlobalEventService {
} }
@bindThis @bindThis
public publishAdminStream<K extends keyof AdminStreamTypes>(userId: MiUser['id'], type: K, value?: AdminStreamTypes[K]): void { public publishAdminStream<K extends keyof AdminEventTypes>(userId: MiUser['id'], type: K, value?: AdminEventTypes[K]): void {
this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value); this.publish(`adminStream:${userId}`, type, typeof value === 'undefined' ? null : value);
} }
} }

View File

@ -10,7 +10,7 @@ import { DI } from '@/di-symbols.js';
import { MiMeta } from '@/models/Meta.js'; import { MiMeta } from '@/models/Meta.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { StreamMessages } from '@/server/api/stream/types.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable() @Injectable()
@ -46,7 +46,7 @@ export class MetaService implements OnApplicationShutdown {
const obj = JSON.parse(data); const obj = JSON.parse(data);
if (obj.channel === 'internal') { if (obj.channel === 'internal') {
const { type, body } = obj.message as StreamMessages['internal']['payload']; const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) { switch (type) {
case 'metaUpdated': { case 'metaUpdated': {
this.cache = body; this.cache = body;

View File

@ -110,9 +110,8 @@ class NotificationManager {
// 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する // 通知される側のユーザーが通知する側のユーザーをミュートしていない限りは通知する
if (!mentioneesMutedUserIds.includes(this.notifier.id)) { if (!mentioneesMutedUserIds.includes(this.notifier.id)) {
this.notificationService.createNotification(x.target, x.reason, { this.notificationService.createNotification(x.target, x.reason, {
notifierId: this.notifier.id,
noteId: this.note.id, noteId: this.note.id,
}); }, this.notifier.id);
} }
} }
} }
@ -515,9 +514,8 @@ export class NoteCreateService implements OnApplicationShutdown {
}).then(followings => { }).then(followings => {
for (const following of followings) { for (const following of followings) {
this.notificationService.createNotification(following.followerId, 'note', { this.notificationService.createNotification(following.followerId, 'note', {
notifierId: user.id,
noteId: note.id, noteId: note.id,
}); }, user.id);
} }
}); });
} }

View File

@ -18,6 +18,7 @@ import { NotificationEntityService } from '@/core/entities/NotificationEntitySer
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import { UserListService } from '@/core/UserListService.js';
@Injectable() @Injectable()
export class NotificationService implements OnApplicationShutdown { export class NotificationService implements OnApplicationShutdown {
@ -38,6 +39,7 @@ export class NotificationService implements OnApplicationShutdown {
private globalEventService: GlobalEventService, private globalEventService: GlobalEventService,
private pushNotificationService: PushNotificationService, private pushNotificationService: PushNotificationService,
private cacheService: CacheService, private cacheService: CacheService,
private userListService: UserListService,
) { ) {
} }
@ -74,27 +76,56 @@ export class NotificationService implements OnApplicationShutdown {
public async createNotification( public async createNotification(
notifieeId: MiUser['id'], notifieeId: MiUser['id'],
type: MiNotification['type'], type: MiNotification['type'],
data: Partial<MiNotification>, data: Omit<Partial<MiNotification>, 'notifierId'>,
notifierId?: MiUser['id'] | null,
): Promise<MiNotification | null> { ): Promise<MiNotification | null> {
const profile = await this.cacheService.userProfileCache.fetch(notifieeId); const profile = await this.cacheService.userProfileCache.fetch(notifieeId);
const isMuted = profile.mutingNotificationTypes.includes(type); const recieveConfig = profile.notificationRecieveConfig[type];
if (isMuted) return null; if (recieveConfig?.type === 'never') {
return null;
}
if (data.notifierId) { if (notifierId) {
if (notifieeId === data.notifierId) { if (notifieeId === notifierId) {
return null; return null;
} }
const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId); const mutings = await this.cacheService.userMutingsCache.fetch(notifieeId);
if (mutings.has(data.notifierId)) { if (mutings.has(notifierId)) {
return null; return null;
} }
if (recieveConfig?.type === 'following') {
const isFollowing = await this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId));
if (!isFollowing) {
return null;
}
} else if (recieveConfig?.type === 'follower') {
const isFollower = await this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId));
if (!isFollower) {
return null;
}
} else if (recieveConfig?.type === 'mutualFollow') {
const [isFollowing, isFollower] = await Promise.all([
this.cacheService.userFollowingsCache.fetch(notifieeId).then(followings => followings.has(notifierId)),
this.cacheService.userFollowingsCache.fetch(notifierId).then(followings => followings.has(notifieeId)),
]);
if (!isFollowing && !isFollower) {
return null;
}
} else if (recieveConfig?.type === 'list') {
const isMember = await this.userListService.membersCache.fetch(recieveConfig.userListId).then(members => members.has(notifierId));
if (!isMember) {
return null;
}
}
} }
const notification = { const notification = {
id: this.idService.genId(), id: this.idService.genId(),
createdAt: new Date(), createdAt: new Date(),
type: type, type: type,
notifierId: notifierId,
...data, ...data,
} as MiNotification; } as MiNotification;
@ -117,8 +148,8 @@ export class NotificationService implements OnApplicationShutdown {
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); if (type === 'follow') this.emailNotificationFollow(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! }));
if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: data.notifierId! })); if (type === 'receiveFollowRequest') this.emailNotificationReceiveFollowRequest(notifieeId, await this.usersRepository.findOneByOrFail({ id: notifierId! }));
}, () => { /* aborted, ignore it */ }); }, () => { /* aborted, ignore it */ });
return notification; return notification;

View File

@ -219,10 +219,9 @@ export class ReactionService {
// リアクションされたユーザーがローカルユーザーなら通知を作成 // リアクションされたユーザーがローカルユーザーなら通知を作成
if (note.userHost === null) { if (note.userHost === null) {
this.notificationService.createNotification(note.userId, 'reaction', { this.notificationService.createNotification(note.userId, 'reaction', {
notifierId: user.id,
noteId: note.id, noteId: note.id,
reaction: reaction, reaction: reaction,
}); }, user.id);
} }
//#region 配信 //#region 配信

View File

@ -15,7 +15,7 @@ import { MetaService } from '@/core/MetaService.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import type { RoleCondFormulaValue } from '@/models/Role.js'; import type { RoleCondFormulaValue } from '@/models/Role.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { StreamMessages } from '@/server/api/stream/types.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
import { IdService } from '@/core/IdService.js'; import { IdService } from '@/core/IdService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js'; import { GlobalEventService } from '@/core/GlobalEventService.js';
import { ModerationLogService } from '@/core/ModerationLogService.js'; import { ModerationLogService } from '@/core/ModerationLogService.js';
@ -116,7 +116,7 @@ export class RoleService implements OnApplicationShutdown {
const obj = JSON.parse(data); const obj = JSON.parse(data);
if (obj.channel === 'internal') { if (obj.channel === 'internal') {
const { type, body } = obj.message as StreamMessages['internal']['payload']; const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) { switch (type) {
case 'roleCreated': { case 'roleCreated': {
const cached = this.rolesCache.get(); const cached = this.rolesCache.get();

View File

@ -230,8 +230,7 @@ export class UserFollowingService implements OnModuleInit {
// 通知を作成 // 通知を作成
this.notificationService.createNotification(follower.id, 'followRequestAccepted', { this.notificationService.createNotification(follower.id, 'followRequestAccepted', {
notifierId: followee.id, }, followee.id);
});
} }
if (alreadyFollowed) return; if (alreadyFollowed) return;
@ -304,8 +303,7 @@ export class UserFollowingService implements OnModuleInit {
// 通知を作成 // 通知を作成
this.notificationService.createNotification(followee.id, 'follow', { this.notificationService.createNotification(followee.id, 'follow', {
notifierId: follower.id, }, follower.id);
});
} }
} }
@ -488,9 +486,8 @@ export class UserFollowingService implements OnModuleInit {
// 通知を作成 // 通知を作成
this.notificationService.createNotification(followee.id, 'receiveFollowRequest', { this.notificationService.createNotification(followee.id, 'receiveFollowRequest', {
notifierId: follower.id,
followRequestId: followRequest.id, followRequestId: followRequest.id,
}); }, follower.id);
} }
if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) { if (this.userEntityService.isLocalUser(follower) && this.userEntityService.isRemoteUser(followee)) {

View File

@ -3,7 +3,8 @@
* SPDX-License-Identifier: AGPL-3.0-only * SPDX-License-Identifier: AGPL-3.0-only
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import * as Redis from 'ioredis';
import type { UserListJoiningsRepository } from '@/models/_.js'; import type { UserListJoiningsRepository } from '@/models/_.js';
import type { MiUser } from '@/models/User.js'; import type { MiUser } from '@/models/User.js';
import type { MiUserList } from '@/models/UserList.js'; import type { MiUserList } from '@/models/UserList.js';
@ -16,12 +17,22 @@ import { ProxyAccountService } from '@/core/ProxyAccountService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import { QueueService } from '@/core/QueueService.js'; import { QueueService } from '@/core/QueueService.js';
import { RedisKVCache } from '@/misc/cache.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
@Injectable() @Injectable()
export class UserListService { export class UserListService implements OnApplicationShutdown {
public static TooManyUsersError = class extends Error {}; public static TooManyUsersError = class extends Error {};
public membersCache: RedisKVCache<Set<string>>;
constructor( constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.redisForSub)
private redisForSub: Redis.Redis,
@Inject(DI.userListJoiningsRepository) @Inject(DI.userListJoiningsRepository)
private userListJoiningsRepository: UserListJoiningsRepository, private userListJoiningsRepository: UserListJoiningsRepository,
@ -32,10 +43,48 @@ export class UserListService {
private proxyAccountService: ProxyAccountService, private proxyAccountService: ProxyAccountService,
private queueService: QueueService, private queueService: QueueService,
) { ) {
this.membersCache = new RedisKVCache<Set<string>>(this.redisClient, 'userListMembers', {
lifetime: 1000 * 60 * 30, // 30m
memoryCacheLifetime: 1000 * 60, // 1m
fetcher: (key) => this.userListJoiningsRepository.find({ where: { userListId: key }, select: ['userId'] }).then(xs => new Set(xs.map(x => x.userId))),
toRedisConverter: (value) => JSON.stringify(Array.from(value)),
fromRedisConverter: (value) => new Set(JSON.parse(value)),
});
this.redisForSub.on('message', this.onMessage);
} }
@bindThis @bindThis
public async push(target: MiUser, list: MiUserList, me: MiUser) { private async onMessage(_: string, data: string): Promise<void> {
const obj = JSON.parse(data);
if (obj.channel === 'internal') {
const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) {
case 'userListMemberAdded': {
const { userListId, memberId } = body;
const members = await this.membersCache.get(userListId);
if (members) {
members.add(memberId);
}
break;
}
case 'userListMemberRemoved': {
const { userListId, memberId } = body;
const members = await this.membersCache.get(userListId);
if (members) {
members.delete(memberId);
}
break;
}
default:
break;
}
}
}
@bindThis
public async addMember(target: MiUser, list: MiUserList, me: MiUser) {
const currentCount = await this.userListJoiningsRepository.countBy({ const currentCount = await this.userListJoiningsRepository.countBy({
userListId: list.id, userListId: list.id,
}); });
@ -50,6 +99,7 @@ export class UserListService {
userListId: list.id, userListId: list.id,
} as MiUserListJoining); } as MiUserListJoining);
this.globalEventService.publishInternalEvent('userListMemberAdded', { userListId: list.id, memberId: target.id });
this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target)); this.globalEventService.publishUserListStream(list.id, 'userAdded', await this.userEntityService.pack(target));
// このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする // このインスタンス内にこのリモートユーザーをフォローしているユーザーがいなくても投稿を受け取るためにダミーのユーザーがフォローしたということにする
@ -60,4 +110,26 @@ export class UserListService {
} }
} }
} }
@bindThis
public async removeMember(target: MiUser, list: MiUserList) {
await this.userListJoiningsRepository.delete({
userId: target.id,
userListId: list.id,
});
this.globalEventService.publishInternalEvent('userListMemberRemoved', { userListId: list.id, memberId: target.id });
this.globalEventService.publishUserListStream(list.id, 'userRemoved', await this.userEntityService.pack(target));
}
@bindThis
public dispose(): void {
this.redisForSub.off('message', this.onMessage);
this.membersCache.dispose();
}
@bindThis
public onApplicationShutdown(signal?: string | undefined): void {
this.dispose();
}
} }

View File

@ -9,7 +9,7 @@ import type { WebhooksRepository } from '@/models/_.js';
import type { MiWebhook } from '@/models/Webhook.js'; import type { MiWebhook } from '@/models/Webhook.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { StreamMessages } from '@/server/api/stream/types.js'; import type { GlobalEvents } from '@/core/GlobalEventService.js';
import type { OnApplicationShutdown } from '@nestjs/common'; import type { OnApplicationShutdown } from '@nestjs/common';
@Injectable() @Injectable()
@ -45,7 +45,7 @@ export class WebhookService implements OnApplicationShutdown {
const obj = JSON.parse(data); const obj = JSON.parse(data);
if (obj.channel === 'internal') { if (obj.channel === 'internal') {
const { type, body } = obj.message as StreamMessages['internal']['payload']; const { type, body } = obj.message as GlobalEvents['internal']['payload'];
switch (type) { switch (type) {
case 'webhookCreated': case 'webhookCreated':
if (body.active) { if (body.active) {

View File

@ -452,7 +452,7 @@ export class UserEntityService implements OnModuleInit {
hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id),
mutedWords: profile!.mutedWords, mutedWords: profile!.mutedWords,
mutedInstances: profile!.mutedInstances, mutedInstances: profile!.mutedInstances,
mutingNotificationTypes: profile!.mutingNotificationTypes, notificationRecieveConfig: profile!.notificationRecieveConfig,
emailNotificationTypes: profile!.emailNotificationTypes, emailNotificationTypes: profile!.emailNotificationTypes,
achievements: profile!.achievements, achievements: profile!.achievements,
loggedInDays: profile!.loggedInDates.length, loggedInDays: profile!.loggedInDates.length,

View File

@ -8,6 +8,7 @@ import { obsoleteNotificationTypes, ffVisibility, notificationTypes } from '@/ty
import { id } from './util/id.js'; import { id } from './util/id.js';
import { MiUser } from './User.js'; import { MiUser } from './User.js';
import { MiPage } from './Page.js'; import { MiPage } from './Page.js';
import { MiUserList } from './UserList.js';
// TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも // TODO: このテーブルで管理している情報すべてレジストリで管理するようにしても良いかも
// ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン // ただ、「emailVerified が true なユーザーを find する」のようなクエリは書けなくなるからウーン
@ -222,16 +223,25 @@ export class MiUserProfile {
}) })
public mutedInstances: string[]; public mutedInstances: string[];
@Column('enum', { @Column('jsonb', {
enum: [ default: {},
...notificationTypes,
// マイグレーションで削除が困難なので古いenumは残しておく
...obsoleteNotificationTypes,
],
array: true,
default: [],
}) })
public mutingNotificationTypes: typeof notificationTypes[number][]; public notificationRecieveConfig: {
[notificationType in typeof notificationTypes[number]]?: {
type: 'all';
} | {
type: 'never';
} | {
type: 'following';
} | {
type: 'follower';
} | {
type: 'mutualFollow';
} | {
type: 'list';
userListId: MiUserList['id'];
};
};
@Column('varchar', { @Column('varchar', {
length: 32, array: true, default: '{}', length: 32, array: true, default: '{}',

View File

@ -387,13 +387,9 @@ export const packedMeDetailedOnlySchema = {
nullable: false, optional: false, nullable: false, optional: false,
}, },
}, },
mutingNotificationTypes: { notificationRecieveConfig: {
type: 'array', type: 'object',
nullable: true, optional: false, nullable: false, optional: false,
items: {
type: 'string',
nullable: false, optional: false,
},
}, },
emailNotificationTypes: { emailNotificationTypes: {
type: 'array', type: 'array',

View File

@ -101,7 +101,7 @@ export class ImportUserListsProcessorService {
if (await this.userListJoiningsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue; if (await this.userListJoiningsRepository.findOneBy({ userListId: list!.id, userId: target.id }) != null) continue;
this.userListService.push(target, list!, user); this.userListService.addMember(target, list!, user);
} catch (e) { } catch (e) {
this.logger.warn(`Error in line:${linenum} ${e}`); this.logger.warn(`Error in line:${linenum} ${e}`);
} }

View File

@ -81,7 +81,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
receiveAnnouncementEmail: profile.receiveAnnouncementEmail, receiveAnnouncementEmail: profile.receiveAnnouncementEmail,
mutedWords: profile.mutedWords, mutedWords: profile.mutedWords,
mutedInstances: profile.mutedInstances, mutedInstances: profile.mutedInstances,
mutingNotificationTypes: profile.mutingNotificationTypes, notificationRecieveConfig: profile.notificationRecieveConfig,
isModerator: isModerator, isModerator: isModerator,
isSilenced: isSilenced, isSilenced: isSilenced,
isSuspended: user.isSuspended, isSuspended: user.isSuspended,

View File

@ -165,9 +165,7 @@ export const paramDef = {
mutedInstances: { type: 'array', items: { mutedInstances: { type: 'array', items: {
type: 'string', type: 'string',
} }, } },
mutingNotificationTypes: { type: 'array', items: { notificationRecieveConfig: { type: 'object' },
type: 'string', enum: notificationTypes,
} },
emailNotificationTypes: { type: 'array', items: { emailNotificationTypes: { type: 'array', items: {
type: 'string', type: 'string',
} }, } },
@ -248,7 +246,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
profileUpdates.enableWordMute = ps.mutedWords.length > 0; profileUpdates.enableWordMute = ps.mutedWords.length > 0;
} }
if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances; if (ps.mutedInstances !== undefined) profileUpdates.mutedInstances = ps.mutedInstances;
if (ps.mutingNotificationTypes !== undefined) profileUpdates.mutingNotificationTypes = ps.mutingNotificationTypes as typeof notificationTypes[number][]; if (ps.notificationRecieveConfig !== undefined) profileUpdates.notificationRecieveConfig = ps.notificationRecieveConfig;
if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked; if (typeof ps.isLocked === 'boolean') updates.isLocked = ps.isLocked;
if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable; if (typeof ps.isExplorable === 'boolean') updates.isExplorable = ps.isExplorable;
if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus; if (typeof ps.hideOnlineStatus === 'boolean') updates.hideOnlineStatus = ps.hideOnlineStatus;

View File

@ -144,7 +144,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
try { try {
await this.userListService.push(currentUser, userList, me); await this.userListService.addMember(currentUser, userList, me);
} catch (err) { } catch (err) {
if (err instanceof UserListService.TooManyUsersError) { if (err instanceof UserListService.TooManyUsersError) {
throw new ApiError(meta.errors.tooManyUsers); throw new ApiError(meta.errors.tooManyUsers);

View File

@ -4,12 +4,11 @@
*/ */
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UserListsRepository, UserListJoiningsRepository } from '@/models/_.js'; import type { UserListsRepository } from '@/models/_.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { GetterService } from '@/server/api/GetterService.js'; import { GetterService } from '@/server/api/GetterService.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { UserListService } from '@/core/UserListService.js';
import { ApiError } from '../../../error.js'; import { ApiError } from '../../../error.js';
export const meta = { export const meta = {
@ -53,12 +52,8 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
@Inject(DI.userListsRepository) @Inject(DI.userListsRepository)
private userListsRepository: UserListsRepository, private userListsRepository: UserListsRepository,
@Inject(DI.userListJoiningsRepository) private userListService: UserListService,
private userListJoiningsRepository: UserListJoiningsRepository,
private userEntityService: UserEntityService,
private getterService: GetterService, private getterService: GetterService,
private globalEventService: GlobalEventService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
// Fetch the list // Fetch the list
@ -77,10 +72,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
throw err; throw err;
}); });
// Pull the user await this.userListService.removeMember(user, userList);
await this.userListJoiningsRepository.delete({ userListId: userList.id, userId: user.id });
this.globalEventService.publishUserListStream(userList.id, 'userRemoved', await this.userEntityService.pack(user));
}); });
} }
} }

View File

@ -127,7 +127,7 @@ export default class extends Endpoint<typeof meta, typeof paramDef> { // eslint-
} }
try { try {
await this.userListService.push(user, userList, me); await this.userListService.addMember(user, userList, me);
} catch (err) { } catch (err) {
if (err instanceof UserListService.TooManyUsersError) { if (err instanceof UserListService.TooManyUsersError) {
throw new ApiError(meta.errors.tooManyUsers); throw new ApiError(meta.errors.tooManyUsers);

View File

@ -12,10 +12,10 @@ import type { NotificationService } from '@/core/NotificationService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { CacheService } from '@/core/CacheService.js'; import { CacheService } from '@/core/CacheService.js';
import { MiUserProfile } from '@/models/_.js'; import { MiUserProfile } from '@/models/_.js';
import type { StreamEventEmitter, GlobalEvents } from '@/core/GlobalEventService.js';
import type { ChannelsService } from './ChannelsService.js'; import type { ChannelsService } from './ChannelsService.js';
import type { EventEmitter } from 'events'; import type { EventEmitter } from 'events';
import type Channel from './channel.js'; import type Channel from './channel.js';
import type { StreamEventEmitter, StreamMessages } from './types.js';
/** /**
* Main stream connection * Main stream connection
@ -122,7 +122,7 @@ export default class Connection {
} }
@bindThis @bindThis
private onBroadcastMessage(data: StreamMessages['broadcast']['payload']) { private onBroadcastMessage(data: GlobalEvents['broadcast']['payload']) {
this.sendMessageToWs(data.type, data.body); this.sendMessageToWs(data.type, data.body);
} }
@ -196,7 +196,7 @@ export default class Connection {
} }
@bindThis @bindThis
private async onNoteStreamMessage(data: StreamMessages['note']['payload']) { private async onNoteStreamMessage(data: GlobalEvents['note']['payload']) {
this.sendMessageToWs('noteUpdated', { this.sendMessageToWs('noteUpdated', {
id: data.body.id, id: data.body.id,
type: data.type, type: data.type,

View File

@ -7,8 +7,8 @@ import { Injectable } from '@nestjs/common';
import { isUserRelated } from '@/misc/is-user-related.js'; import { isUserRelated } from '@/misc/is-user-related.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import Channel from '../channel.js'; import Channel from '../channel.js';
import type { StreamMessages } from '../types.js';
class AntennaChannel extends Channel { class AntennaChannel extends Channel {
public readonly chName = 'antenna'; public readonly chName = 'antenna';
@ -35,7 +35,7 @@ class AntennaChannel extends Channel {
} }
@bindThis @bindThis
private async onEvent(data: StreamMessages['antenna']['payload']) { private async onEvent(data: GlobalEvents['antenna']['payload']) {
if (data.type === 'note') { if (data.type === 'note') {
const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true }); const note = await this.noteEntityService.pack(data.body.id, this.user, { detail: true });

View File

@ -9,8 +9,8 @@ import type { Packed } from '@/misc/json-schema.js';
import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
import { RoleService } from '@/core/RoleService.js'; import { RoleService } from '@/core/RoleService.js';
import type { GlobalEvents } from '@/core/GlobalEventService.js';
import Channel from '../channel.js'; import Channel from '../channel.js';
import { StreamMessages } from '../types.js';
class RoleTimelineChannel extends Channel { class RoleTimelineChannel extends Channel {
public readonly chName = 'roleTimeline'; public readonly chName = 'roleTimeline';
@ -37,7 +37,7 @@ class RoleTimelineChannel extends Channel {
} }
@bindThis @bindThis
private async onEvent(data: StreamMessages['roleTimeline']['payload']) { private async onEvent(data: GlobalEvents['roleTimeline']['payload']) {
if (data.type === 'note') { if (data.type === 'note') {
const note = data.body; const note = data.body;

View File

@ -1,259 +0,0 @@
/*
* SPDX-FileCopyrightText: syuilo and other misskey contributors
* SPDX-License-Identifier: AGPL-3.0-only
*/
import type { MiChannel } from '@/models/Channel.js';
import type { MiUser } from '@/models/User.js';
import type { MiUserProfile } from '@/models/UserProfile.js';
import type { MiNote } from '@/models/Note.js';
import type { MiAntenna } from '@/models/Antenna.js';
import type { MiDriveFile } from '@/models/DriveFile.js';
import type { MiDriveFolder } from '@/models/DriveFolder.js';
import type { MiUserList } from '@/models/UserList.js';
import type { MiAbuseUserReport } from '@/models/AbuseUserReport.js';
import type { MiSignin } from '@/models/Signin.js';
import type { MiPage } from '@/models/Page.js';
import type { Packed } from '@/misc/json-schema.js';
import type { MiWebhook } from '@/models/Webhook.js';
import type { MiMeta } from '@/models/Meta.js';
import { MiRole, MiRoleAssignment } from '@/models/_.js';
import type Emitter from 'strict-event-emitter-types';
import type { EventEmitter } from 'events';
//#region Stream type-body definitions
export interface InternalStreamTypes {
userChangeSuspendedState: { id: MiUser['id']; isSuspended: MiUser['isSuspended']; };
userTokenRegenerated: { id: MiUser['id']; oldToken: string; newToken: string; };
remoteUserUpdated: { id: MiUser['id']; };
follow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
unfollow: { followerId: MiUser['id']; followeeId: MiUser['id']; };
blockingCreated: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
blockingDeleted: { blockerId: MiUser['id']; blockeeId: MiUser['id']; };
policiesUpdated: MiRole['policies'];
roleCreated: MiRole;
roleDeleted: MiRole;
roleUpdated: MiRole;
userRoleAssigned: MiRoleAssignment;
userRoleUnassigned: MiRoleAssignment;
webhookCreated: MiWebhook;
webhookDeleted: MiWebhook;
webhookUpdated: MiWebhook;
antennaCreated: MiAntenna;
antennaDeleted: MiAntenna;
antennaUpdated: MiAntenna;
metaUpdated: MiMeta;
followChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
unfollowChannel: { userId: MiUser['id']; channelId: MiChannel['id']; };
updateUserProfile: MiUserProfile;
mute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
unmute: { muterId: MiUser['id']; muteeId: MiUser['id']; };
}
export interface BroadcastTypes {
emojiAdded: {
emoji: Packed<'EmojiDetailed'>;
};
emojiUpdated: {
emojis: Packed<'EmojiDetailed'>[];
};
emojiDeleted: {
emojis: {
id?: string;
name: string;
[other: string]: any;
}[];
};
announcementCreated: {
announcement: Packed<'Announcement'>;
};
}
export interface MainStreamTypes {
notification: Packed<'Notification'>;
mention: Packed<'Note'>;
reply: Packed<'Note'>;
renote: Packed<'Note'>;
follow: Packed<'UserDetailedNotMe'>;
followed: Packed<'User'>;
unfollow: Packed<'User'>;
meUpdated: Packed<'User'>;
pageEvent: {
pageId: MiPage['id'];
event: string;
var: any;
userId: MiUser['id'];
user: Packed<'User'>;
};
urlUploadFinished: {
marker?: string | null;
file: Packed<'DriveFile'>;
};
readAllNotifications: undefined;
unreadNotification: Packed<'Notification'>;
unreadMention: MiNote['id'];
readAllUnreadMentions: undefined;
unreadSpecifiedNote: MiNote['id'];
readAllUnreadSpecifiedNotes: undefined;
readAllAntennas: undefined;
unreadAntenna: MiAntenna;
readAllAnnouncements: undefined;
myTokenRegenerated: undefined;
signin: MiSignin;
registryUpdated: {
scope?: string[];
key: string;
value: any | null;
};
driveFileCreated: Packed<'DriveFile'>;
readAntenna: MiAntenna;
receiveFollowRequest: Packed<'User'>;
announcementCreated: {
announcement: Packed<'Announcement'>;
};
}
export interface DriveStreamTypes {
fileCreated: Packed<'DriveFile'>;
fileDeleted: MiDriveFile['id'];
fileUpdated: Packed<'DriveFile'>;
folderCreated: Packed<'DriveFolder'>;
folderDeleted: MiDriveFolder['id'];
folderUpdated: Packed<'DriveFolder'>;
}
export interface NoteStreamTypes {
pollVoted: {
choice: number;
userId: MiUser['id'];
};
deleted: {
deletedAt: Date;
};
updated: {
cw: string | null;
text: string;
};
reacted: {
reaction: string;
emoji?: {
name: string;
url: string;
} | null;
userId: MiUser['id'];
};
unreacted: {
reaction: string;
userId: MiUser['id'];
};
}
type NoteStreamEventTypes = {
[key in keyof NoteStreamTypes]: {
id: MiNote['id'];
body: NoteStreamTypes[key];
};
};
export interface UserListStreamTypes {
userAdded: Packed<'User'>;
userRemoved: Packed<'User'>;
}
export interface AntennaStreamTypes {
note: MiNote;
}
export interface RoleTimelineStreamTypes {
note: Packed<'Note'>;
}
export interface AdminStreamTypes {
newAbuseUserReport: {
id: MiAbuseUserReport['id'];
targetUserId: MiUser['id'],
reporterId: MiUser['id'],
comment: string;
};
}
//#endregion
// 辞書(interface or type)から{ type, body }ユニオンを定義
// https://stackoverflow.com/questions/49311989/can-i-infer-the-type-of-a-value-using-extends-keyof-type
// VS Codeの展開を防止するためにEvents型を定義
type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } };
type EventUnionFromDictionary<
T extends object,
U = Events<T>
> = U[keyof U];
// redis通すとDateのインスタンスはstringに変換されるので
export type Serialized<T> = {
[K in keyof T]:
T[K] extends Date
? string
: T[K] extends (Date | null)
? (string | null)
: T[K] extends Record<string, any>
? Serialized<T[K]>
: T[K];
};
type SerializedAll<T> = {
[K in keyof T]: Serialized<T[K]>;
};
// name/messages(spec) pairs dictionary
export type StreamMessages = {
internal: {
name: 'internal';
payload: EventUnionFromDictionary<SerializedAll<InternalStreamTypes>>;
};
broadcast: {
name: 'broadcast';
payload: EventUnionFromDictionary<SerializedAll<BroadcastTypes>>;
};
main: {
name: `mainStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<MainStreamTypes>>;
};
drive: {
name: `driveStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<DriveStreamTypes>>;
};
note: {
name: `noteStream:${MiNote['id']}`;
payload: EventUnionFromDictionary<SerializedAll<NoteStreamEventTypes>>;
};
userList: {
name: `userListStream:${MiUserList['id']}`;
payload: EventUnionFromDictionary<SerializedAll<UserListStreamTypes>>;
};
roleTimeline: {
name: `roleTimelineStream:${MiRole['id']}`;
payload: EventUnionFromDictionary<SerializedAll<RoleTimelineStreamTypes>>;
};
antenna: {
name: `antennaStream:${MiAntenna['id']}`;
payload: EventUnionFromDictionary<SerializedAll<AntennaStreamTypes>>;
};
admin: {
name: `adminStream:${MiUser['id']}`;
payload: EventUnionFromDictionary<SerializedAll<AdminStreamTypes>>;
};
notes: {
name: 'notesStream';
payload: Serialized<Packed<'Note'>>;
};
};
// API event definitions
// ストリームごとのEmitterの辞書を用意
type EventEmitterDictionary = { [x in keyof StreamMessages]: Emitter.default<EventEmitter, { [y in StreamMessages[x]['name']]: (e: StreamMessages[x]['payload']) => void }> };
// 共用体型を交差型にする型 https://stackoverflow.com/questions/54938141/typescript-convert-union-to-intersection
type UnionToIntersection<U> = (U extends any ? (k: U) => void : never) extends ((k: infer I) => void) ? I : never;
// Emitter辞書から共用体型を作り、UnionToIntersectionで交差型にする
export type StreamEventEmitter = UnionToIntersection<EventEmitterDictionary[keyof StreamMessages]>;
// { [y in name]: (e: spec) => void }をまとめてその交差型をEmitterにかけるとts(2590)にひっかかる
// provide stream channels union
export type StreamChannels = StreamMessages[keyof StreamMessages]['name'];

View File

@ -203,3 +203,14 @@ export type ModerationLogPayloads = {
invitations: any[]; invitations: any[];
}; };
}; };
export type Serialized<T> = {
[K in keyof T]:
T[K] extends Date
? string
: T[K] extends (Date | null)
? (string | null)
: T[K] extends Record<string, any>
? Serialized<T[K]>
: T[K];
};

View File

@ -166,7 +166,7 @@ describe('ユーザー', () => {
unreadAnnouncements: user.unreadAnnouncements, unreadAnnouncements: user.unreadAnnouncements,
mutedWords: user.mutedWords, mutedWords: user.mutedWords,
mutedInstances: user.mutedInstances, mutedInstances: user.mutedInstances,
mutingNotificationTypes: user.mutingNotificationTypes, notificationRecieveConfig: user.notificationRecieveConfig,
emailNotificationTypes: user.emailNotificationTypes, emailNotificationTypes: user.emailNotificationTypes,
achievements: user.achievements, achievements: user.achievements,
loggedInDays: user.loggedInDays, loggedInDays: user.loggedInDays,
@ -414,7 +414,7 @@ describe('ユーザー', () => {
assert.deepStrictEqual(response.unreadAnnouncements, []); assert.deepStrictEqual(response.unreadAnnouncements, []);
assert.deepStrictEqual(response.mutedWords, []); assert.deepStrictEqual(response.mutedWords, []);
assert.deepStrictEqual(response.mutedInstances, []); assert.deepStrictEqual(response.mutedInstances, []);
assert.deepStrictEqual(response.mutingNotificationTypes, []); assert.deepStrictEqual(response.notificationRecieveConfig, {});
assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']); assert.deepStrictEqual(response.emailNotificationTypes, ['follow', 'receiveFollowRequest']);
assert.deepStrictEqual(response.achievements, []); assert.deepStrictEqual(response.achievements, []);
assert.deepStrictEqual(response.loggedInDays, 0); assert.deepStrictEqual(response.loggedInDays, 0);
@ -495,8 +495,8 @@ describe('ユーザー', () => {
{ parameters: (): object => ({ mutedWords: [] }) }, { parameters: (): object => ({ mutedWords: [] }) },
{ parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) }, { parameters: (): object => ({ mutedInstances: ['xxxx.xxxxx'] }) },
{ parameters: (): object => ({ mutedInstances: [] }) }, { parameters: (): object => ({ mutedInstances: [] }) },
{ parameters: (): object => ({ mutingNotificationTypes: ['note', 'follow', 'mention', 'reply', 'renote', 'quote', 'reaction', 'pollEnded', 'receiveFollowRequest', 'followRequestAccepted', 'achievementEarned', 'app'] }) }, { parameters: (): object => ({ notificationRecieveConfig: { mention: { type: 'following' } } }) },
{ parameters: (): object => ({ mutingNotificationTypes: [] }) }, { parameters: (): object => ({ notificationRecieveConfig: {} }) },
{ parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) }, { parameters: (): object => ({ emailNotificationTypes: ['mention', 'reply', 'quote', 'follow', 'receiveFollowRequest'] }) },
{ parameters: (): object => ({ emailNotificationTypes: [] }) }, { parameters: (): object => ({ emailNotificationTypes: [] }) },
] as const)('を書き換えることができる($#)', async ({ parameters }) => { ] as const)('を書き換えることができる($#)', async ({ parameters }) => {

View File

@ -0,0 +1,78 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="450"
:withOkButton="true"
:okButtonDisabled="false"
@ok="ok()"
@close="dialog?.close()"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.notificationSetting }}</template>
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps_m">
<MkInfo>{{ i18n.ts.notificationSettingDesc }}</MkInfo>
<div class="_buttons">
<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
</div>
<MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype].value">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch>
</div>
</MkSpacer>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref, Ref } from 'vue';
import MkSwitch from './MkSwitch.vue';
import MkInfo from './MkInfo.vue';
import MkButton from './MkButton.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { notificationTypes } from '@/const.js';
import { i18n } from '@/i18n.js';
type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>
const emit = defineEmits<{
(ev: 'done', v: { excludeTypes: string[] }): void,
(ev: 'closed'): void,
}>();
const props = withDefaults(defineProps<{
excludeTypes?: typeof notificationTypes[number][];
}>(), {
excludeTypes: () => [],
});
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
const typesMap: TypesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(!props.excludeTypes.includes(t)) }), {} as any);
function ok() {
emit('done', {
excludeTypes: (Object.keys(typesMap) as typeof notificationTypes[number][])
.filter(type => !typesMap[type].value),
});
if (dialog) dialog.close();
}
function disableAll() {
for (const type of notificationTypes) {
typesMap[type].value = false;
}
}
function enableAll() {
for (const type of notificationTypes) {
typesMap[type].value = true;
}
}
</script>

View File

@ -1,95 +0,0 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<MkModalWindow
ref="dialog"
:width="400"
:height="450"
:withOkButton="true"
:okButtonDisabled="false"
@ok="ok()"
@close="dialog?.close()"
@closed="emit('closed')"
>
<template #header>{{ i18n.ts.notificationSetting }}</template>
<MkSpacer :marginMin="20" :marginMax="28">
<div class="_gaps_m">
<template v-if="showGlobalToggle">
<MkSwitch v-model="useGlobalSetting">
{{ i18n.ts.useGlobalSetting }}
<template #caption>{{ i18n.ts.useGlobalSettingDesc }}</template>
</MkSwitch>
</template>
<template v-if="!useGlobalSetting">
<MkInfo>{{ i18n.ts.notificationSettingDesc }}</MkInfo>
<div class="_buttons">
<MkButton inline @click="disableAll">{{ i18n.ts.disableAll }}</MkButton>
<MkButton inline @click="enableAll">{{ i18n.ts.enableAll }}</MkButton>
</div>
<MkSwitch v-for="ntype in notificationTypes" :key="ntype" v-model="typesMap[ntype].value">{{ i18n.t(`_notification._types.${ntype}`) }}</MkSwitch>
</template>
</div>
</MkSpacer>
</MkModalWindow>
</template>
<script lang="ts" setup>
import { ref, Ref } from 'vue';
import MkSwitch from './MkSwitch.vue';
import MkInfo from './MkInfo.vue';
import MkButton from './MkButton.vue';
import MkModalWindow from '@/components/MkModalWindow.vue';
import { notificationTypes } from '@/const';
import { i18n } from '@/i18n.js';
type TypesMap = Record<typeof notificationTypes[number], Ref<boolean>>
const emit = defineEmits<{
(ev: 'done', v: { includingTypes: string[] | null }): void,
(ev: 'closed'): void,
}>();
const props = withDefaults(defineProps<{
includingTypes?: typeof notificationTypes[number][] | null;
showGlobalToggle?: boolean;
}>(), {
includingTypes: () => [],
showGlobalToggle: true,
});
let includingTypes = $computed(() => props.includingTypes?.filter(x => notificationTypes.includes(x)) ?? []);
const dialog = $shallowRef<InstanceType<typeof MkModalWindow>>();
const typesMap: TypesMap = notificationTypes.reduce((p, t) => ({ ...p, [t]: ref<boolean>(includingTypes.includes(t)) }), {} as any);
let useGlobalSetting = $ref((includingTypes === null || includingTypes.length === 0) && props.showGlobalToggle);
function ok() {
if (useGlobalSetting) {
emit('done', { includingTypes: null });
} else {
emit('done', {
includingTypes: (Object.keys(typesMap) as typeof notificationTypes[number][])
.filter(type => typesMap[type].value),
});
}
if (dialog) dialog.close();
}
function disableAll() {
for (const type of notificationTypes) {
typesMap[type].value = false;
}
}
function enableAll() {
for (const type of notificationTypes) {
typesMap[type].value = true;
}
}
</script>

View File

@ -30,11 +30,11 @@ import MkNote from '@/components/MkNote.vue';
import { useStream } from '@/stream.js'; import { useStream } from '@/stream.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { notificationTypes } from '@/const'; import { notificationTypes } from '@/const.js';
import { infoImageUrl } from '@/instance.js'; import { infoImageUrl } from '@/instance.js';
const props = defineProps<{ const props = defineProps<{
includeTypes?: typeof notificationTypes[number][]; excludeTypes?: typeof notificationTypes[number][];
}>(); }>();
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
@ -43,13 +43,12 @@ const pagination: Paging = {
endpoint: 'i/notifications' as const, endpoint: 'i/notifications' as const,
limit: 10, limit: 10,
params: computed(() => ({ params: computed(() => ({
includeTypes: props.includeTypes ?? undefined, excludeTypes: props.excludeTypes ?? undefined,
excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes,
})), })),
}; };
const onNotification = (notification) => { const onNotification = (notification) => {
const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type); const isMuted = props.excludeTypes ? props.excludeTypes.includes(notification.type) : false;
if (isMuted || document.visibilityState === 'visible') { if (isMuted || document.visibilityState === 'visible') {
useStream().send('readNotification'); useStream().send('readNotification');
} }

View File

@ -8,7 +8,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template> <template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
<MkSpacer :contentMax="800"> <MkSpacer :contentMax="800">
<div v-if="tab === 'all'"> <div v-if="tab === 'all'">
<XNotifications class="notifications" :includeTypes="includeTypes"/> <XNotifications class="notifications" :excludeTypes="excludeTypes"/>
</div> </div>
<div v-else-if="tab === 'mentions'"> <div v-else-if="tab === 'mentions'">
<MkNotes :pagination="mentionsPagination"/> <MkNotes :pagination="mentionsPagination"/>
@ -27,10 +27,11 @@ import MkNotes from '@/components/MkNotes.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import { notificationTypes } from '@/const'; import { notificationTypes } from '@/const.js';
let tab = $ref('all'); let tab = $ref('all');
let includeTypes = $ref<string[] | null>(null); let includeTypes = $ref<string[] | null>(null);
const excludeTypes = $computed(() => includeTypes ? notificationTypes.filter(t => !includeTypes.includes(t)) : null);
const mentionsPagination = { const mentionsPagination = {
endpoint: 'notes/mentions' as const, endpoint: 'notes/mentions' as const,

View File

@ -0,0 +1,50 @@
<!--
SPDX-FileCopyrightText: syuilo and other misskey contributors
SPDX-License-Identifier: AGPL-3.0-only
-->
<template>
<div class="_gaps_m">
<MkSelect v-model="type">
<option value="all">{{ i18n.ts.all }}</option>
<option value="following">{{ i18n.ts.following }}</option>
<option value="follower">{{ i18n.ts.followers }}</option>
<option value="mutualFollow">{{ i18n.ts.mutualFollow }}</option>
<option value="list">{{ i18n.ts.userList }}</option>
<option value="never">{{ i18n.ts.none }}</option>
</MkSelect>
<MkSelect v-if="type === 'list'" v-model="userListId">
<template #label>{{ i18n.ts.userList }}</template>
<option v-for="list in props.userLists" :key="list.id" :value="list.id">{{ list.name }}</option>
</MkSelect>
<div class="_buttons">
<MkButton inline primary @click="save"><i class="ti ti-check"></i> {{ i18n.ts.save }}</MkButton>
</div>
</div>
</template>
<script lang="ts" setup>
import { } from 'vue';
import * as Misskey from 'misskey-js';
import MkSelect from '@/components/MkSelect.vue';
import MkButton from '@/components/MkButton.vue';
import { i18n } from '@/i18n.js';
const props = defineProps<{
value: any;
userLists: Misskey.entities.UserList[];
}>();
const emit = defineEmits<{
(ev: 'update', result: any): void;
}>();
let type = $ref(props.value.type);
let userListId = $ref(props.value.userListId);
function save() {
emit('update', { type, userListId });
}
</script>

View File

@ -5,7 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only
<template> <template>
<div class="_gaps_m"> <div class="_gaps_m">
<FormLink @click="configure"><template #icon><i class="ti ti-settings"></i></template>{{ i18n.ts.notificationSetting }}</FormLink> <FormSection first>
<template #label>{{ i18n.ts.notificationRecieveConfig }}</template>
<div class="_gaps_s">
<MkFolder v-for="type in notificationTypes" :key="type">
<template #label>{{ i18n.t('_notification._types.' + type) }}</template>
<template #suffix>
{{
$i.notificationRecieveConfig[type]?.type === 'never' ? i18n.ts.none :
$i.notificationRecieveConfig[type]?.type === 'following' ? i18n.ts.following :
$i.notificationRecieveConfig[type]?.type === 'follower' ? i18n.ts.followers :
$i.notificationRecieveConfig[type]?.type === 'mutualFollow' ? i18n.ts.mutualFollow :
$i.notificationRecieveConfig[type]?.type === 'list' ? i18n.ts.userList :
i18n.ts.all
}}
</template>
<XNotificationConfig :userLists="userLists" :value="$i.notificationRecieveConfig[type] ?? { type: 'all' }" @update="(res) => updateReceiveConfig(type, res)"/>
</MkFolder>
</div>
</FormSection>
<FormSection> <FormSection>
<div class="_gaps_m"> <div class="_gaps_m">
<FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink> <FormLink @click="readAllNotifications">{{ i18n.ts.markAsReadAllNotifications }}</FormLink>
@ -37,19 +56,22 @@ SPDX-License-Identifier: AGPL-3.0-only
<script lang="ts" setup> <script lang="ts" setup>
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from 'vue';
import XNotificationConfig from './notifications.notification-config.vue';
import FormLink from '@/components/form/link.vue'; import FormLink from '@/components/form/link.vue';
import FormSection from '@/components/form/section.vue'; import FormSection from '@/components/form/section.vue';
import MkFolder from '@/components/MkFolder.vue';
import MkSwitch from '@/components/MkSwitch.vue'; import MkSwitch from '@/components/MkSwitch.vue';
import * as os from '@/os.js'; import * as os from '@/os.js';
import { $i } from '@/account.js'; import { $i } from '@/account.js';
import { i18n } from '@/i18n.js'; import { i18n } from '@/i18n.js';
import { definePageMetadata } from '@/scripts/page-metadata.js'; import { definePageMetadata } from '@/scripts/page-metadata.js';
import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue'; import MkPushNotificationAllowButton from '@/components/MkPushNotificationAllowButton.vue';
import { notificationTypes } from '@/const'; import { notificationTypes } from '@/const.js';
let allowButton = $shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>(); let allowButton = $shallowRef<InstanceType<typeof MkPushNotificationAllowButton>>();
let pushRegistrationInServer = $computed(() => allowButton?.pushRegistrationInServer); let pushRegistrationInServer = $computed(() => allowButton?.pushRegistrationInServer);
let sendReadMessage = $computed(() => pushRegistrationInServer?.sendReadMessage || false); let sendReadMessage = $computed(() => pushRegistrationInServer?.sendReadMessage || false);
const userLists = await os.api('users/lists/list');
async function readAllUnreadNotes() { async function readAllUnreadNotes() {
await os.api('i/read-all-unread-notes'); await os.api('i/read-all-unread-notes');
@ -59,21 +81,15 @@ async function readAllNotifications() {
await os.api('notifications/mark-all-as-read'); await os.api('notifications/mark-all-as-read');
} }
function configure() { async function updateReceiveConfig(type, value) {
const includingTypes = notificationTypes.filter(x => !$i!.mutingNotificationTypes.includes(x)); await os.apiWithDialog('i/update', {
os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { notificationRecieveConfig: {
includingTypes, ...$i!.notificationRecieveConfig,
showGlobalToggle: false, [type]: value,
}, {
done: async (res) => {
const { includingTypes: value } = res;
await os.apiWithDialog('i/update', {
mutingNotificationTypes: notificationTypes.filter(x => !value.includes(x)),
}).then(i => {
$i!.mutingNotificationTypes = i.mutingNotificationTypes;
});
}, },
}, 'closed'); }).then(i => {
$i!.notificationRecieveConfig = i.notificationRecieveConfig;
});
} }
function onChangeSendReadMessage(v: boolean) { function onChangeSendReadMessage(v: boolean) {

View File

@ -65,9 +65,7 @@ const dev = _DEV_;
let notifications = $ref<Misskey.entities.Notification[]>([]); let notifications = $ref<Misskey.entities.Notification[]>([]);
function onNotification(notification: Misskey.entities.Notification, isClient: boolean = false) { function onNotification(notification: Misskey.entities.Notification, isClient = false) {
if ($i.mutingNotificationTypes.includes(notification.type)) return;
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
if (!isClient) { if (!isClient) {
useStream().send('readNotification'); useStream().send('readNotification');

View File

@ -28,7 +28,7 @@ export type Column = {
listId?: string; listId?: string;
channelId?: string; channelId?: string;
roleId?: string; roleId?: string;
includingTypes?: typeof notificationTypes[number][]; excludeTypes?: typeof notificationTypes[number][];
tl?: 'home' | 'local' | 'social' | 'global'; tl?: 'home' | 'local' | 'social' | 'global';
withRenotes?: boolean; withRenotes?: boolean;
withReplies?: boolean; withReplies?: boolean;

View File

@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<XColumn :column="column" :isStacked="isStacked" :menu="menu"> <XColumn :column="column" :isStacked="isStacked" :menu="menu">
<template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template> <template #header><i class="ti ti-bell" style="margin-right: 8px;"></i>{{ column.name }}</template>
<XNotifications :includeTypes="column.includingTypes"/> <XNotifications :excludeTypes="props.column.excludeTypes"/>
</XColumn> </XColumn>
</template> </template>
@ -25,13 +25,13 @@ const props = defineProps<{
}>(); }>();
function func() { function func() {
os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), {
includingTypes: props.column.includingTypes, excludeTypes: props.column.excludeTypes,
}, { }, {
done: async (res) => { done: async (res) => {
const { includingTypes } = res; const { excludeTypes } = res;
updateColumn(props.column.id, { updateColumn(props.column.id, {
includingTypes: includingTypes, excludeTypes: excludeTypes,
}); });
}, },
}, 'closed'); }, 'closed');

View File

@ -10,7 +10,7 @@ SPDX-License-Identifier: AGPL-3.0-only
<template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configureNotification()"><i class="ti ti-settings"></i></button></template> <template #func="{ buttonStyleClass }"><button class="_button" :class="buttonStyleClass" @click="configureNotification()"><i class="ti ti-settings"></i></button></template>
<div> <div>
<XNotifications :includeTypes="widgetProps.includingTypes"/> <XNotifications :excludeTypes="widgetProps.excludeTypes"/>
</div> </div>
</MkContainer> </MkContainer>
</template> </template>
@ -35,10 +35,10 @@ const widgetPropsDef = {
type: 'number' as const, type: 'number' as const,
default: 300, default: 300,
}, },
includingTypes: { excludeTypes: {
type: 'array' as const, type: 'array' as const,
hidden: true, hidden: true,
default: null, default: [],
}, },
}; };
@ -54,12 +54,12 @@ const { widgetProps, configure, save } = useWidgetPropsManager(name,
); );
const configureNotification = () => { const configureNotification = () => {
os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSettingWindow.vue')), { os.popup(defineAsyncComponent(() => import('@/components/MkNotificationSelectWindow.vue')), {
includingTypes: widgetProps.includingTypes, excludeTypes: widgetProps.excludeTypes,
}, { }, {
done: async (res) => { done: async (res) => {
const { includingTypes } = res; const { excludeTypes } = res;
widgetProps.includingTypes = includingTypes; widgetProps.excludeTypes = excludeTypes;
save(); save();
}, },
}, 'closed'); }, 'closed');

View File

@ -1545,7 +1545,7 @@ export type Endpoints = {
receiveAnnouncementEmail?: boolean; receiveAnnouncementEmail?: boolean;
alwaysMarkNsfw?: boolean; alwaysMarkNsfw?: boolean;
mutedWords?: string[][]; mutedWords?: string[][];
mutingNotificationTypes?: Notification_2['type'][]; notificationRecieveConfig?: any;
emailNotificationTypes?: string[]; emailNotificationTypes?: string[];
alsoKnownAs?: string[]; alsoKnownAs?: string[];
}; };
@ -2475,7 +2475,22 @@ type MeDetailed = UserDetailed & {
isDeleted: boolean; isDeleted: boolean;
isExplorable: boolean; isExplorable: boolean;
mutedWords: string[][]; mutedWords: string[][];
mutingNotificationTypes: string[]; notificationRecieveConfig: {
[notificationType in typeof notificationTypes_2[number]]?: {
type: 'all';
} | {
type: 'never';
} | {
type: 'following';
} | {
type: 'follower';
} | {
type: 'mutualFollow';
} | {
type: 'list';
userListId: string;
};
};
noCrawle: boolean; noCrawle: boolean;
receiveAnnouncementEmail: boolean; receiveAnnouncementEmail: boolean;
usePasswordLessLogin: boolean; usePasswordLessLogin: boolean;
@ -2958,7 +2973,8 @@ type UserSorting = '+follower' | '-follower' | '+createdAt' | '-createdAt' | '+u
// src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts // src/api.types.ts:16:32 - (ae-forgotten-export) The symbol "TODO" needs to be exported by the entry point index.d.ts
// src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts // src/api.types.ts:18:25 - (ae-forgotten-export) The symbol "NoParams" needs to be exported by the entry point index.d.ts
// src/api.types.ts:631:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts // src/api.types.ts:631:18 - (ae-forgotten-export) The symbol "ShowUserReq" needs to be exported by the entry point index.d.ts
// src/entities.ts:580:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts // src/entities.ts:107:2 - (ae-forgotten-export) The symbol "notificationTypes_2" needs to be exported by the entry point index.d.ts
// src/entities.ts:595:2 - (ae-forgotten-export) The symbol "ModerationLogPayloads" needs to be exported by the entry point index.d.ts
// src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts // src/streaming.types.ts:33:4 - (ae-forgotten-export) The symbol "FIXME" needs to be exported by the entry point index.d.ts
// (No @packageDocumentation comment for this package) // (No @packageDocumentation comment for this package)

View File

@ -430,7 +430,7 @@ export type Endpoints = {
receiveAnnouncementEmail?: boolean; receiveAnnouncementEmail?: boolean;
alwaysMarkNsfw?: boolean; alwaysMarkNsfw?: boolean;
mutedWords?: string[][]; mutedWords?: string[][];
mutingNotificationTypes?: Notification['type'][]; notificationRecieveConfig?: any;
emailNotificationTypes?: string[]; emailNotificationTypes?: string[];
alsoKnownAs?: string[]; alsoKnownAs?: string[];
}; res: MeDetailed; }; }; res: MeDetailed; };

View File

@ -1,4 +1,4 @@
import { ModerationLogPayloads } from './consts.js'; import { ModerationLogPayloads, notificationTypes } from './consts.js';
export type ID = string; export type ID = string;
export type DateString = string; export type DateString = string;
@ -104,7 +104,22 @@ export type MeDetailed = UserDetailed & {
isDeleted: boolean; isDeleted: boolean;
isExplorable: boolean; isExplorable: boolean;
mutedWords: string[][]; mutedWords: string[][];
mutingNotificationTypes: string[]; notificationRecieveConfig: {
[notificationType in typeof notificationTypes[number]]?: {
type: 'all';
} | {
type: 'never';
} | {
type: 'following';
} | {
type: 'follower';
} | {
type: 'mutualFollow';
} | {
type: 'list';
userListId: string;
};
};
noCrawle: boolean; noCrawle: boolean;
receiveAnnouncementEmail: boolean; receiveAnnouncementEmail: boolean;
usePasswordLessLogin: boolean; usePasswordLessLogin: boolean;