Compare commits
12
Commits
android_v2
...
2c3d691f27
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2c3d691f27 | ||
|
|
0dd06ce89d | ||
|
|
962323814c | ||
|
|
fc1cf1fd6e | ||
|
|
83ed328dd5 | ||
|
|
c592197017 | ||
|
|
b65c8f3633 | ||
|
|
0f593e52e0 | ||
|
|
c839a9bc03 | ||
|
|
b2c29958b5 | ||
|
|
38e7184a0d | ||
|
|
7c66e1c0c0 |
@@ -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);
|
||||||
|
|||||||
@@ -19,13 +19,16 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
|
|
||||||
public bool IsEdited => HasState(MessageState.IsEdited);
|
public bool IsEdited => HasState(MessageState.IsEdited);
|
||||||
public bool IsDeleted => HasState(MessageState.IsDeleted);
|
public bool IsDeleted => HasState(MessageState.IsDeleted);
|
||||||
|
|
||||||
protected List<DeletedMessage> _deletedFor = new();
|
protected List<DeletedMessage> _deletedFor = new();
|
||||||
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
||||||
|
|
||||||
|
protected List<Guid> _readByUsers = new();
|
||||||
|
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
|
||||||
|
|
||||||
protected Message() : base(Guid.Empty) { }
|
protected Message() : base(Guid.Empty) { }
|
||||||
|
|
||||||
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||||
: base(id)
|
: base(id)
|
||||||
{
|
{
|
||||||
ChatId = chatId;
|
ChatId = chatId;
|
||||||
@@ -42,15 +45,25 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
|
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
|
||||||
|
|
||||||
public virtual void Delete() => AddState(MessageState.IsDeleted);
|
public virtual void Delete() => AddState(MessageState.IsDeleted);
|
||||||
public virtual void Edit(string newContent)
|
public virtual void Edit(string newContent)
|
||||||
{
|
{
|
||||||
Content = newContent;
|
Content = newContent;
|
||||||
AddState(MessageState.IsEdited);
|
AddState(MessageState.IsEdited);
|
||||||
}
|
}
|
||||||
|
|
||||||
public void DeleteForUser(Guid userId)
|
public void DeleteForUser(Guid userId)
|
||||||
{
|
{
|
||||||
if (!_deletedFor.Exists(x => x.UserId == userId))
|
if (!_deletedFor.Exists(x => x.UserId == userId))
|
||||||
_deletedFor.Add(new DeletedMessage(Id, userId));
|
_deletedFor.Add(new DeletedMessage(Id, userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void MarkAsRead(Guid userId)
|
||||||
|
{
|
||||||
|
if (!_readByUsers.Contains(userId))
|
||||||
|
{
|
||||||
|
_readByUsers.Add(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
|
||||||
}
|
}
|
||||||
|
|||||||
+22
-8
@@ -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);
|
||||||
@@ -67,7 +80,8 @@ 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);
|
||||||
|
|||||||
+8
-3
@@ -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);
|
||||||
|
|||||||
+24
-4
@@ -1,7 +1,8 @@
|
|||||||
using MediatR;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.Read;
|
namespace Knot.Modules.Conversations.Application.Messages.Read;
|
||||||
|
|
||||||
@@ -11,11 +12,13 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
|
|||||||
{
|
{
|
||||||
private readonly IChatRepository _chatRepository;
|
private readonly IChatRepository _chatRepository;
|
||||||
private readonly IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
|
private readonly IMessageRepository _messageRepository;
|
||||||
|
|
||||||
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
|
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository)
|
||||||
{
|
{
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
_unitOfWork = unitOfWork;
|
_unitOfWork = unitOfWork;
|
||||||
|
_messageRepository = messageRepository;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
|
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
|
||||||
@@ -28,6 +31,23 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
|
|||||||
|
|
||||||
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
|
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
|
||||||
|
|
||||||
|
// Обновляем ReadByUsers для всех сообщений до LastReadSequenceId
|
||||||
|
var messages = await _messageRepository.GetChatMessagesAfterAsync(
|
||||||
|
request.ChatId,
|
||||||
|
0,
|
||||||
|
1000,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
foreach (var message in messages)
|
||||||
|
{
|
||||||
|
if (message.SequenceId <= request.LastReadSequenceId &&
|
||||||
|
message.SenderId != request.UserId &&
|
||||||
|
!message.IsReadBy(request.UserId))
|
||||||
|
{
|
||||||
|
message.MarkAsRead(request.UserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
|
|||||||
+4
-3
@@ -3,10 +3,10 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
@@ -41,7 +41,8 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
|||||||
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
|
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
|
||||||
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
|
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
|
||||||
|
|
||||||
var result = messages.Select(message => {
|
var result = messages.Select(message =>
|
||||||
|
{
|
||||||
var textMessage = message as TextMessage;
|
var textMessage = message as TextMessage;
|
||||||
var mediaMessage = message as MediaMessage;
|
var mediaMessage = message as MediaMessage;
|
||||||
var storyMessage = message as StoryMessage;
|
var storyMessage = message as StoryMessage;
|
||||||
@@ -66,7 +67,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
|||||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
||||||
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||||
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
|
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
|
||||||
new List<ReadByDto>()
|
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList()
|
||||||
);
|
);
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -1,8 +1,8 @@
|
|||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Contracts.Settings.Application.Abstractions;
|
using Knot.Contracts.Settings.Application.Abstractions;
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
||||||
@@ -192,6 +192,7 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
|
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
|
||||||
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
||||||
senderMember.UpdateDeliveredCursor(message.Id);
|
senderMember.UpdateDeliveredCursor(message.Id);
|
||||||
|
message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение
|
||||||
|
|
||||||
// 5. ���������
|
// 5. ���������
|
||||||
_messageRepository.Add(message);
|
_messageRepository.Add(message);
|
||||||
|
|||||||
@@ -1,26 +1,26 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Security.Claims;
|
using System.Security.Claims;
|
||||||
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Auth.Domain;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Messaging.Domain;
|
||||||
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Delete;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Edit;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Pin;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.React;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Read;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Send;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Unpin;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Vote;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Send;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Read;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Delete;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.React;
|
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Knot.Contracts.Auth.Domain;
|
using Microsoft.Extensions.Logging;
|
||||||
using Knot.Contracts.Auth.Application.Abstractions;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Pin;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Unpin;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Vote;
|
|
||||||
using Knot.Modules.Conversations.Application.Messages.Edit;
|
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
|
||||||
using Knot.Contracts.Messaging.Domain;
|
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
|
|
||||||
@@ -37,7 +37,7 @@ public sealed class ChatHub : Hub
|
|||||||
|
|
||||||
public static int OnlineUsersCount => _userConnections.Count;
|
public static int OnlineUsersCount => _userConnections.Count;
|
||||||
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
|
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
|
||||||
|
|
||||||
// userId → CallSession (one user can be in only one call at a time)
|
// userId → CallSession (one user can be in only one call at a time)
|
||||||
private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new();
|
private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new();
|
||||||
// chatId → (startTime, callType)
|
// chatId → (startTime, callType)
|
||||||
@@ -53,12 +53,12 @@ public sealed class ChatHub : Hub
|
|||||||
private readonly IUserDisplayNameProvider _userProvider;
|
private readonly IUserDisplayNameProvider _userProvider;
|
||||||
|
|
||||||
public ChatHub(
|
public ChatHub(
|
||||||
ISender sender,
|
ISender sender,
|
||||||
IUserContext userContext,
|
IUserContext userContext,
|
||||||
IChatRepository chatRepository,
|
IChatRepository chatRepository,
|
||||||
IUserRepository userRepository,
|
IUserRepository userRepository,
|
||||||
IMessageRepository messageRepository,
|
IMessageRepository messageRepository,
|
||||||
ILogger<ChatHub> logger,
|
ILogger<ChatHub> logger,
|
||||||
IMemoryCache cache,
|
IMemoryCache cache,
|
||||||
IUserDisplayNameProvider userProvider)
|
IUserDisplayNameProvider userProvider)
|
||||||
{
|
{
|
||||||
@@ -158,6 +158,7 @@ public sealed class ChatHub : Hub
|
|||||||
await _sender.Send(command);
|
await _sender.Send(command);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отправляем событие всем в чате о том, что пользователь прочитал сообщения
|
||||||
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
|
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
|
||||||
{
|
{
|
||||||
ChatId = request.ChatId.ToString(),
|
ChatId = request.ChatId.ToString(),
|
||||||
@@ -261,7 +262,7 @@ public sealed class ChatHub : Hub
|
|||||||
{
|
{
|
||||||
var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId });
|
var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId });
|
||||||
var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>());
|
var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>());
|
||||||
|
|
||||||
await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new
|
await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new
|
||||||
{
|
{
|
||||||
chatId = request.ChatId,
|
chatId = request.ChatId,
|
||||||
@@ -276,7 +277,7 @@ public sealed class ChatHub : Hub
|
|||||||
{
|
{
|
||||||
var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
|
var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
|
||||||
var result = await _sender.Send(command);
|
var result = await _sender.Send(command);
|
||||||
|
|
||||||
await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new
|
await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new
|
||||||
{
|
{
|
||||||
chatId = request.ChatId,
|
chatId = request.ChatId,
|
||||||
@@ -328,7 +329,7 @@ public sealed class ChatHub : Hub
|
|||||||
public async Task FriendAccepted(FriendSignalRequest request)
|
public async Task FriendAccepted(FriendSignalRequest request)
|
||||||
{
|
{
|
||||||
if (request == null || string.IsNullOrEmpty(request.FriendId)) return;
|
if (request == null || string.IsNullOrEmpty(request.FriendId)) return;
|
||||||
|
|
||||||
_logger.LogInformation("Signaling friend_request_accepted to {FriendId} from {UserId}", request.FriendId, _userContext.UserId);
|
_logger.LogInformation("Signaling friend_request_accepted to {FriendId} from {UserId}", request.FriendId, _userContext.UserId);
|
||||||
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
|
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
|
||||||
}
|
}
|
||||||
@@ -350,8 +351,8 @@ public sealed class ChatHub : Hub
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(targetUserId))
|
if (string.IsNullOrEmpty(targetUserId))
|
||||||
{
|
{
|
||||||
_logger.LogWarning("SendToUserAsync called with null or empty targetUserId");
|
_logger.LogWarning("SendToUserAsync called with null or empty targetUserId");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
|
if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
|
||||||
@@ -398,15 +399,15 @@ public sealed class ChatHub : Hub
|
|||||||
// Track session for history
|
// Track session for history
|
||||||
Guid? chatId = null;
|
Guid? chatId = null;
|
||||||
if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId;
|
if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId;
|
||||||
|
|
||||||
if (!chatId.HasValue)
|
if (!chatId.HasValue)
|
||||||
{
|
{
|
||||||
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
|
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||||
if (Guid.TryParse(request.TargetUserId, out var targetId))
|
if (Guid.TryParse(request.TargetUserId, out var targetId))
|
||||||
{
|
{
|
||||||
var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId));
|
var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId));
|
||||||
if (personalChat != null) chatId = personalChat.Id;
|
if (personalChat != null) chatId = personalChat.Id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow);
|
var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow);
|
||||||
@@ -461,10 +462,10 @@ public sealed class ChatHub : Hub
|
|||||||
_activeSessionsByUser.TryRemove(request.TargetUserId, out _);
|
_activeSessionsByUser.TryRemove(request.TargetUserId, out _);
|
||||||
if (session.ChatId.HasValue)
|
if (session.ChatId.HasValue)
|
||||||
{
|
{
|
||||||
int duration = session.IsAnswered && session.AnswerTime.HasValue
|
int duration = session.IsAnswered && session.AnswerTime.HasValue
|
||||||
? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds
|
? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds
|
||||||
: 0;
|
: 0;
|
||||||
|
|
||||||
string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed");
|
string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed");
|
||||||
await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration);
|
await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration);
|
||||||
}
|
}
|
||||||
@@ -573,7 +574,7 @@ public sealed class ChatHub : Hub
|
|||||||
|
|
||||||
var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
|
var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
|
||||||
|
|
||||||
var participants = _groupCallParticipants.GetOrAdd(chatId, _ =>
|
var participants = _groupCallParticipants.GetOrAdd(chatId, _ =>
|
||||||
{
|
{
|
||||||
_activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType);
|
_activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType);
|
||||||
return new ConcurrentDictionary<string, ParticipantInfo>();
|
return new ConcurrentDictionary<string, ParticipantInfo>();
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
public Guid SenderId { get; protected set; }
|
public Guid SenderId { get; protected set; }
|
||||||
public DateTime CreatedAt { get; protected set; }
|
public DateTime CreatedAt { get; protected set; }
|
||||||
public long SequenceId { get; protected set; }
|
public long SequenceId { get; protected set; }
|
||||||
|
|
||||||
public void SetSequenceId(long sequenceId)
|
public void SetSequenceId(long sequenceId)
|
||||||
{
|
{
|
||||||
SequenceId = sequenceId;
|
SequenceId = sequenceId;
|
||||||
@@ -26,10 +26,10 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
// ================== Опциональные метаданные (общего назначения) ==================
|
// ================== Опциональные метаданные (общего назначения) ==================
|
||||||
public Guid? ReplyToId { get; protected set; }
|
public Guid? ReplyToId { get; protected set; }
|
||||||
public Guid? ForwardedFromId { get; protected set; }
|
public Guid? ForwardedFromId { get; protected set; }
|
||||||
|
|
||||||
// ================== Флаги ==================
|
// ================== Флаги ==================
|
||||||
public MessageState State { get; protected set; }
|
public MessageState State { get; protected set; }
|
||||||
|
|
||||||
// ================== Абстрактные / Виртуальные свойства ==================
|
// ================== Абстрактные / Виртуальные свойства ==================
|
||||||
public abstract string Type { get; }
|
public abstract string Type { get; }
|
||||||
public abstract string? Content { get; protected set; }
|
public abstract string? Content { get; protected set; }
|
||||||
@@ -42,16 +42,20 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
protected List<DeletedMessage> _deletedFor = new();
|
protected List<DeletedMessage> _deletedFor = new();
|
||||||
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
||||||
|
|
||||||
|
// ================== Прочитано ==================
|
||||||
|
protected List<Guid> _readByUsers = new();
|
||||||
|
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
|
||||||
|
|
||||||
// ================== Инфраструктурный конструктор EF ==================
|
// ================== Инфраструктурный конструктор EF ==================
|
||||||
protected Message() : base(Guid.Empty) { }
|
protected Message() : base(Guid.Empty) { }
|
||||||
|
|
||||||
protected Message(
|
protected Message(
|
||||||
Guid id,
|
Guid id,
|
||||||
Guid chatId,
|
Guid chatId,
|
||||||
Guid senderId,
|
Guid senderId,
|
||||||
Guid? replyToId,
|
Guid? replyToId,
|
||||||
Guid? forwardedFromId,
|
Guid? forwardedFromId,
|
||||||
DateTime createdAt,
|
DateTime createdAt,
|
||||||
bool isImported) : base(id)
|
bool isImported) : base(id)
|
||||||
{
|
{
|
||||||
ChatId = chatId;
|
ChatId = chatId;
|
||||||
@@ -59,7 +63,7 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
ReplyToId = replyToId;
|
ReplyToId = replyToId;
|
||||||
ForwardedFromId = forwardedFromId;
|
ForwardedFromId = forwardedFromId;
|
||||||
CreatedAt = createdAt;
|
CreatedAt = createdAt;
|
||||||
|
|
||||||
if (isImported) AddState(MessageState.IsImported);
|
if (isImported) AddState(MessageState.IsImported);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +93,16 @@ public abstract class Message : AggregateRoot<Guid>
|
|||||||
_deletedFor.Add(new DeletedMessage(Id, userId));
|
_deletedFor.Add(new DeletedMessage(Id, userId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void MarkAsRead(Guid userId)
|
||||||
|
{
|
||||||
|
if (!_readByUsers.Contains(userId))
|
||||||
|
{
|
||||||
|
_readByUsers.Add(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -84,7 +84,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
|
|||||||
size = m.Size
|
size = m.Size
|
||||||
}).ToList() ?? (object)Array.Empty<object>(),
|
}).ToList() ?? (object)Array.Empty<object>(),
|
||||||
sender = senderObj,
|
sender = senderObj,
|
||||||
readBy = new List<object>(),
|
readBy = message.ReadByUsers.Select(id => new { id }).ToList(),
|
||||||
storyId = (message as StoryMessage)?.StoryId,
|
storyId = (message as StoryMessage)?.StoryId,
|
||||||
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
|
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
|
||||||
storyMediaType = (message as StoryMessage)?.StoryMediaType,
|
storyMediaType = (message as StoryMessage)?.StoryMediaType,
|
||||||
|
|||||||
@@ -95,14 +95,28 @@ 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;
|
||||||
|
|
||||||
// Target message
|
// Target message
|
||||||
var targetFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Eq(m => m.SequenceId, sequenceId));
|
var targetFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Eq(m => m.SequenceId, sequenceId));
|
||||||
var targetMsg = await _messages.Find(targetFilter).FirstOrDefaultAsync(cancellationToken);
|
var targetMsg = await _messages.Find(targetFilter).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
// Older messages
|
// Older messages
|
||||||
var olderFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Lt(m => m.SequenceId, sequenceId));
|
var olderFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Lt(m => m.SequenceId, sequenceId));
|
||||||
var older = await _messages.Find(olderFilter)
|
var older = await _messages.Find(olderFilter)
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -122,8 +122,18 @@ dependencies {
|
|||||||
val room_version = "2.6.1"
|
val room_version = "2.6.1"
|
||||||
implementation("androidx.room:room-runtime:$room_version")
|
implementation("androidx.room:room-runtime:$room_version")
|
||||||
implementation("androidx.room:room-ktx:$room_version")
|
implementation("androidx.room:room-ktx:$room_version")
|
||||||
|
implementation("androidx.room:room-paging:$room_version")
|
||||||
kapt("androidx.room:room-compiler:$room_version")
|
kapt("androidx.room:room-compiler:$room_version")
|
||||||
|
|
||||||
|
// Paging 3
|
||||||
|
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
|
||||||
|
implementation("androidx.paging:paging-compose:3.2.1")
|
||||||
|
|
||||||
|
// WorkManager
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
|
implementation("androidx.hilt:hilt-work:1.1.0")
|
||||||
|
kapt("androidx.hilt:hilt-compiler:1.1.0")
|
||||||
|
|
||||||
// Testing
|
// Testing
|
||||||
testImplementation("junit:junit:4.13.2")
|
testImplementation("junit:junit:4.13.2")
|
||||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
package="ru.knot.messager">
|
package="ru.knot.messager">
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||||
|
|||||||
@@ -22,7 +22,9 @@ class MainActivity : ComponentActivity() {
|
|||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
android.util.Log.d("MainActivity", "onCreate called")
|
||||||
signalrNotificationObserver.start()
|
signalrNotificationObserver.start()
|
||||||
|
android.util.Log.d("MainActivity", "signalrNotificationObserver.start() called")
|
||||||
|
|
||||||
intent.getStringExtra("chatId")?.let { chatId ->
|
intent.getStringExtra("chatId")?.let { chatId ->
|
||||||
navigationManager.navigateToChat(chatId)
|
navigationManager.navigateToChat(chatId)
|
||||||
|
|||||||
@@ -16,3 +16,4 @@ class MainApplication : Application(), ImageLoaderFactory {
|
|||||||
.build()
|
.build()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -60,4 +60,5 @@
|
|||||||
<string name="reply_gif">GIF</string>
|
<string name="reply_gif">GIF</string>
|
||||||
<string name="reply_prefix">Reply to </string>
|
<string name="reply_prefix">Reply to </string>
|
||||||
<string name="reply_self">yourself</string>
|
<string name="reply_self">yourself</string>
|
||||||
|
<string name="no_messages_yet">No messages yet</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -82,4 +82,5 @@
|
|||||||
<string name="reply_gif">GIF</string>
|
<string name="reply_gif">GIF</string>
|
||||||
<string name="reply_prefix">Ответ </string>
|
<string name="reply_prefix">Ответ </string>
|
||||||
<string name="reply_self">самому себе</string>
|
<string name="reply_self">самому себе</string>
|
||||||
|
<string name="no_messages_yet">Сообщений пока нет</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
# Архитектура Offline-first для мессенджера Knot
|
||||||
|
|
||||||
|
## Обзор
|
||||||
|
|
||||||
|
Система кэширования истории чатов реализует паттерн **Offline-first** с использованием:
|
||||||
|
- **Room** - локальная база данных
|
||||||
|
- **Paging 3** - пагинация с RemoteMediator
|
||||||
|
- **WorkManager** - фоновая синхронизация
|
||||||
|
- **SignalR** - real-time обновления
|
||||||
|
|
||||||
|
## Компоненты
|
||||||
|
|
||||||
|
### 1. Data Layer
|
||||||
|
|
||||||
|
#### MessageEntity
|
||||||
|
```kotlin
|
||||||
|
@Entity(tableName = "messages")
|
||||||
|
data class MessageEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val chatId: String,
|
||||||
|
val senderId: String,
|
||||||
|
val content: String?,
|
||||||
|
val sequenceId: Int,
|
||||||
|
val createdAt: String,
|
||||||
|
|
||||||
|
// Поля синхронизации
|
||||||
|
val syncStatus: SyncStatus, // SYNCED, SYNCING, FAILED
|
||||||
|
val isDeletedLocally: Boolean, // Помечено на удаление
|
||||||
|
val isEditedLocally: Boolean, // Помечено на редактирование
|
||||||
|
val editedContent: String?, // Новое содержимое
|
||||||
|
val lastUpdated: Long // Время последнего изменения
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### MessageDao
|
||||||
|
Основные методы:
|
||||||
|
- `getMessagesPagingSource()` - PagingSource для Paging 3
|
||||||
|
- `upsertMessage()` - Вставка/обновление с разрешением конфликтов
|
||||||
|
- `markAsDeletedLocally()` - Пометка на удаление
|
||||||
|
- `markAsEditedLocally()` - Пометка на редактирование
|
||||||
|
- `getPendingSyncMessages()` - Получение сообщений для синхронизации
|
||||||
|
|
||||||
|
### 2. Pagination (Paging 3)
|
||||||
|
|
||||||
|
#### MessageRemoteMediator
|
||||||
|
Управляет загрузкой данных:
|
||||||
|
- **REFRESH** - первая загрузка последних сообщений
|
||||||
|
- **APPEND** - загрузка более старых сообщений (прокрутка вниз)
|
||||||
|
- **PREPEND** - загрузка более новых сообщений (прокрутка вверх)
|
||||||
|
|
||||||
|
Логика:
|
||||||
|
1. Проверяет наличие данных в Room
|
||||||
|
2. При необходимости загружает из API
|
||||||
|
3. Сохраняет в Room
|
||||||
|
4. Paging читает из локальной базы
|
||||||
|
|
||||||
|
### 3. Background Sync (WorkManager)
|
||||||
|
|
||||||
|
#### MessageSyncWorker
|
||||||
|
Обрабатывает отложенную синхронизацию:
|
||||||
|
- Отправка новых сообщений (SYNCING)
|
||||||
|
- Обновление отредактированных (isEditedLocally = true)
|
||||||
|
- Удаление помеченных (isDeletedLocally = true)
|
||||||
|
- Повтор при ошибках (FAILED)
|
||||||
|
|
||||||
|
Политика повторных попыток:
|
||||||
|
- Экспоненциальная задержка
|
||||||
|
- Максимум 3 попытки
|
||||||
|
- Требуется подключение к сети
|
||||||
|
|
||||||
|
### 4. Real-time Updates (SignalR)
|
||||||
|
|
||||||
|
#### MessageSignalRHandler
|
||||||
|
Обрабатывает события:
|
||||||
|
- `new_message` - новое сообщение
|
||||||
|
- `message_edited` - редактирование
|
||||||
|
- `message_deleted` - удаление
|
||||||
|
- `messages_read` - прочтение
|
||||||
|
- `reaction_added/removed` - реакции
|
||||||
|
|
||||||
|
Все изменения сразу записываются в Room → UI обновляется через Flow
|
||||||
|
|
||||||
|
### 5. Repository
|
||||||
|
|
||||||
|
#### ChatRepositoryImpl
|
||||||
|
Единая точка входа для ViewModel:
|
||||||
|
- `getMessagesPaging()` - Paging 3 поток
|
||||||
|
- `getMessagesFlow()` - простой Flow списка
|
||||||
|
- `sendMessage()` - отправка с локальным сохранением
|
||||||
|
- `deleteLocalMessage()` - локальное удаление
|
||||||
|
- `editLocalMessage()` - локальное редактирование
|
||||||
|
|
||||||
|
## Conflict Resolution
|
||||||
|
|
||||||
|
Приоритет данных:
|
||||||
|
1. **Сообщения в процессе отправки (SYNCING)** - локальные данные имеют приоритет
|
||||||
|
2. **Сообщения в процессе редактирования** - локальные данные имеют приоритет
|
||||||
|
3. **Все остальные случаи** - серверные данные имеют приоритет
|
||||||
|
|
||||||
|
## Схема работы
|
||||||
|
|
||||||
|
### Отправка сообщения
|
||||||
|
```
|
||||||
|
User → sendMessage() → Сохранение в Room (SYNCING) → UI показывает сообщение
|
||||||
|
→ WorkManager планирует синхронизацию
|
||||||
|
→ Отправка на сервер
|
||||||
|
→ Обновление статуса (SYNCED)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Получение сообщений
|
||||||
|
```
|
||||||
|
UI ← getMessagesPaging() ← Room ← RemoteMediator ← API
|
||||||
|
↑
|
||||||
|
└─── SignalR обновления
|
||||||
|
```
|
||||||
|
|
||||||
|
### Удаление сообщения
|
||||||
|
```
|
||||||
|
User → deleteLocalMessage() → Пометка (isDeletedLocally = true)
|
||||||
|
→ WorkManager удаляет на сервере
|
||||||
|
→ Удаление из Room
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
### Paging 3 в ViewModel
|
||||||
|
```kotlin
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
val messages: Flow<PagingData<Message>> =
|
||||||
|
repository.getMessagesPaging(chatId)
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Офлайн отправка
|
||||||
|
```kotlin
|
||||||
|
// Сообщение сразу появится в UI
|
||||||
|
val message = repository.sendMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
content = "Hello"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Синхронизация произойдёт в фоне
|
||||||
|
```
|
||||||
|
|
||||||
|
## Миграции
|
||||||
|
|
||||||
|
При обновлении схемы БД используется миграция `MIGRATION_1_2`:
|
||||||
|
- Добавляет поля синхронизации
|
||||||
|
- Сохраняет существующие данные
|
||||||
|
- Устанавливает значения по умолчанию
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
### Юнит-тесты
|
||||||
|
- MessageDao тесты
|
||||||
|
- MessageRemoteMediator тесты
|
||||||
|
- ChatRepositoryImpl тесты
|
||||||
|
|
||||||
|
### Интеграционные тесты
|
||||||
|
- Синхронизация с сервером
|
||||||
|
- Обработка конфликтов
|
||||||
|
- WorkManager сценарии
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# Сводка реализации системы кэширования
|
||||||
|
|
||||||
|
## 📁 Созданные файлы
|
||||||
|
|
||||||
|
### Data Layer
|
||||||
|
1. **core/database/data/ChatDatabase.kt** (обновлён)
|
||||||
|
- Добавлены поля синхронизации в MessageEntity
|
||||||
|
- Расширен MessageDao методами для Paging и офлайн-операций
|
||||||
|
- Добавлен SyncStatusConverter для Room
|
||||||
|
|
||||||
|
2. **core/database/data/Migrations.kt** (новый)
|
||||||
|
- Миграция MIGRATION_1_2 для обновления схемы БД
|
||||||
|
|
||||||
|
3. **core/di/DatabaseModule.kt** (обновлён)
|
||||||
|
- Добавлена миграция
|
||||||
|
- Изменено имя БД на константу
|
||||||
|
|
||||||
|
4. **core/di/WorkManagerModule.kt** (новый)
|
||||||
|
- DI модуль для WorkManager
|
||||||
|
|
||||||
|
### Paging 3
|
||||||
|
5. **chats/data/paging/MessageRemoteMediator.kt** (новый)
|
||||||
|
- RemoteMediator для загрузки данных из сети
|
||||||
|
- Управление пагинацией (REFRESH, APPEND, PREPEND)
|
||||||
|
- Сохранение в Room
|
||||||
|
|
||||||
|
6. **chats/data/paging/MessagePagingSource.kt** (новый)
|
||||||
|
- PagingSource для чтения из Room
|
||||||
|
|
||||||
|
### Sync (WorkManager)
|
||||||
|
7. **chats/data/sync/MessageSyncWorker.kt** (новый)
|
||||||
|
- Worker для фоновой синхронизации
|
||||||
|
- Обработка отправки, редактирования, удаления
|
||||||
|
- Политика повторных попыток (exponential backoff)
|
||||||
|
|
||||||
|
### SignalR Integration
|
||||||
|
8. **chats/data/signalr/MessageSignalRHandler.kt** (новый)
|
||||||
|
- Обработчик SignalR событий
|
||||||
|
- Обновление локального кэша в реальном времени
|
||||||
|
- Разрешение конфликтов
|
||||||
|
|
||||||
|
### Repository
|
||||||
|
9. **chats/domain/repository/ChatRepository.kt** (обновлён)
|
||||||
|
- Добавлен метод getMessagesPaging()
|
||||||
|
- Добавлены editLocalMessage()
|
||||||
|
|
||||||
|
10. **chats/data/repository/ChatRepositoryImpl.kt** (обновлён)
|
||||||
|
- Полная реализация Offline-first
|
||||||
|
- Интеграция Paging 3, SignalR, WorkManager
|
||||||
|
- Conflict Resolution логика
|
||||||
|
|
||||||
|
### DI
|
||||||
|
11. **chats/di/ChatModule.kt** (обновлён)
|
||||||
|
- Регистрация MessageSignalRHandler
|
||||||
|
- Обновлён ChatRepositoryImpl с новыми зависимостями
|
||||||
|
|
||||||
|
### Domain Models
|
||||||
|
12. **chats/domain/model/ChatModels.kt** (обновлён)
|
||||||
|
- Добавлен ChatMember
|
||||||
|
- Добавлено поле members в Chat
|
||||||
|
|
||||||
|
### Application
|
||||||
|
13. **app/src/main/kotlin/com/knot/messenger/MainApplication.kt** (обновлён)
|
||||||
|
- Реализация Configuration.Provider для WorkManager
|
||||||
|
- Интеграция Hilt WorkerFactory
|
||||||
|
|
||||||
|
### Документация
|
||||||
|
14. **chats/ARCHITECTURE.md** (новый)
|
||||||
|
- Описание архитектуры
|
||||||
|
- Схема работы компонентов
|
||||||
|
|
||||||
|
15. **chats/USAGE_EXAMPLES.md** (новый)
|
||||||
|
- Примеры использования
|
||||||
|
- Best practices
|
||||||
|
|
||||||
|
## 🔧 Изменения в зависимостях (app/build.gradle.kts)
|
||||||
|
|
||||||
|
Добавлено:
|
||||||
|
```kotlin
|
||||||
|
// Paging 3
|
||||||
|
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
|
||||||
|
implementation("androidx.paging:paging-compose:3.2.1")
|
||||||
|
|
||||||
|
// WorkManager + Hilt
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
|
implementation("androidx.hilt:hilt-work:1.1.0")
|
||||||
|
kapt("androidx.hilt:hilt-compiler:1.1.0")
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🏗️ Архитектурные решения
|
||||||
|
|
||||||
|
### 1. Offline-first подход
|
||||||
|
- Все данные читаются из локальной Room базы
|
||||||
|
- Сетевые запросы только для синхронизации
|
||||||
|
- UI всегда работает с локальными данными
|
||||||
|
|
||||||
|
### 2. Paging 3 с RemoteMediator
|
||||||
|
- Единый источник истины - Room
|
||||||
|
- RemoteMediator управляет загрузкой из сети
|
||||||
|
- Автоматическая инвалидация при изменениях
|
||||||
|
|
||||||
|
### 3. Conflict Resolution
|
||||||
|
- **SYNCING/EDITING**: локальные данные имеют приоритет
|
||||||
|
- **SYNCED**: серверные данные имеют приоритет
|
||||||
|
- SignalR события применяются аккуратно
|
||||||
|
|
||||||
|
### 4. Background Sync
|
||||||
|
- WorkManager для надёжной доставки
|
||||||
|
- Exponential backoff при ошибках
|
||||||
|
- Требуется NetzwerkType.CONNECTED
|
||||||
|
|
||||||
|
### 5. Real-time Updates
|
||||||
|
- SignalR события → Room → Flow → UI
|
||||||
|
- Автоматическое обновление UI
|
||||||
|
- Минимальная задержка
|
||||||
|
|
||||||
|
## 📊 Схема потока данных
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌─────────────┐
|
||||||
|
│ SignalR │────▶│ SignalR │────▶│ Room │
|
||||||
|
│ (Server) │ │ Handler │ │ (SQLite) │
|
||||||
|
└─────────────┘ └──────────────┘ └──────┬──────┘
|
||||||
|
│
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌──────▼──────┐
|
||||||
|
│ API │◀───▶│ Remote │◀───▶│ Paging │
|
||||||
|
│ (Retrofit) │ │ Mediator │ │ Source │
|
||||||
|
└─────────────┘ └──────────────┘ └──────┬──────┘
|
||||||
|
│
|
||||||
|
┌─────────────┐ ┌──────────────┐ ┌──────▼──────┐
|
||||||
|
│ WorkManager│◀───▶│ Repository │◀───▶│ UI │
|
||||||
|
│ (Sync) │ │ │ │ (Flow) │
|
||||||
|
└─────────────┘ └──────────────┘ └─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## ✅ Checklist реализации
|
||||||
|
|
||||||
|
- [x] MessageEntity с полями синхронизации
|
||||||
|
- [x] MessageDao с PagingSource методами
|
||||||
|
- [x] MessageRemoteMediator для Paging 3
|
||||||
|
- [x] MessageSyncWorker для WorkManager
|
||||||
|
- [x] MessageSignalRHandler для real-time
|
||||||
|
- [x] ChatRepositoryImpl с полной логикой
|
||||||
|
- [x] DI модули обновлены
|
||||||
|
- [x] Миграция БД
|
||||||
|
- [x] Hilt Worker интеграция
|
||||||
|
- [x] Документация
|
||||||
|
|
||||||
|
## 🚀 Следующие шаги
|
||||||
|
|
||||||
|
1. **Тестирование**
|
||||||
|
- Юнит-тесты для MessageDao
|
||||||
|
- Интеграционные тесты для Repository
|
||||||
|
- UI тесты с Paging 3
|
||||||
|
|
||||||
|
2. **Мониторинг**
|
||||||
|
- Логирование синхронизации
|
||||||
|
- Метрики ошибок
|
||||||
|
- Analytics офлайн-режима
|
||||||
|
|
||||||
|
3. **Оптимизация**
|
||||||
|
- Индексы в БД для производительности
|
||||||
|
- Кэширование изображений
|
||||||
|
- Оптимизация запросов
|
||||||
|
|
||||||
|
4. **Улучшения**
|
||||||
|
- Поиск по сообщениям
|
||||||
|
- Избранные сообщения
|
||||||
|
- Архивация чатов
|
||||||
|
|
||||||
|
## 🔍 Ключевые особенности
|
||||||
|
|
||||||
|
1. **Мгновенный UI** - сообщения появляются сразу
|
||||||
|
2. **Надёжная синхронизация** - WorkManager гарантирует доставку
|
||||||
|
3. **Real-time** - SignalR для мгновенных обновлений
|
||||||
|
4. **Офлайн-работа** - полное функционирование без сети
|
||||||
|
5. **Разрешение конфликтов** - умная логика приоритетов
|
||||||
|
6. **Пагинация** - эффективная работа с большими чатами
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
# Руководство по интеграции
|
||||||
|
|
||||||
|
## Быстрый старт
|
||||||
|
|
||||||
|
### 1. Добавление зависимостей
|
||||||
|
|
||||||
|
В `app/build.gradle.kts` уже добавлены:
|
||||||
|
```kotlin
|
||||||
|
// Paging 3
|
||||||
|
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
|
||||||
|
implementation("androidx.paging:paging-compose:3.2.1")
|
||||||
|
|
||||||
|
// WorkManager + Hilt
|
||||||
|
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||||
|
implementation("androidx.hilt:hilt-work:1.1.0")
|
||||||
|
kapt("androidx.hilt:hilt-compiler:1.1.0")
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Обновление Application класса
|
||||||
|
|
||||||
|
`MainApplication.kt` уже обновлён:
|
||||||
|
```kotlin
|
||||||
|
@HiltAndroidApp
|
||||||
|
class MainApplication : Application(), ImageLoaderFactory, Configuration.Provider {
|
||||||
|
@Inject lateinit var workerFactory: WorkerFactory
|
||||||
|
|
||||||
|
override val workManagerConfiguration: Configuration
|
||||||
|
get() = Configuration.Builder()
|
||||||
|
.setWorkerFactory(workerFactory)
|
||||||
|
.setMinimumLoggingLevel(android.util.Log.INFO)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Миграция базы данных
|
||||||
|
|
||||||
|
База данных автоматически обновится при первом запуске благодаря `MIGRATION_1_2`.
|
||||||
|
|
||||||
|
## Использование в ViewModel
|
||||||
|
|
||||||
|
### Вариант 1: Paging 3 (рекомендуется для больших чатов)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
savedStateHandle: SavedStateHandle
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val chatId: String = savedStateHandle["chatId"] ?: ""
|
||||||
|
|
||||||
|
val messages: Flow<PagingData<Message>> = repository
|
||||||
|
.getMessagesPaging(chatId)
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
fun sendMessage(content: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.sendMessage(chatId, content, "text")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun deleteMessage(messageId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteLocalMessage(messageId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Вариант 2: Простой Flow (для небольших чатов)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
savedStateHandle: SavedStateHandle
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val chatId: String = savedStateHandle["chatId"] ?: ""
|
||||||
|
|
||||||
|
val messages: Flow<List<Message>> = repository
|
||||||
|
.getMessagesFlow(chatId)
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), emptyList())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Использование в UI (Compose)
|
||||||
|
|
||||||
|
### С Paging 3
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) {
|
||||||
|
val messages by viewModel.messages.collectAsLazyPagingItems()
|
||||||
|
|
||||||
|
LazyColumn(
|
||||||
|
reverseLayout = true, // Сообщения снизу вверх
|
||||||
|
modifier = Modifier.fillMaxSize()
|
||||||
|
) {
|
||||||
|
items(
|
||||||
|
count = messages.itemCount,
|
||||||
|
key = messages.key
|
||||||
|
) { index ->
|
||||||
|
messages[index]?.let { message ->
|
||||||
|
MessageItem(message = message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Индикатор загрузки
|
||||||
|
when {
|
||||||
|
messages.loadState.refresh is LoadState.Loading -> {
|
||||||
|
item { LoadingIndicator() }
|
||||||
|
}
|
||||||
|
messages.loadState.append is LoadState.Loading -> {
|
||||||
|
item { LoadingIndicator() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ошибки
|
||||||
|
messages.loadState.append.let { loadState ->
|
||||||
|
if (loadState is LoadState.Error) {
|
||||||
|
item {
|
||||||
|
Text("Ошибка: ${loadState.error.message}")
|
||||||
|
Button(onClick = { messages.retry() }) {
|
||||||
|
Text("Повторить")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### С простым Flow
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) {
|
||||||
|
val messages by viewModel.messages.collectAsState()
|
||||||
|
|
||||||
|
LazyColumn(reverseLayout = true) {
|
||||||
|
items(messages) { message ->
|
||||||
|
MessageItem(message = message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Отправка сообщения
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Мгновенное отображение в UI
|
||||||
|
viewModel.sendMessage("Привет!")
|
||||||
|
|
||||||
|
// Сообщение сохраняется локально и появляется в UI сразу
|
||||||
|
// WorkManager отправит его на сервер в фоне
|
||||||
|
```
|
||||||
|
|
||||||
|
## Удаление сообщения
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Мягкое удаление (через WorkManager)
|
||||||
|
viewModel.deleteMessage(messageId)
|
||||||
|
|
||||||
|
// Или немедленное удаление
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteMessage(messageId, forEveryone = false)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Редактирование сообщения
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.editLocalMessage(messageId, "Новый текст")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Мониторинг синхронизации
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// В ViewModel
|
||||||
|
val syncStatus: Flow<List<MessageEntity>> = messageDao
|
||||||
|
.getPendingSyncMessagesFlow()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList())
|
||||||
|
|
||||||
|
// В UI
|
||||||
|
val pendingMessages by syncStatus.collectAsState()
|
||||||
|
if (pendingMessages.isNotEmpty()) {
|
||||||
|
Text("${pendingMessages.size} сообщений ожидают отправки")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Обработка офлайн-режима
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun MessageItem(message: Message) {
|
||||||
|
val isPending = message.id.startsWith("local_")
|
||||||
|
|
||||||
|
Row(modifier = Modifier.fillMaxWidth()) {
|
||||||
|
Text(
|
||||||
|
text = message.content ?: "",
|
||||||
|
modifier = Modifier.weight(1f)
|
||||||
|
)
|
||||||
|
|
||||||
|
// Индикатор отправки
|
||||||
|
if (isPending) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
strokeWidth = 2.dp
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Статус прочтения
|
||||||
|
Icon(
|
||||||
|
imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Done,
|
||||||
|
contentDescription = null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Проверка сборки
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd client-mobile
|
||||||
|
./gradlew assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
## Возможные проблемы и решения
|
||||||
|
|
||||||
|
### 1. Ошибка: "WorkerFactory not found"
|
||||||
|
**Решение:** Убедитесь, что `MainApplication` реализует `Configuration.Provider`
|
||||||
|
|
||||||
|
### 2. Ошибка: "Table messages has no column named syncStatus"
|
||||||
|
**Решение:** Проверьте, что миграция `MIGRATION_1_2` добавлена в `DatabaseModule`
|
||||||
|
|
||||||
|
### 3. Paging не загружает данные
|
||||||
|
**Решение:** Проверьте логи `MessageRemoteMediator` - возможны проблемы с API
|
||||||
|
|
||||||
|
### 4. Сообщения не синхронизируются
|
||||||
|
**Решение:** Проверьте WorkManager логи и наличие сетевого подключения
|
||||||
|
|
||||||
|
### 5. SignalR не подключается
|
||||||
|
**Решение:** Проверьте `ChatHubClient.connect()` - должен вызываться после авторизации
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
### Юнит-тесты
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `message saved locally should have SYNCING status`() = runTest {
|
||||||
|
val message = MessageEntity(
|
||||||
|
id = "test",
|
||||||
|
chatId = "chat1",
|
||||||
|
// ...
|
||||||
|
syncStatus = SyncStatus.SYNCING
|
||||||
|
)
|
||||||
|
dao.insertMessage(message)
|
||||||
|
|
||||||
|
val saved = dao.getMessageById("test")
|
||||||
|
assertEquals(SyncStatus.SYNCING, saved?.syncStatus)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Интеграционные тесты
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Test
|
||||||
|
fun `sending message should save locally and sync to server`() = runTest {
|
||||||
|
// Arrange
|
||||||
|
val repository = ChatRepositoryImpl(...)
|
||||||
|
|
||||||
|
// Act
|
||||||
|
val message = repository.sendMessage("chat1", "Hello", "text")
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assertTrue(message.id.startsWith("local_"))
|
||||||
|
|
||||||
|
// Wait for sync
|
||||||
|
delay(5000)
|
||||||
|
|
||||||
|
val synced = dao.getMessageById(message.id)
|
||||||
|
assertEquals(SyncStatus.SYNCED, synced?.syncStatus)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Дополнительные ресурсы
|
||||||
|
|
||||||
|
- [Paging 3 Documentation](https://developer.android.com/topic/libraries/architecture/paging/v3-overview)
|
||||||
|
- [WorkManager Documentation](https://developer.android.com/topic/libraries/architecture/workmanager)
|
||||||
|
- [Room Documentation](https://developer.android.com/training/data-storage/room)
|
||||||
|
- [ARCHITECTURE.md](ARCHITECTURE.md) - детальное описание архитектуры
|
||||||
|
- [USAGE_EXAMPLES.md](USAGE_EXAMPLES.md) - больше примеров использования
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
# Примеры использования системы кэширования
|
||||||
|
|
||||||
|
## 1. Paging 3 в ViewModel
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
savedStateHandle: SavedStateHandle
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val chatId: String = savedStateHandle["chatId"] ?: ""
|
||||||
|
|
||||||
|
// Paging 3 поток для UI
|
||||||
|
val messages: Flow<PagingData<Message>> = repository
|
||||||
|
.getMessagesPaging(chatId)
|
||||||
|
.cachedIn(viewModelScope)
|
||||||
|
|
||||||
|
// Простой Flow для небольших чатов
|
||||||
|
val messagesList: Flow<List<Message>> = repository
|
||||||
|
.getMessagesFlow(chatId)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. UI с Paging 3 (Jetpack Compose)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@Composable
|
||||||
|
fun ChatScreen(viewModel: ChatViewModel = hiltViewModel()) {
|
||||||
|
val messages by viewModel.messages.collectAsLazyPagingItems()
|
||||||
|
|
||||||
|
LazyColumn {
|
||||||
|
items(
|
||||||
|
count = messages.itemCount,
|
||||||
|
key = messages.key
|
||||||
|
) { index ->
|
||||||
|
val message = messages[index]
|
||||||
|
message?.let {
|
||||||
|
MessageItem(message = it)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Индикаторы загрузки
|
||||||
|
when {
|
||||||
|
messages.loadState.refresh is LoadState.Loading -> {
|
||||||
|
item { LoadingIndicator() }
|
||||||
|
}
|
||||||
|
messages.loadState.append is LoadState.Loading -> {
|
||||||
|
item { LoadingIndicator() }
|
||||||
|
}
|
||||||
|
messages.loadState.prepend is LoadState.Loading -> {
|
||||||
|
item { LoadingIndicator() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка ошибок
|
||||||
|
messages.loadState.append.let { loadState ->
|
||||||
|
if (loadState is LoadState.Error) {
|
||||||
|
item {
|
||||||
|
ErrorView(
|
||||||
|
message = loadState.error.message,
|
||||||
|
onRetry = { messages.retry() }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Отправка сообщения (Offline-first)
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
fun sendMessage(chatId: String, content: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// Сообщение сразу сохраняется локально и появляется в UI
|
||||||
|
val message = repository.sendMessage(
|
||||||
|
chatId = chatId,
|
||||||
|
content = content,
|
||||||
|
type = "text"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UI обновляется мгновенно через Flow
|
||||||
|
Log.d("ChatViewModel", "Message saved locally: ${message.id}")
|
||||||
|
|
||||||
|
// Синхронизация с сервером произойдёт в фоне
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("ChatViewModel", "Failed to send message", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Удаление сообщения
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun deleteMessage(messageId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Локальное удаление (сообщение скрывается из UI)
|
||||||
|
repository.deleteLocalMessage(messageId)
|
||||||
|
|
||||||
|
// WorkManager удалит сообщение на сервере в фоне
|
||||||
|
// При получении подтверждения - сообщение удаляется из БД
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Или немедленное удаление (если онлайн)
|
||||||
|
fun deleteMessageImmediately(messageId: String, forEveryone: Boolean) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.deleteMessage(messageId, forEveryone)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Редактирование сообщения
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun editMessage(messageId: String, newContent: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Локальное редактирование
|
||||||
|
repository.editLocalMessage(messageId, newContent)
|
||||||
|
|
||||||
|
// UI обновляется мгновенно
|
||||||
|
// WorkManager отправит изменения на сервер в фоне
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 6. Отслеживание статуса синхронизации
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Наблюдение за сообщениями, ожидающими синхронизации
|
||||||
|
fun observePendingMessages() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
messageDao.getPendingSyncMessagesFlow().collect { messages ->
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
Log.d("Sync", "${messages.size} messages pending sync")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка статуса конкретного сообщения
|
||||||
|
fun isMessageSynced(messageId: String): Boolean {
|
||||||
|
return runBlocking {
|
||||||
|
val message = messageDao.getMessageById(messageId)
|
||||||
|
message?.syncStatus == SyncStatus.SYNCED
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Обработка ошибок синхронизации
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun retryFailedMessages() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
val failedMessages = messageDao.getFailedSyncMessages()
|
||||||
|
|
||||||
|
failedMessages.forEach { message ->
|
||||||
|
when {
|
||||||
|
message.isDeletedLocally -> {
|
||||||
|
// Повторить удаление
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
}
|
||||||
|
message.isEditedLocally -> {
|
||||||
|
// Повторить редактирование
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
// Повторить отправку
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. Прочтение сообщений
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun markAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
// Отправляем статус прочтения через SignalR
|
||||||
|
repository.markMessagesAsRead(chatId, lastMessageId, lastReadSequenceId)
|
||||||
|
|
||||||
|
// Локальная база обновляется автоматически
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. Real-time обновления
|
||||||
|
|
||||||
|
SignalR события обрабатываются автоматически:
|
||||||
|
- Новые сообщения появляются в UI мгновенно
|
||||||
|
- Редактирования/удаления синхронизируются
|
||||||
|
- Статусы прочтения обновляются
|
||||||
|
- Реакции отображаются в реальном времени
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
// Обработчик SignalR уже интегрирован в ChatRepositoryImpl
|
||||||
|
// Дополнительные действия можно добавить в MessageSignalRHandler
|
||||||
|
```
|
||||||
|
|
||||||
|
## 10. Кэширование в ViewModel
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
@HiltViewModel
|
||||||
|
class ChatViewModel @Inject constructor(
|
||||||
|
private val repository: ChatRepository,
|
||||||
|
savedStateHandle: SavedStateHandle
|
||||||
|
) : ViewModel() {
|
||||||
|
|
||||||
|
private val chatId: String = savedStateHandle["chatId"] ?: ""
|
||||||
|
|
||||||
|
// Кэшируем PagingData в scope ViewModel
|
||||||
|
val messages: Flow<PagingData<Message>> = repository
|
||||||
|
.getMessagesPaging(chatId)
|
||||||
|
.cachedIn(viewModelScope) // Важно для сохранения состояния
|
||||||
|
|
||||||
|
// При повороте экрана пагинация сохраняется
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Рекомендации
|
||||||
|
|
||||||
|
### 1. Выбор между Paging 3 и Flow
|
||||||
|
- **Paging 3** - для больших чатов (>100 сообщений)
|
||||||
|
- **Flow<List>** - для небольших чатов или когда нужна вся история сразу
|
||||||
|
|
||||||
|
### 2. Обработка офлайн-режима
|
||||||
|
```kotlin
|
||||||
|
// UI должен показывать статус сообщения
|
||||||
|
@Composable
|
||||||
|
fun MessageItem(message: Message) {
|
||||||
|
val isPending = message.id.startsWith("local_")
|
||||||
|
|
||||||
|
Row {
|
||||||
|
Text(text = message.content)
|
||||||
|
if (isPending) {
|
||||||
|
CircularProgressIndicator(modifier = Modifier.size(12.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Конфликты данных
|
||||||
|
- Локальные изменения имеют приоритет во время отправки
|
||||||
|
- Серверные данные перезаписывают локальные после SYNCED
|
||||||
|
- SignalR события всегда применяются к актуальным данным
|
||||||
|
|
||||||
|
### 4. Производительность
|
||||||
|
- Используйте `cachedIn(viewModelScope)` для PagingData
|
||||||
|
- Избегайте частых вызовов `getMessages()` из сети
|
||||||
|
- Позволяйте WorkManager управлять синхронизацией
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package chats.data.paging
|
||||||
|
|
||||||
|
import androidx.paging.PagingSource
|
||||||
|
import androidx.paging.PagingState
|
||||||
|
import core.database.data.MessageDao
|
||||||
|
import core.database.data.MessageEntity
|
||||||
|
import android.util.Log
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PagingSource для загрузки сообщений из локальной базы Room
|
||||||
|
* Данные сортируются по sequenceId DESC (новые сообщения первыми)
|
||||||
|
*/
|
||||||
|
class MessagePagingSource(
|
||||||
|
private val dao: MessageDao,
|
||||||
|
private val chatId: String
|
||||||
|
) : PagingSource<Int, MessageEntity>() {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MessagePagingSource"
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getRefreshKey(state: PagingState<Int, MessageEntity>): Int? {
|
||||||
|
Log.d(TAG, "getRefreshKey() called")
|
||||||
|
return state.anchorPosition?.let { anchorPosition ->
|
||||||
|
val anchorPage = state.closestPageToPosition(anchorPosition)
|
||||||
|
anchorPage?.prevKey?.plus(1) ?: anchorPage?.nextKey?.minus(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun load(params: LoadParams<Int>): LoadResult<Int, MessageEntity> {
|
||||||
|
Log.d(TAG, ">>> load() called with: position=${params.key}, loadSize=${params.loadSize}")
|
||||||
|
return try {
|
||||||
|
// При DESC порядке:
|
||||||
|
// - key = sequenceId последнего сообщения в текущей странице
|
||||||
|
// - APPEND = загружаем сообщения С МЕНЬШИМ sequenceId (старые)
|
||||||
|
// - PREPEND = загружаем сообщения С БОЛЬШИМ sequenceId (новые)
|
||||||
|
|
||||||
|
val position = params.key // sequenceId граничного сообщения
|
||||||
|
val loadSize = params.loadSize
|
||||||
|
|
||||||
|
Log.d(TAG, "Loading: chatId=$chatId, position=$position, loadSize=$loadSize")
|
||||||
|
|
||||||
|
val messages = if (position == null) {
|
||||||
|
// Первая загрузка - получаем самые последние сообщения (включая maxSeq)
|
||||||
|
val maxSeq = dao.getMaxSequenceId(chatId)
|
||||||
|
Log.d(TAG, "First load: maxSeq=$maxSeq")
|
||||||
|
if (maxSeq == null) {
|
||||||
|
Log.d(TAG, "No messages in database")
|
||||||
|
emptyList()
|
||||||
|
} else {
|
||||||
|
// Используем <= чтобы включить самое последнее сообщение
|
||||||
|
dao.getMessagesUpToAndIncluding(chatId, maxSeq, loadSize)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Загружаем сообщения с sequenceId < position (старые)
|
||||||
|
Log.d(TAG, "Append: loading messages before sequenceId=$position")
|
||||||
|
dao.getMessagesBefore(chatId, position, loadSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Loaded ${messages.size} messages, first=${messages.firstOrNull()?.sequenceId}, last=${messages.lastOrNull()?.sequenceId}")
|
||||||
|
|
||||||
|
// Для DESC порядка:
|
||||||
|
// - prevKey = максимальный sequenceId в странице (для загрузки новых)
|
||||||
|
// - nextKey = минимальный sequenceId в странице (для загрузки старых)
|
||||||
|
val prevKey = messages.firstOrNull()?.sequenceId?.plus(1)
|
||||||
|
val nextKey = messages.lastOrNull()?.sequenceId?.minus(1)
|
||||||
|
|
||||||
|
Log.d(TAG, "prevKey=$prevKey, nextKey=$nextKey")
|
||||||
|
|
||||||
|
LoadResult.Page(
|
||||||
|
data = messages,
|
||||||
|
prevKey = prevKey,
|
||||||
|
nextKey = nextKey
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error loading messages", e)
|
||||||
|
LoadResult.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
package chats.data.paging
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.paging.*
|
||||||
|
import chats.data.remote.api.ChatApi
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
import chats.domain.model.MediaType
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import core.database.data.ChatDatabase
|
||||||
|
import core.database.data.MessageDao
|
||||||
|
import core.database.data.MessageEntity
|
||||||
|
import core.database.data.SyncStatus
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RemoteMediator для Paging 3
|
||||||
|
* Управляет загрузкой сообщений из сети и кэшированием в Room
|
||||||
|
*
|
||||||
|
* Логика работы:
|
||||||
|
* 1. При первой загрузке (REFRESH) - загружаем последние сообщения
|
||||||
|
* 2. При прокрутке вниз (APPEND) - загружаем более старые сообщения
|
||||||
|
* 3. При прокрутке вверх (PREPEND) - загружаем более новые сообщения
|
||||||
|
* 4. Данные сохраняются в Room, Paging читает из базы
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
|
class MessageRemoteMediator(
|
||||||
|
private val chatId: String,
|
||||||
|
private val api: ChatApi,
|
||||||
|
private val database: ChatDatabase,
|
||||||
|
private val dao: core.database.data.MessageDao,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) : RemoteMediator<Int, MessageEntity>() {
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Состояние пагинации
|
||||||
|
*/
|
||||||
|
data class MessageRemoteMediatorState(
|
||||||
|
val lastSequenceId: Int?,
|
||||||
|
val firstSequenceId: Int?
|
||||||
|
)
|
||||||
|
|
||||||
|
override suspend fun initialize(): InitializeAction {
|
||||||
|
// Всегда запускаем refresh для проверки кэша
|
||||||
|
return InitializeAction.LAUNCH_INITIAL_REFRESH
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun load(
|
||||||
|
loadType: LoadType,
|
||||||
|
state: PagingState<Int, MessageEntity>
|
||||||
|
): MediatorResult {
|
||||||
|
Log.d(TAG, ">>> load() loadType=$loadType")
|
||||||
|
return try {
|
||||||
|
// Проверяем наличие данных в локальной БД
|
||||||
|
val localMinSeq = dao.getMinSequenceId(chatId)
|
||||||
|
val localMaxSeq = dao.getMaxSequenceId(chatId)
|
||||||
|
Log.d(TAG, "Local DB: minSeq=$localMinSeq, maxSeq=$localMaxSeq")
|
||||||
|
|
||||||
|
when (loadType) {
|
||||||
|
LoadType.REFRESH -> {
|
||||||
|
// Если есть локальные данные - не делаем API запрос
|
||||||
|
if (localMaxSeq != null) {
|
||||||
|
Log.d(TAG, "REFRESH: Using cached data (maxSeq=$localMaxSeq)")
|
||||||
|
return MediatorResult.Success(endOfPaginationReached = false)
|
||||||
|
}
|
||||||
|
// Нет данных - загружаем последние сообщения
|
||||||
|
Log.d(TAG, "REFRESH: No cached data, fetching from API")
|
||||||
|
}
|
||||||
|
LoadType.APPEND -> {
|
||||||
|
Log.d(TAG, "APPEND: Loading older messages")
|
||||||
|
}
|
||||||
|
LoadType.PREPEND -> {
|
||||||
|
Log.d(TAG, "PREPEND: Loading newer messages")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Определяем pivot для API запроса
|
||||||
|
val pivotValue: Long? = when (loadType) {
|
||||||
|
LoadType.REFRESH -> null // Последние сообщения
|
||||||
|
LoadType.APPEND -> {
|
||||||
|
// Старые сообщения - берём минимальный sequenceId из текущей страницы
|
||||||
|
(state.pages.lastOrNull()?.data?.lastOrNull()?.sequenceId
|
||||||
|
?: localMinSeq)?.toLong()?.minus(1)
|
||||||
|
}
|
||||||
|
LoadType.PREPEND -> {
|
||||||
|
// Новые сообщения - берём максимальный sequenceId
|
||||||
|
(state.pages.firstOrNull()?.data?.firstOrNull()?.sequenceId
|
||||||
|
?: localMaxSeq)?.toLong()?.plus(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val limit = when (loadType) {
|
||||||
|
LoadType.REFRESH -> state.config.initialLoadSize
|
||||||
|
else -> state.config.pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "API call: chatId=$chatId, pivot=$pivotValue, limit=$limit")
|
||||||
|
|
||||||
|
// Выполняем запрос к API
|
||||||
|
val messages = try {
|
||||||
|
api.getMessages(
|
||||||
|
chatId = chatId,
|
||||||
|
cursor = null,
|
||||||
|
pivot = pivotValue,
|
||||||
|
limit = limit
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "API call failed - offline?", e)
|
||||||
|
// При ошибке сети и наличии локальных данных - успех
|
||||||
|
if ((localMinSeq != null || localMaxSeq != null)) {
|
||||||
|
Log.d(TAG, "Offline mode: returning success with cached data")
|
||||||
|
return MediatorResult.Success(endOfPaginationReached = true)
|
||||||
|
}
|
||||||
|
throw e
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Received ${messages.size} messages from API")
|
||||||
|
|
||||||
|
if (messages.isEmpty()) {
|
||||||
|
Log.d(TAG, "End of pagination - no more messages")
|
||||||
|
return MediatorResult.Success(endOfPaginationReached = true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем в базу
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
val entities = messages.map { dto ->
|
||||||
|
dto.toEntity(currentUserId, baseUrl, gson)
|
||||||
|
}
|
||||||
|
Log.d(TAG, "Saving ${entities.size} messages to DB, first seq=${entities.firstOrNull()?.sequenceId}, last seq=${entities.lastOrNull()?.sequenceId}")
|
||||||
|
dao.upsertMessages(entities)
|
||||||
|
|
||||||
|
// Проверяем что данные сохранились
|
||||||
|
val savedCount = dao.getMessagesCount(chatId)
|
||||||
|
Log.d(TAG, "Total messages in DB after save: $savedCount")
|
||||||
|
|
||||||
|
val endOfPaginationReached = messages.size < limit
|
||||||
|
Log.d(TAG, "endOfPaginationReached=$endOfPaginationReached")
|
||||||
|
MediatorResult.Success(endOfPaginationReached = endOfPaginationReached)
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error loading messages", e)
|
||||||
|
MediatorResult.Error(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MessageRemoteMediator"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extension function для преобразования DTO в Entity
|
||||||
|
*/
|
||||||
|
private fun MessageDto.toEntity(
|
||||||
|
currentUserId: String,
|
||||||
|
baseUrl: String,
|
||||||
|
gson: Gson
|
||||||
|
): MessageEntity {
|
||||||
|
// Определяем тип медиа
|
||||||
|
val mediaType = when {
|
||||||
|
media.isEmpty() -> MediaType.TEXT.name
|
||||||
|
media.any { it.type.startsWith("image") || it.url.endsWith(".gif") } -> MediaType.GIF.name
|
||||||
|
media.any { it.type.startsWith("image") } -> MediaType.IMAGE.name
|
||||||
|
media.any { it.type.startsWith("video") } -> MediaType.VIDEO.name
|
||||||
|
media.any { it.type.startsWith("audio") } -> MediaType.AUDIO.name
|
||||||
|
else -> MediaType.FILE.name
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сериализуем медиа в JSON
|
||||||
|
val mediaJson = gson.toJson(media)
|
||||||
|
|
||||||
|
// Сериализуем реакции в JSON
|
||||||
|
val reactionsMap = reactions?.associate { it.emoji to it.count } ?: emptyMap()
|
||||||
|
val reactionsJson = gson.toJson(reactionsMap)
|
||||||
|
|
||||||
|
// Определяем, прочитано ли сообщение текущим пользователем
|
||||||
|
// Для своих сообщений: проверяем, прочитал ли кто-то другой (получатели)
|
||||||
|
// Для чужих сообщений: проверяем, прочитал ли текущий пользователь
|
||||||
|
val isRead = if (senderId == currentUserId) {
|
||||||
|
readBy?.any { it.userId != currentUserId } ?: false
|
||||||
|
} else {
|
||||||
|
readBy?.any { it.userId == currentUserId } ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
return MessageEntity(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId ?: "",
|
||||||
|
senderId = senderId ?: "",
|
||||||
|
senderName = sender?.displayName ?: sender?.username ?: "Unknown",
|
||||||
|
senderAvatar = sender?.avatarUrl,
|
||||||
|
content = content,
|
||||||
|
sequenceId = sequenceId ?: 0,
|
||||||
|
createdAt = createdAt ?: "",
|
||||||
|
mediaType = mediaType,
|
||||||
|
mediaJson = mediaJson,
|
||||||
|
reactionsJson = reactionsJson,
|
||||||
|
isRead = isRead,
|
||||||
|
replyToId = replyTo?.id,
|
||||||
|
syncStatus = SyncStatus.SYNCED,
|
||||||
|
isDeletedLocally = false,
|
||||||
|
isEditedLocally = false,
|
||||||
|
editedContent = null,
|
||||||
|
lastUpdated = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,6 +29,7 @@ interface ChatApi {
|
|||||||
@Path("chatId") chatId: String,
|
@Path("chatId") chatId: String,
|
||||||
@Query("cursor") cursor: String? = null,
|
@Query("cursor") cursor: String? = null,
|
||||||
@Query("pivot") pivot: Long? = null,
|
@Query("pivot") pivot: Long? = null,
|
||||||
|
@Query("afterSequenceId") afterSequenceId: Long? = null,
|
||||||
@Query("limit") limit: Int? = 50
|
@Query("limit") limit: Int? = 50
|
||||||
): List<MessageDto>
|
): List<MessageDto>
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,12 @@ data class MessageDto(
|
|||||||
@SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null,
|
@SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null,
|
||||||
@SerializedName("isPinned", alternate = ["IsPinned"]) val isPinned: Boolean? = false,
|
@SerializedName("isPinned", alternate = ["IsPinned"]) val isPinned: Boolean? = false,
|
||||||
@SerializedName("forwardedFromId", alternate = ["ForwardedFromId"]) val forwardedFromId: String? = null,
|
@SerializedName("forwardedFromId", alternate = ["ForwardedFromId"]) val forwardedFromId: String? = null,
|
||||||
@SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null
|
@SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null,
|
||||||
|
@SerializedName("readBy", alternate = ["ReadBy"]) val readBy: List<ReadByDto>? = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ReadByDto(
|
||||||
|
@SerializedName("userId") val userId: String
|
||||||
)
|
)
|
||||||
|
|
||||||
data class ReactionDto(
|
data class ReactionDto(
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ sealed class ChatEvent {
|
|||||||
data class NewMessage(val message: MessageDto) : ChatEvent()
|
data class NewMessage(val message: MessageDto) : ChatEvent()
|
||||||
data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
|
data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
|
||||||
data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
|
data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
|
||||||
|
data class ChatDeleted(val chatId: String) : ChatEvent()
|
||||||
data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
|
data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
|
||||||
data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
|
data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
|
||||||
data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
|
data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
|
|||||||
@Singleton
|
@Singleton
|
||||||
class ChatHubClient @Inject constructor() {
|
class ChatHubClient @Inject constructor() {
|
||||||
private var hubConnection: HubConnection? = null
|
private var hubConnection: HubConnection? = null
|
||||||
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 1024)
|
// extraBufferCapacity=1024 позволяет буферизовать события пока нет подписчиков
|
||||||
|
private val _events = MutableSharedFlow<ChatEvent>(replay = 0, extraBufferCapacity = 1024)
|
||||||
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
|
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
|
||||||
|
|
||||||
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||||
@@ -56,32 +57,59 @@ class ChatHubClient @Inject constructor() {
|
|||||||
private var lastToken: String? = null
|
private var lastToken: String? = null
|
||||||
|
|
||||||
fun connect(baseUrl: String, accessToken: String) {
|
fun connect(baseUrl: String, accessToken: String) {
|
||||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
|
// Проверяем текущее состояние
|
||||||
|
val currentState = hubConnection?.connectionState
|
||||||
|
if (currentState == HubConnectionState.CONNECTED) {
|
||||||
|
Log.d("ChatHubClient", "Already connected, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если соединение в процессе - останавливаем его
|
||||||
|
if (currentState == HubConnectionState.CONNECTING) {
|
||||||
|
Log.d("ChatHubClient", "Connection in progress ($currentState), stopping first...")
|
||||||
|
hubConnection?.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохраняем параметры для переподключения
|
||||||
lastBaseUrl = baseUrl
|
lastBaseUrl = baseUrl
|
||||||
lastToken = accessToken
|
lastToken = accessToken
|
||||||
_status.value = ConnectionStatus.CONNECTING
|
_status.value = ConnectionStatus.CONNECTING
|
||||||
|
|
||||||
hubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
|
Log.d("ChatHubClient", "Connecting to ${baseUrl}/hubs/chat with token: ${accessToken.take(10)}...")
|
||||||
|
|
||||||
|
// Создаем новое соединение
|
||||||
|
val newHubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
|
||||||
.withAccessTokenProvider(Single.just(accessToken))
|
.withAccessTokenProvider(Single.just(accessToken))
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
|
hubConnection = newHubConnection
|
||||||
|
|
||||||
setupHandlers()
|
setupHandlers()
|
||||||
|
|
||||||
hubConnection?.onClosed { exception ->
|
hubConnection?.onClosed { exception ->
|
||||||
Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception)
|
Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception)
|
||||||
_status.value = ConnectionStatus.DISCONNECTED
|
_status.value = ConnectionStatus.DISCONNECTED
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(5000)
|
// Проверяем, есть ли еще актуальные параметры для переподключения
|
||||||
connect(baseUrl, accessToken)
|
val reconnectBaseUrl = lastBaseUrl
|
||||||
|
val reconnectToken = lastToken
|
||||||
|
|
||||||
|
if (reconnectBaseUrl != null && reconnectToken != null) {
|
||||||
|
Log.d("ChatHubClient", "Attempting reconnection with saved parameters...")
|
||||||
|
delay(5000)
|
||||||
|
connect(reconnectBaseUrl, reconnectToken)
|
||||||
|
} else {
|
||||||
|
Log.w("ChatHubClient", "Cannot reconnect: missing baseUrl or token")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
|
Log.d("ChatHubClient", "Starting SignalR connection...")
|
||||||
hubConnection?.start()?.blockingAwait()
|
hubConnection?.start()?.blockingAwait()
|
||||||
_status.value = ConnectionStatus.CONNECTED
|
_status.value = ConnectionStatus.CONNECTED
|
||||||
Log.d("ChatHubClient", "SignalR Connected")
|
Log.d("ChatHubClient", "SignalR Connected successfully!")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Log.e("ChatHubClient", "SignalR Connection Error", e)
|
Log.e("ChatHubClient", "SignalR Connection Error", e)
|
||||||
_status.value = ConnectionStatus.DISCONNECTED
|
_status.value = ConnectionStatus.DISCONNECTED
|
||||||
@@ -91,19 +119,30 @@ class ChatHubClient @Inject constructor() {
|
|||||||
|
|
||||||
private fun setupHandlers() {
|
private fun setupHandlers() {
|
||||||
hubConnection?.let { conn ->
|
hubConnection?.let { conn ->
|
||||||
|
Log.d("ChatHubClient", "Setting up SignalR handlers")
|
||||||
|
|
||||||
conn.on("new_message", { message: MessageDto ->
|
conn.on("new_message", { message: MessageDto ->
|
||||||
|
Log.d("ChatHubClient", ">>> new_message event received: ${message.id} in chat ${message.chatId}")
|
||||||
_events.tryEmit(ChatEvent.NewMessage(message))
|
_events.tryEmit(ChatEvent.NewMessage(message))
|
||||||
}, MessageDto::class.java)
|
}, MessageDto::class.java)
|
||||||
|
|
||||||
conn.on("message_edited", { messageId: String, chatId: String, content: String ->
|
conn.on("message_edited", { messageId: String, chatId: String, content: String ->
|
||||||
|
Log.d("ChatHubClient", ">>> message_edited event: $messageId")
|
||||||
_events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content))
|
_events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content))
|
||||||
}, String::class.java, String::class.java, String::class.java)
|
}, String::class.java, String::class.java, String::class.java)
|
||||||
|
|
||||||
conn.on("message_deleted", { messageId: String, chatId: String ->
|
conn.on("message_deleted", { messageId: String, chatId: String ->
|
||||||
|
Log.d("ChatHubClient", ">>> message_deleted event: $messageId")
|
||||||
_events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId))
|
_events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId))
|
||||||
}, String::class.java, String::class.java)
|
}, String::class.java, String::class.java)
|
||||||
|
|
||||||
|
conn.on("chat_deleted", { chatId: String ->
|
||||||
|
Log.d("ChatHubClient", ">>> chat_deleted event: $chatId")
|
||||||
|
_events.tryEmit(ChatEvent.ChatDeleted(chatId))
|
||||||
|
}, String::class.java)
|
||||||
|
|
||||||
conn.on("messages_read", { data: MessagesReadEvent ->
|
conn.on("messages_read", { data: MessagesReadEvent ->
|
||||||
|
Log.d("ChatHubClient", ">>> messages_read event: ${data.effectiveChatId}")
|
||||||
_events.tryEmit(ChatEvent.MessagesRead(
|
_events.tryEmit(ChatEvent.MessagesRead(
|
||||||
data.effectiveChatId,
|
data.effectiveChatId,
|
||||||
data.effectiveUserId,
|
data.effectiveUserId,
|
||||||
@@ -124,10 +163,12 @@ class ChatHubClient @Inject constructor() {
|
|||||||
}, String::class.java)
|
}, String::class.java)
|
||||||
|
|
||||||
conn.on("new_chat", { chat: ChatDto ->
|
conn.on("new_chat", { chat: ChatDto ->
|
||||||
|
Log.d("ChatHubClient", ">>> new_chat event: ${chat.id}")
|
||||||
_events.tryEmit(ChatEvent.NewChat(chat))
|
_events.tryEmit(ChatEvent.NewChat(chat))
|
||||||
}, ChatDto::class.java)
|
}, ChatDto::class.java)
|
||||||
|
|
||||||
conn.on("reaction_added", { data: ReactionEvent ->
|
conn.on("reaction_added", { data: ReactionEvent ->
|
||||||
|
Log.d("ChatHubClient", ">>> reaction_added event: ${data.emoji} on ${data.messageId}")
|
||||||
_events.tryEmit(ChatEvent.ReactionUpdated(
|
_events.tryEmit(ChatEvent.ReactionUpdated(
|
||||||
data.messageId ?: "",
|
data.messageId ?: "",
|
||||||
data.chatId ?: "",
|
data.chatId ?: "",
|
||||||
@@ -138,6 +179,7 @@ class ChatHubClient @Inject constructor() {
|
|||||||
}, ReactionEvent::class.java)
|
}, ReactionEvent::class.java)
|
||||||
|
|
||||||
conn.on("reaction_removed", { data: ReactionEvent ->
|
conn.on("reaction_removed", { data: ReactionEvent ->
|
||||||
|
Log.d("ChatHubClient", ">>> reaction_removed event: ${data.emoji} on ${data.messageId}")
|
||||||
_events.tryEmit(ChatEvent.ReactionUpdated(
|
_events.tryEmit(ChatEvent.ReactionUpdated(
|
||||||
data.messageId ?: "",
|
data.messageId ?: "",
|
||||||
data.chatId ?: "",
|
data.chatId ?: "",
|
||||||
@@ -185,6 +227,26 @@ class ChatHubClient @Inject constructor() {
|
|||||||
_status.value = ConnectionStatus.DISCONNECTED
|
_status.value = ConnectionStatus.DISCONNECTED
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Принудительное переподключение - останавливает текущее соединение и создает новое
|
||||||
|
*/
|
||||||
|
fun reconnect() {
|
||||||
|
Log.d("ChatHubClient", "Forced reconnect requested")
|
||||||
|
val baseUrl = lastBaseUrl
|
||||||
|
val token = lastToken
|
||||||
|
|
||||||
|
if (baseUrl != null && token != null) {
|
||||||
|
disconnect()
|
||||||
|
// Небольшая задержка перед переподключением
|
||||||
|
scope.launch {
|
||||||
|
delay(1000)
|
||||||
|
connect(baseUrl, token)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Log.w("ChatHubClient", "Cannot reconnect: missing saved credentials")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fun addReaction(messageId: String, chatId: String, emoji: String) {
|
fun addReaction(messageId: String, chatId: String, emoji: String) {
|
||||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
hubConnection?.invoke("add_reaction", mapOf(
|
hubConnection?.invoke("add_reaction", mapOf(
|
||||||
@@ -250,12 +312,13 @@ class ChatHubClient @Inject constructor() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||||
|
Log.d("ChatHubClient", "Joining chat room: $chatId")
|
||||||
hubConnection?.invoke("join_chat", chatId)
|
hubConnection?.invoke("join_chat", chatId)
|
||||||
?.doOnError { Log.e("ChatHubClient", "join_chat error", it) }
|
?.doOnError { Log.e("ChatHubClient", "join_chat error", it) }
|
||||||
?.subscribe()
|
?.subscribe()
|
||||||
Log.d("ChatHubClient", "Joined chat room: $chatId")
|
Log.d("ChatHubClient", "Joined chat room: $chatId")
|
||||||
} else {
|
} else {
|
||||||
Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected")
|
Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected (state=${hubConnection?.connectionState})")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
package chats.data.remote.signalr
|
package chats.data.remote.signalr
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import core.network.NetworkManager
|
||||||
|
import core.network.ServerConfig
|
||||||
import core.notifications.data.ActiveChatTracker
|
import core.notifications.data.ActiveChatTracker
|
||||||
import core.notifications.data.NotificationHelper
|
import core.notifications.data.NotificationHelper
|
||||||
import core.security.TokenManager
|
import core.security.TokenManager
|
||||||
|
import chats.data.sync.MessageSyncWorker
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -12,8 +15,15 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.flow.filterIsInstance
|
import kotlinx.coroutines.flow.filterIsInstance
|
||||||
import kotlinx.coroutines.flow.launchIn
|
import kotlinx.coroutines.flow.launchIn
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
import kotlinx.coroutines.flow.filter
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
|
import kotlinx.coroutines.flow.collect
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import chats.data.remote.signalr.ConnectionStatus
|
||||||
|
import okhttp3.OkHttpClient
|
||||||
|
import okhttp3.Request
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
@Singleton
|
@Singleton
|
||||||
class SignalRNotificationObserver @Inject constructor(
|
class SignalRNotificationObserver @Inject constructor(
|
||||||
@@ -21,41 +31,93 @@ class SignalRNotificationObserver @Inject constructor(
|
|||||||
private val activeChatTracker: ActiveChatTracker,
|
private val activeChatTracker: ActiveChatTracker,
|
||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
private val chatRepository: chats.domain.repository.ChatRepository,
|
private val chatRepository: chats.domain.repository.ChatRepository,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val networkManager: NetworkManager,
|
||||||
|
private val messageDao: core.database.data.MessageDao,
|
||||||
|
private val api: chats.data.remote.api.ChatApi,
|
||||||
@ApplicationContext private val context: Context
|
@ApplicationContext private val context: Context
|
||||||
) {
|
) {
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
private var isStarted = false
|
private var isStarted = false
|
||||||
private val processedMessageIds = mutableSetOf<String>()
|
private val processedMessageIds = mutableSetOf<String>()
|
||||||
|
|
||||||
fun refresh() {
|
// OkHttpClient для ping запроса
|
||||||
scope.launch {
|
private val pingClient = OkHttpClient.Builder()
|
||||||
try {
|
.connectTimeout(5, TimeUnit.SECONDS)
|
||||||
val chats = chatRepository.getChats()
|
.readTimeout(5, TimeUnit.SECONDS)
|
||||||
val total = chats.sumOf { it.unreadCount }
|
.build()
|
||||||
activeChatTracker.setTotalUnreadCount(total)
|
|
||||||
} catch (e: Exception) {
|
|
||||||
// Ignore load error
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun start() {
|
fun start() {
|
||||||
if (isStarted) return
|
if (isStarted) return
|
||||||
isStarted = true
|
isStarted = true
|
||||||
|
|
||||||
|
// Запускаем мониторинг сети
|
||||||
|
networkManager.startMonitoring()
|
||||||
|
|
||||||
|
// Принудительно обновляем состояние сети при старте
|
||||||
|
networkManager.refreshNetworkState()
|
||||||
|
|
||||||
|
// Подключаемся к SignalR при старте приложения
|
||||||
|
connectSignalR()
|
||||||
|
|
||||||
// Initial count load
|
// Initial count load
|
||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
|
// Запускаем периодическую проверку подключения SignalR
|
||||||
|
startConnectionHealthCheck()
|
||||||
|
|
||||||
|
// Слушаем восстановление сети и переподключаем SignalR
|
||||||
|
networkManager.isOnline
|
||||||
|
.filter { it } // Только переход в онлайн
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Network restored! Reconnecting SignalR and syncing...")
|
||||||
|
// Небольшая задержка чтобы сеть стабилизировалась
|
||||||
|
kotlinx.coroutines.delay(1500)
|
||||||
|
reconnectOnNetworkRestored()
|
||||||
|
}
|
||||||
|
.launchIn(scope)
|
||||||
|
|
||||||
|
// Также отслеживаем состояние SignalR для переподключения
|
||||||
|
signalrClient.status
|
||||||
|
.filter { it == ConnectionStatus.DISCONNECTED }
|
||||||
|
.onEach {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "SignalR disconnected, checking network...")
|
||||||
|
// Если мы offline и SignalR отключен - не делаем ничего
|
||||||
|
// Подключимся когда сеть восстановится
|
||||||
|
}
|
||||||
|
.launchIn(scope)
|
||||||
|
|
||||||
|
// При восстановлении соединения SignalR обновляем список чатов и вступаем в них
|
||||||
|
signalrClient.status
|
||||||
|
.filter { it == ConnectionStatus.CONNECTED }
|
||||||
|
.onEach {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "SignalR connected, refreshing chats and joining rooms")
|
||||||
|
onSignalRConnected()
|
||||||
|
}
|
||||||
|
.launchIn(scope)
|
||||||
|
|
||||||
|
// Слушаем события SignalR для уведомлений и обновления списка чатов
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Starting to listen to signalrClient.events")
|
||||||
signalrClient.events
|
signalrClient.events
|
||||||
.onEach { event ->
|
.onEach { event ->
|
||||||
|
android.util.Log.d("SignalRNtfObserver", ">>> Received event: ${event::class.simpleName}")
|
||||||
when (event) {
|
when (event) {
|
||||||
is ChatEvent.NewMessage -> {
|
is ChatEvent.NewMessage -> {
|
||||||
val currentUserId = tokenManager.getUserId()
|
val currentUserId = tokenManager.getUserId()
|
||||||
val message = event.message
|
val message = event.message
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "New message: ${message.id} from ${message.senderId}, current: $currentUserId")
|
||||||
|
|
||||||
// Don't show if it's our message or already processed
|
// Don't show if it's our message or already processed
|
||||||
if (message.senderId == currentUserId) return@onEach
|
if (message.senderId == currentUserId) {
|
||||||
if (processedMessageIds.contains(message.id)) return@onEach
|
android.util.Log.d("SignalRNtfObserver", "Skipping - own message")
|
||||||
|
return@onEach
|
||||||
|
}
|
||||||
|
if (processedMessageIds.contains(message.id)) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Skipping - already processed")
|
||||||
|
return@onEach
|
||||||
|
}
|
||||||
|
|
||||||
// Mark as processed
|
// Mark as processed
|
||||||
processedMessageIds.add(message.id)
|
processedMessageIds.add(message.id)
|
||||||
@@ -70,8 +132,12 @@ class SignalRNotificationObserver @Inject constructor(
|
|||||||
refresh()
|
refresh()
|
||||||
|
|
||||||
// Don't show if this chat is currently open
|
// Don't show if this chat is currently open
|
||||||
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach
|
if (activeChatTracker.currentChatId.value == message.chatId) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Skipping - chat is open: ${message.chatId}")
|
||||||
|
return@onEach
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Showing notification for chat: ${message.chatId}")
|
||||||
NotificationHelper.showNotification(
|
NotificationHelper.showNotification(
|
||||||
context = context,
|
context = context,
|
||||||
title = message.sender?.displayName ?: "Новое сообщение",
|
title = message.sender?.displayName ?: "Новое сообщение",
|
||||||
@@ -83,12 +149,404 @@ class SignalRNotificationObserver @Inject constructor(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
is ChatEvent.MessagesRead -> {
|
is ChatEvent.MessagesRead -> {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Messages read event")
|
||||||
// If anyone read messages, sync our total count
|
// If anyone read messages, sync our total count
|
||||||
refresh()
|
refresh()
|
||||||
}
|
}
|
||||||
else -> Unit
|
else -> {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Unhandled event: ${event::class.simpleName}")
|
||||||
|
Unit
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.launchIn(scope)
|
.launchIn(scope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startConnectionHealthCheck() {
|
||||||
|
// Каждые 10 секунд проверяем подключение и при необходимости переподключаемся
|
||||||
|
scope.launch {
|
||||||
|
var consecutiveFailures = 0
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
kotlinx.coroutines.delay(10000)
|
||||||
|
val currentStatus = signalrClient.status.value
|
||||||
|
val isOnline = networkManager.isOnline.value
|
||||||
|
|
||||||
|
if (isOnline && currentStatus == ConnectionStatus.DISCONNECTED) {
|
||||||
|
consecutiveFailures++
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Health check: Network online but SignalR disconnected (failures: $consecutiveFailures), reconnecting...")
|
||||||
|
signalrClient.reconnect()
|
||||||
|
} else if (isOnline && currentStatus == ConnectionStatus.CONNECTED) {
|
||||||
|
// Сбрасываем счетчик ошибок при успешном подключении
|
||||||
|
consecutiveFailures = 0
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Health check: Connection healthy")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onSignalRConnected() {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "SignalR connected - syncing missed messages...")
|
||||||
|
refresh()
|
||||||
|
// Вступаем во все чаты для получения событий
|
||||||
|
joinAllChats()
|
||||||
|
// Синхронизируем пропущенные сообщения
|
||||||
|
syncMissedMessages()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Синхронизирует пропущенные сообщения после восстановления соединения
|
||||||
|
* Запрашивает только НОВЫЕ сообщения с последнего известного sequenceId
|
||||||
|
* Показывает уведомления для непрочитанных сообщений
|
||||||
|
*/
|
||||||
|
private fun syncMissedMessages() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Starting missed messages sync...")
|
||||||
|
val chats = chatRepository.getChats()
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Syncing ${chats.size} chats for missed messages")
|
||||||
|
|
||||||
|
// Запрашиваем только новые сообщения для каждого чата
|
||||||
|
chats.forEach { chat ->
|
||||||
|
try {
|
||||||
|
// Получаем последний известный sequenceId из локальной базы
|
||||||
|
val lastSequenceId = chatRepository.getLastKnownSequenceId(chat.id)
|
||||||
|
|
||||||
|
if (lastSequenceId != null) {
|
||||||
|
// Запрашиваем сообщения ПОСЛЕ lastSequenceId (только новые)
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: last known seqId=$lastSequenceId, fetching newer...")
|
||||||
|
val newMessages = chatRepository.getMessages(
|
||||||
|
chatId = chat.id,
|
||||||
|
afterSequenceId = lastSequenceId.toLong(),
|
||||||
|
limit = 100
|
||||||
|
)
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: fetched ${newMessages.size} messages from API")
|
||||||
|
|
||||||
|
// Показываем уведомление если есть новые сообщения и чат не открыт
|
||||||
|
if (newMessages.isNotEmpty() && activeChatTracker.currentChatId.value != chat.id) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: ${newMessages.size} new messages, showing notification")
|
||||||
|
showMissedMessagesNotification(chat, newMessages)
|
||||||
|
} else {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: skipping notification (messages=${newMessages.size}, chatOpen=${activeChatTracker.currentChatId.value == chat.id})")
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Нет локальных сообщений - загружаем последние 50
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Chat ${chat.id}: no local messages, fetching last 50")
|
||||||
|
chatRepository.getMessages(chatId = chat.id, limit = 50)
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Synced chat ${chat.id}")
|
||||||
|
// Небольшая пауза между чатами чтобы не перегружать сервер
|
||||||
|
kotlinx.coroutines.delay(100)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to sync chat ${chat.id}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Missed messages sync completed")
|
||||||
|
|
||||||
|
// Обновляем счетчик непрочитанных после синхронизации
|
||||||
|
refresh()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Sync failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Показывает уведомление о пропущенных сообщениях
|
||||||
|
*/
|
||||||
|
private fun showMissedMessagesNotification(chat: chats.domain.model.Chat, newMessages: List<chats.domain.model.Message>) {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "showMissedMessagesNotification called for chat ${chat.id} with ${newMessages.size} messages")
|
||||||
|
|
||||||
|
// Фильтруем сообщения не от текущего пользователя
|
||||||
|
val currentUserId = tokenManager.getUserId()
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Current user ID: $currentUserId")
|
||||||
|
|
||||||
|
val messagesFromOthers = newMessages.filter { it.senderId != currentUserId }
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Messages from others: ${messagesFromOthers.size}")
|
||||||
|
|
||||||
|
if (messagesFromOthers.isEmpty()) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "No new messages from others in chat ${chat.id} - all messages are from current user")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
// Группируем сообщения по отправителям
|
||||||
|
val messagesBySender = messagesFromOthers.groupBy { it.senderId }
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Messages grouped by ${messagesBySender.size} sender(s)")
|
||||||
|
|
||||||
|
// Для каждого отправителя показываем уведомление
|
||||||
|
messagesBySender.forEach { (senderId, messages) ->
|
||||||
|
val senderName = messages.firstOrNull()?.senderName ?: "Контакт"
|
||||||
|
val messageCount = messages.size
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Processing sender $senderName with $messageCount messages")
|
||||||
|
|
||||||
|
// Формируем текст уведомления
|
||||||
|
val notificationText = when {
|
||||||
|
messageCount == 1 -> {
|
||||||
|
messages.firstOrNull()?.content ?: "Новое сообщение"
|
||||||
|
}
|
||||||
|
messageCount <= 3 -> {
|
||||||
|
messages.take(3).mapNotNull { it.content }.joinToString(", ")
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
"$messageCount новых сообщений"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Showing notification for chat ${chat.id}: $senderName - $notificationText")
|
||||||
|
|
||||||
|
// Небольшая задержка перед показом уведомления
|
||||||
|
kotlinx.coroutines.delay(500)
|
||||||
|
|
||||||
|
// Показываем уведомление
|
||||||
|
NotificationHelper.showNotification(
|
||||||
|
context = context,
|
||||||
|
title = senderName,
|
||||||
|
body = notificationText,
|
||||||
|
type = "chat",
|
||||||
|
chatId = chat.id,
|
||||||
|
notificationId = chat.id.hashCode(),
|
||||||
|
totalCount = activeChatTracker.totalUnreadCount.value
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to show missed messages notification", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает последний известный sequenceId для чата из локальной базы
|
||||||
|
*/
|
||||||
|
private suspend fun getLastKnownSequenceId(chatId: String): Int? {
|
||||||
|
return try {
|
||||||
|
chatRepository.getLastKnownSequenceId(chatId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to get last sequenceId for $chatId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconnectOnNetworkRestored() {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Network restored, starting reconnection sequence...")
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
// 1. Сначала делаем HTTP ping запрос чтобы "разбудить" сетевой стек
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Sending HTTP ping to wake up network...")
|
||||||
|
val pingSuccess = sendHttpPing()
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "HTTP ping result: $pingSuccess")
|
||||||
|
|
||||||
|
// 2. Небольшая пауза для стабилизации
|
||||||
|
kotlinx.coroutines.delay(1000)
|
||||||
|
|
||||||
|
// 3. Принудительное переподключение SignalR
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Forcing SignalR reconnect...")
|
||||||
|
signalrClient.reconnect()
|
||||||
|
|
||||||
|
// 4. Ждем пока SignalR подключится (максимум 10 секунд)
|
||||||
|
var waitCount = 0
|
||||||
|
while (signalrClient.status.value != ConnectionStatus.CONNECTED && waitCount < 20) {
|
||||||
|
kotlinx.coroutines.delay(500)
|
||||||
|
waitCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
if (signalrClient.status.value == ConnectionStatus.CONNECTED) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "SignalR reconnected, syncing missed messages...")
|
||||||
|
// 5. Синхронизируем пропущенные сообщения
|
||||||
|
syncMissedMessages()
|
||||||
|
|
||||||
|
// 6. Пробуем отправить отложенные сообщения НЕМЕДЛЕННО (не через WorkManager)
|
||||||
|
sendPendingMessagesImmediately()
|
||||||
|
|
||||||
|
// 7. Также планируем WorkManager на всякий случай
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Outgoing sync worker scheduled")
|
||||||
|
} else {
|
||||||
|
android.util.Log.w("SignalRNtfObserver", "SignalR failed to reconnect within timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Немедленно отправляет отложенные сообщения без ожидания WorkManager
|
||||||
|
* Вызывается сразу после восстановления соединения
|
||||||
|
*/
|
||||||
|
private fun sendPendingMessagesImmediately() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Checking for pending messages to send...")
|
||||||
|
|
||||||
|
// Получаем все сообщения со статусом SYNCING
|
||||||
|
val pendingMessages = messageDao.getPendingSyncMessages()
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Found ${pendingMessages.size} pending messages")
|
||||||
|
|
||||||
|
if (pendingMessages.isEmpty()) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "No pending messages to send")
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
|
||||||
|
var successCount = 0
|
||||||
|
var failureCount = 0
|
||||||
|
|
||||||
|
for (message in pendingMessages) {
|
||||||
|
try {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Sending pending message: ${message.id}")
|
||||||
|
|
||||||
|
val attachments = parseAttachments(message.mediaJson)
|
||||||
|
val request = chats.data.remote.api.SendMessageRequest(
|
||||||
|
content = message.content,
|
||||||
|
type = message.mediaType.lowercase(),
|
||||||
|
attachments = attachments,
|
||||||
|
replyToId = message.replyToId
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.sendMessage(message.chatId, request)
|
||||||
|
|
||||||
|
// Обновляем сообщение в базе
|
||||||
|
val syncedMessage = message.copy(
|
||||||
|
id = response.id,
|
||||||
|
sequenceId = response.sequenceId ?: message.sequenceId,
|
||||||
|
createdAt = response.createdAt ?: message.createdAt,
|
||||||
|
syncStatus = core.database.data.SyncStatus.SYNCED,
|
||||||
|
isDeletedLocally = false,
|
||||||
|
isEditedLocally = false,
|
||||||
|
editedContent = null,
|
||||||
|
lastUpdated = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
messageDao.insertMessage(syncedMessage)
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Message sent successfully: ${response.id}")
|
||||||
|
successCount++
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to send message ${message.id}", e)
|
||||||
|
failureCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Pending messages sync completed. Success: $successCount, Failed: $failureCount")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "sendPendingMessagesImmediately failed", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAttachments(mediaJson: String): List<chats.data.remote.api.AttachmentRequest>? {
|
||||||
|
return try {
|
||||||
|
val gson = com.google.gson.Gson()
|
||||||
|
val mediaList = gson.fromJson(mediaJson, Array::class.java)
|
||||||
|
?.map { elem ->
|
||||||
|
val map = elem as Map<*, *>
|
||||||
|
chats.data.remote.api.AttachmentRequest(
|
||||||
|
type = map["type"] as? String ?: "file",
|
||||||
|
url = map["url"] as? String ?: "",
|
||||||
|
fileName = map["filename"] as? String ?: "file",
|
||||||
|
fileSize = (map["size"] as? Number)?.toLong() ?: 0L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mediaList?.takeIf { it.isNotEmpty() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to parse attachments", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отправляет HTTP ping запрос для активации сетевого соединения
|
||||||
|
*/
|
||||||
|
private suspend fun sendHttpPing(): Boolean {
|
||||||
|
return try {
|
||||||
|
val baseUrl = serverConfig.getBaseUrl()
|
||||||
|
val token = tokenManager.getToken()
|
||||||
|
|
||||||
|
if (baseUrl.isBlank() || token == null) {
|
||||||
|
android.util.Log.w("SignalRNtfObserver", "Cannot ping: missing baseUrl or token")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = "${baseUrl.removeSuffix("/api/")}/api/auth/refresh"
|
||||||
|
val request = Request.Builder()
|
||||||
|
.url(url)
|
||||||
|
.post(okhttp3.RequestBody.create(null, "{}"))
|
||||||
|
.addHeader("Authorization", "Bearer $token")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val response = pingClient.newCall(request).execute()
|
||||||
|
val success = response.isSuccessful || response.code == 401 // 401 OK для refresh
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "HTTP ping to $url: ${response.code}")
|
||||||
|
response.close()
|
||||||
|
success
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "HTTP ping failed", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun connectSignalR() {
|
||||||
|
val token = tokenManager.getToken()
|
||||||
|
val baseUrl = serverConfig.getBaseUrl()
|
||||||
|
|
||||||
|
if (token == null || baseUrl.isBlank()) {
|
||||||
|
android.util.Log.w("SignalRNtfObserver", "Cannot connect SignalR: token=${token != null}, baseUrl=$baseUrl")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val isOnline = networkManager.isOnline.value
|
||||||
|
if (!isOnline) {
|
||||||
|
android.util.Log.w("SignalRNtfObserver", "Cannot connect SignalR: network is offline")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем текущее состояние SignalR
|
||||||
|
val currentStatus = signalrClient.status.value
|
||||||
|
if (currentStatus == ConnectionStatus.CONNECTED) {
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "SignalR already connected, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Connecting SignalR with token: ${token.take(10)}..., baseUrl: $baseUrl")
|
||||||
|
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
|
||||||
|
|
||||||
|
// Если подключение не удалось в течение 5 секунд - пробуем снова
|
||||||
|
scope.launch {
|
||||||
|
kotlinx.coroutines.delay(5000)
|
||||||
|
if (signalrClient.status.value == ConnectionStatus.DISCONNECTED) {
|
||||||
|
android.util.Log.w("SignalRNtfObserver", "SignalR connection timeout, retrying with reconnect()...")
|
||||||
|
signalrClient.reconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun joinAllChats() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val chats = chatRepository.getChats()
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Joining ${chats.size} chat rooms")
|
||||||
|
chats.forEach { chat ->
|
||||||
|
signalrClient.joinChat(chat.id)
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to join chats", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun refresh() {
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val chats = chatRepository.getChats()
|
||||||
|
val total = chats.sumOf { it.unreadCount }
|
||||||
|
activeChatTracker.setTotalUnreadCount(total)
|
||||||
|
android.util.Log.d("SignalRNtfObserver", "Refreshed chats: ${chats.size}, total unread: $total")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("SignalRNtfObserver", "Failed to refresh", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,113 +1,299 @@
|
|||||||
package chats.data.repository
|
package chats.data.repository
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.paging.*
|
||||||
|
import chats.data.paging.MessageRemoteMediator
|
||||||
import chats.data.remote.api.ChatApi
|
import chats.data.remote.api.ChatApi
|
||||||
import chats.data.remote.api.SendMessageRequest
|
import chats.data.remote.api.SendMessageRequest
|
||||||
import chats.data.remote.dto.ChatDto
|
import chats.data.remote.dto.ChatDto
|
||||||
import chats.data.remote.dto.MessageDto
|
import chats.data.remote.dto.MessageDto
|
||||||
import chats.data.remote.dto.MediaItemDto
|
import chats.data.signalr.MessageSignalRHandler
|
||||||
import chats.data.remote.dto.ReactionDto
|
import chats.data.sync.MessageSyncWorker
|
||||||
import chats.domain.model.Chat
|
import chats.domain.model.Chat
|
||||||
import chats.domain.model.Message
|
import chats.domain.model.Message
|
||||||
import chats.domain.model.MediaType
|
import chats.domain.model.MediaType
|
||||||
import chats.domain.repository.ChatRepository
|
import chats.domain.repository.ChatRepository
|
||||||
|
import core.database.data.ChatDatabase
|
||||||
|
import core.database.data.ChatDao
|
||||||
|
import core.database.data.MessageDao
|
||||||
|
import core.database.data.MessageEntity
|
||||||
|
import core.database.data.SyncStatus
|
||||||
import core.network.ServerConfig
|
import core.network.ServerConfig
|
||||||
import chats.data.remote.signalr.ReadMessagesRequest
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
import core.security.TokenManager
|
import core.security.TokenManager
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||||
import okhttp3.MultipartBody
|
import okhttp3.MultipartBody
|
||||||
import okhttp3.RequestBody.Companion.asRequestBody
|
import okhttp3.RequestBody.Companion.asRequestBody
|
||||||
|
import java.text.SimpleDateFormat
|
||||||
|
import java.util.*
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlinx.coroutines.flow.map
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Основная реализация репозитория чатов
|
||||||
|
*
|
||||||
|
* Архитектура Offline-first:
|
||||||
|
* 1. Все данные читаются из локальной базы Room
|
||||||
|
* 2. При изменении данных - сначала запись в БД, потом синхронизация с сервером
|
||||||
|
* 3. SignalR обновления сразу записываются в БД
|
||||||
|
* 4. WorkManager обрабатывает фоновую синхронизацию
|
||||||
|
*
|
||||||
|
* Conflict Resolution:
|
||||||
|
* - Серверные данные имеют приоритет над локальными
|
||||||
|
* - Исключение: сообщения в процессе отправки (SYNCING) или редактирования
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalPagingApi::class)
|
||||||
|
@Singleton
|
||||||
class ChatRepositoryImpl @Inject constructor(
|
class ChatRepositoryImpl @Inject constructor(
|
||||||
private val api: ChatApi,
|
private val api: ChatApi,
|
||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
private val serverConfig: ServerConfig,
|
private val serverConfig: ServerConfig,
|
||||||
private val messageDao: core.database.data.MessageDao,
|
private val messageDao: MessageDao,
|
||||||
private val hubClient: chats.data.remote.signalr.ChatHubClient
|
private val chatDao: ChatDao,
|
||||||
|
private val database: ChatDatabase,
|
||||||
|
private val hubClient: ChatHubClient,
|
||||||
|
private val signalRHandler: MessageSignalRHandler,
|
||||||
|
private val context: Context
|
||||||
) : ChatRepository {
|
) : ChatRepository {
|
||||||
private val gson = com.google.gson.Gson()
|
|
||||||
|
private val gson = Gson()
|
||||||
|
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
|
||||||
|
init {
|
||||||
|
signalRHandler.startListening()
|
||||||
|
Log.d(TAG, "ChatRepositoryImpl initialized")
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun getChats(): List<Chat> {
|
override suspend fun getChats(): List<Chat> {
|
||||||
val currentUserId = tokenManager.getUserId() ?: ""
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
return try {
|
||||||
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
|
// Пробуем загрузить из сети
|
||||||
|
val chats = api.getChats().map { it.toDomain(currentUserId, baseUrl) }
|
||||||
|
// Кэшируем в Room
|
||||||
|
val entities = chats.map { it.toEntity() }
|
||||||
|
chatDao.insertChats(entities)
|
||||||
|
// Кэшируем последние сообщения
|
||||||
|
chats.forEach { chat ->
|
||||||
|
chat.lastMessage?.let { msg ->
|
||||||
|
messageDao.upsertMessage(msg.toEntity(gson))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
android.util.Log.d(TAG, "Cached ${entities.size} chats with messages")
|
||||||
|
|
||||||
|
// Проверяем удалённые чаты - если чата нет в списке от сервера, удаляем из БД
|
||||||
|
val serverChatIds = chats.map { it.id }.toSet()
|
||||||
|
val localChats = chatDao.getAllChats()
|
||||||
|
localChats.forEach { localChat ->
|
||||||
|
if (localChat.id !in serverChatIds) {
|
||||||
|
android.util.Log.d(TAG, "Chat ${localChat.id} was deleted on server, removing from local DB")
|
||||||
|
chatDao.deleteChat(localChat.id)
|
||||||
|
messageDao.clearChat(localChat.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chats
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.d(TAG, "Network load failed, using cache")
|
||||||
|
// При ошибке - возвращаем из кэша с загрузкой последних сообщений
|
||||||
|
chatDao.getAllChats().map { entity ->
|
||||||
|
val lastMessage = entity.lastMessageId?.let { messageId ->
|
||||||
|
messageDao.getMessageById(messageId)?.toDomain(baseUrl, gson)
|
||||||
|
}
|
||||||
|
entity.toDomain(currentUserId, baseUrl, lastMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
|
override fun getChatsFlow(): Flow<List<Chat>> {
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
return messageDao.getMessages(chatId).map { entities: List<core.database.data.MessageEntity> ->
|
return chatDao.getAllChatsFlow().map { entities ->
|
||||||
|
entities.map { entity ->
|
||||||
|
// Загружаем последнее сообщение из базы для каждого чата
|
||||||
|
val lastMessage = entity.lastMessageId?.let { messageId ->
|
||||||
|
messageDao.getMessageById(messageId)?.toDomain(baseUrl, gson)
|
||||||
|
}
|
||||||
|
entity.toDomain(currentUserId, baseUrl, lastMessage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMessagesPaging(chatId: String): Flow<PagingData<Message>> {
|
||||||
|
val pagingConfig = PagingConfig(
|
||||||
|
pageSize = 30,
|
||||||
|
prefetchDistance = 10,
|
||||||
|
initialLoadSize = 50,
|
||||||
|
enablePlaceholders = false
|
||||||
|
)
|
||||||
|
|
||||||
|
return Pager(
|
||||||
|
config = pagingConfig,
|
||||||
|
pagingSourceFactory = { messageDao.getMessagesPagingSource(chatId) },
|
||||||
|
remoteMediator = MessageRemoteMediator(
|
||||||
|
chatId = chatId,
|
||||||
|
api = api,
|
||||||
|
database = database,
|
||||||
|
dao = messageDao,
|
||||||
|
serverConfig = serverConfig,
|
||||||
|
tokenManager = tokenManager
|
||||||
|
)
|
||||||
|
).flow.map { pagingData ->
|
||||||
|
pagingData.map { entity -> entity.toDomain(baseUrl, gson) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getMessagesFlow(chatId: String): Flow<List<Message>> {
|
||||||
|
return messageDao.getMessages(chatId).map { entities ->
|
||||||
entities.map { it.toDomain(baseUrl, gson) }
|
entities.map { it.toDomain(baseUrl, gson) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
|
override suspend fun getMessages(
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
chatId: String, cursor: String?, pivot: Long?, afterSequenceId: Long?, limit: Int?
|
||||||
|
): List<Message> {
|
||||||
val currentUserId = tokenManager.getUserId() ?: ""
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
return try {
|
return try {
|
||||||
android.util.Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit")
|
Log.d(TAG, "Fetching messages from API: chatId=$chatId, afterSequenceId=$afterSequenceId, limit=$limit")
|
||||||
val messages = api.getMessages(chatId, cursor = cursor, limit = limit)
|
val messages = api.getMessages(chatId, cursor = cursor, pivot = pivot, afterSequenceId = afterSequenceId, limit = limit)
|
||||||
|
|
||||||
if (messages.isNotEmpty()) {
|
if (messages.isNotEmpty()) {
|
||||||
android.util.Log.d("ChatRepo", "Received ${messages.size} messages. TopSeq: ${messages.first().sequenceId}, BottomSeq: ${messages.last().sequenceId}")
|
val entities = messages.map { it.toEntity(baseUrl, currentUserId, gson) }
|
||||||
|
messageDao.upsertMessages(entities)
|
||||||
|
Log.d(TAG, "Cached ${entities.size} messages")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Мапим в доменные модели. По умолчанию считаем прочитанными,
|
// Используем корректную логику из маппера (readBy), а не принудительно true
|
||||||
// так как unreadCount нам тут не критичен для истории.
|
messages.map { msg -> msg.toDomain(currentUserId, baseUrl) }
|
||||||
messages.map { msg ->
|
|
||||||
msg.toDomain(currentUserId, baseUrl).copy(isRead = true)
|
|
||||||
}
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("ChatRepo", "Fetch messages failed", e)
|
Log.e(TAG, "Fetch messages failed", e)
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun getLastKnownSequenceId(chatId: String): Int? {
|
||||||
|
return try {
|
||||||
|
messageDao.getMaxSequenceId(chatId)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to get last sequenceId", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun sendMessage(
|
override suspend fun sendMessage(
|
||||||
chatId: String,
|
chatId: String, content: String?, type: String,
|
||||||
content: String?,
|
|
||||||
type: String,
|
|
||||||
attachments: List<chats.data.remote.api.AttachmentRequest>?,
|
attachments: List<chats.data.remote.api.AttachmentRequest>?,
|
||||||
replyToId: String?,
|
replyToId: String?, forwardedFromId: String?
|
||||||
forwardedFromId: String?
|
|
||||||
): Message {
|
): Message {
|
||||||
val request = SendMessageRequest(
|
|
||||||
content = content,
|
|
||||||
type = type,
|
|
||||||
attachments = attachments,
|
|
||||||
replyToId = replyToId,
|
|
||||||
forwardedFromId = forwardedFromId
|
|
||||||
)
|
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
|
||||||
val userId = tokenManager.getUserId() ?: ""
|
val userId = tokenManager.getUserId() ?: ""
|
||||||
return api.sendMessage(chatId, request).toDomain(userId, baseUrl)
|
val currentTime = System.currentTimeMillis()
|
||||||
|
|
||||||
|
val localId = "local_${currentTime}_${chatId}"
|
||||||
|
val localMessage = MessageEntity(
|
||||||
|
id = localId, chatId = chatId, senderId = userId,
|
||||||
|
senderName = "Вы", senderAvatar = null, content = content,
|
||||||
|
sequenceId = 0,
|
||||||
|
createdAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date(currentTime)),
|
||||||
|
mediaType = type.uppercase(),
|
||||||
|
mediaJson = "[]",
|
||||||
|
reactionsJson = "{}",
|
||||||
|
isRead = false, replyToId = replyToId,
|
||||||
|
syncStatus = SyncStatus.SYNCING,
|
||||||
|
isDeletedLocally = false, isEditedLocally = false,
|
||||||
|
editedContent = null, lastUpdated = currentTime
|
||||||
|
)
|
||||||
|
|
||||||
|
messageDao.insertMessage(localMessage)
|
||||||
|
Log.d(TAG, "Saved local message: $localId")
|
||||||
|
|
||||||
|
// Пробуем отправить НЕМЕДЛЕННО через API
|
||||||
|
try {
|
||||||
|
Log.d(TAG, "Sending message immediately via API: $localId")
|
||||||
|
val request = SendMessageRequest(
|
||||||
|
content = content,
|
||||||
|
type = type,
|
||||||
|
attachments = attachments,
|
||||||
|
replyToId = replyToId
|
||||||
|
)
|
||||||
|
val response = api.sendMessage(chatId, request)
|
||||||
|
Log.d(TAG, "Message sent successfully: ${response.id}")
|
||||||
|
|
||||||
|
// Обновляем сообщение в базе с серверными данными
|
||||||
|
val syncedMessage = localMessage.copy(
|
||||||
|
id = response.id,
|
||||||
|
sequenceId = response.sequenceId ?: 0,
|
||||||
|
createdAt = response.createdAt ?: localMessage.createdAt,
|
||||||
|
syncStatus = SyncStatus.SYNCED
|
||||||
|
)
|
||||||
|
messageDao.insertMessage(syncedMessage)
|
||||||
|
|
||||||
|
// Возвращаем доменную модель с серверными данными
|
||||||
|
return response.toDomain(userId, baseUrl)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to send message immediately, scheduling sync: ${e.message}")
|
||||||
|
// Ошибка - планируем синхронизацию через WorkManager
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаём доменную модель вручную для локального сообщения
|
||||||
|
return Message(
|
||||||
|
id = localId,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = userId,
|
||||||
|
senderName = "Вы",
|
||||||
|
senderAvatar = null,
|
||||||
|
content = content,
|
||||||
|
sequenceId = 0,
|
||||||
|
createdAt = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).format(Date(currentTime)),
|
||||||
|
media = emptyList(),
|
||||||
|
mediaType = when (type) {
|
||||||
|
"image" -> MediaType.IMAGE
|
||||||
|
"video" -> MediaType.VIDEO
|
||||||
|
"audio" -> MediaType.AUDIO
|
||||||
|
"gif" -> MediaType.GIF
|
||||||
|
else -> MediaType.TEXT
|
||||||
|
},
|
||||||
|
reactions = emptyMap(),
|
||||||
|
isRead = false,
|
||||||
|
isPinned = false,
|
||||||
|
isForwarded = false,
|
||||||
|
forwardedFromName = null,
|
||||||
|
replyTo = null
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun addReaction(messageId: String, emoji: String) {
|
override suspend fun addReaction(messageId: String, emoji: String) {
|
||||||
api.addReaction(messageId, emoji)
|
hubClient.addReaction(messageId, "", emoji)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun sendTypingStatus(chatId: String) {
|
override suspend fun sendTypingStatus(chatId: String) {
|
||||||
api.sendTypingStatus(chatId)
|
api.sendTypingStatus(chatId)
|
||||||
|
hubClient.sendTypingIndicator(chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
||||||
try {
|
try {
|
||||||
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId. Caller stack: ${android.util.Log.getStackTraceString(Throwable())}")
|
hubClient.readMessages(chats.data.remote.signalr.ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
|
||||||
hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
android.util.Log.e("ChatRepo", "Error marking messages as read", e)
|
Log.e(TAG, "Error marking messages as read", e)
|
||||||
}
|
}
|
||||||
// Обновляем локальную БД
|
|
||||||
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
|
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun saveMessage(message: Message) {
|
override suspend fun saveMessage(message: Message) {
|
||||||
android.util.Log.d("ChatRepo", "DB cache disabled, skipping save: ${message.id}")
|
messageDao.insertMessage(message.toEntity(gson))
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun deleteLocalMessage(messageId: String) {
|
override suspend fun deleteLocalMessage(messageId: String) {
|
||||||
messageDao.deleteMessage(messageId)
|
messageDao.markAsDeletedLocally(messageId)
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun editLocalMessage(messageId: String, newContent: String) {
|
||||||
|
messageDao.markAsEditedLocally(messageId, newContent)
|
||||||
|
MessageSyncWorker.scheduleSync(context)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun uploadMedia(file: java.io.File): String {
|
override suspend fun uploadMedia(file: java.io.File): String {
|
||||||
@@ -124,21 +310,17 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
return api.uploadFile(body).url
|
return api.uploadFile(body).url
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> {
|
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> =
|
||||||
return api.getTrendingGifs(page).data.data
|
api.getTrendingGifs(page).data.data
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> {
|
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> =
|
||||||
return api.searchGifs(query, page).data.data
|
api.searchGifs(query, page).data.data
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
|
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> =
|
||||||
return api.getGifCategories().data.categories
|
api.getGifCategories().data.categories
|
||||||
}
|
|
||||||
|
|
||||||
override suspend fun createPersonalChat(userId: String): Chat {
|
override suspend fun createPersonalChat(userId: String): Chat {
|
||||||
val currentUserId = tokenManager.getUserId() ?: ""
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
|
||||||
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
|
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
|
||||||
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
|
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
|
||||||
}
|
}
|
||||||
@@ -151,7 +333,13 @@ class ChatRepositoryImpl @Inject constructor(
|
|||||||
override suspend fun editMessage(messageId: String, content: String): Message {
|
override suspend fun editMessage(messageId: String, content: String): Message {
|
||||||
val request = SendMessageRequest(content = content)
|
val request = SendMessageRequest(content = content)
|
||||||
val currentUserId = tokenManager.getUserId() ?: ""
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
val response = api.editMessage(messageId, request)
|
||||||
return api.editMessage(messageId, request).toDomain(currentUserId, baseUrl)
|
messageDao.insertMessage(response.toEntity(baseUrl, currentUserId, gson))
|
||||||
|
return response.toDomain(currentUserId, baseUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "ChatRepositoryImpl"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package chats.data.repository
|
|||||||
|
|
||||||
import chats.data.remote.dto.*
|
import chats.data.remote.dto.*
|
||||||
import chats.domain.model.*
|
import chats.domain.model.*
|
||||||
|
import core.database.data.ChatEntity
|
||||||
|
import core.database.data.MessageEntity
|
||||||
|
import core.database.data.SyncStatus
|
||||||
|
|
||||||
// Mappers
|
// Mappers
|
||||||
fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
|
fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
|
||||||
@@ -23,6 +26,38 @@ fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun Chat.toEntity(): ChatEntity {
|
||||||
|
return ChatEntity(
|
||||||
|
id = id,
|
||||||
|
name = name,
|
||||||
|
avatar = avatar,
|
||||||
|
type = type,
|
||||||
|
lastMessageId = lastMessage?.id,
|
||||||
|
lastMessageText = lastMessage?.content,
|
||||||
|
lastMessageAt = lastMessage?.createdAt?.let {
|
||||||
|
try { java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", java.util.Locale.US).parse(it)?.time ?: 0L }
|
||||||
|
catch (e: Exception) { 0L }
|
||||||
|
} ?: 0L,
|
||||||
|
unreadCount = unreadCount,
|
||||||
|
isPinned = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun ChatEntity.toDomain(
|
||||||
|
currentUserId: String,
|
||||||
|
baseUrl: String,
|
||||||
|
lastMessage: Message? = null
|
||||||
|
): Chat {
|
||||||
|
return Chat(
|
||||||
|
id = id,
|
||||||
|
type = type,
|
||||||
|
name = name,
|
||||||
|
avatar = avatar,
|
||||||
|
unreadCount = unreadCount,
|
||||||
|
lastMessage = lastMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
||||||
return core.database.data.MessageEntity(
|
return core.database.data.MessageEntity(
|
||||||
id = id,
|
id = id,
|
||||||
@@ -37,7 +72,12 @@ fun Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEnti
|
|||||||
mediaJson = gson.toJson(media),
|
mediaJson = gson.toJson(media),
|
||||||
reactionsJson = gson.toJson(reactions),
|
reactionsJson = gson.toJson(reactions),
|
||||||
isRead = isRead,
|
isRead = isRead,
|
||||||
replyToId = replyTo?.id
|
replyToId = replyTo?.id,
|
||||||
|
syncStatus = SyncStatus.SYNCED,
|
||||||
|
isDeletedLocally = false,
|
||||||
|
isEditedLocally = false,
|
||||||
|
editedContent = null,
|
||||||
|
lastUpdated = System.currentTimeMillis()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,6 +97,17 @@ fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Исправленная логика isRead:
|
||||||
|
// Для своих сообщений: проверяем, прочитал ли кто-то другой (получатели)
|
||||||
|
// Для чужих сообщений: проверяем, прочитал ли текущий пользователь
|
||||||
|
val isRead = if (senderId == currentUserId) {
|
||||||
|
// Своё сообщение: прочитано, если кто-то кроме отправителя в readBy
|
||||||
|
readBy?.any { it.userId != currentUserId } ?: false
|
||||||
|
} else {
|
||||||
|
// Чужое сообщение: прочитано, если текущий пользователь в readBy
|
||||||
|
readBy?.any { it.userId == currentUserId } ?: false
|
||||||
|
}
|
||||||
|
|
||||||
return Message(
|
return Message(
|
||||||
id = id,
|
id = id,
|
||||||
chatId = chatId ?: "",
|
chatId = chatId ?: "",
|
||||||
@@ -78,7 +129,7 @@ fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
|
|||||||
},
|
},
|
||||||
mediaType = domainMediaType,
|
mediaType = domainMediaType,
|
||||||
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(),
|
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(),
|
||||||
isRead = senderId == currentUserId,
|
isRead = isRead,
|
||||||
isPinned = isPinned ?: false,
|
isPinned = isPinned ?: false,
|
||||||
isForwarded = forwardedFromId != null,
|
isForwarded = forwardedFromId != null,
|
||||||
forwardedFromName = forwardedFrom?.displayName ?: forwardedFrom?.username,
|
forwardedFromName = forwardedFrom?.displayName ?: forwardedFrom?.username,
|
||||||
@@ -87,6 +138,13 @@ fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fun MessageDto.toEntity(baseUrl: String, currentUserId: String, gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
fun MessageDto.toEntity(baseUrl: String, currentUserId: String, gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
||||||
|
// Исправленная логика isRead для базы данных
|
||||||
|
val isRead = if (senderId == currentUserId) {
|
||||||
|
readBy?.any { it.userId != currentUserId } ?: false
|
||||||
|
} else {
|
||||||
|
readBy?.any { it.userId == currentUserId } ?: false
|
||||||
|
}
|
||||||
|
|
||||||
return core.database.data.MessageEntity(
|
return core.database.data.MessageEntity(
|
||||||
id = id,
|
id = id,
|
||||||
chatId = chatId ?: "",
|
chatId = chatId ?: "",
|
||||||
@@ -99,7 +157,7 @@ fun MessageDto.toEntity(baseUrl: String, currentUserId: String, gson: com.google
|
|||||||
mediaType = type ?: "text",
|
mediaType = type ?: "text",
|
||||||
mediaJson = gson.toJson(media),
|
mediaJson = gson.toJson(media),
|
||||||
reactionsJson = gson.toJson(reactions),
|
reactionsJson = gson.toJson(reactions),
|
||||||
isRead = senderId == currentUserId,
|
isRead = isRead,
|
||||||
replyToId = replyTo?.id
|
replyToId = replyTo?.id
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,191 @@
|
|||||||
|
package chats.data.signalr
|
||||||
|
|
||||||
|
import android.util.Log
|
||||||
|
import chats.data.remote.dto.MessageDto
|
||||||
|
import chats.data.remote.signalr.ChatEvent
|
||||||
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
|
import chats.data.remote.signalr.MessagesReadEvent
|
||||||
|
import chats.data.remote.signalr.ReactionEvent
|
||||||
|
import chats.data.repository.toEntity
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import core.database.data.ChatDao
|
||||||
|
import core.database.data.MessageDao
|
||||||
|
import core.database.data.MessageEntity
|
||||||
|
import core.database.data.SyncStatus
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
import kotlinx.coroutines.*
|
||||||
|
import kotlinx.coroutines.flow.collectLatest
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
@Singleton
|
||||||
|
class MessageSignalRHandler @Inject constructor(
|
||||||
|
private val hubClient: ChatHubClient,
|
||||||
|
private val messageDao: MessageDao,
|
||||||
|
private val chatDao: ChatDao,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) {
|
||||||
|
private val gson = Gson()
|
||||||
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
private val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
|
||||||
|
private var isListening = false
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Начинает прослушивание SignalR событий
|
||||||
|
* Вызывается один раз при инициализации приложения
|
||||||
|
*/
|
||||||
|
fun startListening() {
|
||||||
|
if (isListening) {
|
||||||
|
Log.w(TAG, "Already listening to SignalR events")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isListening = true
|
||||||
|
Log.d(TAG, "Started listening to SignalR events")
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
hubClient.events.collectLatest { event ->
|
||||||
|
handleEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Останавливает прослушивание событий
|
||||||
|
*/
|
||||||
|
fun stopListening() {
|
||||||
|
isListening = false
|
||||||
|
scope.cancel()
|
||||||
|
Log.d(TAG, "Stopped listening to SignalR events")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleEvent(event: ChatEvent) {
|
||||||
|
try {
|
||||||
|
when (event) {
|
||||||
|
is ChatEvent.NewMessage -> handleNewMessage(event.message)
|
||||||
|
is ChatEvent.MessageEdited -> handleMessageEdited(event.messageId, event.chatId, event.content)
|
||||||
|
is ChatEvent.MessageDeleted -> handleMessageDeleted(event.messageId, event.chatId)
|
||||||
|
is ChatEvent.ChatDeleted -> handleChatDeleted(event.chatId)
|
||||||
|
is ChatEvent.MessagesRead -> handleMessagesRead(event.chatId, event.lastReadSequenceId)
|
||||||
|
is ChatEvent.ReactionUpdated -> handleReactionUpdated(
|
||||||
|
event.messageId,
|
||||||
|
event.emoji,
|
||||||
|
event.isRemoved
|
||||||
|
)
|
||||||
|
else -> Log.d(TAG, "Unhandled event: ${event::class.simpleName}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Error handling SignalR event: ${event::class.simpleName}", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleChatDeleted(chatId: String) {
|
||||||
|
Log.d(TAG, "Chat deleted: $chatId, removing from local database")
|
||||||
|
chatDao.deleteChat(chatId)
|
||||||
|
Log.d(TAG, "Successfully removed chat $chatId from local database")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleNewMessage(dto: MessageDto) {
|
||||||
|
Log.d(TAG, "New message received: ${dto.id} in chat ${dto.chatId}")
|
||||||
|
|
||||||
|
val currentUserId = tokenManager.getUserId() ?: ""
|
||||||
|
val entity = dto.toEntity(baseUrl, currentUserId, gson).copy(
|
||||||
|
syncStatus = SyncStatus.SYNCED
|
||||||
|
)
|
||||||
|
|
||||||
|
val existing = messageDao.getMessageById(dto.id)
|
||||||
|
if (existing != null && existing.syncStatus == SyncStatus.SYNCING) {
|
||||||
|
val syncedEntity = entity.copy(
|
||||||
|
syncStatus = SyncStatus.SYNCED,
|
||||||
|
isDeletedLocally = false,
|
||||||
|
isEditedLocally = false,
|
||||||
|
editedContent = null
|
||||||
|
)
|
||||||
|
messageDao.insertMessage(syncedEntity)
|
||||||
|
Log.d(TAG, "Merged local message with server response: ${dto.id}")
|
||||||
|
} else {
|
||||||
|
messageDao.insertMessage(entity)
|
||||||
|
Log.d(TAG, "Saved new message: ${dto.id}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleMessageEdited(messageId: String, chatId: String, content: String) {
|
||||||
|
Log.d(TAG, "Message edited: $messageId")
|
||||||
|
|
||||||
|
val existing = messageDao.getMessageById(messageId)
|
||||||
|
if (existing != null) {
|
||||||
|
if (existing.isEditedLocally) {
|
||||||
|
Log.d(TAG, "Message is being edited locally, skipping server update: $messageId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val updated = existing.copy(
|
||||||
|
content = content,
|
||||||
|
lastUpdated = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
messageDao.insertMessage(updated)
|
||||||
|
Log.d(TAG, "Updated edited message: $messageId")
|
||||||
|
} else {
|
||||||
|
Log.w(TAG, "Edited message not found in cache: $messageId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleMessageDeleted(messageId: String, chatId: String) {
|
||||||
|
Log.d(TAG, "Message deleted: $messageId")
|
||||||
|
|
||||||
|
val existing = messageDao.getMessageById(messageId)
|
||||||
|
if (existing?.isDeletedLocally == true) {
|
||||||
|
messageDao.deleteMessage(messageId)
|
||||||
|
Log.d(TAG, "Completed local deletion: $messageId")
|
||||||
|
} else {
|
||||||
|
messageDao.deleteMessage(messageId)
|
||||||
|
Log.d(TAG, "Removed deleted message from cache: $messageId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleMessagesRead(chatId: String, lastReadSequenceId: Int?) {
|
||||||
|
if (lastReadSequenceId == null) {
|
||||||
|
Log.w(TAG, "Messages read event with null sequenceId")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Messages read in chat $chatId up to sequenceId $lastReadSequenceId")
|
||||||
|
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleReactionUpdated(messageId: String, emoji: String, isRemoved: Boolean) {
|
||||||
|
Log.d(TAG, "Reaction ${if (isRemoved) "removed" else "added"}: $emoji on message $messageId")
|
||||||
|
|
||||||
|
val existing = messageDao.getMessageById(messageId)
|
||||||
|
if (existing != null) {
|
||||||
|
val reactions = gson.fromJson<Map<String, Int>>(existing.reactionsJson, Map::class.java) ?: emptyMap()
|
||||||
|
val updatedReactions = reactions.toMutableMap()
|
||||||
|
|
||||||
|
if (isRemoved) {
|
||||||
|
val currentCount = updatedReactions[emoji] ?: 0
|
||||||
|
if (currentCount > 1) {
|
||||||
|
updatedReactions[emoji] = currentCount - 1
|
||||||
|
} else {
|
||||||
|
updatedReactions.remove(emoji)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
updatedReactions[emoji] = (updatedReactions[emoji] ?: 0) + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
val updated = existing.copy(
|
||||||
|
reactionsJson = gson.toJson(updatedReactions),
|
||||||
|
lastUpdated = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
messageDao.insertMessage(updated)
|
||||||
|
Log.d(TAG, "Updated reactions for message: $messageId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "MessageSignalRHandler"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
package chats.data.sync
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.hilt.work.HiltWorker
|
||||||
|
import androidx.work.*
|
||||||
|
import chats.data.remote.api.ChatApi
|
||||||
|
import chats.data.remote.api.SendMessageRequest
|
||||||
|
import core.database.data.MessageDao
|
||||||
|
import core.database.data.MessageEntity
|
||||||
|
import core.database.data.SyncStatus
|
||||||
|
import core.network.ServerConfig
|
||||||
|
import core.security.TokenManager
|
||||||
|
import com.google.gson.Gson
|
||||||
|
import dagger.assisted.Assisted
|
||||||
|
import dagger.assisted.AssistedInject
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WorkManager Worker для фоновой синхронизации сообщений
|
||||||
|
*
|
||||||
|
* Обрабатывает:
|
||||||
|
* 1. Отправку новых сообщений (SYNCING)
|
||||||
|
* 2. Обновление отредактированных сообщений (isEditedLocally = true)
|
||||||
|
* 3. Удаление сообщений (isDeletedLocally = true)
|
||||||
|
* 4. Повторную отправку при ошибках (FAILED)
|
||||||
|
*
|
||||||
|
* Политика повторных попыток:
|
||||||
|
* - Экспоненциальная задержка (backoff)
|
||||||
|
* - Максимум 3 попытки
|
||||||
|
* - Таймаут 10 минут на выполнение
|
||||||
|
*/
|
||||||
|
@HiltWorker
|
||||||
|
class MessageSyncWorker @AssistedInject constructor(
|
||||||
|
@Assisted appContext: Context,
|
||||||
|
@Assisted params: WorkerParameters,
|
||||||
|
private val dao: MessageDao,
|
||||||
|
private val api: ChatApi,
|
||||||
|
private val serverConfig: ServerConfig,
|
||||||
|
private val tokenManager: TokenManager
|
||||||
|
) : CoroutineWorker(appContext, params) {
|
||||||
|
|
||||||
|
private val gson = Gson()
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val WORK_NAME = "message_sync_worker"
|
||||||
|
private const val TAG = "MessageSyncWorker"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Планирует синхронизацию
|
||||||
|
* Вызывается при изменении сообщений в базе
|
||||||
|
*/
|
||||||
|
fun scheduleSync(context: Context) {
|
||||||
|
Log.d(TAG, "Scheduling sync")
|
||||||
|
|
||||||
|
val constraints = Constraints.Builder()
|
||||||
|
.setRequiredNetworkType(NetworkType.CONNECTED)
|
||||||
|
.setRequiresBatteryNotLow(false)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val workRequest = OneTimeWorkRequestBuilder<MessageSyncWorker>()
|
||||||
|
.setConstraints(constraints)
|
||||||
|
.setBackoffCriteria(
|
||||||
|
BackoffPolicy.EXPONENTIAL,
|
||||||
|
WorkRequest.MIN_BACKOFF_MILLIS,
|
||||||
|
TimeUnit.MILLISECONDS
|
||||||
|
)
|
||||||
|
.addTag(WORK_NAME)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
WorkManager.getInstance(context).enqueueUniqueWork(
|
||||||
|
WORK_NAME,
|
||||||
|
ExistingWorkPolicy.REPLACE,
|
||||||
|
workRequest
|
||||||
|
)
|
||||||
|
|
||||||
|
Log.d(TAG, "Sync scheduled")
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отменяет запланированную синхронизацию
|
||||||
|
*/
|
||||||
|
fun cancelSync(context: Context) {
|
||||||
|
WorkManager.getInstance(context).cancelUniqueWork(WORK_NAME)
|
||||||
|
Log.d(TAG, "Sync cancelled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun doWork(): Result {
|
||||||
|
Log.d(TAG, "Starting message sync. Attempt: ${runAttemptCount + 1}")
|
||||||
|
|
||||||
|
if (runAttemptCount >= 3) {
|
||||||
|
Log.e(TAG, "Max retry attempts reached")
|
||||||
|
return Result.failure()
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val pendingMessages = dao.getPendingSyncMessages()
|
||||||
|
Log.d(TAG, "Found ${pendingMessages.size} messages to sync")
|
||||||
|
|
||||||
|
if (pendingMessages.isEmpty()) {
|
||||||
|
Log.d(TAG, "No pending messages. Sync complete.")
|
||||||
|
return Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
var successCount = 0
|
||||||
|
var failureCount = 0
|
||||||
|
|
||||||
|
for (message in pendingMessages) {
|
||||||
|
try {
|
||||||
|
when {
|
||||||
|
message.isDeletedLocally -> {
|
||||||
|
handleDeleteMessage(message)
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
message.isEditedLocally -> {
|
||||||
|
handleEditMessage(message)
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
message.syncStatus == SyncStatus.SYNCING -> {
|
||||||
|
handleSendMessage(message)
|
||||||
|
successCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to sync message ${message.id}", e)
|
||||||
|
dao.markAsSyncFailed(message.id)
|
||||||
|
failureCount++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Sync completed. Success: $successCount, Failed: $failureCount")
|
||||||
|
|
||||||
|
if (failureCount > 0 && successCount == 0) {
|
||||||
|
Result.retry()
|
||||||
|
} else {
|
||||||
|
Result.success()
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Sync failed with exception", e)
|
||||||
|
Result.retry()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleSendMessage(message: MessageEntity) {
|
||||||
|
Log.d(TAG, "Sending message: ${message.id}")
|
||||||
|
|
||||||
|
val attachments = parseAttachments(message.mediaJson)
|
||||||
|
val request = SendMessageRequest(
|
||||||
|
content = message.content,
|
||||||
|
type = message.mediaType.lowercase(),
|
||||||
|
attachments = attachments,
|
||||||
|
replyToId = message.replyToId
|
||||||
|
)
|
||||||
|
|
||||||
|
val response = api.sendMessage(message.chatId, request)
|
||||||
|
|
||||||
|
val syncedMessage = message.copy(
|
||||||
|
id = response.id,
|
||||||
|
sequenceId = response.sequenceId ?: message.sequenceId,
|
||||||
|
createdAt = response.createdAt ?: message.createdAt,
|
||||||
|
syncStatus = SyncStatus.SYNCED,
|
||||||
|
isDeletedLocally = false,
|
||||||
|
isEditedLocally = false,
|
||||||
|
editedContent = null,
|
||||||
|
lastUpdated = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
dao.insertMessage(syncedMessage)
|
||||||
|
Log.d(TAG, "Message sent successfully: ${response.id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleEditMessage(message: MessageEntity) {
|
||||||
|
Log.d(TAG, "Editing message: ${message.id}")
|
||||||
|
|
||||||
|
val newContent = message.editedContent ?: message.content
|
||||||
|
val request = SendMessageRequest(content = newContent)
|
||||||
|
|
||||||
|
val response = api.editMessage(message.id, request)
|
||||||
|
|
||||||
|
val syncedMessage = message.copy(
|
||||||
|
content = response.content,
|
||||||
|
syncStatus = SyncStatus.SYNCED,
|
||||||
|
isEditedLocally = false,
|
||||||
|
editedContent = null,
|
||||||
|
lastUpdated = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
dao.insertMessage(syncedMessage)
|
||||||
|
Log.d(TAG, "Message edited successfully: ${message.id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun handleDeleteMessage(message: MessageEntity) {
|
||||||
|
Log.d(TAG, "Deleting message: ${message.id}")
|
||||||
|
|
||||||
|
val response = api.deleteMessage(message.id, forEveryone = false)
|
||||||
|
|
||||||
|
if (response.isSuccessful || response.code() == 404) {
|
||||||
|
dao.deleteMessage(message.id)
|
||||||
|
Log.d(TAG, "Message deleted successfully: ${message.id}")
|
||||||
|
} else {
|
||||||
|
throw Exception("Delete failed with code: ${response.code()}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseAttachments(mediaJson: String): List<chats.data.remote.api.AttachmentRequest>? {
|
||||||
|
return try {
|
||||||
|
val mediaList = gson.fromJson(mediaJson, Array::class.java)
|
||||||
|
?.map { elem ->
|
||||||
|
val map = elem as Map<*, *>
|
||||||
|
chats.data.remote.api.AttachmentRequest(
|
||||||
|
type = map["type"] as? String ?: "file",
|
||||||
|
url = map["url"] as? String ?: "",
|
||||||
|
fileName = map["filename"] as? String ?: "file",
|
||||||
|
fileSize = (map["size"] as? Number)?.toLong() ?: 0L
|
||||||
|
)
|
||||||
|
}
|
||||||
|
mediaList?.takeIf { it.isNotEmpty() }
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e(TAG, "Failed to parse attachments", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
package chats.di
|
package chats.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
import chats.data.remote.api.ChatApi
|
import chats.data.remote.api.ChatApi
|
||||||
import chats.data.remote.signalr.ChatHubClient
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
import chats.data.repository.ChatRepositoryImpl
|
import chats.data.repository.ChatRepositoryImpl
|
||||||
|
import chats.data.signalr.MessageSignalRHandler
|
||||||
import chats.domain.repository.ChatRepository
|
import chats.domain.repository.ChatRepository
|
||||||
|
import core.database.data.ChatDatabase
|
||||||
|
import core.database.data.ChatDao
|
||||||
import core.database.data.MessageDao
|
import core.database.data.MessageDao
|
||||||
import core.network.ServerConfig
|
import core.network.ServerConfig
|
||||||
import core.security.TokenManager
|
import core.security.TokenManager
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import dagger.hilt.components.SingletonComponent
|
import dagger.hilt.components.SingletonComponent
|
||||||
import retrofit2.Retrofit
|
import retrofit2.Retrofit
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
@@ -24,16 +29,38 @@ object ChatModule {
|
|||||||
return retrofit.create(ChatApi::class.java)
|
return retrofit.create(ChatApi::class.java)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideMessageSignalRHandler(
|
||||||
|
hubClient: ChatHubClient,
|
||||||
|
messageDao: MessageDao,
|
||||||
|
chatDao: ChatDao,
|
||||||
|
serverConfig: ServerConfig,
|
||||||
|
tokenManager: TokenManager
|
||||||
|
): MessageSignalRHandler {
|
||||||
|
return MessageSignalRHandler(hubClient, messageDao, chatDao, serverConfig, tokenManager)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideChatDao(database: ChatDatabase): ChatDao {
|
||||||
|
return database.chatDao()
|
||||||
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
@Singleton
|
@Singleton
|
||||||
fun provideChatRepository(
|
fun provideChatRepository(
|
||||||
api: ChatApi,
|
api: ChatApi,
|
||||||
tokenManager: TokenManager,
|
tokenManager: TokenManager,
|
||||||
serverConfig: ServerConfig,
|
serverConfig: ServerConfig,
|
||||||
messageDao: MessageDao,
|
messageDao: MessageDao,
|
||||||
hubClient: chats.data.remote.signalr.ChatHubClient
|
chatDao: ChatDao,
|
||||||
|
database: ChatDatabase,
|
||||||
|
hubClient: ChatHubClient,
|
||||||
|
signalRHandler: MessageSignalRHandler,
|
||||||
|
@ApplicationContext context: Context
|
||||||
): ChatRepository {
|
): ChatRepository {
|
||||||
return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, hubClient)
|
return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, chatDao, database, hubClient, signalRHandler, context)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -6,5 +6,13 @@ data class Chat(
|
|||||||
val name: String,
|
val name: String,
|
||||||
val avatar: String?,
|
val avatar: String?,
|
||||||
val unreadCount: Int,
|
val unreadCount: Int,
|
||||||
val lastMessage: Message?
|
val lastMessage: Message? = null,
|
||||||
|
val members: List<ChatMember> = emptyList()
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ChatMember(
|
||||||
|
val userId: String,
|
||||||
|
val username: String,
|
||||||
|
val displayName: String? = null,
|
||||||
|
val avatarUrl: String? = null
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,12 +1,26 @@
|
|||||||
package chats.domain.repository
|
package chats.domain.repository
|
||||||
|
|
||||||
|
import androidx.paging.PagingData
|
||||||
import chats.domain.model.Chat
|
import chats.domain.model.Chat
|
||||||
import chats.domain.model.Message
|
import chats.domain.model.Message
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
interface ChatRepository {
|
interface ChatRepository {
|
||||||
suspend fun getChats(): List<Chat>
|
suspend fun getChats(): List<Chat>
|
||||||
fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>>
|
fun getChatsFlow(): Flow<List<Chat>>
|
||||||
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, limit: Int? = null): List<Message>
|
|
||||||
|
// Flow для UI (простой список)
|
||||||
|
fun getMessagesFlow(chatId: String): Flow<List<Message>>
|
||||||
|
|
||||||
|
// Paging 3 для пагинированного списка
|
||||||
|
fun getMessagesPaging(chatId: String): Flow<PagingData<Message>>
|
||||||
|
|
||||||
|
// Загрузка из сети (для начальной синхронизации)
|
||||||
|
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, afterSequenceId: Long? = null, limit: Int? = null): List<Message>
|
||||||
|
|
||||||
|
// Получить последний известный sequenceId для чата (из локальной базы)
|
||||||
|
suspend fun getLastKnownSequenceId(chatId: String): Int?
|
||||||
|
|
||||||
suspend fun sendMessage(
|
suspend fun sendMessage(
|
||||||
chatId: String,
|
chatId: String,
|
||||||
content: String?,
|
content: String?,
|
||||||
@@ -15,16 +29,23 @@ interface ChatRepository {
|
|||||||
replyToId: String? = null,
|
replyToId: String? = null,
|
||||||
forwardedFromId: String? = null
|
forwardedFromId: String? = null
|
||||||
): Message
|
): Message
|
||||||
|
|
||||||
suspend fun addReaction(messageId: String, emoji: String)
|
suspend fun addReaction(messageId: String, emoji: String)
|
||||||
suspend fun sendTypingStatus(chatId: String)
|
suspend fun sendTypingStatus(chatId: String)
|
||||||
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int)
|
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int)
|
||||||
|
|
||||||
|
// Локальные операции с офлайн-поддержкой
|
||||||
suspend fun saveMessage(message: Message)
|
suspend fun saveMessage(message: Message)
|
||||||
suspend fun deleteLocalMessage(messageId: String)
|
suspend fun deleteLocalMessage(messageId: String)
|
||||||
|
suspend fun editLocalMessage(messageId: String, newContent: String)
|
||||||
|
|
||||||
suspend fun uploadMedia(file: java.io.File): String
|
suspend fun uploadMedia(file: java.io.File): String
|
||||||
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||||
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||||
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
|
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
|
||||||
suspend fun createPersonalChat(userId: String): Chat
|
suspend fun createPersonalChat(userId: String): Chat
|
||||||
|
|
||||||
|
// Серверные операции
|
||||||
suspend fun deleteMessage(messageId: String, forEveryone: Boolean)
|
suspend fun deleteMessage(messageId: String, forEveryone: Boolean)
|
||||||
suspend fun editMessage(messageId: String, content: String): Message
|
suspend fun editMessage(messageId: String, content: String): Message
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,11 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import chats.data.remote.signalr.ChatEvent
|
import chats.data.remote.signalr.ChatEvent
|
||||||
import chats.data.remote.signalr.ChatHubClient
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
|
import chats.data.remote.signalr.ConnectionStatus
|
||||||
import chats.data.repository.toDomain
|
import chats.data.repository.toDomain
|
||||||
import chats.domain.model.Message
|
import chats.domain.model.Message
|
||||||
import chats.domain.repository.ChatRepository
|
import chats.domain.repository.ChatRepository
|
||||||
|
import core.network.NetworkManager
|
||||||
import core.network.ServerConfig
|
import core.network.ServerConfig
|
||||||
import core.security.TokenManager
|
import core.security.TokenManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -18,9 +20,10 @@ import java.io.File
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import core.utils.copyUriToFile
|
import core.utils.copyUriToFile
|
||||||
import core.utils.ImageUtils
|
import core.utils.ImageUtils
|
||||||
|
|
||||||
import chats.data.remote.api.KlipyGifDto
|
import chats.data.remote.api.KlipyGifDto
|
||||||
|
|
||||||
|
private const val TAG = "ChatDetailViewModel"
|
||||||
|
|
||||||
data class ChatDetailState(
|
data class ChatDetailState(
|
||||||
val messages: List<Message> = emptyList(),
|
val messages: List<Message> = emptyList(),
|
||||||
val chatName: String? = null,
|
val chatName: String? = null,
|
||||||
@@ -58,6 +61,7 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
private val tokenManager: TokenManager,
|
private val tokenManager: TokenManager,
|
||||||
private val activeChatTracker: core.notifications.data.ActiveChatTracker,
|
private val activeChatTracker: core.notifications.data.ActiveChatTracker,
|
||||||
private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver,
|
private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver,
|
||||||
|
private val networkManager: NetworkManager,
|
||||||
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
|
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
@@ -113,12 +117,39 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
loadChatInfo(chatId)
|
loadChatInfo(chatId)
|
||||||
|
observeSignalRStatus(chatId)
|
||||||
observeSignalREvents(chatId)
|
observeSignalREvents(chatId)
|
||||||
|
observeNetworkStatus(chatId)
|
||||||
|
|
||||||
// Initial sync from network
|
// Initial sync from network
|
||||||
refreshMessages(chatId)
|
refreshMessages(chatId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeNetworkStatus(chatId: String) {
|
||||||
|
// При восстановлении сети обновляем сообщения
|
||||||
|
networkManager.isOnline
|
||||||
|
.filter { it } // Только переход в онлайн
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach {
|
||||||
|
android.util.Log.d(TAG, "Network restored in chat detail, refreshing messages")
|
||||||
|
kotlinx.coroutines.delay(1000) // Дадим сети стабилизироваться
|
||||||
|
refreshMessages(chatId)
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun observeSignalRStatus(chatId: String) {
|
||||||
|
// При переподключении SignalR обновляем сообщения
|
||||||
|
signalrClient.status
|
||||||
|
.filter { it == ConnectionStatus.CONNECTED }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach {
|
||||||
|
android.util.Log.d(TAG, "SignalR connected, refreshing messages for chat $chatId")
|
||||||
|
refreshMessages(chatId)
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
private fun updateMessages(messages: List<Message>) {
|
private fun updateMessages(messages: List<Message>) {
|
||||||
val sortedMessages = messages.sortedByDescending { it.sequenceId }
|
val sortedMessages = messages.sortedByDescending { it.sequenceId }
|
||||||
_state.update { it.copy(
|
_state.update { it.copy(
|
||||||
@@ -134,8 +165,26 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
fun refreshMessages(chatId: String) {
|
fun refreshMessages(chatId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val messages = repository.getMessages(chatId)
|
try {
|
||||||
updateMessages(messages)
|
// Сначала пытаемся загрузить из сети
|
||||||
|
val messages = repository.getMessages(chatId)
|
||||||
|
if (messages.isNotEmpty()) {
|
||||||
|
updateMessages(messages)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.d(TAG, "Network load failed, trying cache")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если сеть не доступна или пуста - загружаем из Room
|
||||||
|
try {
|
||||||
|
val cachedMessages = repository.getMessagesFlow(chatId).first()
|
||||||
|
android.util.Log.d(TAG, "Loaded ${cachedMessages.size} messages from cache")
|
||||||
|
updateMessages(cachedMessages)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e(TAG, "Cache load failed", e)
|
||||||
|
_state.update { it.copy(isLoading = false, messages = emptyList()) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,14 +216,29 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
|
|
||||||
private fun loadChatInfo(chatId: String) {
|
private fun loadChatInfo(chatId: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
|
// Пробуем загрузить из API
|
||||||
try {
|
try {
|
||||||
val chats = repository.getChats()
|
val chats = repository.getChats()
|
||||||
val chat = chats.find { it.id == chatId }
|
val chat = chats.find { it.id == chatId }
|
||||||
chat?.let { c ->
|
chat?.let { c ->
|
||||||
_state.update { it.copy(chatName = c.name, chatAvatar = c.avatar) }
|
_state.update { it.copy(chatName = c.name, chatAvatar = c.avatar) }
|
||||||
|
android.util.Log.d(TAG, "Loaded chat info from API: ${c.name}")
|
||||||
|
return@launch
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Ignore info load error
|
android.util.Log.d(TAG, "API load failed, will use messages for title")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если API недоступно - берём имя из сообщений (для личных чатов)
|
||||||
|
try {
|
||||||
|
val cachedMessages = repository.getMessagesFlow(chatId).first()
|
||||||
|
val otherUserMessage = cachedMessages.firstOrNull { it.senderId != getCurrentUserId() }
|
||||||
|
otherUserMessage?.let { msg ->
|
||||||
|
_state.update { it.copy(chatName = msg.senderName, chatAvatar = msg.senderAvatar) }
|
||||||
|
android.util.Log.d(TAG, "Loaded chat title from messages: ${msg.senderName}")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e(TAG, "Failed to load chat title from messages", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -191,7 +255,7 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
s.copy(messages = updatedMessages)
|
s.copy(messages = updatedMessages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For handling reaction removed event
|
// For handling reaction removed event
|
||||||
private fun removeMessageReaction(messageId: String, userId: String, emoji: String) {
|
private fun removeMessageReaction(messageId: String, userId: String, emoji: String) {
|
||||||
_state.update { s ->
|
_state.update { s ->
|
||||||
@@ -265,6 +329,11 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ChatEvent.UserTyping -> {
|
is ChatEvent.UserTyping -> {
|
||||||
|
// Игнорируем свои же typing события
|
||||||
|
if (event.userId == getCurrentUserId()) {
|
||||||
|
android.util.Log.d("ChatDetailVM", "Ignoring own typing event")
|
||||||
|
return@onEach
|
||||||
|
}
|
||||||
_state.update { it.copy(isTyping = true) }
|
_state.update { it.copy(isTyping = true) }
|
||||||
typingTimerJob?.cancel()
|
typingTimerJob?.cancel()
|
||||||
typingTimerJob = viewModelScope.launch {
|
typingTimerJob = viewModelScope.launch {
|
||||||
@@ -273,13 +342,27 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is ChatEvent.UserStoppedTyping -> {
|
is ChatEvent.UserStoppedTyping -> {
|
||||||
|
// Игнорируем свои же события
|
||||||
|
if (event.userId == getCurrentUserId()) {
|
||||||
|
android.util.Log.d("ChatDetailVM", "Ignoring own stopped typing event")
|
||||||
|
return@onEach
|
||||||
|
}
|
||||||
_state.update { it.copy(isTyping = false) }
|
_state.update { it.copy(isTyping = false) }
|
||||||
}
|
}
|
||||||
is ChatEvent.MessagesRead -> {
|
is ChatEvent.MessagesRead -> {
|
||||||
|
val currentUserId = getCurrentUserId()
|
||||||
_state.update { currentState ->
|
_state.update { currentState ->
|
||||||
val updatedMessages = currentState.messages.map { msg ->
|
val updatedMessages = currentState.messages.map { msg ->
|
||||||
if (msg.sequenceId <= event.lastReadSequenceId) {
|
if (msg.sequenceId <= event.lastReadSequenceId) {
|
||||||
msg.copy(isRead = true)
|
// Обновляем isRead на основе readBy
|
||||||
|
val isRead = if (msg.senderId == currentUserId) {
|
||||||
|
// Своё сообщение: прочитано, если кто-то кроме отправителя в readBy
|
||||||
|
event.userId != currentUserId
|
||||||
|
} else {
|
||||||
|
// Чужое сообщение: прочитано, если текущий пользователь в readBy
|
||||||
|
event.userId == currentUserId
|
||||||
|
}
|
||||||
|
msg.copy(isRead = isRead)
|
||||||
} else msg
|
} else msg
|
||||||
}
|
}
|
||||||
currentState.copy(messages = updatedMessages)
|
currentState.copy(messages = updatedMessages)
|
||||||
@@ -317,8 +400,9 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
val messages = _state.value.messages
|
val messages = _state.value.messages
|
||||||
if (messages.isEmpty()) return
|
if (messages.isEmpty()) return
|
||||||
|
|
||||||
// В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника
|
|
||||||
val currentUserId = getCurrentUserId()
|
val currentUserId = getCurrentUserId()
|
||||||
|
|
||||||
|
// Находим последнее сообщение от собеседника
|
||||||
val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return
|
val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return
|
||||||
|
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
@@ -326,16 +410,21 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
// Мгновенно обновляем в памяти для "галочек"
|
// Мгновенно обновляем в памяти для "галочек"
|
||||||
_state.update { currentState ->
|
_state.update { currentState ->
|
||||||
val updatedMessages = currentState.messages.map { msg ->
|
val updatedMessages = currentState.messages.map { msg ->
|
||||||
|
// Для чужих сообщений помечаем как прочитанные, если sequenceId <= последнего сообщения от собеседника
|
||||||
if (msg.senderId != currentUserId && msg.sequenceId <= lastMessageFromOther.sequenceId) {
|
if (msg.senderId != currentUserId && msg.sequenceId <= lastMessageFromOther.sequenceId) {
|
||||||
msg.copy(isRead = true)
|
msg.copy(isRead = true)
|
||||||
} else msg
|
} else msg
|
||||||
}
|
}
|
||||||
currentState.copy(messages = updatedMessages)
|
currentState.copy(messages = updatedMessages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Отправляем на сервер
|
||||||
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
|
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
|
||||||
|
|
||||||
|
// Обновляем через SignalR observer для получения актуальных данных
|
||||||
signalrNotificationObserver.refresh()
|
signalrNotificationObserver.refresh()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
// Ignore
|
android.util.Log.e("ChatDetailVM", "markAsRead failed", e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -377,7 +466,7 @@ class ChatDetailViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onForward(message: Message) {
|
fun onForward(message: Message) {
|
||||||
_state.update { it.copy(forwardingMessages = listOf(message)) }
|
_state.update { it.copy(forwardingMessages = listOf(message)) }
|
||||||
loadChatsForForwarding()
|
loadChatsForForwarding()
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ fun HorizontalDividerComponent(
|
|||||||
fun ChatListScreen(
|
fun ChatListScreen(
|
||||||
viewModel: ChatListViewModel,
|
viewModel: ChatListViewModel,
|
||||||
storyViewModel: StoryViewModel,
|
storyViewModel: StoryViewModel,
|
||||||
onChatClick: (String) -> Unit,
|
onChatClick: (String, String) -> Unit,
|
||||||
onStoryClick: (Int) -> Unit
|
onStoryClick: (Int) -> Unit
|
||||||
) {
|
) {
|
||||||
val state by viewModel.state.collectAsState()
|
val state by viewModel.state.collectAsState()
|
||||||
@@ -112,7 +112,7 @@ fun ChatListScreen(
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
items(state.chats) { chat ->
|
items(state.chats) { chat ->
|
||||||
ChatItem(chat = chat, onClick = onChatClick)
|
ChatItem(chat = chat, onClick = { onChatClick(chat.id, chat.name) })
|
||||||
HorizontalDividerComponent(
|
HorizontalDividerComponent(
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp),
|
||||||
thickness = 0.5.dp,
|
thickness = 0.5.dp,
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import androidx.lifecycle.viewModelScope
|
|||||||
import chats.domain.model.Chat
|
import chats.domain.model.Chat
|
||||||
import chats.domain.repository.ChatRepository
|
import chats.domain.repository.ChatRepository
|
||||||
import chats.data.remote.signalr.ChatHubClient
|
import chats.data.remote.signalr.ChatHubClient
|
||||||
|
import chats.data.remote.signalr.ConnectionStatus
|
||||||
import chats.data.remote.signalr.ChatEvent
|
import chats.data.remote.signalr.ChatEvent
|
||||||
|
import core.network.NetworkManager
|
||||||
import core.network.ServerConfig
|
import core.network.ServerConfig
|
||||||
import core.security.TokenManager
|
import core.security.TokenManager
|
||||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||||
@@ -14,6 +16,8 @@ import kotlinx.coroutines.launch
|
|||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import chats.data.repository.toDomain
|
import chats.data.repository.toDomain
|
||||||
|
|
||||||
|
private const val TAG = "ChatListViewModel"
|
||||||
|
|
||||||
data class ChatListState(
|
data class ChatListState(
|
||||||
val chats: List<Chat> = emptyList(),
|
val chats: List<Chat> = emptyList(),
|
||||||
val isLoading: Boolean = false,
|
val isLoading: Boolean = false,
|
||||||
@@ -24,67 +28,77 @@ data class ChatListState(
|
|||||||
@HiltViewModel
|
@HiltViewModel
|
||||||
class ChatListViewModel @Inject constructor(
|
class ChatListViewModel @Inject constructor(
|
||||||
private val repository: ChatRepository,
|
private val repository: ChatRepository,
|
||||||
private val authRepository: auth.domain.repository.AuthRepository,
|
private val hubClient: ChatHubClient,
|
||||||
private val signalrClient: ChatHubClient,
|
|
||||||
private val serverConfig: ServerConfig,
|
private val serverConfig: ServerConfig,
|
||||||
private val tokenManager: TokenManager
|
private val tokenManager: TokenManager,
|
||||||
|
private val networkManager: NetworkManager
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
||||||
private val _state = MutableStateFlow(ChatListState())
|
private val _state = MutableStateFlow(ChatListState())
|
||||||
val state: StateFlow<ChatListState> = _state.asStateFlow()
|
val state: StateFlow<ChatListState> = _state.asStateFlow()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
updatePushToken()
|
|
||||||
|
|
||||||
val isStoriesEnabled = try {
|
|
||||||
serverConfig.getServerConfig().features.stories
|
|
||||||
} catch (e: Exception) {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
_state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
|
|
||||||
|
|
||||||
val token = tokenManager.getToken()
|
|
||||||
val baseUrl = serverConfig.getBaseUrl()
|
|
||||||
|
|
||||||
// Подключаемся к SignalR только если есть токен И введён URL сервера
|
|
||||||
if (token != null && baseUrl.isNotBlank()) {
|
|
||||||
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
|
|
||||||
}
|
|
||||||
|
|
||||||
loadChats()
|
loadChats()
|
||||||
|
observeSignalRStatus()
|
||||||
observeSignalREvents()
|
observeSignalREvents()
|
||||||
|
observeNetworkStatus()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun updatePushToken() {
|
private fun observeNetworkStatus() {
|
||||||
com.google.firebase.messaging.FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
// При восстановлении сети обновляем чаты
|
||||||
if (task.isSuccessful) {
|
networkManager.isOnline
|
||||||
val token = task.result
|
.filter { it } // Только переход в онлайн
|
||||||
viewModelScope.launch {
|
.distinctUntilChanged()
|
||||||
try {
|
.onEach {
|
||||||
authRepository.updatePushToken(token)
|
android.util.Log.d(TAG, "Network restored in chat list, refreshing chats")
|
||||||
} catch (e: Exception) {
|
kotlinx.coroutines.delay(1000) // Дадим сети стабилизироваться
|
||||||
android.util.Log.e("ChatListVM", "Failed to update push token: ${e.message}")
|
repository.getChats()
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
.launchIn(viewModelScope)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
|
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
|
||||||
|
|
||||||
fun loadChats() {
|
fun loadChats() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
_state.update { it.copy(isLoading = true) }
|
_state.update { it.copy(isLoading = true) }
|
||||||
try {
|
try {
|
||||||
val chats = repository.getChats()
|
// Пробуем загрузить из сети (это закэширует в Room)
|
||||||
_state.update { it.copy(chats = sortChats(chats), isLoading = false) }
|
repository.getChats()
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_state.update { it.copy(isLoading = false, error = e.message) }
|
android.util.Log.d(TAG, "Initial load failed, will use cache")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Подписываемся на Flow из Room (всегда работает, даже оффлайн)
|
||||||
|
repository.getChatsFlow()
|
||||||
|
.catch { e ->
|
||||||
|
android.util.Log.e(TAG, "Flow error", e)
|
||||||
|
emit(emptyList())
|
||||||
|
}
|
||||||
|
.collect { chats ->
|
||||||
|
_state.update {
|
||||||
|
it.copy(
|
||||||
|
chats = sortChats(chats),
|
||||||
|
isLoading = false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun observeSignalRStatus() {
|
||||||
|
// Наблюдаем за статусом подключения SignalR и обновляем чаты при переподключении
|
||||||
|
hubClient.status
|
||||||
|
.filter { it == ConnectionStatus.CONNECTED }
|
||||||
|
.distinctUntilChanged()
|
||||||
|
.onEach {
|
||||||
|
android.util.Log.d(TAG, "SignalR connected, refreshing chats")
|
||||||
|
// При переподключении обновляем чаты из сети
|
||||||
|
repository.getChats()
|
||||||
|
}
|
||||||
|
.launchIn(viewModelScope)
|
||||||
|
}
|
||||||
|
|
||||||
private fun sortChats(chats: List<Chat>): List<Chat> {
|
private fun sortChats(chats: List<Chat>): List<Chat> {
|
||||||
return chats.sortedWith(compareByDescending<Chat> {
|
return chats.sortedWith(compareByDescending<Chat> {
|
||||||
it.name.equals("Избранное", ignoreCase = true) || it.name.equals("Saved Messages", ignoreCase = true)
|
it.name.equals("Избранное", ignoreCase = true) || it.name.equals("Saved Messages", ignoreCase = true)
|
||||||
@@ -92,15 +106,17 @@ class ChatListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
|
|
||||||
private fun observeSignalREvents() {
|
private fun observeSignalREvents() {
|
||||||
signalrClient.events
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
android.util.Log.d(TAG, "Starting to observe SignalR events")
|
||||||
|
hubClient.events
|
||||||
.onEach { event ->
|
.onEach { event ->
|
||||||
|
android.util.Log.d(TAG, ">>> ChatListVM received event: ${event::class.simpleName}")
|
||||||
when (event) {
|
when (event) {
|
||||||
is ChatEvent.NewMessage -> {
|
is ChatEvent.NewMessage -> {
|
||||||
updateChatsWithNewMessage(event)
|
updateChatsWithNewMessage(event)
|
||||||
}
|
}
|
||||||
is ChatEvent.NewChat -> {
|
is ChatEvent.NewChat -> {
|
||||||
val currentUserId = getCurrentUserId()
|
val currentUserId = getCurrentUserId()
|
||||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
|
||||||
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) }
|
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) }
|
||||||
}
|
}
|
||||||
is ChatEvent.MessagesRead -> {
|
is ChatEvent.MessagesRead -> {
|
||||||
@@ -127,30 +143,47 @@ class ChatListViewModel @Inject constructor(
|
|||||||
val currentUserId = getCurrentUserId()
|
val currentUserId = getCurrentUserId()
|
||||||
|
|
||||||
_state.update { currentState ->
|
_state.update { currentState ->
|
||||||
val updatedChats = currentState.chats.map { chat ->
|
val chatIndex = currentState.chats.indexOfFirst {
|
||||||
if (chat.id.equals(event.message.chatId, ignoreCase = true)) {
|
it.id.equals(event.message.chatId, ignoreCase = true)
|
||||||
val isMyMessage = event.message.senderId == currentUserId
|
|
||||||
val isAlreadySeen = chat.lastMessage?.id == event.message.id
|
|
||||||
|
|
||||||
val lastMsgDomain = event.message.toDomain(currentUserId, baseUrl)
|
|
||||||
val newCount = if (isMyMessage || isAlreadySeen) {
|
|
||||||
chat.unreadCount
|
|
||||||
} else {
|
|
||||||
chat.unreadCount + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isAlreadySeen) {
|
|
||||||
android.util.Log.d("ChatListVM", "Message ${event.message.id} -> Count ${chat.unreadCount} -> $newCount")
|
|
||||||
}
|
|
||||||
|
|
||||||
chat.copy(
|
|
||||||
lastMessage = lastMsgDomain,
|
|
||||||
unreadCount = newCount
|
|
||||||
)
|
|
||||||
} else chat
|
|
||||||
}
|
}
|
||||||
|
|
||||||
currentState.copy(chats = sortChats(updatedChats))
|
if (chatIndex >= 0) {
|
||||||
|
// Чат есть в списке - обновляем его
|
||||||
|
val chat = currentState.chats[chatIndex]
|
||||||
|
val isMyMessage = event.message.senderId == currentUserId
|
||||||
|
val isAlreadySeen = chat.lastMessage?.id == event.message.id
|
||||||
|
|
||||||
|
val lastMsgDomain = event.message.toDomain(currentUserId, baseUrl)
|
||||||
|
val newCount = if (isMyMessage || isAlreadySeen) {
|
||||||
|
chat.unreadCount
|
||||||
|
} else {
|
||||||
|
chat.unreadCount + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAlreadySeen) {
|
||||||
|
android.util.Log.d("ChatListVM", "Message ${event.message.id} -> Count ${chat.unreadCount} -> $newCount")
|
||||||
|
}
|
||||||
|
|
||||||
|
val updatedChat = chat.copy(
|
||||||
|
lastMessage = lastMsgDomain,
|
||||||
|
unreadCount = newCount
|
||||||
|
)
|
||||||
|
|
||||||
|
val updatedChats = currentState.chats.toMutableList()
|
||||||
|
updatedChats[chatIndex] = updatedChat
|
||||||
|
currentState.copy(chats = sortChats(updatedChats))
|
||||||
|
} else {
|
||||||
|
// Чата нет в списке - обновляем весь список из репозитория
|
||||||
|
android.util.Log.d("ChatListVM", "Chat ${event.message.chatId} not found in list, refreshing from repository")
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
repository.getChats() // Это обновит Room и Flow
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ChatListVM", "Failed to refresh chats", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
currentState
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,33 @@ import androidx.compose.foundation.background
|
|||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
import androidx.compose.ui.unit.sp
|
||||||
import chats.domain.model.Chat
|
|
||||||
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import core.presentation.components.AppAvatar
|
|
||||||
import chats.domain.model.MediaType
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import chats.domain.model.Chat
|
||||||
|
import chats.domain.model.Message
|
||||||
|
import chats.domain.model.MediaType
|
||||||
|
import core.presentation.components.AppAvatar
|
||||||
|
import ru.knot.messager.R
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Done
|
||||||
|
import androidx.compose.material.icons.filled.DoneAll
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ChatItem(
|
fun ChatItem(
|
||||||
chat: Chat,
|
chat: Chat,
|
||||||
onClick: (String) -> Unit
|
onClick: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
|
val currentUserId = "current_user_id" // TODO: Get from AuthManager/TokenManager
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -74,14 +77,15 @@ fun ChatItem(
|
|||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
|
val context = LocalContext.current
|
||||||
val previewText = remember(chat.lastMessage) {
|
val previewText = remember(chat.lastMessage) {
|
||||||
val msg = chat.lastMessage
|
val msg = chat.lastMessage
|
||||||
if (msg == null) return@remember "No messages yet"
|
if (msg == null) return@remember context.getString(R.string.no_messages_yet)
|
||||||
|
|
||||||
if (!msg.content.isNullOrBlank()) {
|
if (!msg.content.isNullOrBlank()) {
|
||||||
msg.content
|
msg.content
|
||||||
} else if (msg.mediaType == MediaType.AUDIO) {
|
} else if (msg.mediaType == MediaType.AUDIO) {
|
||||||
"Голосовое сообщение"
|
context.getString(R.string.voice_message)
|
||||||
} else if (msg.media.isNotEmpty()) {
|
} else if (msg.media.isNotEmpty()) {
|
||||||
when (msg.mediaType) {
|
when (msg.mediaType) {
|
||||||
MediaType.IMAGE -> "Фото"
|
MediaType.IMAGE -> "Фото"
|
||||||
@@ -90,7 +94,7 @@ fun ChatItem(
|
|||||||
else -> "Медиа"
|
else -> "Медиа"
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
"Сообщение"
|
context.getString(R.string.message)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
@@ -102,20 +106,44 @@ fun ChatItem(
|
|||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.weight(1f)
|
||||||
)
|
)
|
||||||
|
|
||||||
if (chat.unreadCount > 0) {
|
Row(
|
||||||
Box(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier
|
modifier = Modifier.padding(start = 8.dp)
|
||||||
.padding(start = 8.dp)
|
) {
|
||||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
// Галочки прочтения только для своих сообщений
|
||||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
chat.lastMessage?.let { lastMessage ->
|
||||||
contentAlignment = Alignment.Center
|
if (lastMessage.senderId == currentUserId && !lastMessage.isRead) {
|
||||||
) {
|
Icon(
|
||||||
Text(
|
imageVector = Icons.Default.Done,
|
||||||
text = chat.unreadCount.toString(),
|
contentDescription = null,
|
||||||
color = Color.White,
|
tint = Color.Gray,
|
||||||
fontSize = 10.sp,
|
modifier = Modifier.size(14.dp)
|
||||||
fontWeight = FontWeight.Bold
|
)
|
||||||
)
|
} else if (lastMessage.senderId == currentUserId && lastMessage.isRead) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.DoneAll,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = Color.Blue,
|
||||||
|
modifier = Modifier.size(14.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chat.unreadCount > 0) {
|
||||||
|
Spacer(modifier = Modifier.width(4.dp))
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||||
|
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = chat.unreadCount.toString(),
|
||||||
|
color = Color.White,
|
||||||
|
fontSize = 10.sp,
|
||||||
|
fontWeight = FontWeight.Bold
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ fun ContactListScreen(
|
|||||||
onContactClick: (String) -> Unit,
|
onContactClick: (String) -> Unit,
|
||||||
onSearchChange: (String) -> Unit,
|
onSearchChange: (String) -> Unit,
|
||||||
onAddContact: (String) -> Unit,
|
onAddContact: (String) -> Unit,
|
||||||
onStartChat: (String) -> Unit,
|
onStartChat: (String, String) -> Unit,
|
||||||
onAcceptRequest: (String) -> Unit,
|
onAcceptRequest: (String) -> Unit,
|
||||||
onDeclineRequest: (String) -> Unit
|
onDeclineRequest: (String) -> Unit
|
||||||
) {
|
) {
|
||||||
@@ -136,7 +136,7 @@ fun ContactListScreen(
|
|||||||
contact = contact,
|
contact = contact,
|
||||||
onClick = { onContactClick(contact.id) },
|
onClick = { onContactClick(contact.id) },
|
||||||
onAddClick = { onAddContact(contact.id) },
|
onAddClick = { onAddContact(contact.id) },
|
||||||
onChatClick = { onStartChat(contact.id) },
|
onChatClick = { onStartChat(contact.id, contact.displayName ?: contact.effectiveUsername) },
|
||||||
isSearchMode = searchQuery.isNotEmpty()
|
isSearchMode = searchQuery.isNotEmpty()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -106,14 +106,14 @@ class ContactListViewModel @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun startChat(userId: String) {
|
fun startChat(userId: String, userName: String) {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
// В вебе мы ищем существующий чат или создаем новый.
|
// В вебе мы ищем существующий чат или создаем новый.
|
||||||
// В мобилке мы для начала можем просто вызвать createPersonalChat.
|
// В мобилке мы для начала можем просто вызвать createPersonalChat.
|
||||||
// Бэкенд обычно возвращает существующий чат, если он уже есть.
|
// Бэкенд обычно возвращает существующий чат, если он уже есть.
|
||||||
val chat = chatRepository.createPersonalChat(userId)
|
val chat = chatRepository.createPersonalChat(userId)
|
||||||
navigationManager.navigateToChat(chat.id)
|
navigationManager.navigateToChat(chat.id, userName)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
_state.update { it.copy(error = e.message) }
|
_state.update { it.copy(error = e.message) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,39 @@
|
|||||||
package core.database.data
|
package core.database.data
|
||||||
|
|
||||||
|
import androidx.paging.PagingSource
|
||||||
import androidx.room.*
|
import androidx.room.*
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Статус синхронизации сообщения с сервером
|
||||||
|
*/
|
||||||
|
enum class SyncStatus {
|
||||||
|
SYNCED, // Сообщение успешно синхронизировано
|
||||||
|
SYNCING, // Сообщение отправляется на сервер
|
||||||
|
FAILED // Ошибка синхронизации
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity для хранения чатов в локальной базе данных
|
||||||
|
*/
|
||||||
|
@Entity(tableName = "chats")
|
||||||
|
data class ChatEntity(
|
||||||
|
@PrimaryKey val id: String,
|
||||||
|
val name: String,
|
||||||
|
val avatar: String?,
|
||||||
|
val type: String = "personal", // personal, group, saved
|
||||||
|
val lastMessageId: String? = null,
|
||||||
|
val lastMessageText: String? = null,
|
||||||
|
val lastMessageAt: Long = 0,
|
||||||
|
val unreadCount: Int = 0,
|
||||||
|
val isPinned: Boolean = false,
|
||||||
|
val lastUpdated: Long = System.currentTimeMillis()
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Entity для хранения сообщений в локальной базе данных
|
||||||
|
* Поддерживает офлайн-работу и фоновую синхронизацию
|
||||||
|
*/
|
||||||
@Entity(tableName = "messages")
|
@Entity(tableName = "messages")
|
||||||
data class MessageEntity(
|
data class MessageEntity(
|
||||||
@PrimaryKey val id: String,
|
@PrimaryKey val id: String,
|
||||||
@@ -14,20 +45,102 @@ data class MessageEntity(
|
|||||||
val sequenceId: Int,
|
val sequenceId: Int,
|
||||||
val createdAt: String,
|
val createdAt: String,
|
||||||
val mediaType: String,
|
val mediaType: String,
|
||||||
val mediaJson: String, // Simplified for now
|
val mediaJson: String,
|
||||||
val reactionsJson: String,
|
val reactionsJson: String,
|
||||||
val isRead: Boolean,
|
val isRead: Boolean,
|
||||||
val replyToId: String? = null
|
val replyToId: String? = null,
|
||||||
|
|
||||||
|
// Поля для офлайн-синхронизации
|
||||||
|
@ColumnInfo(defaultValue = "SYNCED")
|
||||||
|
val syncStatus: SyncStatus = SyncStatus.SYNCED,
|
||||||
|
|
||||||
|
@ColumnInfo(defaultValue = "0")
|
||||||
|
val isDeletedLocally: Boolean = false,
|
||||||
|
|
||||||
|
@ColumnInfo(defaultValue = "0")
|
||||||
|
val isEditedLocally: Boolean = false,
|
||||||
|
|
||||||
|
val editedContent: String? = null,
|
||||||
|
|
||||||
|
@ColumnInfo(defaultValue = "0")
|
||||||
|
val lastUpdated: Long = 0
|
||||||
)
|
)
|
||||||
|
|
||||||
@Dao
|
@Dao
|
||||||
interface MessageDao {
|
interface MessageDao {
|
||||||
@Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC")
|
// ==================== Flow для UI ====================
|
||||||
|
|
||||||
|
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 ORDER BY sequenceId ASC")
|
||||||
fun getMessages(chatId: String): Flow<List<MessageEntity>>
|
fun getMessages(chatId: String): Flow<List<MessageEntity>>
|
||||||
|
|
||||||
|
// ==================== Paging 3 ====================
|
||||||
|
|
||||||
|
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 ORDER BY sequenceId DESC")
|
||||||
|
fun getMessagesPagingSource(chatId: String): PagingSource<Int, MessageEntity>
|
||||||
|
|
||||||
|
// Загружаем сообщения С МЕНЬШИМ sequenceId (старые), порядок DESC для PagingSource
|
||||||
|
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 AND sequenceId < :sequenceId ORDER BY sequenceId DESC LIMIT :limit")
|
||||||
|
suspend fun getMessagesBefore(chatId: String, sequenceId: Int, limit: Int): List<MessageEntity>
|
||||||
|
|
||||||
|
// Загружаем сообщения до и ВКЛЮЧАЯ sequenceId, порядок DESC
|
||||||
|
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 AND sequenceId <= :sequenceId ORDER BY sequenceId DESC LIMIT :limit")
|
||||||
|
suspend fun getMessagesUpToAndIncluding(chatId: String, sequenceId: Int, limit: Int): List<MessageEntity>
|
||||||
|
|
||||||
|
// Загружаем сообщения С БОЛЬШИМ sequenceId (новые), порядок ASC
|
||||||
|
@Query("SELECT * FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0 AND sequenceId > :sequenceId ORDER BY sequenceId ASC LIMIT :limit")
|
||||||
|
suspend fun getMessagesAfter(chatId: String, sequenceId: Int, limit: Int): List<MessageEntity>
|
||||||
|
|
||||||
|
@Query("SELECT MAX(sequenceId) FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0")
|
||||||
|
suspend fun getMaxSequenceId(chatId: String): Int?
|
||||||
|
|
||||||
|
@Query("SELECT MIN(sequenceId) FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0")
|
||||||
|
suspend fun getMinSequenceId(chatId: String): Int?
|
||||||
|
|
||||||
|
// ==================== Основные операции ====================
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insertMessages(messages: List<MessageEntity>)
|
suspend fun insertMessages(messages: List<MessageEntity>)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertMessage(message: MessageEntity)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert - вставляет или обновляет сообщение
|
||||||
|
* Приоритет: серверные данные > локальные (кроме сообщений в процессе отправки)
|
||||||
|
*/
|
||||||
|
@Transaction
|
||||||
|
suspend fun upsertMessage(message: MessageEntity) {
|
||||||
|
val existing = getMessageById(message.id)
|
||||||
|
if (existing != null) {
|
||||||
|
// Сохраняем локальные изменения если сообщение в процессе отправки
|
||||||
|
val syncedMessage = when {
|
||||||
|
existing.syncStatus == SyncStatus.SYNCING || existing.isEditedLocally -> {
|
||||||
|
message.copy(
|
||||||
|
syncStatus = existing.syncStatus,
|
||||||
|
isEditedLocally = existing.isEditedLocally,
|
||||||
|
editedContent = existing.editedContent,
|
||||||
|
lastUpdated = existing.lastUpdated
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> message
|
||||||
|
}
|
||||||
|
insertMessage(syncedMessage)
|
||||||
|
} else {
|
||||||
|
insertMessage(message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transaction
|
||||||
|
suspend fun upsertMessages(messages: List<MessageEntity>) {
|
||||||
|
messages.forEach { upsertMessage(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
|
||||||
|
suspend fun getMessageById(id: String): MessageEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM messages WHERE id = :id LIMIT 1")
|
||||||
|
fun getMessageByIdFlow(id: String): Flow<MessageEntity?>
|
||||||
|
|
||||||
@Query("DELETE FROM messages WHERE chatId = :chatId")
|
@Query("DELETE FROM messages WHERE chatId = :chatId")
|
||||||
suspend fun clearChat(chatId: String)
|
suspend fun clearChat(chatId: String)
|
||||||
|
|
||||||
@@ -36,9 +149,112 @@ interface MessageDao {
|
|||||||
|
|
||||||
@Query("UPDATE messages SET isRead = 1 WHERE chatId = :chatId AND sequenceId <= :lastReadSequenceId AND isRead = 0")
|
@Query("UPDATE messages SET isRead = 1 WHERE chatId = :chatId AND sequenceId <= :lastReadSequenceId AND isRead = 0")
|
||||||
suspend fun markMessagesAsRead(chatId: String, lastReadSequenceId: Int)
|
suspend fun markMessagesAsRead(chatId: String, lastReadSequenceId: Int)
|
||||||
|
|
||||||
|
// ==================== Офлайн операции ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Помечает сообщение как удалённое локально
|
||||||
|
* Фактическое удаление произойдёт после синхронизации с сервером
|
||||||
|
*/
|
||||||
|
@Query("UPDATE messages SET isDeletedLocally = 1, syncStatus = 'SYNCING', lastUpdated = :timestamp WHERE id = :messageId")
|
||||||
|
suspend fun markAsDeletedLocally(messageId: String, timestamp: Long = System.currentTimeMillis())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Помечает сообщение как отредактированное локально
|
||||||
|
*/
|
||||||
|
@Query("UPDATE messages SET isEditedLocally = 1, editedContent = :newContent, syncStatus = 'SYNCING', lastUpdated = :timestamp WHERE id = :messageId")
|
||||||
|
suspend fun markAsEditedLocally(messageId: String, newContent: String, timestamp: Long = System.currentTimeMillis())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Сбрасывает локальные изменения после успешной синхронизации
|
||||||
|
*/
|
||||||
|
@Query("UPDATE messages SET syncStatus = 'SYNCED', isDeletedLocally = 0, isEditedLocally = 0, editedContent = NULL WHERE id = :messageId")
|
||||||
|
suspend fun markAsSynced(messageId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Устанавливает статус FAILED для сообщений с ошибкой синхронизации
|
||||||
|
*/
|
||||||
|
@Query("UPDATE messages SET syncStatus = 'FAILED' WHERE id = :messageId")
|
||||||
|
suspend fun markAsSyncFailed(messageId: String)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает все сообщения, требующие синхронизации
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM messages WHERE syncStatus = 'SYNCING' OR isDeletedLocally = 1 ORDER BY lastUpdated ASC")
|
||||||
|
suspend fun getPendingSyncMessages(): List<MessageEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM messages WHERE syncStatus = 'SYNCING' OR isDeletedLocally = 1 ORDER BY lastUpdated ASC")
|
||||||
|
fun getPendingSyncMessagesFlow(): Flow<List<MessageEntity>>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получает сообщения с_failed статусом для повторной отправки
|
||||||
|
*/
|
||||||
|
@Query("SELECT * FROM messages WHERE syncStatus = 'FAILED' ORDER BY lastUpdated ASC")
|
||||||
|
suspend fun getFailedSyncMessages(): List<MessageEntity>
|
||||||
|
|
||||||
|
// ==================== Утилиты ====================
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM messages WHERE chatId = :chatId AND isDeletedLocally = 0")
|
||||||
|
suspend fun getMessagesCount(chatId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT COUNT(*) FROM messages WHERE chatId = :chatId AND isRead = 0 AND isDeletedLocally = 0")
|
||||||
|
suspend fun getUnreadCount(chatId: String): Int
|
||||||
|
|
||||||
|
@Query("SELECT EXISTS(SELECT 1 FROM messages WHERE id = :id)")
|
||||||
|
suspend fun exists(id: String): Boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@Database(entities = [MessageEntity::class], version = 1)
|
@Dao
|
||||||
|
interface ChatDao {
|
||||||
|
@Query("SELECT * FROM chats ORDER BY isPinned DESC, lastMessageAt DESC")
|
||||||
|
fun getAllChatsFlow(): Flow<List<ChatEntity>>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM chats ORDER BY isPinned DESC, lastMessageAt DESC")
|
||||||
|
suspend fun getAllChats(): List<ChatEntity>
|
||||||
|
|
||||||
|
@Query("SELECT * FROM chats WHERE id = :chatId LIMIT 1")
|
||||||
|
suspend fun getChatById(chatId: String): ChatEntity?
|
||||||
|
|
||||||
|
@Query("SELECT * FROM chats WHERE id = :chatId LIMIT 1")
|
||||||
|
fun getChatByIdFlow(chatId: String): Flow<ChatEntity?>
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertChat(chat: ChatEntity)
|
||||||
|
|
||||||
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
|
suspend fun insertChats(chats: List<ChatEntity>)
|
||||||
|
|
||||||
|
@Query("DELETE FROM chats WHERE id = :chatId")
|
||||||
|
suspend fun deleteChat(chatId: String)
|
||||||
|
|
||||||
|
@Query("DELETE FROM chats")
|
||||||
|
suspend fun clearAll()
|
||||||
|
|
||||||
|
@Query("UPDATE chats SET unreadCount = :count WHERE id = :chatId")
|
||||||
|
suspend fun updateUnreadCount(chatId: String, count: Int)
|
||||||
|
|
||||||
|
@Query("UPDATE chats SET lastMessageId = :lastMessageId, lastMessageText = :lastMessageText, lastMessageAt = :lastMessageAt WHERE id = :chatId")
|
||||||
|
suspend fun updateLastMessage(chatId: String, lastMessageId: String?, lastMessageText: String?, lastMessageAt: Long)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Database(entities = [MessageEntity::class, ChatEntity::class], version = 3)
|
||||||
|
@TypeConverters(SyncStatusConverter::class)
|
||||||
abstract class ChatDatabase : RoomDatabase() {
|
abstract class ChatDatabase : RoomDatabase() {
|
||||||
abstract fun messageDao(): MessageDao
|
abstract fun messageDao(): MessageDao
|
||||||
|
abstract fun chatDao(): ChatDao
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val DATABASE_NAME = "knot_chat_database"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Конвертер для Enum SyncStatus
|
||||||
|
*/
|
||||||
|
class SyncStatusConverter {
|
||||||
|
@TypeConverter
|
||||||
|
fun fromSyncStatus(status: SyncStatus): String = status.name
|
||||||
|
|
||||||
|
@TypeConverter
|
||||||
|
fun toSyncStatus(value: String): SyncStatus = SyncStatus.valueOf(value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package core.database.data
|
||||||
|
|
||||||
|
import androidx.room.migration.Migration
|
||||||
|
import androidx.sqlite.db.SupportSQLiteDatabase
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция с версии 1 на версию 2
|
||||||
|
* Добавляет поля для офлайн-синхронизации:
|
||||||
|
* - syncStatus (TEXT, по умолчанию 'SYNCED')
|
||||||
|
* - isDeletedLocally (INTEGER, по умолчанию 0)
|
||||||
|
* - isEditedLocally (INTEGER, по умолчанию 0)
|
||||||
|
* - editedContent (TEXT, nullable)
|
||||||
|
* - lastUpdated (INTEGER, по умолчанию текущее время)
|
||||||
|
*/
|
||||||
|
val MIGRATION_1_2 = object : Migration(1, 2) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
// Добавляем новые колонки для синхронизации
|
||||||
|
database.execSQL("""
|
||||||
|
ALTER TABLE messages ADD COLUMN syncStatus TEXT NOT NULL DEFAULT 'SYNCED'
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
database.execSQL("""
|
||||||
|
ALTER TABLE messages ADD COLUMN isDeletedLocally INTEGER NOT NULL DEFAULT 0
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
database.execSQL("""
|
||||||
|
ALTER TABLE messages ADD COLUMN isEditedLocally INTEGER NOT NULL DEFAULT 0
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
database.execSQL("""
|
||||||
|
ALTER TABLE messages ADD COLUMN editedContent TEXT
|
||||||
|
""".trimIndent())
|
||||||
|
|
||||||
|
// lastUpdated по умолчанию 0, будет обновлён при первой синхронизации
|
||||||
|
database.execSQL("""
|
||||||
|
ALTER TABLE messages ADD COLUMN lastUpdated INTEGER NOT NULL DEFAULT 0
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Миграция с версии 2 на версию 3
|
||||||
|
* Добавляет таблицу chats для кэширования списка чатов
|
||||||
|
*/
|
||||||
|
val MIGRATION_2_3 = object : Migration(2, 3) {
|
||||||
|
override fun migrate(database: SupportSQLiteDatabase) {
|
||||||
|
database.execSQL("""
|
||||||
|
CREATE TABLE IF NOT EXISTS chats (
|
||||||
|
id TEXT PRIMARY KEY NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
avatar TEXT,
|
||||||
|
type TEXT NOT NULL DEFAULT 'personal',
|
||||||
|
lastMessageId TEXT,
|
||||||
|
lastMessageText TEXT,
|
||||||
|
lastMessageAt INTEGER NOT NULL DEFAULT 0,
|
||||||
|
unreadCount INTEGER NOT NULL DEFAULT 0,
|
||||||
|
isPinned INTEGER NOT NULL DEFAULT 0,
|
||||||
|
lastUpdated INTEGER NOT NULL DEFAULT 0
|
||||||
|
)
|
||||||
|
""".trimIndent())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import android.content.Context
|
|||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import core.database.data.ChatDatabase
|
import core.database.data.ChatDatabase
|
||||||
import core.database.data.MessageDao
|
import core.database.data.MessageDao
|
||||||
|
import core.database.data.MIGRATION_1_2
|
||||||
|
import core.database.data.MIGRATION_2_3
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
import dagger.hilt.InstallIn
|
import dagger.hilt.InstallIn
|
||||||
@@ -21,8 +23,10 @@ object DatabaseModule {
|
|||||||
return Room.databaseBuilder(
|
return Room.databaseBuilder(
|
||||||
context,
|
context,
|
||||||
ChatDatabase::class.java,
|
ChatDatabase::class.java,
|
||||||
"chat_database"
|
ChatDatabase.DATABASE_NAME
|
||||||
).fallbackToDestructiveMigration().build()
|
)
|
||||||
|
.addMigrations(MIGRATION_1_2, MIGRATION_2_3)
|
||||||
|
.build()
|
||||||
}
|
}
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package core.di
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import androidx.work.Configuration
|
||||||
|
import androidx.work.WorkManager
|
||||||
|
import dagger.Module
|
||||||
|
import dagger.Provides
|
||||||
|
import dagger.hilt.InstallIn
|
||||||
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
|
import dagger.hilt.components.SingletonComponent
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DI модуль для WorkManager
|
||||||
|
*/
|
||||||
|
@Module
|
||||||
|
@InstallIn(SingletonComponent::class)
|
||||||
|
object WorkManagerModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideWorkManager(
|
||||||
|
@ApplicationContext context: Context,
|
||||||
|
configuration: Configuration
|
||||||
|
): WorkManager {
|
||||||
|
WorkManager.initialize(context, configuration)
|
||||||
|
return WorkManager.getInstance(context)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideWorkManagerConfiguration(): Configuration {
|
||||||
|
return Configuration.Builder()
|
||||||
|
.setMinimumLoggingLevel(android.util.Log.INFO)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
package core.network
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.IntentFilter
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.net.Network
|
||||||
|
import android.net.NetworkCapabilities
|
||||||
|
import android.net.NetworkRequest
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Менеджер состояния сети
|
||||||
|
* Отслеживает подключение к интернету через NetworkCallback + BroadcastReceiver
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NetworkManager @Inject constructor(
|
||||||
|
private val context: Context
|
||||||
|
) {
|
||||||
|
private val _isOnline = MutableStateFlow(isNetworkAvailable())
|
||||||
|
val isOnline: StateFlow<Boolean> = _isOnline.asStateFlow()
|
||||||
|
|
||||||
|
private val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
private var networkReceiver: NetworkReceiver? = null
|
||||||
|
private var isMonitoring = false
|
||||||
|
|
||||||
|
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) {
|
||||||
|
Log.d("NetworkManager", "Network available - callback")
|
||||||
|
_isOnline.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onLost(network: Network) {
|
||||||
|
Log.d("NetworkManager", "Network lost - callback")
|
||||||
|
_isOnline.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(
|
||||||
|
network: Network,
|
||||||
|
networkCapabilities: NetworkCapabilities
|
||||||
|
) {
|
||||||
|
val hasInternet = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
val hasValidated = networkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||||
|
Log.d("NetworkManager", "Capabilities changed: hasInternet=$hasInternet, hasValidated=$hasValidated")
|
||||||
|
_isOnline.value = hasInternet && hasValidated
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onUnavailable() {
|
||||||
|
Log.d("NetworkManager", "Network unavailable - callback")
|
||||||
|
_isOnline.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startMonitoring() {
|
||||||
|
if (isMonitoring) {
|
||||||
|
Log.w("NetworkManager", "Already monitoring, skipping")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isMonitoring = true
|
||||||
|
val cm = connectivityManager ?: run {
|
||||||
|
Log.e("NetworkManager", "ConnectivityManager is null")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. Регистрируем NetworkCallback для активного отслеживания
|
||||||
|
val networkRequest = NetworkRequest.Builder()
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||||
|
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
cm.registerNetworkCallback(networkRequest, networkCallback)
|
||||||
|
Log.d("NetworkManager", "Registered NetworkCallback")
|
||||||
|
|
||||||
|
// 2. Регистрируем BroadcastReceiver как запасной механизм
|
||||||
|
// (на Android 10+ работает только для foreground приложений)
|
||||||
|
val receiver = NetworkReceiver(this)
|
||||||
|
networkReceiver = receiver
|
||||||
|
val filter = IntentFilter(ConnectivityManager.CONNECTIVITY_ACTION)
|
||||||
|
try {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
context.registerReceiver(receiver, filter)
|
||||||
|
Log.d("NetworkManager", "Registered NetworkReceiver")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.w("NetworkManager", "Failed to register BroadcastReceiver: ${e.message}")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Инициализируем текущее состояние
|
||||||
|
_isOnline.value = isNetworkAvailable()
|
||||||
|
Log.d("NetworkManager", "Initial network state: ${_isOnline.value}")
|
||||||
|
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("NetworkManager", "Error setting up network monitoring", e)
|
||||||
|
_isOnline.value = isNetworkAvailable()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stopMonitoring() {
|
||||||
|
if (!isMonitoring) return
|
||||||
|
isMonitoring = false
|
||||||
|
|
||||||
|
try {
|
||||||
|
connectivityManager?.unregisterNetworkCallback(networkCallback)
|
||||||
|
Log.d("NetworkManager", "Unregistered NetworkCallback")
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("NetworkManager", "Error unregistering NetworkCallback", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
networkReceiver?.let {
|
||||||
|
context.unregisterReceiver(it)
|
||||||
|
it.cleanup()
|
||||||
|
networkReceiver = null
|
||||||
|
Log.d("NetworkManager", "Unregistered NetworkReceiver")
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("NetworkManager", "Error unregistering NetworkReceiver", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isNetworkAvailable(): Boolean {
|
||||||
|
return try {
|
||||||
|
val network = connectivityManager?.activeNetwork
|
||||||
|
val capabilities = connectivityManager?.getNetworkCapabilities(network)
|
||||||
|
val available = capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||||
|
Log.d("NetworkManager", "isNetworkAvailable check: $available")
|
||||||
|
available
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("NetworkManager", "Error checking network", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun notifyNetworkRestored() {
|
||||||
|
Log.d("NetworkManager", "Network restored notified - forcing state update")
|
||||||
|
_isOnline.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Принудительно проверяет текущее состояние сети и уведомляет подписчиков
|
||||||
|
*/
|
||||||
|
fun refreshNetworkState() {
|
||||||
|
val currentState = isNetworkAvailable()
|
||||||
|
Log.d("NetworkManager", "Refreshed network state: $currentState")
|
||||||
|
_isOnline.value = currentState
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package core.network
|
||||||
|
|
||||||
|
import android.content.BroadcastReceiver
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.net.ConnectivityManager
|
||||||
|
import android.util.Log
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.cancel
|
||||||
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import javax.inject.Inject
|
||||||
|
import javax.inject.Singleton
|
||||||
|
|
||||||
|
/**
|
||||||
|
* BroadcastReceiver для надежного отслеживания изменений сети
|
||||||
|
* Используется как дополнение к NetworkCallback для лучшей надежности
|
||||||
|
*/
|
||||||
|
@Singleton
|
||||||
|
class NetworkReceiver @Inject constructor(
|
||||||
|
private val networkManager: NetworkManager
|
||||||
|
) : BroadcastReceiver() {
|
||||||
|
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||||
|
private var lastKnownState: Boolean = false
|
||||||
|
|
||||||
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
|
if (intent.action == ConnectivityManager.CONNECTIVITY_ACTION) {
|
||||||
|
val isConnected = isNetworkAvailable(context)
|
||||||
|
|
||||||
|
Log.d("NetworkReceiver", "Broadcast received: isConnected=$isConnected, lastKnown=$lastKnownState")
|
||||||
|
|
||||||
|
// Отправляем уведомление только если состояние изменилось
|
||||||
|
if (isConnected != lastKnownState) {
|
||||||
|
lastKnownState = isConnected
|
||||||
|
|
||||||
|
scope.launch {
|
||||||
|
if (isConnected) {
|
||||||
|
Log.d("NetworkReceiver", "Network connected - notifying NetworkManager")
|
||||||
|
// Небольшая задержка для стабилизации сети
|
||||||
|
kotlinx.coroutines.delay(500)
|
||||||
|
networkManager.notifyNetworkRestored()
|
||||||
|
} else {
|
||||||
|
Log.d("NetworkReceiver", "Network disconnected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun isNetworkAvailable(context: Context): Boolean {
|
||||||
|
return try {
|
||||||
|
val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager
|
||||||
|
val network = cm?.activeNetwork
|
||||||
|
val capabilities = cm?.getNetworkCapabilities(network)
|
||||||
|
capabilities?.hasCapability(android.net.NetworkCapabilities.NET_CAPABILITY_INTERNET) == true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Log.e("NetworkReceiver", "Error checking network", e)
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cleanup() {
|
||||||
|
scope.cancel()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import javax.inject.Singleton
|
|||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
|
|
||||||
sealed class NavEvent {
|
sealed class NavEvent {
|
||||||
data class OpenChat(val chatId: String) : NavEvent()
|
data class OpenChat(val chatId: String, val chatName: String = "Chat") : NavEvent()
|
||||||
object Logout : NavEvent()
|
object Logout : NavEvent()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -14,8 +14,8 @@ class NavigationManager @Inject constructor() {
|
|||||||
private val _events = MutableSharedFlow<NavEvent>(extraBufferCapacity = 1)
|
private val _events = MutableSharedFlow<NavEvent>(extraBufferCapacity = 1)
|
||||||
val events = _events
|
val events = _events
|
||||||
|
|
||||||
fun navigateToChat(chatId: String) {
|
fun navigateToChat(chatId: String, chatName: String = "Chat") {
|
||||||
_events.tryEmit(NavEvent.OpenChat(chatId))
|
_events.tryEmit(NavEvent.OpenChat(chatId, chatName))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun logout() {
|
fun logout() {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ fun AppNavigation(
|
|||||||
when (event) {
|
when (event) {
|
||||||
is core.utils.NavEvent.OpenChat -> {
|
is core.utils.NavEvent.OpenChat -> {
|
||||||
if (authState.isAuthenticated) {
|
if (authState.isAuthenticated) {
|
||||||
navController.navigate(Screen.ChatDetail.createRoute(event.chatId, "Chat")) {
|
navController.navigate(Screen.ChatDetail.createRoute(event.chatId, event.chatName)) {
|
||||||
launchSingleTop = true
|
launchSingleTop = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,8 +149,10 @@ fun AppNavigation(
|
|||||||
ChatListScreen(
|
ChatListScreen(
|
||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
storyViewModel = storyViewModel,
|
storyViewModel = storyViewModel,
|
||||||
onChatClick = { chatId ->
|
onChatClick = { chatId, chatName ->
|
||||||
navController.navigate(Screen.ChatDetail.createRoute(chatId, "Chat"))
|
navController.navigate(Screen.ChatDetail.createRoute(chatId, chatName)) {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onStoryClick = { /* Story click logic */ }
|
onStoryClick = { /* Story click logic */ }
|
||||||
)
|
)
|
||||||
@@ -183,7 +185,7 @@ fun AppNavigation(
|
|||||||
onContactClick = { id -> navController.navigate(Screen.Profile.createRoute(id)) },
|
onContactClick = { id -> navController.navigate(Screen.Profile.createRoute(id)) },
|
||||||
onSearchChange = { viewModel.onSearchChange(it) },
|
onSearchChange = { viewModel.onSearchChange(it) },
|
||||||
onAddContact = { id -> viewModel.addContact(id) },
|
onAddContact = { id -> viewModel.addContact(id) },
|
||||||
onStartChat = { id -> viewModel.startChat(id) },
|
onStartChat = { id, name -> viewModel.startChat(id, name) },
|
||||||
onAcceptRequest = { id -> viewModel.acceptRequest(id) },
|
onAcceptRequest = { id -> viewModel.acceptRequest(id) },
|
||||||
onDeclineRequest = { id -> viewModel.declineRequest(id) }
|
onDeclineRequest = { id -> viewModel.declineRequest(id) }
|
||||||
)
|
)
|
||||||
@@ -205,7 +207,7 @@ fun AppNavigation(
|
|||||||
viewModel = viewModel,
|
viewModel = viewModel,
|
||||||
onEditProfile = { navController.navigate(Screen.EditProfile.route) },
|
onEditProfile = { navController.navigate(Screen.EditProfile.route) },
|
||||||
onBack = { navController.popBackStack() },
|
onBack = { navController.popBackStack() },
|
||||||
onSendMessage = { id -> navController.navigate(Screen.ChatDetail.createRoute(id, "Chat")) }
|
onSendMessage = { id, name -> navController.navigate(Screen.ChatDetail.createRoute(id, name)) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
composable(Screen.EditProfile.route) {
|
composable(Screen.EditProfile.route) {
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ fun ProfileScreen(
|
|||||||
profileId: String? = null, // null for own profile
|
profileId: String? = null, // null for own profile
|
||||||
viewModel: ProfileViewModel = hiltViewModel(),
|
viewModel: ProfileViewModel = hiltViewModel(),
|
||||||
onEditProfile: () -> Unit = {},
|
onEditProfile: () -> Unit = {},
|
||||||
onSendMessage: (String) -> Unit = {},
|
onSendMessage: (String, String) -> Unit = { _, _ -> },
|
||||||
onCall: (String) -> Unit = {},
|
onCall: (String) -> Unit = {},
|
||||||
onBack: () -> Unit = {}
|
onBack: () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
@@ -108,7 +108,7 @@ fun ProfileScreen(
|
|||||||
birthday = profile?.birthday,
|
birthday = profile?.birthday,
|
||||||
isOwnProfile = isOwnProfile,
|
isOwnProfile = isOwnProfile,
|
||||||
isCallsEnabled = true,
|
isCallsEnabled = true,
|
||||||
onSendMessage = { profile?.id?.let { id -> onSendMessage(id) } },
|
onSendMessage = { profile?.let { p -> onSendMessage(p.id ?: "", p.displayName ?: p.username ?: "Chat") } },
|
||||||
onCall = { profile?.id?.let { id -> onCall(id) } }
|
onCall = { profile?.id?.let { id -> onCall(id) } }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -125,9 +125,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
set({ isLoadingMessages: true });
|
set({ isLoadingMessages: true });
|
||||||
const currentMessages = state.messages[chatId] || [];
|
const currentMessages = state.messages[chatId] || [];
|
||||||
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].sequenceId.toString() : undefined;
|
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].sequenceId.toString() : undefined;
|
||||||
|
|
||||||
const fetched = await ChatApi.getMessages(chatId, cursor);
|
const fetched = await ChatApi.getMessages(chatId, cursor);
|
||||||
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
// Merge fetched messages with any that arrived via socket
|
// Merge fetched messages with any that arrived via socket
|
||||||
const existing = reset ? [] : (state.messages[chatId] || []);
|
const existing = reset ? [] : (state.messages[chatId] || []);
|
||||||
@@ -401,6 +401,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
if (m.sequenceId <= lastReadSequenceId) {
|
if (m.sequenceId <= lastReadSequenceId) {
|
||||||
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
|
const alreadyRead = m.readBy?.some((r) => r.userId === userId);
|
||||||
if (alreadyRead) return m;
|
if (alreadyRead) return m;
|
||||||
|
// Увеличиваем счётчик только если текущий пользователь читает чужие сообщения
|
||||||
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
|
if (userId === currentUserId && m.senderId !== currentUserId) newlyReadCount++;
|
||||||
return { ...m, readBy: [...(m.readBy || []), { userId }] };
|
return { ...m, readBy: [...(m.readBy || []), { userId }] };
|
||||||
}
|
}
|
||||||
@@ -412,6 +413,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const updatedChats = state.chats.map((chat) => {
|
const updatedChats = state.chats.map((chat) => {
|
||||||
if (chat.id === chatId) {
|
if (chat.id === chatId) {
|
||||||
const updatedLastMessages = chat.messages?.map(updateMsg);
|
const updatedLastMessages = chat.messages?.map(updateMsg);
|
||||||
|
// Уменьшаем unreadCount только если текущий пользователь прочитал сообщения
|
||||||
if (userId === currentUserId) {
|
if (userId === currentUserId) {
|
||||||
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
|
return { ...chat, messages: updatedLastMessages, unreadCount: Math.max(0, (chat.unreadCount || 0) - newlyReadCount) };
|
||||||
}
|
}
|
||||||
@@ -475,16 +477,16 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
addChat: (chat) => {
|
addChat: (chat) => {
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const existing = state.chats.find((c) => c.id === chat.id);
|
const existing = state.chats.find((c) => c.id === chat.id);
|
||||||
|
|
||||||
const messagesFromState = state.messages[chat.id] || [];
|
const messagesFromState = state.messages[chat.id] || [];
|
||||||
const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []);
|
const messagesToUse = messagesFromState.length > 0 ? messagesFromState : (chat.messages || []);
|
||||||
|
|
||||||
let unreadCount = chat.unreadCount || 0;
|
let unreadCount = chat.unreadCount || 0;
|
||||||
if (!existing && messagesFromState.length > 0) {
|
if (!existing && messagesFromState.length > 0) {
|
||||||
const userId = useAuthStore.getState().user?.id;
|
const userId = useAuthStore.getState().user?.id;
|
||||||
unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length;
|
unreadCount = messagesFromState.filter((m) => m.senderId !== userId && !m.readBy?.some(r => r.userId === userId)).length;
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount };
|
const updatedChat = { ...chat, messages: messagesToUse.length > 0 ? [messagesToUse[messagesToUse.length - 1]] : [], unreadCount };
|
||||||
|
|
||||||
if (existing) {
|
if (existing) {
|
||||||
@@ -524,9 +526,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
const existing = state.pinnedMessages[chatId] || [];
|
const existing = state.pinnedMessages[chatId] || [];
|
||||||
if (existing.some(m => m.id === message.id)) return state;
|
if (existing.some(m => m.id === message.id)) return state;
|
||||||
return {
|
return {
|
||||||
pinnedMessages: {
|
pinnedMessages: {
|
||||||
...state.pinnedMessages,
|
...state.pinnedMessages,
|
||||||
[chatId]: [...existing, message]
|
[chatId]: [...existing, message]
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -550,7 +552,7 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
|||||||
try {
|
try {
|
||||||
set({ isLoadingMessages: true });
|
set({ isLoadingMessages: true });
|
||||||
const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50);
|
const fetched = await ChatApi.getMessages(chatId, undefined, sequenceId, 50);
|
||||||
|
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
messages: { ...state.messages, [chatId]: fetched },
|
messages: { ...state.messages, [chatId]: fetched },
|
||||||
// Since we jumped, we assume there is more history to load above
|
// Since we jumped, we assume there is more history to load above
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ export default function ChatPage() {
|
|||||||
const [groupCallSessionId, setGroupCallSessionId] = useState(0);
|
const [groupCallSessionId, setGroupCallSessionId] = useState(0);
|
||||||
|
|
||||||
const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null);
|
const [incomingGroupCall, setIncomingGroupCall] = useState<{ chatId: string; from: string; callerInfo: any; callType: string; chatName: string } | null>(null);
|
||||||
|
|
||||||
const groupCallOpenRef = useRef(false);
|
const groupCallOpenRef = useRef(false);
|
||||||
const groupCallChatIdRef = useRef('');
|
const groupCallChatIdRef = useRef('');
|
||||||
|
|
||||||
@@ -180,7 +180,12 @@ export default function ChatPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
socket.on('messages_read', (data: any) => {
|
socket.on('messages_read', (data: any) => {
|
||||||
markRead(data.chatId || data.ChatId, data.userId || data.UserId, data.lastReadSequenceId || data.LastReadSequenceId || 0);
|
const chatId = data.chatId || data.ChatId;
|
||||||
|
const userId = data.userId || data.UserId;
|
||||||
|
const lastReadSequenceId = data.lastReadSequenceId || data.LastReadSequenceId || 0;
|
||||||
|
|
||||||
|
// Обновляем стейт - добавляем userId в readBy для всех сообщений до lastReadSequenceId
|
||||||
|
markRead(chatId, userId, lastReadSequenceId);
|
||||||
});
|
});
|
||||||
|
|
||||||
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
|
socket.on('user_typing', (data: { chatId: string; userId: string }) => {
|
||||||
@@ -281,7 +286,7 @@ export default function ChatPage() {
|
|||||||
callType: data.callType,
|
callType: data.callType,
|
||||||
chatName: chat.name || 'Group',
|
chatName: chat.name || 'Group',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Auto-dismiss after 15 seconds if ignored
|
// Auto-dismiss after 15 seconds if ignored
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setIncomingGroupCall(prev => {
|
setIncomingGroupCall(prev => {
|
||||||
@@ -383,14 +388,14 @@ export default function ChatPage() {
|
|||||||
{activeTab === 'chats' ? (
|
{activeTab === 'chats' ? (
|
||||||
<>
|
<>
|
||||||
{/* Chat List (Sidebar) */}
|
{/* Chat List (Sidebar) */}
|
||||||
<div
|
<div
|
||||||
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`}
|
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`}
|
||||||
>
|
>
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Selected Chat View (Main Area) */}
|
{/* Selected Chat View (Main Area) */}
|
||||||
<div
|
<div
|
||||||
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest relative group slide-on-ice`}
|
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest relative group slide-on-ice`}
|
||||||
>
|
>
|
||||||
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
||||||
@@ -398,31 +403,31 @@ export default function ChatPage() {
|
|||||||
</>
|
</>
|
||||||
) : activeTab === 'contacts' ? (
|
) : activeTab === 'contacts' ? (
|
||||||
<div className="flex-1 flex flex-row h-full overflow-hidden">
|
<div className="flex-1 flex flex-row h-full overflow-hidden">
|
||||||
{/* Contacts Sidebar List */}
|
{/* Contacts Sidebar List */}
|
||||||
<div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased">
|
<div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased">
|
||||||
<ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} />
|
<ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side placeholder / Profile detail */}
|
{/* Right side placeholder / Profile detail */}
|
||||||
<div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base h-full relative slide-on-ice">
|
<div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base h-full relative slide-on-ice">
|
||||||
<div className="flex flex-col items-center gap-6 max-w-sm text-center">
|
<div className="flex flex-col items-center gap-6 max-w-sm text-center">
|
||||||
<div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
|
<div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
|
||||||
<Users size={48} className="knot-logo-spin opacity-50" />
|
<Users size={48} className="knot-logo-spin opacity-50" />
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
|
|
||||||
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
|
|
||||||
{t('selectContactToChat')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('chats')}
|
|
||||||
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
|
|
||||||
>
|
|
||||||
{t('backToChats')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
|
||||||
|
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
|
||||||
|
{t('selectContactToChat')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('chats')}
|
||||||
|
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
|
||||||
|
>
|
||||||
|
{t('backToChats')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : activeTab === 'settings' ? (
|
) : activeTab === 'settings' ? (
|
||||||
<SettingsPage />
|
<SettingsPage />
|
||||||
@@ -432,7 +437,7 @@ export default function ChatPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|
||||||
<CallModal
|
<CallModal
|
||||||
key={callSessionId}
|
key={callSessionId}
|
||||||
@@ -485,9 +490,9 @@ export default function ChatPage() {
|
|||||||
>
|
>
|
||||||
<div className="relative mb-6">
|
<div className="relative mb-6">
|
||||||
<div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/20 animate-call-wave" />
|
<div className="absolute inset-0 rounded-[1.5rem] bg-emerald-500/20 animate-call-wave" />
|
||||||
<Avatar
|
<Avatar
|
||||||
src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null}
|
src={incomingGroupCall.callerInfo?.avatar ? getMediaUrl(incomingGroupCall.callerInfo.avatar) : null}
|
||||||
name={incomingGroupCall.chatName || '?'}
|
name={incomingGroupCall.chatName || '?'}
|
||||||
size="2xl"
|
size="2xl"
|
||||||
className="relative shadow-2xl"
|
className="relative shadow-2xl"
|
||||||
/>
|
/>
|
||||||
@@ -499,9 +504,9 @@ export default function ChatPage() {
|
|||||||
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('isCalling' as any) || 'звонит...'}
|
{incomingGroupCall.callerInfo?.displayName || incomingGroupCall.callerInfo?.username || 'User'} {t('isCalling' as any) || 'звонит...'}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
|
<p className="text-zinc-400 text-sm mb-8 bg-white/5 px-3 py-1 rounded-full border border-white/5">
|
||||||
{t('groupCall' as any) || 'Групповой звонок'}
|
{t('groupCall' as any) || 'Групповой звонок'}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="flex items-center gap-8 w-full justify-center">
|
<div className="flex items-center gap-8 w-full justify-center">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
|||||||
@@ -63,14 +63,14 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
: lastMessage.media?.[0]?.type === 'video'
|
: lastMessage.media?.[0]?.type === 'video'
|
||||||
? t('video')
|
? t('video')
|
||||||
: t('file')
|
: t('file')
|
||||||
: lastMessage.type === 'call'
|
: lastMessage.type === 'call'
|
||||||
? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t(
|
? `${lastMessage.callType === 'video' ? '🎬' : '📞'} ${t(
|
||||||
lastMessage.callStatus === 'missed' ? 'missedCall' :
|
lastMessage.callStatus === 'missed' ? 'missedCall' :
|
||||||
lastMessage.callStatus === 'declined' ? 'declinedCall' :
|
lastMessage.callStatus === 'declined' ? 'declinedCall' :
|
||||||
lastMessage.callStatus === 'cancelled' ? 'cancelledCall' :
|
lastMessage.callStatus === 'cancelled' ? 'cancelledCall' :
|
||||||
'completedCall'
|
'completedCall'
|
||||||
)}`
|
)}`
|
||||||
: lastMessage.content || ''
|
: lastMessage.content || ''
|
||||||
: '';
|
: '';
|
||||||
|
|
||||||
const previewText = chat.isImporting ? 'Импорт...' : stripMarkdown(lastMessageText);
|
const previewText = chat.isImporting ? 'Импорт...' : stripMarkdown(lastMessageText);
|
||||||
@@ -78,7 +78,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
const isMine = !chat.isImporting && lastMessage?.senderId === user?.id;
|
const isMine = !chat.isImporting && lastMessage?.senderId === user?.id;
|
||||||
|
|
||||||
// Галочки прочтения
|
// Галочки прочтения
|
||||||
const isRead = !chat.isImporting && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
|
// Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели)
|
||||||
|
// Для чужих сообщений: не показываем галочки
|
||||||
|
const isRead = !chat.isImporting && isMine && lastMessage?.readBy?.some((r) => r.userId !== user?.id);
|
||||||
|
|
||||||
const timeStr = !chat.isImporting && lastMessage
|
const timeStr = !chat.isImporting && lastMessage
|
||||||
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
|
? formatDistanceToNow(new Date(lastMessage.createdAt), { addSuffix: false, locale: lang === 'ru' ? ru : enUS })
|
||||||
@@ -89,25 +91,25 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!chat.isImporting || !chat.importJobId) {
|
if (!chat.isImporting || !chat.importJobId) {
|
||||||
setImportStatus(null);
|
setImportStatus(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const poll = async () => {
|
const poll = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
|
const data = await httpClient.request<any>(`/import/telegram/status/${chat.importJobId}`);
|
||||||
setImportStatus({ processed: data.processedMessages, total: data.totalMessages });
|
setImportStatus({ processed: data.processedMessages, total: data.totalMessages });
|
||||||
if (data.status === 'Completed' || data.status === 'Failed') {
|
if (data.status === 'Completed' || data.status === 'Failed') {
|
||||||
loadChats();
|
loadChats();
|
||||||
}
|
|
||||||
} catch (e: any) {
|
|
||||||
if (e.status === 404) {
|
|
||||||
// Job might have expired or backend restarted
|
|
||||||
console.warn('Import job not found');
|
|
||||||
} else {
|
|
||||||
console.error('Failed to poll status', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.status === 404) {
|
||||||
|
// Job might have expired or backend restarted
|
||||||
|
console.warn('Import job not found');
|
||||||
|
} else {
|
||||||
|
console.error('Failed to poll status', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
poll();
|
poll();
|
||||||
@@ -117,9 +119,9 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
if (chat.isImporting) {
|
if (chat.isImporting) {
|
||||||
// Here we could show a progress modal, but for now just select it
|
// Here we could show a progress modal, but for now just select it
|
||||||
setActiveChat(chat.id);
|
setActiveChat(chat.id);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((window as any).hasUnsavedAttachments && !isActive) {
|
if ((window as any).hasUnsavedAttachments && !isActive) {
|
||||||
setShowAttachmentConfirm(true);
|
setShowAttachmentConfirm(true);
|
||||||
@@ -131,7 +133,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
const proceedWithClick = () => {
|
const proceedWithClick = () => {
|
||||||
setShowAttachmentConfirm(false);
|
setShowAttachmentConfirm(false);
|
||||||
(window as any).hasUnsavedAttachments = false;
|
(window as any).hasUnsavedAttachments = false;
|
||||||
|
|
||||||
if (isActive) {
|
if (isActive) {
|
||||||
window.dispatchEvent(new CustomEvent('CHAT_SCROLL_TO_BOTTOM', { detail: { chatId: chat.id } }));
|
window.dispatchEvent(new CustomEvent('CHAT_SCROLL_TO_BOTTOM', { detail: { chatId: chat.id } }));
|
||||||
} else {
|
} else {
|
||||||
@@ -188,9 +190,8 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
<button
|
<button
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
onContextMenu={handleContextMenu}
|
onContextMenu={handleContextMenu}
|
||||||
className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${
|
className={`w-full flex items-center gap-4 px-4 py-3.5 transition-all duration-300 slide-on-ice text-left rounded-2xl mx-1 my-0.5 w-[calc(100%-8px)] ${isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
|
||||||
isActive ? 'bg-primary/10' : 'hover:bg-surface-container-highest/20'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{/* Аватар */}
|
{/* Аватар */}
|
||||||
<div className="relative flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
@@ -200,7 +201,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="avatar-knot-container group-hover:scale-105 transition-transform">
|
<div className="avatar-knot-container group-hover:scale-105 transition-transform">
|
||||||
<Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} />
|
<Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -225,20 +226,20 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
)}
|
)}
|
||||||
<div className="flex flex-col gap-1 w-full min-w-0">
|
<div className="flex flex-col gap-1 w-full min-w-0">
|
||||||
<p className={`text-[13px] truncate leading-tight ${isTyping ? 'text-tertiary font-bold' : draft ? 'text-error font-medium' : 'text-on-surface-variant/60'}`}>
|
<p className={`text-[13px] truncate leading-tight ${isTyping ? 'text-tertiary font-bold' : draft ? 'text-error font-medium' : 'text-on-surface-variant/60'}`}>
|
||||||
{isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
|
{isTyping ? t('typing') : draft ? <><span className="font-bold">{t('draft')} </span>{stripMarkdown(draft)}</> : previewText}
|
||||||
{chat.isImporting && importStatus && importStatus.total > 0 && (
|
{chat.isImporting && importStatus && importStatus.total > 0 && (
|
||||||
<span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums">
|
<span className="ml-1.5 text-[11px] font-black text-primary/70 tabular-nums">
|
||||||
{importStatus.processed} / {importStatus.total}
|
{importStatus.processed} / {importStatus.total}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
{chat.isImporting && importStatus && importStatus.total > 0 && (
|
{chat.isImporting && importStatus && importStatus.total > 0 && (
|
||||||
<div className="w-full h-1 bg-surface-container-highest rounded-full overflow-hidden mt-0.5">
|
<div className="w-full h-1 bg-surface-container-highest rounded-full overflow-hidden mt-0.5">
|
||||||
<div
|
<div
|
||||||
className="h-full bg-primary transition-all duration-500 ease-out"
|
className="h-full bg-primary transition-all duration-500 ease-out"
|
||||||
style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }}
|
style={{ width: `${Math.min(100, Math.round((importStatus.processed / importStatus.total) * 100))}%` }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -79,7 +79,11 @@ function MessageBubble({
|
|||||||
const [quotedText, setQuotedText] = useState<string | null>(null);
|
const [quotedText, setQuotedText] = useState<string | null>(null);
|
||||||
|
|
||||||
// Прочитано
|
// Прочитано
|
||||||
const isRead = message.readBy?.some((r) => r.userId !== user?.id);
|
// Для своих сообщений: проверено, есть ли в readBy другие пользователи (получатели)
|
||||||
|
// Для чужих сообщений: проверено, есть ли в readBy текущий пользователь
|
||||||
|
const isRead = isMine
|
||||||
|
? message.readBy?.some((r) => r.userId !== user?.id) // Кто-то кроме меня прочитал
|
||||||
|
: message.readBy?.some((r) => r.userId === user?.id); // Я прочитал
|
||||||
|
|
||||||
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
const timeStr = new Date(message.createdAt).toLocaleTimeString(lang === 'ru' ? 'ru-RU' : 'en-US', {
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
@@ -492,10 +496,10 @@ function MessageBubble({
|
|||||||
onDoubleClick={handleReply}
|
onDoubleClick={handleReply}
|
||||||
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
|
title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'}
|
||||||
className={`cursor-pointer max-w-full min-w-[60px] transition-all duration-500 overflow-hidden ${!needsFrame
|
className={`cursor-pointer max-w-full min-w-[60px] transition-all duration-500 overflow-hidden ${!needsFrame
|
||||||
? 'p-0 shadow-none border-none bg-transparent'
|
? 'p-0 shadow-none border-none bg-transparent'
|
||||||
: isMine
|
: isMine
|
||||||
? 'bubble-sent px-4 py-3 hover:brightness-110'
|
? 'bubble-sent px-4 py-3 hover:brightness-110'
|
||||||
: 'bubble-received px-4 py-3 hover:brightness-110'
|
: 'bubble-received px-4 py-3 hover:brightness-110'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
|
|
||||||
@@ -1068,8 +1072,8 @@ function MessageBubble({
|
|||||||
key={emoji}
|
key={emoji}
|
||||||
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
|
onClick={(e) => { e.stopPropagation(); handleReaction(emoji); }}
|
||||||
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-[10px] transition-all border ${data.isMine
|
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-[10px] transition-all border ${data.isMine
|
||||||
? 'bg-primary/20 border-primary text-white shadow-lg'
|
? 'bg-primary/20 border-primary text-white shadow-lg'
|
||||||
: 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]'
|
: 'bg-[#201F1F] border-white/5 text-zinc-300 hover:bg-[#2a2a2a]'
|
||||||
} shadow-md group/react`}
|
} shadow-md group/react`}
|
||||||
title={data.users.join(', ')}
|
title={data.users.join(', ')}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user