diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index 0524585ca6..6a6d1b864a 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -22,6 +22,7 @@ import { IdService } from './IdService.js'; import { ImageProcessingService } from './ImageProcessingService.js'; import { InstanceActorService } from './InstanceActorService.js'; import { InternalStorageService } from './InternalStorageService.js'; +import { MessagingService } from './MessagingService.js'; import { MetaService } from './MetaService.js'; import { MfmService } from './MfmService.js'; import { ModerationLogService } from './ModerationLogService.js'; @@ -81,6 +82,7 @@ import { GalleryLikeEntityService } from './entities/GalleryLikeEntityService.js import { GalleryPostEntityService } from './entities/GalleryPostEntityService.js'; import { HashtagEntityService } from './entities/HashtagEntityService.js'; import { InstanceEntityService } from './entities/InstanceEntityService.js'; +import { MessagingMessageEntityService } from './entities/MessagingMessageEntityService.js'; import { ModerationLogEntityService } from './entities/ModerationLogEntityService.js'; import { MutingEntityService } from './entities/MutingEntityService.js'; import { NoteEntityService } from './entities/NoteEntityService.js'; @@ -144,6 +146,7 @@ const $IdService: Provider = { provide: 'IdService', useExisting: IdService }; const $ImageProcessingService: Provider = { provide: 'ImageProcessingService', useExisting: ImageProcessingService }; const $InstanceActorService: Provider = { provide: 'InstanceActorService', useExisting: InstanceActorService }; const $InternalStorageService: Provider = { provide: 'InternalStorageService', useExisting: InternalStorageService }; +const $MessagingService: Provider = { provide: 'MessagingService', useExisting: MessagingService }; const $MetaService: Provider = { provide: 'MetaService', useExisting: MetaService }; const $MfmService: Provider = { provide: 'MfmService', useExisting: MfmService }; const $ModerationLogService: Provider = { provide: 'ModerationLogService', useExisting: ModerationLogService }; @@ -204,6 +207,7 @@ const $GalleryLikeEntityService: Provider = { provide: 'GalleryLikeEntityService const $GalleryPostEntityService: Provider = { provide: 'GalleryPostEntityService', useExisting: GalleryPostEntityService }; const $HashtagEntityService: Provider = { provide: 'HashtagEntityService', useExisting: HashtagEntityService }; const $InstanceEntityService: Provider = { provide: 'InstanceEntityService', useExisting: InstanceEntityService }; +const $MessagingMessageEntityService: Provider = { provide: 'MessagingMessageEntityService', useExisting: MessagingMessageEntityService }; const $ModerationLogEntityService: Provider = { provide: 'ModerationLogEntityService', useExisting: ModerationLogEntityService }; const $MutingEntityService: Provider = { provide: 'MutingEntityService', useExisting: MutingEntityService }; const $NoteEntityService: Provider = { provide: 'NoteEntityService', useExisting: NoteEntityService }; @@ -269,6 +273,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ImageProcessingService, InstanceActorService, InternalStorageService, + MessagingService, MetaService, MfmService, ModerationLogService, @@ -328,6 +333,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting GalleryPostEntityService, HashtagEntityService, InstanceEntityService, + MessagingMessageEntityService, ModerationLogEntityService, MutingEntityService, NoteEntityService, @@ -388,6 +394,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ImageProcessingService, $InstanceActorService, $InternalStorageService, + $MessagingService, $MetaService, $MfmService, $ModerationLogService, @@ -447,6 +454,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $GalleryPostEntityService, $HashtagEntityService, $InstanceEntityService, + $MessagingMessageEntityService, $ModerationLogEntityService, $MutingEntityService, $NoteEntityService, @@ -508,6 +516,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ImageProcessingService, InstanceActorService, InternalStorageService, + MessagingService, MetaService, MfmService, ModerationLogService, @@ -566,6 +575,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting GalleryPostEntityService, HashtagEntityService, InstanceEntityService, + MessagingMessageEntityService, ModerationLogEntityService, MutingEntityService, NoteEntityService, @@ -626,6 +636,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ImageProcessingService, $InstanceActorService, $InternalStorageService, + $MessagingService, $MetaService, $MfmService, $ModerationLogService, @@ -684,6 +695,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $GalleryPostEntityService, $HashtagEntityService, $InstanceEntityService, + $MessagingMessageEntityService, $ModerationLogEntityService, $MutingEntityService, $NoteEntityService, diff --git a/packages/backend/src/core/GlobalEventService.ts b/packages/backend/src/core/GlobalEventService.ts index f70347c46a..784612149d 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -11,9 +11,13 @@ import type { AdminStreamTypes, AntennaStreamTypes, BroadcastTypes, + ChannelStreamTypes, DriveStreamTypes, + GroupMessagingStreamTypes, InternalStreamTypes, MainStreamTypes, + MessagingIndexStreamTypes, + MessagingStreamTypes, NoteStreamTypes, UserListStreamTypes, UserStreamTypes, @@ -79,6 +83,11 @@ export class GlobalEventService { }); } + @bindThis + public publishChannelStream(channelId: Channel['id'], type: K, value?: ChannelStreamTypes[K]): void { + this.publish(`channelStream:${channelId}`, type, typeof value === 'undefined' ? null : value); + } + @bindThis public publishUserListStream(listId: UserList['id'], type: K, value?: UserListStreamTypes[K]): void { this.publish(`userListStream:${listId}`, type, typeof value === 'undefined' ? null : value); @@ -89,6 +98,21 @@ export class GlobalEventService { this.publish(`antennaStream:${antennaId}`, type, typeof value === 'undefined' ? null : value); } + @bindThis + public publishMessagingStream(userId: User['id'], otherpartyId: User['id'], type: K, value?: MessagingStreamTypes[K]): void { + this.publish(`messagingStream:${userId}-${otherpartyId}`, type, typeof value === 'undefined' ? null : value); + } + + @bindThis + public publishGroupMessagingStream(groupId: UserGroup['id'], type: K, value?: GroupMessagingStreamTypes[K]): void { + this.publish(`messagingStream:${groupId}`, type, typeof value === 'undefined' ? null : value); + } + + @bindThis + public publishMessagingIndexStream(userId: User['id'], type: K, value?: MessagingIndexStreamTypes[K]): void { + this.publish(`messagingIndexStream:${userId}`, type, typeof value === 'undefined' ? null : value); + } + @bindThis public publishNotesStream(note: Packed<'Note'>): void { this.publish('notesStream', null, note); diff --git a/packages/backend/src/core/MessagingService.ts b/packages/backend/src/core/MessagingService.ts new file mode 100644 index 0000000000..3a8a25c602 --- /dev/null +++ b/packages/backend/src/core/MessagingService.ts @@ -0,0 +1,307 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { In, Not } from 'typeorm'; +import { DI } from '@/di-symbols.js'; +import type { Config } from '@/config.js'; +import type { DriveFile } from '@/models/entities/DriveFile.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import type { Note } from '@/models/entities/Note.js'; +import type { User, RemoteUser } from '@/models/entities/User.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import { QueueService } from '@/core/QueueService.js'; +import { toArray } from '@/misc/prelude/array.js'; +import { IdentifiableError } from '@/misc/identifiable-error.js'; +import type { MessagingMessagesRepository, MutingsRepository, UserGroupJoiningsRepository, UsersRepository } from '@/models/index.js'; +import { IdService } from '@/core/IdService.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { ApRendererService } from '@/core/activitypub/ApRendererService.js'; +import { MessagingMessageEntityService } from '@/core/entities/MessagingMessageEntityService.js'; +import { PushNotificationService } from '@/core/PushNotificationService.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class MessagingService { + constructor( + @Inject(DI.config) + private config: Config, + + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + private userEntityService: UserEntityService, + private messagingMessageEntityService: MessagingMessageEntityService, + private idService: IdService, + private globalEventService: GlobalEventService, + private apRendererService: ApRendererService, + private queueService: QueueService, + private pushNotificationService: PushNotificationService, + ) { + } + + @bindThis + public async createMessage(user: { id: User['id']; host: User['host']; }, recipientUser: User | undefined, recipientGroup: UserGroup | undefined, text: string | null | undefined, file: DriveFile | null, uri?: string) { + const message = { + id: this.idService.genId(), + createdAt: new Date(), + fileId: file ? file.id : null, + recipientId: recipientUser ? recipientUser.id : null, + groupId: recipientGroup ? recipientGroup.id : null, + text: text ? text.trim() : null, + userId: user.id, + isRead: false, + reads: [] as any[], + uri, + } as MessagingMessage; + + await this.messagingMessagesRepository.insert(message); + + const messageObj = await this.messagingMessageEntityService.pack(message); + + if (recipientUser) { + if (this.userEntityService.isLocalUser(user)) { + // 自分のストリーム + this.globalEventService.publishMessagingStream(message.userId, recipientUser.id, 'message', messageObj); + this.globalEventService.publishMessagingIndexStream(message.userId, 'message', messageObj); + this.globalEventService.publishMainStream(message.userId, 'messagingMessage', messageObj); + } + + if (this.userEntityService.isLocalUser(recipientUser)) { + // 相手のストリーム + this.globalEventService.publishMessagingStream(recipientUser.id, message.userId, 'message', messageObj); + this.globalEventService.publishMessagingIndexStream(recipientUser.id, 'message', messageObj); + this.globalEventService.publishMainStream(recipientUser.id, 'messagingMessage', messageObj); + } + } else if (recipientGroup) { + // グループのストリーム + this.globalEventService.publishGroupMessagingStream(recipientGroup.id, 'message', messageObj); + + // メンバーのストリーム + const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: recipientGroup.id }); + for (const joining of joinings) { + this.globalEventService.publishMessagingIndexStream(joining.userId, 'message', messageObj); + this.globalEventService.publishMainStream(joining.userId, 'messagingMessage', messageObj); + } + } + + // 2秒経っても(今回作成した)メッセージが既読にならなかったら「未読のメッセージがありますよ」イベントを発行する + setTimeout(async () => { + const freshMessage = await this.messagingMessagesRepository.findOneBy({ id: message.id }); + if (freshMessage == null) return; // メッセージが削除されている場合もある + + if (recipientUser && this.userEntityService.isLocalUser(recipientUser)) { + if (freshMessage.isRead) return; // 既読 + + //#region ただしミュートされているなら発行しない + const mute = await this.mutingsRepository.findBy({ + muterId: recipientUser.id, + }); + if (mute.map(m => m.muteeId).includes(user.id)) return; + //#endregion + + this.globalEventService.publishMainStream(recipientUser.id, 'unreadMessagingMessage', messageObj); + this.pushNotificationService.pushNotification(recipientUser.id, 'unreadMessagingMessage', messageObj); + } else if (recipientGroup) { + const joinings = await this.userGroupJoiningsRepository.findBy({ userGroupId: recipientGroup.id, userId: Not(user.id) }); + for (const joining of joinings) { + if (freshMessage.reads.includes(joining.userId)) return; // 既読 + this.globalEventService.publishMainStream(joining.userId, 'unreadMessagingMessage', messageObj); + this.pushNotificationService.pushNotification(joining.userId, 'unreadMessagingMessage', messageObj); + } + } + }, 2000); + + if (recipientUser && this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipientUser)) { + const note = { + id: message.id, + createdAt: message.createdAt, + fileIds: message.fileId ? [message.fileId] : [], + text: message.text, + userId: message.userId, + visibility: 'specified', + mentions: [recipientUser].map(u => u.id), + mentionedRemoteUsers: JSON.stringify([recipientUser].map(u => ({ + uri: u.uri, + username: u.username, + host: u.host, + }))), + } as Note; + + const activity = this.apRendererService.addContext(this.apRendererService.renderCreate(await this.apRendererService.renderNote(note, false, true), note)); + + this.queueService.deliver(user, activity, recipientUser.inbox); + } + return messageObj; + } + + @bindThis + public async deleteMessage(message: MessagingMessage) { + await this.messagingMessagesRepository.delete(message.id); + this.postDeleteMessage(message); + } + + @bindThis + private async postDeleteMessage(message: MessagingMessage) { + if (message.recipientId) { + const user = await this.usersRepository.findOneByOrFail({ id: message.userId }); + const recipient = await this.usersRepository.findOneByOrFail({ id: message.recipientId }); + + if (this.userEntityService.isLocalUser(user)) this.globalEventService.publishMessagingStream(message.userId, message.recipientId, 'deleted', message.id); + if (this.userEntityService.isLocalUser(recipient)) this.globalEventService.publishMessagingStream(message.recipientId, message.userId, 'deleted', message.id); + + if (this.userEntityService.isLocalUser(user) && this.userEntityService.isRemoteUser(recipient)) { + const activity = this.apRendererService.addContext(this.apRendererService.renderDelete(this.apRendererService.renderTombstone(`${this.config.url}/notes/${message.id}`), user)); + this.queueService.deliver(user, activity, recipient.inbox); + } + } else if (message.groupId) { + this.globalEventService.publishGroupMessagingStream(message.groupId, 'deleted', message.id); + } + } + + /** + * Mark messages as read + */ + @bindThis + public async readUserMessagingMessage( + userId: User['id'], + otherpartyId: User['id'], + messageIds: MessagingMessage['id'][], + ) { + if (messageIds.length === 0) return; + + const messages = await this.messagingMessagesRepository.findBy({ + id: In(messageIds), + }); + + for (const message of messages) { + if (message.recipientId !== userId) { + throw new IdentifiableError('e140a4bf-49ce-4fb6-b67c-b78dadf6b52f', 'Access denied (user).'); + } + } + + // Update documents + await this.messagingMessagesRepository.update({ + id: In(messageIds), + userId: otherpartyId, + recipientId: userId, + isRead: false, + }, { + isRead: true, + }); + + // Publish event + this.globalEventService.publishMessagingStream(otherpartyId, userId, 'read', messageIds); + this.globalEventService.publishMessagingIndexStream(userId, 'read', messageIds); + + if (!await this.userEntityService.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + this.globalEventService.publishMainStream(userId, 'readAllMessagingMessages'); + this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessages', undefined); + } else { + // そのユーザーとのメッセージで未読がなければイベント発行 + const count = await this.messagingMessagesRepository.count({ + where: { + userId: otherpartyId, + recipientId: userId, + isRead: false, + }, + take: 1, + }); + + if (!count) { + this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessagesOfARoom', { userId: otherpartyId }); + } + } + } + + /** + * Mark messages as read + */ + @bindThis + public async readGroupMessagingMessage( + userId: User['id'], + groupId: UserGroup['id'], + messageIds: MessagingMessage['id'][], + ) { + if (messageIds.length === 0) return; + + // check joined + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userId: userId, + userGroupId: groupId, + }); + + if (joining == null) { + throw new IdentifiableError('930a270c-714a-46b2-b776-ad27276dc569', 'Access denied (group).'); + } + + const messages = await this.messagingMessagesRepository.findBy({ + id: In(messageIds), + }); + + const reads: MessagingMessage['id'][] = []; + + for (const message of messages) { + if (message.userId === userId) continue; + if (message.reads.includes(userId)) continue; + + // Update document + await this.messagingMessagesRepository.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.publishGroupMessagingStream(groupId, 'read', { + ids: reads, + userId: userId, + }); + this.globalEventService.publishMessagingIndexStream(userId, 'read', reads); + + if (!await this.userEntityService.getHasUnreadMessagingMessage(userId)) { + // 全ての(いままで未読だった)自分宛てのメッセージを(これで)読みましたよというイベントを発行 + this.globalEventService.publishMainStream(userId, 'readAllMessagingMessages'); + this.pushNotificationService.pushNotification(userId, 'readAllMessagingMessages', undefined); + } else { + // そのグループにおいて未読がなければイベント発行 + const unreadExist = await this.messagingMessagesRepository.createQueryBuilder('message') + .where('message.groupId = :groupId', { groupId: groupId }) + .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, 'readAllMessagingMessagesOfARoom', { groupId }); + } + } + } + + @bindThis + public async deliverReadActivity(user: { id: User['id']; host: null; }, recipient: RemoteUser, messages: MessagingMessage | MessagingMessage[]) { + messages = toArray(messages).filter(x => x.uri); + const contents = messages.map(x => this.apRendererService.renderRead(user, x)); + + if (contents.length > 1) { + const collection = this.apRendererService.renderOrderedCollection(null, contents.length, undefined, undefined, contents); + this.queueService.deliver(user, this.apRendererService.addContext(collection), recipient.inbox); + } else { + for (const content of contents) { + this.queueService.deliver(user, this.apRendererService.addContext(content), recipient.inbox); + } + } + } +} diff --git a/packages/backend/src/core/PushNotificationService.ts b/packages/backend/src/core/PushNotificationService.ts index aff233a3e4..b18b7bb2cd 100644 --- a/packages/backend/src/core/PushNotificationService.ts +++ b/packages/backend/src/core/PushNotificationService.ts @@ -11,12 +11,15 @@ import { bindThis } from '@/decorators.js'; // Defined also packages/sw/types.ts#L13 type pushNotificationsTypes = { 'notification': Packed<'Notification'>; + 'unreadMessagingMessage': Packed<'MessagingMessage'>; 'unreadAntennaNote': { antenna: { id: string, name: string }; note: Packed<'Note'>; }; 'readNotifications': { notificationIds: string[] }; 'readAllNotifications': undefined; + 'readAllMessagingMessages': undefined; + 'readAllMessagingMessagesOfARoom': { userId: string } | { groupId: string }; 'readAntenna': { antennaId: string }; 'readAllAntennas': undefined; }; @@ -37,10 +40,11 @@ function truncateBody(type: T, body: pus reply: undefined, renote: undefined, user: type === 'notification' ? undefined as any : body.note.user, - }, + } } : {}), }; + return body; } @Injectable() @@ -77,6 +81,8 @@ export class PushNotificationService { if ([ 'readNotifications', 'readAllNotifications', + 'readAllMessagingMessages', + 'readAllMessagingMessagesOfARoom', 'readAntenna', 'readAllAntennas', ].includes(type) && !subscription.sendReadMessage) continue; diff --git a/packages/backend/src/core/activitypub/ApDbResolverService.ts b/packages/backend/src/core/activitypub/ApDbResolverService.ts index d0a4ad7a75..9a894826c8 100644 --- a/packages/backend/src/core/activitypub/ApDbResolverService.ts +++ b/packages/backend/src/core/activitypub/ApDbResolverService.ts @@ -1,12 +1,13 @@ import { Inject, Injectable } from '@nestjs/common'; import escapeRegexp from 'escape-regexp'; import { DI } from '@/di-symbols.js'; -import type { NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; +import type { MessagingMessagesRepository, NotesRepository, UserPublickeysRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import { Cache } from '@/misc/cache.js'; import type { UserPublickey } from '@/models/entities/UserPublickey.js'; import { UserCacheService } from '@/core/UserCacheService.js'; import type { Note } from '@/models/entities/Note.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; import { bindThis } from '@/decorators.js'; import { RemoteUser, User } from '@/models/entities/User.js'; import { getApId } from './type.js'; @@ -41,6 +42,9 @@ export class ApDbResolverService { @Inject(DI.usersRepository) private usersRepository: UsersRepository, + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + @Inject(DI.notesRepository) private notesRepository: NotesRepository, @@ -97,6 +101,23 @@ export class ApDbResolverService { } } + @bindThis + public async getMessageFromApId(value: string | IObject): Promise { + const parsed = this.parseUri(value); + + if (parsed.local) { + if (parsed.type !== 'notes') return null; + + return await this.messagingMessagesRepository.findOneBy({ + id: parsed.id, + }); + } else { + return await this.messagingMessagesRepository.findOneBy({ + uri: parsed.uri, + }); + } + } + /** * AP Person => Misskey User in DB */ diff --git a/packages/backend/src/core/activitypub/ApInboxService.ts b/packages/backend/src/core/activitypub/ApInboxService.ts index 15d7d097c9..62f3827343 100644 --- a/packages/backend/src/core/activitypub/ApInboxService.ts +++ b/packages/backend/src/core/activitypub/ApInboxService.ts @@ -19,7 +19,8 @@ import { UtilityService } from '@/core/UtilityService.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { QueueService } from '@/core/QueueService.js'; -import type { UsersRepository, NotesRepository, FollowingsRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import type { UsersRepository, NotesRepository, FollowingsRepository, MessagingMessagesRepository, AbuseUserReportsRepository, FollowRequestsRepository } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import type { RemoteUser } from '@/models/entities/User.js'; import { getApId, getApIds, getApType, isAccept, isActor, isAdd, isAnnounce, isBlock, isCollection, isCollectionOrOrderedCollection, isCreate, isDelete, isFlag, isFollow, isLike, isPost, isRead, isReject, isRemove, isTombstone, isUndo, isUpdate, validActor, validPost } from './type.js'; @@ -50,6 +51,9 @@ export class ApInboxService { @Inject(DI.followingsRepository) private followingsRepository: FollowingsRepository, + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + @Inject(DI.abuseUserReportsRepository) private abuseUserReportsRepository: AbuseUserReportsRepository, @@ -77,6 +81,7 @@ export class ApInboxService { private apPersonService: ApPersonService, private apQuestionService: ApQuestionService, private queueService: QueueService, + private messagingService: MessagingService, ) { this.logger = this.apLoggerService.logger; } @@ -119,6 +124,8 @@ export class ApInboxService { await this.delete(actor, activity); } else if (isUpdate(activity)) { await this.update(actor, activity); + } else if (isRead(activity)) { + await this.read(actor, activity); } else if (isFollow(activity)) { await this.follow(actor, activity); } else if (isAccept(activity)) { @@ -178,6 +185,29 @@ export class ApInboxService { }).then(() => 'ok'); } + @bindThis + private async read(actor: RemoteUser, activity: IRead): Promise { + const id = await getApId(activity.object); + + if (!this.utilityService.isSelfHost(this.utilityService.extractDbHost(id))) { + return `skip: Read to foreign host (${id})`; + } + + const messageId = id.split('/').pop(); + + const message = await this.messagingMessagesRepository.findOneBy({ id: messageId }); + if (message == null) { + return 'skip: message not found'; + } + + if (actor.id !== message.recipientId) { + return 'skip: actor is not a message recipient'; + } + + await this.messagingService.readUserMessagingMessage(message.recipientId!, message.userId, [message.id]); + return `ok: mark as read (${message.userId} => ${message.recipientId} ${message.id})`; + } + @bindThis private async accept(actor: RemoteUser, activity: IAccept): Promise { const uri = activity.id ?? activity; @@ -474,7 +504,16 @@ export class ApInboxService { const note = await this.apDbResolverService.getNoteFromApId(uri); if (note == null) { - return 'message not found'; + const message = await this.apDbResolverService.getMessageFromApId(uri); + if (message == null) return 'message not found'; + + if (message.userId !== actor.id) { + return '投稿を削除しようとしているユーザーは投稿の作成者ではありません'; + } + + await this.messagingService.deleteMessage(message); + + return 'ok: message deleted'; } if (note.userId !== actor.id) { diff --git a/packages/backend/src/core/activitypub/ApRendererService.ts b/packages/backend/src/core/activitypub/ApRendererService.ts index c4f9cc0556..92bc1869e5 100644 --- a/packages/backend/src/core/activitypub/ApRendererService.ts +++ b/packages/backend/src/core/activitypub/ApRendererService.ts @@ -13,6 +13,7 @@ import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { NoteReaction } from '@/models/entities/NoteReaction.js'; import type { Emoji } from '@/models/entities/Emoji.js'; import type { Poll } from '@/models/entities/Poll.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; import type { PollVote } from '@/models/entities/PollVote.js'; import { UserKeypairStoreService } from '@/core/UserKeypairStoreService.js'; import { MfmService } from '@/core/MfmService.js'; @@ -292,7 +293,7 @@ export class ApRendererService { } @bindThis - public async renderNote(note: Note, dive = true): Promise { + public async renderNote(note: Note, dive = true, isTalk = false): Promise { const getPromisedFiles = async (ids: string[]) => { if (!ids || ids.length === 0) return []; const items = await this.driveFilesRepository.findBy({ id: In(ids) }); @@ -406,7 +407,11 @@ export class ApRendererService { }, })), } as const : {}; - + + const asTalk = isTalk ? { + _misskey_talk: true, + } as const : {}; + return { id: `${this.config.url}/notes/${note.id}`, type: 'Note', @@ -428,6 +433,7 @@ export class ApRendererService { sensitive: note.cw != null || files.some(file => file.isSensitive), tag, ...asPoll, + ...asTalk, }; } @@ -526,6 +532,15 @@ export class ApRendererService { }; } + @bindThis + public renderRead(user: { id: User['id'] }, message: MessagingMessage): IRead { + return { + type: 'Read', + actor: `${this.config.url}/users/${user.id}`, + object: message.uri!, + }; + } + @bindThis public renderReject(object: any, user: { id: User['id'] }): IReject { return { @@ -628,6 +643,7 @@ export class ApRendererService { '_misskey_quote': 'misskey:_misskey_quote', '_misskey_reaction': 'misskey:_misskey_reaction', '_misskey_votes': 'misskey:_misskey_votes', + '_misskey_talk': 'misskey:_misskey_talk', 'isCat': 'misskey:isCat', // vcard vcard: 'http://www.w3.org/2006/vcard/ns#', diff --git a/packages/backend/src/core/activitypub/models/ApNoteService.ts b/packages/backend/src/core/activitypub/models/ApNoteService.ts index 21243ec7ae..560b19e57d 100644 --- a/packages/backend/src/core/activitypub/models/ApNoteService.ts +++ b/packages/backend/src/core/activitypub/models/ApNoteService.ts @@ -1,7 +1,7 @@ import { forwardRef, Inject, Injectable } from '@nestjs/common'; import promiseLimit from 'promise-limit'; import { DI } from '@/di-symbols.js'; -import type { PollsRepository, EmojisRepository, UsersRepository } from '@/models/index.js'; +import type { MessagingMessagesRepository, PollsRepository, EmojisRepository, UsersRepository } from '@/models/index.js'; import type { Config } from '@/config.js'; import type { RemoteUser } from '@/models/entities/User.js'; import type { Note } from '@/models/entities/Note.js'; @@ -16,6 +16,7 @@ import { IdService } from '@/core/IdService.js'; import { PollService } from '@/core/PollService.js'; import { StatusError } from '@/misc/status-error.js'; import { UtilityService } from '@/core/UtilityService.js'; +import { MessagingService } from '@/core/MessagingService.js'; import { bindThis } from '@/decorators.js'; import { getOneApId, getApId, getOneApHrefNullable, validPost, isEmoji, getApType } from '../type.js'; // eslint-disable-next-line @typescript-eslint/consistent-type-imports @@ -46,6 +47,9 @@ export class ApNoteService { @Inject(DI.emojisRepository) private emojisRepository: EmojisRepository, + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + private idService: IdService, private apMfmService: ApMfmService, private apResolverService: ApResolverService, @@ -60,6 +64,7 @@ export class ApNoteService { private apImageService: ApImageService, private apQuestionService: ApQuestionService, private metaService: MetaService, + private messagingService: MessagingService, private appLockService: AppLockService, private pollService: PollService, private noteCreateService: NoteCreateService, @@ -160,6 +165,8 @@ export class ApNoteService { } } + let isMessaging = note._misskey_talk && visibility === 'specified'; + const apMentions = await this.apMentionService.extractApMentions(note.tag, resolver); const apHashtags = await extractApHashtags(note.tag); @@ -186,6 +193,17 @@ export class ApNoteService { return x; } }).catch(async err => { + // トークだったらinReplyToのエラーは無視 + const uri = getApId(note.inReplyTo); + if (uri.startsWith(this.config.url + '/')) { + const id = uri.split('/').pop(); + const talk = await this.messagingMessagesRepository.findOneBy({ id }); + if (talk) { + isMessaging = true; + return null; + } + } + this.logger.warn(`Error in inReplyTo ${note.inReplyTo} - ${err.statusCode ?? err}`); throw err; }) @@ -274,7 +292,14 @@ export class ApNoteService { const apEmojis = emojis.map(emoji => emoji.name); const poll = await this.apQuestionService.extractPollFromQuestion(note, resolver).catch(() => undefined); - + + if (isMessaging) { + for (const recipient of visibleUsers) { + await this.messagingService.createMessage(actor, recipient, undefined, text ?? undefined, (files && files.length > 0) ? files[0] : null, object.id); + return null; + } + } + return await this.noteCreateService.create(actor, { createdAt: note.published ? new Date(note.published) : null, files, diff --git a/packages/backend/src/core/activitypub/type.ts b/packages/backend/src/core/activitypub/type.ts index 268bf99119..9dc7ed4e31 100644 --- a/packages/backend/src/core/activitypub/type.ts +++ b/packages/backend/src/core/activitypub/type.ts @@ -113,6 +113,7 @@ export interface IPost extends IObject { _misskey_quote?: string; _misskey_content?: string; quoteUrl?: string; + _misskey_talk?: boolean; } export interface IQuestion extends IObject { diff --git a/packages/backend/src/core/entities/MessagingMessageEntityService.ts b/packages/backend/src/core/entities/MessagingMessageEntityService.ts new file mode 100644 index 0000000000..cdb752dd81 --- /dev/null +++ b/packages/backend/src/core/entities/MessagingMessageEntityService.ts @@ -0,0 +1,59 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { DI } from '@/di-symbols.js'; +import type { MessagingMessagesRepository } from '@/models/index.js'; +import { awaitAll } from '@/misc/prelude/await-all.js'; +import type { Packed } from '@/misc/schema.js'; +import type { } from '@/models/entities/Blocking.js'; +import type { User } from '@/models/entities/User.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import { UserEntityService } from './UserEntityService.js'; +import { DriveFileEntityService } from './DriveFileEntityService.js'; +import { UserGroupEntityService } from './UserGroupEntityService.js'; +import { bindThis } from '@/decorators.js'; + +@Injectable() +export class MessagingMessageEntityService { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + private userEntityService: UserEntityService, + private userGroupEntityService: UserGroupEntityService, + private driveFileEntityService: DriveFileEntityService, + ) { + } + + @bindThis + public async pack( + src: MessagingMessage['id'] | MessagingMessage, + me?: { id: User['id'] } | null | undefined, + options?: { + populateRecipient?: boolean, + populateGroup?: boolean, + }, + ): Promise> { + const opts = options ?? { + populateRecipient: true, + populateGroup: true, + }; + + const message = typeof src === 'object' ? src : await this.messagingMessagesRepository.findOneByOrFail({ id: src }); + + return { + id: message.id, + createdAt: message.createdAt.toISOString(), + text: message.text, + userId: message.userId, + user: await this.userEntityService.pack(message.user ?? message.userId, me), + recipientId: message.recipientId, + recipient: message.recipientId && opts.populateRecipient ? await this.userEntityService.pack(message.recipient ?? message.recipientId, me) : undefined, + groupId: message.groupId, + group: message.groupId && opts.populateGroup ? await this.userGroupEntityService.pack(message.group ?? message.groupId) : undefined, + fileId: message.fileId, + file: message.fileId ? await this.driveFileEntityService.pack(message.fileId) : null, + isRead: message.isRead, + reads: message.reads, + }; + } +} + diff --git a/packages/backend/src/core/entities/UserEntityService.ts b/packages/backend/src/core/entities/UserEntityService.ts index 19bae443c8..fa3337c019 100644 --- a/packages/backend/src/core/entities/UserEntityService.ts +++ b/packages/backend/src/core/entities/UserEntityService.ts @@ -12,7 +12,7 @@ import { Cache } from '@/misc/cache.js'; import type { Instance } from '@/models/entities/Instance.js'; import type { LocalUser, RemoteUser, User } from '@/models/entities/User.js'; import { birthdaySchema, descriptionSchema, localUsernameSchema, locationSchema, nameSchema, passwordSchema } from '@/models/entities/User.js'; -import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js'; +import type { UsersRepository, UserSecurityKeysRepository, FollowingsRepository, FollowRequestsRepository, BlockingsRepository, MutingsRepository, DriveFilesRepository, NoteUnreadsRepository, ChannelFollowingsRepository, NotificationsRepository, UserNotePiningsRepository, UserProfilesRepository, InstancesRepository, AnnouncementReadsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository, AnnouncementsRepository, AntennaNotesRepository, PagesRepository, UserProfile } from '@/models/index.js'; import { bindThis } from '@/decorators.js'; import { RoleService } from '@/core/RoleService.js'; import type { OnModuleInit } from '@nestjs/common'; @@ -102,6 +102,9 @@ export class UserEntityService implements OnModuleInit { @Inject(DI.announcementReadsRepository) private announcementReadsRepository: AnnouncementReadsRepository, + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + @Inject(DI.userGroupJoiningsRepository) private userGroupJoiningsRepository: UserGroupJoiningsRepository, @@ -201,6 +204,36 @@ export class UserEntityService implements OnModuleInit { }); } + @bindThis + public async getHasUnreadMessagingMessage(userId: User['id']): Promise { + const mute = await this.mutingsRepository.findBy({ + muterId: userId, + }); + + const joinings = await this.userGroupJoiningsRepository.findBy({ userId: userId }); + + const groupQs = Promise.all(joinings.map(j => this.messagingMessagesRepository.createQueryBuilder('message') + .where('message.groupId = :groupId', { groupId: j.userGroupId }) + .andWhere('message.userId != :userId', { userId: userId }) + .andWhere('NOT (:userId = ANY(message.reads))', { userId: userId }) + .andWhere('message.createdAt > :joinedAt', { joinedAt: j.createdAt }) // 自分が加入する前の会話については、未読扱いしない + .getOne().then(x => x != null))); + + const [withUser, withGroups] = await Promise.all([ + this.messagingMessagesRepository.count({ + where: { + recipientId: userId, + isRead: false, + ...(mute.length > 0 ? { userId: Not(In(mute.map(x => x.muteeId))) } : {}), + }, + take: 1, + }).then(count => count > 0), + groupQs, + ]); + + return withUser || withGroups.some(x => x); + } + @bindThis public async getHasUnreadAnnouncement(userId: User['id']): Promise { const reads = await this.announcementReadsRepository.findBy({ @@ -459,6 +492,7 @@ export class UserEntityService implements OnModuleInit { hasUnreadAnnouncement: this.getHasUnreadAnnouncement(user.id), hasUnreadAntenna: this.getHasUnreadAntenna(user.id), hasUnreadChannel: this.getHasUnreadChannel(user.id), + hasUnreadMessagingMessage: this.getHasUnreadMessagingMessage(user.id), hasUnreadNotification: this.getHasUnreadNotification(user.id), hasPendingReceivedFollowRequest: this.getHasPendingReceivedFollowRequest(user.id), mutedWords: profile!.mutedWords, diff --git a/packages/backend/src/di-symbols.ts b/packages/backend/src/di-symbols.ts index da21409541..3fb0cd4dae 100644 --- a/packages/backend/src/di-symbols.ts +++ b/packages/backend/src/di-symbols.ts @@ -47,6 +47,7 @@ export const DI = { authSessionsRepository: Symbol('authSessionsRepository'), accessTokensRepository: Symbol('accessTokensRepository'), signinsRepository: Symbol('signinsRepository'), + messagingMessagesRepository: Symbol('messagingMessagesRepository'), pagesRepository: Symbol('pagesRepository'), pageLikesRepository: Symbol('pageLikesRepository'), galleryPostsRepository: Symbol('galleryPostsRepository'), diff --git a/packages/backend/src/misc/schema.ts b/packages/backend/src/misc/schema.ts index b3db19b338..7aeb65f296 100644 --- a/packages/backend/src/misc/schema.ts +++ b/packages/backend/src/misc/schema.ts @@ -10,6 +10,7 @@ import { import { packedNoteSchema } from '@/models/schema/note.js'; import { packedUserListSchema } from '@/models/schema/user-list.js'; import { packedAppSchema } from '@/models/schema/app.js'; +import { packedMessagingMessageSchema } from '@/models/schema/messaging-message.js'; import { packedNotificationSchema } from '@/models/schema/notification.js'; import { packedDriveFileSchema } from '@/models/schema/drive-file.js'; import { packedDriveFolderSchema } from '@/models/schema/drive-folder.js'; @@ -41,6 +42,7 @@ export const refs = { UserList: packedUserListSchema, UserGroup: packedUserGroupSchema, App: packedAppSchema, + MessagingMessage: packedMessagingMessageSchema, Note: packedNoteSchema, NoteReaction: packedNoteReactionSchema, NoteFavorite: packedNoteFavoriteSchema, diff --git a/packages/backend/src/models/RepositoryModule.ts b/packages/backend/src/models/RepositoryModule.ts index 231dbb225b..2a235bc6fc 100644 --- a/packages/backend/src/models/RepositoryModule.ts +++ b/packages/backend/src/models/RepositoryModule.ts @@ -1,6 +1,6 @@ import { Module } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js'; +import { User, Note, Announcement, AnnouncementRead, App, NoteFavorite, NoteThreadMuting, NoteReaction, NoteUnread, Notification, Poll, PollVote, UserProfile, UserKeypair, UserPending, AttestationChallenge, UserSecurityKey, UserPublickey, UserList, UserListJoining, UserGroup, UserGroupJoining, UserGroupInvitation, UserNotePining, UserIp, UsedUsername, Following, FollowRequest, Instance, Emoji, DriveFile, DriveFolder, Meta, Muting, Blocking, SwSubscription, Hashtag, AbuseUserReport, RegistrationTicket, AuthSession, AccessToken, Signin, MessagingMessage, Page, PageLike, GalleryPost, GalleryLike, ModerationLog, Clip, ClipNote, Antenna, AntennaNote, PromoNote, PromoRead, Relay, MutedNote, Channel, ChannelFollowing, ChannelNotePining, RegistryItem, Webhook, Ad, PasswordResetRequest, RetentionAggregation, FlashLike, Flash, Role, RoleAssignment } from './index.js'; import type { DataSource } from 'typeorm'; import type { Provider } from '@nestjs/common'; @@ -256,6 +256,12 @@ const $signinsRepository: Provider = { inject: [DI.db], }; +const $messagingMessagesRepository: Provider = { + provide: DI.messagingMessagesRepository, + useFactory: (db: DataSource) => db.getRepository(MessagingMessage), + inject: [DI.db], +}; + const $pagesRepository: Provider = { provide: DI.pagesRepository, useFactory: (db: DataSource) => db.getRepository(Page), @@ -452,6 +458,7 @@ const $roleAssignmentsRepository: Provider = { $authSessionsRepository, $accessTokensRepository, $signinsRepository, + $messagingMessagesRepository, $pagesRepository, $pageLikesRepository, $galleryPostsRepository, @@ -521,6 +528,7 @@ const $roleAssignmentsRepository: Provider = { $authSessionsRepository, $accessTokensRepository, $signinsRepository, + $messagingMessagesRepository, $pagesRepository, $pageLikesRepository, $galleryPostsRepository, diff --git a/packages/backend/src/models/entities/MessagingMessage.ts b/packages/backend/src/models/entities/MessagingMessage.ts new file mode 100644 index 0000000000..69fc9815d4 --- /dev/null +++ b/packages/backend/src/models/entities/MessagingMessage.ts @@ -0,0 +1,89 @@ +import { PrimaryColumn, Entity, Index, JoinColumn, Column, ManyToOne } from 'typeorm'; +import { id } from '../id.js'; +import { User } from './User.js'; +import { DriveFile } from './DriveFile.js'; +import { UserGroup } from './UserGroup.js'; + +@Entity() +export class MessagingMessage { + @PrimaryColumn(id()) + public id: string; + + @Index() + @Column('timestamp with time zone', { + comment: 'The created date of the MessagingMessage.', + }) + public createdAt: Date; + + @Index() + @Column({ + ...id(), + comment: 'The sender user ID.', + }) + public userId: User['id']; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public user: User | null; + + @Index() + @Column({ + ...id(), nullable: true, + comment: 'The recipient user ID.', + }) + public recipientId: User['id'] | null; + + @ManyToOne(type => User, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public recipient: User | null; + + @Index() + @Column({ + ...id(), nullable: true, + comment: 'The recipient group ID.', + }) + public groupId: UserGroup['id'] | null; + + @ManyToOne(type => UserGroup, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public group: UserGroup | null; + + @Column('varchar', { + length: 4096, nullable: true, + }) + public text: string | null; + + @Column('boolean', { + default: false, + }) + public isRead: boolean; + + @Column('varchar', { + length: 512, nullable: true, + }) + public uri: string | null; + + @Column({ + ...id(), + array: true, default: '{}', + }) + public reads: User['id'][]; + + @Column({ + ...id(), + nullable: true, + }) + public fileId: DriveFile['id'] | null; + + @ManyToOne(type => DriveFile, { + onDelete: 'CASCADE', + }) + @JoinColumn() + public file: DriveFile | null; +} diff --git a/packages/backend/src/models/index.ts b/packages/backend/src/models/index.ts index 1494905277..50697597ad 100644 --- a/packages/backend/src/models/index.ts +++ b/packages/backend/src/models/index.ts @@ -22,6 +22,7 @@ import { GalleryLike } from '@/models/entities/GalleryLike.js'; import { GalleryPost } from '@/models/entities/GalleryPost.js'; import { Hashtag } from '@/models/entities/Hashtag.js'; import { Instance } from '@/models/entities/Instance.js'; +import { MessagingMessage } from '@/models/entities/MessagingMessage.js'; import { Meta } from '@/models/entities/Meta.js'; import { ModerationLog } from '@/models/entities/ModerationLog.js'; import { MutedNote } from '@/models/entities/MutedNote.js'; @@ -92,6 +93,7 @@ export { GalleryPost, Hashtag, Instance, + MessagingMessage, Meta, ModerationLog, MutedNote, @@ -161,6 +163,7 @@ export type GalleryLikesRepository = Repository; export type GalleryPostsRepository = Repository; export type HashtagsRepository = Repository; export type InstancesRepository = Repository; +export type MessagingMessagesRepository = Repository; export type MetasRepository = Repository; export type ModerationLogsRepository = Repository; export type MutedNotesRepository = Repository; diff --git a/packages/backend/src/models/schema/messaging-message.ts b/packages/backend/src/models/schema/messaging-message.ts new file mode 100644 index 0000000000..b1ffa45955 --- /dev/null +++ b/packages/backend/src/models/schema/messaging-message.ts @@ -0,0 +1,73 @@ +export const packedMessagingMessageSchema = { + type: 'object', + properties: { + id: { + type: 'string', + optional: false, nullable: false, + format: 'id', + example: 'xxxxxxxxxx', + }, + createdAt: { + type: 'string', + optional: false, nullable: false, + format: 'date-time', + }, + userId: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + user: { + type: 'object', + ref: 'UserLite', + optional: true, nullable: false, + }, + text: { + type: 'string', + optional: false, nullable: true, + }, + fileId: { + type: 'string', + optional: true, nullable: true, + format: 'id', + }, + file: { + type: 'object', + optional: true, nullable: true, + ref: 'DriveFile', + }, + recipientId: { + type: 'string', + optional: false, nullable: true, + format: 'id', + }, + recipient: { + type: 'object', + optional: true, nullable: true, + ref: 'UserLite', + }, + groupId: { + type: 'string', + optional: false, nullable: true, + format: 'id', + }, + group: { + type: 'object', + optional: true, nullable: true, + ref: 'UserGroup', + }, + isRead: { + type: 'boolean', + optional: true, nullable: false, + }, + reads: { + type: 'array', + optional: true, nullable: false, + items: { + type: 'string', + optional: false, nullable: false, + format: 'id', + }, + }, + }, +} as const; diff --git a/packages/backend/src/models/schema/user.ts b/packages/backend/src/models/schema/user.ts index a7ec01e023..0cd1bd41ba 100644 --- a/packages/backend/src/models/schema/user.ts +++ b/packages/backend/src/models/schema/user.ts @@ -311,6 +311,10 @@ export const packedMeDetailedOnlySchema = { type: 'boolean', nullable: false, optional: false, }, + hasUnreadMessagingMessage: { + type: 'boolean', + nullable: false, optional: false, + }, hasUnreadNotification: { type: 'boolean', nullable: false, optional: false, diff --git a/packages/backend/src/postgres.ts b/packages/backend/src/postgres.ts index 8cf259eb16..33b924e776 100644 --- a/packages/backend/src/postgres.ts +++ b/packages/backend/src/postgres.ts @@ -30,6 +30,7 @@ import { GalleryLike } from '@/models/entities/GalleryLike.js'; import { GalleryPost } from '@/models/entities/GalleryPost.js'; import { Hashtag } from '@/models/entities/Hashtag.js'; import { Instance } from '@/models/entities/Instance.js'; +import { MessagingMessage } from '@/models/entities/MessagingMessage.js'; import { Meta } from '@/models/entities/Meta.js'; import { ModerationLog } from '@/models/entities/ModerationLog.js'; import { MutedNote } from '@/models/entities/MutedNote.js'; @@ -166,6 +167,7 @@ export const entities = [ SwSubscription, AbuseUserReport, RegistrationTicket, + MessagingMessage, Signin, ModerationLog, Clip, diff --git a/packages/backend/src/server/ServerModule.ts b/packages/backend/src/server/ServerModule.ts index a5a5f9e7f9..b605f3c8ab 100644 --- a/packages/backend/src/server/ServerModule.ts +++ b/packages/backend/src/server/ServerModule.ts @@ -30,6 +30,8 @@ import { HashtagChannelService } from './api/stream/channels/hashtag.js'; import { HomeTimelineChannelService } from './api/stream/channels/home-timeline.js'; import { HybridTimelineChannelService } from './api/stream/channels/hybrid-timeline.js'; import { LocalTimelineChannelService } from './api/stream/channels/local-timeline.js'; +import { MessagingIndexChannelService } from './api/stream/channels/messaging-index.js'; +import { MessagingChannelService } from './api/stream/channels/messaging.js'; 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'; @@ -69,6 +71,8 @@ import { UserListChannelService } from './api/stream/channels/user-list.js'; HomeTimelineChannelService, HybridTimelineChannelService, LocalTimelineChannelService, + MessagingIndexChannelService, + MessagingChannelService, QueueStatsChannelService, ServerStatsChannelService, UserListChannelService, diff --git a/packages/backend/src/server/api/EndpointsModule.ts b/packages/backend/src/server/api/EndpointsModule.ts index 933dcbebe6..4a55c6cbe3 100644 --- a/packages/backend/src/server/api/EndpointsModule.ts +++ b/packages/backend/src/server/api/EndpointsModule.ts @@ -195,6 +195,7 @@ import * as ep___i_notifications from './endpoints/i/notifications.js'; import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; import * as ep___i_pin from './endpoints/i/pin.js'; +import * as ep___i_readAllMessagingMessages from './endpoints/i/read-all-messaging-messages.js'; import * as ep___i_readAllUnreadNotes from './endpoints/i/read-all-unread-notes.js'; import * as ep___i_readAnnouncement from './endpoints/i/read-announcement.js'; import * as ep___i_regenerateToken from './endpoints/i/regenerate-token.js'; @@ -217,6 +218,11 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; +import * as ep___messaging_history from './endpoints/messaging/history.js'; +import * as ep___messaging_messages from './endpoints/messaging/messages.js'; +import * as ep___messaging_messages_create from './endpoints/messaging/messages/create.js'; +import * as ep___messaging_messages_delete from './endpoints/messaging/messages/delete.js'; +import * as ep___messaging_messages_read from './endpoints/messaging/messages/read.js'; import * as ep___meta from './endpoints/meta.js'; import * as ep___emojis from './endpoints/emojis.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; @@ -524,6 +530,7 @@ const $i_notifications: Provider = { provide: 'ep:i/notifications', useClass: ep const $i_pageLikes: Provider = { provide: 'ep:i/page-likes', useClass: ep___i_pageLikes.default }; const $i_pages: Provider = { provide: 'ep:i/pages', useClass: ep___i_pages.default }; const $i_pin: Provider = { provide: 'ep:i/pin', useClass: ep___i_pin.default }; +const $i_readAllMessagingMessages: Provider = { provide: 'ep:i/read-all-messaging-messages', useClass: ep___i_readAllMessagingMessages.default }; const $i_readAllUnreadNotes: Provider = { provide: 'ep:i/read-all-unread-notes', useClass: ep___i_readAllUnreadNotes.default }; const $i_readAnnouncement: Provider = { provide: 'ep:i/read-announcement', useClass: ep___i_readAnnouncement.default }; const $i_regenerateToken: Provider = { provide: 'ep:i/regenerate-token', useClass: ep___i_regenerateToken.default }; @@ -546,6 +553,11 @@ const $i_webhooks_list: Provider = { provide: 'ep:i/webhooks/list', useClass: ep const $i_webhooks_show: Provider = { provide: 'ep:i/webhooks/show', useClass: ep___i_webhooks_show.default }; const $i_webhooks_update: Provider = { provide: 'ep:i/webhooks/update', useClass: ep___i_webhooks_update.default }; const $i_webhooks_delete: Provider = { provide: 'ep:i/webhooks/delete', useClass: ep___i_webhooks_delete.default }; +const $messaging_history: Provider = { provide: 'ep:messaging/history', useClass: ep___messaging_history.default }; +const $messaging_messages: Provider = { provide: 'ep:messaging/messages', useClass: ep___messaging_messages.default }; +const $messaging_messages_create: Provider = { provide: 'ep:messaging/messages/create', useClass: ep___messaging_messages_create.default }; +const $messaging_messages_delete: Provider = { provide: 'ep:messaging/messages/delete', useClass: ep___messaging_messages_delete.default }; +const $messaging_messages_read: Provider = { provide: 'ep:messaging/messages/read', useClass: ep___messaging_messages_read.default }; const $meta: Provider = { provide: 'ep:meta', useClass: ep___meta.default }; const $emojis: Provider = { provide: 'ep:emojis', useClass: ep___emojis.default }; const $miauth_genToken: Provider = { provide: 'ep:miauth/gen-token', useClass: ep___miauth_genToken.default }; @@ -857,6 +869,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_pageLikes, $i_pages, $i_pin, + $i_readAllMessagingMessages, $i_readAllUnreadNotes, $i_readAnnouncement, $i_regenerateToken, @@ -879,6 +892,11 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_webhooks_show, $i_webhooks_update, $i_webhooks_delete, + $messaging_history, + $messaging_messages, + $messaging_messages_create, + $messaging_messages_delete, + $messaging_messages_read, $meta, $emojis, $miauth_genToken, @@ -1184,6 +1202,7 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_pageLikes, $i_pages, $i_pin, + $i_readAllMessagingMessages, $i_readAllUnreadNotes, $i_readAnnouncement, $i_regenerateToken, @@ -1206,6 +1225,11 @@ const $retention: Provider = { provide: 'ep:retention', useClass: ep___retention $i_webhooks_show, $i_webhooks_update, $i_webhooks_delete, + $messaging_history, + $messaging_messages, + $messaging_messages_create, + $messaging_messages_delete, + $messaging_messages_read, $meta, $emojis, $miauth_genToken, diff --git a/packages/backend/src/server/api/endpoints.ts b/packages/backend/src/server/api/endpoints.ts index 639cf30195..55e1900d51 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.ts @@ -194,6 +194,7 @@ import * as ep___i_notifications from './endpoints/i/notifications.js'; import * as ep___i_pageLikes from './endpoints/i/page-likes.js'; import * as ep___i_pages from './endpoints/i/pages.js'; import * as ep___i_pin from './endpoints/i/pin.js'; +import * as ep___i_readAllMessagingMessages from './endpoints/i/read-all-messaging-messages.js'; import * as ep___i_readAllUnreadNotes from './endpoints/i/read-all-unread-notes.js'; import * as ep___i_readAnnouncement from './endpoints/i/read-announcement.js'; import * as ep___i_regenerateToken from './endpoints/i/regenerate-token.js'; @@ -216,6 +217,11 @@ import * as ep___i_webhooks_show from './endpoints/i/webhooks/show.js'; import * as ep___i_webhooks_list from './endpoints/i/webhooks/list.js'; import * as ep___i_webhooks_update from './endpoints/i/webhooks/update.js'; import * as ep___i_webhooks_delete from './endpoints/i/webhooks/delete.js'; +import * as ep___messaging_history from './endpoints/messaging/history.js'; +import * as ep___messaging_messages from './endpoints/messaging/messages.js'; +import * as ep___messaging_messages_create from './endpoints/messaging/messages/create.js'; +import * as ep___messaging_messages_delete from './endpoints/messaging/messages/delete.js'; +import * as ep___messaging_messages_read from './endpoints/messaging/messages/read.js'; import * as ep___meta from './endpoints/meta.js'; import * as ep___emojis from './endpoints/emojis.js'; import * as ep___miauth_genToken from './endpoints/miauth/gen-token.js'; @@ -521,6 +527,7 @@ const eps = [ ['i/page-likes', ep___i_pageLikes], ['i/pages', ep___i_pages], ['i/pin', ep___i_pin], + ['i/read-all-messaging-messages', ep___i_readAllMessagingMessages], ['i/read-all-unread-notes', ep___i_readAllUnreadNotes], ['i/read-announcement', ep___i_readAnnouncement], ['i/regenerate-token', ep___i_regenerateToken], @@ -543,6 +550,11 @@ const eps = [ ['i/webhooks/show', ep___i_webhooks_show], ['i/webhooks/update', ep___i_webhooks_update], ['i/webhooks/delete', ep___i_webhooks_delete], + ['messaging/history', ep___messaging_history], + ['messaging/messages', ep___messaging_messages], + ['messaging/messages/create', ep___messaging_messages_create], + ['messaging/messages/delete', ep___messaging_messages_delete], + ['messaging/messages/read', ep___messaging_messages_read], ['meta', ep___meta], ['emojis', ep___emojis], ['miauth/gen-token', ep___miauth_genToken], diff --git a/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts new file mode 100644 index 0000000000..109d6d1068 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/i/read-all-messaging-messages.ts @@ -0,0 +1,56 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { MessagingMessagesRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { GlobalEventService } from '@/core/GlobalEventService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['account', 'messaging'], + + requireCredential: true, + + kind: 'write:account', +} as const; + +export const paramDef = { + type: 'object', + properties: {}, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private globalEventService: GlobalEventService, + ) { + super(meta, paramDef, async (ps, me) => { + // Update documents + await this.messagingMessagesRepository.update({ + recipientId: me.id, + isRead: false, + }, { + isRead: true, + }); + + const joinings = await this.userGroupJoiningsRepository.findBy({ userId: me.id }); + + await Promise.all(joinings.map(j => this.messagingMessagesRepository.createQueryBuilder().update() + .set({ + reads: (() => `array_append("reads", '${me.id}')`) as any, + }) + .where('groupId = :groupId', { groupId: j.userGroupId }) + .andWhere('userId != :userId', { userId: me.id }) + .andWhere('NOT (:userId = ANY(reads))', { userId: me.id }) + .execute())); + + this.globalEventService.publishMainStream(me.id, 'readAllMessagingMessages'); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/messaging/history.ts b/packages/backend/src/server/api/endpoints/messaging/history.ts new file mode 100644 index 0000000000..0b6099d4ac --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/history.ts @@ -0,0 +1,110 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; +import type { MutingsRepository, UserGroupJoiningsRepository, MessagingMessagesRepository } from '@/models/index.js'; +import { MessagingMessageEntityService } from '@/core/entities/MessagingMessageEntityService.js'; +import { DI } from '@/di-symbols.js'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true, + + kind: 'read:messaging', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'MessagingMessage', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + group: { type: 'boolean', default: false }, + }, + required: [], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + @Inject(DI.mutingsRepository) + private mutingsRepository: MutingsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private messagingMessageEntityService: MessagingMessageEntityService, + ) { + super(meta, paramDef, async (ps, me) => { + const mute = await this.mutingsRepository.findBy({ + muterId: me.id, + }); + + const groups = ps.group ? await this.userGroupJoiningsRepository.findBy({ + userId: me.id, + }).then(xs => xs.map(x => x.userGroupId)) : []; + + if (ps.group && groups.length === 0) { + return []; + } + + const history: MessagingMessage[] = []; + + for (let i = 0; i < ps.limit; i++) { + const found = ps.group + ? history.map(m => m.groupId!) + : history.map(m => (m.userId === me.id) ? m.recipientId! : m.userId!); + + const query = this.messagingMessagesRepository.createQueryBuilder('message') + .orderBy('message.createdAt', 'DESC'); + + if (ps.group) { + query.where('message.groupId IN (:...groups)', { groups: groups }); + + if (found.length > 0) { + query.andWhere('message.groupId NOT IN (:...found)', { found: found }); + } + } else { + query.where(new Brackets(qb => { qb + .where('message.userId = :userId', { userId: me.id }) + .orWhere('message.recipientId = :userId', { userId: me.id }); + })); + query.andWhere('message.groupId IS NULL'); + + if (found.length > 0) { + query.andWhere('message.userId NOT IN (:...found)', { found: found }); + query.andWhere('message.recipientId NOT IN (:...found)', { found: found }); + } + + if (mute.length > 0) { + query.andWhere('message.userId NOT IN (:...mute)', { mute: mute.map(m => m.muteeId) }); + query.andWhere('message.recipientId NOT IN (:...mute)', { mute: mute.map(m => m.muteeId) }); + } + } + + const message = await query.getOne(); + + if (message) { + history.push(message); + } else { + break; + } + } + + return await Promise.all(history.map(h => this.messagingMessageEntityService.pack(h.id, me))); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/messaging/messages.ts b/packages/backend/src/server/api/endpoints/messaging/messages.ts new file mode 100644 index 0000000000..3673e252ae --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages.ts @@ -0,0 +1,165 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Brackets } from 'typeorm'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { UsersRepository, UserGroupsRepository, MessagingMessagesRepository, UserGroupJoiningsRepository } from '@/models/index.js'; +import { QueryService } from '@/core/QueryService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { MessagingMessageEntityService } from '@/core/entities/MessagingMessageEntityService.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../error.js'; +import { GetterService } from '@/server/api/GetterService.js'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true, + + kind: 'read:messaging', + + res: { + type: 'array', + optional: false, nullable: false, + items: { + type: 'object', + optional: false, nullable: false, + ref: 'MessagingMessage', + }, + }, + + errors: { + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d', + }, + + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'c4d9f88c-9270-4632-b032-6ed8cee36f7f', + }, + + groupAccessDenied: { + message: 'You can not read messages of groups that you have not joined.', + code: 'GROUP_ACCESS_DENIED', + id: 'a053a8dd-a491-4718-8f87-50775aad9284', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + limit: { type: 'integer', minimum: 1, maximum: 100, default: 10 }, + sinceId: { type: 'string', format: 'misskey:id' }, + untilId: { type: 'string', format: 'misskey:id' }, + markAsRead: { type: 'boolean', default: true }, + }, + anyOf: [ + { + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], + }, + { + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId'], + }, + ], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + @Inject(DI.userGroupsRepository) + private userGroupRepository: UserGroupsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + private messagingMessageEntityService: MessagingMessageEntityService, + private messagingService: MessagingService, + private userEntityService: UserEntityService, + private queryService: QueryService, + private getterService: GetterService, + ) { + super(meta, paramDef, async (ps, me) => { + if (ps.userId != null) { + // Fetch recipient (user) + const recipient = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + const query = this.queryService.makePaginationQuery(this.messagingMessagesRepository.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere(new Brackets(qb => { qb + .where(new Brackets(qb => { qb + .where('message.userId = :meId') + .andWhere('message.recipientId = :recipientId'); + })) + .orWhere(new Brackets(qb => { qb + .where('message.userId = :recipientId') + .andWhere('message.recipientId = :meId'); + })); + })) + .setParameter('meId', me.id) + .setParameter('recipientId', recipient.id); + + const messages = await query.take(ps.limit).getMany(); + + // Mark all as read + if (ps.markAsRead) { + this.messagingService.readUserMessagingMessage(me.id, recipient.id, messages.filter(m => m.recipientId === me.id).map(x => x.id)); + + // リモートユーザーとのメッセージだったら既読配信 + if (this.userEntityService.isLocalUser(me) && this.userEntityService.isRemoteUser(recipient)) { + this.messagingService.deliverReadActivity(me, recipient, messages); + } + } + + return await Promise.all(messages.map(message => this.messagingMessageEntityService.pack(message, me, { + populateRecipient: false, + }))); + } else if (ps.groupId != null) { + // Fetch recipient (group) + const recipientGroup = await this.userGroupRepository.findOneBy({ id: ps.groupId }); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userId: me.id, + userGroupId: recipientGroup.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + + const query = this.queryService.makePaginationQuery(this.messagingMessagesRepository.createQueryBuilder('message'), ps.sinceId, ps.untilId) + .andWhere('message.groupId = :groupId', { groupId: recipientGroup.id }); + + const messages = await query.take(ps.limit).getMany(); + + // Mark all as read + if (ps.markAsRead) { + this.messagingService.readGroupMessagingMessage(me.id, recipientGroup.id, messages.map(x => x.id)); + } + + return await Promise.all(messages.map(message => this.messagingMessageEntityService.pack(message, me, { + populateGroup: false, + }))); + } + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/create.ts b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts new file mode 100644 index 0000000000..e9ffc7a9eb --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/create.ts @@ -0,0 +1,179 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { BlockingsRepository, UserGroupJoiningsRepository, DriveFilesRepository, UserGroupsRepository } from '@/models/index.js'; +import type { User } from '@/models/entities/User.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import { GetterService } from '@/server/api/GetterService.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true, + + kind: 'write:messaging', + + limit: { + duration: ms('1hour'), + max: 120, + }, + + res: { + type: 'object', + optional: false, nullable: false, + ref: 'MessagingMessage', + }, + + errors: { + recipientIsYourself: { + message: 'You can not send a message to yourself.', + code: 'RECIPIENT_IS_YOURSELF', + id: '17e2ba79-e22a-4cbc-bf91-d327643f4a7e', + }, + + noSuchUser: { + message: 'No such user.', + code: 'NO_SUCH_USER', + id: '11795c64-40ea-4198-b06e-3c873ed9039d', + }, + + noSuchGroup: { + message: 'No such group.', + code: 'NO_SUCH_GROUP', + id: 'c94e2a5d-06aa-4914-8fa6-6a42e73d6537', + }, + + groupAccessDenied: { + message: 'You can not send messages to groups that you have not joined.', + code: 'GROUP_ACCESS_DENIED', + id: 'd96b3cca-5ad1-438b-ad8b-02f931308fbd', + }, + + noSuchFile: { + message: 'No such file.', + code: 'NO_SUCH_FILE', + id: '4372b8e2-185d-4146-8749-2f68864a3e5f', + }, + + contentRequired: { + message: 'Content required. You need to set text or fileId.', + code: 'CONTENT_REQUIRED', + id: '25587321-b0e6-449c-9239-f8925092942c', + }, + + youHaveBeenBlocked: { + message: 'You cannot send a message because you have been blocked by this user.', + code: 'YOU_HAVE_BEEN_BLOCKED', + id: 'c15a5199-7422-4968-941a-2a462c478f7d', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + text: { type: 'string', nullable: true, maxLength: 3000 }, + fileId: { type: 'string', format: 'misskey:id' }, + }, + anyOf: [ + { + properties: { + userId: { type: 'string', format: 'misskey:id' }, + }, + required: ['userId'], + }, + { + properties: { + groupId: { type: 'string', format: 'misskey:id' }, + }, + required: ['groupId'], + }, + ], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.userGroupsRepository) + private userGroupsRepository: UserGroupsRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + @Inject(DI.blockingsRepository) + private blockingsRepository: BlockingsRepository, + + @Inject(DI.driveFilesRepository) + private driveFilesRepository: DriveFilesRepository, + + private getterService: GetterService, + private messagingService: MessagingService, + ) { + super(meta, paramDef, async (ps, me) => { + let recipientUser: User | null; + let recipientGroup: UserGroup | null; + + if (ps.userId != null) { + // Myself + if (ps.userId === me.id) { + throw new ApiError(meta.errors.recipientIsYourself); + } + + // Fetch recipient (user) + recipientUser = await this.getterService.getUser(ps.userId).catch(err => { + if (err.id === '15348ddd-432d-49c2-8a5a-8069753becff') throw new ApiError(meta.errors.noSuchUser); + throw err; + }); + + // Check blocking + const block = await this.blockingsRepository.findOneBy({ + blockerId: recipientUser.id, + blockeeId: me.id, + }); + if (block) { + throw new ApiError(meta.errors.youHaveBeenBlocked); + } + } else if (ps.groupId != null) { + // Fetch recipient (group) + recipientGroup = await this.userGroupsRepository.findOneBy({ id: ps.groupId! }); + + if (recipientGroup == null) { + throw new ApiError(meta.errors.noSuchGroup); + } + + // check joined + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userId: me.id, + userGroupId: recipientGroup.id, + }); + + if (joining == null) { + throw new ApiError(meta.errors.groupAccessDenied); + } + } + + let file = null; + if (ps.fileId != null) { + file = await this.driveFilesRepository.findOneBy({ + id: ps.fileId, + userId: me.id, + }); + + if (file == null) { + throw new ApiError(meta.errors.noSuchFile); + } + } + + // テキストが無いかつ添付ファイルも無かったらエラー + if (ps.text == null && file == null) { + throw new ApiError(meta.errors.contentRequired); + } + + return await this.messagingService.createMessage(me, recipientUser, recipientGroup, ps.text, file); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts b/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts new file mode 100644 index 0000000000..cd74f5f197 --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/delete.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common'; +import ms from 'ms'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { MessagingMessagesRepository } from '@/models/index.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true, + + kind: 'write:messaging', + + limit: { + duration: ms('1hour'), + max: 300, + minInterval: ms('1sec'), + }, + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '54b5b326-7925-42cf-8019-130fda8b56af', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + }, + required: ['messageId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + private messagingService: MessagingService, + ) { + super(meta, paramDef, async (ps, me) => { + const message = await this.messagingMessagesRepository.findOneBy({ + id: ps.messageId, + userId: me.id, + }); + + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + + await this.messagingService.deleteMessage(message); + }); + } +} diff --git a/packages/backend/src/server/api/endpoints/messaging/messages/read.ts b/packages/backend/src/server/api/endpoints/messaging/messages/read.ts new file mode 100644 index 0000000000..bddb6d932d --- /dev/null +++ b/packages/backend/src/server/api/endpoints/messaging/messages/read.ts @@ -0,0 +1,61 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Endpoint } from '@/server/api/endpoint-base.js'; +import type { MessagingMessagesRepository } from '@/models/index.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { DI } from '@/di-symbols.js'; +import { ApiError } from '../../../error.js'; + +export const meta = { + tags: ['messaging'], + + requireCredential: true, + + kind: 'write:messaging', + + errors: { + noSuchMessage: { + message: 'No such message.', + code: 'NO_SUCH_MESSAGE', + id: '86d56a2f-a9c3-4afb-b13c-3e9bfef9aa14', + }, + }, +} as const; + +export const paramDef = { + type: 'object', + properties: { + messageId: { type: 'string', format: 'misskey:id' }, + }, + required: ['messageId'], +} as const; + +// eslint-disable-next-line import/no-default-export +@Injectable() +export default class extends Endpoint { + constructor( + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + private messagingService: MessagingService, + ) { + super(meta, paramDef, async (ps, me) => { + const message = await this.messagingMessagesRepository.findOneBy({ id: ps.messageId }); + + if (message == null) { + throw new ApiError(meta.errors.noSuchMessage); + } + + if (message.recipientId) { + await this.messagingService.readUserMessagingMessage(me.id, message.userId, [message.id]).catch(err => { + if (err.id === 'e140a4bf-49ce-4fb6-b67c-b78dadf6b52f') throw new ApiError(meta.errors.noSuchMessage); + throw err; + }); + } else if (message.groupId) { + await this.messagingService.readGroupMessagingMessage(me.id, message.groupId, [message.id]).catch(err => { + if (err.id === '930a270c-714a-46b2-b776-ad27276dc569') throw new ApiError(meta.errors.noSuchMessage); + throw err; + }); + } + }); + } +} diff --git a/packages/backend/src/server/api/stream/ChannelsService.ts b/packages/backend/src/server/api/stream/ChannelsService.ts index 6ca7bdf623..198fc190d4 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -1,6 +1,5 @@ import { Inject, Injectable } from '@nestjs/common'; import { DI } from '@/di-symbols.js'; -import { bindThis } from '@/decorators.js'; import { HybridTimelineChannelService } from './channels/hybrid-timeline.js'; import { LocalTimelineChannelService } from './channels/local-timeline.js'; import { HomeTimelineChannelService } from './channels/home-timeline.js'; @@ -12,8 +11,11 @@ import { ServerStatsChannelService } from './channels/server-stats.js'; import { QueueStatsChannelService } from './channels/queue-stats.js'; import { UserListChannelService } from './channels/user-list.js'; import { AntennaChannelService } from './channels/antenna.js'; +import { MessagingChannelService } from './channels/messaging.js'; +import { MessagingIndexChannelService } from './channels/messaging-index.js'; import { DriveChannelService } from './channels/drive.js'; import { HashtagChannelService } from './channels/hashtag.js'; +import { bindThis } from '@/decorators.js'; @Injectable() export class ChannelsService { @@ -27,6 +29,8 @@ export class ChannelsService { private hashtagChannelService: HashtagChannelService, private antennaChannelService: AntennaChannelService, private channelChannelService: ChannelChannelService, + private messagingChannelService: MessagingChannelService, + private messagingIndexChannelService: MessagingIndexChannelService, private driveChannelService: DriveChannelService, private serverStatsChannelService: ServerStatsChannelService, private queueStatsChannelService: QueueStatsChannelService, @@ -46,6 +50,8 @@ export class ChannelsService { case 'hashtag': return this.hashtagChannelService; case 'antenna': return this.antennaChannelService; case 'channel': return this.channelChannelService; + case 'messaging': return this.messagingChannelService; + case 'messagingIndex': return this.messagingIndexChannelService; case 'drive': return this.driveChannelService; case 'serverStats': return this.serverStatsChannelService; case 'queueStats': return this.queueStatsChannelService; diff --git a/packages/backend/src/server/api/stream/channels/channel.ts b/packages/backend/src/server/api/stream/channels/channel.ts index 589c294372..5ba84e43c4 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -1,25 +1,32 @@ import { Inject, Injectable } from '@nestjs/common'; +import type { NotesRepository, UsersRepository } from '@/models/index.js'; import { isUserRelated } from '@/misc/is-user-related.js'; +import type { User } from '@/models/entities/User.js'; import type { Packed } from '@/misc/schema.js'; import { NoteEntityService } from '@/core/entities/NoteEntityService.js'; import { UserEntityService } from '@/core/entities/UserEntityService.js'; import { bindThis } from '@/decorators.js'; import Channel from '../channel.js'; +import type { StreamMessages } from '../types.js'; class ChannelChannel extends Channel { public readonly chName = 'channel'; public static shouldShare = false; public static requireCredential = false; private channelId: string; + private typers: Record = {}; + private emitTypersIntervalId: ReturnType; constructor( private noteEntityService: NoteEntityService, + private userEntityService: UserEntityService, id: string, connection: Channel['connection'], ) { super(id, connection); //this.onNote = this.onNote.bind(this); + //this.emitTypers = this.emitTypers.bind(this); } @bindThis @@ -28,6 +35,8 @@ class ChannelChannel extends Channel { // Subscribe stream this.subscriber.on('notesStream', this.onNote); + this.subscriber.on(`channelStream:${this.channelId}`, this.onEvent); + this.emitTypersIntervalId = setInterval(this.emitTypers, 5000); } @bindThis @@ -57,10 +66,42 @@ class ChannelChannel extends Channel { this.send('note', note); } + @bindThis + private onEvent(data: StreamMessages['channel']['payload']) { + if (data.type === 'typing') { + const id = data.body; + const begin = this.typers[id] == null; + this.typers[id] = new Date(); + if (begin) { + this.emitTypers(); + } + } + } + + @bindThis + private async emitTypers() { + const now = new Date(); + + // Remove not typing users + for (const [userId, date] of Object.entries(this.typers)) { + if (now.getTime() - date.getTime() > 5000) delete this.typers[userId]; + } + + const users = await this.userEntityService.packMany(Object.keys(this.typers), null, { detail: false }); + + this.send({ + type: 'typers', + body: users, + }); + } + @bindThis public dispose() { // Unsubscribe events this.subscriber.off('notesStream', this.onNote); + this.subscriber.off(`channelStream:${this.channelId}`, this.onEvent); + + clearInterval(this.emitTypersIntervalId); } } @@ -71,6 +112,7 @@ export class ChannelChannelService { constructor( private noteEntityService: NoteEntityService, + private userEntityService: UserEntityService, ) { } @@ -78,6 +120,7 @@ export class ChannelChannelService { public create(id: string, connection: Channel['connection']): ChannelChannel { return new ChannelChannel( this.noteEntityService, + this.userEntityService, id, connection, ); diff --git a/packages/backend/src/server/api/stream/channels/messaging-index.ts b/packages/backend/src/server/api/stream/channels/messaging-index.ts new file mode 100644 index 0000000000..66cb79f7a7 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/messaging-index.ts @@ -0,0 +1,35 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { bindThis } from '@/decorators.js'; +import Channel from '../channel.js'; + +class MessagingIndexChannel extends Channel { + public readonly chName = 'messagingIndex'; + public static shouldShare = true; + public static requireCredential = true; + + @bindThis + public async init(params: any) { + // Subscribe messaging index stream + this.subscriber.on(`messagingIndexStream:${this.user!.id}`, data => { + this.send(data); + }); + } +} + +@Injectable() +export class MessagingIndexChannelService { + public readonly shouldShare = MessagingIndexChannel.shouldShare; + public readonly requireCredential = MessagingIndexChannel.requireCredential; + + constructor( + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): MessagingIndexChannel { + return new MessagingIndexChannel( + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/channels/messaging.ts b/packages/backend/src/server/api/stream/channels/messaging.ts new file mode 100644 index 0000000000..b544e297c5 --- /dev/null +++ b/packages/backend/src/server/api/stream/channels/messaging.ts @@ -0,0 +1,159 @@ +import { Inject, Injectable } from '@nestjs/common'; +import type { UserGroupJoiningsRepository, UsersRepository, MessagingMessagesRepository } from '@/models/index.js'; +import type { User, LocalUser, RemoteUser } from '@/models/entities/User.js'; +import type { UserGroup } from '@/models/entities/UserGroup.js'; +import { MessagingService } from '@/core/MessagingService.js'; +import { UserEntityService } from '@/core/entities/UserEntityService.js'; +import { DI } from '@/di-symbols.js'; +import { bindThis } from '@/decorators.js'; +import Channel from '../channel.js'; +import type { StreamMessages } from '../types.js'; + +class MessagingChannel extends Channel { + public readonly chName = 'messaging'; + public static shouldShare = false; + public static requireCredential = true; + + private otherpartyId: string | null; + private otherparty: User | null; + private groupId: string | null; + private subCh: `messagingStream:${User['id']}-${User['id']}` | `messagingStream:${UserGroup['id']}`; + private typers: Record = {}; + private emitTypersIntervalId: ReturnType; + + constructor( + private usersRepository: UsersRepository, + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + private messagingMessagesRepository: MessagingMessagesRepository, + private userEntityService: UserEntityService, + private messagingService: MessagingService, + + id: string, + connection: Channel['connection'], + ) { + super(id, connection); + //this.onEvent = this.onEvent.bind(this); + //this.onMessage = this.onMessage.bind(this); + //this.emitTypers = this.emitTypers.bind(this); + } + + @bindThis + public async init(params: any) { + this.otherpartyId = params.otherparty; + this.otherparty = this.otherpartyId ? await this.usersRepository.findOneByOrFail({ id: this.otherpartyId }) : null; + this.groupId = params.group; + + // Check joining + if (this.groupId) { + const joining = await this.userGroupJoiningsRepository.findOneBy({ + userId: this.user!.id, + userGroupId: this.groupId, + }); + + if (joining == null) { + return; + } + } + + this.emitTypersIntervalId = setInterval(this.emitTypers, 5000); + + this.subCh = this.otherpartyId + ? `messagingStream:${this.user!.id}-${this.otherpartyId}` + : `messagingStream:${this.groupId}`; + + // Subscribe messaging stream + this.subscriber.on(this.subCh, this.onEvent); + } + + @bindThis + private onEvent(data: StreamMessages['messaging']['payload'] | StreamMessages['groupMessaging']['payload']) { + if (data.type === 'typing') { + const id = data.body; + const begin = this.typers[id] == null; + this.typers[id] = new Date(); + if (begin) { + this.emitTypers(); + } + } else { + this.send(data); + } + } + + @bindThis + public onMessage(type: string, body: any) { + switch (type) { + case 'read': + if (this.otherpartyId) { + this.messagingService.readUserMessagingMessage(this.user!.id, this.otherpartyId, [body.id]); + + // リモートユーザーからのメッセージだったら既読配信 + if (this.userEntityService.isLocalUser(this.user!) && this.userEntityService.isRemoteUser(this.otherparty!)) { + this.messagingMessagesRepository.findOneBy({ id: body.id }).then(message => { + if (message) this.messagingService.deliverReadActivity(this.user as LocalUser, this.otherparty as RemoteUser, message); + }); + } + } else if (this.groupId) { + this.messagingService.readGroupMessagingMessage(this.user!.id, this.groupId, [body.id]); + } + break; + } + } + + @bindThis + private async emitTypers() { + const now = new Date(); + + // Remove not typing users + for (const [userId, date] of Object.entries(this.typers)) { + if (now.getTime() - date.getTime() > 5000) delete this.typers[userId]; + } + + const users = await this.userEntityService.packMany(Object.keys(this.typers), null, { detail: false }); + + this.send({ + type: 'typers', + body: users, + }); + } + + @bindThis + public dispose() { + this.subscriber.off(this.subCh, this.onEvent); + + clearInterval(this.emitTypersIntervalId); + } +} + +@Injectable() +export class MessagingChannelService { + public readonly shouldShare = MessagingChannel.shouldShare; + public readonly requireCredential = MessagingChannel.requireCredential; + + constructor( + @Inject(DI.usersRepository) + private usersRepository: UsersRepository, + + @Inject(DI.userGroupJoiningsRepository) + private userGroupJoiningsRepository: UserGroupJoiningsRepository, + + @Inject(DI.messagingMessagesRepository) + private messagingMessagesRepository: MessagingMessagesRepository, + + private userEntityService: UserEntityService, + private messagingService: MessagingService, + ) { + } + + @bindThis + public create(id: string, connection: Channel['connection']): MessagingChannel { + return new MessagingChannel( + this.usersRepository, + this.userGroupJoiningsRepository, + this.messagingMessagesRepository, + this.userEntityService, + this.messagingService, + id, + connection, + ); + } +} diff --git a/packages/backend/src/server/api/stream/index.ts b/packages/backend/src/server/api/stream/index.ts index fea06c0315..6763953f9d 100644 --- a/packages/backend/src/server/api/stream/index.ts +++ b/packages/backend/src/server/api/stream/index.ts @@ -147,6 +147,12 @@ export default class Connection { case 'disconnect': this.onChannelDisconnectRequested(body); break; case 'channel': this.onChannelMessageRequested(body); break; case 'ch': this.onChannelMessageRequested(body); break; // alias + + // 個々のチャンネルではなくルートレベルでこれらのメッセージを受け取る理由は、 + // クライアントの事情を考慮したとき、入力フォームはノートチャンネルやメッセージのメインコンポーネントとは別 + // なこともあるため、それらのコンポーネントがそれぞれ各チャンネルに接続するようにするのは面倒なため。 + case 'typingOnChannel': this.typingOnChannel(body.channel); break; + case 'typingOnMessaging': this.typingOnMessaging(body); break; } } @@ -319,6 +325,24 @@ export default class Connection { } } + @bindThis + private typingOnChannel(channel: ChannelModel['id']) { + if (this.user) { + this.globalEventService.publishChannelStream(channel, 'typing', this.user.id); + } + } + + @bindThis + private typingOnMessaging(param: { partner?: User['id']; group?: UserGroup['id']; }) { + if (this.user) { + if (param.partner) { + this.globalEventService.publishMessagingStream(param.partner, this.user.id, 'typing', this.user.id); + } else if (param.group) { + this.globalEventService.publishGroupMessagingStream(param.group, 'typing', this.user.id); + } + } + } + @bindThis private async updateFollowing() { const followings = await this.followingsRepository.find({ diff --git a/packages/backend/src/server/api/stream/types.ts b/packages/backend/src/server/api/stream/types.ts index 3d8cfea9d3..8bb4147b43 100644 --- a/packages/backend/src/server/api/stream/types.ts +++ b/packages/backend/src/server/api/stream/types.ts @@ -6,6 +6,7 @@ import type { Antenna } from '@/models/entities/Antenna.js'; import type { DriveFile } from '@/models/entities/DriveFile.js'; import type { DriveFolder } from '@/models/entities/DriveFolder.js'; import type { UserList } from '@/models/entities/UserList.js'; +import type { MessagingMessage } from '@/models/entities/MessagingMessage.js'; import type { UserGroup } from '@/models/entities/UserGroup.js'; import type { AbuseUserReport } from '@/models/entities/AbuseUserReport.js'; import type { Signin } from '@/models/entities/Signin.js'; @@ -95,6 +96,9 @@ export interface MainStreamTypes { readAllUnreadMentions: undefined; unreadSpecifiedNote: Note['id']; readAllUnreadSpecifiedNotes: undefined; + readAllMessagingMessages: undefined; + messagingMessage: Packed<'MessagingMessage'>; + unreadMessagingMessage: Packed<'MessagingMessage'>; readAllAntennas: undefined; unreadAntenna: Antenna; readAllAnnouncements: undefined; @@ -149,6 +153,10 @@ type NoteStreamEventTypes = { }; }; +export interface ChannelStreamTypes { + typing: User['id']; +} + export interface UserListStreamTypes { userAdded: Packed<'User'>; userRemoved: Packed<'User'>; @@ -158,6 +166,28 @@ export interface AntennaStreamTypes { note: Note; } +export interface MessagingStreamTypes { + read: MessagingMessage['id'][]; + typing: User['id']; + message: Packed<'MessagingMessage'>; + deleted: MessagingMessage['id']; +} + +export interface GroupMessagingStreamTypes { + read: { + ids: MessagingMessage['id'][]; + userId: User['id']; + }; + typing: User['id']; + message: Packed<'MessagingMessage'>; + deleted: MessagingMessage['id']; +} + +export interface MessagingIndexStreamTypes { + read: MessagingMessage['id'][]; + message: Packed<'MessagingMessage'>; +} + export interface AdminStreamTypes { newAbuseUserReport: { id: AbuseUserReport['id']; @@ -212,6 +242,10 @@ export type StreamMessages = { name: `noteStream:${Note['id']}`; payload: EventUnionFromDictionary>; }; + channel: { + name: `channelStream:${Channel['id']}`; + payload: EventUnionFromDictionary>; + }; userList: { name: `userListStream:${UserList['id']}`; payload: EventUnionFromDictionary>; @@ -220,6 +254,18 @@ export type StreamMessages = { name: `antennaStream:${Antenna['id']}`; payload: EventUnionFromDictionary>; }; + messaging: { + name: `messagingStream:${User['id']}-${User['id']}`; + payload: EventUnionFromDictionary>; + }; + groupMessaging: { + name: `messagingStream:${UserGroup['id']}`; + payload: EventUnionFromDictionary>; + }; + messagingIndex: { + name: `messagingIndexStream:${User['id']}`; + payload: EventUnionFromDictionary>; + }; admin: { name: `adminStream:${User['id']}`; payload: EventUnionFromDictionary>; diff --git a/packages/backend/test/_e2e/endpoints.ts b/packages/backend/test/_e2e/endpoints.ts index aed980d6c8..ea8433dfa2 100644 --- a/packages/backend/test/_e2e/endpoints.ts +++ b/packages/backend/test/_e2e/endpoints.ts @@ -778,6 +778,63 @@ describe('API: Endpoints', () => { })); }); + describe('messaging/messages/create', () => { + test('メッセージを送信できる', async () => { + const res = await api('/messaging/messages/create', { + userId: bob.id, + text: 'test' + }, alice); + + assert.strictEqual(res.status, 200); + assert.strictEqual(typeof res.body === 'object' && !Array.isArray(res.body), true); + assert.strictEqual(res.body.text, 'test'); + })); + + test('自分自身にはメッセージを送信できない', async () => { + const res = await api('/messaging/messages/create', { + userId: alice.id, + text: 'Yo' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + test('存在しないユーザーにはメッセージを送信できない', async () => { + const res = await api('/messaging/messages/create', { + userId: '000000000000000000000000', + text: 'test' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + test('不正なユーザーIDで怒られる', async () => { + const res = await api('/messaging/messages/create', { + userId: 'foo', + text: 'test' + }, alice); + + assert.strictEqual(res.status, 400); + })); + + test('テキストが無くて怒られる', async () => { + const res = await api('/messaging/messages/create', { + userId: bob.id + }, alice); + + assert.strictEqual(res.status, 400); + })); + + test('文字数オーバーで怒られる', async () => { + const res = await api('/messaging/messages/create', { + userId: bob.id, + text: '!'.repeat(1001) + }, alice); + + assert.strictEqual(res.status, 400); + })); + }); + describe('notes/replies', () => { test('自分に閲覧権限のない投稿は含まれない', async () => { const alicePost = await post(alice, { diff --git a/packages/frontend/src/components/MkPostForm.vue b/packages/frontend/src/components/MkPostForm.vue index c96005d3ab..f84713b50f 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -162,6 +162,12 @@ let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') || '[]') let imeText = $ref(''); let disableRightClick = $ref(false); +const typing = throttle(3000, () => { + if (props.channel) { + stream.send('typingOnChannel', { channel: props.channel.id }); + } +}); + const draftKey = $computed((): string => { let key = props.channel ? `channel:${props.channel.id}` : ''; @@ -457,10 +463,12 @@ function onKeydown(ev: KeyboardEvent) { } if (ev.which === 27) emit('esc'); + typing(); } function onCompositionUpdate(ev: CompositionEvent) { imeText = ev.data; + typing(); } function onCompositionEnd(ev: CompositionEvent) { diff --git a/packages/frontend/src/init.ts b/packages/frontend/src/init.ts index b6cccf249f..5eab20d00b 100644 --- a/packages/frontend/src/init.ts +++ b/packages/frontend/src/init.ts @@ -508,6 +508,15 @@ if ($i) { updateAccount({ hasUnreadSpecifiedNotes: false }); }); + main.on('readAllMessagingMessages', () => { + updateAccount({ hasUnreadMessagingMessage: false }); + }); + + main.on('unreadMessagingMessage', () => { + updateAccount({ hasUnreadMessagingMessage: true }); + sound.play('chatBg'); + }); + main.on('readAllAntennas', () => { updateAccount({ hasUnreadAntenna: false }); }); diff --git a/packages/frontend/src/navbar.ts b/packages/frontend/src/navbar.ts index 687be345da..c1428a8c55 100644 --- a/packages/frontend/src/navbar.ts +++ b/packages/frontend/src/navbar.ts @@ -15,6 +15,13 @@ export const navbarItemDef = reactive({ indicated: computed(() => $i != null && $i.hasUnreadNotification), to: '/my/notifications', }, + messaging: { + title: i18n.ts.messaging, + icon: 'ti ti-messages', + show: computed(() => $i != null), + indicated: computed(() => $i != null && $i.hasUnreadMessagingMessage), + to: '/my/messaging', + }, drive: { title: i18n.ts.drive, icon: 'ti ti-cloud', diff --git a/packages/frontend/src/pages/messaging/index.vue b/packages/frontend/src/pages/messaging/index.vue new file mode 100644 index 0000000000..3d11cf13e9 --- /dev/null +++ b/packages/frontend/src/pages/messaging/index.vue @@ -0,0 +1,305 @@ + + + + + diff --git a/packages/frontend/src/pages/messaging/messaging-room.form.vue b/packages/frontend/src/pages/messaging/messaging-room.form.vue new file mode 100644 index 0000000000..d6113668dd --- /dev/null +++ b/packages/frontend/src/pages/messaging/messaging-room.form.vue @@ -0,0 +1,366 @@ + + + + + diff --git a/packages/frontend/src/pages/messaging/messaging-room.message.vue b/packages/frontend/src/pages/messaging/messaging-room.message.vue new file mode 100644 index 0000000000..d10798b92e --- /dev/null +++ b/packages/frontend/src/pages/messaging/messaging-room.message.vue @@ -0,0 +1,338 @@ + + + + + diff --git a/packages/frontend/src/pages/messaging/messaging-room.vue b/packages/frontend/src/pages/messaging/messaging-room.vue new file mode 100644 index 0000000000..0867f003a3 --- /dev/null +++ b/packages/frontend/src/pages/messaging/messaging-room.vue @@ -0,0 +1,415 @@ + + + + + diff --git a/packages/frontend/src/pages/settings/notifications.vue b/packages/frontend/src/pages/settings/notifications.vue index 05c7fb72e5..db32ee862c 100644 --- a/packages/frontend/src/pages/settings/notifications.vue +++ b/packages/frontend/src/pages/settings/notifications.vue @@ -5,6 +5,7 @@
{{ i18n.ts.markAsReadAllNotifications }} {{ i18n.ts.markAsReadAllUnreadNotes }} + {{ i18n.ts.markAsReadAllTalkMessages }}
@@ -46,6 +47,10 @@ async function readAllUnreadNotes() { await os.api('i/read-all-unread-notes'); } +async function readAllMessagingMessages() { + await os.api('i/read-all-messaging-messages'); +} + async function readAllNotifications() { await os.api('notifications/mark-all-as-read'); } diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts index 1ec4491273..6e7845f667 100644 --- a/packages/sw/src/scripts/create-notification.ts +++ b/packages/sw/src/scripts/create-notification.ts @@ -236,6 +236,23 @@ async function composeNotification(data: pushNotificationDataMap[keyof pushNotif default: return null; } + case 'unreadMessagingMessage': + if (data.body.groupId === null) { + return [t('_notification.youGotMessagingMessageFromUser', { name: getUserName(data.body.user) }), { + icon: data.body.user.avatarUrl, + badge: iconUrl('messages'), + tag: `messaging:user:${data.body.userId}`, + data, + renotify: true, + }]; + } + return [t('_notification.youGotMessagingMessageFromGroup', { name: data.body.group?.name ?? '' }), { + icon: data.body.user.avatarUrl, + badge: iconUrl('messages'), + tag: `messaging:group:${data.body.groupId}`, + data, + renotify: true, + }]; case 'unreadAntennaNote': return [t('_notification.unreadAntennaNote', { name: data.body.antenna.name }), { body: `${getUserName(data.body.note.user)}: ${data.body.note.text ?? ''}`, diff --git a/packages/sw/src/sw.ts b/packages/sw/src/sw.ts index f56208d6d6..7bcf4d5976 100644 --- a/packages/sw/src/sw.ts +++ b/packages/sw/src/sw.ts @@ -15,9 +15,9 @@ globalThis.addEventListener('activate', ev => { .then(cacheNames => Promise.all( cacheNames .filter((v) => v !== swLang.cacheName) - .map(name => caches.delete(name)), + .map(name => caches.delete(name)) )) - .then(() => self.clients.claim()), + .then(() => self.clients.claim()) ); }); @@ -34,7 +34,7 @@ globalThis.addEventListener('fetch', ev => { if (!isHTMLRequest) return; ev.respondWith( fetch(ev.request) - .catch(() => new Response(`Offline. Service Worker @${_VERSION_}`, { status: 200 })), + .catch(() => new Response(`Offline. Service Worker @${_VERSION_}`, { status: 200 })) ); }); @@ -42,13 +42,14 @@ globalThis.addEventListener('push', ev => { // クライアント取得 ev.waitUntil(self.clients.matchAll({ includeUncontrolled: true, - type: 'window', + type: 'window' }).then(async (clients: readonly WindowClient[]) => { const data: pushNotificationDataMap[keyof pushNotificationDataMap] = ev.data?.json(); switch (data.type) { // case 'driveFileCreated': case 'notification': + case 'unreadMessagingMessage': case 'unreadAntennaNote': // 1日以上経過している場合は無視 if ((new Date()).getTime() - data.dateTime > 1000 * 60 * 60 * 24) break; @@ -62,6 +63,11 @@ globalThis.addEventListener('push', ev => { if (n?.data?.type === 'notification') n.close(); } break; + case 'readAllMessagingMessages': + for (const n of await self.registration.getNotifications()) { + if (n?.data?.type === 'unreadMessagingMessage') n.close(); + } + break; case 'readAllAntennas': for (const n of await self.registration.getNotifications()) { if (n?.data?.type === 'unreadAntennaNote') n.close(); @@ -69,14 +75,25 @@ globalThis.addEventListener('push', ev => { break; case 'readNotifications': for (const n of await self.registration.getNotifications()) { - if (data.body.notificationIds.includes(n.data.body.id)) { + if (data.body?.notificationIds?.includes(n.data.body.id)) { n.close(); } } break; + case 'readAllMessagingMessagesOfARoom': + for (const n of await self.registration.getNotifications()) { + if (n.data.type === 'unreadMessagingMessage' + && ('userId' in data.body + ? data.body.userId === n.data.body.userId + : data.body.groupId === n.data.body.groupId) + ) { + n.close(); + } + } + break; case 'readAntenna': for (const n of await self.registration.getNotifications()) { - if (n?.data?.type === 'unreadAntennaNote' && data.body.antennaId === n.data.body.antenna.id) { + if (n?.data?.type === 'unreadAntennaNote' && data.body?.antennaId === n.data.body.antenna.id) { n.close(); } } @@ -157,6 +174,9 @@ globalThis.addEventListener('notificationclick', (ev: ServiceWorkerGlobalScopeEv } } break; + case 'unreadMessagingMessage': + client = await swos.openChat(data.body, loginId); + break; case 'unreadAntennaNote': client = await swos.openAntenna(data.body.antenna.id, loginId); } @@ -187,7 +207,7 @@ globalThis.addEventListener('message', (ev: ServiceWorkerGlobalScopeEventMap['me // Cache Storage全削除 await caches.keys() .then(cacheNames => Promise.all( - cacheNames.map(name => caches.delete(name)), + cacheNames.map(name => caches.delete(name)) )); return; // TODO } diff --git a/packages/sw/src/types.ts b/packages/sw/src/types.ts index 5b53ddecac..3b35de4079 100644 --- a/packages/sw/src/types.ts +++ b/packages/sw/src/types.ts @@ -13,12 +13,15 @@ export type SwMessage = { // Defined also @/core/PushNotificationService.ts#L12 type pushNotificationDataSourceMap = { notification: Misskey.entities.Notification; + unreadMessagingMessage: Misskey.entities.MessagingMessage; unreadAntennaNote: { antenna: { id: string, name: string }; note: Misskey.entities.Note; }; readNotifications: { notificationIds: string[] }; readAllNotifications: undefined; + readAllMessagingMessages: undefined; + readAllMessagingMessagesOfARoom: { userId: string } | { groupId: string }; readAntenna: { antennaId: string }; readAllAntennas: undefined; };