Compare commits
15 Commits
android_v2
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
67d5764f6e | ||
|
|
786eaffb33 | ||
|
|
4940f1212f | ||
|
|
c166f1d186 | ||
|
|
71b3d2b491 | ||
|
|
a5d40f28c8 | ||
|
|
9bfc5555bc | ||
|
|
7eea8ff6d1 | ||
|
|
72325f48e5 | ||
|
|
2b51375fbf | ||
|
|
c92289f074 | ||
|
|
33ea792941 | ||
|
|
63fc0e197b | ||
|
|
83ed328dd5 | ||
|
|
0f593e52e0 |
@@ -13,6 +13,7 @@ public interface IMessageRepository
|
||||
|
||||
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken);
|
||||
Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
||||
Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
||||
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
||||
|
||||
@@ -19,13 +19,16 @@ public abstract class Message : AggregateRoot<Guid>
|
||||
|
||||
public bool IsEdited => HasState(MessageState.IsEdited);
|
||||
public bool IsDeleted => HasState(MessageState.IsDeleted);
|
||||
|
||||
|
||||
protected List<DeletedMessage> _deletedFor = new();
|
||||
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
||||
|
||||
protected List<Guid> _readByUsers = new();
|
||||
public IReadOnlyCollection<Guid> 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<Guid>
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using MediatR;
|
||||
using System.Linq;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Shared.Kernel.Storage;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
|
||||
|
||||
@@ -19,17 +21,21 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly IFileStorageService _fileStorage;
|
||||
private readonly IChatsUnitOfWork _uow;
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
|
||||
public LeaveOrDeleteChatCommandHandler(
|
||||
IChatRepository chatRepository,
|
||||
IChatRepository chatRepository,
|
||||
|
||||
IMessageRepository messageRepository,
|
||||
IFileStorageService fileStorage,
|
||||
IChatsUnitOfWork uow)
|
||||
IChatsUnitOfWork uow,
|
||||
IHubContext<ChatHub> hubContext)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_messageRepository = messageRepository;
|
||||
_fileStorage = fileStorage;
|
||||
_uow = uow;
|
||||
_hubContext = hubContext;
|
||||
}
|
||||
|
||||
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
|
||||
@@ -58,6 +64,14 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
|
||||
// DELETE ALL MESSAGES AND FILES FIRST
|
||||
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
|
||||
_chatRepository.Remove(chat);
|
||||
|
||||
// Notify all remaining members that the chat was deleted
|
||||
|
||||
foreach (var member in chat.Members)
|
||||
{
|
||||
await _hubContext.Clients.User(member.UserId.ToString())
|
||||
.SendAsync("chat_deleted", chat.Id.ToString(), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
await _uow.SaveChangesAsync(cancellationToken);
|
||||
@@ -67,7 +81,8 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
|
||||
|
||||
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
|
||||
{
|
||||
try
|
||||
try
|
||||
|
||||
{
|
||||
// Get all messages directly from Mongo (not paged)
|
||||
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
|
||||
|
||||
@@ -13,7 +13,7 @@ using MediatR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
|
||||
|
||||
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
||||
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, long? AfterSequenceId = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
||||
|
||||
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
||||
{
|
||||
@@ -41,7 +41,12 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
List<Message> messages;
|
||||
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
|
||||
|
||||
if (request.Pivot.HasValue)
|
||||
if (request.AfterSequenceId.HasValue)
|
||||
{
|
||||
// Получаем только сообщения ПОСЛЕ указанного sequenceId (для синхронизации)
|
||||
messages = await _messageRepository.GetChatMessagesAfterAsync(request.ChatId, request.AfterSequenceId.Value, queryLimit, cancellationToken);
|
||||
}
|
||||
else if (request.Pivot.HasValue)
|
||||
{
|
||||
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
|
||||
}
|
||||
@@ -154,7 +159,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
(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>(),
|
||||
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 =>
|
||||
{
|
||||
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.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<ReadMessagesCom
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
|
||||
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
|
||||
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_messageRepository = messageRepository;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// Обновляем 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);
|
||||
|
||||
return Result.Success();
|
||||
|
||||
@@ -3,10 +3,10 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
@@ -41,7 +41,8 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
||||
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
|
||||
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 mediaMessage = message as MediaMessage;
|
||||
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>(),
|
||||
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>(),
|
||||
new List<ReadByDto>()
|
||||
message.ReadByUsers.Select(id => new ReadByDto(id)).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.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<SendMessageComma
|
||||
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
|
||||
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
||||
senderMember.UpdateDeliveredCursor(message.Id);
|
||||
message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение
|
||||
|
||||
// 5. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
_messageRepository.Add(message);
|
||||
|
||||
@@ -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<string, CallSession> _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<ChatHub> logger,
|
||||
ILogger<ChatHub> 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<MessageReaction>(), Enumerable.Empty<Guid>());
|
||||
|
||||
|
||||
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<string, ParticipantInfo>();
|
||||
|
||||
@@ -19,9 +19,17 @@ public static class MessagesEndpoints
|
||||
{
|
||||
var group = app.MapGroup("api/messages").RequireAuthorization();
|
||||
|
||||
group.MapGet("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromQuery] string? cursor, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
group.MapGet("chat/{chatId:guid}", async (
|
||||
[FromRoute] Guid chatId,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] long? afterSequenceId,
|
||||
[FromQuery] long? pivot,
|
||||
[FromQuery] int? limit,
|
||||
ISender sender,
|
||||
IUserContext userContext,
|
||||
CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor), ct);
|
||||
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor, pivot, afterSequenceId, limit), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ public abstract class Message : AggregateRoot<Guid>
|
||||
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<Guid>
|
||||
// ================== Опциональные метаданные (общего назначения) ==================
|
||||
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<Guid>
|
||||
protected List<DeletedMessage> _deletedFor = new();
|
||||
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
||||
|
||||
// ================== Прочитано ==================
|
||||
protected List<Guid> _readByUsers = new();
|
||||
public IReadOnlyCollection<Guid> 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<Guid>
|
||||
ReplyToId = replyToId;
|
||||
ForwardedFromId = forwardedFromId;
|
||||
CreatedAt = createdAt;
|
||||
|
||||
|
||||
if (isImported) AddState(MessageState.IsImported);
|
||||
}
|
||||
|
||||
@@ -89,6 +93,16 @@ public abstract class Message : AggregateRoot<Guid>
|
||||
_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
|
||||
}).ToList() ?? (object)Array.Empty<object>(),
|
||||
sender = senderObj,
|
||||
readBy = new List<object>(),
|
||||
readBy = message.ReadByUsers.Select(id => new { id }).ToList(),
|
||||
storyId = (message as StoryMessage)?.StoryId,
|
||||
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
|
||||
storyMediaType = (message as StoryMessage)?.StoryMediaType,
|
||||
|
||||
@@ -95,14 +95,28 @@ public sealed class MessageRepository : IMessageRepository
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = Builders<Message>.Filter;
|
||||
var filter = builder.And(
|
||||
builder.Eq(m => m.ChatId, chatId),
|
||||
builder.Gt(m => m.SequenceId, sequenceId)
|
||||
);
|
||||
|
||||
return await _messages.Find(filter)
|
||||
.SortBy(m => m.SequenceId)
|
||||
.Limit(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = Builders<Message>.Filter;
|
||||
|
||||
|
||||
// Target message
|
||||
var targetFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Eq(m => m.SequenceId, sequenceId));
|
||||
var targetMsg = await _messages.Find(targetFilter).FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
|
||||
// Older messages
|
||||
var olderFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Lt(m => m.SequenceId, sequenceId));
|
||||
var older = await _messages.Find(olderFilter)
|
||||
|
||||
@@ -194,7 +194,8 @@ const translations = {
|
||||
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
|
||||
clearHistory: 'Очистить историю',
|
||||
clearHistoryConfirm: 'Очистить историю?',
|
||||
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
||||
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
|
||||
deleteGroupChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
|
||||
pinChat: 'Закрепить чат',
|
||||
unpinChat: 'Открепить чат',
|
||||
chatCleared: 'Очищено',
|
||||
@@ -574,7 +575,8 @@ const translations = {
|
||||
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
|
||||
clearHistory: 'Clear history',
|
||||
clearHistoryConfirm: 'Clear history?',
|
||||
deleteChatConfirm: 'Delete this chat? This action cannot be undone.',
|
||||
deleteChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
|
||||
deleteGroupChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
|
||||
pinChat: 'Pin chat',
|
||||
unpinChat: 'Unpin chat',
|
||||
chatCleared: 'Chat cleared',
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:py-8 lg:gap-4 z-50 transition-all safe-area-bottom"
|
||||
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:pt-8 lg:pb-14 lg:gap-4 z-50 transition-all safe-area-bottom"
|
||||
>
|
||||
<div className="hidden lg:flex mb-10 flex-col items-center">
|
||||
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
|
||||
@@ -46,7 +46,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="lg:mt-auto group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
|
||||
className="lg:mt-auto lg:mb-4 group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
|
||||
onClick={() => onTabChange('settings')}
|
||||
>
|
||||
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
||||
|
||||
@@ -125,9 +125,9 @@ export const useChatStore = create<ChatState>((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<ChatState>((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<ChatState>((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<ChatState>((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<ChatState>((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<ChatState>((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
|
||||
|
||||
@@ -58,13 +58,15 @@ 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('');
|
||||
|
||||
const [activeTab, setActiveTab] = useState('chats');
|
||||
const { t } = useLang();
|
||||
|
||||
const activeChat = useChatStore((state) => state.activeChat);
|
||||
|
||||
useEffect(() => {
|
||||
groupCallOpenRef.current = groupCallOpen;
|
||||
groupCallChatIdRef.current = groupCallChatId;
|
||||
@@ -180,7 +182,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 +288,7 @@ export default function ChatPage() {
|
||||
callType: data.callType,
|
||||
chatName: chat.name || 'Group',
|
||||
});
|
||||
|
||||
|
||||
// Auto-dismiss after 15 seconds if ignored
|
||||
setTimeout(() => {
|
||||
setIncomingGroupCall(prev => {
|
||||
@@ -330,6 +337,16 @@ export default function ChatPage() {
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
// Join chat group when activeChat changes
|
||||
useEffect(() => {
|
||||
if (activeChat) {
|
||||
const socket = getSocket();
|
||||
if (socket) {
|
||||
socket.emit('join_chat', activeChat);
|
||||
}
|
||||
}
|
||||
}, [activeChat]);
|
||||
|
||||
const handleStartCall = (targetUser: UserBasic, type: 'voice' | 'video') => {
|
||||
setCallTarget(targetUser);
|
||||
setCallType(type);
|
||||
@@ -366,8 +383,6 @@ export default function ChatPage() {
|
||||
setGroupCallOpen(false);
|
||||
};
|
||||
|
||||
const activeChat = useChatStore((state) => state.activeChat);
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
@@ -383,14 +398,14 @@ export default function ChatPage() {
|
||||
{activeTab === 'chats' ? (
|
||||
<>
|
||||
{/* 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`}
|
||||
>
|
||||
<Sidebar />
|
||||
</div>
|
||||
|
||||
{/* 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`}
|
||||
>
|
||||
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
||||
@@ -398,31 +413,31 @@ export default function ChatPage() {
|
||||
</>
|
||||
) : activeTab === 'contacts' ? (
|
||||
<div className="flex-1 flex flex-row h-full overflow-hidden">
|
||||
{/* Contacts Sidebar List */}
|
||||
<div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased">
|
||||
<ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} />
|
||||
</div>
|
||||
{/* Contacts Sidebar List */}
|
||||
<div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased">
|
||||
<ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} />
|
||||
</div>
|
||||
|
||||
{/* 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="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">
|
||||
<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>
|
||||
{/* 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="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">
|
||||
<Users size={48} className="knot-logo-spin opacity-50" />
|
||||
</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>
|
||||
) : activeTab === 'settings' ? (
|
||||
<SettingsPage />
|
||||
@@ -432,7 +447,7 @@ export default function ChatPage() {
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
|
||||
|
||||
<CallModal
|
||||
key={callSessionId}
|
||||
@@ -485,9 +500,9 @@ export default function ChatPage() {
|
||||
>
|
||||
<div className="relative mb-6">
|
||||
<div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/20 animate-call-wave" />
|
||||
<Avatar
|
||||
src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null}
|
||||
name={incomingGroupCall.chatName || '?'}
|
||||
<Avatar
|
||||
src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null}
|
||||
name={incomingGroupCall.chatName || '?'}
|
||||
size="2xl"
|
||||
className="relative shadow-2xl"
|
||||
/>
|
||||
@@ -499,9 +514,9 @@ export default function ChatPage() {
|
||||
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('isCalling' as any) || 'звонит...'}
|
||||
</p>
|
||||
<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>
|
||||
|
||||
|
||||
<div className="flex items-center gap-8 w-full justify-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
|
||||
@@ -63,14 +63,14 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
: lastMessage.media?.[0]?.type === 'video'
|
||||
? t('video')
|
||||
: t('file')
|
||||
: lastMessage.type === 'call'
|
||||
? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t(
|
||||
: lastMessage.type === 'call'
|
||||
? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t(
|
||||
lastMessage.callStatus === 'missed' ? 'missedCall' :
|
||||
lastMessage.callStatus === 'declined' ? 'declinedCall' :
|
||||
lastMessage.callStatus === 'cancelled' ? 'cancelledCall' :
|
||||
'completedCall'
|
||||
lastMessage.callStatus === 'declined' ? 'declinedCall' :
|
||||
lastMessage.callStatus === 'cancelled' ? 'cancelledCall' :
|
||||
'completedCall'
|
||||
)}`
|
||||
: lastMessage.content || ''
|
||||
: lastMessage.content || ''
|
||||
: '';
|
||||
|
||||
const previewText = chat.isImporting ? 'Импорт...' : stripMarkdown(lastMessageText);
|
||||
@@ -78,7 +78,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
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
|
||||
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
|
||||
@@ -89,25 +91,25 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat.isImporting || !chat.importJobId) {
|
||||
setImportStatus(null);
|
||||
return;
|
||||
setImportStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
|
||||
setImportStatus({ processed: data.processedMessages, total: data.totalMessages });
|
||||
if (data.status === 'Completed' || data.status === 'Failed') {
|
||||
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);
|
||||
}
|
||||
try {
|
||||
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
|
||||
setImportStatus({ processed: data.processedMessages, total: data.totalMessages });
|
||||
if (data.status === 'Completed' || data.status === 'Failed') {
|
||||
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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
@@ -117,9 +119,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
|
||||
const handleClick = () => {
|
||||
if (chat.isImporting) {
|
||||
// Here we could show a progress modal, but for now just select it
|
||||
setActiveChat(chat.id);
|
||||
return;
|
||||
// Here we could show a progress modal, but for now just select it
|
||||
setActiveChat(chat.id);
|
||||
return;
|
||||
}
|
||||
if ((window as any).hasUnsavedAttachments && !isActive) {
|
||||
setShowAttachmentConfirm(true);
|
||||
@@ -131,7 +133,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
const proceedWithClick = () => {
|
||||
setShowAttachmentConfirm(false);
|
||||
(window as any).hasUnsavedAttachments = false;
|
||||
|
||||
|
||||
if (isActive) {
|
||||
window.dispatchEvent(new CustomEvent('CHAT_SCROLL_TO_BOTTOM', { detail: { chatId: chat.id } }));
|
||||
} else {
|
||||
@@ -188,9 +190,8 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
<button
|
||||
onClick={handleClick}
|
||||
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)] ${
|
||||
isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
|
||||
}`}
|
||||
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'
|
||||
}`}
|
||||
>
|
||||
{/* Аватар */}
|
||||
<div className="relative flex-shrink-0">
|
||||
@@ -200,7 +201,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
@@ -225,20 +226,20 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
)}
|
||||
<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'}`}>
|
||||
{isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
|
||||
{chat.isImporting && importStatus && importStatus.total > 0 && (
|
||||
<span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums">
|
||||
{importStatus.processed} / {importStatus.total}
|
||||
</span>
|
||||
)}
|
||||
{isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
|
||||
{chat.isImporting && importStatus && importStatus.total > 0 && (
|
||||
<span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums">
|
||||
{importStatus.processed} / {importStatus.total}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
{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="h-full bg-primary transition-all duration-500 ease-out"
|
||||
style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-full h-1 bg-surface-container-highest rounded-full overflow-hidden mt-0.5">
|
||||
<div
|
||||
className="h-full bg-primary transition-all duration-500 ease-out"
|
||||
style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -98,13 +98,13 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
NotificationStore.useNotificationStore.getState().addNotification('info', (t as any)('searchingHistory') || 'Searching message in history...');
|
||||
|
||||
const chatStore = useChatStore.getState();
|
||||
|
||||
|
||||
if (sequenceId !== undefined) {
|
||||
await chatStore.jumpToMessage(activeChat, sequenceId);
|
||||
// Wait a bit for React to render
|
||||
setTimeout(() => {
|
||||
if (!tryScroll()) {
|
||||
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
|
||||
NotificationStore.useNotificationStore.getState().addNotification('warning', (t as any)('messageNotFound') || 'Message not found');
|
||||
}
|
||||
}, 300);
|
||||
} else {
|
||||
@@ -137,25 +137,25 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat?.isImporting || !chat?.importJobId) {
|
||||
setImportStatus(null);
|
||||
return;
|
||||
setImportStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
|
||||
setImportStatus({ processed: data.processedMessages, total: data.totalMessages, status: data.status });
|
||||
if (data.status === 'Completed' || data.status === 'Failed') {
|
||||
setImportStatus(null);
|
||||
loadChats();
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.status === 404) {
|
||||
console.warn('Import job not found');
|
||||
} else {
|
||||
console.error('Failed to poll status', e);
|
||||
}
|
||||
try {
|
||||
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
|
||||
setImportStatus({ processed: data.processedMessages, total: data.totalMessages, status: data.status });
|
||||
if (data.status === 'Completed' || data.status === 'Failed') {
|
||||
setImportStatus(null);
|
||||
loadChats();
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e.status === 404) {
|
||||
console.warn('Import job not found');
|
||||
} else {
|
||||
console.error('Failed to poll status', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
@@ -187,12 +187,31 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
// Refs and logic for tracking session's first unread message to show the divider exactly once per load
|
||||
const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null });
|
||||
|
||||
if (activeChat && activeChat !== sessionUnreadRef.current.chatId && !isLoadingMessages) {
|
||||
const firstUnreadMsg = chatMessages.find(
|
||||
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
||||
);
|
||||
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
|
||||
}
|
||||
// Update sessionUnreadRef when chat changes OR when messages are marked as read
|
||||
useEffect(() => {
|
||||
if (!activeChat || isLoadingMessages) return;
|
||||
|
||||
// Reset on chat change
|
||||
if (activeChat !== sessionUnreadRef.current.chatId) {
|
||||
const firstUnreadMsg = chatMessages.find(
|
||||
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
||||
);
|
||||
sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null };
|
||||
} else {
|
||||
// Update if the first unread message was read (msgId no longer exists in unread list)
|
||||
const firstUnreadMsg = chatMessages.find(
|
||||
(m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id)
|
||||
);
|
||||
if (sessionUnreadRef.current.msgId && !firstUnreadMsg) {
|
||||
// All messages are now read
|
||||
sessionUnreadRef.current.msgId = null;
|
||||
} else if (firstUnreadMsg && sessionUnreadRef.current.msgId !== firstUnreadMsg.id) {
|
||||
// First unread changed (some messages were read)
|
||||
sessionUnreadRef.current.msgId = firstUnreadMsg.id;
|
||||
}
|
||||
}
|
||||
}, [activeChat, chatMessages, user?.id, isLoadingMessages]);
|
||||
|
||||
const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null;
|
||||
const initialScrollChatId = useRef<string | null>(null);
|
||||
|
||||
@@ -270,19 +289,19 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
const saveScrollPosition = useCallback((targetChatId?: string) => {
|
||||
const container = scrollContainerRef.current;
|
||||
const chatId = targetChatId || activeChat;
|
||||
|
||||
|
||||
if (!container || !chatId || !scrollReady || isInitializingRef.current || isScrollingToBottomRef.current) return;
|
||||
|
||||
// Проверка: сообщения в стейте должны быть от целевого чата
|
||||
if (chatMessages.length > 0 && chatMessages[0].chatId !== chatId) return;
|
||||
|
||||
|
||||
const isAtBottomNow = container.scrollHeight - container.scrollTop - container.clientHeight < 40;
|
||||
isAtBottomRef.current = isAtBottomNow;
|
||||
|
||||
|
||||
if (isAtBottomNow) {
|
||||
localStorage.setItem(`chat_at_bottom_${chatId}`, 'true');
|
||||
localStorage.removeItem(`chat_anchor_${chatId}`);
|
||||
return;
|
||||
localStorage.setItem(`chat_at_bottom_${chatId}`, 'true');
|
||||
localStorage.removeItem(`chat_anchor_${chatId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const messageElements = container.querySelectorAll('[data-message-id]');
|
||||
@@ -304,8 +323,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
}
|
||||
|
||||
if (anchor && anchor.id) {
|
||||
localStorage.setItem(`chat_anchor_${chatId}`, JSON.stringify(anchor));
|
||||
localStorage.removeItem(`chat_at_bottom_${chatId}`);
|
||||
localStorage.setItem(`chat_anchor_${chatId}`, JSON.stringify(anchor));
|
||||
localStorage.removeItem(`chat_at_bottom_${chatId}`);
|
||||
}
|
||||
}, [activeChat, scrollReady, chatMessages]);
|
||||
|
||||
@@ -313,7 +332,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
const restoreScrollPosition = useCallback(() => {
|
||||
const container = scrollContainerRef.current;
|
||||
if (!container || !activeChat) return false;
|
||||
|
||||
|
||||
if (chatMessages.length > 0 && chatMessages[0].chatId !== activeChat) return false;
|
||||
if (chatMessages.length === 0) return true;
|
||||
|
||||
@@ -330,16 +349,16 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
try {
|
||||
const { id, offset } = JSON.parse(saved);
|
||||
let el = container.querySelector(`[data-message-id="${id}"]`) as HTMLElement;
|
||||
|
||||
|
||||
// Поиск ближайшего, если точное сообщение еще не загружено
|
||||
if (!el) {
|
||||
const msgIndex = chatMessages.findIndex(m => m.id === id);
|
||||
if (msgIndex !== -1) {
|
||||
for (let i = msgIndex; i < chatMessages.length; i++) {
|
||||
const nextEl = container.querySelector(`[data-message-id="${chatMessages[i].id}"]`) as HTMLElement;
|
||||
if (nextEl) { el = nextEl; break; }
|
||||
}
|
||||
}
|
||||
const msgIndex = chatMessages.findIndex(m => m.id === id);
|
||||
if (msgIndex !== -1) {
|
||||
for (let i = msgIndex; i < chatMessages.length; i++) {
|
||||
const nextEl = container.querySelector(`[data-message-id="${chatMessages[i].id}"]`) as HTMLElement;
|
||||
if (nextEl) { el = nextEl; break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (el) {
|
||||
@@ -368,7 +387,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
useEffect(() => {
|
||||
if (isLoadingMessages || !scrollContainerRef.current || !activeChat) return;
|
||||
const container = scrollContainerRef.current;
|
||||
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (isInitializingRef.current) return;
|
||||
|
||||
@@ -405,18 +424,18 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
// Сохраняем позицию старого чата
|
||||
if (prevChatIdRef.current && scrollContainerRef.current && scrollReady) {
|
||||
saveScrollPosition(prevChatIdRef.current);
|
||||
saveScrollPosition(prevChatIdRef.current);
|
||||
}
|
||||
|
||||
|
||||
setScrollReady(false);
|
||||
prevChatIdRef.current = activeChat;
|
||||
|
||||
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
scrollContainerRef.current.scrollTop = 0;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
isInitializingRef.current = false;
|
||||
isInitializingRef.current = false;
|
||||
}, 600);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
@@ -443,7 +462,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
if (scrollTimeoutRef.current) clearTimeout(scrollTimeoutRef.current);
|
||||
scrollTimeoutRef.current = setTimeout(() => saveScrollPosition(), 150);
|
||||
|
||||
|
||||
const st = container.scrollTop;
|
||||
const isScrollingUp = st < lastScrollTopRef.current;
|
||||
const stChanged = st !== lastScrollTopRef.current;
|
||||
@@ -473,7 +492,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const messageElements = container.querySelectorAll('[data-message-id]');
|
||||
|
||||
|
||||
let currentTopMsgId = null;
|
||||
for (const el of messageElements) {
|
||||
const rect = el.getBoundingClientRect();
|
||||
@@ -506,15 +525,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
// Принудительный сброс режима (Второй Клик)
|
||||
localStorage.setItem(`chat_at_bottom_${activeChat}`, 'true');
|
||||
localStorage.removeItem(`chat_anchor_${activeChat}`);
|
||||
|
||||
|
||||
isScrollingToBottomRef.current = true;
|
||||
if (scrollContainerRef.current) {
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
scrollContainerRef.current.scrollTo({
|
||||
top: scrollContainerRef.current.scrollHeight,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
setTimeout(() => {
|
||||
isScrollingToBottomRef.current = false;
|
||||
}, 1000);
|
||||
@@ -573,6 +592,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
{
|
||||
root: scrollContainerRef.current,
|
||||
threshold: 0.1,
|
||||
rootMargin: '0px',
|
||||
}
|
||||
);
|
||||
|
||||
@@ -584,7 +604,26 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
unreadElements.forEach((el: Element) => {
|
||||
const id = el.getAttribute('data-message-id');
|
||||
if (id && !sentReadIdsRef.current.has(id)) {
|
||||
observer.observe(el);
|
||||
// Check if element is already visible
|
||||
const rect = el.getBoundingClientRect();
|
||||
const containerRect = scrollContainerRef.current!.getBoundingClientRect();
|
||||
const isVisible = rect.top >= containerRect.top && rect.bottom <= containerRect.bottom;
|
||||
|
||||
if (isVisible) {
|
||||
// Mark as read immediately without waiting for intersection
|
||||
const seqId = parseInt(el.getAttribute('data-sequence-id') || '0', 10);
|
||||
if (seqId > 0) {
|
||||
socket.emit('read_messages', {
|
||||
chatId: activeChat,
|
||||
lastReadMessageId: id,
|
||||
lastReadSequenceId: seqId,
|
||||
});
|
||||
useChatStore.getState().markRead(activeChat, user.id, seqId);
|
||||
sentReadIdsRef.current.add(id);
|
||||
}
|
||||
} else {
|
||||
observer.observe(el);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -646,7 +685,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
<section className="flex-1 h-full flex flex-col items-center justify-center bg-[#010101] relative overflow-hidden">
|
||||
{/* Background Knot Texture */}
|
||||
<div className="absolute inset-0 opacity-[0.04] pointer-events-none flex items-center justify-center">
|
||||
<span className="material-symbols-outlined text-white text-[500px] select-none">cloud_download</span>
|
||||
<span className="material-symbols-outlined text-white text-[500px] select-none">cloud_download</span>
|
||||
</div>
|
||||
|
||||
{/* Chat Empty State View */}
|
||||
@@ -1152,16 +1191,16 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-2 px-1">
|
||||
<span className="text-xs font-black uppercase tracking-widest text-primary">
|
||||
{importStatus?.status === 'Processing' ? 'Обработка' :
|
||||
importStatus?.status === 'Queued' ? 'В очереди' : 'Загрузка'}
|
||||
{importStatus?.status === 'Processing' ? 'Обработка' :
|
||||
importStatus?.status === 'Queued' ? 'В очереди' : 'Загрузка'}
|
||||
</span>
|
||||
<span className="text-xs font-black text-on-surface tabular-nums">
|
||||
{importStatus?.processed || 0} / {importStatus?.total || 0}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="h-3 w-full bg-surface-container-highest rounded-full overflow-hidden border border-outline/10 p-0.5">
|
||||
<motion.div
|
||||
<motion.div
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${Math.min(100, Math.round(((importStatus?.processed || 0) / (importStatus?.total || 1)) * 100))}%` }}
|
||||
transition={{ type: 'spring', damping: 20 }}
|
||||
@@ -1199,9 +1238,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
{chatPinnedMessages.length > 1 && (
|
||||
<div className="absolute left-1 top-1.5 bottom-1.5 w-0.5 rounded-full bg-white/5 flex flex-col gap-0.5 overflow-hidden">
|
||||
{chatPinnedMessages.map((_, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex-1 transition-colors duration-300 ${idx === pinnedIndex % chatPinnedMessages.length ? 'bg-primary' : 'bg-primary/20'}`}
|
||||
<div
|
||||
key={idx}
|
||||
className={`flex-1 transition-colors duration-300 ${idx === pinnedIndex % chatPinnedMessages.length ? 'bg-primary' : 'bg-primary/20'}`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -1226,8 +1265,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
{t('pinnedMessage')} {chatPinnedMessages.length > 1 ? `#${(pinnedIndex % chatPinnedMessages.length) + 1}` : ''}
|
||||
</p>
|
||||
<p className="text-sm text-zinc-300 truncate font-medium">
|
||||
{chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.content ||
|
||||
(chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.media?.length > 0 ? t('media') : '...')}
|
||||
{chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.content ||
|
||||
(chatPinnedMessages[pinnedIndex % chatPinnedMessages.length]?.media?.length > 0 ? t('media') : '...')}
|
||||
</p>
|
||||
</div>
|
||||
</button>
|
||||
@@ -1377,11 +1416,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
</>
|
||||
)}
|
||||
|
||||
{typingInChat.length > 0 && (
|
||||
<div className="px-4 pb-1">
|
||||
<TypingIndicator />
|
||||
</div>
|
||||
)}
|
||||
{/* Typing indicator is already shown in the header, removed from here to prevent layout jumping */}
|
||||
|
||||
{(() => {
|
||||
return (
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -331,65 +331,70 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0) {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
let tooLarge = false;
|
||||
let limitExceeded = false;
|
||||
const processFiles = useCallback((files: File[]) => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
let tooLarge = false;
|
||||
let limitExceeded = false;
|
||||
|
||||
for (const file of files) {
|
||||
if (attachments.length + newAttachments.length >= 20) {
|
||||
limitExceeded = true;
|
||||
break;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
tooLarge = true;
|
||||
continue;
|
||||
}
|
||||
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||
newAttachments.push({ file, type: isAudio ? 'audio' : 'file' });
|
||||
for (const file of files) {
|
||||
if (attachments.length + newAttachments.length >= 20) {
|
||||
limitExceeded = true;
|
||||
break;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
tooLarge = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||
|
||||
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
|
||||
const preview = isImage ? URL.createObjectURL(file) : undefined;
|
||||
|
||||
newAttachments.push({ file, type, preview });
|
||||
}
|
||||
|
||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
}, [attachments, t]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0) processFiles(files);
|
||||
e.target.value = '';
|
||||
setShowAttachMenu(false);
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0) {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
let limitExceeded = false;
|
||||
|
||||
for (const file of files) {
|
||||
if (attachments.length + newAttachments.length >= 20) {
|
||||
limitExceeded = true;
|
||||
break;
|
||||
}
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const preview = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined;
|
||||
newAttachments.push({ file, preview, type: isVideo ? 'video' : 'image' });
|
||||
}
|
||||
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
if (files.length > 0) processFiles(files);
|
||||
e.target.value = '';
|
||||
setShowAttachMenu(false);
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
const files = items
|
||||
.filter(item => item.kind === 'file')
|
||||
.map(item => item.getAsFile())
|
||||
.filter((f): f is File => f !== null);
|
||||
|
||||
if (files.length > 0) {
|
||||
processFiles(files);
|
||||
// If we only pasted files, don't paste the filename/text representation in the textarea
|
||||
if (items.every(item => item.kind === 'file')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Запись голосового
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
@@ -569,41 +574,11 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
let tooLarge = false;
|
||||
let limitExceeded = false;
|
||||
|
||||
for (const file of files) {
|
||||
if (attachments.length + newAttachments.length >= 20) {
|
||||
limitExceeded = true;
|
||||
break;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
tooLarge = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma', '.opus'];
|
||||
const isAudio = file.type.startsWith('audio/') || audioExts.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||
|
||||
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
|
||||
const preview = isImage ? URL.createObjectURL(file) : undefined;
|
||||
|
||||
newAttachments.push({ file, type, preview });
|
||||
}
|
||||
|
||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
processFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const hasContent = text.trim() || attachments.length > 0;
|
||||
|
||||
return (
|
||||
@@ -856,6 +831,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleInputContextMenu}
|
||||
onPaste={handlePaste}
|
||||
rows={1}
|
||||
className="w-full bg-transparent border-none focus:ring-0 text-[#efeff3] placeholder-on-surface-variant/30 text-[16px] leading-[1.3] resize-none max-h-[140px] custom-scrollbar outline-none py-1 px-0"
|
||||
placeholder={attachments.length > 0 ? t('addCaption') : t('messagePlaceholder') || 'Сообщение...'}
|
||||
|
||||
Reference in New Issue
Block a user