perf(backend): 通知をRedisに保存するように

Resolve #10168
This commit is contained in:
syuilo 2023-04-04 14:06:57 +09:00
parent 38d0b62167
commit 30d6992684
29 changed files with 185 additions and 613 deletions

View File

@ -34,6 +34,7 @@
- ノート作成時のパフォーマンスを向上 - ノート作成時のパフォーマンスを向上
- アンテナのタイムライン取得時のパフォーマンスを向上 - アンテナのタイムライン取得時のパフォーマンスを向上
- チャンネルのタイムライン取得時のパフォーマンスを向上 - チャンネルのタイムライン取得時のパフォーマンスを向上
- 通知に関する全体的なパフォーマンスを向上
## 13.10.3 ## 13.10.3

View File

@ -0,0 +1,11 @@
export class cleanup1680582195041 {
name = 'cleanup1680582195041'
async up(queryRunner) {
await queryRunner.query(`DROP TABLE "notification" `);
}
async down(queryRunner) {
}
}

View File

@ -169,10 +169,6 @@ export class NoteReadService implements OnApplicationShutdown {
this.globalEventService.publishMainStream(userId, 'readAllChannels'); this.globalEventService.publishMainStream(userId, 'readAllChannels');
} }
}); });
this.notificationService.readNotificationByQuery(userId, {
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
});
} }
} }

View File

@ -1,8 +1,9 @@
import { setTimeout } from 'node:timers/promises'; import { setTimeout } from 'node:timers/promises';
import Redis from 'ioredis';
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common'; import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
import { In } from 'typeorm'; import { In } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { MutingsRepository, NotificationsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js'; import type { MutingsRepository, UserProfilesRepository, UsersRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js'; import type { User } from '@/models/entities/User.js';
import type { Notification } from '@/models/entities/Notification.js'; import type { Notification } from '@/models/entities/Notification.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js';
@ -17,15 +18,15 @@ export class NotificationService implements OnApplicationShutdown {
#shutdownController = new AbortController(); #shutdownController = new AbortController();
constructor( constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
@Inject(DI.notificationsRepository)
private notificationsRepository: NotificationsRepository,
@Inject(DI.mutingsRepository) @Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository, private mutingsRepository: MutingsRepository,
@ -38,50 +39,31 @@ export class NotificationService implements OnApplicationShutdown {
} }
@bindThis @bindThis
public async readNotification( public async readAllNotification(
userId: User['id'], userId: User['id'],
notificationIds: Notification['id'][],
) { ) {
if (notificationIds.length === 0) return; const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
// Update documents const latestNotificationIdsRes = await this.redisClient.xrevrange(
const result = await this.notificationsRepository.update({ `notificationTimeline:${userId}`,
notifieeId: userId, '+',
id: In(notificationIds), '-',
isRead: false, 'COUNT', 1);
}, { console.log('latestNotificationIdsRes', latestNotificationIdsRes);
isRead: true, const latestNotificationId = latestNotificationIdsRes[0]?.[0];
});
if (result.affected === 0) return; if (latestNotificationId == null) return;
if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId); this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId);
else return this.postReadNotifications(userId, notificationIds);
}
@bindThis if (latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) {
public async readNotificationByQuery( return this.postReadAllNotifications(userId);
userId: User['id'], }
query: Record<string, any>,
) {
const notificationIds = await this.notificationsRepository.findBy({
...query,
notifieeId: userId,
isRead: false,
}).then(notifications => notifications.map(notification => notification.id));
return this.readNotification(userId, notificationIds);
} }
@bindThis @bindThis
private postReadAllNotifications(userId: User['id']) { private postReadAllNotifications(userId: User['id']) {
this.globalEventService.publishMainStream(userId, 'readAllNotifications'); this.globalEventService.publishMainStream(userId, 'readAllNotifications');
return this.pushNotificationService.pushNotification(userId, 'readAllNotifications', undefined);
}
@bindThis
private postReadNotifications(userId: User['id'], notificationIds: Notification['id'][]) {
return this.pushNotificationService.pushNotification(userId, 'readNotifications', { notificationIds });
} }
@bindThis @bindThis
@ -90,47 +72,48 @@ export class NotificationService implements OnApplicationShutdown {
type: Notification['type'], type: Notification['type'],
data: Partial<Notification>, data: Partial<Notification>,
): Promise<Notification | null> { ): Promise<Notification | null> {
if (data.notifierId && (notifieeId === data.notifierId)) { // TODO: Cache
return null; const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId });
const isMuted = profile?.mutingNotificationTypes.includes(type);
if (isMuted) return null;
if (data.notifierId) {
if (notifieeId === data.notifierId) {
return null;
}
// TODO: cache
const mutings = await this.mutingsRepository.findOneBy({
muterId: notifieeId,
muteeId: data.notifierId,
});
if (mutings) {
return null;
}
} }
const profile = await this.userProfilesRepository.findOneBy({ userId: notifieeId }); const notification = {
// TODO: Cache
const isMuted = profile?.mutingNotificationTypes.includes(type);
// Create notification
const notification = await this.notificationsRepository.insert({
id: this.idService.genId(), id: this.idService.genId(),
createdAt: new Date(), createdAt: new Date(),
notifieeId: notifieeId,
type: type, type: type,
// 相手がこの通知をミュートしているようなら、既読を予めつけておく
isRead: isMuted,
...data, ...data,
} as Partial<Notification>) } as Notification;
.then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0]));
const packed = await this.notificationEntityService.pack(notification, {}); this.redisClient.xadd(
`notificationTimeline:${notifieeId}`,
'MAXLEN', '~', '300',
`${this.idService.parse(notification.id).date.getTime()}-*`,
'data', JSON.stringify(notification));
const packed = await this.notificationEntityService.pack(notification, notifieeId, {});
// Publish notification event // Publish notification event
this.globalEventService.publishMainStream(notifieeId, 'notification', packed); this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する // 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => { setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => {
const fresh = await this.notificationsRepository.findOneBy({ id: notification.id }); const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`);
if (fresh == null) return; // 既に削除されているかもしれない if (latestReadNotificationId && (latestReadNotificationId >= notification.id)) return;
if (fresh.isRead) return;
//#region ただしミュートしているユーザーからの通知なら無視
// TODO: Cache
const mutings = await this.mutingsRepository.findBy({
muterId: notifieeId,
});
if (data.notifierId && mutings.map(m => m.muteeId).includes(data.notifierId)) {
return;
}
//#endregion
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed); this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed); this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);

View File

@ -15,10 +15,6 @@ type PushNotificationsTypes = {
antenna: { id: string, name: string }; antenna: { id: string, name: string };
note: Packed<'Note'>; note: Packed<'Note'>;
}; };
'readNotifications': { notificationIds: string[] };
'readAllNotifications': undefined;
'readAntenna': { antennaId: string };
'readAllAntennas': undefined;
}; };
// Reduce length because push message servers have character limits // Reduce length because push message servers have character limits
@ -72,14 +68,6 @@ export class PushNotificationService {
}); });
for (const subscription of subscriptions) { for (const subscription of subscriptions) {
// Continue if sendReadMessage is false
if ([
'readNotifications',
'readAllNotifications',
'readAntenna',
'readAllAntennas',
].includes(type) && !subscription.sendReadMessage) continue;
const pushSubscription = { const pushSubscription = {
endpoint: subscription.endpoint, endpoint: subscription.endpoint,
keys: { keys: {

View File

@ -1,7 +1,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { In } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { AccessTokensRepository, NoteReactionsRepository, NotificationsRepository, User } from '@/models/index.js'; import type { AccessTokensRepository, NoteReactionsRepository, NotesRepository, User, UsersRepository } from '@/models/index.js';
import { awaitAll } from '@/misc/prelude/await-all.js'; import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Notification } from '@/models/entities/Notification.js'; import type { Notification } from '@/models/entities/Notification.js';
import type { Note } from '@/models/entities/Note.js'; import type { Note } from '@/models/entities/Note.js';
@ -25,8 +26,11 @@ export class NotificationEntityService implements OnModuleInit {
constructor( constructor(
private moduleRef: ModuleRef, private moduleRef: ModuleRef,
@Inject(DI.notificationsRepository) @Inject(DI.notesRepository)
private notificationsRepository: NotificationsRepository, private notesRepository: NotesRepository,
@Inject(DI.usersRepository)
private usersRepository: UsersRepository,
@Inject(DI.noteReactionsRepository) @Inject(DI.noteReactionsRepository)
private noteReactionsRepository: NoteReactionsRepository, private noteReactionsRepository: NoteReactionsRepository,
@ -48,30 +52,39 @@ export class NotificationEntityService implements OnModuleInit {
@bindThis @bindThis
public async pack( public async pack(
src: Notification['id'] | Notification, src: Notification,
meId: User['id'],
options: { options: {
_hint_?: {
packedNotes: Map<Note['id'], Packed<'Note'>>; },
}; hint?: {
packedNotes: Map<Note['id'], Packed<'Note'>>;
packedUsers: Map<User['id'], Packed<'User'>>;
}, },
): Promise<Packed<'Notification'>> { ): Promise<Packed<'Notification'>> {
const notification = typeof src === 'object' ? src : await this.notificationsRepository.findOneByOrFail({ id: src }); const notification = src;
const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null; const token = notification.appAccessTokenId ? await this.accessTokensRepository.findOneByOrFail({ id: notification.appAccessTokenId }) : null;
const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? ( const noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? (
options._hint_?.packedNotes != null hint?.packedNotes != null
? options._hint_.packedNotes.get(notification.noteId) ? hint.packedNotes.get(notification.noteId)
: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, { : this.noteEntityService.pack(notification.noteId!, { id: meId }, {
detail: true, detail: true,
}) })
) : undefined; ) : undefined;
const userIfNeed = notification.notifierId != null ? (
hint?.packedUsers != null
? hint.packedUsers.get(notification.notifierId)
: this.userEntityService.pack(notification.notifierId!, { id: meId }, {
detail: false,
})
) : undefined;
return await awaitAll({ return await awaitAll({
id: notification.id, id: notification.id,
createdAt: notification.createdAt.toISOString(), createdAt: new Date(notification.createdAt).toISOString(),
type: notification.type, type: notification.type,
isRead: notification.isRead,
userId: notification.notifierId, userId: notification.notifierId,
user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null, ...(userIfNeed != null ? { user: userIfNeed } : {}),
...(noteIfNeed != null ? { note: noteIfNeed } : {}), ...(noteIfNeed != null ? { note: noteIfNeed } : {}),
...(notification.type === 'reaction' ? { ...(notification.type === 'reaction' ? {
reaction: notification.reaction, reaction: notification.reaction,
@ -87,9 +100,6 @@ export class NotificationEntityService implements OnModuleInit {
}); });
} }
/**
* @param notifications you should join "note" property when fetch from DB, and all notifieeId should be same as meId
*/
@bindThis @bindThis
public async packMany( public async packMany(
notifications: Notification[], notifications: Notification[],
@ -97,23 +107,29 @@ export class NotificationEntityService implements OnModuleInit {
) { ) {
if (notifications.length === 0) return []; if (notifications.length === 0) return [];
for (const notification of notifications) { const noteIds = notifications.map(x => x.noteId).filter(isNotNull);
if (meId !== notification.notifieeId) { const notes = noteIds.length > 0 ? await this.notesRepository.find({
// because we call note packMany with meId, all notifieeId should be same as meId where: { id: In(noteIds) },
throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION'); relations: ['user', 'user.avatar', 'user.banner', 'reply', 'reply.user', 'reply.user.avatar', 'reply.user.banner', 'renote', 'renote.user', 'renote.user.avatar', 'renote.user.banner'],
} }) : [];
}
const notes = notifications.map(x => x.note).filter(isNotNull);
const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, { const packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
detail: true, detail: true,
}); });
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p])); const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
return await Promise.all(notifications.map(x => this.pack(x, { const userIds = notifications.map(x => x.notifierId).filter(isNotNull);
_hint_: { const users = userIds.length > 0 ? await this.usersRepository.find({
packedNotes, where: { id: In(userIds) },
}, relations: ['avatar', 'banner'],
}) : [];
const packedUsersArray = await this.userEntityService.packMany(users, { id: meId }, {
detail: false,
});
const packedUsers = new Map(packedUsersArray.map(p => [p.id, p]));
return await Promise.all(notifications.map(x => this.pack(x, meId, {}, {
packedNotes,
packedUsers,
}))); })));
} }
} }

View File

@ -1,5 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { In, Not } from 'typeorm'; import { In, Not } from 'typeorm';
import Redis from 'ioredis';
import Ajv from 'ajv'; import Ajv from 'ajv';
import { ModuleRef } from '@nestjs/core'; import { ModuleRef } from '@nestjs/core';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
@ -12,7 +13,7 @@ import { KVCache } from '@/misc/cache.js';
import type { Instance } from '@/models/entities/Instance.js'; import type { Instance } from '@/models/entities/Instance.js';
import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js';
import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js';
import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.js'; import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, AnnouncementsRepository, PagesRepository, UserProfile, RenoteMutingsRepository } from '@/models/index.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 { OnModuleInit } from '@nestjs/common'; import type { OnModuleInit } from '@nestjs/common';
@ -60,6 +61,9 @@ export class UserEntityService implements OnModuleInit {
@Inject(DI.config) @Inject(DI.config)
private config: Config, private config: Config,
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@ -90,9 +94,6 @@ export class UserEntityService implements OnModuleInit {
@Inject(DI.channelFollowingsRepository) @Inject(DI.channelFollowingsRepository)
private channelFollowingsRepository: ChannelFollowingsRepository, private channelFollowingsRepository: ChannelFollowingsRepository,
@Inject(DI.notificationsRepository)
private notificationsRepository: NotificationsRepository,
@Inject(DI.userNotePiningsRepository) @Inject(DI.userNotePiningsRepository)
private userNotePiningsRepository: UserNotePiningsRepository, private userNotePiningsRepository: UserNotePiningsRepository,
@ -247,21 +248,17 @@ export class UserEntityService implements OnModuleInit {
@bindThis @bindThis
public async getHasUnreadNotification(userId: User['id']): Promise<boolean> { public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
const mute = await this.mutingsRepository.findBy({ const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
muterId: userId,
});
const mutedUserIds = mute.map(m => m.muteeId);
const count = await this.notificationsRepository.count({ const latestNotificationIdsRes = await this.redisClient.xrevrange(
where: { `notificationTimeline:${userId}`,
notifieeId: userId, '+',
...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}), '-',
isRead: false, 'COUNT', 1);
}, console.log('latestNotificationIdsRes', latestNotificationIdsRes);
take: 1, const latestNotificationId = latestNotificationIdsRes[0]?.[0];
});
return count > 0; return latestNotificationId != null && (latestReadNotificationId == null || latestReadNotificationId < latestNotificationId);
} }
@bindThis @bindThis

View File

@ -33,7 +33,6 @@ export const DI = {
emojisRepository: Symbol('emojisRepository'), emojisRepository: Symbol('emojisRepository'),
driveFilesRepository: Symbol('driveFilesRepository'), driveFilesRepository: Symbol('driveFilesRepository'),
driveFoldersRepository: Symbol('driveFoldersRepository'), driveFoldersRepository: Symbol('driveFoldersRepository'),
notificationsRepository: Symbol('notificationsRepository'),
metasRepository: Symbol('metasRepository'), metasRepository: Symbol('metasRepository'),
mutingsRepository: Symbol('mutingsRepository'), mutingsRepository: Symbol('mutingsRepository'),
renoteMutingsRepository: Symbol('renoteMutingsRepository'), renoteMutingsRepository: Symbol('renoteMutingsRepository'),

View File

@ -1,6 +1,6 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js'; import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, RenoteMuting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelFavorite, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment, ClipFavorite } from './index.js';
import type { DataSource } from 'typeorm'; import type { DataSource } from 'typeorm';
import type { Provider } from '@nestjs/common'; import type { Provider } from '@nestjs/common';
@ -172,12 +172,6 @@ const $driveFoldersRepository: Provider = {
inject: [DI.db], inject: [DI.db],
}; };
const $notificationsRepository: Provider = {
provide: DI.notificationsRepository,
useFactory: (db: DataSource) => db.getRepository(Notification),
inject: [DI.db],
};
const $metasRepository: Provider = { const $metasRepository: Provider = {
provide: DI.metasRepository, provide: DI.metasRepository,
useFactory: (db: DataSource) => db.getRepository(Meta), useFactory: (db: DataSource) => db.getRepository(Meta),
@ -426,7 +420,6 @@ const $roleAssignmentsRepository: Provider = {
$emojisRepository, $emojisRepository,
$driveFilesRepository, $driveFilesRepository,
$driveFoldersRepository, $driveFoldersRepository,
$notificationsRepository,
$metasRepository, $metasRepository,
$mutingsRepository, $mutingsRepository,
$renoteMutingsRepository, $renoteMutingsRepository,
@ -493,7 +486,6 @@ const $roleAssignmentsRepository: Provider = {
$emojisRepository, $emojisRepository,
$driveFilesRepository, $driveFilesRepository,
$driveFoldersRepository, $driveFoldersRepository,
$notificationsRepository,
$metasRepository, $metasRepository,
$mutingsRepository, $mutingsRepository,
$renoteMutingsRepository, $renoteMutingsRepository,

View File

@ -1,54 +1,19 @@
import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm'; import { notificationTypes } from '@/types.js';
import { notificationTypes, obsoleteNotificationTypes } from '@/types.js';
import { id } from '../id.js';
import { User } from './User.js'; import { User } from './User.js';
import { Note } from './Note.js'; import { Note } from './Note.js';
import { FollowRequest } from './FollowRequest.js'; import { FollowRequest } from './FollowRequest.js';
import { AccessToken } from './AccessToken.js'; import { AccessToken } from './AccessToken.js';
@Entity() export type Notification = {
export class Notification { id: string;
@PrimaryColumn(id())
public id: string;
@Index() // RedisのためDateではなくstring
@Column('timestamp with time zone', { createdAt: string;
comment: 'The created date of the Notification.',
})
public createdAt: Date;
/**
*
*/
@Index()
@Column({
...id(),
comment: 'The ID of recipient user of the Notification.',
})
public notifieeId: User['id'];
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public notifiee: User | null;
/** /**
* (initiator) * (initiator)
*/ */
@Index() notifierId: User['id'] | null;
@Column({
...id(),
nullable: true,
comment: 'The ID of sender user of the Notification.',
})
public notifierId: User['id'] | null;
@ManyToOne(type => User, {
onDelete: 'CASCADE',
})
@JoinColumn()
public notifier: User | null;
/** /**
* *
@ -64,104 +29,37 @@ export class Notification {
* achievementEarned - * achievementEarned -
* app - * app -
*/ */
@Index() type: typeof notificationTypes[number];
@Column('enum', {
enum: [
...notificationTypes,
...obsoleteNotificationTypes,
],
comment: 'The type of the Notification.',
})
public type: typeof notificationTypes[number];
/** noteId: Note['id'] | null;
*
*/
@Index()
@Column('boolean', {
default: false,
comment: 'Whether the Notification is read.',
})
public isRead: boolean;
@Column({ followRequestId: FollowRequest['id'] | null;
...id(),
nullable: true,
})
public noteId: Note['id'] | null;
@ManyToOne(type => Note, { reaction: string | null;
onDelete: 'CASCADE',
})
@JoinColumn()
public note: Note | null;
@Column({ choice: number | null;
...id(),
nullable: true,
})
public followRequestId: FollowRequest['id'] | null;
@ManyToOne(type => FollowRequest, { achievement: string | null;
onDelete: 'CASCADE',
})
@JoinColumn()
public followRequest: FollowRequest | null;
@Column('varchar', {
length: 128, nullable: true,
})
public reaction: string | null;
@Column('integer', {
nullable: true,
})
public choice: number | null;
@Column('varchar', {
length: 128, nullable: true,
})
public achievement: string | null;
/** /**
* body * body
*/ */
@Column('varchar', { customBody: string | null;
length: 2048, nullable: true,
})
public customBody: string | null;
/** /**
* header * header
* () * ()
*/ */
@Column('varchar', { customHeader: string | null;
length: 256, nullable: true,
})
public customHeader: string | null;
/** /**
* icon(URL) * icon(URL)
* () * ()
*/ */
@Column('varchar', { customIcon: string | null;
length: 1024, nullable: true,
})
public customIcon: string | null;
/** /**
* () * ()
*/ */
@Index() appAccessTokenId: AccessToken['id'] | null;
@Column({
...id(),
nullable: true,
})
public appAccessTokenId: AccessToken['id'] | null;
@ManyToOne(type => AccessToken, {
onDelete: 'CASCADE',
})
@JoinColumn()
public appAccessToken: AccessToken | null;
} }

View File

@ -32,7 +32,6 @@ import { NoteFavorite } from '@/models/entities/NoteFavorite.js';
import { NoteReaction } from '@/models/entities/NoteReaction.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js';
import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js'; import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js';
import { NoteUnread } from '@/models/entities/NoteUnread.js'; import { NoteUnread } from '@/models/entities/NoteUnread.js';
import { Notification } from '@/models/entities/Notification.js';
import { Page } from '@/models/entities/Page.js'; import { Page } from '@/models/entities/Page.js';
import { PageLike } from '@/models/entities/PageLike.js'; import { PageLike } from '@/models/entities/PageLike.js';
import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js'; import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js';
@ -100,7 +99,6 @@ export {
NoteReaction, NoteReaction,
NoteThreadMuting, NoteThreadMuting,
NoteUnread, NoteUnread,
Notification,
Page, Page,
PageLike, PageLike,
PasswordResetRequest, PasswordResetRequest,
@ -167,7 +165,6 @@ export type NoteFavoritesRepository = Repository<NoteFavorite>;
export type NoteReactionsRepository = Repository<NoteReaction>; export type NoteReactionsRepository = Repository<NoteReaction>;
export type NoteThreadMutingsRepository = Repository<NoteThreadMuting>; export type NoteThreadMutingsRepository = Repository<NoteThreadMuting>;
export type NoteUnreadsRepository = Repository<NoteUnread>; export type NoteUnreadsRepository = Repository<NoteUnread>;
export type NotificationsRepository = Repository<Notification>;
export type PagesRepository = Repository<Page>; export type PagesRepository = Repository<Page>;
export type PageLikesRepository = Repository<PageLike>; export type PageLikesRepository = Repository<PageLike>;
export type PasswordResetRequestsRepository = Repository<PasswordResetRequest>; export type PasswordResetRequestsRepository = Repository<PasswordResetRequest>;

View File

@ -14,10 +14,6 @@ export const packedNotificationSchema = {
optional: false, nullable: false, optional: false, nullable: false,
format: 'date-time', format: 'date-time',
}, },
isRead: {
type: 'boolean',
optional: false, nullable: false,
},
type: { type: {
type: 'string', type: 'string',
optional: false, nullable: false, optional: false, nullable: false,

View File

@ -40,7 +40,6 @@ import { NoteFavorite } from '@/models/entities/NoteFavorite.js';
import { NoteReaction } from '@/models/entities/NoteReaction.js'; import { NoteReaction } from '@/models/entities/NoteReaction.js';
import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js'; import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js';
import { NoteUnread } from '@/models/entities/NoteUnread.js'; import { NoteUnread } from '@/models/entities/NoteUnread.js';
import { Notification } from '@/models/entities/Notification.js';
import { Page } from '@/models/entities/Page.js'; import { Page } from '@/models/entities/Page.js';
import { PageLike } from '@/models/entities/PageLike.js'; import { PageLike } from '@/models/entities/PageLike.js';
import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js'; import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js';
@ -155,7 +154,6 @@ export const entities = [
DriveFolder, DriveFolder,
Poll, Poll,
PollVote, PollVote,
Notification,
Emoji, Emoji,
Hashtag, Hashtag,
SwSubscription, SwSubscription,

View File

@ -1,7 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { In, LessThan } from 'typeorm'; import { In, LessThan } from 'typeorm';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import type { AntennasRepository, MutedNotesRepository, NotificationsRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js'; import type { AntennasRepository, MutedNotesRepository, RoleAssignmentsRepository, UserIpsRepository } from '@/models/index.js';
import type { Config } from '@/config.js'; import type { Config } from '@/config.js';
import type Logger from '@/logger.js'; import type Logger from '@/logger.js';
import { bindThis } from '@/decorators.js'; import { bindThis } from '@/decorators.js';
@ -20,9 +20,6 @@ export class CleanProcessorService {
@Inject(DI.userIpsRepository) @Inject(DI.userIpsRepository)
private userIpsRepository: UserIpsRepository, private userIpsRepository: UserIpsRepository,
@Inject(DI.notificationsRepository)
private notificationsRepository: NotificationsRepository,
@Inject(DI.mutedNotesRepository) @Inject(DI.mutedNotesRepository)
private mutedNotesRepository: MutedNotesRepository, private mutedNotesRepository: MutedNotesRepository,
@ -46,10 +43,6 @@ export class CleanProcessorService {
createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))), createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))),
}); });
this.notificationsRepository.delete({
createdAt: LessThan(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90))),
});
this.mutedNotesRepository.delete({ this.mutedNotesRepository.delete({
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))), id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
reason: 'word', reason: 'word',

View File

@ -268,7 +268,6 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js';
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_create from './endpoints/notifications/create.js';
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
import * as ep___notifications_read from './endpoints/notifications/read.js';
import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pagePush from './endpoints/page-push.js';
import * as ep___pages_create from './endpoints/pages/create.js'; import * as ep___pages_create from './endpoints/pages/create.js';
import * as ep___pages_delete from './endpoints/pages/delete.js'; import * as ep___pages_delete from './endpoints/pages/delete.js';
@ -600,7 +599,6 @@ const $notes_unrenote: Provider = { provide: 'ep:notes/unrenote', useClass: ep__
const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default }; const $notes_userListTimeline: Provider = { provide: 'ep:notes/user-list-timeline', useClass: ep___notes_userListTimeline.default };
const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default }; const $notifications_create: Provider = { provide: 'ep:notifications/create', useClass: ep___notifications_create.default };
const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default }; const $notifications_markAllAsRead: Provider = { provide: 'ep:notifications/mark-all-as-read', useClass: ep___notifications_markAllAsRead.default };
const $notifications_read: Provider = { provide: 'ep:notifications/read', useClass: ep___notifications_read.default };
const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default }; const $pagePush: Provider = { provide: 'ep:page-push', useClass: ep___pagePush.default };
const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default }; const $pages_create: Provider = { provide: 'ep:pages/create', useClass: ep___pages_create.default };
const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default }; const $pages_delete: Provider = { provide: 'ep:pages/delete', useClass: ep___pages_delete.default };
@ -936,7 +934,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$notes_userListTimeline, $notes_userListTimeline,
$notifications_create, $notifications_create,
$notifications_markAllAsRead, $notifications_markAllAsRead,
$notifications_read,
$pagePush, $pagePush,
$pages_create, $pages_create,
$pages_delete, $pages_delete,
@ -1266,7 +1263,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
$notes_userListTimeline, $notes_userListTimeline,
$notifications_create, $notifications_create,
$notifications_markAllAsRead, $notifications_markAllAsRead,
$notifications_read,
$pagePush, $pagePush,
$pages_create, $pages_create,
$pages_delete, $pages_delete,

View File

@ -268,7 +268,6 @@ import * as ep___notes_unrenote from './endpoints/notes/unrenote.js';
import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js'; import * as ep___notes_userListTimeline from './endpoints/notes/user-list-timeline.js';
import * as ep___notifications_create from './endpoints/notifications/create.js'; import * as ep___notifications_create from './endpoints/notifications/create.js';
import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js'; import * as ep___notifications_markAllAsRead from './endpoints/notifications/mark-all-as-read.js';
import * as ep___notifications_read from './endpoints/notifications/read.js';
import * as ep___pagePush from './endpoints/page-push.js'; import * as ep___pagePush from './endpoints/page-push.js';
import * as ep___pages_create from './endpoints/pages/create.js'; import * as ep___pages_create from './endpoints/pages/create.js';
import * as ep___pages_delete from './endpoints/pages/delete.js'; import * as ep___pages_delete from './endpoints/pages/delete.js';
@ -598,7 +597,6 @@ const eps = [
['notes/user-list-timeline', ep___notes_userListTimeline], ['notes/user-list-timeline', ep___notes_userListTimeline],
['notifications/create', ep___notifications_create], ['notifications/create', ep___notifications_create],
['notifications/mark-all-as-read', ep___notifications_markAllAsRead], ['notifications/mark-all-as-read', ep___notifications_markAllAsRead],
['notifications/read', ep___notifications_read],
['page-push', ep___pagePush], ['page-push', ep___pagePush],
['pages/create', ep___pages_create], ['pages/create', ep___pages_create],
['pages/delete', ep___pages_delete], ['pages/delete', ep___pages_delete],

View File

@ -1,6 +1,6 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import type { UsersRepository, FollowingsRepository, NotificationsRepository } from '@/models/index.js'; import type { UsersRepository, FollowingsRepository } from '@/models/index.js';
import type { User } from '@/models/entities/User.js'; import type { User } from '@/models/entities/User.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';
@ -36,9 +36,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
@Inject(DI.followingsRepository) @Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository, private followingsRepository: FollowingsRepository,
@Inject(DI.notificationsRepository)
private notificationsRepository: NotificationsRepository,
private userEntityService: UserEntityService, private userEntityService: UserEntityService,
private userFollowingService: UserFollowingService, private userFollowingService: UserFollowingService,
private userSuspendService: UserSuspendService, private userSuspendService: UserSuspendService,
@ -73,7 +70,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
(async () => { (async () => {
await this.userSuspendService.doPostSuspend(user).catch(e => {}); await this.userSuspendService.doPostSuspend(user).catch(e => {});
await this.unFollowAll(user).catch(e => {}); await this.unFollowAll(user).catch(e => {});
await this.readAllNotify(user).catch(e => {});
})(); })();
}); });
} }
@ -96,14 +92,4 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
await this.userFollowingService.unfollow(follower, followee, true); await this.userFollowingService.unfollow(follower, followee, true);
} }
} }
@bindThis
private async readAllNotify(notifier: User) {
await this.notificationsRepository.update({
notifierId: notifier.id,
isRead: false,
}, {
isRead: true,
});
}
} }

View File

@ -1,6 +1,7 @@
import { Brackets } from 'typeorm'; import { Brackets, In } from 'typeorm';
import Redis from 'ioredis';
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotificationsRepository } from '@/models/index.js'; import type { UsersRepository, FollowingsRepository, MutingsRepository, UserProfilesRepository, NotesRepository } from '@/models/index.js';
import { obsoleteNotificationTypes, notificationTypes } from '@/types.js'; import { obsoleteNotificationTypes, notificationTypes } from '@/types.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { QueryService } from '@/core/QueryService.js'; import { QueryService } from '@/core/QueryService.js';
@ -8,6 +9,8 @@ import { NoteReadService } from '@/core/NoteReadService.js';
import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js'; import { NotificationEntityService } from '@/core/entities/NotificationEntityService.js';
import { NotificationService } from '@/core/NotificationService.js'; import { NotificationService } from '@/core/NotificationService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { IdService } from '@/core/IdService.js';
import { Notification } from '@/models/entities/Notification.js';
export const meta = { export const meta = {
tags: ['account', 'notifications'], tags: ['account', 'notifications'],
@ -38,8 +41,6 @@ export const paramDef = {
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
sinceId: { type: 'string', format: 'misskey:id' }, sinceId: { type: 'string', format: 'misskey:id' },
untilId: { type: 'string', format: 'misskey:id' }, untilId: { type: 'string', format: 'misskey:id' },
following: { type: 'boolean', default: false },
unreadOnly: { type: 'boolean', default: false },
markAsRead: { type: 'boolean', default: true }, markAsRead: { type: 'boolean', default: true },
// 後方互換のため、廃止された通知タイプも受け付ける // 後方互換のため、廃止された通知タイプも受け付ける
includeTypes: { type: 'array', items: { includeTypes: { type: 'array', items: {
@ -56,21 +57,22 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor( constructor(
@Inject(DI.redis)
private redisClient: Redis.Redis,
@Inject(DI.usersRepository) @Inject(DI.usersRepository)
private usersRepository: UsersRepository, private usersRepository: UsersRepository,
@Inject(DI.followingsRepository)
private followingsRepository: FollowingsRepository,
@Inject(DI.mutingsRepository) @Inject(DI.mutingsRepository)
private mutingsRepository: MutingsRepository, private mutingsRepository: MutingsRepository,
@Inject(DI.userProfilesRepository) @Inject(DI.userProfilesRepository)
private userProfilesRepository: UserProfilesRepository, private userProfilesRepository: UserProfilesRepository,
@Inject(DI.notificationsRepository) @Inject(DI.notesRepository)
private notificationsRepository: NotificationsRepository, private notesRepository: NotesRepository,
private idService: IdService,
private notificationEntityService: NotificationEntityService, private notificationEntityService: NotificationEntityService,
private notificationService: NotificationService, private notificationService: NotificationService,
private queryService: QueryService, private queryService: QueryService,
@ -89,85 +91,39 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const includeTypes = ps.includeTypes && ps.includeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][]; const excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
const followingQuery = this.followingsRepository.createQueryBuilder('following') const notificationsRes = await this.redisClient.xrevrange(
.select('following.followeeId') `notificationTimeline:${me.id}`,
.where('following.followerId = :followerId', { followerId: me.id }); ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
'-',
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting') if (notificationsRes.length === 0) {
.select('muting.muteeId') return [];
.where('muting.muterId = :muterId', { muterId: me.id });
const mutingInstanceQuery = this.userProfilesRepository.createQueryBuilder('user_profile')
.select('user_profile.mutedInstances')
.where('user_profile.userId = :muterId', { muterId: me.id });
const suspendedQuery = this.usersRepository.createQueryBuilder('users')
.select('users.id')
.where('users.isSuspended = TRUE');
const query = this.queryService.makePaginationQuery(this.notificationsRepository.createQueryBuilder('notification'), ps.sinceId, ps.untilId)
.andWhere('notification.notifieeId = :meId', { meId: me.id })
.leftJoinAndSelect('notification.notifier', 'notifier')
.leftJoinAndSelect('notification.note', 'note')
.leftJoinAndSelect('notifier.avatar', 'notifierAvatar')
.leftJoinAndSelect('notifier.banner', 'notifierBanner')
.leftJoinAndSelect('note.user', 'user')
.leftJoinAndSelect('user.avatar', 'avatar')
.leftJoinAndSelect('user.banner', 'banner')
.leftJoinAndSelect('note.reply', 'reply')
.leftJoinAndSelect('note.renote', 'renote')
.leftJoinAndSelect('reply.user', 'replyUser')
.leftJoinAndSelect('replyUser.avatar', 'replyUserAvatar')
.leftJoinAndSelect('replyUser.banner', 'replyUserBanner')
.leftJoinAndSelect('renote.user', 'renoteUser')
.leftJoinAndSelect('renoteUser.avatar', 'renoteUserAvatar')
.leftJoinAndSelect('renoteUser.banner', 'renoteUserBanner');
// muted users
query.andWhere(new Brackets(qb => { qb
.where(`notification.notifierId NOT IN (${ mutingQuery.getQuery() })`)
.orWhere('notification.notifierId IS NULL');
}));
query.setParameters(mutingQuery.getParameters());
// muted instances
query.andWhere(new Brackets(qb => { qb
.andWhere('notifier.host IS NULL')
.orWhere(`NOT (( ${mutingInstanceQuery.getQuery()} )::jsonb ? notifier.host)`);
}));
query.setParameters(mutingInstanceQuery.getParameters());
// suspended users
query.andWhere(new Brackets(qb => { qb
.where(`notification.notifierId NOT IN (${ suspendedQuery.getQuery() })`)
.orWhere('notification.notifierId IS NULL');
}));
if (ps.following) {
query.andWhere(`((notification.notifierId IN (${ followingQuery.getQuery() })) OR (notification.notifierId = :meId))`, { meId: me.id });
query.setParameters(followingQuery.getParameters());
} }
let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId) as Notification[];
if (includeTypes && includeTypes.length > 0) { if (includeTypes && includeTypes.length > 0) {
query.andWhere('notification.type IN (:...includeTypes)', { includeTypes }); notifications = notifications.filter(notification => includeTypes.includes(notification.type));
} else if (excludeTypes && excludeTypes.length > 0) { } else if (excludeTypes && excludeTypes.length > 0) {
query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes }); notifications = notifications.filter(notification => !excludeTypes.includes(notification.type));
} }
if (ps.unreadOnly) { if (notifications.length === 0) {
query.andWhere('notification.isRead = false'); return [];
} }
const notifications = await query.take(ps.limit).getMany();
// Mark all as read // Mark all as read
if (notifications.length > 0 && ps.markAsRead) { if (ps.markAsRead) {
this.notificationService.readNotification(me.id, notifications.map(x => x.id)); this.notificationService.readAllNotification(me.id);
} }
const notes = notifications.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type)).map(notification => notification.note!); const noteIds = notifications
.filter(notification => ['mention', 'reply', 'quote'].includes(notification.type))
.map(notification => notification.noteId!);
if (notes.length > 0) { if (noteIds.length > 0) {
const notes = await this.notesRepository.findBy({ id: In(noteIds) });
this.noteReadService.read(me.id, notes); this.noteReadService.read(me.id, notes);
} }

View File

@ -1,9 +1,7 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import type { NotificationsRepository } from '@/models/index.js';
import { Endpoint } from '@/server/api/endpoint-base.js'; import { Endpoint } from '@/server/api/endpoint-base.js';
import { GlobalEventService } from '@/core/GlobalEventService.js';
import { PushNotificationService } from '@/core/PushNotificationService.js';
import { DI } from '@/di-symbols.js'; import { DI } from '@/di-symbols.js';
import { NotificationService } from '@/core/NotificationService.js';
export const meta = { export const meta = {
tags: ['notifications', 'account'], tags: ['notifications', 'account'],
@ -23,24 +21,10 @@ export const paramDef = {
@Injectable() @Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> { export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor( constructor(
@Inject(DI.notificationsRepository) private notificationService: NotificationService,
private notificationsRepository: NotificationsRepository,
private globalEventService: GlobalEventService,
private pushNotificationService: PushNotificationService,
) { ) {
super(meta, paramDef, async (ps, me) => { super(meta, paramDef, async (ps, me) => {
// Update documents this.notificationService.readAllNotification(me.id);
await this.notificationsRepository.update({
notifieeId: me.id,
isRead: false,
}, {
isRead: true,
});
// 全ての通知を読みましたよというイベントを発行
this.globalEventService.publishMainStream(me.id, 'readAllNotifications');
this.pushNotificationService.pushNotification(me.id, 'readAllNotifications', undefined);
}); });
} }
} }

View File

@ -1,57 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Endpoint } from '@/server/api/endpoint-base.js';
import { NotificationService } from '@/core/NotificationService.js';
export const meta = {
tags: ['notifications', 'account'],
requireCredential: true,
kind: 'write:notifications',
description: 'Mark a notification as read.',
errors: {
noSuchNotification: {
message: 'No such notification.',
code: 'NO_SUCH_NOTIFICATION',
id: 'efa929d5-05b5-47d1-beec-e6a4dbed011e',
},
},
} as const;
export const paramDef = {
oneOf: [
{
type: 'object',
properties: {
notificationId: { type: 'string', format: 'misskey:id' },
},
required: ['notificationId'],
},
{
type: 'object',
properties: {
notificationIds: {
type: 'array',
items: { type: 'string', format: 'misskey:id' },
maxItems: 100,
},
},
required: ['notificationIds'],
},
],
} as const;
// eslint-disable-next-line import/no-default-export
@Injectable()
export default class extends Endpoint<typeof meta, typeof paramDef> {
constructor(
private notificationService: NotificationService,
) {
super(meta, paramDef, async (ps, me) => {
if ('notificationId' in ps) return this.notificationService.readNotification(me.id, [ps.notificationId]);
return this.notificationService.readNotification(me.id, ps.notificationIds);
});
}
}

View File

@ -195,8 +195,7 @@ export default class Connection {
@bindThis @bindThis
private onReadNotification(payload: any) { private onReadNotification(payload: any) {
if (!payload.id) return; this.notificationService.readAllNotification(this.user!.id);
this.notificationService.readNotification(this.user!.id, [payload.id]);
} }
/** /**

View File

@ -83,7 +83,7 @@
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
import { ref, shallowRef, onMounted, onUnmounted, watch } from 'vue'; import { ref, shallowRef } from 'vue';
import * as misskey from 'misskey-js'; import * as misskey from 'misskey-js';
import MkReactionIcon from '@/components/MkReactionIcon.vue'; import MkReactionIcon from '@/components/MkReactionIcon.vue';
import MkFollowButton from '@/components/MkFollowButton.vue'; import MkFollowButton from '@/components/MkFollowButton.vue';
@ -94,7 +94,6 @@ import { notePage } from '@/filters/note';
import { userPage } from '@/filters/user'; import { userPage } from '@/filters/user';
import { i18n } from '@/i18n'; import { i18n } from '@/i18n';
import * as os from '@/os'; import * as os from '@/os';
import { stream } from '@/stream';
import { useTooltip } from '@/scripts/use-tooltip'; import { useTooltip } from '@/scripts/use-tooltip';
import { $i } from '@/account'; import { $i } from '@/account';
@ -110,35 +109,6 @@ const props = withDefaults(defineProps<{
const elRef = shallowRef<HTMLElement>(null); const elRef = shallowRef<HTMLElement>(null);
const reactionRef = ref(null); const reactionRef = ref(null);
let readObserver: IntersectionObserver | undefined;
let connection;
onMounted(() => {
if (!props.notification.isRead) {
readObserver = new IntersectionObserver((entries, observer) => {
if (!entries.some(entry => entry.isIntersecting)) return;
stream.send('readNotification', {
id: props.notification.id,
});
observer.disconnect();
});
readObserver.observe(elRef.value);
connection = stream.useChannel('main');
connection.on('readAllNotifications', () => readObserver.disconnect());
watch(props.notification.isRead, () => {
readObserver.disconnect();
});
}
});
onUnmounted(() => {
if (readObserver) readObserver.disconnect();
if (connection) connection.dispose();
});
const followRequestDone = ref(false); const followRequestDone = ref(false);
const acceptFollowRequest = () => { const acceptFollowRequest = () => {

View File

@ -29,7 +29,6 @@ import { notificationTypes } from '@/const';
const props = defineProps<{ const props = defineProps<{
includeTypes?: typeof notificationTypes[number][]; includeTypes?: typeof notificationTypes[number][];
unreadOnly?: boolean;
}>(); }>();
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>(); const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
@ -40,23 +39,17 @@ const pagination: Paging = {
params: computed(() => ({ params: computed(() => ({
includeTypes: props.includeTypes ?? undefined, includeTypes: props.includeTypes ?? undefined,
excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes, excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes,
unreadOnly: props.unreadOnly,
})), })),
}; };
const onNotification = (notification) => { const onNotification = (notification) => {
const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type); const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type);
if (isMuted || document.visibilityState === 'visible') { if (isMuted || document.visibilityState === 'visible') {
stream.send('readNotification', { stream.send('readNotification');
id: notification.id,
});
} }
if (!isMuted) { if (!isMuted) {
pagingComponent.value.prepend({ pagingComponent.value.prepend(notification);
...notification,
isRead: document.visibilityState === 'visible',
});
} }
}; };
@ -65,30 +58,6 @@ let connection;
onMounted(() => { onMounted(() => {
connection = stream.useChannel('main'); connection = stream.useChannel('main');
connection.on('notification', onNotification); connection.on('notification', onNotification);
connection.on('readAllNotifications', () => {
if (pagingComponent.value) {
for (const item of pagingComponent.value.queue) {
item.isRead = true;
}
for (const item of pagingComponent.value.items) {
item.isRead = true;
}
}
});
connection.on('readNotifications', notificationIds => {
if (pagingComponent.value) {
for (let i = 0; i < pagingComponent.value.queue.length; i++) {
if (notificationIds.includes(pagingComponent.value.queue[i].id)) {
pagingComponent.value.queue[i].isRead = true;
}
}
for (let i = 0; i < (pagingComponent.value.items || []).length; i++) {
if (notificationIds.includes(pagingComponent.value.items[i].id)) {
pagingComponent.value.items[i].isRead = true;
}
}
}
});
}); });
onUnmounted(() => { onUnmounted(() => {

View File

@ -2,8 +2,8 @@
<MkStickyContainer> <MkStickyContainer>
<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 :content-max="800"> <MkSpacer :content-max="800">
<div v-if="tab === 'all' || tab === 'unread'"> <div v-if="tab === 'all'">
<XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/> <XNotifications class="notifications" :include-types="includeTypes"/>
</div> </div>
<div v-else-if="tab === 'mentions'"> <div v-else-if="tab === 'mentions'">
<MkNotes :pagination="mentionsPagination"/> <MkNotes :pagination="mentionsPagination"/>
@ -26,7 +26,6 @@ import { notificationTypes } from '@/const';
let tab = $ref('all'); let tab = $ref('all');
let includeTypes = $ref<string[] | null>(null); let includeTypes = $ref<string[] | null>(null);
let unreadOnly = $computed(() => tab === 'unread');
const mentionsPagination = { const mentionsPagination = {
endpoint: 'notes/mentions' as const, endpoint: 'notes/mentions' as const,
@ -76,10 +75,6 @@ const headerTabs = $computed(() => [{
key: 'all', key: 'all',
title: i18n.ts.all, title: i18n.ts.all,
icon: 'ti ti-point', icon: 'ti ti-point',
}, {
key: 'unread',
title: i18n.ts.unread,
icon: 'ti ti-loader',
}, { }, {
key: 'mentions', key: 'mentions',
title: i18n.ts.mentions, title: i18n.ts.mentions,

View File

@ -53,9 +53,7 @@ function onNotification(notification) {
if ($i.mutingNotificationTypes.includes(notification.type)) return; if ($i.mutingNotificationTypes.includes(notification.type)) return;
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
stream.send('readNotification', { stream.send('readNotification');
id: notification.id,
});
notifications.unshift(notification); notifications.unshift(notification);
window.setTimeout(() => { window.setTimeout(() => {

View File

@ -515,7 +515,6 @@ export type Endpoints = {
// notifications // notifications
'notifications/create': { req: { body: string; header?: string | null; icon?: string | null; }; res: null; }; 'notifications/create': { req: { body: string; header?: string | null; icon?: string | null; }; res: null; };
'notifications/mark-all-as-read': { req: NoParams; res: null; }; 'notifications/mark-all-as-read': { req: NoParams; res: null; };
'notifications/read': { req: { notificationId: Notification['id']; }; res: null; };
// page-push // page-push
'page-push': { req: { pageId: Page['id']; event: string; var?: any; }; res: null; }; 'page-push': { req: { pageId: Page['id']; event: string; var?: any; }; res: null; };

View File

@ -1,58 +0,0 @@
import { get } from 'idb-keyval';
import { pushNotificationDataMap } from '@/types';
import { api } from '@/scripts/operations';
type Accounts = {
[x: string]: {
queue: string[],
timeout: number | null
}
};
class SwNotificationReadManager {
private accounts: Accounts = {};
public async construct() {
const accounts = await get('accounts');
if (!accounts) Error('Accounts are not recorded');
this.accounts = accounts.reduce((acc, e) => {
acc[e.id] = {
queue: [],
timeout: null
};
return acc;
}, {} as Accounts);
return this;
}
// プッシュ通知の既読をサーバーに送信
public async read(data: pushNotificationDataMap[keyof pushNotificationDataMap]) {
if (data.type !== 'notification' || !(data.userId in this.accounts)) return;
const account = this.accounts[data.userId];
account.queue.push(data.body.id as string);
if (account.queue.length >= 20) {
if (account.timeout) clearTimeout(account.timeout);
const notificationIds = account.queue;
account.queue = [];
await api('notifications/read', data.userId, { notificationIds });
return;
}
// 最後の呼び出しから200ms待ってまとめて処理する
if (account.timeout) clearTimeout(account.timeout);
account.timeout = setTimeout(() => {
account.timeout = null;
const notificationIds = account.queue;
account.queue = [];
api('notifications/read', data.userId, { notificationIds });
}, 200);
}
}
export const swNotificationRead = (new SwNotificationReadManager()).construct();

View File

@ -1,6 +1,6 @@
import { createEmptyNotification, createNotification } from '@/scripts/create-notification'; import { createEmptyNotification, createNotification } from '@/scripts/create-notification';
import { swLang } from '@/scripts/lang'; import { swLang } from '@/scripts/lang';
import { swNotificationRead } from '@/scripts/notification-read'; import { api } from '@/scripts/operations';
import { pushNotificationDataMap } from '@/types'; import { pushNotificationDataMap } from '@/types';
import * as swos from '@/scripts/operations'; import * as swos from '@/scripts/operations';
import { acct as getAcct } from '@/filters/user'; import { acct as getAcct } from '@/filters/user';
@ -54,30 +54,6 @@ globalThis.addEventListener('push', ev => {
if ((new Date()).getTime() - data.dateTime > 1000 * 60 * 60 * 24) break; if ((new Date()).getTime() - data.dateTime > 1000 * 60 * 60 * 24) break;
return createNotification(data); return createNotification(data);
case 'readAllNotifications':
for (const n of await globalThis.registration.getNotifications()) {
if (n?.data?.type === 'notification') n.close();
}
break;
case 'readAllAntennas':
for (const n of await globalThis.registration.getNotifications()) {
if (n?.data?.type === 'unreadAntennaNote') n.close();
}
break;
case 'readNotifications':
for (const n of await globalThis.registration.getNotifications()) {
if (data.body.notificationIds.includes(n.data.body.id)) {
n.close();
}
}
break;
case 'readAntenna':
for (const n of await globalThis.registration.getNotifications()) {
if (n?.data?.type === 'unreadAntennaNote' && data.body.antennaId === n.data.body.antenna.id) {
n.close();
}
}
break;
} }
await createEmptyNotification(); await createEmptyNotification();
@ -154,7 +130,7 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv
client.focus(); client.focus();
} }
if (data.type === 'notification') { if (data.type === 'notification') {
swNotificationRead.then(that => that.read(data)); api('notifications/mark-all-as-read', data.userId);
} }
notification.close(); notification.close();
@ -165,7 +141,7 @@ globalThis.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEv
const data: pushNotificationDataMap[keyof pushNotificationDataMap] = ev.notification.data; const data: pushNotificationDataMap[keyof pushNotificationDataMap] = ev.notification.data;
if (data.type === 'notification') { if (data.type === 'notification') {
swNotificationRead.then(that => that.read(data)); api('notifications/mark-all-as-read', data.userId);
} }
}); });

View File

@ -17,10 +17,6 @@ type pushNotificationDataSourceMap = {
antenna: { id: string, name: string }; antenna: { id: string, name: string };
note: Misskey.entities.Note; note: Misskey.entities.Note;
}; };
readNotifications: { notificationIds: string[] };
readAllNotifications: undefined;
readAntenna: { antennaId: string };
readAllAntennas: undefined;
}; };
export type pushNotificationData<K extends keyof pushNotificationDataSourceMap> = { export type pushNotificationData<K extends keyof pushNotificationDataSourceMap> = {