diff --git a/packages/backend/src/core/CoreModule.ts b/packages/backend/src/core/CoreModule.ts index e3465e3932..2ba51af512 100644 --- a/packages/backend/src/core/CoreModule.ts +++ b/packages/backend/src/core/CoreModule.ts @@ -21,6 +21,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'; @@ -80,6 +81,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'; @@ -143,6 +145,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 }; @@ -203,6 +206,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 }; @@ -268,6 +272,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ImageProcessingService, InstanceActorService, InternalStorageService, + MessagingService, MetaService, MfmService, ModerationLogService, @@ -327,6 +332,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting GalleryPostEntityService, HashtagEntityService, InstanceEntityService, + MessagingMessageEntityService, ModerationLogEntityService, MutingEntityService, NoteEntityService, @@ -387,6 +393,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ImageProcessingService, $InstanceActorService, $InternalStorageService, + $MessagingService, $MetaService, $MfmService, $ModerationLogService, @@ -446,6 +453,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $GalleryPostEntityService, $HashtagEntityService, $InstanceEntityService, + $MessagingMessageEntityService, $ModerationLogEntityService, $MutingEntityService, $NoteEntityService, @@ -507,6 +515,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting ImageProcessingService, InstanceActorService, InternalStorageService, + MessagingService, MetaService, MfmService, ModerationLogService, @@ -565,6 +574,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting GalleryPostEntityService, HashtagEntityService, InstanceEntityService, + MessagingMessageEntityService, ModerationLogEntityService, MutingEntityService, NoteEntityService, @@ -625,6 +635,7 @@ const $ApQuestionService: Provider = { provide: 'ApQuestionService', useExisting $ImageProcessingService, $InstanceActorService, $InternalStorageService, + $MessagingService, $MetaService, $MfmService, $ModerationLogService, @@ -683,6 +694,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 6602e36799..5f9f04b655 100644 --- a/packages/backend/src/core/GlobalEventService.ts +++ b/packages/backend/src/core/GlobalEventService.ts @@ -10,9 +10,13 @@ import type { AdminStreamTypes, AntennaStreamTypes, BroadcastTypes, + ChannelStreamTypes, DriveStreamTypes, + GroupMessagingStreamTypes, InternalStreamTypes, MainStreamTypes, + MessagingIndexStreamTypes, + MessagingStreamTypes, NoteStreamTypes, UserListStreamTypes, UserStreamTypes, @@ -78,6 +82,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); @@ -88,6 +97,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 75bf4b0e01..6f369b2fc0 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,7 +40,7 @@ function truncateBody(type: T, body: pus reply: undefined, renote: undefined, user: type === 'notification' ? undefined as any : body.note.user, - }, + } } : {}), }; } @@ -76,6 +79,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 9bd80b6066..6ad3b18397 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, 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 6a1f233bd8..4cbd86c322 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'; @@ -291,7 +292,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) }); @@ -405,7 +406,11 @@ export class ApRendererService { }, })), } as const : {}; - + + const asTalk = isTalk ? { + _misskey_talk: true, + } as const : {}; + return { id: `${this.config.url}/notes/${note.id}`, type: 'Note', @@ -427,6 +432,7 @@ export class ApRendererService { sensitive: note.cw != null || files.some(file => file.isSensitive), tag, ...asPoll, + ...asTalk, }; } @@ -525,6 +531,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 { @@ -627,6 +642,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 c36e8d4ed6..194280ebcd 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 } from '@/models/index.js'; +import type { MessagingMessagesRepository, PollsRepository, EmojisRepository } 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 45eb8a354b..fd63a52130 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 7cff2e5b7c..d3cf86e61b 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'; @@ -42,6 +43,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 c390018b46..1fc9352539 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 ba9c9e5155..86efe5a1ba 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'; @@ -165,6 +166,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 7af238fd6f..f99a5e535e 100644 --- a/packages/backend/src/server/api/endpoints.ts +++ b/packages/backend/src/server/api/endpoints.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'; @@ -522,6 +528,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], @@ -544,6 +551,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 f9ef8218c1..ac0e0d998e 100644 --- a/packages/backend/src/server/api/stream/ChannelsService.ts +++ b/packages/backend/src/server/api/stream/ChannelsService.ts @@ -1,5 +1,4 @@ import { Injectable } from '@nestjs/common'; -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'; @@ -11,8 +10,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 { @@ -26,6 +28,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, @@ -45,6 +49,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 f5ef1d1102..86e307c3dc 100644 --- a/packages/backend/src/server/api/stream/channels/channel.ts +++ b/packages/backend/src/server/api/stream/channels/channel.ts @@ -1,24 +1,31 @@ import { Injectable } from '@nestjs/common'; 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'; 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 @@ -27,6 +34,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 @@ -56,10 +65,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); } } @@ -70,6 +111,7 @@ export class ChannelChannelService { constructor( private noteEntityService: NoteEntityService, + private userEntityService: UserEntityService, ) { } @@ -77,6 +119,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 9c35a57db4..808d51bf4d 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 44462f8ff2..691f79b4d4 100644 --- a/packages/frontend/src/components/MkPostForm.vue +++ b/packages/frontend/src/components/MkPostForm.vue @@ -158,6 +158,12 @@ let hasNotSpecifiedMentions = $ref(false); let recentHashtags = $ref(JSON.parse(miLocalStorage.getItem('hashtags') || '[]')); let imeText = $ref(''); +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}` : ''; @@ -439,10 +445,12 @@ function clear() { function onKeydown(ev: KeyboardEvent) { if ((ev.which === 10 || ev.which === 13) && (ev.ctrlKey || ev.metaKey) && canPost) post(); 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 8c657295f9..753a7341dd 100644 --- a/packages/frontend/src/init.ts +++ b/packages/frontend/src/init.ts @@ -505,6 +505,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 79de6212cf..9cbd4156d3 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 f64202fff2..ebf757c179 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 }}
@@ -45,6 +46,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/frontend/src/router.ts b/packages/frontend/src/router.ts index 06ae78d81d..9f4e903828 100644 --- a/packages/frontend/src/router.ts +++ b/packages/frontend/src/router.ts @@ -419,6 +419,19 @@ export const routes = [{ path: '/my/achievements', component: page(() => import('./pages/achievements.vue')), loginRequired: true, +}, { + name: 'messaging', + path: '/my/messaging', + component: page(() => import('./pages/messaging/index.vue')), + loginRequired: true, +}, { + path: '/my/messaging/:userAcct', + component: page(() => import('./pages/messaging/messaging-room.vue')), + loginRequired: true, +}, { + path: '/my/messaging/group/:groupId', + component: page(() => import('./pages/messaging/messaging-room.vue')), + loginRequired: true, }, { path: '/my/drive/folder/:folder', component: page(() => import('./pages/drive.vue')), diff --git a/packages/sw/src/scripts/create-notification.ts b/packages/sw/src/scripts/create-notification.ts index 6e8560e560..0b5ea2308f 100644 --- a/packages/sw/src/scripts/create-notification.ts +++ b/packages/sw/src/scripts/create-notification.ts @@ -244,6 +244,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': const tag = `antenna:${data.body.antenna.id}`; return [t('_notification.unreadAntennaNote', { name: data.body.antenna.name }), { diff --git a/packages/sw/src/sw.ts b/packages/sw/src/sw.ts index ebffdfbed5..73eeba07cf 100644 --- a/packages/sw/src/sw.ts +++ b/packages/sw/src/sw.ts @@ -15,7 +15,7 @@ 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(() => globalThis.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(globalThis.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; @@ -59,6 +60,11 @@ globalThis.addEventListener('push', ev => { if (n?.data?.type === 'notification') n.close(); } break; + case 'readAllMessagingMessages': + for (const n of await globalThis.registration.getNotifications()) { + if (n?.data?.type === 'unreadMessagingMessage') n.close(); + } + break; case 'readAllAntennas': for (const n of await globalThis.registration.getNotifications()) { if (n?.data?.type === 'unreadAntennaNote') n.close(); @@ -71,6 +77,17 @@ globalThis.addEventListener('push', ev => { } } break; + case 'readAllMessagingMessagesOfARoom': + for (const n of await globalThis.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 globalThis.registration.getNotifications()) { if (n?.data?.type === 'unreadAntennaNote' && data.body.antennaId === n.data.body.antenna.id) { @@ -155,6 +172,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); } @@ -185,7 +205,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; };