parent
38d0b62167
commit
30d6992684
|
@ -34,6 +34,7 @@
|
|||
- ノート作成時のパフォーマンスを向上
|
||||
- アンテナのタイムライン取得時のパフォーマンスを向上
|
||||
- チャンネルのタイムライン取得時のパフォーマンスを向上
|
||||
- 通知に関する全体的なパフォーマンスを向上
|
||||
|
||||
## 13.10.3
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
export class cleanup1680582195041 {
|
||||
name = 'cleanup1680582195041'
|
||||
|
||||
async up(queryRunner) {
|
||||
await queryRunner.query(`DROP TABLE "notification" `);
|
||||
}
|
||||
|
||||
async down(queryRunner) {
|
||||
|
||||
}
|
||||
}
|
|
@ -169,10 +169,6 @@ export class NoteReadService implements OnApplicationShutdown {
|
|||
this.globalEventService.publishMainStream(userId, 'readAllChannels');
|
||||
}
|
||||
});
|
||||
|
||||
this.notificationService.readNotificationByQuery(userId, {
|
||||
noteId: In([...readMentions.map(n => n.id), ...readSpecifiedNotes.map(n => n.id)]),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -1,8 +1,9 @@
|
|||
import { setTimeout } from 'node:timers/promises';
|
||||
import Redis from 'ioredis';
|
||||
import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
|
||||
import { In } from 'typeorm';
|
||||
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 { Notification } from '@/models/entities/Notification.js';
|
||||
import { UserEntityService } from '@/core/entities/UserEntityService.js';
|
||||
|
@ -17,15 +18,15 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
#shutdownController = new AbortController();
|
||||
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
|
@ -38,50 +39,31 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
}
|
||||
|
||||
@bindThis
|
||||
public async readNotification(
|
||||
public async readAllNotification(
|
||||
userId: User['id'],
|
||||
notificationIds: Notification['id'][],
|
||||
) {
|
||||
if (notificationIds.length === 0) return;
|
||||
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
|
||||
|
||||
const latestNotificationIdsRes = await this.redisClient.xrevrange(
|
||||
`notificationTimeline:${userId}`,
|
||||
'+',
|
||||
'-',
|
||||
'COUNT', 1);
|
||||
console.log('latestNotificationIdsRes', latestNotificationIdsRes);
|
||||
const latestNotificationId = latestNotificationIdsRes[0]?.[0];
|
||||
|
||||
// Update documents
|
||||
const result = await this.notificationsRepository.update({
|
||||
notifieeId: userId,
|
||||
id: In(notificationIds),
|
||||
isRead: false,
|
||||
}, {
|
||||
isRead: true,
|
||||
});
|
||||
if (latestNotificationId == null) return;
|
||||
|
||||
if (result.affected === 0) return;
|
||||
this.redisClient.set(`latestReadNotification:${userId}`, latestNotificationId);
|
||||
|
||||
if (!await this.userEntityService.getHasUnreadNotification(userId)) return this.postReadAllNotifications(userId);
|
||||
else return this.postReadNotifications(userId, notificationIds);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
public async readNotificationByQuery(
|
||||
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);
|
||||
if (latestReadNotificationId == null || (latestReadNotificationId < latestNotificationId)) {
|
||||
return this.postReadAllNotifications(userId);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private postReadAllNotifications(userId: User['id']) {
|
||||
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
|
||||
|
@ -90,47 +72,48 @@ export class NotificationService implements OnApplicationShutdown {
|
|||
type: Notification['type'],
|
||||
data: Partial<Notification>,
|
||||
): Promise<Notification | null> {
|
||||
if (data.notifierId && (notifieeId === data.notifierId)) {
|
||||
return null;
|
||||
// TODO: Cache
|
||||
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 });
|
||||
|
||||
// TODO: Cache
|
||||
const isMuted = profile?.mutingNotificationTypes.includes(type);
|
||||
|
||||
// Create notification
|
||||
const notification = await this.notificationsRepository.insert({
|
||||
const notification = {
|
||||
id: this.idService.genId(),
|
||||
createdAt: new Date(),
|
||||
notifieeId: notifieeId,
|
||||
type: type,
|
||||
// 相手がこの通知をミュートしているようなら、既読を予めつけておく
|
||||
isRead: isMuted,
|
||||
...data,
|
||||
} as Partial<Notification>)
|
||||
.then(x => this.notificationsRepository.findOneByOrFail(x.identifiers[0]));
|
||||
} as Notification;
|
||||
|
||||
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
|
||||
this.globalEventService.publishMainStream(notifieeId, 'notification', packed);
|
||||
|
||||
// 2秒経っても(今回作成した)通知が既読にならなかったら「未読の通知がありますよ」イベントを発行する
|
||||
setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
|
||||
const fresh = await this.notificationsRepository.findOneBy({ id: notification.id });
|
||||
if (fresh == null) 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
|
||||
setTimeout(2000, 'unread notification', { signal: this.#shutdownController.signal }).then(async () => {
|
||||
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${notifieeId}`);
|
||||
if (latestReadNotificationId && (latestReadNotificationId >= notification.id)) return;
|
||||
|
||||
this.globalEventService.publishMainStream(notifieeId, 'unreadNotification', packed);
|
||||
this.pushNotificationService.pushNotification(notifieeId, 'notification', packed);
|
||||
|
|
|
@ -15,10 +15,6 @@ type PushNotificationsTypes = {
|
|||
antenna: { id: string, name: string };
|
||||
note: Packed<'Note'>;
|
||||
};
|
||||
'readNotifications': { notificationIds: string[] };
|
||||
'readAllNotifications': undefined;
|
||||
'readAntenna': { antennaId: string };
|
||||
'readAllAntennas': undefined;
|
||||
};
|
||||
|
||||
// Reduce length because push message servers have character limits
|
||||
|
@ -72,14 +68,6 @@ export class PushNotificationService {
|
|||
});
|
||||
|
||||
for (const subscription of subscriptions) {
|
||||
// Continue if sendReadMessage is false
|
||||
if ([
|
||||
'readNotifications',
|
||||
'readAllNotifications',
|
||||
'readAntenna',
|
||||
'readAllAntennas',
|
||||
].includes(type) && !subscription.sendReadMessage) continue;
|
||||
|
||||
const pushSubscription = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { In } from 'typeorm';
|
||||
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 type { Notification } from '@/models/entities/Notification.js';
|
||||
import type { Note } from '@/models/entities/Note.js';
|
||||
|
@ -25,8 +26,11 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
constructor(
|
||||
private moduleRef: ModuleRef,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.noteReactionsRepository)
|
||||
private noteReactionsRepository: NoteReactionsRepository,
|
||||
|
@ -48,30 +52,39 @@ export class NotificationEntityService implements OnModuleInit {
|
|||
|
||||
@bindThis
|
||||
public async pack(
|
||||
src: Notification['id'] | Notification,
|
||||
src: Notification,
|
||||
meId: User['id'],
|
||||
options: {
|
||||
_hint_?: {
|
||||
packedNotes: Map<Note['id'], Packed<'Note'>>;
|
||||
};
|
||||
|
||||
},
|
||||
hint?: {
|
||||
packedNotes: Map<Note['id'], Packed<'Note'>>;
|
||||
packedUsers: Map<User['id'], Packed<'User'>>;
|
||||
},
|
||||
): 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 noteIfNeed = NOTE_REQUIRED_NOTIFICATION_TYPES.has(notification.type) && notification.noteId != null ? (
|
||||
options._hint_?.packedNotes != null
|
||||
? options._hint_.packedNotes.get(notification.noteId)
|
||||
: this.noteEntityService.pack(notification.note ?? notification.noteId!, { id: notification.notifieeId }, {
|
||||
hint?.packedNotes != null
|
||||
? hint.packedNotes.get(notification.noteId)
|
||||
: this.noteEntityService.pack(notification.noteId!, { id: meId }, {
|
||||
detail: true,
|
||||
})
|
||||
) : 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({
|
||||
id: notification.id,
|
||||
createdAt: notification.createdAt.toISOString(),
|
||||
createdAt: new Date(notification.createdAt).toISOString(),
|
||||
type: notification.type,
|
||||
isRead: notification.isRead,
|
||||
userId: notification.notifierId,
|
||||
user: notification.notifierId ? this.userEntityService.pack(notification.notifier ?? notification.notifierId) : null,
|
||||
...(userIfNeed != null ? { user: userIfNeed } : {}),
|
||||
...(noteIfNeed != null ? { note: noteIfNeed } : {}),
|
||||
...(notification.type === 'reaction' ? {
|
||||
reaction: notification.reaction,
|
||||
|
@ -87,33 +100,36 @@ 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
|
||||
public async packMany(
|
||||
notifications: Notification[],
|
||||
meId: User['id'],
|
||||
) {
|
||||
if (notifications.length === 0) return [];
|
||||
|
||||
for (const notification of notifications) {
|
||||
if (meId !== notification.notifieeId) {
|
||||
// because we call note packMany with meId, all notifieeId should be same as meId
|
||||
throw new Error('TRY_TO_PACK_ANOTHER_USER_NOTIFICATION');
|
||||
}
|
||||
}
|
||||
|
||||
const notes = notifications.map(x => x.note).filter(isNotNull);
|
||||
const noteIds = notifications.map(x => x.noteId).filter(isNotNull);
|
||||
const notes = noteIds.length > 0 ? await this.notesRepository.find({
|
||||
where: { id: In(noteIds) },
|
||||
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 packedNotesArray = await this.noteEntityService.packMany(notes, { id: meId }, {
|
||||
detail: true,
|
||||
});
|
||||
const packedNotes = new Map(packedNotesArray.map(p => [p.id, p]));
|
||||
|
||||
return await Promise.all(notifications.map(x => this.pack(x, {
|
||||
_hint_: {
|
||||
packedNotes,
|
||||
},
|
||||
const userIds = notifications.map(x => x.notifierId).filter(isNotNull);
|
||||
const users = userIds.length > 0 ? await this.usersRepository.find({
|
||||
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,
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, Not } from 'typeorm';
|
||||
import Redis from 'ioredis';
|
||||
import Ajv from 'ajv';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
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 { LocalUser, RemoteUser, User } 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 { RoleService } from '@/core/RoleService.js';
|
||||
import type { OnModuleInit } from '@nestjs/common';
|
||||
|
@ -60,6 +61,9 @@ export class UserEntityService implements OnModuleInit {
|
|||
@Inject(DI.config)
|
||||
private config: Config,
|
||||
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
|
@ -90,9 +94,6 @@ export class UserEntityService implements OnModuleInit {
|
|||
@Inject(DI.channelFollowingsRepository)
|
||||
private channelFollowingsRepository: ChannelFollowingsRepository,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
@Inject(DI.userNotePiningsRepository)
|
||||
private userNotePiningsRepository: UserNotePiningsRepository,
|
||||
|
||||
|
@ -247,21 +248,17 @@ export class UserEntityService implements OnModuleInit {
|
|||
|
||||
@bindThis
|
||||
public async getHasUnreadNotification(userId: User['id']): Promise<boolean> {
|
||||
const mute = await this.mutingsRepository.findBy({
|
||||
muterId: userId,
|
||||
});
|
||||
const mutedUserIds = mute.map(m => m.muteeId);
|
||||
const latestReadNotificationId = await this.redisClient.get(`latestReadNotification:${userId}`);
|
||||
|
||||
const latestNotificationIdsRes = await this.redisClient.xrevrange(
|
||||
`notificationTimeline:${userId}`,
|
||||
'+',
|
||||
'-',
|
||||
'COUNT', 1);
|
||||
console.log('latestNotificationIdsRes', latestNotificationIdsRes);
|
||||
const latestNotificationId = latestNotificationIdsRes[0]?.[0];
|
||||
|
||||
const count = await this.notificationsRepository.count({
|
||||
where: {
|
||||
notifieeId: userId,
|
||||
...(mutedUserIds.length > 0 ? { notifierId: Not(In(mutedUserIds)) } : {}),
|
||||
isRead: false,
|
||||
},
|
||||
take: 1,
|
||||
});
|
||||
|
||||
return count > 0;
|
||||
return latestNotificationId != null && (latestReadNotificationId == null || latestReadNotificationId < latestNotificationId);
|
||||
}
|
||||
|
||||
@bindThis
|
||||
|
|
|
@ -33,7 +33,6 @@ export const DI = {
|
|||
emojisRepository: Symbol('emojisRepository'),
|
||||
driveFilesRepository: Symbol('driveFilesRepository'),
|
||||
driveFoldersRepository: Symbol('driveFoldersRepository'),
|
||||
notificationsRepository: Symbol('notificationsRepository'),
|
||||
metasRepository: Symbol('metasRepository'),
|
||||
mutingsRepository: Symbol('mutingsRepository'),
|
||||
renoteMutingsRepository: Symbol('renoteMutingsRepository'),
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
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 { Provider } from '@nestjs/common';
|
||||
|
||||
|
@ -172,12 +172,6 @@ const $driveFoldersRepository: Provider = {
|
|||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $notificationsRepository: Provider = {
|
||||
provide: DI.notificationsRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Notification),
|
||||
inject: [DI.db],
|
||||
};
|
||||
|
||||
const $metasRepository: Provider = {
|
||||
provide: DI.metasRepository,
|
||||
useFactory: (db: DataSource) => db.getRepository(Meta),
|
||||
|
@ -426,7 +420,6 @@ const $roleAssignmentsRepository: Provider = {
|
|||
$emojisRepository,
|
||||
$driveFilesRepository,
|
||||
$driveFoldersRepository,
|
||||
$notificationsRepository,
|
||||
$metasRepository,
|
||||
$mutingsRepository,
|
||||
$renoteMutingsRepository,
|
||||
|
@ -493,7 +486,6 @@ const $roleAssignmentsRepository: Provider = {
|
|||
$emojisRepository,
|
||||
$driveFilesRepository,
|
||||
$driveFoldersRepository,
|
||||
$notificationsRepository,
|
||||
$metasRepository,
|
||||
$mutingsRepository,
|
||||
$renoteMutingsRepository,
|
||||
|
|
|
@ -1,54 +1,19 @@
|
|||
import { Entity, Index, JoinColumn, ManyToOne, Column, PrimaryColumn } from 'typeorm';
|
||||
import { notificationTypes, obsoleteNotificationTypes } from '@/types.js';
|
||||
import { id } from '../id.js';
|
||||
import { notificationTypes } from '@/types.js';
|
||||
import { User } from './User.js';
|
||||
import { Note } from './Note.js';
|
||||
import { FollowRequest } from './FollowRequest.js';
|
||||
import { AccessToken } from './AccessToken.js';
|
||||
|
||||
@Entity()
|
||||
export class Notification {
|
||||
@PrimaryColumn(id())
|
||||
public id: string;
|
||||
export type Notification = {
|
||||
id: string;
|
||||
|
||||
@Index()
|
||||
@Column('timestamp with time zone', {
|
||||
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;
|
||||
// RedisのためDateではなくstring
|
||||
createdAt: string;
|
||||
|
||||
/**
|
||||
* 通知の送信者(initiator)
|
||||
*/
|
||||
@Index()
|
||||
@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;
|
||||
notifierId: User['id'] | null;
|
||||
|
||||
/**
|
||||
* 通知の種類。
|
||||
|
@ -64,104 +29,37 @@ export class Notification {
|
|||
* achievementEarned - 実績を獲得
|
||||
* app - アプリ通知
|
||||
*/
|
||||
@Index()
|
||||
@Column('enum', {
|
||||
enum: [
|
||||
...notificationTypes,
|
||||
...obsoleteNotificationTypes,
|
||||
],
|
||||
comment: 'The type of the Notification.',
|
||||
})
|
||||
public type: typeof notificationTypes[number];
|
||||
type: typeof notificationTypes[number];
|
||||
|
||||
/**
|
||||
* 通知が読まれたかどうか
|
||||
*/
|
||||
@Index()
|
||||
@Column('boolean', {
|
||||
default: false,
|
||||
comment: 'Whether the Notification is read.',
|
||||
})
|
||||
public isRead: boolean;
|
||||
noteId: Note['id'] | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public noteId: Note['id'] | null;
|
||||
followRequestId: FollowRequest['id'] | null;
|
||||
|
||||
@ManyToOne(type => Note, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public note: Note | null;
|
||||
reaction: string | null;
|
||||
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public followRequestId: FollowRequest['id'] | null;
|
||||
choice: number | null;
|
||||
|
||||
@ManyToOne(type => FollowRequest, {
|
||||
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;
|
||||
achievement: string | null;
|
||||
|
||||
/**
|
||||
* アプリ通知のbody
|
||||
*/
|
||||
@Column('varchar', {
|
||||
length: 2048, nullable: true,
|
||||
})
|
||||
public customBody: string | null;
|
||||
customBody: string | null;
|
||||
|
||||
/**
|
||||
* アプリ通知のheader
|
||||
* (省略時はアプリ名で表示されることを期待)
|
||||
*/
|
||||
@Column('varchar', {
|
||||
length: 256, nullable: true,
|
||||
})
|
||||
public customHeader: string | null;
|
||||
customHeader: string | null;
|
||||
|
||||
/**
|
||||
* アプリ通知のicon(URL)
|
||||
* (省略時はアプリアイコンで表示されることを期待)
|
||||
*/
|
||||
@Column('varchar', {
|
||||
length: 1024, nullable: true,
|
||||
})
|
||||
public customIcon: string | null;
|
||||
customIcon: string | null;
|
||||
|
||||
/**
|
||||
* アプリ通知のアプリ(のトークン)
|
||||
*/
|
||||
@Index()
|
||||
@Column({
|
||||
...id(),
|
||||
nullable: true,
|
||||
})
|
||||
public appAccessTokenId: AccessToken['id'] | null;
|
||||
|
||||
@ManyToOne(type => AccessToken, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn()
|
||||
public appAccessToken: AccessToken | null;
|
||||
appAccessTokenId: AccessToken['id'] | null;
|
||||
}
|
||||
|
|
|
@ -32,7 +32,6 @@ import { NoteFavorite } from '@/models/entities/NoteFavorite.js';
|
|||
import { NoteReaction } from '@/models/entities/NoteReaction.js';
|
||||
import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js';
|
||||
import { NoteUnread } from '@/models/entities/NoteUnread.js';
|
||||
import { Notification } from '@/models/entities/Notification.js';
|
||||
import { Page } from '@/models/entities/Page.js';
|
||||
import { PageLike } from '@/models/entities/PageLike.js';
|
||||
import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js';
|
||||
|
@ -100,7 +99,6 @@ export {
|
|||
NoteReaction,
|
||||
NoteThreadMuting,
|
||||
NoteUnread,
|
||||
Notification,
|
||||
Page,
|
||||
PageLike,
|
||||
PasswordResetRequest,
|
||||
|
@ -167,7 +165,6 @@ export type NoteFavoritesRepository = Repository<NoteFavorite>;
|
|||
export type NoteReactionsRepository = Repository<NoteReaction>;
|
||||
export type NoteThreadMutingsRepository = Repository<NoteThreadMuting>;
|
||||
export type NoteUnreadsRepository = Repository<NoteUnread>;
|
||||
export type NotificationsRepository = Repository<Notification>;
|
||||
export type PagesRepository = Repository<Page>;
|
||||
export type PageLikesRepository = Repository<PageLike>;
|
||||
export type PasswordResetRequestsRepository = Repository<PasswordResetRequest>;
|
||||
|
|
|
@ -14,10 +14,6 @@ export const packedNotificationSchema = {
|
|||
optional: false, nullable: false,
|
||||
format: 'date-time',
|
||||
},
|
||||
isRead: {
|
||||
type: 'boolean',
|
||||
optional: false, nullable: false,
|
||||
},
|
||||
type: {
|
||||
type: 'string',
|
||||
optional: false, nullable: false,
|
||||
|
|
|
@ -40,7 +40,6 @@ import { NoteFavorite } from '@/models/entities/NoteFavorite.js';
|
|||
import { NoteReaction } from '@/models/entities/NoteReaction.js';
|
||||
import { NoteThreadMuting } from '@/models/entities/NoteThreadMuting.js';
|
||||
import { NoteUnread } from '@/models/entities/NoteUnread.js';
|
||||
import { Notification } from '@/models/entities/Notification.js';
|
||||
import { Page } from '@/models/entities/Page.js';
|
||||
import { PageLike } from '@/models/entities/PageLike.js';
|
||||
import { PasswordResetRequest } from '@/models/entities/PasswordResetRequest.js';
|
||||
|
@ -155,7 +154,6 @@ export const entities = [
|
|||
DriveFolder,
|
||||
Poll,
|
||||
PollVote,
|
||||
Notification,
|
||||
Emoji,
|
||||
Hashtag,
|
||||
SwSubscription,
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import { In, LessThan } from 'typeorm';
|
||||
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 Logger from '@/logger.js';
|
||||
import { bindThis } from '@/decorators.js';
|
||||
|
@ -20,9 +20,6 @@ export class CleanProcessorService {
|
|||
@Inject(DI.userIpsRepository)
|
||||
private userIpsRepository: UserIpsRepository,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
@Inject(DI.mutedNotesRepository)
|
||||
private mutedNotesRepository: MutedNotesRepository,
|
||||
|
||||
|
@ -46,10 +43,6 @@ export class CleanProcessorService {
|
|||
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({
|
||||
id: LessThan(this.idService.genId(new Date(Date.now() - (1000 * 60 * 60 * 24 * 90)))),
|
||||
reason: 'word',
|
||||
|
|
|
@ -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___notifications_create from './endpoints/notifications/create.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___pages_create from './endpoints/pages/create.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 $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_read: Provider = { provide: 'ep:notifications/read', useClass: ep___notifications_read.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_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,
|
||||
$notifications_create,
|
||||
$notifications_markAllAsRead,
|
||||
$notifications_read,
|
||||
$pagePush,
|
||||
$pages_create,
|
||||
$pages_delete,
|
||||
|
@ -1266,7 +1263,6 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention
|
|||
$notes_userListTimeline,
|
||||
$notifications_create,
|
||||
$notifications_markAllAsRead,
|
||||
$notifications_read,
|
||||
$pagePush,
|
||||
$pages_create,
|
||||
$pages_delete,
|
||||
|
|
|
@ -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___notifications_create from './endpoints/notifications/create.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___pages_create from './endpoints/pages/create.js';
|
||||
import * as ep___pages_delete from './endpoints/pages/delete.js';
|
||||
|
@ -598,7 +597,6 @@ const eps = [
|
|||
['notes/user-list-timeline', ep___notes_userListTimeline],
|
||||
['notifications/create', ep___notifications_create],
|
||||
['notifications/mark-all-as-read', ep___notifications_markAllAsRead],
|
||||
['notifications/read', ep___notifications_read],
|
||||
['page-push', ep___pagePush],
|
||||
['pages/create', ep___pages_create],
|
||||
['pages/delete', ep___pages_delete],
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
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 { GlobalEventService } from '@/core/GlobalEventService.js';
|
||||
import { ModerationLogService } from '@/core/ModerationLogService.js';
|
||||
|
@ -36,9 +36,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
private userEntityService: UserEntityService,
|
||||
private userFollowingService: UserFollowingService,
|
||||
private userSuspendService: UserSuspendService,
|
||||
|
@ -73,7 +70,6 @@ export default class extends Endpoint<typeof meta, typeof paramDef> {
|
|||
(async () => {
|
||||
await this.userSuspendService.doPostSuspend(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);
|
||||
}
|
||||
}
|
||||
|
||||
@bindThis
|
||||
private async readAllNotify(notifier: User) {
|
||||
await this.notificationsRepository.update({
|
||||
notifierId: notifier.id,
|
||||
isRead: false,
|
||||
}, {
|
||||
isRead: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { Brackets } from 'typeorm';
|
||||
import { Brackets, In } from 'typeorm';
|
||||
import Redis from 'ioredis';
|
||||
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 { Endpoint } from '@/server/api/endpoint-base.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 { NotificationService } from '@/core/NotificationService.js';
|
||||
import { DI } from '@/di-symbols.js';
|
||||
import { IdService } from '@/core/IdService.js';
|
||||
import { Notification } from '@/models/entities/Notification.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['account', 'notifications'],
|
||||
|
@ -38,8 +41,6 @@ export const paramDef = {
|
|||
limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 },
|
||||
sinceId: { 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 },
|
||||
// 後方互換のため、廃止された通知タイプも受け付ける
|
||||
includeTypes: { type: 'array', items: {
|
||||
|
@ -56,21 +57,22 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.redis)
|
||||
private redisClient: Redis.Redis,
|
||||
|
||||
@Inject(DI.usersRepository)
|
||||
private usersRepository: UsersRepository,
|
||||
|
||||
@Inject(DI.followingsRepository)
|
||||
private followingsRepository: FollowingsRepository,
|
||||
|
||||
@Inject(DI.mutingsRepository)
|
||||
private mutingsRepository: MutingsRepository,
|
||||
|
||||
@Inject(DI.userProfilesRepository)
|
||||
private userProfilesRepository: UserProfilesRepository,
|
||||
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
@Inject(DI.notesRepository)
|
||||
private notesRepository: NotesRepository,
|
||||
|
||||
private idService: IdService,
|
||||
private notificationEntityService: NotificationEntityService,
|
||||
private notificationService: NotificationService,
|
||||
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 excludeTypes = ps.excludeTypes && ps.excludeTypes.filter(type => !(obsoleteNotificationTypes).includes(type as any)) as typeof notificationTypes[number][];
|
||||
|
||||
const followingQuery = this.followingsRepository.createQueryBuilder('following')
|
||||
.select('following.followeeId')
|
||||
.where('following.followerId = :followerId', { followerId: me.id });
|
||||
const notificationsRes = await this.redisClient.xrevrange(
|
||||
`notificationTimeline:${me.id}`,
|
||||
ps.untilId ? this.idService.parse(ps.untilId).date.getTime() : '+',
|
||||
'-',
|
||||
'COUNT', ps.limit + 1); // untilIdに指定したものも含まれるため+1
|
||||
|
||||
const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
|
||||
.select('muting.muteeId')
|
||||
.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());
|
||||
if (notificationsRes.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
let notifications = notificationsRes.map(x => JSON.parse(x[1][1])).filter(x => x.id !== ps.untilId) as Notification[];
|
||||
|
||||
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) {
|
||||
query.andWhere('notification.type NOT IN (:...excludeTypes)', { excludeTypes });
|
||||
notifications = notifications.filter(notification => !excludeTypes.includes(notification.type));
|
||||
}
|
||||
|
||||
if (ps.unreadOnly) {
|
||||
query.andWhere('notification.isRead = false');
|
||||
if (notifications.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const notifications = await query.take(ps.limit).getMany();
|
||||
|
||||
// Mark all as read
|
||||
if (notifications.length > 0 && ps.markAsRead) {
|
||||
this.notificationService.readNotification(me.id, notifications.map(x => x.id));
|
||||
if (ps.markAsRead) {
|
||||
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);
|
||||
}
|
||||
|
||||
|
|
|
@ -1,9 +1,7 @@
|
|||
import { Inject, Injectable } from '@nestjs/common';
|
||||
import type { NotificationsRepository } from '@/models/index.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 { NotificationService } from '@/core/NotificationService.js';
|
||||
|
||||
export const meta = {
|
||||
tags: ['notifications', 'account'],
|
||||
|
@ -23,24 +21,10 @@ export const paramDef = {
|
|||
@Injectable()
|
||||
export default class extends Endpoint<typeof meta, typeof paramDef> {
|
||||
constructor(
|
||||
@Inject(DI.notificationsRepository)
|
||||
private notificationsRepository: NotificationsRepository,
|
||||
|
||||
private globalEventService: GlobalEventService,
|
||||
private pushNotificationService: PushNotificationService,
|
||||
private notificationService: NotificationService,
|
||||
) {
|
||||
super(meta, paramDef, async (ps, me) => {
|
||||
// Update documents
|
||||
await this.notificationsRepository.update({
|
||||
notifieeId: me.id,
|
||||
isRead: false,
|
||||
}, {
|
||||
isRead: true,
|
||||
});
|
||||
|
||||
// 全ての通知を読みましたよというイベントを発行
|
||||
this.globalEventService.publishMainStream(me.id, 'readAllNotifications');
|
||||
this.pushNotificationService.pushNotification(me.id, 'readAllNotifications', undefined);
|
||||
this.notificationService.readAllNotification(me.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -195,8 +195,7 @@ export default class Connection {
|
|||
|
||||
@bindThis
|
||||
private onReadNotification(payload: any) {
|
||||
if (!payload.id) return;
|
||||
this.notificationService.readNotification(this.user!.id, [payload.id]);
|
||||
this.notificationService.readAllNotification(this.user!.id);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -83,7 +83,7 @@
|
|||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref, shallowRef, onMounted, onUnmounted, watch } from 'vue';
|
||||
import { ref, shallowRef } from 'vue';
|
||||
import * as misskey from 'misskey-js';
|
||||
import MkReactionIcon from '@/components/MkReactionIcon.vue';
|
||||
import MkFollowButton from '@/components/MkFollowButton.vue';
|
||||
|
@ -94,7 +94,6 @@ import { notePage } from '@/filters/note';
|
|||
import { userPage } from '@/filters/user';
|
||||
import { i18n } from '@/i18n';
|
||||
import * as os from '@/os';
|
||||
import { stream } from '@/stream';
|
||||
import { useTooltip } from '@/scripts/use-tooltip';
|
||||
import { $i } from '@/account';
|
||||
|
||||
|
@ -110,35 +109,6 @@ const props = withDefaults(defineProps<{
|
|||
const elRef = shallowRef<HTMLElement>(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 acceptFollowRequest = () => {
|
||||
|
|
|
@ -29,7 +29,6 @@ import { notificationTypes } from '@/const';
|
|||
|
||||
const props = defineProps<{
|
||||
includeTypes?: typeof notificationTypes[number][];
|
||||
unreadOnly?: boolean;
|
||||
}>();
|
||||
|
||||
const pagingComponent = shallowRef<InstanceType<typeof MkPagination>>();
|
||||
|
@ -40,23 +39,17 @@ const pagination: Paging = {
|
|||
params: computed(() => ({
|
||||
includeTypes: props.includeTypes ?? undefined,
|
||||
excludeTypes: props.includeTypes ? undefined : $i.mutingNotificationTypes,
|
||||
unreadOnly: props.unreadOnly,
|
||||
})),
|
||||
};
|
||||
|
||||
const onNotification = (notification) => {
|
||||
const isMuted = props.includeTypes ? !props.includeTypes.includes(notification.type) : $i.mutingNotificationTypes.includes(notification.type);
|
||||
if (isMuted || document.visibilityState === 'visible') {
|
||||
stream.send('readNotification', {
|
||||
id: notification.id,
|
||||
});
|
||||
stream.send('readNotification');
|
||||
}
|
||||
|
||||
if (!isMuted) {
|
||||
pagingComponent.value.prepend({
|
||||
...notification,
|
||||
isRead: document.visibilityState === 'visible',
|
||||
});
|
||||
pagingComponent.value.prepend(notification);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -65,30 +58,6 @@ let connection;
|
|||
onMounted(() => {
|
||||
connection = stream.useChannel('main');
|
||||
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(() => {
|
||||
|
|
|
@ -2,8 +2,8 @@
|
|||
<MkStickyContainer>
|
||||
<template #header><MkPageHeader v-model:tab="tab" :actions="headerActions" :tabs="headerTabs"/></template>
|
||||
<MkSpacer :content-max="800">
|
||||
<div v-if="tab === 'all' || tab === 'unread'">
|
||||
<XNotifications class="notifications" :include-types="includeTypes" :unread-only="unreadOnly"/>
|
||||
<div v-if="tab === 'all'">
|
||||
<XNotifications class="notifications" :include-types="includeTypes"/>
|
||||
</div>
|
||||
<div v-else-if="tab === 'mentions'">
|
||||
<MkNotes :pagination="mentionsPagination"/>
|
||||
|
@ -26,7 +26,6 @@ import { notificationTypes } from '@/const';
|
|||
|
||||
let tab = $ref('all');
|
||||
let includeTypes = $ref<string[] | null>(null);
|
||||
let unreadOnly = $computed(() => tab === 'unread');
|
||||
|
||||
const mentionsPagination = {
|
||||
endpoint: 'notes/mentions' as const,
|
||||
|
@ -76,10 +75,6 @@ const headerTabs = $computed(() => [{
|
|||
key: 'all',
|
||||
title: i18n.ts.all,
|
||||
icon: 'ti ti-point',
|
||||
}, {
|
||||
key: 'unread',
|
||||
title: i18n.ts.unread,
|
||||
icon: 'ti ti-loader',
|
||||
}, {
|
||||
key: 'mentions',
|
||||
title: i18n.ts.mentions,
|
||||
|
|
|
@ -53,9 +53,7 @@ function onNotification(notification) {
|
|||
if ($i.mutingNotificationTypes.includes(notification.type)) return;
|
||||
|
||||
if (document.visibilityState === 'visible') {
|
||||
stream.send('readNotification', {
|
||||
id: notification.id,
|
||||
});
|
||||
stream.send('readNotification');
|
||||
|
||||
notifications.unshift(notification);
|
||||
window.setTimeout(() => {
|
||||
|
|
|
@ -515,7 +515,6 @@ export type Endpoints = {
|
|||
// notifications
|
||||
'notifications/create': { req: { body: string; header?: string | null; icon?: string | null; }; res: null; };
|
||||
'notifications/mark-all-as-read': { req: NoParams; res: null; };
|
||||
'notifications/read': { req: { notificationId: Notification['id']; }; res: null; };
|
||||
|
||||
// page-push
|
||||
'page-push': { req: { pageId: Page['id']; event: string; var?: any; }; res: null; };
|
||||
|
|
|
@ -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();
|
|
@ -1,6 +1,6 @@
|
|||
import { createEmptyNotification, createNotification } from '@/scripts/create-notification';
|
||||
import { swLang } from '@/scripts/lang';
|
||||
import { swNotificationRead } from '@/scripts/notification-read';
|
||||
import { api } from '@/scripts/operations';
|
||||
import { pushNotificationDataMap } from '@/types';
|
||||
import * as swos from '@/scripts/operations';
|
||||
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;
|
||||
|
||||
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();
|
||||
|
@ -154,7 +130,7 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv
|
|||
client.focus();
|
||||
}
|
||||
if (data.type === 'notification') {
|
||||
swNotificationRead.then(that => that.read(data));
|
||||
api('notifications/mark-all-as-read', data.userId);
|
||||
}
|
||||
|
||||
notification.close();
|
||||
|
@ -165,7 +141,7 @@ globalThis.addEventListener('notificationclose', (ev: ServiceWorkerGlobalScopeEv
|
|||
const data: pushNotificationDataMap[keyof pushNotificationDataMap] = ev.notification.data;
|
||||
|
||||
if (data.type === 'notification') {
|
||||
swNotificationRead.then(that => that.read(data));
|
||||
api('notifications/mark-all-as-read', data.userId);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
|
@ -17,10 +17,6 @@ type pushNotificationDataSourceMap = {
|
|||
antenna: { id: string, name: string };
|
||||
note: Misskey.entities.Note;
|
||||
};
|
||||
readNotifications: { notificationIds: string[] };
|
||||
readAllNotifications: undefined;
|
||||
readAntenna: { antennaId: string };
|
||||
readAllAntennas: undefined;
|
||||
};
|
||||
|
||||
export type pushNotificationData<K extends keyof pushNotificationDataSourceMap> = {
|
||||
|
|
Loading…
Reference in New Issue