diff --git a/packages/backend/migration/1742707840715-chat-4.js b/packages/backend/migration/1742707840715-chat-4.js new file mode 100644 index 0000000000..953a53d880 --- /dev/null +++ b/packages/backend/migration/1742707840715-chat-4.js @@ -0,0 +1,26 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +export class Chat41742707840715 { + name = 'Chat41742707840715' + + async up(queryRunner) { + await queryRunner.query(`CREATE TABLE "chat_room_invitation" ("id" character varying(32) NOT NULL, "userId" character varying(32) NOT NULL, "roomId" character varying(32) NOT NULL, CONSTRAINT "PK_9d489521a312dd28225672de2dc" PRIMARY KEY ("id"))`); + await queryRunner.query(`CREATE INDEX "IDX_8552bb38e7ed038c5bdd398a38" ON "chat_room_invitation" ("userId") `); + await queryRunner.query(`CREATE INDEX "IDX_5f265075b215fc390a57523b12" ON "chat_room_invitation" ("roomId") `); + await queryRunner.query(`CREATE UNIQUE INDEX "IDX_044f2a7962b8ee5bbfaa02e8a3" ON "chat_room_invitation" ("userId", "roomId") `); + await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384" FOREIGN KEY ("userId") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + await queryRunner.query(`ALTER TABLE "chat_room_invitation" ADD CONSTRAINT "FK_5f265075b215fc390a57523b12a" FOREIGN KEY ("roomId") REFERENCES "chat_room"("id") ON DELETE CASCADE ON UPDATE NO ACTION`); + } + + async down(queryRunner) { + await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_5f265075b215fc390a57523b12a"`); + await queryRunner.query(`ALTER TABLE "chat_room_invitation" DROP CONSTRAINT "FK_8552bb38e7ed038c5bdd398a384"`); + await queryRunner.query(`DROP INDEX "public"."IDX_044f2a7962b8ee5bbfaa02e8a3"`); + await queryRunner.query(`DROP INDEX "public"."IDX_5f265075b215fc390a57523b12"`); + await queryRunner.query(`DROP INDEX "public"."IDX_8552bb38e7ed038c5bdd398a38"`); + await queryRunner.query(`DROP TABLE "chat_room_invitation"`); + } +} diff --git a/packages/backend/src/core/ChatService.ts b/packages/backend/src/core/ChatService.ts index 6056564ae0..75445307cc 100644 --- a/packages/backend/src/core/ChatService.ts +++ b/packages/backend/src/core/ChatService.ts @@ -12,15 +12,16 @@ 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 { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.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, ChatRoomMembershipsRepository, ChatRoomsRepository, MiChatMessage, MiChatRoom, MiDriveFile, MiUser, MutingsRepository, UsersRepository } from '@/models/_.js'; +import type { ChatApprovalsRepository, ChatMessagesRepository, ChatRoomInvitationsRepository, ChatRoomMembershipsRepository, ChatRoomsRepository, MiChatMessage, MiChatRoom, 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'; @Injectable() export class ChatService { @@ -43,6 +44,9 @@ export class ChatService { @Inject(DI.chatRoomsRepository) private chatRoomsRepository: ChatRoomsRepository, + @Inject(DI.chatRoomInvitationsRepository) + private chatRoomInvitationsRepository: ChatRoomInvitationsRepository, + @Inject(DI.chatRoomMembershipsRepository) private chatRoomMembershipsRepository: ChatRoomMembershipsRepository, @@ -50,7 +54,7 @@ export class ChatService { private mutingsRepository: MutingsRepository, private userEntityService: UserEntityService, - private chatMessageEntityService: ChatMessageEntityService, + private chatEntityService: ChatEntityService, private idService: IdService, private globalEventService: GlobalEventService, private apRendererService: ApRendererService, @@ -64,7 +68,7 @@ export class ChatService { } @bindThis - public async createMessage(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: { + public async createMessageToUser(fromUser: { id: MiUser['id']; host: MiUser['host']; }, toUser: MiUser, params: { text?: string | null; file?: MiDriveFile | null; uri?: string | null; @@ -139,61 +143,38 @@ export class ChatService { }); } - const packedMessage = await this.chatMessageEntityService.packLite(inserted); + const packedMessage = await this.chatEntityService.packMessageLite(inserted); if (this.userEntityService.isLocalUser(toUser)) { const redisPipeline = this.redisClient.pipeline(); - redisPipeline.set(`newChatMessageExists:${toUser.id}:${fromUser.id}`, message.id); + 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.publishChatStream(fromUser.id, toUser.id, 'message', packedMessage); + this.globalEventService.publishChatUserStream(fromUser.id, toUser.id, 'message', packedMessage); } if (this.userEntityService.isLocalUser(toUser)) { // 相手のストリーム - this.globalEventService.publishChatStream(toUser.id, fromUser.id, 'message', packedMessage); + this.globalEventService.publishChatUserStream(toUser.id, fromUser.id, 'message', packedMessage); } // 3秒経っても既読にならなかったらイベント発行 if (this.userEntityService.isLocalUser(toUser)) { setTimeout(async () => { - const marker = await this.redisClient.get(`newChatMessageExists:${toUser.id}:${fromUser.id}`); + const marker = await this.redisClient.get(`newUserChatMessageExists:${toUser.id}:${fromUser.id}`); if (marker == null) return; // 既読 - const packedMessageForTo = await this.chatMessageEntityService.pack(inserted, toUser); + const packedMessageForTo = await this.chatEntityService.packMessageDetailed(inserted, toUser); this.globalEventService.publishMainStream(toUser.id, 'newChatMessage', packedMessageForTo); this.pushNotificationService.pushNotification(toUser.id, 'newChatMessage', packedMessageForTo); }, 3000); } - /* TODO: AP - if (toUser && this.userEntityService.isLocalUser(fromUser) && this.userEntityService.isRemoteUser(toUser)) { - const note = { - id: message.id, - createdAt: message.createdAt, - fileIds: message.fileId ? [message.fileId] : [], - text: message.text, - userId: message.userId, - visibility: 'specified', - mentions: [toUser].map(u => u.id), - mentionedRemoteUsers: JSON.stringify([toUser].map(u => ({ - uri: u.uri, - username: u.username, - host: u.host, - }))), - } as MiNote; - - const activity = this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false, true), note)); - - this.queueService.deliver(fromUser, activity, toUser.inbox); - } - */ - return packedMessage; } @@ -203,6 +184,12 @@ export class ChatService { file?: MiDriveFile | null; uri?: string | null; }) { + 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, @@ -215,30 +202,36 @@ export class ChatService { const inserted = await this.chatMessagesRepository.insertOne(message); - const packedMessage = await this.chatMessageEntityService.packLite(inserted); + const packedMessage = await this.chatEntityService.packMessageLiteForRoom(inserted); - /* - // グループのストリーム - this.globalEventService.publishRoomChatStream(toRoom.id, 'message', messageObj); + this.globalEventService.publishChatRoomStream(toRoom.id, 'message', packedMessage); - // メンバーのストリーム - const joinings = await this.userRoomJoiningsRepository.findBy({ userRoomId: toRoom.id }); - for (const joining of joinings) { - this.globalEventService.publishChatIndexStream(joining.userId, 'message', messageObj); - this.globalEventService.publishMainStream(joining.userId, 'chatMessage', messageObj); - } - */ + const redisPipeline = this.redisClient.pipeline(); + for (const membership of memberships) { + redisPipeline.set(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`, message.id); + redisPipeline.sadd(`newChatMessagesExists:${membership.userId}`, `room:${toRoom.id}`); + } + redisPipeline.exec(); // 3秒経っても既読にならなかったらイベント発行 setTimeout(async () => { - /* - const joinings = await this.userRoomJoiningsRepository.findBy({ userRoomId: toRoom.id, userId: Not(fromUser.id) }); - for (const joining of joinings) { - if (freshMessage.reads.includes(joining.userId)) return; // 既読 - this.globalEventService.publishMainStream(joining.userId, 'newChatMessage', messageObj); - this.pushNotificationService.pushNotification(joining.userId, 'newChatMessage', messageObj); - } - */ + const redisPipeline = this.redisClient.pipeline(); + for (const membership of memberships) { + redisPipeline.get(`newRoomChatMessageExists:${membership.userId}:${toRoom.id}`); + } + const markers = await redisPipeline.exec(); + + 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; @@ -250,11 +243,22 @@ export class ChatService { senderId: MiUser['id'], ): Promise { const redisPipeline = this.redisClient.pipeline(); - redisPipeline.del(`newChatMessageExists:${readerId}:${senderId}`); + 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 { + 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 }); @@ -275,84 +279,17 @@ export class ChatService { this.usersRepository.findOneByOrFail({ id: message.toUserId }), ]); - if (this.userEntityService.isLocalUser(fromUser)) this.globalEventService.publishChatStream(message.fromUserId, message.toUserId, 'deleted', message.id); - if (this.userEntityService.isLocalUser(toUser)) this.globalEventService.publishChatStream(message.toUserId, message.fromUserId, 'deleted', message.id); + 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.publishRoomChatStream(message.toRoomId, 'deleted', message.id); - }*/ - } - - /* - @bindThis - public async readRoomChatMessage( - userId: MiUser['id'], - roomId: MiUserRoom['id'], - messageIds: MiChatMessage['id'][], - ) { - if (messageIds.length === 0) return; - - // check joined - const joining = await this.userRoomJoiningsRepository.findOneBy({ - userId: userId, - userRoomId: roomId, - }); - - if (joining == null) { - throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (room).'); - } - - const messages = await this.chatMessagesRepository.findBy({ - id: In(messageIds), - }); - - const reads: ChatMessage['id'][] = []; - - for (const message of messages) { - if (message.userId === userId) continue; - if (message.reads.includes(userId)) continue; - - // Update document - await this.chatMessagesRepository.createQueryBuilder().update() - .set({ - reads: (() => `array_append("reads", '${joining.userId}')`) as any, - }) - .where('id = :id', { id: message.id }) - .execute(); - - reads.push(message.id); - } - - // Publish event - this.globalEventService.publishRoomChatStream(roomId, 'read', { - ids: reads, - userId: userId, - }); - this.globalEventService.publishChatIndexStream(userId, 'read', reads); - - if (!await this.userEntityService.getHasUnreadChatMessage(userId)) { - // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 - this.globalEventService.publishMainStream(userId, 'readAllChatMessages'); - this.pushNotificationService.pushNotification(userId, 'readAllChatMessages', undefined); - } else { - // そのグループにおいて未読がなければイベント発行 - const unreadExist = await this.chatMessagesRepository.createQueryBuilder('message') - .where('message.toRoomId = :roomId', { roomId: roomId }) - .andWhere('message.userId != :userId', { userId: userId }) - .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) - .andWhere('message.createdAt > :joinedAt', { joinedAt: joining.createdAt }) // 自分が加入する前の会話については、未読扱いしない - .getOne().then(x => x != null); - - if (!unreadExist) { - this.pushNotificationService.pushNotification(userId, 'readAllChatMessagesOfARoom', { roomId }); - } + } else if (message.toRoomId) { + this.globalEventService.publishChatRoomStream(message.toRoomId, 'deleted', message.id); } } - */ @bindThis public async userTimeline(meId: MiUser['id'], otherId: MiUser['id'], sinceId: MiChatMessage['id'] | null, untilId: MiChatMessage['id'] | null, limit: number) { @@ -421,24 +358,30 @@ export class ChatService { @bindThis public async roomHistory(meId: MiUser['id'], limit: number): Promise { - return []; - /* - const rooms = await this.userRoomJoiningsRepository.findBy({ - userId: meId, - }).then(xs => xs.map(x => x.userRoomId)); + // 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)), + ]); - if (rooms.length === 0) { + 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.roomId!); + const found = history.map(m => m.toRoomId!); const query = this.chatMessagesRepository.createQueryBuilder('message') .orderBy('message.id', 'DESC') - .where('message.toRoomId IN (:...rooms)', { rooms: rooms }); + .where('message.toRoomId IN (:...roomIds)', { roomIds }); if (found.length > 0) { query.andWhere('message.toRoomId NOT IN (:...found)', { found: found }); @@ -454,7 +397,6 @@ export class ChatService { } return history; - */ } @bindThis @@ -464,7 +406,7 @@ export class ChatService { const redisPipeline = this.redisClient.pipeline(); for (const otherId of otherIds) { - redisPipeline.get(`newChatMessageExists:${userId}:${otherId}`); + redisPipeline.get(`newUserChatMessageExists:${userId}:${otherId}`); } const markers = await redisPipeline.exec(); @@ -477,9 +419,68 @@ export class ChatService { return readStateMap; } + @bindThis + public async getRoomReadStateMap(userId: MiUser['id'], roomIds: MiChatRoom['id'][]) { + const readStateMap: Record = {}; + + const redisPipeline = this.redisClient.pipeline(); + + for (const roomId of roomIds) { + redisPipeline.get(`newRoomChatMessageExists:${userId}:${roomId}`); + } + + const markers = await redisPipeline.exec(); + + 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, name: string) { + const room = { + id: this.idService.gen(), + name: name, + ownerId: owner.id, + } satisfies Partial; + + const created = await this.chatRoomsRepository.insertOne(room); + + return created; + } + + @bindThis + public async deleteRoom(room: MiChatRoom) { + await this.chatRoomsRepository.delete(room.id); + } + + @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 }); + + // TODO: cehck block + + const invitation = { + id: this.idService.gen(), + roomId: room.id, + userId: inviteeId, + } satisfies Partial; + + const created = await this.chatRoomInvitationsRepository.insertOne(invitation); + + return created; + } } diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index feb6ac6a40..d8617e343c 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -100,7 +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 { ChatMessageEntityService } from './entities/ChatMessageEntityService.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'; @@ -248,7 +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 $ChatMessageEntityService: Provider = { provide: 'ChatMessageEntityService', useExisting: ChatMessageEntityService }; +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 }; @@ -398,7 +398,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AuthSessionEntityService, BlockingEntityService, ChannelEntityService, - ChatMessageEntityService, + ChatEntityService, ClipEntityService, DriveFileEntityService, DriveFolderEntityService, @@ -544,7 +544,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AuthSessionEntityService, $BlockingEntityService, $ChannelEntityService, - $ChatMessageEntityService, + $ChatEntityService, $ClipEntityService, $DriveFileEntityService, $DriveFolderEntityService, @@ -690,7 +690,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting AuthSessionEntityService, BlockingEntityService, ChannelEntityService, - ChatMessageEntityService, + ChatEntityService, ClipEntityService, DriveFileEntityService, DriveFolderEntityService, @@ -834,7 +834,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $AuthSessionEntityService, $BlockingEntityService, $ChannelEntityService, - $ChatMessageEntityService, + $ChatEntityService, $ClipEntityService, $DriveFileEntityService, $DriveFolderEntityService, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index 898815a445..74af94b791 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, 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'; @@ -297,7 +297,11 @@ export type GlobalEvents = { payload: Serialized>; }; chat: { - name: `chatStream:${MiUser['id']}-${MiUser['id']}`; + name: `chatUserStream:${MiUser['id']}-${MiUser['id']}`; + payload: EventTypesToEventPayload; + }; + chatRoom: { + name: `chatRoomStream:${MiChatRoom['id']}`; payload: EventTypesToEventPayload; }; reversi: { @@ -399,8 +403,13 @@ export class GlobalEventService { } @bindThis - public publishChatStream(fromUserId: MiUser['id'], toUserId: MiUser['id'], type: K, value?: ChatEventTypes[K]): void { - this.publish(`chatStream:${fromUserId}-${toUserId}`, type, typeof value === 'undefined' ? null : value); + public publishChatUserStream(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(toRoomId: MiChatRoom['id'], type: K, value?: ChatEventTypes[K]): void { + this.publish(`chatRoomStream:${toRoomId}`, type, typeof value === 'undefined' ? null : value); } @bindThis diff --git a/packages/backend/src/core/entities/ChatEntityService.ts b/packages/backend/src/core/entities/ChatEntityService.ts new file mode 100644 index 0000000000..6cb67df5cf --- /dev/null +++ b/packages/backend/src/core/entities/ChatEntityService.ts @@ -0,0 +1,209 @@ +/* + * 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 } 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'; + +@Injectable() +export class ChatEntityService { + constructor( + @Inject(DI.chatMessagesRepository) + private chatMessagesRepository: ChatMessagesRepository, + + @Inject(DI.chatRoomsRepository) + private chatRoomsRepository: ChatRoomsRepository, + + 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 | null>; + packedUsers: Map>; + packedRooms: Map | null>; + }; + }, + ): Promise> { + 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 }); + + 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.file ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file)) : null, + }; + } + + @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 [packedUsers, packedFiles] = 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)), + ]); + + return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles } }))); + } + + @bindThis + public async packMessageLite( + src: MiChatMessage['id'] | MiChatMessage, + options?: { + _hint_?: { + packedFiles: Map | null>; + }; + }, + ): Promise> { + const packedFiles = options?._hint_?.packedFiles; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + 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.file ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file)) : null, + }; + } + + @bindThis + public async packMessagesLite( + messages: MiChatMessage[], + ) { + if (messages.length === 0) return []; + + const [packedFiles] = await Promise.all([ + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)), + ]); + + return Promise.all(messages.map(message => this.packMessageLite(message, { _hint_: { packedFiles } }))); + } + + @bindThis + public async packMessageLiteForRoom( + src: MiChatMessage['id'] | MiChatMessage, + options?: { + _hint_?: { + packedFiles: Map | null>; + packedUsers: Map>; + }; + }, + ): Promise> { + const packedFiles = options?._hint_?.packedFiles; + const packedUsers = options?._hint_?.packedUsers; + + const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); + + return { + id: message.id, + createdAt: this.idService.parse(message.id).date.toISOString(), + text: message.text, + fromUserId: message.fromUserId, + toUserId: message.toUserId, + toUser: packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId), + toRoomId: message.toRoomId, + fileId: message.fileId, + file: message.file ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file)) : null, + }; + } + + @bindThis + public async packMessagesLiteForRoom( + messages: MiChatMessage[], + ) { + if (messages.length === 0) return []; + + const [packedUsers, packedFiles] = await Promise.all([ + this.userEntityService.packMany(messages.map(x => x.fromUser)) + .then(users => new Map(users.map(u => [u.id, u]))), + this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)), + ]); + + 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>; + }; + }, + ): Promise> { + const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src }); + + return { + id: room.id, + createdAt: this.idService.parse(room.id).date.toISOString(), + name: room.name, + ownerId: room.ownerId, + owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me), + }; + } + + @bindThis + public async packRooms( + rooms: MiChatRoom[], + me: { id: MiUser['id'] }, + ) { + if (rooms.length === 0) return []; + + const owners = rooms.map(x => x.owner ?? x.ownerId); + + const packedOwners = await this.userEntityService.packMany(owners, me) + .then(users => new Map(users.map(u => [u.id, u]))); + + return Promise.all(rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners } }))); + } +} diff --git a/packages/backend/src/core/entities/ChatMessageEntityService.ts b/packages/backend/src/core/entities/ChatMessageEntityService.ts deleted file mode 100644 index 6d3baa9159..0000000000 --- a/packages/backend/src/core/entities/ChatMessageEntityService.ts +++ /dev/null @@ -1,127 +0,0 @@ -/* - * 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 } 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'; - -@Injectable() -export class ChatMessageEntityService { - constructor( - @Inject(DI.chatMessagesRepository) - private chatMessagesRepository: ChatMessagesRepository, - - private userEntityService: UserEntityService, - private driveFileEntityService: DriveFileEntityService, - private idService: IdService, - ) { - } - - @bindThis - public async pack( - src: MiChatMessage['id'] | MiChatMessage, - me: { id: MiUser['id'] }, - options?: { - _hint_?: { - packedFiles: Map | null>; - packedUsers: Map> - }; - }, - ): Promise> { - const packedUsers = options?._hint_?.packedUsers; - const packedFiles = options?._hint_?.packedFiles; - - const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); - - return { - id: message.id, - createdAt: this.idService.parse(message.id).date.toISOString(), - text: message.text, - fromUserId: message.fromUserId, - fromUser: message.fromUserId !== me.id ? (packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId, me)) : undefined, - toUserId: message.toUserId, - toUser: (message.toUserId && message.toUserId !== me.id) ? (packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId, me)) : undefined, - //toRoomId: message.toRoomId, - //toRoom: message.toRoomId && opts.populateRoom ? await this.userRoomEntityService.pack(message.toRoom ?? message.toRoomId) : undefined, - fileId: message.fileId, - file: message.file ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file)) : null, - }; - } - - @bindThis - public async packMany( - 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 [packedUsers, packedFiles] = 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)), - ]); - - return Promise.all(messages.map(message => this.pack(message, me, { _hint_: { packedUsers, packedFiles } }))); - } - - @bindThis - public async packLite( - src: MiChatMessage['id'] | MiChatMessage, - options?: { - _hint_?: { - packedFiles: Map | null>; - }; - }, - ): Promise> { - const packedFiles = options?._hint_?.packedFiles; - - const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src }); - - return { - id: message.id, - createdAt: this.idService.parse(message.id).date.toISOString(), - text: message.text, - fromUserId: message.fromUserId, - toUserId: message.toUserId, - //toRoomId: message.toRoomId, - fileId: message.fileId, - file: message.file ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file)) : null, - }; - } - - @bindThis - public async packLiteMany( - messages: MiChatMessage[], - ) { - if (messages.length === 0) return []; - - const [packedFiles] = await Promise.all([ - this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null)), - ]); - - return Promise.all(messages.map(message => this.packLite(message, { _hint_: { packedFiles } }))); - } -} - diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index 3c848189ab..77d2838e09 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -86,6 +86,7 @@ export const DI = { chatApprovalsRepository: Symbol('chatApprovalsRepository'), chatRoomsRepository: Symbol('chatRoomsRepository'), chatRoomMembershipsRepository: Symbol('chatRoomMembershipsRepository'), + chatRoomInvitationsRepository: Symbol('chatRoomInvitationsRepository'), bubbleGameRecordsRepository: Symbol('bubbleGameRecordsRepository'), reversiGamesRepository: Symbol('reversiGamesRepository'), //#endregion diff --git a/packages/backend/src/models/ChatRoomInvitation.ts b/packages/backend/src/models/ChatRoomInvitation.ts new file mode 100644 index 0000000000..1e0c8bd746 --- /dev/null +++ b/packages/backend/src/models/ChatRoomInvitation.ts @@ -0,0 +1,40 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from './util/id.js'; +import { MiUser } from './User.js'; +import { MiChatRoom } from './ChatRoom.js'; + +@Entity('chat_room_invitation') +@Index(['userId', 'roomId'], { unique: true }) +export class MiChatRoomInvitation { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column({ + ...id(), + }) + public userId: MiUser['id']; + + @ManyToOne(type => MiUser, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: MiUser | null; + + @Index() + @Column({ + ...id(), + }) + public roomId: MiChatRoom['id']; + + @ManyToOne(type => MiChatRoom, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public room: MiChatRoom | null; +} diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 000be0a317..b7142d91bf 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -80,6 +80,7 @@ import { MiChatMessage, MiChatRoom, MiChatRoomMembership, + MiChatRoomInvitation, MiChatApproval, } from './_.js'; import type { Provider } from '@nestjs/common'; @@ -505,6 +506,12 @@ const $chatRoomMembershipsRepository: Provider = { inject: [DI.db], }; +const $chatRoomInvitationsRepository: Provider = { + provide: DI.chatRoomInvitationsRepository, + useFactory: (db: DataSource) => db.getRepository(MiChatRoomInvitation).extend(miRepository as MiRepository), + inject: [DI.db], +}; + const $chatApprovalsRepository: Provider = { provide: DI.chatApprovalsRepository, useFactory: (db: DataSource) => db.getRepository(MiChatApproval).extend(miRepository as MiRepository), @@ -596,6 +603,7 @@ const $reversiGamesRepository: Provider = { $chatMessagesRepository, $chatRoomsRepository, $chatRoomMembershipsRepository, + $chatRoomInvitationsRepository, $chatApprovalsRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, @@ -671,6 +679,7 @@ const $reversiGamesRepository: Provider = { $chatMessagesRepository, $chatRoomsRepository, $chatRoomMembershipsRepository, + $chatRoomInvitationsRepository, $chatApprovalsRepository, $bubbleGameRecordsRepository, $reversiGamesRepository, diff --git a/packages/backend/src/models/_.ts b/packages/backend/src/models/_.ts index 368306892c..e852b302f3 100644 --- a/packages/backend/src/models/_.ts +++ b/packages/backend/src/models/_.ts @@ -77,6 +77,7 @@ import { MiUserListFavorite } from '@/models/UserListFavorite.js'; import { MiChatMessage } from '@/models/ChatMessage.js'; import { MiChatRoom } from '@/models/ChatRoom.js'; import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; import { MiChatApproval } from '@/models/ChatApproval.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; @@ -196,6 +197,7 @@ export { MiChatMessage, MiChatRoom, MiChatRoomMembership, + MiChatRoomInvitation, MiChatApproval, MiBubbleGameRecord, MiReversiGame, @@ -271,6 +273,7 @@ export type UserMemoRepository = Repository & MiRepository & MiRepository; export type ChatRoomsRepository = Repository & MiRepository; export type ChatRoomMembershipsRepository = Repository & MiRepository; +export type ChatRoomInvitationsRepository = Repository & MiRepository; export type ChatApprovalsRepository = Repository & MiRepository; export type BubbleGameRecordsRepository = Repository & MiRepository; export type ReversiGamesRepository = Repository & MiRepository; diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 43f2b91c8f..4694e7003d 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -81,6 +81,7 @@ import { MiUserMemo } from '@/models/UserMemo.js'; import { MiChatMessage } from '@/models/ChatMessage.js'; import { MiChatRoom } from '@/models/ChatRoom.js'; import { MiChatRoomMembership } from '@/models/ChatRoomMembership.js'; +import { MiChatRoomInvitation } from '@/models/ChatRoomInvitation.js'; import { MiBubbleGameRecord } from '@/models/BubbleGameRecord.js'; import { MiReversiGame } from '@/models/ReversiGame.js'; import { MiChatApproval } from '@/models/ChatApproval.js'; @@ -240,6 +241,7 @@ export const entities = [ MiChatMessage, MiChatRoom, MiChatRoomMembership, + MiChatRoomInvitation, MiChatApproval, MiBubbleGameRecord, MiReversiGame, diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index 11998f8d49..0223650329 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -44,7 +44,8 @@ import { QueueStatsChannelService } from './api/stream/channels/queue-stats.js'; import { ServerStatsChannelService } from './api/stream/channels/server-stats.js'; import { UserListChannelService } from './api/stream/channels/user-list.js'; import { RoleTimelineChannelService } from './api/stream/channels/role-timeline.js'; -import { ChatChannelService } from './api/stream/channels/chat.js'; +import { ChatUserChannelService } from './api/stream/channels/chat-user.js'; +import { ChatRoomChannelService } from './api/stream/channels/chat-room.js'; import { ReversiChannelService } from './api/stream/channels/reversi.js'; import { ReversiGameChannelService } from './api/stream/channels/reversi-game.js'; import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.js'; @@ -85,7 +86,8 @@ import { SigninWithPasskeyApiService } from './api/SigninWithPasskeyApiService.j GlobalTimelineChannelService, HashtagChannelService, RoleTimelineChannelService, - ChatChannelService, + ChatUserChannelService, + ChatRoomChannelService, ReversiChannelService, ReversiGameChannelService, HomeTimelineChannelService, diff --git a/packages/backend/src/server/api/endpoints/chat/history.ts b/packages/backend/src/server/api/endpoints/chat/history.ts index 366ea9c96f..7553a751e0 100644 --- a/packages/backend/src/server/api/endpoints/chat/history.ts +++ b/packages/backend/src/server/api/endpoints/chat/history.ts @@ -7,7 +7,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { ChatService } from '@/core/ChatService.js'; -import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; import { ApiError } from '@/server/api/error.js'; export const meta = { @@ -42,15 +42,21 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - private chatMessageEntityService: ChatMessageEntityService, + private chatEntityService: ChatEntityService, private chatService: ChatService, ) { super(meta, paramDef, async (ps, me) => { const history = ps.room ? await this.chatService.roomHistory(me.id, ps.limit) : await this.chatService.userHistory(me.id, ps.limit); - const packedMessages = await this.chatMessageEntityService.packMany(history, me); + const packedMessages = await this.chatEntityService.packMessagesDetailed(history, me); if (ps.room) { + const roomIds = history.map(m => m.toRoomId!); + const readStateMap = await this.chatService.getRoomReadStateMap(me.id, roomIds); + + for (const message of packedMessages) { + message.isRead = readStateMap[message.toRoomId!] ?? false; + } } else { const otherIds = history.map(m => m.fromUserId === me.id ? m.toUserId! : m.fromUserId!); const readStateMap = await this.chatService.getUserReadStateMap(me.id, otherIds); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/create.ts b/packages/backend/src/server/api/endpoints/chat/messages/create.ts index c95da79a1b..cba8c84dfc 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/create.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/create.ts @@ -125,7 +125,7 @@ export default class extends Endpoint { // eslint- throw err; }); - return await this.chatService.createMessage(me, toUser, { + return await this.chatService.createMessageToUser(me, toUser, { text: ps.text, file: file, }); diff --git a/packages/backend/src/server/api/endpoints/chat/messages/delete.ts b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts index ff4f4bd43f..14957eeece 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/delete.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/delete.ts @@ -8,7 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ChatService } from '@/core/ChatService.js'; -import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; import { ApiError } from '@/server/api/error.js'; export const meta = { diff --git a/packages/backend/src/server/api/endpoints/chat/messages/show.ts b/packages/backend/src/server/api/endpoints/chat/messages/show.ts index 1c3346baf0..371f7a7071 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/show.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/show.ts @@ -8,7 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ChatService } from '@/core/ChatService.js'; -import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; import { ApiError } from '@/server/api/error.js'; import { RoleService } from '@/core/RoleService.js'; @@ -47,7 +47,7 @@ export default class extends Endpoint { // eslint- constructor( private chatService: ChatService, private roleService: RoleService, - private chatMessageEntityService: ChatMessageEntityService, + private chatEntityService: ChatEntityService, ) { super(meta, paramDef, async (ps, me) => { const message = await this.chatService.findMessageById(ps.messageId); @@ -57,7 +57,7 @@ export default class extends Endpoint { // eslint- if (message.fromUserId !== me.id && message.toUserId !== me.id && !(await this.roleService.isModerator(me))) { throw new ApiError(meta.errors.noSuchMessage); } - return this.chatMessageEntityService.pack(message, me); + return this.chatEntityService.packMessageDetailed(message, me); }); } } diff --git a/packages/backend/src/server/api/endpoints/chat/messages/timeline.ts b/packages/backend/src/server/api/endpoints/chat/messages/timeline.ts index 08c2c8842e..b4a4020a97 100644 --- a/packages/backend/src/server/api/endpoints/chat/messages/timeline.ts +++ b/packages/backend/src/server/api/endpoints/chat/messages/timeline.ts @@ -8,7 +8,7 @@ import { Endpoint } from '@/server/api/endpoint-base.js'; import { DI } from '@/di-symbols.js'; import { GetterService } from '@/server/api/GetterService.js'; import { ChatService } from '@/core/ChatService.js'; -import { ChatMessageEntityService } from '@/core/entities/ChatMessageEntityService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; import { ApiError } from '@/server/api/error.js'; export const meta = { @@ -62,7 +62,7 @@ export const paramDef = { @Injectable() export default class extends Endpoint { // eslint-disable-line import/no-default-export constructor( - private chatMessageEntityService: ChatMessageEntityService, + private chatEntityService: ChatEntityService, private chatService: ChatService, private getterService: GetterService, ) { @@ -77,7 +77,7 @@ export default class extends Endpoint { // eslint- this.chatService.readUserChatMessage(me.id, other.id); - return await this.chatMessageEntityService.packLiteMany(messages); + return await this.chatEntityService.packMessagesLite(messages); }/* else if (ps.roomId != null) { // Fetch recipient (room) const recipientRoom = await this.userRoomRepository.findOneBy({ id: ps.roomId }); diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts new file mode 100644 index 0000000000..5aeaa17cbe --- /dev/null +++ b/packages/backend/src/server/api/endpoints/chat/rooms/create.ts @@ -0,0 +1,58 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '@/server/api/error.js'; +import { ChatService } from '@/core/ChatService.js'; +import { ChatEntityService } from '@/core/entities/ChatEntityService.js'; + +export const meta = { + tags: ['chat'], + + requireCredential: true, + requiredRolePolicy: 'canChat', + + prohibitMoved: true, + + kind: 'write:chat', + + limit: { + duration: ms('1day'), + max: 10, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'ChatRoom', + }, + + errors: { + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + name: { type: 'string', maxLength: 256 }, + }, + required: ['name'], +} as const; + +@Injectable() +export default class extends Endpoint { // eslint-disable-line import/no-default-export + constructor( + private chatService: ChatService, + private chatEntityService: ChatEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const room = await this.chatService.createRoom(me, ps.name); + return await this.chatEntityService.packRoom(room); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts b/packages/backend/src/server/api/endpoints/chat/rooms/delete.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/accept.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/accept.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/create.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/delete.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/delete.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/inbox.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/invitations/reject.ts b/packages/backend/src/server/api/endpoints/chat/rooms/invitations/reject.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts b/packages/backend/src/server/api/endpoints/chat/rooms/owned.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index 9ec0a25171..c0ef589dea 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -19,7 +19,8 @@ import { AntennaChannelService } from './channels/antenna.js'; import { DriveChannelService } from './channels/drive.js'; import { HashtagChannelService } from './channels/hashtag.js'; import { RoleTimelineChannelService } from './channels/role-timeline.js'; -import { ChatChannelService } from './channels/chat.js'; +import { ChatUserChannelService } from './channels/chat-user.js'; +import { ChatRoomChannelService } from './channels/chat-room.js'; import { ReversiChannelService } from './channels/reversi.js'; import { ReversiGameChannelService } from './channels/reversi-game.js'; import { type MiChannelService } from './channel.js'; @@ -41,7 +42,8 @@ export class ChannelsService { private serverStatsChannelService: ServerStatsChannelService, private queueStatsChannelService: QueueStatsChannelService, private adminChannelService: AdminChannelService, - private chatChannelService: ChatChannelService, + private chatUserChannelService: ChatUserChannelService, + private chatRoomChannelService: ChatRoomChannelService, private reversiChannelService: ReversiChannelService, private reversiGameChannelService: ReversiGameChannelService, ) { @@ -64,7 +66,8 @@ export class ChannelsService { case 'serverStats': return this.serverStatsChannelService; case 'queueStats': return this.queueStatsChannelService; case 'admin': return this.adminChannelService; - case 'chat': return this.chatChannelService; + case 'chatUser': return this.chatUserChannelService; + case 'chatRoom': return this.chatRoomChannelService; case 'reversi': return this.reversiChannelService; case 'reversiGame': return this.reversiGameChannelService; diff --git a/packages/backend/src/server/api/stream/channels/chat-room.ts b/packages/backend/src/server/api/stream/channels/chat-room.ts new file mode 100644 index 0000000000..e989969345 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/chat-room.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: syuilo and misskey-project + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import type { GlobalEvents } from '@/core/GlobalEventService.js'; +import type { JsonObject } from '@/misc/json-value.js'; +import { ChatService } from '@/core/ChatService.js'; +import Channel, { type MiChannelService } from '../channel.js'; + +class ChatRoomChannel extends Channel { + public readonly chName = 'chatRoom'; + public static shouldShare = false; + public static requireCredential = true as const; + public static kind = 'read:chat'; + private roomId: string; + + constructor( + private chatService: ChatService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + } + + @bindThis + public async init(params: JsonObject) { + if (typeof params.roomId !== 'string') return; + this.roomId = params.roomId; + + this.subscriber.on(`chatRoomStream:${this.roomId}`, this.onEvent); + } + + @bindThis + private async onEvent(data: GlobalEvents['chat']['payload']) { + this.send(data.type, data.body); + } + + @bindThis + public onMessage(type: string, body: any) { + switch (type) { + case 'read': + if (this.roomId) { + this.chatService.readRoomChatMessage(this.user!.id, this.roomId); + } + break; + } + } + + @bindThis + public dispose() { + this.subscriber.off(`chatRoomStream:${this.roomId}`, this.onEvent); + } +} + +@Injectable() +export class ChatRoomChannelService implements MiChannelService { + public readonly shouldShare = ChatRoomChannel.shouldShare; + public readonly requireCredential = ChatRoomChannel.requireCredential; + public readonly kind = ChatRoomChannel.kind; + + constructor( + private chatService: ChatService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): ChatRoomChannel { + return new ChatRoomChannel( + this.chatService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/chat.ts b/packages/backend/src/server/api/stream/channels/chat-user.ts similarity index 72% rename from packages/backend/src/server/api/stream/channels/chat.ts rename to packages/backend/src/server/api/stream/channels/chat-user.ts index ac5d7c73b3..c4e898cd5b 100644 --- a/packages/backend/src/server/api/stream/channels/chat.ts +++ b/packages/backend/src/server/api/stream/channels/chat-user.ts @@ -10,8 +10,8 @@ import type { JsonObject } from '@/misc/json-value.js'; import { ChatService } from '@/core/ChatService.js'; import Channel, { type MiChannelService } from '../channel.js'; -class ChatChannel extends Channel { - public readonly chName = 'chat'; +class ChatUserChannel extends Channel { + public readonly chName = 'chatUser'; public static shouldShare = false; public static requireCredential = true as const; public static kind = 'read:chat'; @@ -31,7 +31,7 @@ class ChatChannel extends Channel { if (typeof params.otherId !== 'string') return; this.otherId = params.otherId; - this.subscriber.on(`chatStream:${this.user!.id}-${this.otherId}`, this.onEvent); + this.subscriber.on(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent); } @bindThis @@ -52,16 +52,15 @@ class ChatChannel extends Channel { @bindThis public dispose() { - // Unsubscribe events - this.subscriber.off(`chatStream:${this.user!.id}-${this.otherId}`, this.onEvent); + this.subscriber.off(`chatUserStream:${this.user!.id}-${this.otherId}`, this.onEvent); } } @Injectable() -export class ChatChannelService implements MiChannelService { - public readonly shouldShare = ChatChannel.shouldShare; - public readonly requireCredential = ChatChannel.requireCredential; - public readonly kind = ChatChannel.kind; +export class ChatUserChannelService implements MiChannelService { + public readonly shouldShare = ChatUserChannel.shouldShare; + public readonly requireCredential = ChatUserChannel.requireCredential; + public readonly kind = ChatUserChannel.kind; constructor( private chatService: ChatService, @@ -69,8 +68,8 @@ export class ChatChannelService implements MiChannelService { } @bindThis - public create(id: string, connection: Channel['connection']): ChatChannel { - return new ChatChannel( + public create(id: string, connection: Channel['connection']): ChatUserChannel { + return new ChatUserChannel( this.chatService, id, connection, diff --git a/packages/frontend/src/pages/chat/room.vue b/packages/frontend/src/pages/chat/room.vue index 5781cc47c2..e293ccbf6e 100644 --- a/packages/frontend/src/pages/chat/room.vue +++ b/packages/frontend/src/pages/chat/room.vue @@ -88,11 +88,11 @@ const connection = ref | nul const showIndicator = ref(false); async function initialize() { + const LIMIT = 20; + initializing.value = true; if (props.userId) { - const LIMIT = 20; - const [u, m] = await Promise.all([ misskeyApi('users/show', { userId: props.userId }), misskeyApi('chat/messages/timeline', { userId: props.userId, limit: LIMIT }), @@ -105,28 +105,30 @@ async function initialize() { canFetchMore.value = true; } - connection.value = useStream().useChannel('chat', { + connection.value = useStream().useChannel('chatUser', { otherId: user.value.id, }); - }/* else { - user = null; - room = await misskeyApi('users/rooms/show', { roomId: props.roomId }); + connection.value.on('message', onMessage); + connection.value.on('deleted', onDeleted); + } else { + const [r, m] = await Promise.all([ + misskeyApi('chat/rooms/show', { roomId: props.roomId }), + misskeyApi('chat/messages/timeline', { roomId: props.roomId, limit: LIMIT }), + ]); - pagination = { - endpoint: 'chat/messages', - limit: 20, - params: { - roomId: room?.id, - }, - reversed: true, - }; - connection = useStream().useChannel('chat', { - room: room?.id, + room.value = r; + messages.value = m; + + if (messages.value.length === LIMIT) { + canFetchMore.value = true; + } + + connection.value = useStream().useChannel('chatRoom', { + otherId: user.value.id, }); - }*/ - - connection.value.on('message', onMessage); - connection.value.on('deleted', onDeleted); + connection.value.on('message', onMessage); + connection.value.on('deleted', onDeleted); + } window.document.addEventListener('visibilitychange', onVisibilitychange); diff --git a/packages/misskey-js/etc/misskey-js.api.md b/packages/misskey-js/etc/misskey-js.api.md index 110525d3f7..dd1ac89ea3 100644 --- a/packages/misskey-js/etc/misskey-js.api.md +++ b/packages/misskey-js/etc/misskey-js.api.md @@ -971,6 +971,12 @@ type ChatMessagesDeleteRequest = operations['chat___messages___delete']['request // @public (undocumented) type ChatMessagesDeleteResponse = operations['chat___messages___delete']['responses']['200']['content']['application/json']; +// @public (undocumented) +type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody']['content']['application/json']; + +// @public (undocumented) +type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json']; + // @public (undocumented) type ChatMessagesTimelineRequest = operations['chat___messages___timeline']['requestBody']['content']['application/json']; @@ -1483,6 +1489,8 @@ declare namespace entities { ChatMessagesCreateResponse, ChatMessagesDeleteRequest, ChatMessagesDeleteResponse, + ChatMessagesShowRequest, + ChatMessagesShowResponse, ChatMessagesTimelineRequest, ChatMessagesTimelineResponse, ClipsAddNoteRequest, diff --git a/packages/misskey-js/src/autogen/apiClientJSDoc.ts b/packages/misskey-js/src/autogen/apiClientJSDoc.ts index 8983f616e6..7ee50678a3 100644 --- a/packages/misskey-js/src/autogen/apiClientJSDoc.ts +++ b/packages/misskey-js/src/autogen/apiClientJSDoc.ts @@ -1567,6 +1567,17 @@ declare module '../api.js' { credential?: string | null, ): Promise>; + /** + * No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:chat* + */ + request( + endpoint: E, + params: P, + credential?: string | null, + ): Promise>; + /** * No description provided. * diff --git a/packages/misskey-js/src/autogen/endpoint.ts b/packages/misskey-js/src/autogen/endpoint.ts index 61fd110bbf..74887f5587 100644 --- a/packages/misskey-js/src/autogen/endpoint.ts +++ b/packages/misskey-js/src/autogen/endpoint.ts @@ -213,6 +213,8 @@ import type { ChatMessagesCreateResponse, ChatMessagesDeleteRequest, ChatMessagesDeleteResponse, + ChatMessagesShowRequest, + ChatMessagesShowResponse, ChatMessagesTimelineRequest, ChatMessagesTimelineResponse, ClipsAddNoteRequest, @@ -737,6 +739,7 @@ export type Endpoints = { 'chat/history': { req: ChatHistoryRequest; res: ChatHistoryResponse }; 'chat/messages/create': { req: ChatMessagesCreateRequest; res: ChatMessagesCreateResponse }; 'chat/messages/delete': { req: ChatMessagesDeleteRequest; res: ChatMessagesDeleteResponse }; + 'chat/messages/show': { req: ChatMessagesShowRequest; res: ChatMessagesShowResponse }; 'chat/messages/timeline': { req: ChatMessagesTimelineRequest; res: ChatMessagesTimelineResponse }; 'clips/add-note': { req: ClipsAddNoteRequest; res: EmptyResponse }; 'clips/create': { req: ClipsCreateRequest; res: ClipsCreateResponse }; diff --git a/packages/misskey-js/src/autogen/entities.ts b/packages/misskey-js/src/autogen/entities.ts index 4a05c4f1bb..a1c3a7ffb3 100644 --- a/packages/misskey-js/src/autogen/entities.ts +++ b/packages/misskey-js/src/autogen/entities.ts @@ -216,6 +216,8 @@ export type ChatMessagesCreateRequest = operations['chat___messages___create'][' export type ChatMessagesCreateResponse = operations['chat___messages___create']['responses']['200']['content']['application/json']; export type ChatMessagesDeleteRequest = operations['chat___messages___delete']['requestBody']['content']['application/json']; export type ChatMessagesDeleteResponse = operations['chat___messages___delete']['responses']['200']['content']['application/json']; +export type ChatMessagesShowRequest = operations['chat___messages___show']['requestBody']['content']['application/json']; +export type ChatMessagesShowResponse = operations['chat___messages___show']['responses']['200']['content']['application/json']; export type ChatMessagesTimelineRequest = operations['chat___messages___timeline']['requestBody']['content']['application/json']; export type ChatMessagesTimelineResponse = operations['chat___messages___timeline']['responses']['200']['content']['application/json']; export type ClipsAddNoteRequest = operations['clips___add-note']['requestBody']['content']['application/json']; diff --git a/packages/misskey-js/src/autogen/types.ts b/packages/misskey-js/src/autogen/types.ts index baf6d69ba2..eaec05eca8 100644 --- a/packages/misskey-js/src/autogen/types.ts +++ b/packages/misskey-js/src/autogen/types.ts @@ -1385,6 +1385,15 @@ export type paths = { */ post: operations['chat___messages___delete']; }; + '/chat/messages/show': { + /** + * chat/messages/show + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:chat* + */ + post: operations['chat___messages___show']; + }; '/chat/messages/timeline': { /** * chat/messages/timeline @@ -13907,6 +13916,60 @@ export type operations = { }; }; }; + /** + * chat/messages/show + * @description No description provided. + * + * **Credential required**: *Yes* / **Permission**: *read:chat* + */ + chat___messages___show: { + requestBody: { + content: { + 'application/json': { + /** Format: misskey:id */ + messageId: string; + }; + }; + }; + responses: { + /** @description OK (with results) */ + 200: { + content: { + 'application/json': components['schemas']['ChatMessage']; + }; + }; + /** @description Client error */ + 400: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Authentication error */ + 401: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Forbidden error */ + 403: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description I'm Ai */ + 418: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + /** @description Internal server error */ + 500: { + content: { + 'application/json': components['schemas']['Error']; + }; + }; + }; + }; /** * chat/messages/timeline * @description No description provided.