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:
parent
0471e457fe
commit
f1f24e39d2
129 changed files with 8176 additions and 773 deletions
376
packages/backend/src/core/entities/ChatEntityService.ts
Normal file
376
packages/backend/src/core/entities/ChatEntityService.ts
Normal 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 } })));
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue