Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc1cf1fd6e | ||
|
|
83ed328dd5 | ||
|
|
0f593e52e0 |
@@ -19,13 +19,16 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
|
|
||||||
public bool IsEdited => HasState(MessageState.IsEdited);
|
public bool IsEdited => HasState(MessageState.IsEdited);
|
||||||
public bool IsDeleted => HasState(MessageState.IsDeleted);
|
public bool IsDeleted => HasState(MessageState.IsDeleted);
|
||||||
|
|
||||||
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)
|
||||||
: base(id)
|
: base(id)
|
||||||
{
|
{
|
||||||
ChatId = chatId;
|
ChatId = chatId;
|
||||||
@@ -42,15 +45,25 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
|
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
|
||||||
|
|
||||||
public virtual void Delete() => AddState(MessageState.IsDeleted);
|
public virtual void Delete() => AddState(MessageState.IsDeleted);
|
||||||
public virtual void Edit(string newContent)
|
public virtual void Edit(string newContent)
|
||||||
{
|
{
|
||||||
Content = newContent;
|
Content = newContent;
|
||||||
AddState(MessageState.IsEdited);
|
AddState(MessageState.IsEdited);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DeleteForUser(Guid userId)
|
public void DeleteForUser(Guid userId)
|
||||||
{
|
{
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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);
|
||||||
|
|||||||
+24
-4
@@ -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();
|
||||||
|
|||||||
+4
-3
@@ -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();
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -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;
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ public sealed class ChatHub : Hub
|
|||||||
|
|
||||||
public static int OnlineUsersCount => _userConnections.Count;
|
public static int OnlineUsersCount => _userConnections.Count;
|
||||||
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
|
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
|
||||||
|
|
||||||
// userId → CallSession (one user can be in only one call at a time)
|
// userId → CallSession (one user can be in only one call at a time)
|
||||||
private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new();
|
private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new();
|
||||||
// chatId → (startTime, callType)
|
// chatId → (startTime, callType)
|
||||||
@@ -53,12 +53,12 @@ public sealed class ChatHub : Hub
|
|||||||
private readonly IUserDisplayNameProvider _userProvider;
|
private readonly IUserDisplayNameProvider _userProvider;
|
||||||
|
|
||||||
public ChatHub(
|
public ChatHub(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
IUserContext userContext,
|
IUserContext userContext,
|
||||||
IChatRepository chatRepository,
|
IChatRepository chatRepository,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
IMessageRepository messageRepository,
|
IMessageRepository messageRepository,
|
||||||
ILogger<ChatHub> logger,
|
ILogger<ChatHub> logger,
|
||||||
IMemoryCache cache,
|
IMemoryCache cache,
|
||||||
IUserDisplayNameProvider userProvider)
|
IUserDisplayNameProvider userProvider)
|
||||||
{
|
{
|
||||||
@@ -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(),
|
||||||
@@ -261,7 +262,7 @@ public sealed class ChatHub : Hub
|
|||||||
{
|
{
|
||||||
var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId });
|
var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId });
|
||||||
var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>());
|
var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>());
|
||||||
|
|
||||||
await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new
|
await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new
|
||||||
{
|
{
|
||||||
chatId = request.ChatId,
|
chatId = request.ChatId,
|
||||||
@@ -276,7 +277,7 @@ public sealed class ChatHub : Hub
|
|||||||
{
|
{
|
||||||
var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
|
var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
|
||||||
var result = await _sender.Send(command);
|
var result = await _sender.Send(command);
|
||||||
|
|
||||||
await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new
|
await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new
|
||||||
{
|
{
|
||||||
chatId = request.ChatId,
|
chatId = request.ChatId,
|
||||||
@@ -328,7 +329,7 @@ public sealed class ChatHub : Hub
|
|||||||
public async Task FriendAccepted(FriendSignalRequest request)
|
public async Task FriendAccepted(FriendSignalRequest request)
|
||||||
{
|
{
|
||||||
if (request == null || string.IsNullOrEmpty(request.FriendId)) return;
|
if (request == null || string.IsNullOrEmpty(request.FriendId)) return;
|
||||||
|
|
||||||
_logger.LogInformation("Signaling friend_request_accepted to {FriendId} from {UserId}", request.FriendId, _userContext.UserId);
|
_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 });
|
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
|
||||||
}
|
}
|
||||||
@@ -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))
|
||||||
@@ -398,15 +399,15 @@ public sealed class ChatHub : Hub
|
|||||||
// Track session for history
|
// Track session for history
|
||||||
Guid? chatId = null;
|
Guid? chatId = null;
|
||||||
if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId;
|
if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId;
|
||||||
|
|
||||||
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);
|
||||||
@@ -461,10 +462,10 @@ public sealed class ChatHub : Hub
|
|||||||
_activeSessionsByUser.TryRemove(request.TargetUserId, out _);
|
_activeSessionsByUser.TryRemove(request.TargetUserId, out _);
|
||||||
if (session.ChatId.HasValue)
|
if (session.ChatId.HasValue)
|
||||||
{
|
{
|
||||||
int duration = session.IsAnswered && session.AnswerTime.HasValue
|
int duration = session.IsAnswered && session.AnswerTime.HasValue
|
||||||
? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds
|
? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed");
|
string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed");
|
||||||
await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration);
|
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 userInfo = new ParticipantInfo(userId, username, displayName, avatar);
|
||||||
|
|
||||||
var participants = _groupCallParticipants.GetOrAdd(chatId, _ =>
|
var participants = _groupCallParticipants.GetOrAdd(chatId, _ =>
|
||||||
{
|
{
|
||||||
_activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType);
|
_activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType);
|
||||||
return new ConcurrentDictionary<string, ParticipantInfo>();
|
return new ConcurrentDictionary<string, ParticipantInfo>();
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
public Guid SenderId { get; protected set; }
|
public Guid SenderId { get; protected set; }
|
||||||
public DateTime CreatedAt { get; protected set; }
|
public DateTime CreatedAt { get; protected set; }
|
||||||
public long SequenceId { get; protected set; }
|
public long SequenceId { get; protected set; }
|
||||||
|
|
||||||
public void SetSequenceId(long sequenceId)
|
public void SetSequenceId(long sequenceId)
|
||||||
{
|
{
|
||||||
SequenceId = sequenceId;
|
SequenceId = sequenceId;
|
||||||
@@ -26,10 +26,10 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
// ================== Опциональные метаданные (общего назначения) ==================
|
// ================== Опциональные метаданные (общего назначения) ==================
|
||||||
public Guid? ReplyToId { get; protected set; }
|
public Guid? ReplyToId { get; protected set; }
|
||||||
public Guid? ForwardedFromId { get; protected set; }
|
public Guid? ForwardedFromId { get; protected set; }
|
||||||
|
|
||||||
// ================== Флаги ==================
|
// ================== Флаги ==================
|
||||||
public MessageState State { get; protected set; }
|
public MessageState State { get; protected set; }
|
||||||
|
|
||||||
// ================== Абстрактные / Виртуальные свойства ==================
|
// ================== Абстрактные / Виртуальные свойства ==================
|
||||||
public abstract string Type { get; }
|
public abstract string Type { get; }
|
||||||
public abstract string? Content { get; protected set; }
|
public abstract string? Content { get; protected set; }
|
||||||
@@ -42,16 +42,20 @@ 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) { }
|
||||||
|
|
||||||
protected Message(
|
protected Message(
|
||||||
Guid id,
|
Guid id,
|
||||||
Guid chatId,
|
Guid chatId,
|
||||||
Guid senderId,
|
Guid senderId,
|
||||||
Guid? replyToId,
|
Guid? replyToId,
|
||||||
Guid? forwardedFromId,
|
Guid? forwardedFromId,
|
||||||
DateTime createdAt,
|
DateTime createdAt,
|
||||||
bool isImported) : base(id)
|
bool isImported) : base(id)
|
||||||
{
|
{
|
||||||
ChatId = chatId;
|
ChatId = chatId;
|
||||||
@@ -59,7 +63,7 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
ReplyToId = replyToId;
|
ReplyToId = replyToId;
|
||||||
ForwardedFromId = forwardedFromId;
|
ForwardedFromId = forwardedFromId;
|
||||||
CreatedAt = createdAt;
|
CreatedAt = createdAt;
|
||||||
|
|
||||||
if (isImported) AddState(MessageState.IsImported);
|
if (isImported) AddState(MessageState.IsImported);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -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,
|
||||||
|
|||||||
@@ -125,9 +125,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
set({ isLoadingMessages: true });
|
set({ isLoadingMessages: true });
|
||||||
const currentMessages = state.messages[chatId] || [];
|
const currentMessages = state.messages[chatId] || [];
|
||||||
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].sequenceId.toString() : undefined;
|
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].sequenceId.toString() : undefined;
|
||||||
|
|
||||||
const fetched = await ChatApi.getMessages(chatId, cursor);
|
const fetched = await ChatApi.getMessages(chatId, cursor);
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
// Merge fetched messages with any that arrived via socket
|
// Merge fetched messages with any that arrived via socket
|
||||||
const existing = reset ? [] : (state.messages[chatId] || []);
|
const existing = reset ? [] : (state.messages[chatId] || []);
|
||||||
@@ -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) };
|
||||||
}
|
}
|
||||||
@@ -475,16 +477,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
addChat: (chat) => {
|
addChat: (chat) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const existing = state.chats.find((c) => c.id === chat.id);
|
const existing = state.chats.find((c) => c.id === chat.id);
|
||||||
|
|
||||||
const messagesFromState = state.messages[chat.id] || [];
|
const messagesFromState = state.messages[chat.id] || [];
|
||||||
const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []);
|
const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []);
|
||||||
|
|
||||||
let unreadCount = chat.unreadCount || 0;
|
let unreadCount = chat.unreadCount || 0;
|
||||||
if (!existing && messagesFromState.length > 0) {
|
if (!existing && messagesFromState.length > 0) {
|
||||||
const userId = useAuthStore.getState().user?.id;
|
const userId = useAuthStore.getState().user?.id;
|
||||||
unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length;
|
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 };
|
const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount };
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -524,9 +526,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const existing = state.pinnedMessages[chatId] || [];
|
const existing = state.pinnedMessages[chatId] || [];
|
||||||
if (existing.some(m => m.id === message.id)) return state;
|
if (existing.some(m => m.id === message.id)) return state;
|
||||||
return {
|
return {
|
||||||
pinnedMessages: {
|
pinnedMessages: {
|
||||||
...state.pinnedMessages,
|
...state.pinnedMessages,
|
||||||
[chatId]: [...existing, message]
|
[chatId]: [...existing, message]
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -550,7 +552,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
set({ isLoadingMessages: true });
|
set({ isLoadingMessages: true });
|
||||||
const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50);
|
const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50);
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messages: { ...state.messages, [chatId]: fetched },
|
messages: { ...state.messages, [chatId]: fetched },
|
||||||
// Since we jumped, we assume there is more history to load above
|
// Since we jumped, we assume there is more history to load above
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function ChatPage() {
|
|||||||
const [groupCallSessionId, setGroupCallSessionId] = useState(0);
|
const [groupCallSessionId, setGroupCallSessionId] = useState(0);
|
||||||
|
|
||||||
const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null);
|
const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null);
|
||||||
|
|
||||||
const groupCallOpenRef = useRef(false);
|
const groupCallOpenRef = useRef(false);
|
||||||
const groupCallChatIdRef = useRef('');
|
const groupCallChatIdRef = useRef('');
|
||||||
|
|
||||||
@@ -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 }) => {
|
||||||
@@ -281,7 +286,7 @@ export default function ChatPage() {
|
|||||||
callType: data.callType,
|
callType: data.callType,
|
||||||
chatName: chat.name || 'Group',
|
chatName: chat.name || 'Group',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-dismiss after 15 seconds if ignored
|
// Auto-dismiss after 15 seconds if ignored
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIncomingGroupCall(prev => {
|
setIncomingGroupCall(prev => {
|
||||||
@@ -383,14 +388,14 @@ export default function ChatPage() {
|
|||||||
{activeTab === 'chats' ? (
|
{activeTab === 'chats' ? (
|
||||||
<>
|
<>
|
||||||
{/* Chat List (Sidebar) */}
|
{/* Chat List (Sidebar) */}
|
||||||
<div
|
<div
|
||||||
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`}
|
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`}
|
||||||
>
|
>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected Chat View (Main Area) */}
|
{/* Selected Chat View (Main Area) */}
|
||||||
<div
|
<div
|
||||||
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest relative group slide-on-ice`}
|
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest relative group slide-on-ice`}
|
||||||
>
|
>
|
||||||
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
||||||
@@ -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 />
|
||||||
@@ -432,7 +437,7 @@ export default function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
<CallModal
|
<CallModal
|
||||||
key={callSessionId}
|
key={callSessionId}
|
||||||
@@ -485,9 +490,9 @@ export default function ChatPage() {
|
|||||||
>
|
>
|
||||||
<div className="relative mb-6">
|
<div className="relative mb-6">
|
||||||
<div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/20 animate-call-wave" />
|
<div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/20 animate-call-wave" />
|
||||||
<Avatar
|
<Avatar
|
||||||
src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null}
|
src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null}
|
||||||
name={incomingGroupCall.chatName || '?'}
|
name={incomingGroupCall.chatName || '?'}
|
||||||
size="2xl"
|
size="2xl"
|
||||||
className="relative shadow-2xl"
|
className="relative shadow-2xl"
|
||||||
/>
|
/>
|
||||||
@@ -499,9 +504,9 @@ export default function ChatPage() {
|
|||||||
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('isCalling' as any) || 'звонит...'}
|
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('isCalling' as any) || 'звонит...'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
|
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
|
||||||
{t('groupCall' as any) || 'Групповой звонок'}
|
{t('groupCall' as any) || 'Групповой звонок'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-8 w-full justify-center">
|
<div className="flex items-center gap-8 w-full justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -131,7 +133,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
const proceedWithClick = () => {
|
const proceedWithClick = () => {
|
||||||
setShowAttachmentConfirm(false);
|
setShowAttachmentConfirm(false);
|
||||||
(window as any).hasUnsavedAttachments = false;
|
(window as any).hasUnsavedAttachments = false;
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
window.dispatchEvent(new CustomEvent('CHAT_SCROLL_TO_BOTTOM', { detail: { chatId: chat.id } }));
|
window.dispatchEvent(new CustomEvent('CHAT_SCROLL_TO_BOTTOM', { detail: { chatId: chat.id } }));
|
||||||
} else {
|
} else {
|
||||||
@@ -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(', ')}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user