global update and global fix

This commit is contained in:
lendry
2026-06-25 23:48:57 +03:00
parent 3880c68d59
commit b0ea87e898
121 changed files with 13759 additions and 663 deletions

View File

@@ -1,5 +1,6 @@
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../infra/prisma.service';
import { MinioService } from '../infra/minio.service';
import { resolveVerificationIcon } from './verification.constants';
import { FamilyService } from './family.service';
import { NotificationsService } from './notifications.service';
@@ -21,11 +22,19 @@ export class ChatService {
constructor(
private readonly prisma: PrismaService,
private readonly family: FamilyService,
private readonly notifications: NotificationsService
private readonly notifications: NotificationsService,
private readonly minio: MinioService
) {}
async listRooms(userId: string, groupId: string) {
await this.family.assertFamilyMember(groupId, userId);
await this.syncFamilyChats(groupId);
await this.prisma.chatRoom.updateMany({
where: { groupId, type: 'DIRECT', isE2E: true },
data: { isE2E: false }
});
let generalRoom = await this.prisma.chatRoom.findFirst({ where: { groupId, type: 'GENERAL' } });
if (!generalRoom) {
const members = await this.prisma.familyMember.findMany({ where: { groupId } });
@@ -34,10 +43,11 @@ export class ChatService {
groupId,
type: 'GENERAL',
name: 'Общий чат',
members: { create: members.map((member) => ({ userId: member.userId })) }
members: { create: members.filter((member) => member.role !== 'bot').map((member) => ({ userId: member.userId })) }
}
});
}
const rooms = await this.prisma.chatRoom.findMany({
where: {
groupId,
@@ -57,21 +67,122 @@ export class ChatService {
orderBy: [{ type: 'asc' }, { updatedAt: 'desc' }]
});
const dedupedRooms = rooms.filter((room) => {
if (room.type !== 'GROUP' || room.members.length !== 2) return true;
const memberIds = room.members.map((member) => member.userId).sort().join(':');
return !rooms.some(
(candidate) =>
candidate.id !== room.id &&
(candidate.type === 'DIRECT' || candidate.type === 'BOT') &&
candidate.members.map((member) => member.userId).sort().join(':') === memberIds
);
});
const familyMembers = await this.prisma.familyMember.findMany({ where: { groupId } });
const familyRoleByUserId = new Map(familyMembers.map((member) => [member.userId, member.role]));
return {
rooms: await Promise.all(
rooms.map(async (room) => {
const lastMessage = room.messages[0];
return this.formatRoom(
room,
familyRoleByUserId,
lastMessage ? await this.toMessage(lastMessage, userId, await this.getRoomReadContext(room.id)) : undefined
);
})
)
};
const formatted = await Promise.all(
dedupedRooms.map(async (room) => {
let lastMessage = room.messages[0]
? await this.toMessage(room.messages[0], userId, await this.getRoomReadContext(room.id))
: undefined;
if (room.type === 'BOT' && room.botUsername) {
const botPreview = await this.getBotRoomPreview(userId, room.botUsername);
if (botPreview) {
lastMessage = botPreview;
}
}
return this.formatRoom(
room,
familyRoleByUserId,
lastMessage,
userId
);
})
);
formatted.sort((a, b) => {
const rank = (type: string) => (type === 'GENERAL' ? 0 : 1);
const rankDiff = rank(a.type) - rank(b.type);
if (rankDiff !== 0) return rankDiff;
const aTime = a.lastMessage?.createdAt ?? a.updatedAt;
const bTime = b.lastMessage?.createdAt ?? b.updatedAt;
return bTime.localeCompare(aTime);
});
return { rooms: formatted };
}
async syncFamilyChats(groupId: string) {
const members = await this.prisma.familyMember.findMany({
where: { groupId },
include: { user: { include: { linkedBot: { select: { username: true } } } } }
});
const humans = members.filter((member) => !member.user.linkedBotId);
const bots = members.filter((member) => member.user.linkedBotId);
const legacyRooms = await this.prisma.chatRoom.findMany({
where: { groupId, type: 'GROUP' },
include: { members: true }
});
for (const room of legacyRooms) {
if (room.members.length !== 2) continue;
const memberIds = room.members.map((member) => member.userId);
const memberA = members.find((member) => member.userId === memberIds[0]);
const memberB = members.find((member) => member.userId === memberIds[1]);
const aIsBot = Boolean(memberA?.user.linkedBotId);
const bIsBot = Boolean(memberB?.user.linkedBotId);
if (aIsBot || bIsBot) {
const humanId = aIsBot ? memberIds[1]! : memberIds[0]!;
const botId = aIsBot ? memberIds[0]! : memberIds[1]!;
const botMember = members.find((member) => member.userId === botId);
await this.prisma.chatRoom.update({
where: { id: room.id },
data: {
type: 'BOT',
peerUserId: botId,
botUsername: botMember?.user.linkedBot?.username ?? undefined,
name: botMember?.user.displayName ?? room.name,
isE2E: false
}
});
} else {
await this.prisma.chatRoom.update({
where: { id: room.id },
data: {
type: 'DIRECT',
isE2E: false,
peerUserId: memberIds[1],
name: memberB?.user.displayName ?? room.name
}
});
}
}
for (let i = 0; i < humans.length; i += 1) {
for (let j = i + 1; j < humans.length; j += 1) {
const left = humans[i]!;
const right = humans[j]!;
await this.ensureDirectRoom(groupId, left.userId, right.userId, right.user.displayName);
}
}
for (const botMember of bots) {
for (const human of humans) {
await this.ensureBotRoom(
groupId,
human.userId,
botMember.userId,
botMember.user.linkedBot!.username,
botMember.user.displayName
);
}
}
}
async createRoom(userId: string, groupId: string, name: string, memberUserIds: string[]) {
@@ -82,6 +193,9 @@ export class ChatService {
}
const uniqueMembers = Array.from(new Set([userId, ...memberUserIds]));
if (uniqueMembers.length === 2) {
throw new BadRequestException('Личный чат создаётся автоматически при добавлении участника в семью');
}
for (const memberId of uniqueMembers) {
if (!group.members.some((member) => member.userId === memberId)) {
throw new BadRequestException('Все участники чата должны состоять в семье');
@@ -107,7 +221,86 @@ export class ChatService {
const familyMembers = await this.prisma.familyMember.findMany({ where: { groupId } });
const familyRoleByUserId = new Map(familyMembers.map((member) => [member.userId, member.role]));
return this.formatRoom(room, familyRoleByUserId);
return this.formatRoom(room, familyRoleByUserId, undefined, userId);
}
async createE2ERoom(userId: string, groupId: string, peerUserId: string) {
if (userId === peerUserId) {
throw new BadRequestException('Нельзя создать секретный чат с самим собой');
}
const group = await this.family.assertFamilyMember(groupId, userId);
if (!group.members.some((member) => member.userId === peerUserId)) {
throw new BadRequestException('Пользователь не состоит в семье');
}
const peerUser = await this.prisma.user.findUnique({
where: { id: peerUserId },
select: { displayName: true, linkedBotId: true, isSystemAccount: true }
});
if (!peerUser || peerUser.linkedBotId || peerUser.isSystemAccount) {
throw new BadRequestException('Секретный E2E чат доступен только с участниками семьи');
}
const existing = await this.findPairRoom(groupId, userId, peerUserId, 'E2E');
if (existing) {
return this.getRoomResponse(existing.id, userId);
}
const room = await this.prisma.chatRoom.create({
data: {
groupId,
type: 'E2E',
name: peerUser.displayName,
peerUserId,
isE2E: true,
members: {
create: [{ userId }, { userId: peerUserId }]
}
},
include: {
members: { include: { user: true } },
messages: true
}
});
const familyMembers = await this.prisma.familyMember.findMany({ where: { groupId } });
const familyRoleByUserId = new Map(familyMembers.map((member) => [member.userId, member.role]));
return this.formatRoom(room, familyRoleByUserId, undefined, userId);
}
async deleteRoom(userId: string, roomId: string) {
const room = await this.getRoomForMember(roomId, userId);
if (room.type === 'GENERAL') {
throw new BadRequestException('Нельзя удалить общий чат семьи');
}
if (room.type === 'GROUP') {
const isFamilyOwner = room.group.ownerId === userId;
const isChatCreator = room.createdById === userId;
if (!isFamilyOwner && !isChatCreator) {
throw new ForbiddenException('Удалить групповой чат может только создатель чата или семьи');
}
}
const messages = await this.prisma.chatMessage.findMany({
where: { roomId },
select: { storageKey: true }
});
const storageKeys = messages
.map((message) => message.storageKey)
.filter((key): key is string => Boolean(key));
if (room.avatarStorageKey) {
storageKeys.push(room.avatarStorageKey);
}
await this.prisma.chatRoom.delete({ where: { id: roomId } });
if (storageKeys.length) {
await this.minio.deleteObjects(storageKeys).catch(() => undefined);
}
return { count: 1 };
}
async addRoomMember(userId: string, roomId: string, memberUserId: string) {
@@ -115,6 +308,9 @@ export class ChatService {
if (room.type === 'GENERAL') {
throw new BadRequestException('В общий чат нельзя добавлять участников вручную');
}
if (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E') {
throw new BadRequestException('Нельзя изменять состав личного чата или чата с ботом');
}
if (room.members.some((member) => member.userId === memberUserId)) {
throw new BadRequestException('Пользователь уже состоит в чате');
}
@@ -134,6 +330,9 @@ export class ChatService {
if (room.type === 'GENERAL') {
throw new BadRequestException('Нельзя удалить участника из общего чата');
}
if (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E') {
throw new BadRequestException('Нельзя изменять состав личного чата или чата с ботом');
}
const isFamilyOwner = room.group.ownerId === userId;
const isChatCreator = room.createdById === userId;
@@ -171,6 +370,9 @@ export class ChatService {
if (room.type === 'GENERAL') {
throw new BadRequestException('Нельзя переименовать общий чат');
}
if (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E') {
throw new BadRequestException('Нельзя переименовать личный чат или чат с ботом');
}
await this.prisma.chatRoom.update({ where: { id: roomId }, data: { name: trimmedName } });
}
if (notificationsMuted !== undefined) {
@@ -183,7 +385,10 @@ export class ChatService {
}
async listMessages(userId: string, roomId: string, beforeMessageId?: string, limit = 50) {
await this.getRoomForMember(roomId, userId);
const room = await this.getRoomForMember(roomId, userId);
if (room.type === 'BOT') {
throw new BadRequestException('Сообщения бота загружаются через Bot API');
}
const take = Math.min(Math.max(limit, 1), 100);
const readContext = await this.getRoomReadContext(roomId);
@@ -221,9 +426,20 @@ export class ChatService {
storageKey?: string,
mimeType?: string,
metadataJson?: string,
poll?: PollPayload
poll?: PollPayload,
isEncrypted?: boolean
) {
const room = await this.getRoomForMember(roomId, userId);
if (room.type === 'BOT') {
throw new BadRequestException('Сообщения боту отправляйте через Bot API');
}
const requiresEncryption = room.type === 'E2E' || (room.type === 'DIRECT' && room.isE2E);
if (requiresEncryption && !isEncrypted) {
throw new BadRequestException('Сообщения секретного чата должны быть зашифрованы на клиенте');
}
if (room.type === 'E2E' && type === 'POLL') {
throw new BadRequestException('Опросы недоступны в секретном E2E чате');
}
const allowedTypes = new Set(['TEXT', 'IMAGE', 'AUDIO', 'VOICE', 'FILE', 'EMOJI', 'POLL']);
if (!allowedTypes.has(type)) {
throw new BadRequestException('Неподдерживаемый тип сообщения');
@@ -259,6 +475,7 @@ export class ChatService {
senderId: userId,
type,
content: content?.trim() || null,
isEncrypted: Boolean(isEncrypted),
replyToId: replyToId || null,
storageKey: storageKey || null,
mimeType: mimeType || null,
@@ -299,6 +516,42 @@ export class ChatService {
return response;
}
async publishRoomAvatarChangedMessage(roomId: string, userId: string) {
const room = await this.prisma.chatRoom.findUnique({
where: { id: roomId },
include: { members: true }
});
if (!room || (room.type !== 'GROUP' && room.type !== 'GENERAL')) {
return null;
}
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const userName = user?.displayName?.trim() || 'Участник';
const content = `${userName} изменил(а) фото чата`;
const message = await this.prisma.chatMessage.create({
data: {
roomId,
senderId: userId,
type: 'SYSTEM',
content,
metadata: { isSystem: true, systemKind: 'avatar_changed' }
},
include: { sender: true }
});
await this.prisma.chatRoom.update({ where: { id: roomId }, data: { updatedAt: new Date() } });
const readContext = await this.getRoomReadContext(roomId);
const response = await this.toMessage(message, userId, readContext);
await this.publishRoomEvent(
{ id: room.id, name: room.name, members: room.members },
'chat_message',
response
);
return response;
}
async editMessage(userId: string, messageId: string, content: string) {
const message = await this.prisma.chatMessage.findUnique({
where: { id: messageId },
@@ -310,6 +563,9 @@ export class ChatService {
if (message.senderId !== userId) {
throw new ForbiddenException('Можно редактировать только свои сообщения');
}
if (message.isEncrypted) {
throw new BadRequestException('Зашифрованные сообщения нельзя редактировать');
}
if (!['TEXT', 'EMOJI'].includes(message.type)) {
throw new BadRequestException('Этот тип сообщения нельзя редактировать');
}
@@ -351,6 +607,155 @@ export class ChatService {
return response;
}
async toggleMessageReaction(userId: string, messageId: string, emoji: string) {
const normalizedEmoji = emoji.trim();
if (!normalizedEmoji || normalizedEmoji.length > 16) {
throw new BadRequestException('Некорректная реакция');
}
const message = await this.prisma.chatMessage.findUnique({
where: { id: messageId },
include: { room: { include: { members: true } } }
});
if (!message || message.deletedAt) {
throw new NotFoundException('Сообщение не найдено');
}
if (!message.room.members.some((member) => member.userId === userId)) {
throw new ForbiddenException('Вы не состоите в этом чате');
}
const meta = (message.metadata as Record<string, unknown> | null) ?? {};
const reactions = { ...((meta.reactions as Record<string, string[]>) ?? {}) };
const current = reactions[normalizedEmoji] ?? [];
if (current.includes(userId)) {
const next = current.filter((id) => id !== userId);
if (next.length) {
reactions[normalizedEmoji] = next;
} else {
delete reactions[normalizedEmoji];
}
} else {
reactions[normalizedEmoji] = [...current, userId];
}
await this.prisma.chatMessage.update({
where: { id: messageId },
data: { metadata: { ...meta, reactions } }
});
const response = await this.loadMessageResponse(messageId, userId);
await this.publishRoomEvent(message.room, 'chat_message_updated', response);
return response;
}
async forwardMessages(userId: string, targetRoomId: string, messageIds: string[]) {
if (!messageIds.length) {
throw new BadRequestException('Укажите сообщения для пересылки');
}
const targetRoom = await this.getRoomForMember(targetRoomId, userId);
if (targetRoom.type === 'BOT') {
throw new BadRequestException('Нельзя пересылать сообщения в чат с ботом');
}
const uniqueIds = [...new Set(messageIds)];
const sourceMessages = await this.prisma.chatMessage.findMany({
where: { id: { in: uniqueIds }, deletedAt: null },
include: {
sender: true,
room: { include: { members: true } }
},
orderBy: { createdAt: 'asc' }
});
if (sourceMessages.length !== uniqueIds.length) {
throw new NotFoundException('Одно или несколько сообщений не найдены');
}
for (const message of sourceMessages) {
if (!message.room.members.some((member) => member.userId === userId)) {
throw new ForbiddenException('Нет доступа к одному из сообщений');
}
if (message.isEncrypted) {
throw new BadRequestException('Зашифрованные сообщения нельзя пересылать');
}
}
const forwarded: Awaited<ReturnType<ChatService['loadMessageResponse']>>[] = [];
for (const source of sourceMessages) {
let forwardType = source.type;
let forwardContent = source.content ?? undefined;
let forwardStorageKey = source.storageKey ?? undefined;
let forwardMimeType = source.mimeType ?? undefined;
if (!['TEXT', 'EMOJI'].includes(source.type)) {
forwardType = 'TEXT';
forwardContent = this.buildForwardPreview(source.type, source.content);
forwardStorageKey = undefined;
forwardMimeType = undefined;
}
const metadata = {
forwardedFrom: {
messageId: source.id,
roomId: source.roomId,
senderId: source.senderId,
senderName: source.sender.displayName,
type: source.type,
content: source.content ?? undefined
}
};
const created = await this.sendMessage(
userId,
targetRoomId,
forwardType,
forwardContent,
undefined,
forwardStorageKey,
forwardMimeType,
JSON.stringify(metadata),
undefined,
false
);
forwarded.push(created);
}
return { messages: forwarded };
}
async reportTyping(userId: string, roomId: string) {
const room = await this.getRoomForMember(roomId, userId);
const user = await this.prisma.user.findUnique({ where: { id: userId } });
const userName = user?.displayName?.trim() || user?.phone || 'Участник';
for (const member of room.members) {
if (member.userId === userId) continue;
await this.notifications.publishRealtime(member.userId, 'chat_typing', room.name, '', {
roomId: room.id,
userId,
userName
});
}
return { success: true };
}
private buildForwardPreview(type: string, content?: string | null) {
switch (type) {
case 'IMAGE':
return '📷 Пересланное фото';
case 'VOICE':
return '🎤 Пересланное голосовое сообщение';
case 'AUDIO':
return '🎵 Пересланное аудио';
case 'FILE':
return content ? `📄 Пересланный файл: ${content}` : '📄 Пересланный файл';
case 'POLL':
return '📊 Пересланный опрос';
default:
return '↪️ Пересланное сообщение';
}
}
async markRoomRead(userId: string, roomId: string, lastMessageId?: string) {
const room = await this.getRoomForMember(roomId, userId);
let readAt = new Date();
@@ -461,6 +866,9 @@ export class ChatService {
groupId: string;
type: string;
name: string;
peerUserId?: string | null;
botUsername?: string | null;
isE2E?: boolean;
hasAvatar: boolean;
updatedAt: Date;
createdById: string | null;
@@ -472,13 +880,22 @@ export class ChatService {
}>;
},
familyRoleByUserId: Map<string, string>,
lastMessage?: Awaited<ReturnType<ChatService['toMessage']>>
lastMessage?: Awaited<ReturnType<ChatService['toMessage']>>,
viewerId?: string
) {
const peerMember =
viewerId && (room.type === 'DIRECT' || room.type === 'BOT' || room.type === 'E2E')
? room.members.find((member) => member.userId !== viewerId)
: undefined;
return {
id: room.id,
groupId: room.groupId,
type: room.type,
name: room.name,
name: peerMember?.user.displayName ?? room.name,
peerUserId: peerMember?.userId ?? (room.peerUserId && room.peerUserId !== viewerId ? room.peerUserId : undefined),
botUsername: room.botUsername ?? undefined,
isE2E: room.isE2E ?? false,
hasAvatar: room.hasAvatar,
createdById: room.createdById ?? undefined,
updatedAt: room.updatedAt.toISOString(),
@@ -505,7 +922,7 @@ export class ChatService {
});
const familyMembers = await this.prisma.familyMember.findMany({ where: { groupId: full!.groupId } });
const familyRoleByUserId = new Map(familyMembers.map((member) => [member.userId, member.role]));
return this.formatRoom(full!, familyRoleByUserId);
return this.formatRoom(full!, familyRoleByUserId, undefined, userId);
}
private async getRoomReadContext(roomId: string): Promise<ReadContextMember[]> {
@@ -548,7 +965,11 @@ export class ChatService {
) {
const sender = await this.prisma.user.findUnique({ where: { id: senderId } });
const preview =
message.type === 'TEXT' || message.type === 'EMOJI'
message.isEncrypted
? '🔒 Зашифрованное сообщение'
: message.type === 'SYSTEM'
? (message.content ?? 'Системное сообщение')
: message.type === 'TEXT' || message.type === 'EMOJI'
? (message.content ?? '')
: message.type === 'POLL'
? '📊 Опрос'
@@ -590,6 +1011,7 @@ export class ChatService {
senderId: string;
type: string;
content: string | null;
isEncrypted?: boolean;
replyToId: string | null;
storageKey: string | null;
mimeType: string | null;
@@ -628,6 +1050,7 @@ export class ChatService {
senderVerificationIcon: message.sender.isVerified ? resolveVerificationIcon(message.sender.verificationIcon) : undefined,
type: message.type,
content: isDeleted ? undefined : message.content ?? undefined,
isEncrypted: Boolean(message.isEncrypted),
replyToId: message.replyToId ?? undefined,
storageKey: isDeleted ? undefined : message.storageKey ?? undefined,
mimeType: isDeleted ? undefined : message.mimeType ?? undefined,
@@ -636,6 +1059,7 @@ export class ChatService {
editedAt: message.editedAt?.toISOString(),
isDeleted,
readByAll: message.senderId === viewerId ? readByAll : undefined,
reactions: isDeleted ? [] : this.buildReactionResponses(message.metadata, viewerId),
poll: !isDeleted && message.poll
? {
id: message.poll.id,
@@ -653,4 +1077,132 @@ export class ChatService {
: undefined
};
}
private buildReactionResponses(metadata: unknown, viewerId: string) {
const meta = metadata as { reactions?: Record<string, string[]> } | null;
const reactions = meta?.reactions;
if (!reactions) return [];
return Object.entries(reactions)
.filter(([, userIds]) => userIds.length > 0)
.map(([emoji, userIds]) => ({
emoji,
count: userIds.length,
reactedByMe: userIds.includes(viewerId)
}));
}
private async findPairRoom(groupId: string, userA: string, userB: string, type: 'DIRECT' | 'BOT' | 'E2E') {
const rooms = await this.prisma.chatRoom.findMany({
where: { groupId, type },
include: { members: true }
});
return rooms.find((room) => {
const ids = new Set(room.members.map((member) => member.userId));
return ids.size === 2 && ids.has(userA) && ids.has(userB);
});
}
private async ensureDirectRoom(groupId: string, userA: string, userB: string, peerName: string) {
const existing = await this.findPairRoom(groupId, userA, userB, 'DIRECT');
if (existing) return existing;
return this.prisma.chatRoom.create({
data: {
groupId,
type: 'DIRECT',
name: peerName,
peerUserId: userB,
isE2E: false,
members: {
create: [{ userId: userA }, { userId: userB }]
}
}
});
}
private async ensureBotRoom(
groupId: string,
humanUserId: string,
botUserId: string,
botUsername: string,
botDisplayName: string
) {
const existing = await this.findPairRoom(groupId, humanUserId, botUserId, 'BOT');
if (existing) {
if (!existing.botUsername) {
await this.prisma.chatRoom.update({
where: { id: existing.id },
data: { botUsername, peerUserId: botUserId, name: botDisplayName }
});
}
return existing;
}
return this.prisma.chatRoom.create({
data: {
groupId,
type: 'BOT',
name: botDisplayName,
peerUserId: botUserId,
botUsername,
isE2E: false,
members: {
create: [{ userId: humanUserId }, { userId: botUserId }]
}
}
});
}
private async getBotRoomPreview(userId: string, botUsername: string) {
const bot = await this.prisma.bot.findFirst({
where: {
OR: [{ username: botUsername }, { username: `${botUsername.replace(/_bot$/i, '')}_bot` }]
},
select: { id: true, name: true }
});
if (!bot) return undefined;
const chat = await this.prisma.botChat.findUnique({
where: { botId_recipientUserId: { botId: bot.id, recipientUserId: userId } }
});
if (!chat) return undefined;
const [lastInbound, lastOutbound] = await Promise.all([
this.prisma.botInboundMessage.findFirst({
where: { botChatId: chat.id },
orderBy: { createdAt: 'desc' }
}),
this.prisma.botMessage.findFirst({
where: { botChatId: chat.id },
orderBy: { createdAt: 'desc' }
})
]);
const candidates = [lastInbound, lastOutbound].filter(Boolean) as Array<{ createdAt: Date; text: string | null }>;
if (!candidates.length) return undefined;
const latest = candidates.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0]!;
return {
id: `bot-preview-${chat.id}`,
roomId: '',
senderId: userId,
senderName: bot.name,
senderHasAvatar: false,
senderIsVerified: true,
senderVerificationIcon: resolveVerificationIcon('bot'),
type: 'TEXT',
content: latest.text ?? '',
isEncrypted: false,
replyToId: undefined,
storageKey: undefined,
mimeType: undefined,
metadataJson: undefined,
createdAt: latest.createdAt.toISOString(),
editedAt: undefined,
isDeleted: false,
readByAll: undefined,
reactions: [],
poll: undefined
};
}
}