Исправлен механизм прочтения сообщений

This commit is contained in:
Халимов Рустам
2026-04-20 21:44:39 +03:00
parent 0f593e52e0
commit 83ed328dd5
12 changed files with 224 additions and 162 deletions
@@ -23,6 +23,9 @@ public abstract class Message : AggregateRoot<Guid>
protected List<DeletedMessage> _deletedFor = new(); protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly(); public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
protected Message() : base(Guid.Empty) { } protected Message() : base(Guid.Empty) { }
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
@@ -53,4 +56,14 @@ public abstract class Message : AggregateRoot<Guid>
if (!_deletedFor.Exists(x => x.UserId == userId)) if (!_deletedFor.Exists(x => x.UserId == userId))
_deletedFor.Add(new DeletedMessage(Id, userId)); _deletedFor.Add(new DeletedMessage(Id, userId));
} }
public void MarkAsRead(Guid userId)
{
if (!_readByUsers.Contains(userId))
{
_readByUsers.Add(userId);
}
}
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
} }
@@ -159,7 +159,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
(message as StoryMessage)?.StoryMediaType, (message as StoryMessage)?.StoryMediaType,
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(), (message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null), sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(),
reactions?.Select(r => reactions?.Select(r =>
{ {
senders.TryGetValue(r.UserId, out var ru); senders.TryGetValue(r.UserId, out var ru);
@@ -1,7 +1,8 @@
using MediatR;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.Read; namespace Knot.Modules.Conversations.Application.Messages.Read;
@@ -11,11 +12,13 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
{ {
private readonly IChatRepository _chatRepository; private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork; private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMessageRepository _messageRepository;
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork) public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository)
{ {
_chatRepository = chatRepository; _chatRepository = chatRepository;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_messageRepository = messageRepository;
} }
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken) public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
@@ -28,6 +31,23 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId); member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
// Обновляем ReadByUsers для всех сообщений до LastReadSequenceId
var messages = await _messageRepository.GetChatMessagesAfterAsync(
request.ChatId,
0,
1000,
cancellationToken);
foreach (var message in messages)
{
if (message.SequenceId <= request.LastReadSequenceId &&
message.SenderId != request.UserId &&
!message.IsReadBy(request.UserId))
{
message.MarkAsRead(request.UserId);
}
}
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(); return Result.Success();
@@ -3,10 +3,10 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using MediatR; using MediatR;
@@ -41,7 +41,8 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken); var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList()); var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
var result = messages.Select(message => { var result = messages.Select(message =>
{
var textMessage = message as TextMessage; var textMessage = message as TextMessage;
var mediaMessage = message as MediaMessage; var mediaMessage = message as MediaMessage;
var storyMessage = message as StoryMessage; var storyMessage = message as StoryMessage;
@@ -66,7 +67,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(), mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null), senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(), reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
new List<ReadByDto>() message.ReadByUsers.Select(id => new ReadByDto(id)).ToList()
); );
}).ToList(); }).ToList();
@@ -1,8 +1,8 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Messages.Send; namespace Knot.Modules.Conversations.Application.Messages.Send;
@@ -192,6 +192,7 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
var senderMember = chat.Members.First(m => m.UserId == request.SenderId); var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
senderMember.UpdateReadCursor(message.Id, message.SequenceId); senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id); senderMember.UpdateDeliveredCursor(message.Id);
message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение
// 5. // 5.
_messageRepository.Add(message); _messageRepository.Add(message);
@@ -1,26 +1,26 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Security.Claims; using System.Security.Claims;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.Edit;
using Knot.Modules.Conversations.Application.Messages.Pin;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Unpin;
using Knot.Modules.Conversations.Application.Messages.Vote;
using Knot.Shared.Kernel;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Knot.Contracts.Auth.Domain; using Microsoft.Extensions.Logging;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Modules.Conversations.Application.Messages.Pin;
using Knot.Modules.Conversations.Application.Messages.Unpin;
using Knot.Modules.Conversations.Application.Messages.Vote;
using Knot.Modules.Conversations.Application.Messages.Edit;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Infrastructure.SignalR; namespace Knot.Modules.Conversations.Infrastructure.SignalR;
@@ -158,6 +158,7 @@ public sealed class ChatHub : Hub
await _sender.Send(command); await _sender.Send(command);
} }
// Отправляем событие всем в чате о том, что пользователь прочитал сообщения
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
{ {
ChatId = request.ChatId.ToString(), ChatId = request.ChatId.ToString(),
@@ -350,8 +351,8 @@ public sealed class ChatHub : Hub
{ {
if (string.IsNullOrEmpty(targetUserId)) if (string.IsNullOrEmpty(targetUserId))
{ {
_logger.LogWarning("SendToUserAsync called with null or empty targetUserId"); _logger.LogWarning("SendToUserAsync called with null or empty targetUserId");
return; return;
} }
if (_userConnections.TryGetValue(targetUserId, out var connectionIds)) if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
@@ -401,12 +402,12 @@ public sealed class ChatHub : Hub
if (!chatId.HasValue) if (!chatId.HasValue)
{ {
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted); var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
if (Guid.TryParse(request.TargetUserId, out var targetId)) if (Guid.TryParse(request.TargetUserId, out var targetId))
{ {
var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId)); var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId));
if (personalChat != null) chatId = personalChat.Id; if (personalChat != null) chatId = personalChat.Id;
} }
} }
var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow); var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow);
@@ -42,6 +42,10 @@ public abstract class Message : AggregateRoot<Guid>
protected List<DeletedMessage> _deletedFor = new(); protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly(); public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
// ================== Прочитано ==================
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
// ================== Инфраструктурный конструктор EF ================== // ================== Инфраструктурный конструктор EF ==================
protected Message() : base(Guid.Empty) { } protected Message() : base(Guid.Empty) { }
@@ -89,6 +93,16 @@ public abstract class Message : AggregateRoot<Guid>
_deletedFor.Add(new DeletedMessage(Id, userId)); _deletedFor.Add(new DeletedMessage(Id, userId));
} }
} }
public void MarkAsRead(Guid userId)
{
if (!_readByUsers.Contains(userId))
{
_readByUsers.Add(userId);
}
}
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
} }
@@ -84,7 +84,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
size = m.Size size = m.Size
}).ToList() ?? (object)Array.Empty<object>(), }).ToList() ?? (object)Array.Empty<object>(),
sender = senderObj, sender = senderObj,
readBy = new List<object>(), readBy = message.ReadByUsers.Select(id => new { id }).ToList(),
storyId = (message as StoryMessage)?.StoryId, storyId = (message as StoryMessage)?.StoryId,
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl, storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
storyMediaType = (message as StoryMessage)?.StoryMediaType, storyMediaType = (message as StoryMessage)?.StoryMediaType,
@@ -401,6 +401,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (m.sequenceId <= lastReadSequenceId) { if (m.sequenceId <= lastReadSequenceId) {
const alreadyRead = m.readBy?.some((r) => r.userId === userId); const alreadyRead = m.readBy?.some((r) => r.userId === userId);
if (alreadyRead) return m; if (alreadyRead) return m;
// Увеличиваем счётчик только если текущий пользователь читает чужие сообщения
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++; if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
return { ...m, readBy: [...(m.readBy || []), { userId }] }; return { ...m, readBy: [...(m.readBy || []), { userId }] };
} }
@@ -412,6 +413,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
const updatedChats = state.chats.map((chat) => { const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) { if (chat.id === chatId) {
const updatedLastMessages = chat.messages?.map(updateMsg); const updatedLastMessages = chat.messages?.map(updateMsg);
// Уменьшаем unreadCount только если текущий пользователь прочитал сообщения
if (userId === currentUserId) { if (userId === currentUserId) {
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) }; return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
} }
@@ -180,7 +180,12 @@ export default function ChatPage() {
}); });
socket.on('messages_read', (data: any) => { socket.on('messages_read', (data: any) => {
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.lastReadSequenceId || data.LastReadSequenceId || 0); const chatId = data.chatId || data.ChatId;
const userId = data.userId || data.UserId;
const lastReadSequenceId = data.lastReadSequenceId || data.LastReadSequenceId || 0;
// Обновляем стейт - добавляем userId в readBy для всех сообщений до lastReadSequenceId
markRead(chatId, userId, lastReadSequenceId);
}); });
socket.on('user_typing', (data: { chatId: string; userId: string }) => { socket.on('user_typing', (data: { chatId: string; userId: string }) => {
@@ -398,31 +403,31 @@ export default function ChatPage() {
</> </>
) : activeTab === 'contacts' ? ( ) : activeTab === 'contacts' ? (
<div className="flex-1 flex flex-row h-full overflow-hidden"> <div className="flex-1 flex flex-row h-full overflow-hidden">
{/* Contacts Sidebar List */} {/* Contacts Sidebar List */}
<div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased"> <div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased">
<ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} /> <ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} />
</div> </div>
{/* Right side placeholder / Profile detail */} {/* Right side placeholder / Profile detail */}
<div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base h-full relative slide-on-ice"> <div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base h-full relative slide-on-ice">
<div className="flex flex-col items-center gap-6 max-w-sm text-center"> <div className="flex flex-col items-center gap-6 max-w-sm text-center">
<div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner"> <div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
<Users size={48} className="knot-logo-spin opacity-50" /> <Users size={48} className="knot-logo-spin opacity-50" />
</div>
<div>
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
{t('selectContactToChat')}
</p>
</div>
<button
onClick={() => setActiveTab('chats')}
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
>
{t('backToChats')}
</button>
</div> </div>
</div> <div>
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
{t('selectContactToChat')}
</p>
</div>
<button
onClick={() => setActiveTab('chats')}
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
>
{t('backToChats')}
</button>
</div>
</div>
</div> </div>
) : activeTab === 'settings' ? ( ) : activeTab === 'settings' ? (
<SettingsPage /> <SettingsPage />
@@ -63,14 +63,14 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
: lastMessage.media?.[0]?.type === 'video' : lastMessage.media?.[0]?.type === 'video'
? t('video') ? t('video')
: t('file') : t('file')
: lastMessage.type === 'call' : lastMessage.type === 'call'
? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t( ? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t(
lastMessage.callStatus === 'missed' ? 'missedCall' : lastMessage.callStatus === 'missed' ? 'missedCall' :
lastMessage.callStatus === 'declined' ? 'declinedCall' : lastMessage.callStatus === 'declined' ? 'declinedCall' :
lastMessage.callStatus === 'cancelled' ? 'cancelledCall' : lastMessage.callStatus === 'cancelled' ? 'cancelledCall' :
'completedCall' 'completedCall'
)}` )}`
: lastMessage.content || '' : lastMessage.content || ''
: ''; : '';
const previewText = chat.isImporting ? 'Импорт...' : stripMarkdown(lastMessageText); const previewText = chat.isImporting ? 'Импорт...' : stripMarkdown(lastMessageText);
@@ -78,7 +78,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
const isMine = !chat.isImporting && lastMessage?.senderId === user?.id; const isMine = !chat.isImporting && lastMessage?.senderId === user?.id;
// Галочки прочтения // Галочки прочтения
const isRead = !chat.isImporting && lastMessage?.readBy?.some((r) => r.userId !== user?.id); // Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели)
// Для чужих сообщений: не показываем галочки
const isRead = !chat.isImporting && isMine && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
const timeStr = !chat.isImporting && lastMessage const timeStr = !chat.isImporting && lastMessage
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS }) ? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
@@ -89,25 +91,25 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
useEffect(() => { useEffect(() => {
if (!chat.isImporting || !chat.importJobId) { if (!chat.isImporting || !chat.importJobId) {
setImportStatus(null); setImportStatus(null);
return; return;
} }
const poll = async () => { const poll = async () => {
try { try {
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`); const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
setImportStatus({ processed: data.processedMessages, total: data.totalMessages }); setImportStatus({ processed: data.processedMessages, total: data.totalMessages });
if (data.status === 'Completed' || data.status === 'Failed') { if (data.status === 'Completed' || data.status === 'Failed') {
loadChats(); loadChats();
}
} catch (e: any) {
if (e.status === 404) {
// Job might have expired or backend restarted
console.warn('Import job not found');
} else {
console.error('Failed to poll status', e);
}
} }
} catch (e: any) {
if (e.status === 404) {
// Job might have expired or backend restarted
console.warn('Import job not found');
} else {
console.error('Failed to poll status', e);
}
}
}; };
poll(); poll();
@@ -117,9 +119,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
const handleClick = () => { const handleClick = () => {
if (chat.isImporting) { if (chat.isImporting) {
// Here we could show a progress modal, but for now just select it // Here we could show a progress modal, but for now just select it
setActiveChat(chat.id); setActiveChat(chat.id);
return; return;
} }
if ((window as any).hasUnsavedAttachments && !isActive) { if ((window as any).hasUnsavedAttachments && !isActive) {
setShowAttachmentConfirm(true); setShowAttachmentConfirm(true);
@@ -188,9 +190,8 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
<button <button
onClick={handleClick} onClick={handleClick}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${ className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20' }`}
}`}
> >
{/* Аватар */} {/* Аватар */}
<div className="relative flex-shrink-0"> <div className="relative flex-shrink-0">
@@ -200,7 +201,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
</div> </div>
) : ( ) : (
<div className="avatar-knot-container group-hover:scale-105 transition-transform"> <div className="avatar-knot-container group-hover:scale-105 transition-transform">
<Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} /> <Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} />
</div> </div>
)} )}
</div> </div>
@@ -225,20 +226,20 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
)} )}
<div className="flex flex-col gap-1 w-full min-w-0"> <div className="flex flex-col gap-1 w-full min-w-0">
<p className={`text-[13px] truncate leading-tight ${isTyping ? 'text-tertiary font-bold' : draft ? 'text-error font-medium' : 'text-on-surface-variant/60'}`}> <p className={`text-[13px] truncate leading-tight ${isTyping ? 'text-tertiary font-bold' : draft ? 'text-error font-medium' : 'text-on-surface-variant/60'}`}>
{isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText} {isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
{chat.isImporting && importStatus && importStatus.total > 0 && ( {chat.isImporting && importStatus && importStatus.total > 0 && (
<span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums"> <span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums">
{importStatus.processed} / {importStatus.total} {importStatus.processed} / {importStatus.total}
</span> </span>
)} )}
</p> </p>
{chat.isImporting && importStatus && importStatus.total > 0 && ( {chat.isImporting && importStatus && importStatus.total > 0 && (
<div className="w-full h-1 bg-surface-container-highest rounded-full overflow-hidden mt-0.5"> <div className="w-full h-1 bg-surface-container-highest rounded-full overflow-hidden mt-0.5">
<div <div
className="h-full bg-primary transition-all duration-500 ease-out" className="h-full bg-primary transition-all duration-500 ease-out"
style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }} style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }}
/> />
</div> </div>
)} )}
</div> </div>
</div> </div>
@@ -79,7 +79,11 @@ function MessageBubble({
const [quotedText, setQuotedText] = useState<string | null>(null); const [quotedText, setQuotedText] = useState<string | null>(null);
// Прочитано // Прочитано
const isRead = message.readBy?.some((r) => r.userId !== user?.id); // Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели)
// Для чужих сообщений: проверено, есть ли в readBy текущий пользователь
const isRead = isMine
? message.readBy?.some((r) => r.userId !== user?.id) // Кто-то кроме меня прочитал
: message.readBy?.some((r) => r.userId === user?.id); // Я прочитал
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', { const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
hour: '2-digit', hour: '2-digit',
@@ -492,10 +496,10 @@ function MessageBubble({
onDoubleClick={handleReply} onDoubleClick={handleReply}
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'} title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
className={`cursor-pointer max-w-full min-w-[60px] transition-all duration-500 overflow-hidden ${!needsFrame className={`cursor-pointer max-w-full min-w-[60px] transition-all duration-500 overflow-hidden ${!needsFrame
? 'p-0 shadow-none border-none bg-transparent' ? 'p-0 shadow-none border-none bg-transparent'
: isMine : isMine
? 'bubble-sent px-4 py-3 hover:brightness-110' ? 'bubble-sent px-4 py-3 hover:brightness-110'
: 'bubble-received px-4 py-3 hover:brightness-110' : 'bubble-received px-4 py-3 hover:brightness-110'
}`} }`}
> >
@@ -1068,8 +1072,8 @@ function MessageBubble({
key={emoji} key={emoji}
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }} onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-[10px] transition-all border ${data.isMine className={`flex items-center gap-2 px-2.5 py-1.5 rounded-[10px] transition-all border ${data.isMine
? 'bg-primary/20 border-primary text-white shadow-lg' ? 'bg-primary/20 border-primary text-white shadow-lg'
: 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]' : 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]'
} shadow-md group/react`} } shadow-md group/react`}
title={data.users.join(', ')} title={data.users.join(', ')}
> >