8 Commits
20 changed files with 517 additions and 426 deletions
@@ -13,6 +13,7 @@ public interface IMessageRepository
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken); 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>> 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<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
Task UpdateAsync(Message message, CancellationToken cancellationToken); Task UpdateAsync(Message message, CancellationToken cancellationToken);
@@ -23,6 +23,9 @@ public abstract class Message : AggregateRoot<Guid>
protected List<DeletedMessage> _deletedFor = new(); protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly(); public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
protected Message() : base(Guid.Empty) { } protected Message() : base(Guid.Empty) { }
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported) protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
@@ -53,4 +56,14 @@ public abstract class Message : AggregateRoot<Guid>
if (!_deletedFor.Exists(x => x.UserId == userId)) if (!_deletedFor.Exists(x => x.UserId == userId))
_deletedFor.Add(new DeletedMessage(Id, userId)); _deletedFor.Add(new DeletedMessage(Id, userId));
} }
public void MarkAsRead(Guid userId)
{
if (!_readByUsers.Contains(userId))
{
_readByUsers.Add(userId);
}
}
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
} }
@@ -1,13 +1,14 @@
using System; using System;
using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Shared.Kernel;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using MediatR; using Knot.Contracts.Conversations.Domain;
using System.Linq; using Knot.Modules.Conversations.Infrastructure.SignalR;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Shared.Kernel;
using Knot.Shared.Kernel.Storage; using Knot.Shared.Kernel.Storage;
using MediatR;
using Microsoft.AspNetCore.SignalR;
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete; namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
@@ -19,17 +20,21 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
private readonly IMessageRepository _messageRepository; private readonly IMessageRepository _messageRepository;
private readonly IFileStorageService _fileStorage; private readonly IFileStorageService _fileStorage;
private readonly IChatsUnitOfWork _uow; private readonly IChatsUnitOfWork _uow;
private readonly IHubContext<ChatHub> _hubContext;
public LeaveOrDeleteChatCommandHandler( public LeaveOrDeleteChatCommandHandler(
IChatRepository chatRepository, IChatRepository chatRepository,
IMessageRepository messageRepository, IMessageRepository messageRepository,
IFileStorageService fileStorage, IFileStorageService fileStorage,
IChatsUnitOfWork uow) IChatsUnitOfWork uow,
IHubContext<ChatHub> hubContext)
{ {
_chatRepository = chatRepository; _chatRepository = chatRepository;
_messageRepository = messageRepository; _messageRepository = messageRepository;
_fileStorage = fileStorage; _fileStorage = fileStorage;
_uow = uow; _uow = uow;
_hubContext = hubContext;
} }
public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken) public async Task<Result<SuccessResponse>> Handle(LeaveOrDeleteChatCommand request, CancellationToken cancellationToken)
@@ -58,6 +63,14 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
// DELETE ALL MESSAGES AND FILES FIRST // DELETE ALL MESSAGES AND FILES FIRST
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken); await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
_chatRepository.Remove(chat); _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); await _uow.SaveChangesAsync(cancellationToken);
@@ -68,6 +81,7 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct) private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
{ {
try try
{ {
// Get all messages directly from Mongo (not paged) // Get all messages directly from Mongo (not paged)
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct); var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
@@ -13,7 +13,7 @@ using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.GetMessages; 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>> internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
{ {
@@ -41,7 +41,12 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
List<Message> messages; List<Message> messages;
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit; 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); 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 StoryMessage)?.StoryMediaType,
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(), (message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null), sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(),
reactions?.Select(r => reactions?.Select(r =>
{ {
senders.TryGetValue(r.UserId, out var ru); senders.TryGetValue(r.UserId, out var ru);
@@ -1,7 +1,8 @@
using MediatR;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Knot.Contracts.Conversations.Application.Abstractions; using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Shared.Kernel;
using MediatR;
namespace Knot.Modules.Conversations.Application.Messages.Read; namespace Knot.Modules.Conversations.Application.Messages.Read;
@@ -11,11 +12,13 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
{ {
private readonly IChatRepository _chatRepository; private readonly IChatRepository _chatRepository;
private readonly IChatsUnitOfWork _unitOfWork; private readonly IChatsUnitOfWork _unitOfWork;
private readonly IMessageRepository _messageRepository;
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork) public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository)
{ {
_chatRepository = chatRepository; _chatRepository = chatRepository;
_unitOfWork = unitOfWork; _unitOfWork = unitOfWork;
_messageRepository = messageRepository;
} }
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken) public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
@@ -28,6 +31,23 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId); member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
// Обновляем ReadByUsers для всех сообщений до LastReadSequenceId
var messages = await _messageRepository.GetChatMessagesAfterAsync(
request.ChatId,
0,
1000,
cancellationToken);
foreach (var message in messages)
{
if (message.SequenceId <= request.LastReadSequenceId &&
message.SenderId != request.UserId &&
!message.IsReadBy(request.UserId))
{
message.MarkAsRead(request.UserId);
}
}
await _unitOfWork.SaveChangesAsync(cancellationToken); await _unitOfWork.SaveChangesAsync(cancellationToken);
return Result.Success(); return Result.Success();
@@ -3,10 +3,10 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs; using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
using MediatR; using MediatR;
@@ -41,7 +41,8 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken); var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList()); var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
var result = messages.Select(message => { var result = messages.Select(message =>
{
var textMessage = message as TextMessage; var textMessage = message as TextMessage;
var mediaMessage = message as MediaMessage; var mediaMessage = message as MediaMessage;
var storyMessage = message as StoryMessage; var storyMessage = message as StoryMessage;
@@ -66,7 +67,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(), mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null), senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(), reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
new List<ReadByDto>() message.ReadByUsers.Select(id => new ReadByDto(id)).ToList()
); );
}).ToList(); }).ToList();
@@ -1,8 +1,8 @@
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions; using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain; using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Settings.Application.Abstractions; using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel; using Knot.Shared.Kernel;
namespace Knot.Modules.Conversations.Application.Messages.Send; namespace Knot.Modules.Conversations.Application.Messages.Send;
@@ -192,6 +192,7 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
var senderMember = chat.Members.First(m => m.UserId == request.SenderId); var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
senderMember.UpdateReadCursor(message.Id, message.SequenceId); senderMember.UpdateReadCursor(message.Id, message.SequenceId);
senderMember.UpdateDeliveredCursor(message.Id); senderMember.UpdateDeliveredCursor(message.Id);
message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение
// 5. // 5.
_messageRepository.Add(message); _messageRepository.Add(message);
@@ -1,26 +1,26 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Security.Claims; using System.Security.Claims;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Contracts.Auth.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
using Knot.Contracts.Conversations.Domain;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.Edit;
using Knot.Modules.Conversations.Application.Messages.Pin;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Unpin;
using Knot.Modules.Conversations.Application.Messages.Vote;
using Knot.Shared.Kernel;
using MediatR; using MediatR;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Microsoft.Extensions.Logging;
using Knot.Modules.Conversations.Application.Messages.Send;
using Knot.Modules.Conversations.Application.Messages.Read;
using Knot.Modules.Conversations.Application.Messages.Delete;
using Knot.Modules.Conversations.Application.Messages.React;
using Knot.Contracts.Conversations.Domain;
using Knot.Shared.Kernel;
using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Caching.Memory;
using Knot.Contracts.Auth.Domain; using Microsoft.Extensions.Logging;
using Knot.Contracts.Auth.Application.Abstractions;
using Knot.Modules.Conversations.Application.Messages.Pin;
using Knot.Modules.Conversations.Application.Messages.Unpin;
using Knot.Modules.Conversations.Application.Messages.Vote;
using Knot.Modules.Conversations.Application.Messages.Edit;
using Knot.Modules.Conversations.Application.DTOs;
using Knot.Contracts.Messaging.Application.Abstractions;
using Knot.Contracts.Messaging.Domain;
using Knot.Contracts.Conversations.Application.Abstractions;
namespace Knot.Modules.Conversations.Infrastructure.SignalR; namespace Knot.Modules.Conversations.Infrastructure.SignalR;
@@ -158,6 +158,7 @@ public sealed class ChatHub : Hub
await _sender.Send(command); await _sender.Send(command);
} }
// Отправляем событие всем в чате о том, что пользователь прочитал сообщения
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
{ {
ChatId = request.ChatId.ToString(), ChatId = request.ChatId.ToString(),
@@ -19,9 +19,17 @@ public static class MessagesEndpoints
{ {
var group = app.MapGroup("api/messages").RequireAuthorization(); 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); return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
}); });
@@ -42,6 +42,10 @@ public abstract class Message : AggregateRoot<Guid>
protected List<DeletedMessage> _deletedFor = new(); protected List<DeletedMessage> _deletedFor = new();
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly(); public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
// ================== Прочитано ==================
protected List<Guid> _readByUsers = new();
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
// ================== Инфраструктурный конструктор EF ================== // ================== Инфраструктурный конструктор EF ==================
protected Message() : base(Guid.Empty) { } protected Message() : base(Guid.Empty) { }
@@ -89,6 +93,16 @@ public abstract class Message : AggregateRoot<Guid>
_deletedFor.Add(new DeletedMessage(Id, userId)); _deletedFor.Add(new DeletedMessage(Id, userId));
} }
} }
public void MarkAsRead(Guid userId)
{
if (!_readByUsers.Contains(userId))
{
_readByUsers.Add(userId);
}
}
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
} }
@@ -84,7 +84,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
size = m.Size size = m.Size
}).ToList() ?? (object)Array.Empty<object>(), }).ToList() ?? (object)Array.Empty<object>(),
sender = senderObj, sender = senderObj,
readBy = new List<object>(), readBy = message.ReadByUsers.Select(id => new { id }).ToList(),
storyId = (message as StoryMessage)?.StoryId, storyId = (message as StoryMessage)?.StoryId,
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl, storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
storyMediaType = (message as StoryMessage)?.StoryMediaType, storyMediaType = (message as StoryMessage)?.StoryMediaType,
@@ -95,6 +95,20 @@ public sealed class MessageRepository : IMessageRepository
.ToListAsync(cancellationToken); .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) public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
{ {
var builder = Builders<Message>.Filter; var builder = Builders<Message>.Filter;
+3 -1
View File
@@ -194,7 +194,8 @@ const translations = {
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.', clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
clearHistory: 'Очистить историю', clearHistory: 'Очистить историю',
clearHistoryConfirm: 'Очистить историю?', clearHistoryConfirm: 'Очистить историю?',
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.', deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
deleteGroupChatConfirm: 'Удалить чат? Это действие нельзя отменить. Чат будет удалён у всех участников.',
pinChat: 'Закрепить чат', pinChat: 'Закрепить чат',
unpinChat: 'Открепить чат', unpinChat: 'Открепить чат',
chatCleared: 'Очищено', chatCleared: 'Очищено',
@@ -575,6 +576,7 @@ const translations = {
clearHistory: 'Clear history', clearHistory: 'Clear history',
clearHistoryConfirm: 'Clear history?', clearHistoryConfirm: 'Clear history?',
deleteChatConfirm: 'Delete this chat? This action cannot be undone.', deleteChatConfirm: 'Delete this chat? This action cannot be undone.',
deleteGroupChatConfirm: 'Delete this chat? This action cannot be undone. The chat will be removed for all participants.',
pinChat: 'Pin chat', pinChat: 'Pin chat',
unpinChat: 'Unpin chat', unpinChat: 'Unpin chat',
chatCleared: 'Chat cleared', chatCleared: 'Chat cleared',
@@ -17,7 +17,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
return ( return (
<nav <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"> <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> <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>
<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')} onClick={() => onTabChange('settings')}
> >
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" /> <div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
@@ -401,6 +401,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
if (m.sequenceId <= lastReadSequenceId) { if (m.sequenceId <= lastReadSequenceId) {
const alreadyRead = m.readBy?.some((r) => r.userId === userId); const alreadyRead = m.readBy?.some((r) => r.userId === userId);
if (alreadyRead) return m; if (alreadyRead) return m;
// Увеличиваем счётчик только если текущий пользователь читает чужие сообщения
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++; if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
return { ...m, readBy: [...(m.readBy || []), { userId }] }; return { ...m, readBy: [...(m.readBy || []), { userId }] };
} }
@@ -412,6 +413,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
const updatedChats = state.chats.map((chat) => { const updatedChats = state.chats.map((chat) => {
if (chat.id === chatId) { if (chat.id === chatId) {
const updatedLastMessages = chat.messages?.map(updateMsg); const updatedLastMessages = chat.messages?.map(updateMsg);
// Уменьшаем unreadCount только если текущий пользователь прочитал сообщения
if (userId === currentUserId) { if (userId === currentUserId) {
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) }; return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
} }
@@ -180,7 +180,12 @@ export default function ChatPage() {
}); });
socket.on('messages_read', (data: any) => { socket.on('messages_read', (data: any) => {
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.lastReadSequenceId || data.LastReadSequenceId || 0); const chatId = data.chatId || data.ChatId;
const userId = data.userId || data.UserId;
const lastReadSequenceId = data.lastReadSequenceId || data.LastReadSequenceId || 0;
// Обновляем стейт - добавляем userId в readBy для всех сообщений до lastReadSequenceId
markRead(chatId, userId, lastReadSequenceId);
}); });
socket.on('user_typing', (data: { chatId: string; userId: string }) => { socket.on('user_typing', (data: { chatId: string; userId: string }) => {
@@ -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 })
@@ -188,8 +190,7 @@ 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'
}`} }`}
> >
{/* Аватар */} {/* Аватар */}
@@ -282,7 +283,11 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
<ConfirmModal <ConfirmModal
open={showDeleteConfirm} open={showDeleteConfirm}
message={isFavorites ? t('clearHistoryConfirm') : t('deleteChatConfirm')} message={
isFavorites ? t('clearHistoryConfirm') :
chat.type === 'group' ? t('deleteGroupChatConfirm') :
t('deleteChatConfirm')
}
onConfirm={confirmDelete} onConfirm={confirmDelete}
onCancel={() => setShowDeleteConfirm(false)} onCancel={() => setShowDeleteConfirm(false)}
/> />
@@ -1060,7 +1060,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
setShowTopMenu(false); setShowTopMenu(false);
if (activeChat) { if (activeChat) {
setConfirmAction({ setConfirmAction({
message: t('deleteChatConfirm'), message: chat.type === 'group' ? t('deleteGroupChatConfirm') : t('deleteChatConfirm'),
action: async () => { action: async () => {
try { try {
await ChatApi.deleteChat(activeChat); await ChatApi.deleteChat(activeChat);
@@ -1377,11 +1377,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
</> </>
)} )}
{typingInChat.length > 0 && ( {/* Typing indicator is already shown in the header, removed from here to prevent layout jumping */}
<div className="px-4 pb-1">
<TypingIndicator />
</div>
)}
{(() => { {(() => {
return ( return (
@@ -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',
@@ -455,7 +459,7 @@ function MessageBubble({
</div> </div>
)} )}
{!isMine && ( {!isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
<div className="w-8 flex-shrink-0 mr-2 self-end"> <div className="w-8 flex-shrink-0 mr-2 self-end">
{showAvatar ? ( {showAvatar ? (
<button onClick={() => onViewProfile?.(message.senderId)}> <button onClick={() => onViewProfile?.(message.senderId)}>
@@ -472,7 +476,7 @@ function MessageBubble({
)} )}
<div className={`max-[500px]:max-w-[85%] max-w-[75%] lg:max-w-[65%] min-w-0 ${isMine ? 'items-end' : 'items-start'} flex flex-col`}> <div className={`max-[500px]:max-w-[85%] max-w-[75%] lg:max-w-[65%] min-w-0 ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
{!isMine && showAvatar && ( {!isMine && showAvatar && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
<button <button
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline" className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
onClick={() => onViewProfile?.(message.senderId)} onClick={() => onViewProfile?.(message.senderId)}
@@ -610,7 +614,7 @@ function MessageBubble({
return ( return (
<div className={` <div className={`
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content ? 'mb-2' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''} ${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content || hasVoice || hasAudio || hasFile || message.type === 'poll' ? 'mb-3' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''}
${isSingleGif ? 'max-w-[260px]' : ''} ${isSingleGif ? 'max-w-[260px]' : ''}
overflow-hidden relative rounded-[1.25rem] overflow-hidden relative rounded-[1.25rem]
`}> `}>
@@ -699,6 +703,89 @@ function MessageBubble({
</span> </span>
</div> </div>
)} )}
</span>
</div>
)}
</div>
);
})()}
{/* Голосовое - Optimized Kinetic Layout */}
{hasVoice && (
<div className={`flex items-center gap-3 min-w-[200px] py-0.5 ${hasImage || hasVideo || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
<audio
ref={audioRef}
src={media.find((m) => m.type === 'voice')?.url}
preload="auto"
/>
<button
onClick={toggleAudio}
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-white text-primary' : 'bg-primary text-white'} shadow-sm transition-all active:scale-95`}
>
{isPlaying ? (
<Pause size={16} fill="currentColor" />
) : (
<Play size={16} fill="currentColor" className="ml-0.5" />
)}
</button>
<div className="flex-1 min-w-0">
<div
className="flex items-center gap-[2px] h-6 cursor-pointer"
onClick={(e) => {
const audio = audioRef.current;
if (!audio || !audio.duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * audio.duration;
setAudioProgress(pct * 100);
if (!isPlaying) toggleAudio();
}}
>
{(waveformBars || Array(28).fill(0.5)).map((val, i) => {
const barHeight = Math.max(10, val * 85);
const progress = audioProgress / 100;
const barProgress = i / 28;
const isActive = barProgress < progress;
return (
<div
key={i}
className={`flex-1 rounded-full transition-all duration-200 ${isActive
? isMine ? 'bg-[#000000] opacity-70' : 'bg-primary'
: isMine ? 'bg-[#000000] opacity-20' : 'bg-white/30'
}`}
style={{ height: `${barHeight}%` }}
/>
);
})}
</div>
<div className="flex justify-end mt-0.5">
<span className={`text-[10px] font-bold tabular-nums ${isMine ? 'text-[#0a0a0a]/60' : 'text-white/60'}`}>
{isPlaying
? formatDuration(audioRef.current?.currentTime || 0)
: formatDuration(audioDuration || message.media?.find((m) => m.type === 'voice')?.duration || 0)}
</span>
</div>
</div>
</div>
)}
{/* Аудио (mp3 файлы) */}
{hasAudio && (() => {
const audioMedia = media.find(isAudioFile);
const formatSize = (bytes?: number | null) => {
if (!bytes) return "";
if (bytes < 1024) return bytes + " B";
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + " KB";
if (bytes < 1024 * 1024 * 1024) return (bytes / (1024 * 1024)).toFixed(1) + " MB";
return (bytes / (1024 * 1024 * 1024)).toFixed(1) + " GB";
};
return (
<div className={`min-w-[220px] ${hasImage || hasVideo || hasVoice || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
{audioMedia?.filename && (
<div className="flex items-center gap-2 mb-2 min-w-0">
<Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} />
<span className={`text-[11px] font-bold truncate ${isMine ? 'text-[#0a0a0a]/80' : 'text-zinc-200'}`}>{audioMedia.filename}</span>
</div> </div>
); );
})()} })()}
@@ -774,78 +861,18 @@ function MessageBubble({
}; };
return ( return (
<div className="min-w-[220px]">
{audioMedia?.filename && (
<div className="flex items-center gap-2 mb-2 min-w-0">
<Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} />
<span className={`text-[11px] font-bold truncate ${isMine ? 'text-[#0a0a0a]/80' : 'text-zinc-200'}`}>{audioMedia.filename}</span>
</div>
)}
<div className="flex items-center gap-3">
<audio
ref={audioRef}
src={getMediaUrl(audioMedia?.url)}
preload="auto"
/>
<button
onClick={toggleAudio}
className={`w-9 h-9 rounded-full flex items-center justify-center flex-shrink-0 ${isMine ? 'bg-[#0a0a0a]/10 hover:bg-[#0a0a0a]/20 text-[#0a0a0a]' : 'bg-primary text-white shadow-lg'} transition-all active:scale-95`}
>
{isPlaying ? (
<Pause size={16} fill="currentColor" />
) : (
<Play size={16} fill="currentColor" className="ml-0.5" />
)}
</button>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-[2px] h-6 cursor-pointer"
onClick={(e) => {
const audio = audioRef.current;
if (!audio || !audio.duration) return;
const rect = e.currentTarget.getBoundingClientRect();
const pct = (e.clientX - rect.left) / rect.width;
audio.currentTime = pct * audio.duration;
setAudioProgress(pct * 100);
}}>
{Array.from({ length: 28 }).map((_, i) => {
const barHeight = [40, 65, 35, 80, 50, 90, 45, 70, 55, 85, 30, 75, 60, 95, 40, 80, 50, 70, 35, 90, 55, 65, 45, 85, 60, 75, 50, 40][i] || 50;
const progress = audioProgress / 100;
const barProgress = i / 28;
const isActive = barProgress < progress;
return (
<div
key={i}
className={`flex-1 rounded-full transition-all duration-150 ${isActive
? isMine ? 'bg-[#0a0a0a]/70' : 'bg-primary'
: isMine ? 'bg-[#0a0a0a]/10' : 'bg-white/20'
}`}
style={{ height: `${barHeight}%` }}
/>
);
})}
</div>
<div className="flex justify-between items-center mt-0.5">
<span className={`text-[10px] font-bold tabular-nums ${isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-500'}`}>
{isPlaying
? formatDuration(audioRef.current?.currentTime || 0)
: (typeof audioMedia?.duration === 'number'
? formatDuration(audioMedia.duration)
: (audioMedia?.duration || formatDuration(audioDuration || 0)))}
</span>
<div className="flex items-center gap-2">
<span className={`text-[10px] font-black uppercase tracking-tighter ${isMine ? 'text-[#0a0a0a]/40' : 'text-zinc-500'}`}>{formatSize(audioMedia?.size)}</span>
<a <a
href={getMediaUrl(audioMedia?.url)} key={m.id}
download={audioMedia?.filename || 'audio'} href={getMediaUrl(m.url)}
download={m.filename || 'file'}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className={`flex items-center justify-center p-1 rounded-md transition-all ${isMine ? 'hover:bg-[#0a0a0a]/10 text-[#0a0a0a]/40 hover:text-[#0a0a0a]' : 'hover:bg-white/10 text-zinc-500 hover:text-white'}`} className={`flex items-center gap-3 p-3 rounded-2xl ${isMine ? 'bg-[#0a0a0a]/5 hover:bg-[#0a0a0a]/10' : 'bg-zinc-900/50 hover:bg-zinc-800/80 border border-white/5'
} transition-all mb-1 group/file ${hasImage || hasVideo || hasVoice || hasAudio || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}
> >
<Download size={12} /> <div className={`w-11 h-11 rounded-xl flex items-center justify-center ${isMine ? 'bg-[#0a0a0a]/10' : 'bg-primary/20'
</a> } group-hover/file:scale-110 transition-transform`}>
</div> <FileText size={22} className={isMine ? 'text-[#0a0a0a]' : 'text-primary'} />
</div>
</div>
</div> </div>
</div> </div>
); );
@@ -1030,7 +1057,7 @@ function MessageBubble({
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u; const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15; const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
return ( return (
<div className="flex items-end gap-2 text-sm w-full"> <div className={`flex items-end gap-2 text-sm w-full ${hasImage || hasVideo || hasVoice || hasAudio || hasFile || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
<div className="flex-1 min-w-0 w-full"> <div className="flex-1 min-w-0 w-full">
<p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''} ${isMine ? 'text-[#0a0a0a] font-normal' : 'text-zinc-200'}`}> <p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''} ${isMine ? 'text-[#0a0a0a] font-normal' : 'text-zinc-200'}`}>
{renderFormattedText(message.content)} {renderFormattedText(message.content)}
@@ -1039,20 +1066,6 @@ function MessageBubble({
<div className="w-full mt-1 mb-1 relative overflow-hidden"> <div className="w-full mt-1 mb-1 relative overflow-hidden">
<LinkPreview url={firstUrl} /> <LinkPreview url={firstUrl} />
</div> </div>
)}
</div>
<span className={`text-[10px] font-bold flex-shrink-0 flex items-center gap-0.5 self-end float-right leading-none ${isOnlyEmojis ? '-mb-1' : 'mb-0.5'} ${isMine ? 'text-[#0a0a0a]/50' : 'text-on-surface-variant/40'}`}>
{message.isEdited && <span className="mr-0.5">{t('edited')}</span>}
{message.scheduledAt && <span className="material-symbols-outlined text-[12px] text-amber-400 mr-0.5">schedule</span>}
{isPinned && <Pin size={10} className={`rotate-45 ${isMine ? 'text-[#0a0a0a]/60 fill-[#0a0a0a]/20' : 'text-primary fill-primary/20'} mr-0.5`} />}
{timeStr}
{isMine && !message.scheduledAt && (
<span className={`material-symbols-outlined text-[14px] ${isRead ? 'text-[#0a0a0a]/80 fill-1' : 'text-[#0a0a0a]/40'}`} style={{ fontVariationSettings: `'FILL' ${isRead ? 1 : 0}` }}>
{isRead ? 'done_all' : 'done'}
</span>
)}
</span>
</div>
); );
})()} })()}
@@ -1083,7 +1096,7 @@ function MessageBubble({
)} )}
</div> </div>
{isMine && ( {isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
<div className="w-8 flex-shrink-0 ml-2 self-end"> <div className="w-8 flex-shrink-0 ml-2 self-end">
{showAvatar ? ( {showAvatar ? (
<button onClick={() => onViewProfile?.(message.senderId)}> <button onClick={() => onViewProfile?.(message.senderId)}>
@@ -331,9 +331,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
}); });
}; };
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { const processFiles = useCallback((files: File[]) => {
const files = Array.from(e.target.files || []);
if (files.length > 0) {
const { addNotification } = useNotificationStore.getState(); const { addNotification } = useNotificationStore.getState();
const newAttachments: Attachment[] = []; const newAttachments: Attachment[] = [];
@@ -349,8 +347,15 @@ export default function MessageInput({ chatId }: MessageInputProps) {
tooLarge = true; tooLarge = true;
continue; continue;
} }
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 isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
newAttachments.push({ file, type: isAudio ? 'audio' : 'file' });
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 (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
@@ -358,38 +363,38 @@ export default function MessageInput({ chatId }: MessageInputProps) {
setAttachments(prev => [...prev, ...newAttachments]); setAttachments(prev => [...prev, ...newAttachments]);
inputRef.current?.focus(); 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 = ''; e.target.value = '';
setShowAttachMenu(false); setShowAttachMenu(false);
}; };
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => { const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []); const files = Array.from(e.target.files || []);
if (files.length > 0) { if (files.length > 0) processFiles(files);
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();
}
e.target.value = ''; e.target.value = '';
setShowAttachMenu(false); 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 () => { const startRecording = async () => {
try { try {
@@ -569,41 +574,11 @@ export default function MessageInput({ chatId }: MessageInputProps) {
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
const files = Array.from(e.dataTransfer.files); const files = Array.from(e.dataTransfer.files);
const { addNotification } = useNotificationStore.getState(); processFiles(files);
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();
} }
}; };
const hasContent = text.trim() || attachments.length > 0; const hasContent = text.trim() || attachments.length > 0;
return ( return (
@@ -856,6 +831,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
}} }}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
onContextMenu={handleInputContextMenu} onContextMenu={handleInputContextMenu}
onPaste={handlePaste}
rows={1} 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" 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') || 'Сообщение...'} placeholder={attachments.length > 0 ? t('addCaption') : t('messagePlaceholder') || 'Сообщение...'}