summaryrefslogtreecommitdiff
path: root/packages/backend/src/core
diff options
context:
space:
mode:
authorsyuilo <4439005+syuilo@users.noreply.github.com>2025-03-24 21:32:46 +0900
committerGitHub <noreply@github.com>2025-03-24 21:32:46 +0900
commitf1f24e39d2df3135493e2c2087230b428e2d02b7 (patch)
treea5ae0e9d2cf810649b2f4e08ef4d00ce7ea91dc9 /packages/backend/src/core
parentfix(frontend): fix broken styles (diff)
downloadmisskey-f1f24e39d2df3135493e2c2087230b428e2d02b7.tar.gz
misskey-f1f24e39d2df3135493e2c2087230b428e2d02b7.tar.bz2
misskey-f1f24e39d2df3135493e2c2087230b428e2d02b7.zip
Feat: Chat (#15686)
* wip * wip * wip * wip * wip * wip * Update types.ts * Create 1742203321812-chat.js * wip * wip * Update room.vue * Update home.vue * Update home.vue * Update ja-JP.yml * Update index.d.ts * wip * wip * wip * wip * wip * wip * wip * wip * wip * wip * Update CHANGELOG.md * wip * Update home.vue * clean up * Update misskey-js.api.md * wip * wip * wip * wip * wip * wip * wip * wip * wip * lint fixes * lint * Update UserEntityService.ts * search * wip * 🎨 * wip * Update home.ownedRooms.vue * wip * Update CHANGELOG.md * Update style.scss * wip * improve performance * improve performance * Update timeline.test.ts
Diffstat (limited to 'packages/backend/src/core')
-rw-r--r--packages/backend/src/core/ChatService.ts776
-rw-r--r--packages/backend/src/core/CoreModule.ts18
-rw-r--r--packages/backend/src/core/GlobalEventService.ts38
-rw-r--r--packages/backend/src/core/NoteCreateService.ts27
-rw-r--r--packages/backend/src/core/NoteReadService.ts143
-rw-r--r--packages/backend/src/core/RoleService.ts3
-rw-r--r--packages/backend/src/core/UserFollowingService.ts28
-rw-r--r--packages/backend/src/core/WebhookTestService.ts2
-rw-r--r--packages/backend/src/core/entities/ChatEntityService.ts376
-rw-r--r--packages/backend/src/core/entities/UserEntityService.ts20
10 files changed, 1234 insertions, 197 deletions
diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts
new file mode 100644
index 0000000000..eece5d0da3
--- /dev/null
+++ b/packages/backend/src/core/ChatService.ts
@@ -0,0 +1,776 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import * as Redis from 'ioredis';
+import { Brackets } from 'typeorm';
+import { DI } from '@/di-symbols.js';
+import type { Config } from '@/config.js';
+import { QueueService } from '@/core/QueueService.js';
+import { IdService } from '@/core/IdService.js';
+import { GlobalEventService } from '@/core/GlobalEventService.js';
+import { UserEntityService } from '@/core/entities/UserEntityService.js';
+import { ChatEntityService } from '@/core/entities/ChatEntityService.js';
+import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
+import { PushNotificationService } from '@/core/PushNotificationService.js';
+import { bindThis } from '@/decorators.js';
+import type { ChatApprovalsRepository, ChatMessagesRepository, ChatRoomInvitationsRepository, ChatRoomMembershipsRepository, ChatRoomsRepository, MiChatMessage, MiChatRoom, MiChatRoomMembership, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js';
+import { UserBlockingService } from '@/core/UserBlockingService.js';
+import { QueryService } from '@/core/QueryService.js';
+import { RoleService } from '@/core/RoleService.js';
+import { UserFollowingService } from '@/core/UserFollowingService.js';
+import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js';
+import { Packed } from '@/misc/json-schema.js';
+import { sqlLikeEscape } from '@/misc/sql-like-escape.js';
+import { CustomEmojiService } from '@/core/CustomEmojiService.js';
+import { emojiRegex } from '@/misc/emoji-regex.js';
+
+const MAX_ROOM_MEMBERS = 30;
+const MAX_REACTIONS_PER_MESSAGE = 100;
+const isCustomEmojiRegexp = /^:([\w+-]+)(?:@\.)?:$/;
+
+@Injectable()
+export class ChatService {
+ constructor(
+ @Inject(DI.config)
+ private config: Config,
+
+ @Inject(DI.redis)
+ private redisClient: Redis.Redis,
+
+ @Inject(DI.usersRepository)
+ private usersRepository: UsersRepository,
+
+ @Inject(DI.chatMessagesRepository)
+ private chatMessagesRepository: ChatMessagesRepository,
+
+ @Inject(DI.chatApprovalsRepository)
+ private chatApprovalsRepository: ChatApprovalsRepository,
+
+ @Inject(DI.chatRoomsRepository)
+ private chatRoomsRepository: ChatRoomsRepository,
+
+ @Inject(DI.chatRoomInvitationsRepository)
+ private chatRoomInvitationsRepository: ChatRoomInvitationsRepository,
+
+ @Inject(DI.chatRoomMembershipsRepository)
+ private chatRoomMembershipsRepository: ChatRoomMembershipsRepository,
+
+ @Inject(DI.mutingsRepository)
+ private mutingsRepository: MutingsRepository,
+
+ private userEntityService: UserEntityService,
+ private chatEntityService: ChatEntityService,
+ private idService: IdService,
+ private globalEventService: GlobalEventService,
+ private apRendererService: ApRendererService,
+ private queueService: QueueService,
+ private pushNotificationService: PushNotificationService,
+ private userBlockingService: UserBlockingService,
+ private queryService: QueryService,
+ private roleService: RoleService,
+ private userFollowingService: UserFollowingService,
+ private customEmojiService: CustomEmojiService,
+ ) {
+ }
+
+ @bindThis
+ public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: {
+ text?: string | null;
+ file?: MiDriveFile | null;
+ uri?: string | null;
+ }): Promise<Packed<'ChatMessageLite'>> {
+ if (fromUser.id === toUser.id) {
+ throw new Error('yourself');
+ }
+
+ const approvals = await this.chatApprovalsRepository.createQueryBuilder('approval')
+ .where(new Brackets(qb => { // 自分が相手を許可しているか
+ qb.where('approval.userId = :fromUserId', { fromUserId: fromUser.id })
+ .andWhere('approval.otherId = :toUserId', { toUserId: toUser.id });
+ }))
+ .orWhere(new Brackets(qb => { // 相手が自分を許可しているか
+ qb.where('approval.userId = :toUserId', { toUserId: toUser.id })
+ .andWhere('approval.otherId = :fromUserId', { fromUserId: fromUser.id });
+ }))
+ .take(2)
+ .getMany();
+
+ const otherApprovedMe = approvals.some(approval => approval.userId === toUser.id);
+ const iApprovedOther = approvals.some(approval => approval.userId === fromUser.id);
+
+ if (!otherApprovedMe) {
+ if (toUser.chatScope === 'none') {
+ throw new Error('recipient is cannot chat (none)');
+ } else if (toUser.chatScope === 'followers') {
+ const isFollower = await this.userFollowingService.isFollowing(fromUser.id, toUser.id);
+ if (!isFollower) {
+ throw new Error('recipient is cannot chat (followers)');
+ }
+ } else if (toUser.chatScope === 'following') {
+ const isFollowing = await this.userFollowingService.isFollowing(toUser.id, fromUser.id);
+ if (!isFollowing) {
+ throw new Error('recipient is cannot chat (following)');
+ }
+ } else if (toUser.chatScope === 'mutual') {
+ const isMutual = await this.userFollowingService.isMutual(fromUser.id, toUser.id);
+ if (!isMutual) {
+ throw new Error('recipient is cannot chat (mutual)');
+ }
+ }
+ }
+
+ if (!(await this.roleService.getUserPolicies(toUser.id)).canChat) {
+ throw new Error('recipient is cannot chat (policy)');
+ }
+
+ const blocked = await this.userBlockingService.checkBlocked(toUser.id, fromUser.id);
+ if (blocked) {
+ throw new Error('blocked');
+ }
+
+ const message = {
+ id: this.idService.gen(),
+ fromUserId: fromUser.id,
+ toUserId: toUser.id,
+ text: params.text ? params.text.trim() : null,
+ fileId: params.file ? params.file.id : null,
+ reads: [],
+ uri: params.uri ?? null,
+ } satisfies Partial<MiChatMessage>;
+
+ const inserted = await this.chatMessagesRepository.insertOne(message);
+
+ // 相手を許可しておく
+ if (!iApprovedOther) {
+ this.chatApprovalsRepository.insertOne({
+ id: this.idService.gen(),
+ userId: fromUser.id,
+ otherId: toUser.id,
+ });
+ }
+
+ const packedMessage = await this.chatEntityService.packMessageLiteFor1on1(inserted);
+
+ if (this.userEntityService.isLocalUser(toUser)) {
+ const redisPipeline = this.redisClient.pipeline();
+ redisPipeline.set(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`, message.id);
+ redisPipeline.sadd(`newChatMessagesExists:${toUser.id}`, `user:${fromUser.id}`);
+ redisPipeline.exec();
+ }
+
+ if (this.userEntityService.isLocalUser(fromUser)) {
+ // 自分のストリーム
+ this.globalEventService.publishChatUserStream(fromUser.id, toUser.id, 'message', packedMessage);
+ }
+
+ if (this.userEntityService.isLocalUser(toUser)) {
+ // 相手のストリーム
+ this.globalEventService.publishChatUserStream(toUser.id, fromUser.id, 'message', packedMessage);
+ }
+
+ // 3秒経っても既読にならなかったらイベント発行
+ if (this.userEntityService.isLocalUser(toUser)) {
+ setTimeout(async () => {
+ const marker = await this.redisClient.get(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`);
+
+ if (marker == null) return; // 既読
+
+ const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser);
+ this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo);
+ //this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo);
+ }, 3000);
+ }
+
+ return packedMessage;
+ }
+
+ @bindThis
+ public async createMessageToRoom(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toRoom: MiChatRoom, params: {
+ text?: string | null;
+ file?: MiDriveFile | null;
+ uri?: string | null;
+ }): Promise<Packed<'ChatMessageLite'>> {
+ const memberships = await this.chatRoomMembershipsRepository.findBy({ roomId: toRoom.id });
+
+ if (toRoom.ownerId !== fromUser.id && !memberships.some(member => member.userId === fromUser.id)) {
+ throw new Error('you are not a member of the room');
+ }
+
+ const message = {
+ id: this.idService.gen(),
+ fromUserId: fromUser.id,
+ toRoomId: toRoom.id,
+ text: params.text ? params.text.trim() : null,
+ fileId: params.file ? params.file.id : null,
+ reads: [],
+ uri: params.uri ?? null,
+ } satisfies Partial<MiChatMessage>;
+
+ const inserted = await this.chatMessagesRepository.insertOne(message);
+
+ const packedMessage = await this.chatEntityService.packMessageLiteForRoom(inserted);
+
+ this.globalEventService.publishChatRoomStream(toRoom.id, 'message', packedMessage);
+
+ const redisPipeline = this.redisClient.pipeline();
+ for (const membership of memberships) {
+ if (membership.isMuted) continue;
+
+ redisPipeline.set(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`, message.id);
+ redisPipeline.sadd(`newChatMessagesExists:${membership.userId}`, `room:${toRoom.id}`);
+ }
+ redisPipeline.exec();
+
+ // 3秒経っても既読にならなかったらイベント発行
+ setTimeout(async () => {
+ const redisPipeline = this.redisClient.pipeline();
+ for (const membership of memberships) {
+ redisPipeline.get(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`);
+ }
+ const markers = await redisPipeline.exec();
+ if (markers == null) throw new Error('redis error');
+
+ if (markers.every(marker => marker[1] == null)) return;
+
+ const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted);
+
+ for (let i = 0; i < memberships.length; i++) {
+ const marker = markers[i][1];
+ if (marker == null) continue;
+
+ this.globalEventService.publishMainStream(memberships[i].userId, 'newChatMessage', packedMessageForTo);
+ //this.pushNotificationService.pushNotification(memberships[i].userId, 'newChatMessage', packedMessageForTo);
+ }
+ }, 3000);
+
+ return packedMessage;
+ }
+
+ @bindThis
+ public async readUserChatMessage(
+ readerId: MiUser['id'],
+ senderId: MiUser['id'],
+ ): Promise<void> {
+ const redisPipeline = this.redisClient.pipeline();
+ redisPipeline.del(`newUserChatMessageExists:${readerId}:${senderId}`);
+ redisPipeline.srem(`newChatMessagesExists:${readerId}`, `user:${senderId}`);
+ await redisPipeline.exec();
+ }
+
+ @bindThis
+ public async readRoomChatMessage(
+ readerId: MiUser['id'],
+ roomId: MiChatRoom['id'],
+ ): Promise<void> {
+ const redisPipeline = this.redisClient.pipeline();
+ redisPipeline.del(`newRoomChatMessageExists:${readerId}:${roomId}`);
+ redisPipeline.srem(`newChatMessagesExists:${readerId}`, `room:${roomId}`);
+ await redisPipeline.exec();
+ }
+
+ @bindThis
+ public findMessageById(messageId: MiChatMessage['id']) {
+ return this.chatMessagesRepository.findOneBy({ id: messageId });
+ }
+
+ @bindThis
+ public findMyMessageById(userId: MiUser['id'], messageId: MiChatMessage['id']) {
+ return this.chatMessagesRepository.findOneBy({ id: messageId, fromUserId: userId });
+ }
+
+ @bindThis
+ public async deleteMessage(message: MiChatMessage) {
+ await this.chatMessagesRepository.delete(message.id);
+
+ if (message.toUserId) {
+ const [fromUser, toUser] = await Promise.all([
+ this.usersRepository.findOneByOrFail({ id: message.fromUserId }),
+ this.usersRepository.findOneByOrFail({ id: message.toUserId }),
+ ]);
+
+ if (this.userEntityService.isLocalUser(fromUser)) this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId, 'deleted', message.id);
+ if (this.userEntityService.isLocalUser(toUser)) this.globalEventService.publishChatUserStream(message.toUserId, message.fromUserId, 'deleted', message.id);
+
+ if (this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) {
+ //const activity = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), fromUser));
+ //this.queueService.deliver(fromUser, activity, toUser.inbox);
+ }
+ } else if (message.toRoomId) {
+ this.globalEventService.publishChatRoomStream(message.toRoomId, 'deleted', message.id);
+ }
+ }
+
+ @bindThis
+ public async userTimeline(meId: MiUser['id'], otherId: MiUser['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) {
+ const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId)
+ .andWhere(new Brackets(qb => {
+ qb
+ .where(new Brackets(qb => {
+ qb
+ .where('message.fromUserId = :meId')
+ .andWhere('message.toUserId = :otherId');
+ }))
+ .orWhere(new Brackets(qb => {
+ qb
+ .where('message.fromUserId = :otherId')
+ .andWhere('message.toUserId = :meId');
+ }));
+ }))
+ .setParameter('meId', meId)
+ .setParameter('otherId', otherId);
+
+ const messages = await query.take(limit).getMany();
+
+ return messages;
+ }
+
+ @bindThis
+ public async roomTimeline(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatMessage['id'] | null, untilId?: MiChatMessage['id'] | null) {
+ const query = this.queryService.makePaginationQuery(this.chatMessagesRepository.createQueryBuilder('message'), sinceId, untilId)
+ .where('message.toRoomId = :roomId', { roomId })
+ .leftJoinAndSelect('message.file', 'file')
+ .leftJoinAndSelect('message.fromUser', 'fromUser');
+
+ const messages = await query.take(limit).getMany();
+
+ return messages;
+ }
+
+ @bindThis
+ public async userHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> {
+ const history: MiChatMessage[] = [];
+
+ const mutingQuery = this.mutingsRepository.createQueryBuilder('muting')
+ .select('muting.muteeId')
+ .where('muting.muterId = :muterId', { muterId: meId });
+
+ for (let i = 0; i < limit; i++) {
+ const found = history.map(m => (m.fromUserId === meId) ? m.toUserId! : m.fromUserId!);
+
+ const query = this.chatMessagesRepository.createQueryBuilder('message')
+ .orderBy('message.id', 'DESC')
+ .where(new Brackets(qb => {
+ qb
+ .where('message.fromUserId = :meId', { meId: meId })
+ .orWhere('message.toUserId = :meId', { meId: meId });
+ }))
+ .andWhere('message.toRoomId IS NULL')
+ .andWhere(`message.fromUserId NOT IN (${ mutingQuery.getQuery() })`)
+ .andWhere(`message.toUserId NOT IN (${ mutingQuery.getQuery() })`);
+
+ if (found.length > 0) {
+ query.andWhere('message.fromUserId NOT IN (:...found)', { found: found });
+ query.andWhere('message.toUserId NOT IN (:...found)', { found: found });
+ }
+
+ query.setParameters(mutingQuery.getParameters());
+
+ const message = await query.getOne();
+
+ if (message) {
+ history.push(message);
+ } else {
+ break;
+ }
+ }
+
+ return history;
+ }
+
+ @bindThis
+ public async roomHistory(meId: MiUser['id'], limit: number): Promise<MiChatMessage[]> {
+ // TODO: 一回のクエリにまとめられるかも
+ const [memberRoomIds, ownedRoomIds] = await Promise.all([
+ this.chatRoomMembershipsRepository.findBy({
+ userId: meId,
+ }).then(xs => xs.map(x => x.roomId)),
+ this.chatRoomsRepository.findBy({
+ ownerId: meId,
+ }).then(xs => xs.map(x => x.id)),
+ ]);
+
+ const roomIds = memberRoomIds.concat(ownedRoomIds);
+
+ if (memberRoomIds.length === 0 && ownedRoomIds.length === 0) {
+ return [];
+ }
+
+ const history: MiChatMessage[] = [];
+
+ for (let i = 0; i < limit; i++) {
+ const found = history.map(m => m.toRoomId!);
+
+ const query = this.chatMessagesRepository.createQueryBuilder('message')
+ .orderBy('message.id', 'DESC')
+ .where('message.toRoomId IN (:...roomIds)', { roomIds });
+
+ if (found.length > 0) {
+ query.andWhere('message.toRoomId NOT IN (:...found)', { found: found });
+ }
+
+ const message = await query.getOne();
+
+ if (message) {
+ history.push(message);
+ } else {
+ break;
+ }
+ }
+
+ return history;
+ }
+
+ @bindThis
+ public async getUserReadStateMap(userId: MiUser['id'], otherIds: MiUser['id'][]) {
+ const readStateMap: Record<MiUser['id'], boolean> = {};
+
+ const redisPipeline = this.redisClient.pipeline();
+
+ for (const otherId of otherIds) {
+ redisPipeline.get(`newUserChatMessageExists:${userId}:${otherId}`);
+ }
+
+ const markers = await redisPipeline.exec();
+ if (markers == null) throw new Error('redis error');
+
+ for (let i = 0; i < otherIds.length; i++) {
+ const marker = markers[i][1];
+ readStateMap[otherIds[i]] = marker == null;
+ }
+
+ return readStateMap;
+ }
+
+ @bindThis
+ public async getRoomReadStateMap(userId: MiUser['id'], roomIds: MiChatRoom['id'][]) {
+ const readStateMap: Record<MiChatRoom['id'], boolean> = {};
+
+ const redisPipeline = this.redisClient.pipeline();
+
+ for (const roomId of roomIds) {
+ redisPipeline.get(`newRoomChatMessageExists:${userId}:${roomId}`);
+ }
+
+ const markers = await redisPipeline.exec();
+ if (markers == null) throw new Error('redis error');
+
+ for (let i = 0; i < roomIds.length; i++) {
+ const marker = markers[i][1];
+ readStateMap[roomIds[i]] = marker == null;
+ }
+
+ return readStateMap;
+ }
+
+ @bindThis
+ public async hasUnreadMessages(userId: MiUser['id']) {
+ const card = await this.redisClient.scard(`newChatMessagesExists:${userId}`);
+ return card > 0;
+ }
+
+ @bindThis
+ public async createRoom(owner: MiUser, params: Partial<{
+ name: string;
+ description: string;
+ }>) {
+ const room = {
+ id: this.idService.gen(),
+ name: params.name,
+ description: params.description,
+ ownerId: owner.id,
+ } satisfies Partial<MiChatRoom>;
+
+ const created = await this.chatRoomsRepository.insertOne(room);
+
+ return created;
+ }
+
+ @bindThis
+ public async deleteRoom(room: MiChatRoom) {
+ await this.chatRoomsRepository.delete(room.id);
+ }
+
+ @bindThis
+ public async findMyRoomById(ownerId: MiUser['id'], roomId: MiChatRoom['id']) {
+ return this.chatRoomsRepository.findOneBy({ id: roomId, ownerId: ownerId });
+ }
+
+ @bindThis
+ public async findRoomById(roomId: MiChatRoom['id']) {
+ return this.chatRoomsRepository.findOne({ where: { id: roomId }, relations: ['owner'] });
+ }
+
+ @bindThis
+ public async isRoomMember(room: MiChatRoom, userId: MiUser['id']) {
+ if (room.ownerId === userId) return true;
+ const membership = await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId });
+ return membership != null;
+ }
+
+ @bindThis
+ public async createRoomInvitation(inviterId: MiUser['id'], roomId: MiChatRoom['id'], inviteeId: MiUser['id']) {
+ if (inviterId === inviteeId) {
+ throw new Error('yourself');
+ }
+
+ const room = await this.chatRoomsRepository.findOneByOrFail({ id: roomId, ownerId: inviterId });
+
+ const existingInvitation = await this.chatRoomInvitationsRepository.findOneBy({ roomId, userId: inviteeId });
+ if (existingInvitation) {
+ throw new Error('already invited');
+ }
+
+ const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId });
+ if (membershipsCount >= MAX_ROOM_MEMBERS) {
+ throw new Error('room is full');
+ }
+
+ // TODO: cehck block
+
+ const invitation = {
+ id: this.idService.gen(),
+ roomId: room.id,
+ userId: inviteeId,
+ } satisfies Partial<MiChatRoomInvitation>;
+
+ const created = await this.chatRoomInvitationsRepository.insertOne(invitation);
+
+ return created;
+ }
+
+ @bindThis
+ public async getOwnedRoomsWithPagination(ownerId: MiUser['id'], limit: number, sinceId?: MiChatRoom['id'] | null, untilId?: MiChatRoom['id'] | null) {
+ const query = this.queryService.makePaginationQuery(this.chatRoomsRepository.createQueryBuilder('room'), sinceId, untilId)
+ .where('room.ownerId = :ownerId', { ownerId });
+
+ const rooms = await query.take(limit).getMany();
+
+ return rooms;
+ }
+
+ @bindThis
+ public async getReceivedRoomInvitationsWithPagination(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomInvitation['id'] | null, untilId?: MiChatRoomInvitation['id'] | null) {
+ const query = this.queryService.makePaginationQuery(this.chatRoomInvitationsRepository.createQueryBuilder('invitation'), sinceId, untilId)
+ .where('invitation.userId = :userId', { userId })
+ .andWhere('invitation.ignored = FALSE');
+
+ const invitations = await query.take(limit).getMany();
+
+ return invitations;
+ }
+
+ @bindThis
+ public async joinToRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) {
+ const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId });
+
+ const membershipsCount = await this.chatRoomMembershipsRepository.countBy({ roomId });
+ if (membershipsCount >= MAX_ROOM_MEMBERS) {
+ throw new Error('room is full');
+ }
+
+ const membership = {
+ id: this.idService.gen(),
+ roomId: roomId,
+ userId: userId,
+ } satisfies Partial<MiChatRoomMembership>;
+
+ // TODO: transaction
+ await this.chatRoomMembershipsRepository.insertOne(membership);
+ await this.chatRoomInvitationsRepository.delete(invitation.id);
+ }
+
+ @bindThis
+ public async ignoreRoomInvitation(userId: MiUser['id'], roomId: MiChatRoom['id']) {
+ const invitation = await this.chatRoomInvitationsRepository.findOneByOrFail({ roomId, userId });
+ await this.chatRoomInvitationsRepository.update(invitation.id, { ignored: true });
+ }
+
+ @bindThis
+ public async leaveRoom(userId: MiUser['id'], roomId: MiChatRoom['id']) {
+ const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId });
+ await this.chatRoomMembershipsRepository.delete(membership.id);
+ }
+
+ @bindThis
+ public async muteRoom(userId: MiUser['id'], roomId: MiChatRoom['id'], mute: boolean) {
+ const membership = await this.chatRoomMembershipsRepository.findOneByOrFail({ roomId, userId });
+ await this.chatRoomMembershipsRepository.update(membership.id, { isMuted: mute });
+ }
+
+ @bindThis
+ public async updateRoom(room: MiChatRoom, params: {
+ name?: string;
+ description?: string;
+ }): Promise<MiChatRoom> {
+ return this.chatRoomsRepository.createQueryBuilder().update()
+ .set(params)
+ .where('id = :id', { id: room.id })
+ .returning('*')
+ .execute()
+ .then((response) => {
+ return response.raw[0];
+ });
+ }
+
+ @bindThis
+ public async getRoomMembershipsWithPagination(roomId: MiChatRoom['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
+ const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
+ .where('membership.roomId = :roomId', { roomId });
+
+ const memberships = await query.take(limit).getMany();
+
+ return memberships;
+ }
+
+ @bindThis
+ public async searchMessages(meId: MiUser['id'], query: string, limit: number, params: {
+ userId?: MiUser['id'] | null;
+ roomId?: MiChatRoom['id'] | null;
+ }) {
+ const q = this.chatMessagesRepository.createQueryBuilder('message');
+
+ if (params.userId) {
+ q.andWhere(new Brackets(qb => {
+ qb
+ .where(new Brackets(qb => {
+ qb
+ .where('message.fromUserId = :meId')
+ .andWhere('message.toUserId = :otherId');
+ }))
+ .orWhere(new Brackets(qb => {
+ qb
+ .where('message.fromUserId = :otherId')
+ .andWhere('message.toUserId = :meId');
+ }));
+ }))
+ .setParameter('meId', meId)
+ .setParameter('otherId', params.userId);
+ } else if (params.roomId) {
+ q.where('message.toRoomId = :roomId', { roomId: params.roomId });
+ } else {
+ const membershipsQuery = this.chatRoomMembershipsRepository.createQueryBuilder('membership')
+ .select('membership.roomId')
+ .where('membership.userId = :meId', { meId: meId });
+
+ const ownedRoomsQuery = this.chatRoomsRepository.createQueryBuilder('room')
+ .select('room.id')
+ .where('room.ownerId = :meId', { meId });
+
+ q.andWhere(new Brackets(qb => {
+ qb
+ .where('message.fromUserId = :meId')
+ .orWhere('message.toUserId = :meId')
+ .orWhere(`message.toRoomId IN (${membershipsQuery.getQuery()})`)
+ .orWhere(`message.toRoomId IN (${ownedRoomsQuery.getQuery()})`);
+ }));
+
+ q.setParameters(membershipsQuery.getParameters());
+ q.setParameters(ownedRoomsQuery.getParameters());
+ }
+
+ q.andWhere('LOWER(message.text) LIKE :q', { q: `%${ sqlLikeEscape(query.toLowerCase()) }%` });
+
+ q.leftJoinAndSelect('message.file', 'file');
+ q.leftJoinAndSelect('message.fromUser', 'fromUser');
+ q.leftJoinAndSelect('message.toUser', 'toUser');
+ q.leftJoinAndSelect('message.toRoom', 'toRoom');
+ q.leftJoinAndSelect('toRoom.owner', 'toRoomOwner');
+
+ const messages = await q.orderBy('message.id', 'DESC').take(limit).getMany();
+
+ return messages;
+ }
+
+ @bindThis
+ public async react(messageId: MiChatMessage['id'], userId: MiUser['id'], reaction_: string) {
+ let reaction;
+
+ // TODO: ReactionServiceのやつと共通化
+ function normalize(x: string) {
+ const match = emojiRegex.exec(x);
+ if (match) {
+ // 合字を含む1つの絵文字
+ const unicode = match[0];
+
+ // 異体字セレクタ除去
+ return unicode.match('\u200d') ? unicode : unicode.replace(/\ufe0f/g, '');
+ } else {
+ throw new Error('invalid emoji');
+ }
+ }
+
+ const custom = reaction_.match(isCustomEmojiRegexp);
+
+ if (custom == null) {
+ reaction = normalize(reaction_);
+ } else {
+ const name = custom[1];
+ const emoji = (await this.customEmojiService.localEmojisCache.fetch()).get(name);
+
+ if (emoji == null) {
+ throw new Error('no such emoji');
+ } else {
+ reaction = `:${name}:`;
+ }
+ }
+
+ const message = await this.chatMessagesRepository.findOneByOrFail({ id: messageId });
+
+ if (message.fromUserId === userId) {
+ throw new Error('cannot react to own message');
+ }
+
+ if (message.toRoomId === null && message.toUserId !== userId) {
+ throw new Error('cannot react to others message');
+ }
+
+ if (message.reactions.length >= MAX_REACTIONS_PER_MESSAGE) {
+ throw new Error('too many reactions');
+ }
+
+ const room = message.toRoomId ? await this.chatRoomsRepository.findOneByOrFail({ id: message.toRoomId }) : null;
+
+ if (room) {
+ if (!await this.isRoomMember(room, userId)) {
+ throw new Error('cannot react to others message');
+ }
+ }
+
+ await this.chatMessagesRepository.createQueryBuilder().update()
+ .set({
+ reactions: () => `array_append("reactions", '${userId}/${reaction}')`,
+ })
+ .where('id = :id', { id: message.id })
+ .execute();
+
+ if (room) {
+ this.globalEventService.publishChatRoomStream(room.id, 'react', {
+ messageId: message.id,
+ user: await this.userEntityService.pack(userId),
+ reaction,
+ });
+ } else {
+ this.globalEventService.publishChatUserStream(message.fromUserId, message.toUserId!, 'react', {
+ messageId: message.id,
+ reaction,
+ });
+ this.globalEventService.publishChatUserStream(message.toUserId!, message.fromUserId, 'react', {
+ messageId: message.id,
+ reaction,
+ });
+ }
+ }
+
+ @bindThis
+ public async getMyMemberships(userId: MiUser['id'], limit: number, sinceId?: MiChatRoomMembership['id'] | null, untilId?: MiChatRoomMembership['id'] | null) {
+ const query = this.queryService.makePaginationQuery(this.chatRoomMembershipsRepository.createQueryBuilder('membership'), sinceId, untilId)
+ .where('membership.userId = :userId', { userId });
+
+ const memberships = await query.take(limit).getMany();
+
+ return memberships;
+ }
+}
diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts
index dc85a23e5b..d8617e343c 100644
--- a/packages/backend/src/core/CoreModule.ts
+++ b/packages/backend/src/core/CoreModule.ts
@@ -44,7 +44,6 @@ import { ModerationLogService } from './ModerationLogService.js';
import { NoteCreateService } from './NoteCreateService.js';
import { NoteDeleteService } from './NoteDeleteService.js';
import { NotePiningService } from './NotePiningService.js';
-import { NoteReadService } from './NoteReadService.js';
import { NotificationService } from './NotificationService.js';
import { PollService } from './PollService.js';
import { PushNotificationService } from './PushNotificationService.js';
@@ -75,6 +74,7 @@ import { ClipService } from './ClipService.js';
import { FeaturedService } from './FeaturedService.js';
import { FanoutTimelineService } from './FanoutTimelineService.js';
import { ChannelFollowingService } from './ChannelFollowingService.js';
+import { ChatService } from './ChatService.js';
import { RegistryApiService } from './RegistryApiService.js';
import { ReversiService } from './ReversiService.js';
@@ -100,6 +100,7 @@ import { AppEntityService } from './entities/AppEntityService.js';
import { AuthSessionEntityService } from './entities/AuthSessionEntityService.js';
import { BlockingEntityService } from './entities/BlockingEntityService.js';
import { ChannelEntityService } from './entities/ChannelEntityService.js';
+import { ChatEntityService } from './entities/ChatEntityService.js';
import { ClipEntityService } from './entities/ClipEntityService.js';
import { DriveFileEntityService } from './entities/DriveFileEntityService.js';
import { DriveFolderEntityService } from './entities/DriveFolderEntityService.js';
@@ -184,7 +185,6 @@ const $ModerationLogService: Provider = { provide: 'ModerationLogService', useEx
const $NoteCreateService: Provider = { provide: 'NoteCreateService', useExisting: NoteCreateService };
const $NoteDeleteService: Provider = { provide: 'NoteDeleteService', useExisting: NoteDeleteService };
const $NotePiningService: Provider = { provide: 'NotePiningService', useExisting: NotePiningService };
-const $NoteReadService: Provider = { provide: 'NoteReadService', useExisting: NoteReadService };
const $NotificationService: Provider = { provide: 'NotificationService', useExisting: NotificationService };
const $PollService: Provider = { provide: 'PollService', useExisting: PollService };
const $SystemAccountService: Provider = { provide: 'SystemAccountService', useExisting: SystemAccountService };
@@ -221,6 +221,7 @@ const $FeaturedService: Provider = { provide: 'FeaturedService', useExisting: Fe
const $FanoutTimelineService: Provider = { provide: 'FanoutTimelineService', useExisting: FanoutTimelineService };
const $FanoutTimelineEndpointService: Provider = { provide: 'FanoutTimelineEndpointService', useExisting: FanoutTimelineEndpointService };
const $ChannelFollowingService: Provider = { provide: 'ChannelFollowingService', useExisting: ChannelFollowingService };
+const $ChatService: Provider = { provide: 'ChatService', useExisting: ChatService };
const $RegistryApiService: Provider = { provide: 'RegistryApiService', useExisting: RegistryApiService };
const $ReversiService: Provider = { provide: 'ReversiService', useExisting: ReversiService };
@@ -247,6 +248,7 @@ const $AppEntityService: Provider = { provide: 'AppEntityService', useExisting:
const $AuthSessionEntityService: Provider = { provide: 'AuthSessionEntityService', useExisting: AuthSessionEntityService };
const $BlockingEntityService: Provider = { provide: 'BlockingEntityService', useExisting: BlockingEntityService };
const $ChannelEntityService: Provider = { provide: 'ChannelEntityService', useExisting: ChannelEntityService };
+const $ChatEntityService: Provider = { provide: 'ChatEntityService', useExisting: ChatEntityService };
const $ClipEntityService: Provider = { provide: 'ClipEntityService', useExisting: ClipEntityService };
const $DriveFileEntityService: Provider = { provide: 'DriveFileEntityService', useExisting: DriveFileEntityService };
const $DriveFolderEntityService: Provider = { provide: 'DriveFolderEntityService', useExisting: DriveFolderEntityService };
@@ -333,7 +335,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteCreateService,
NoteDeleteService,
NotePiningService,
- NoteReadService,
NotificationService,
PollService,
SystemAccountService,
@@ -370,6 +371,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineService,
FanoutTimelineEndpointService,
ChannelFollowingService,
+ ChatService,
RegistryApiService,
ReversiService,
@@ -396,6 +398,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AuthSessionEntityService,
BlockingEntityService,
ChannelEntityService,
+ ChatEntityService,
ClipEntityService,
DriveFileEntityService,
DriveFolderEntityService,
@@ -478,7 +481,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteCreateService,
$NoteDeleteService,
$NotePiningService,
- $NoteReadService,
$NotificationService,
$PollService,
$SystemAccountService,
@@ -515,6 +517,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineService,
$FanoutTimelineEndpointService,
$ChannelFollowingService,
+ $ChatService,
$RegistryApiService,
$ReversiService,
@@ -541,6 +544,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AuthSessionEntityService,
$BlockingEntityService,
$ChannelEntityService,
+ $ChatEntityService,
$ClipEntityService,
$DriveFileEntityService,
$DriveFolderEntityService,
@@ -624,7 +628,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
NoteCreateService,
NoteDeleteService,
NotePiningService,
- NoteReadService,
NotificationService,
PollService,
SystemAccountService,
@@ -661,6 +664,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
FanoutTimelineService,
FanoutTimelineEndpointService,
ChannelFollowingService,
+ ChatService,
RegistryApiService,
ReversiService,
@@ -686,6 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
AuthSessionEntityService,
BlockingEntityService,
ChannelEntityService,
+ ChatEntityService,
ClipEntityService,
DriveFileEntityService,
DriveFolderEntityService,
@@ -768,7 +773,6 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$NoteCreateService,
$NoteDeleteService,
$NotePiningService,
- $NoteReadService,
$NotificationService,
$PollService,
$SystemAccountService,
@@ -804,6 +808,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$FanoutTimelineService,
$FanoutTimelineEndpointService,
$ChannelFollowingService,
+ $ChatService,
$RegistryApiService,
$ReversiService,
@@ -829,6 +834,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting
$AuthSessionEntityService,
$BlockingEntityService,
$ChannelEntityService,
+ $ChatEntityService,
$ClipEntityService,
$DriveFileEntityService,
$DriveFolderEntityService,
diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts
index 224fdabc4c..4da3b8bc78 100644
--- a/packages/backend/src/core/GlobalEventService.ts
+++ b/packages/backend/src/core/GlobalEventService.ts
@@ -20,7 +20,7 @@ import type { MiPage } from '@/models/Page.js';
import type { MiWebhook } from '@/models/Webhook.js';
import type { MiSystemWebhook } from '@/models/SystemWebhook.js';
import type { MiMeta } from '@/models/Meta.js';
-import { MiAvatarDecoration, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
+import { MiAvatarDecoration, MiChatMessage, MiChatRoom, MiReversiGame, MiRole, MiRoleAssignment } from '@/models/_.js';
import type { Packed } from '@/misc/json-schema.js';
import { DI } from '@/di-symbols.js';
import type { Config } from '@/config.js';
@@ -72,12 +72,8 @@ export interface MainEventTypes {
readAllNotifications: undefined;
notificationFlushed: undefined;
unreadNotification: Packed<'Notification'>;
- unreadMention: MiNote['id'];
- readAllUnreadMentions: undefined;
- unreadSpecifiedNote: MiNote['id'];
- readAllUnreadSpecifiedNotes: undefined;
- readAllAntennas: undefined;
unreadAntenna: MiAntenna;
+ newChatMessage: Packed<'ChatMessage'>;
readAllAnnouncements: undefined;
myTokenRegenerated: undefined;
signin: {
@@ -163,6 +159,16 @@ export interface AdminEventTypes {
};
}
+export interface ChatEventTypes {
+ message: Packed<'ChatMessageLite'>;
+ deleted: Packed<'ChatMessageLite'>['id'];
+ react: {
+ reaction: string;
+ user?: Packed<'UserLite'>;
+ messageId: MiChatMessage['id'];
+ };
+}
+
export interface ReversiEventTypes {
matched: {
game: Packed<'ReversiGameDetailed'>;
@@ -202,7 +208,7 @@ export interface ReversiGameEventTypes {
type Events<T extends object> = { [K in keyof T]: { type: K; body: T[K]; } };
type EventUnionFromDictionary<
T extends object,
- U = Events<T>
+ U = Events<T>,
> = U[keyof U];
type SerializedAll<T> = {
@@ -295,6 +301,14 @@ export type GlobalEvents = {
name: 'notesStream';
payload: Serialized<Packed<'Note'>>;
};
+ chat: {
+ name: `chatUserStream:${MiUser['id']}-${MiUser['id']}`;
+ payload: EventTypesToEventPayload<ChatEventTypes>;
+ };
+ chatRoom: {
+ name: `chatRoomStream:${MiChatRoom['id']}`;
+ payload: EventTypesToEventPayload<ChatEventTypes>;
+ };
reversi: {
name: `reversiStream:${MiUser['id']}`;
payload: EventTypesToEventPayload<ReversiEventTypes>;
@@ -394,6 +408,16 @@ export class GlobalEventService {
}
@bindThis
+ public publishChatUserStream<K extends keyof ChatEventTypes>(fromUserId: MiUser['id'], toUserId: MiUser['id'], type: K, value?: ChatEventTypes[K]): void {
+ this.publish(`chatUserStream:${fromUserId}-${toUserId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ @bindThis
+ public publishChatRoomStream<K extends keyof ChatEventTypes>(toRoomId: MiChatRoom['id'], type: K, value?: ChatEventTypes[K]): void {
+ this.publish(`chatRoomStream:${toRoomId}`, type, typeof value === 'undefined' ? null : value);
+ }
+
+ @bindThis
public publishReversiStream<K extends keyof ReversiEventTypes>(userId: MiUser['id'], type: K, value?: ReversiEventTypes[K]): void {
this.publish(`reversiStream:${userId}`, type, typeof value === 'undefined' ? null : value);
}
diff --git a/packages/backend/src/core/NoteCreateService.ts b/packages/backend/src/core/NoteCreateService.ts
index 8a79908e82..8f416f398c 100644
--- a/packages/backend/src/core/NoteCreateService.ts
+++ b/packages/backend/src/core/NoteCreateService.ts
@@ -42,7 +42,6 @@ import { NoteEntityService } from '@/core/entities/NoteEntityService.js';
import { UserEntityService } from '@/core/entities/UserEntityService.js';
import { ApRendererService } from '@/core/activitypub/ApRendererService.js';
import { ApDeliverManagerService } from '@/core/activitypub/ApDeliverManagerService.js';
-import { NoteReadService } from '@/core/NoteReadService.js';
import { RemoteUserResolveService } from '@/core/RemoteUserResolveService.js';
import { bindThis } from '@/decorators.js';
import { DB_MAX_NOTE_TEXT_LENGTH } from '@/const.js';
@@ -199,7 +198,6 @@ export class NoteCreateService implements OnApplicationShutdown {
private globalEventService: GlobalEventService,
private queueService: QueueService,
private fanoutTimelineService: FanoutTimelineService,
- private noteReadService: NoteReadService,
private notificationService: NotificationService,
private relayService: RelayService,
private federatedInstanceService: FederatedInstanceService,
@@ -582,31 +580,6 @@ export class NoteCreateService implements OnApplicationShutdown {
if (!silent) {
if (this.userEntityService.isLocalUser(user)) this.activeUsersChart.write(user);
- // 未読通知を作成
- if (data.visibility === 'specified') {
- if (data.visibleUsers == null) throw new Error('invalid param');
-
- for (const u of data.visibleUsers) {
- // ローカルユーザーのみ
- if (!this.userEntityService.isLocalUser(u)) continue;
-
- this.noteReadService.insertNoteUnread(u.id, note, {
- isSpecified: true,
- isMentioned: false,
- });
- }
- } else {
- for (const u of mentionedUsers) {
- // ローカルユーザーのみ
- if (!this.userEntityService.isLocalUser(u)) continue;
-
- this.noteReadService.insertNoteUnread(u.id, note, {
- isSpecified: false,
- isMentioned: true,
- });
- }
- }
-
// Pack the note
const noteObj = await this.noteEntityService.pack(note, null, { skipHide: true, withReactionAndUserPairCache: true });
diff --git a/packages/backend/src/core/NoteReadService.ts b/packages/backend/src/core/NoteReadService.ts
deleted file mode 100644
index 181c9f7649..0000000000
--- a/packages/backend/src/core/NoteReadService.ts
+++ /dev/null
@@ -1,143 +0,0 @@
-/*
- * SPDX-FileCopyrightText: syuilo and misskey-project
- * SPDX-License-Identifier: AGPL-3.0-only
- */
-
-import { setTimeout } from 'node:timers/promises';
-import { Inject, Injectable, OnApplicationShutdown } from '@nestjs/common';
-import { In } from 'typeorm';
-import { DI } from '@/di-symbols.js';
-import type { MiUser } from '@/models/User.js';
-import type { Packed } from '@/misc/json-schema.js';
-import type { MiNote } from '@/models/Note.js';
-import { IdService } from '@/core/IdService.js';
-import { GlobalEventService } from '@/core/GlobalEventService.js';
-import type { NoteUnreadsRepository, MutingsRepository, NoteThreadMutingsRepository } from '@/models/_.js';
-import { bindThis } from '@/decorators.js';
-import { trackPromise } from '@/misc/promise-tracker.js';
-
-@Injectable()
-export class NoteReadService implements OnApplicationShutdown {
- #shutdownController = new AbortController();
-
- constructor(
- @Inject(DI.noteUnreadsRepository)
- private noteUnreadsRepository: NoteUnreadsRepository,
-
- @Inject(DI.mutingsRepository)
- private mutingsRepository: MutingsRepository,
-
- @Inject(DI.noteThreadMutingsRepository)
- private noteThreadMutingsRepository: NoteThreadMutingsRepository,
-
- private idService: IdService,
- private globalEventService: GlobalEventService,
- ) {
- }
-
- @bindThis
- public async insertNoteUnread(userId: MiUser['id'], note: MiNote, params: {
- // NOTE: isSpecifiedがtrueならisMentionedは必ずfalse
- isSpecified: boolean;
- isMentioned: boolean;
- }): Promise<void> {
- //#region ミュートしているなら無視
- const mute = await this.mutingsRepository.findBy({
- muterId: userId,
- });
- if (mute.map(m => m.muteeId).includes(note.userId)) return;
- //#endregion
-
- // スレッドミュート
- const isThreadMuted = await this.noteThreadMutingsRepository.exists({
- where: {
- userId: userId,
- threadId: note.threadId ?? note.id,
- },
- });
- if (isThreadMuted) return;
-
- const unread = {
- id: this.idService.gen(),
- noteId: note.id,
- userId: userId,
- isSpecified: params.isSpecified,
- isMentioned: params.isMentioned,
- noteUserId: note.userId,
- };
-
- await this.noteUnreadsRepository.insert(unread);
-
- // 2秒経っても既読にならなかったら「未読の投稿がありますよ」イベントを発行する
- setTimeout(2000, 'unread note', { signal: this.#shutdownController.signal }).then(async () => {
- const exist = await this.noteUnreadsRepository.exists({ where: { id: unread.id } });
-
- if (!exist) return;
-
- if (params.isMentioned) {
- this.globalEventService.publishMainStream(userId, 'unreadMention', note.id);
- }
- if (params.isSpecified) {
- this.globalEventService.publishMainStream(userId, 'unreadSpecifiedNote', note.id);
- }
- }, () => { /* aborted, ignore it */ });
- }
-
- @bindThis
- public async read(
- userId: MiUser['id'],
- notes: (MiNote | Packed<'Note'>)[],
- ): Promise<void> {
- if (notes.length === 0) return;
-
- const noteIds = new Set<MiNote['id']>();
-
- for (const note of notes) {
- if (note.mentions && note.mentions.includes(userId)) {
- noteIds.add(note.id);
- } else if (note.visibleUserIds && note.visibleUserIds.includes(userId)) {
- noteIds.add(note.id);
- }
- }
-
- if (noteIds.size === 0) return;
-
- // Remove the record
- await this.noteUnreadsRepository.delete({
- userId: userId,
- noteId: In(Array.from(noteIds)),
- });
-
- // TODO: ↓まとめてクエリしたい
-
- trackPromise(this.noteUnreadsRepository.countBy({
- userId: userId,
- isMentioned: true,
- }).then(mentionsCount => {
- if (mentionsCount === 0) {
- // 全て既読になったイベントを発行
- this.globalEventService.publishMainStream(userId, 'readAllUnreadMentions');
- }
- }));
-
- trackPromise(this.noteUnreadsRepository.countBy({
- userId: userId,
- isSpecified: true,
- }).then(specifiedCount => {
- if (specifiedCount === 0) {
- // 全て既読になったイベントを発行
- this.globalEventService.publishMainStream(userId, 'readAllUnreadSpecifiedNotes');
- }
- }));
- }
-
- @bindThis
- public dispose(): void {
- this.#shutdownController.abort();
- }
-
- @bindThis
- public onApplicationShutdown(signal?: string | undefined): void {
- this.dispose();
- }
-}
diff --git a/packages/backend/src/core/RoleService.ts b/packages/backend/src/core/RoleService.ts
index 01f3e0c116..86f8a5caa1 100644
--- a/packages/backend/src/core/RoleService.ts
+++ b/packages/backend/src/core/RoleService.ts
@@ -63,6 +63,7 @@ export type RolePolicies = {
canImportFollowing: boolean;
canImportMuting: boolean;
canImportUserLists: boolean;
+ canChat: boolean;
};
export const DEFAULT_POLICIES: RolePolicies = {
@@ -97,6 +98,7 @@ export const DEFAULT_POLICIES: RolePolicies = {
canImportFollowing: true,
canImportMuting: true,
canImportUserLists: true,
+ canChat: true,
};
@Injectable()
@@ -400,6 +402,7 @@ export class RoleService implements OnApplicationShutdown, OnModuleInit {
canImportFollowing: calc('canImportFollowing', vs => vs.some(v => v === true)),
canImportMuting: calc('canImportMuting', vs => vs.some(v => v === true)),
canImportUserLists: calc('canImportUserLists', vs => vs.some(v => v === true)),
+ canChat: calc('canChat', vs => vs.some(v => v === true)),
};
}
diff --git a/packages/backend/src/core/UserFollowingService.ts b/packages/backend/src/core/UserFollowingService.ts
index b98ca97ec9..e7a6be99fb 100644
--- a/packages/backend/src/core/UserFollowingService.ts
+++ b/packages/backend/src/core/UserFollowingService.ts
@@ -5,7 +5,7 @@
import { Inject, Injectable, OnModuleInit } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';
-import { IsNull } from 'typeorm';
+import { Brackets, IsNull } from 'typeorm';
import type { MiLocalUser, MiPartialLocalUser, MiPartialRemoteUser, MiRemoteUser, MiUser } from '@/models/User.js';
import { IdentifiableError } from '@/misc/identifiable-error.js';
import { QueueService } from '@/core/QueueService.js';
@@ -736,4 +736,30 @@ export class UserFollowingService implements OnModuleInit {
.where('following.followerId = :followerId', { followerId: userId })
.getMany();
}
+
+ @bindThis
+ public isFollowing(followerId: MiUser['id'], followeeId: MiUser['id']) {
+ return this.followingsRepository.exists({
+ where: {
+ followerId,
+ followeeId,
+ },
+ });
+ }
+
+ @bindThis
+ public async isMutual(aUserId: MiUser['id'], bUserId: MiUser['id']) {
+ const count = await this.followingsRepository.createQueryBuilder('following')
+ .where(new Brackets(qb => {
+ qb.where('following.followerId = :aUserId', { aUserId })
+ .andWhere('following.followeeId = :bUserId', { bUserId });
+ }))
+ .orWhere(new Brackets(qb => {
+ qb.where('following.followerId = :bUserId', { bUserId })
+ .andWhere('following.followeeId = :aUserId', { aUserId });
+ }))
+ .getCount();
+
+ return count === 2;
+ }
}
diff --git a/packages/backend/src/core/WebhookTestService.ts b/packages/backend/src/core/WebhookTestService.ts
index e1a7801bb1..a6198f7686 100644
--- a/packages/backend/src/core/WebhookTestService.ts
+++ b/packages/backend/src/core/WebhookTestService.ts
@@ -53,6 +53,7 @@ function generateDummyUser(override?: Partial<MiUser>): MiUser {
requireSigninToViewContents: false,
makeNotesFollowersOnlyBefore: null,
makeNotesHiddenBefore: null,
+ chatScope: 'mutual',
emojis: [],
score: 0,
host: null,
@@ -461,6 +462,7 @@ export class WebhookTestService {
publicReactions: true,
followersVisibility: 'public',
followingVisibility: 'public',
+ chatScope: 'mutual',
twoFactorEnabled: false,
usePasswordLessLogin: false,
securityKeys: false,
diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts
new file mode 100644
index 0000000000..099a9e3ad2
--- /dev/null
+++ b/packages/backend/src/core/entities/ChatEntityService.ts
@@ -0,0 +1,376 @@
+/*
+ * SPDX-FileCopyrightText: syuilo and misskey-project
+ * SPDX-License-Identifier: AGPL-3.0-only
+ */
+
+import { Inject, Injectable } from '@nestjs/common';
+import { DI } from '@/di-symbols.js';
+import type { MiUser, ChatMessagesRepository, MiChatMessage, ChatRoomsRepository, MiChatRoom, MiChatRoomInvitation, ChatRoomInvitationsRepository, MiChatRoomMembership, ChatRoomMembershipsRepository } from '@/models/_.js';
+import { awaitAll } from '@/misc/prelude/await-all.js';
+import type { Packed } from '@/misc/json-schema.js';
+import type { } from '@/models/Blocking.js';
+import { bindThis } from '@/decorators.js';
+import { IdService } from '@/core/IdService.js';
+import { UserEntityService } from './UserEntityService.js';
+import { DriveFileEntityService } from './DriveFileEntityService.js';
+import { In } from 'typeorm';
+
+@Injectable()
+export class ChatEntityService {
+ constructor(
+ @Inject(DI.chatMessagesRepository)
+ private chatMessagesRepository: ChatMessagesRepository,
+
+ @Inject(DI.chatRoomsRepository)
+ private chatRoomsRepository: ChatRoomsRepository,
+
+ @Inject(DI.chatRoomInvitationsRepository)
+ private chatRoomInvitationsRepository: ChatRoomInvitationsRepository,
+
+ @Inject(DI.chatRoomMembershipsRepository)
+ private chatRoomMembershipsRepository: ChatRoomMembershipsRepository,
+
+ private userEntityService: UserEntityService,
+ private driveFileEntityService: DriveFileEntityService,
+ private idService: IdService,
+ ) {
+ }
+
+ @bindThis
+ public async packMessageDetailed(
+ src: MiChatMessage['id'] | MiChatMessage,
+ me?: { id: MiUser['id'] },
+ options?: {
+ _hint_?: {
+ packedFiles?: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
+ packedUsers?: Map<MiChatMessage['id'], Packed<'UserLite'>>;
+ packedRooms?: Map<MiChatMessage['toRoomId'], Packed<'ChatRoom'> | null>;
+ };
+ },
+ ): Promise<Packed<'ChatMessage'>> {
+ const packedUsers = options?._hint_?.packedUsers;
+ const packedFiles = options?._hint_?.packedFiles;
+ const packedRooms = options?._hint_?.packedRooms;
+
+ const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
+
+ const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = [];
+
+ for (const record of message.reactions) {
+ const [userId, reaction] = record.split('/');
+ reactions.push({
+ user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId),
+ reaction,
+ });
+ }
+
+ return {
+ id: message.id,
+ createdAt: this.idService.parse(message.id).date.toISOString(),
+ text: message.text,
+ fromUserId: message.fromUserId,
+ fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId, me),
+ toUserId: message.toUserId,
+ toUser: message.toUserId ? (packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId, me)) : undefined,
+ toRoomId: message.toRoomId,
+ toRoom: message.toRoomId ? (packedRooms?.get(message.toRoomId) ?? await this.packRoom(message.toRoom ?? message.toRoomId, me)) : undefined,
+ fileId: message.fileId,
+ file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
+ reactions,
+ };
+ }
+
+ @bindThis
+ public async packMessagesDetailed(
+ messages: MiChatMessage[],
+ me: { id: MiUser['id'] },
+ ) {
+ if (messages.length === 0) return [];
+
+ const excludeMe = (x: MiUser | string) => {
+ if (typeof x === 'string') {
+ return x !== me.id;
+ } else {
+ return x.id !== me.id;
+ }
+ };
+
+ const users = [
+ ...messages.map((m) => m.fromUser ?? m.fromUserId).filter(excludeMe),
+ ...messages.map((m) => m.toUser ?? m.toUserId).filter(x => x != null).filter(excludeMe),
+ ];
+
+ const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0]));
+
+ for (const reactedUserId of reactedUserIds) {
+ if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) {
+ users.push(reactedUserId);
+ }
+ }
+
+ const [packedUsers, packedFiles, packedRooms] = await Promise.all([
+ this.userEntityService.packMany(users, me)
+ .then(users => new Map(users.map(u => [u.id, u]))),
+ this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
+ .then(files => new Map(files.map(f => [f.id, f]))),
+ this.packRooms(messages.map(m => m.toRoom ?? m.toRoomId).filter(x => x != null), me)
+ .then(rooms => new Map(rooms.map(r => [r.id, r]))),
+ ]);
+
+ return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } })));
+ }
+
+ @bindThis
+ public async packMessageLiteFor1on1(
+ src: MiChatMessage['id'] | MiChatMessage,
+ options?: {
+ _hint_?: {
+ packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
+ };
+ },
+ ): Promise<Packed<'ChatMessageLite'>> {
+ const packedFiles = options?._hint_?.packedFiles;
+
+ const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
+
+ const reactions: { reaction: string; }[] = [];
+
+ for (const record of message.reactions) {
+ const [userId, reaction] = record.split('/');
+ reactions.push({
+ reaction,
+ });
+ }
+
+ return {
+ id: message.id,
+ createdAt: this.idService.parse(message.id).date.toISOString(),
+ text: message.text,
+ fromUserId: message.fromUserId,
+ toUserId: message.toUserId,
+ fileId: message.fileId,
+ file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
+ reactions,
+ };
+ }
+
+ @bindThis
+ public async packMessagesLiteFor1on1(
+ messages: MiChatMessage[],
+ ) {
+ if (messages.length === 0) return [];
+
+ const [packedFiles] = await Promise.all([
+ this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
+ .then(files => new Map(files.map(f => [f.id, f]))),
+ ]);
+
+ return Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } })));
+ }
+
+ @bindThis
+ public async packMessageLiteForRoom(
+ src: MiChatMessage['id'] | MiChatMessage,
+ options?: {
+ _hint_?: {
+ packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
+ packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
+ };
+ },
+ ): Promise<Packed<'ChatMessageLite'>> {
+ const packedFiles = options?._hint_?.packedFiles;
+ const packedUsers = options?._hint_?.packedUsers;
+
+ const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
+
+ const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = [];
+
+ for (const record of message.reactions) {
+ const [userId, reaction] = record.split('/');
+ reactions.push({
+ user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId),
+ reaction,
+ });
+ }
+
+ return {
+ id: message.id,
+ createdAt: this.idService.parse(message.id).date.toISOString(),
+ text: message.text,
+ fromUserId: message.fromUserId,
+ fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId),
+ toRoomId: message.toRoomId,
+ fileId: message.fileId,
+ file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
+ reactions,
+ };
+ }
+
+ @bindThis
+ public async packMessagesLiteForRoom(
+ messages: MiChatMessage[],
+ ) {
+ if (messages.length === 0) return [];
+
+ const users = messages.map(x => x.fromUser ?? x.fromUserId);
+ const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0]));
+
+ for (const reactedUserId of reactedUserIds) {
+ if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) {
+ users.push(reactedUserId);
+ }
+ }
+
+ const [packedUsers, packedFiles] = await Promise.all([
+ this.userEntityService.packMany(users)
+ .then(users => new Map(users.map(u => [u.id, u]))),
+ this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
+ .then(files => new Map(files.map(f => [f.id, f]))),
+ ]);
+
+ return Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } })));
+ }
+
+ @bindThis
+ public async packRoom(
+ src: MiChatRoom['id'] | MiChatRoom,
+ me?: { id: MiUser['id'] },
+ options?: {
+ _hint_?: {
+ packedOwners: Map<MiChatRoom['id'], Packed<'UserLite'>>;
+ memberships?: Map<MiChatRoom['id'], MiChatRoomMembership | null | undefined>;
+ };
+ },
+ ): Promise<Packed<'ChatRoom'>> {
+ const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src });
+
+ const membership = me && me.id !== room.ownerId ? (options?._hint_?.memberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null;
+
+ return {
+ id: room.id,
+ createdAt: this.idService.parse(room.id).date.toISOString(),
+ name: room.name,
+ description: room.description,
+ ownerId: room.ownerId,
+ owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me),
+ isMuted: membership != null ? membership.isMuted : false,
+ };
+ }
+
+ @bindThis
+ public async packRooms(
+ rooms: (MiChatRoom | MiChatRoom['id'])[],
+ me: { id: MiUser['id'] },
+ ) {
+ if (rooms.length === 0) return [];
+
+ const _rooms = rooms.filter((room): room is MiChatRoom => typeof room !== 'string');
+ if (_rooms.length !== rooms.length) {
+ _rooms.push(
+ ...await this.chatRoomsRepository.find({
+ where: {
+ id: In(rooms.filter((room): room is string => typeof room === 'string')),
+ },
+ relations: ['owner'],
+ }),
+ );
+ }
+
+ const owners = _rooms.map(x => x.owner ?? x.ownerId);
+
+ const [packedOwners, memberships] = await Promise.all([
+ this.userEntityService.packMany(owners, me)
+ .then(users => new Map(users.map(u => [u.id, u]))),
+ this.chatRoomMembershipsRepository.find({
+ where: {
+ roomId: In(_rooms.map(x => x.id)),
+ userId: me.id,
+ },
+ }).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))),
+ ]);
+
+ return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } })));
+ }
+
+ @bindThis
+ public async packRoomInvitation(
+ src: MiChatRoomInvitation['id'] | MiChatRoomInvitation,
+ me: { id: MiUser['id'] },
+ options?: {
+ _hint_?: {
+ packedRooms: Map<MiChatRoomInvitation['roomId'], Packed<'ChatRoom'>>;
+ packedUsers: Map<MiChatRoomInvitation['id'], Packed<'UserLite'>>;
+ };
+ },
+ ): Promise<Packed<'ChatRoomInvitation'>> {
+ const invitation = typeof src === 'object' ? src : await this.chatRoomInvitationsRepository.findOneByOrFail({ id: src });
+
+ return {
+ id: invitation.id,
+ createdAt: this.idService.parse(invitation.id).date.toISOString(),
+ roomId: invitation.roomId,
+ room: options?._hint_?.packedRooms.get(invitation.roomId) ?? await this.packRoom(invitation.room ?? invitation.roomId, me),
+ userId: invitation.userId,
+ user: options?._hint_?.packedUsers.get(invitation.userId) ?? await this.userEntityService.pack(invitation.user ?? invitation.userId, me),
+ };
+ }
+
+ @bindThis
+ public async packRoomInvitations(
+ invitations: MiChatRoomInvitation[],
+ me: { id: MiUser['id'] },
+ ) {
+ if (invitations.length === 0) return [];
+
+ return Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me)));
+ }
+
+ @bindThis
+ public async packRoomMembership(
+ src: MiChatRoomMembership['id'] | MiChatRoomMembership,
+ me: { id: MiUser['id'] },
+ options?: {
+ populateUser?: boolean;
+ populateRoom?: boolean;
+ _hint_?: {
+ packedRooms: Map<MiChatRoomMembership['roomId'], Packed<'ChatRoom'>>;
+ packedUsers: Map<MiChatRoomMembership['id'], Packed<'UserLite'>>;
+ };
+ },
+ ): Promise<Packed<'ChatRoomMembership'>> {
+ const membership = typeof src === 'object' ? src : await this.chatRoomMembershipsRepository.findOneByOrFail({ id: src });
+
+ return {
+ id: membership.id,
+ createdAt: this.idService.parse(membership.id).date.toISOString(),
+ userId: membership.userId,
+ user: options?.populateUser ? (options._hint_?.packedUsers.get(membership.userId) ?? await this.userEntityService.pack(membership.user ?? membership.userId, me)) : undefined,
+ roomId: membership.roomId,
+ room: options?.populateRoom ? (options._hint_?.packedRooms.get(membership.roomId) ?? await this.packRoom(membership.room ?? membership.roomId, me)) : undefined,
+ };
+ }
+
+ @bindThis
+ public async packRoomMemberships(
+ memberships: MiChatRoomMembership[],
+ me: { id: MiUser['id'] },
+ options: {
+ populateUser?: boolean;
+ populateRoom?: boolean;
+ } = {},
+ ) {
+ if (memberships.length === 0) return [];
+
+ const users = memberships.map(x => x.user ?? x.userId);
+ const rooms = memberships.map(x => x.room ?? x.roomId);
+
+ const [packedUsers, packedRooms] = await Promise.all([
+ this.userEntityService.packMany(users, me)
+ .then(users => new Map(users.map(u => [u.id, u]))),
+ this.packRooms(rooms, me)
+ .then(rooms => new Map(rooms.map(r => [r.id, r]))),
+ ]);
+
+ return Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } })));
+ }
+}
diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts
index 69f698d9cb..92a684dc1a 100644
--- a/packages/backend/src/core/entities/UserEntityService.ts
+++ b/packages/backend/src/core/entities/UserEntityService.ts
@@ -32,7 +32,6 @@ import type {
MiUserNotePining,
MiUserProfile,
MutingsRepository,
- NoteUnreadsRepository,
RenoteMutingsRepository,
UserMemoRepository,
UserNotePiningsRepository,
@@ -48,9 +47,9 @@ import { IdService } from '@/core/IdService.js';
import type { AnnouncementService } from '@/core/AnnouncementService.js';
import type { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
+import { ChatService } from '@/core/ChatService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { NoteEntityService } from './NoteEntityService.js';
-import type { DriveFileEntityService } from './DriveFileEntityService.js';
import type { PageEntityService } from './PageEntityService.js';
const Ajv = _Ajv.default;
@@ -94,6 +93,7 @@ export class UserEntityService implements OnModuleInit {
private federatedInstanceService: FederatedInstanceService;
private idService: IdService;
private avatarDecorationService: AvatarDecorationService;
+ private chatService: ChatService;
constructor(
private moduleRef: ModuleRef,
@@ -128,9 +128,6 @@ export class UserEntityService implements OnModuleInit {
@Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository,
- @Inject(DI.noteUnreadsRepository)
- private noteUnreadsRepository: NoteUnreadsRepository,
-
@Inject(DI.userNotePiningsRepository)
private userNotePiningsRepository: UserNotePiningsRepository,
@@ -152,6 +149,7 @@ export class UserEntityService implements OnModuleInit {
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
this.idService = this.moduleRef.get('IdService');
this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService');
+ this.chatService = this.moduleRef.get('ChatService');
}
//#region Validators
@@ -558,6 +556,7 @@ export class UserEntityService implements OnModuleInit {
publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
followersVisibility: profile!.followersVisibility,
followingVisibility: profile!.followingVisibility,
+ chatScope: user.chatScope,
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
id: role.id,
name: role.name,
@@ -598,14 +597,9 @@ export class UserEntityService implements OnModuleInit {
isDeleted: user.isDeleted,
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
hideOnlineStatus: user.hideOnlineStatus,
- hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
- where: { userId: user.id, isSpecified: true },
- take: 1,
- }).then(count => count > 0),
- hasUnreadMentions: this.noteUnreadsRepository.count({
- where: { userId: user.id, isMentioned: true },
- take: 1,
- }).then(count => count > 0),
+ hasUnreadSpecifiedNotes: false, // 後方互換性のため
+ hasUnreadMentions: false, // 後方互換性のため
+ hasUnreadChatMessages: this.chatService.hasUnreadMessages(user.id),
hasUnreadAnnouncement: unreadAnnouncements!.length > 0,
unreadAnnouncements,
hasUnreadAntenna: this.getHasUnreadAntenna(user.id),