diff --git a/backend/src/Contracts/Messaging/Domain/Message.cs b/backend/src/Contracts/Messaging/Domain/Message.cs index d624c46..26775e5 100644 --- a/backend/src/Contracts/Messaging/Domain/Message.cs +++ b/backend/src/Contracts/Messaging/Domain/Message.cs @@ -19,13 +19,16 @@ public abstract class Message : AggregateRoot public bool IsEdited => HasState(MessageState.IsEdited); public bool IsDeleted => HasState(MessageState.IsDeleted); - + protected List _deletedFor = new(); public IReadOnlyCollection DeletedFor => _deletedFor.AsReadOnly(); + protected List _readByUsers = new(); + public IReadOnlyCollection ReadByUsers => _readByUsers.AsReadOnly(); + 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) : base(id) { ChatId = chatId; @@ -42,15 +45,25 @@ public abstract class Message : AggregateRoot public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId); public virtual void Delete() => AddState(MessageState.IsDeleted); - public virtual void Edit(string newContent) - { - Content = newContent; - AddState(MessageState.IsEdited); + public virtual void Edit(string newContent) + { + Content = newContent; + AddState(MessageState.IsEdited); } - public void DeleteForUser(Guid userId) - { - if (!_deletedFor.Exists(x => x.UserId == userId)) - _deletedFor.Add(new DeletedMessage(Id, userId)); + public void DeleteForUser(Guid userId) + { + if (!_deletedFor.Exists(x => x.UserId == 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); } diff --git a/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs b/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs index 4fa6e9a..dd84101 100644 --- a/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs +++ b/backend/src/Modules/Conversations/Application/Messages/GetMessages/GetMessagesQuery.cs @@ -159,7 +159,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List(), sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null), - new List(), // ReadBy not implemented in this detailed view yet + message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(), reactions?.Select(r => { senders.TryGetValue(r.UserId, out var ru); diff --git a/backend/src/Modules/Conversations/Application/Messages/Read/ReadMessagesCommandHandler.cs b/backend/src/Modules/Conversations/Application/Messages/Read/ReadMessagesCommandHandler.cs index 6a72426..283c169 100644 --- a/backend/src/Modules/Conversations/Application/Messages/Read/ReadMessagesCommandHandler.cs +++ b/backend/src/Modules/Conversations/Application/Messages/Read/ReadMessagesCommandHandler.cs @@ -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.Domain; +using Knot.Contracts.Messaging.Application.Abstractions; +using Knot.Shared.Kernel; +using MediatR; namespace Knot.Modules.Conversations.Application.Messages.Read; @@ -11,11 +12,13 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler Handle(ReadMessagesCommand request, CancellationToken cancellationToken) @@ -28,6 +31,23 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler 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 mediaMessage = message as MediaMessage; var storyMessage = message as StoryMessage; @@ -66,7 +67,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List(), 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(), - new List() + message.ReadByUsers.Select(id => new ReadByDto(id)).ToList() ); }).ToList(); diff --git a/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs b/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs index ca1c007..51ff27e 100644 --- a/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs +++ b/backend/src/Modules/Conversations/Application/Messages/Send/SendMessageCommandHandler.cs @@ -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.Domain; using Knot.Contracts.Settings.Application.Abstractions; -using Knot.Contracts.Conversations.Application.Abstractions; -using Knot.Contracts.Conversations.Domain; using Knot.Shared.Kernel; namespace Knot.Modules.Conversations.Application.Messages.Send; @@ -192,6 +192,7 @@ public sealed class SendMessageCommandHandler : ICommandHandler m.UserId == request.SenderId); senderMember.UpdateReadCursor(message.Id, message.SequenceId); senderMember.UpdateDeliveredCursor(message.Id); + message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение // 5. ��������� _messageRepository.Add(message); diff --git a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs index 83fb099..21a99c2 100644 --- a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs +++ b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs @@ -1,26 +1,26 @@ using System.Collections.Concurrent; 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 Microsoft.AspNetCore.Authorization; 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 Knot.Contracts.Auth.Domain; -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; +using Microsoft.Extensions.Logging; namespace Knot.Modules.Conversations.Infrastructure.SignalR; @@ -37,7 +37,7 @@ public sealed class ChatHub : Hub public static int OnlineUsersCount => _userConnections.Count; public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId); - + // userId → CallSession (one user can be in only one call at a time) private static readonly ConcurrentDictionary _activeSessionsByUser = new(); // chatId → (startTime, callType) @@ -53,12 +53,12 @@ public sealed class ChatHub : Hub private readonly IUserDisplayNameProvider _userProvider; public ChatHub( - ISender sender, - IUserContext userContext, - IChatRepository chatRepository, - IUserRepository userRepository, + ISender sender, + IUserContext userContext, + IChatRepository chatRepository, + IUserRepository userRepository, IMessageRepository messageRepository, - ILogger logger, + ILogger logger, IMemoryCache cache, IUserDisplayNameProvider userProvider) { @@ -158,6 +158,7 @@ public sealed class ChatHub : Hub await _sender.Send(command); } + // Отправляем событие всем в чате о том, что пользователь прочитал сообщения await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new { ChatId = request.ChatId.ToString(), @@ -261,7 +262,7 @@ public sealed class ChatHub : Hub { var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId }); var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty(), Enumerable.Empty()); - + await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new { chatId = request.ChatId, @@ -276,7 +277,7 @@ public sealed class ChatHub : Hub { var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId); var result = await _sender.Send(command); - + await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new { chatId = request.ChatId, @@ -328,7 +329,7 @@ public sealed class ChatHub : Hub public async Task FriendAccepted(FriendSignalRequest request) { if (request == null || string.IsNullOrEmpty(request.FriendId)) return; - + _logger.LogInformation("Signaling friend_request_accepted to {FriendId} from {UserId}", request.FriendId, _userContext.UserId); await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId }); } @@ -350,8 +351,8 @@ public sealed class ChatHub : Hub { if (string.IsNullOrEmpty(targetUserId)) { - _logger.LogWarning("SendToUserAsync called with null or empty targetUserId"); - return; + _logger.LogWarning("SendToUserAsync called with null or empty targetUserId"); + return; } if (_userConnections.TryGetValue(targetUserId, out var connectionIds)) @@ -398,15 +399,15 @@ public sealed class ChatHub : Hub // Track session for history Guid? chatId = null; if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId; - + if (!chatId.HasValue) { - var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted); - if (Guid.TryParse(request.TargetUserId, out var targetId)) - { - var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId)); - if (personalChat != null) chatId = personalChat.Id; - } + var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted); + if (Guid.TryParse(request.TargetUserId, out var targetId)) + { + var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId)); + if (personalChat != null) chatId = personalChat.Id; + } } var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow); @@ -461,10 +462,10 @@ public sealed class ChatHub : Hub _activeSessionsByUser.TryRemove(request.TargetUserId, out _); if (session.ChatId.HasValue) { - int duration = session.IsAnswered && session.AnswerTime.HasValue - ? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds + int duration = session.IsAnswered && session.AnswerTime.HasValue + ? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds : 0; - + string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed"); await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration); } @@ -573,7 +574,7 @@ public sealed class ChatHub : Hub var userInfo = new ParticipantInfo(userId, username, displayName, avatar); - var participants = _groupCallParticipants.GetOrAdd(chatId, _ => + var participants = _groupCallParticipants.GetOrAdd(chatId, _ => { _activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType); return new ConcurrentDictionary(); diff --git a/backend/src/Modules/Messaging/Domain/Message.cs b/backend/src/Modules/Messaging/Domain/Message.cs index cde6437..569cc9b 100644 --- a/backend/src/Modules/Messaging/Domain/Message.cs +++ b/backend/src/Modules/Messaging/Domain/Message.cs @@ -18,7 +18,7 @@ public abstract class Message : AggregateRoot public Guid SenderId { get; protected set; } public DateTime CreatedAt { get; protected set; } public long SequenceId { get; protected set; } - + public void SetSequenceId(long sequenceId) { SequenceId = sequenceId; @@ -26,10 +26,10 @@ public abstract class Message : AggregateRoot // ================== Опциональные метаданные (общего назначения) ================== public Guid? ReplyToId { get; protected set; } public Guid? ForwardedFromId { get; protected set; } - + // ================== Флаги ================== public MessageState State { get; protected set; } - + // ================== Абстрактные / Виртуальные свойства ================== public abstract string Type { get; } public abstract string? Content { get; protected set; } @@ -42,16 +42,20 @@ public abstract class Message : AggregateRoot protected List _deletedFor = new(); public IReadOnlyCollection DeletedFor => _deletedFor.AsReadOnly(); + // ================== Прочитано ================== + protected List _readByUsers = new(); + public IReadOnlyCollection ReadByUsers => _readByUsers.AsReadOnly(); + // ================== Инфраструктурный конструктор EF ================== protected Message() : base(Guid.Empty) { } protected Message( - Guid id, - Guid chatId, - Guid senderId, - Guid? replyToId, - Guid? forwardedFromId, - DateTime createdAt, + Guid id, + Guid chatId, + Guid senderId, + Guid? replyToId, + Guid? forwardedFromId, + DateTime createdAt, bool isImported) : base(id) { ChatId = chatId; @@ -59,7 +63,7 @@ public abstract class Message : AggregateRoot ReplyToId = replyToId; ForwardedFromId = forwardedFromId; CreatedAt = createdAt; - + if (isImported) AddState(MessageState.IsImported); } @@ -89,6 +93,16 @@ public abstract class Message : AggregateRoot _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); } diff --git a/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs b/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs index eb19991..d42c0fe 100644 --- a/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs +++ b/backend/src/Modules/Messaging/Infrastructure/Handlers/MessageSentDomainEventHandler.cs @@ -84,7 +84,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler(), sender = senderObj, - readBy = new List(), + readBy = message.ReadByUsers.Select(id => new { id }).ToList(), storyId = (message as StoryMessage)?.StoryId, storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl, storyMediaType = (message as StoryMessage)?.StoryMediaType, diff --git a/client-web/src/modules/chats/application/chatStore.ts b/client-web/src/modules/chats/application/chatStore.ts index f9323bd..d7725d0 100644 --- a/client-web/src/modules/chats/application/chatStore.ts +++ b/client-web/src/modules/chats/application/chatStore.ts @@ -125,9 +125,9 @@ export const useChatStore = create((set, get) => ({ set({ isLoadingMessages: true }); const currentMessages = state.messages[chatId] || []; const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].sequenceId.toString() : undefined; - + const fetched = await ChatApi.getMessages(chatId, cursor); - + set((state) => { // Merge fetched messages with any that arrived via socket const existing = reset ? [] : (state.messages[chatId] || []); @@ -401,6 +401,7 @@ export const useChatStore = create((set, get) => ({ if (m.sequenceId <= lastReadSequenceId) { const alreadyRead = m.readBy?.some((r) => r.userId === userId); if (alreadyRead) return m; + // Увеличиваем счётчик только если текущий пользователь читает чужие сообщения if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++; return { ...m, readBy: [...(m.readBy || []), { userId }] }; } @@ -412,6 +413,7 @@ export const useChatStore = create((set, get) => ({ const updatedChats = state.chats.map((chat) => { if (chat.id === chatId) { const updatedLastMessages = chat.messages?.map(updateMsg); + // Уменьшаем unreadCount только если текущий пользователь прочитал сообщения if (userId === currentUserId) { return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) }; } @@ -475,16 +477,16 @@ export const useChatStore = create((set, get) => ({ addChat: (chat) => { set((state) => { const existing = state.chats.find((c) => c.id === chat.id); - + const messagesFromState = state.messages[chat.id] || []; const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []); - + let unreadCount = chat.unreadCount || 0; if (!existing && messagesFromState.length > 0) { const userId = useAuthStore.getState().user?.id; unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length; } - + const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount }; if (existing) { @@ -524,9 +526,9 @@ export const useChatStore = create((set, get) => ({ const existing = state.pinnedMessages[chatId] || []; if (existing.some(m => m.id === message.id)) return state; return { - pinnedMessages: { - ...state.pinnedMessages, - [chatId]: [...existing, message] + pinnedMessages: { + ...state.pinnedMessages, + [chatId]: [...existing, message] }, }; }); @@ -550,7 +552,7 @@ export const useChatStore = create((set, get) => ({ try { set({ isLoadingMessages: true }); const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50); - + set((state) => ({ messages: { ...state.messages, [chatId]: fetched }, // Since we jumped, we assume there is more history to load above diff --git a/client-web/src/modules/chats/presentation/ChatPage.tsx b/client-web/src/modules/chats/presentation/ChatPage.tsx index 0709008..28205cb 100644 --- a/client-web/src/modules/chats/presentation/ChatPage.tsx +++ b/client-web/src/modules/chats/presentation/ChatPage.tsx @@ -58,7 +58,7 @@ export default function ChatPage() { const [groupCallSessionId, setGroupCallSessionId] = useState(0); const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null); - + const groupCallOpenRef = useRef(false); const groupCallChatIdRef = useRef(''); @@ -180,7 +180,12 @@ export default function ChatPage() { }); 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 }) => { @@ -281,7 +286,7 @@ export default function ChatPage() { callType: data.callType, chatName: chat.name || 'Group', }); - + // Auto-dismiss after 15 seconds if ignored setTimeout(() => { setIncomingGroupCall(prev => { @@ -383,14 +388,14 @@ export default function ChatPage() { {activeTab === 'chats' ? ( <> {/* Chat List (Sidebar) */} -
{/* Selected Chat View (Main Area) */} -
@@ -398,31 +403,31 @@ export default function ChatPage() { ) : activeTab === 'contacts' ? (
- {/* Contacts Sidebar List */} -
- setActiveTab('chats')} /> -
+ {/* Contacts Sidebar List */} +
+ setActiveTab('chats')} /> +
- {/* Right side placeholder / Profile detail */} -
-
-
- -
-
-

{t('contacts')}

-

- {t('selectContactToChat')} -

-
- + {/* Right side placeholder / Profile detail */} +
+
+
+
-
+
+

{t('contacts')}

+

+ {t('selectContactToChat')} +

+
+ +
+
) : activeTab === 'settings' ? ( @@ -432,7 +437,7 @@ export default function ChatPage() {
)} - +
- @@ -499,9 +504,9 @@ export default function ChatPage() { {incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('isCalling' as any) || 'звонит...'}

- {t('groupCall' as any) || 'Групповой звонок'} + {t('groupCall' as any) || 'Групповой звонок'}

- +
@@ -225,20 +226,20 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) { )}

- {isTyping ? t('typing') : draft ? <>{t('draft')} {stripMarkdown(draft)} : previewText} - {chat.isImporting && importStatus && importStatus.total > 0 && ( - - {importStatus.processed} / {importStatus.total} - - )} + {isTyping ? t('typing') : draft ? <>{t('draft')} {stripMarkdown(draft)} : previewText} + {chat.isImporting && importStatus && importStatus.total > 0 && ( + + {importStatus.processed} / {importStatus.total} + + )}

{chat.isImporting && importStatus && importStatus.total > 0 && ( -
-
-
+
+
+
)}
diff --git a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx index 97ae92d..dc23f80 100644 --- a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx @@ -79,7 +79,11 @@ function MessageBubble({ const [quotedText, setQuotedText] = useState(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', { hour: '2-digit', @@ -492,10 +496,10 @@ function MessageBubble({ onDoubleClick={handleReply} 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 - ? 'p-0 shadow-none border-none bg-transparent' - : isMine - ? 'bubble-sent px-4 py-3 hover:brightness-110' - : 'bubble-received px-4 py-3 hover:brightness-110' + ? 'p-0 shadow-none border-none bg-transparent' + : isMine + ? 'bubble-sent px-4 py-3 hover:brightness-110' + : 'bubble-received px-4 py-3 hover:brightness-110' }`} > @@ -1068,8 +1072,8 @@ function MessageBubble({ key={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 - ? 'bg-primary/20 border-primary text-white shadow-lg' - : 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]' + ? 'bg-primary/20 border-primary text-white shadow-lg' + : 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]' } shadow-md group/react`} title={data.users.join(', ')} >