Feat: Chat (#15686)

* wip

* wip

* wip

* wip

* wip

* wip

* Update types.ts

* Create 1742203321812-chat.js

* wip

* wip

* Update room.vue

* Update home.vue

* Update home.vue

* Update ja-JP.yml

* Update index.d.ts

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* Update CHANGELOG.md

* wip

* Update home.vue

* clean up

* Update misskey-js.api.md

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* wip

* lint fixes

* lint

* Update UserEntityService.ts

* search

* wip

* 🎨

* wip

* Update home.ownedRooms.vue

* wip

* Update CHANGELOG.md

* Update style.scss

* wip

* improve performance

* improve performance

* Update timeline.test.ts
This commit is contained in:
syuilo 2025-03-24 21:32:46 +09:00 committed by GitHub
parent 0471e457fe
commit f1f24e39d2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
129 changed files with 8176 additions and 773 deletions

View file

@ -0,0 +1,376 @@
/*
* SPDX-FileCopyrightText: syuilo and misskey-project
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Inject, Injectable } from '@nestjs/common';
import { DI } from '@/di-symbols.js';
import type { MiUser, ChatMessagesRepository, MiChatMessage, ChatRoomsRepository, MiChatRoom, MiChatRoomInvitation, ChatRoomInvitationsRepository, MiChatRoomMembership, ChatRoomMembershipsRepository } from '@/models/_.js';
import { awaitAll } from '@/misc/prelude/await-all.js';
import type { Packed } from '@/misc/json-schema.js';
import type { } from '@/models/Blocking.js';
import { bindThis } from '@/decorators.js';
import { IdService } from '@/core/IdService.js';
import { UserEntityService } from './UserEntityService.js';
import { DriveFileEntityService } from './DriveFileEntityService.js';
import { In } from 'typeorm';
@Injectable()
export class ChatEntityService {
constructor(
@Inject(DI.chatMessagesRepository)
private chatMessagesRepository: ChatMessagesRepository,
@Inject(DI.chatRoomsRepository)
private chatRoomsRepository: ChatRoomsRepository,
@Inject(DI.chatRoomInvitationsRepository)
private chatRoomInvitationsRepository: ChatRoomInvitationsRepository,
@Inject(DI.chatRoomMembershipsRepository)
private chatRoomMembershipsRepository: ChatRoomMembershipsRepository,
private userEntityService: UserEntityService,
private driveFileEntityService: DriveFileEntityService,
private idService: IdService,
) {
}
@bindThis
public async packMessageDetailed(
src: MiChatMessage['id'] | MiChatMessage,
me?: { id: MiUser['id'] },
options?: {
_hint_?: {
packedFiles?: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
packedUsers?: Map<MiChatMessage['id'], Packed<'UserLite'>>;
packedRooms?: Map<MiChatMessage['toRoomId'], Packed<'ChatRoom'> | null>;
};
},
): Promise<Packed<'ChatMessage'>> {
const packedUsers = options?._hint_?.packedUsers;
const packedFiles = options?._hint_?.packedFiles;
const packedRooms = options?._hint_?.packedRooms;
const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = [];
for (const record of message.reactions) {
const [userId, reaction] = record.split('/');
reactions.push({
user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId),
reaction,
});
}
return {
id: message.id,
createdAt: this.idService.parse(message.id).date.toISOString(),
text: message.text,
fromUserId: message.fromUserId,
fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId, me),
toUserId: message.toUserId,
toUser: message.toUserId ? (packedUsers?.get(message.toUserId) ?? await this.userEntityService.pack(message.toUser ?? message.toUserId, me)) : undefined,
toRoomId: message.toRoomId,
toRoom: message.toRoomId ? (packedRooms?.get(message.toRoomId) ?? await this.packRoom(message.toRoom ?? message.toRoomId, me)) : undefined,
fileId: message.fileId,
file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
reactions,
};
}
@bindThis
public async packMessagesDetailed(
messages: MiChatMessage[],
me: { id: MiUser['id'] },
) {
if (messages.length === 0) return [];
const excludeMe = (x: MiUser | string) => {
if (typeof x === 'string') {
return x !== me.id;
} else {
return x.id !== me.id;
}
};
const users = [
...messages.map((m) => m.fromUser ?? m.fromUserId).filter(excludeMe),
...messages.map((m) => m.toUser ?? m.toUserId).filter(x => x != null).filter(excludeMe),
];
const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0]));
for (const reactedUserId of reactedUserIds) {
if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) {
users.push(reactedUserId);
}
}
const [packedUsers, packedFiles, packedRooms] = await Promise.all([
this.userEntityService.packMany(users, me)
.then(users => new Map(users.map(u => [u.id, u]))),
this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
.then(files => new Map(files.map(f => [f.id, f]))),
this.packRooms(messages.map(m => m.toRoom ?? m.toRoomId).filter(x => x != null), me)
.then(rooms => new Map(rooms.map(r => [r.id, r]))),
]);
return Promise.all(messages.map(message => this.packMessageDetailed(message, me, { _hint_: { packedUsers, packedFiles, packedRooms } })));
}
@bindThis
public async packMessageLiteFor1on1(
src: MiChatMessage['id'] | MiChatMessage,
options?: {
_hint_?: {
packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
};
},
): Promise<Packed<'ChatMessageLite'>> {
const packedFiles = options?._hint_?.packedFiles;
const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
const reactions: { reaction: string; }[] = [];
for (const record of message.reactions) {
const [userId, reaction] = record.split('/');
reactions.push({
reaction,
});
}
return {
id: message.id,
createdAt: this.idService.parse(message.id).date.toISOString(),
text: message.text,
fromUserId: message.fromUserId,
toUserId: message.toUserId,
fileId: message.fileId,
file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
reactions,
};
}
@bindThis
public async packMessagesLiteFor1on1(
messages: MiChatMessage[],
) {
if (messages.length === 0) return [];
const [packedFiles] = await Promise.all([
this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
.then(files => new Map(files.map(f => [f.id, f]))),
]);
return Promise.all(messages.map(message => this.packMessageLiteFor1on1(message, { _hint_: { packedFiles } })));
}
@bindThis
public async packMessageLiteForRoom(
src: MiChatMessage['id'] | MiChatMessage,
options?: {
_hint_?: {
packedFiles: Map<MiChatMessage['fileId'], Packed<'DriveFile'> | null>;
packedUsers: Map<MiUser['id'], Packed<'UserLite'>>;
};
},
): Promise<Packed<'ChatMessageLite'>> {
const packedFiles = options?._hint_?.packedFiles;
const packedUsers = options?._hint_?.packedUsers;
const message = typeof src === 'object' ? src : await this.chatMessagesRepository.findOneByOrFail({ id: src });
const reactions: { user: Packed<'UserLite'>; reaction: string; }[] = [];
for (const record of message.reactions) {
const [userId, reaction] = record.split('/');
reactions.push({
user: packedUsers?.get(userId) ?? await this.userEntityService.pack(userId),
reaction,
});
}
return {
id: message.id,
createdAt: this.idService.parse(message.id).date.toISOString(),
text: message.text,
fromUserId: message.fromUserId,
fromUser: packedUsers?.get(message.fromUserId) ?? await this.userEntityService.pack(message.fromUser ?? message.fromUserId),
toRoomId: message.toRoomId,
fileId: message.fileId,
file: message.fileId ? (packedFiles?.get(message.fileId) ?? await this.driveFileEntityService.pack(message.file ?? message.fileId)) : null,
reactions,
};
}
@bindThis
public async packMessagesLiteForRoom(
messages: MiChatMessage[],
) {
if (messages.length === 0) return [];
const users = messages.map(x => x.fromUser ?? x.fromUserId);
const reactedUserIds = messages.flatMap(x => x.reactions.map(r => r.split('/')[0]));
for (const reactedUserId of reactedUserIds) {
if (!users.some(x => typeof x === 'string' ? x === reactedUserId : x.id === reactedUserId)) {
users.push(reactedUserId);
}
}
const [packedUsers, packedFiles] = await Promise.all([
this.userEntityService.packMany(users)
.then(users => new Map(users.map(u => [u.id, u]))),
this.driveFileEntityService.packMany(messages.map(m => m.file).filter(x => x != null))
.then(files => new Map(files.map(f => [f.id, f]))),
]);
return Promise.all(messages.map(message => this.packMessageLiteForRoom(message, { _hint_: { packedFiles, packedUsers } })));
}
@bindThis
public async packRoom(
src: MiChatRoom['id'] | MiChatRoom,
me?: { id: MiUser['id'] },
options?: {
_hint_?: {
packedOwners: Map<MiChatRoom['id'], Packed<'UserLite'>>;
memberships?: Map<MiChatRoom['id'], MiChatRoomMembership | null | undefined>;
};
},
): Promise<Packed<'ChatRoom'>> {
const room = typeof src === 'object' ? src : await this.chatRoomsRepository.findOneByOrFail({ id: src });
const membership = me && me.id !== room.ownerId ? (options?._hint_?.memberships?.get(room.id) ?? await this.chatRoomMembershipsRepository.findOneBy({ roomId: room.id, userId: me.id })) : null;
return {
id: room.id,
createdAt: this.idService.parse(room.id).date.toISOString(),
name: room.name,
description: room.description,
ownerId: room.ownerId,
owner: options?._hint_?.packedOwners.get(room.ownerId) ?? await this.userEntityService.pack(room.owner ?? room.ownerId, me),
isMuted: membership != null ? membership.isMuted : false,
};
}
@bindThis
public async packRooms(
rooms: (MiChatRoom | MiChatRoom['id'])[],
me: { id: MiUser['id'] },
) {
if (rooms.length === 0) return [];
const _rooms = rooms.filter((room): room is MiChatRoom => typeof room !== 'string');
if (_rooms.length !== rooms.length) {
_rooms.push(
...await this.chatRoomsRepository.find({
where: {
id: In(rooms.filter((room): room is string => typeof room === 'string')),
},
relations: ['owner'],
}),
);
}
const owners = _rooms.map(x => x.owner ?? x.ownerId);
const [packedOwners, memberships] = await Promise.all([
this.userEntityService.packMany(owners, me)
.then(users => new Map(users.map(u => [u.id, u]))),
this.chatRoomMembershipsRepository.find({
where: {
roomId: In(_rooms.map(x => x.id)),
userId: me.id,
},
}).then(memberships => new Map(_rooms.map(r => [r.id, memberships.find(m => m.roomId === r.id)]))),
]);
return Promise.all(_rooms.map(room => this.packRoom(room, me, { _hint_: { packedOwners, memberships } })));
}
@bindThis
public async packRoomInvitation(
src: MiChatRoomInvitation['id'] | MiChatRoomInvitation,
me: { id: MiUser['id'] },
options?: {
_hint_?: {
packedRooms: Map<MiChatRoomInvitation['roomId'], Packed<'ChatRoom'>>;
packedUsers: Map<MiChatRoomInvitation['id'], Packed<'UserLite'>>;
};
},
): Promise<Packed<'ChatRoomInvitation'>> {
const invitation = typeof src === 'object' ? src : await this.chatRoomInvitationsRepository.findOneByOrFail({ id: src });
return {
id: invitation.id,
createdAt: this.idService.parse(invitation.id).date.toISOString(),
roomId: invitation.roomId,
room: options?._hint_?.packedRooms.get(invitation.roomId) ?? await this.packRoom(invitation.room ?? invitation.roomId, me),
userId: invitation.userId,
user: options?._hint_?.packedUsers.get(invitation.userId) ?? await this.userEntityService.pack(invitation.user ?? invitation.userId, me),
};
}
@bindThis
public async packRoomInvitations(
invitations: MiChatRoomInvitation[],
me: { id: MiUser['id'] },
) {
if (invitations.length === 0) return [];
return Promise.all(invitations.map(invitation => this.packRoomInvitation(invitation, me)));
}
@bindThis
public async packRoomMembership(
src: MiChatRoomMembership['id'] | MiChatRoomMembership,
me: { id: MiUser['id'] },
options?: {
populateUser?: boolean;
populateRoom?: boolean;
_hint_?: {
packedRooms: Map<MiChatRoomMembership['roomId'], Packed<'ChatRoom'>>;
packedUsers: Map<MiChatRoomMembership['id'], Packed<'UserLite'>>;
};
},
): Promise<Packed<'ChatRoomMembership'>> {
const membership = typeof src === 'object' ? src : await this.chatRoomMembershipsRepository.findOneByOrFail({ id: src });
return {
id: membership.id,
createdAt: this.idService.parse(membership.id).date.toISOString(),
userId: membership.userId,
user: options?.populateUser ? (options._hint_?.packedUsers.get(membership.userId) ?? await this.userEntityService.pack(membership.user ?? membership.userId, me)) : undefined,
roomId: membership.roomId,
room: options?.populateRoom ? (options._hint_?.packedRooms.get(membership.roomId) ?? await this.packRoom(membership.room ?? membership.roomId, me)) : undefined,
};
}
@bindThis
public async packRoomMemberships(
memberships: MiChatRoomMembership[],
me: { id: MiUser['id'] },
options: {
populateUser?: boolean;
populateRoom?: boolean;
} = {},
) {
if (memberships.length === 0) return [];
const users = memberships.map(x => x.user ?? x.userId);
const rooms = memberships.map(x => x.room ?? x.roomId);
const [packedUsers, packedRooms] = await Promise.all([
this.userEntityService.packMany(users, me)
.then(users => new Map(users.map(u => [u.id, u]))),
this.packRooms(rooms, me)
.then(rooms => new Map(rooms.map(r => [r.id, r]))),
]);
return Promise.all(memberships.map(membership => this.packRoomMembership(membership, me, { ...options, _hint_: { packedUsers, packedRooms } })));
}
}

View file

@ -32,7 +32,6 @@ import type {
MiUserNotePining,
MiUserProfile,
MutingsRepository,
NoteUnreadsRepository,
RenoteMutingsRepository,
UserMemoRepository,
UserNotePiningsRepository,
@ -48,9 +47,9 @@ import { IdService } from '@/core/IdService.js';
import type { AnnouncementService } from '@/core/AnnouncementService.js';
import type { CustomEmojiService } from '@/core/CustomEmojiService.js';
import { AvatarDecorationService } from '@/core/AvatarDecorationService.js';
import { ChatService } from '@/core/ChatService.js';
import type { OnModuleInit } from '@nestjs/common';
import type { NoteEntityService } from './NoteEntityService.js';
import type { DriveFileEntityService } from './DriveFileEntityService.js';
import type { PageEntityService } from './PageEntityService.js';
const Ajv = _Ajv.default;
@ -94,6 +93,7 @@ export class UserEntityService implements OnModuleInit {
private federatedInstanceService: FederatedInstanceService;
private idService: IdService;
private avatarDecorationService: AvatarDecorationService;
private chatService: ChatService;
constructor(
private moduleRef: ModuleRef,
@ -128,9 +128,6 @@ export class UserEntityService implements OnModuleInit {
@Inject(DI.renoteMutingsRepository)
private renoteMutingsRepository: RenoteMutingsRepository,
@Inject(DI.noteUnreadsRepository)
private noteUnreadsRepository: NoteUnreadsRepository,
@Inject(DI.userNotePiningsRepository)
private userNotePiningsRepository: UserNotePiningsRepository,
@ -152,6 +149,7 @@ export class UserEntityService implements OnModuleInit {
this.federatedInstanceService = this.moduleRef.get('FederatedInstanceService');
this.idService = this.moduleRef.get('IdService');
this.avatarDecorationService = this.moduleRef.get('AvatarDecorationService');
this.chatService = this.moduleRef.get('ChatService');
}
//#region Validators
@ -558,6 +556,7 @@ export class UserEntityService implements OnModuleInit {
publicReactions: this.isLocalUser(user) ? profile!.publicReactions : false, // https://github.com/misskey-dev/misskey/issues/12964
followersVisibility: profile!.followersVisibility,
followingVisibility: profile!.followingVisibility,
chatScope: user.chatScope,
roles: this.roleService.getUserRoles(user.id).then(roles => roles.filter(role => role.isPublic).sort((a, b) => b.displayOrder - a.displayOrder).map(role => ({
id: role.id,
name: role.name,
@ -598,14 +597,9 @@ export class UserEntityService implements OnModuleInit {
isDeleted: user.isDeleted,
twoFactorBackupCodesStock: profile?.twoFactorBackupSecret?.length === 5 ? 'full' : (profile?.twoFactorBackupSecret?.length ?? 0) > 0 ? 'partial' : 'none',
hideOnlineStatus: user.hideOnlineStatus,
hasUnreadSpecifiedNotes: this.noteUnreadsRepository.count({
where: { userId: user.id, isSpecified: true },
take: 1,
}).then(count => count > 0),
hasUnreadMentions: this.noteUnreadsRepository.count({
where: { userId: user.id, isMentioned: true },
take: 1,
}).then(count => count > 0),
hasUnreadSpecifiedNotes: false, // 後方互換性のため
hasUnreadMentions: false, // 後方互換性のため
hasUnreadChatMessages: this.chatService.hasUnreadMessages(user.id),
hasUnreadAnnouncement: unreadAnnouncements!.length > 0,
unreadAnnouncements,
hasUnreadAntenna: this.getHasUnreadAntenna(user.id),