Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8399d32490 | ||
|
|
3905094ff4 | ||
|
|
9e8625aea1 | ||
|
|
5b905c94da | ||
|
|
0eecc01374 | ||
|
|
c37e1723d4 | ||
|
|
02043d4d97 | ||
|
|
852efa090e | ||
|
|
c45f4db61c | ||
|
|
02a85fc587 | ||
|
|
e09860700c | ||
|
|
32c9bc43cf | ||
|
|
d96e4ec7d4 | ||
|
|
1558b20470 | ||
|
|
fa185afc73 | ||
|
|
90096ce2bc |
@@ -36,17 +36,21 @@ public sealed class Chat : AggregateRoot<Guid>
|
|||||||
public string? Avatar { get; private set; }
|
public string? Avatar { get; private set; }
|
||||||
public DateTime CreatedAt { get; private set; }
|
public DateTime CreatedAt { get; private set; }
|
||||||
public long LastMessageSequenceId { get; private set; }
|
public long LastMessageSequenceId { get; private set; }
|
||||||
|
public bool IsImporting { get; private set; }
|
||||||
|
public Guid? ImportJobId { get; private set; }
|
||||||
|
|
||||||
private readonly List<ChatMember> _members = new();
|
private readonly List<ChatMember> _members = new();
|
||||||
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
|
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
|
||||||
|
|
||||||
private Chat(Guid id, ChatType type, string? name, string? avatar, string? description = null) : base(id)
|
private Chat(Guid id, ChatType type, string? name, string? avatar, string? description = null, bool isImporting = false, Guid? importJobId = null) : base(id)
|
||||||
{
|
{
|
||||||
Type = type;
|
Type = type;
|
||||||
Name = name;
|
Name = name;
|
||||||
Avatar = avatar;
|
Avatar = avatar;
|
||||||
Description = description;
|
Description = description;
|
||||||
CreatedAt = DateTime.UtcNow;
|
CreatedAt = DateTime.UtcNow;
|
||||||
|
IsImporting = isImporting;
|
||||||
|
ImportJobId = importJobId;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Chat CreatePersonal()
|
public static Chat CreatePersonal()
|
||||||
@@ -63,13 +67,18 @@ public sealed class Chat : AggregateRoot<Guid>
|
|||||||
return chat;
|
return chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null)
|
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null, bool isImporting = false, Guid? importJobId = null)
|
||||||
{
|
{
|
||||||
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description);
|
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description, isImporting, importJobId);
|
||||||
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
||||||
return chat;
|
return chat;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void CompleteImport()
|
||||||
|
{
|
||||||
|
IsImporting = false;
|
||||||
|
}
|
||||||
|
|
||||||
public void AddMember(Guid userId, string role = "member")
|
public void AddMember(Guid userId, string role = "member")
|
||||||
{
|
{
|
||||||
if (_members.Any(m => m.UserId == userId))
|
if (_members.Any(m => m.UserId == userId))
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Knot.Contracts.Conversations.Domain;
|
||||||
|
|
||||||
|
public static class ChatConstants
|
||||||
|
{
|
||||||
|
public const int DefaultMessageQueryLimit = 50;
|
||||||
|
public const int MaxSharedMediaQueryLimit = 1000;
|
||||||
|
public const int MaxGroupNameLength = 100;
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
|
namespace Knot.Contracts.Conversations.Domain;
|
||||||
|
|
||||||
|
public static class ChatErrors
|
||||||
|
{
|
||||||
|
public static readonly Error ChatNotFound = new Error("Chat.NotFound", "Чат не найден");
|
||||||
|
public static readonly Error OnlyOwnerCanUpdate = new Error("Chat.OnlyOwnerCanUpdate", "Только владелец может редактировать чат");
|
||||||
|
public static readonly Error Unauthorized = new Error("Chat.Unauthorized", "Нет доступа к этому чату");
|
||||||
|
public static readonly Error FoldersDisabled = new Error("Chat.FoldersDisabled", "Папки отключены");
|
||||||
|
public static readonly Error FileEmpty = new Error("Chat.FileEmpty", "Файл пуст");
|
||||||
|
public static Error FileTooLarge(long maxMb) => new Error("Chat.FileTooLarge", $"Файл слишком большой (максимум {maxMb} МБ)");
|
||||||
|
public static readonly Error ChatsNotFound = new Error("Chat.NotFound", "Чат не найден");
|
||||||
|
public static readonly Error ChatsForbidden = new Error("Chat.Forbidden", "Доступ запрещен");
|
||||||
|
public static readonly Error MediaDisabled = new Error("Chat.MediaDisabled", "Медиафайлы отключены");
|
||||||
|
public static readonly Error PollsDisabled = new Error("Chat.PollsDisabled", "Опросы отключены");
|
||||||
|
public static readonly Error NotFound = new Error("Chat.NotFound", "Не найдено");
|
||||||
|
public static readonly Error NotMember = new Error("Chat.NotMember", "Вы не являетесь участником чата");
|
||||||
|
}
|
||||||
@@ -3,4 +3,5 @@ namespace Knot.Contracts.Messaging.Application.Abstractions;
|
|||||||
public interface IMessageNotifier
|
public interface IMessageNotifier
|
||||||
{
|
{
|
||||||
Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken);
|
Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken);
|
||||||
|
Task NotifyMessageUpdateAsync(Guid chatId, string updateType, object updatePayload, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ public interface IMessageRepository
|
|||||||
Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
Task<Message?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
||||||
Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken);
|
Task<List<Message>> GetChatMessagesAsync(Guid chatId, int limit, int offset, CancellationToken cancellationToken);
|
||||||
Task<Message?> GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken);
|
Task<Message?> GetLatestChatMessageAsync(Guid chatId, CancellationToken cancellationToken);
|
||||||
|
Task<List<Message>> GetPinnedMessagesAsync(Guid chatId, CancellationToken cancellationToken);
|
||||||
Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken);
|
Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, 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<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);
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Knot.Contracts.Messaging.Domain;
|
||||||
|
|
||||||
|
public class CallMessage : Message
|
||||||
|
{
|
||||||
|
public override string Type => "call";
|
||||||
|
public override string? Content { get; protected set; }
|
||||||
|
public string CallType { get; protected set; }
|
||||||
|
public string CallStatus { get; protected set; }
|
||||||
|
public int? Duration { get; protected set; }
|
||||||
|
|
||||||
|
public CallMessage() : base() { }
|
||||||
|
|
||||||
|
public CallMessage(
|
||||||
|
Guid id,
|
||||||
|
Guid chatId,
|
||||||
|
Guid senderId,
|
||||||
|
string callType,
|
||||||
|
string callStatus,
|
||||||
|
int? duration,
|
||||||
|
Guid? replyToId,
|
||||||
|
Guid? forwardedFromId,
|
||||||
|
DateTime createdAt,
|
||||||
|
bool isImported = false)
|
||||||
|
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
|
||||||
|
{
|
||||||
|
CallType = callType;
|
||||||
|
CallStatus = callStatus;
|
||||||
|
Duration = duration;
|
||||||
|
Content = $"Call {callStatus}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ public class MediaMessage : Message
|
|||||||
public MediaMessage(Guid id, Guid chatId, Guid senderId, string mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
public MediaMessage(Guid id, Guid chatId, Guid senderId, string mediaType, string? caption, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||||
: this(id, chatId, senderId, Enum.TryParse<MediaType>(mediaType, true, out var mt) ? mt : MediaType.File, caption, replyToId, forwardedFromId, createdAt, isImported) { }
|
: this(id, chatId, senderId, Enum.TryParse<MediaType>(mediaType, true, out var mt) ? mt : MediaType.File, caption, replyToId, forwardedFromId, createdAt, isImported) { }
|
||||||
|
|
||||||
public void AddMedia(string type, string url, string? filename, long? size) => _media.Add(new Media { Type = type, Url = url, FileId = filename, Size = size });
|
public void AddMedia(string type, string url, string? filename, long? size, string? duration = null) => _media.Add(new Media { Type = type, Url = url, Filename = filename, FileId = filename, Size = size, Duration = duration });
|
||||||
|
|
||||||
public override void Edit(string newCaption) => base.Edit(newCaption);
|
public override void Edit(string newCaption) => base.Edit(newCaption);
|
||||||
|
|
||||||
|
|||||||
@@ -7,26 +7,32 @@ public class PollMessage : Message
|
|||||||
{
|
{
|
||||||
public override string Type => "poll";
|
public override string Type => "poll";
|
||||||
public override string? Content { get; protected set; }
|
public override string? Content { get; protected set; }
|
||||||
public List<PollOption> Options { get; } = new();
|
public List<PollOption> Options { get; set; } = new();
|
||||||
public List<PollVote> Votes { get; } = new();
|
public List<PollVote> Votes { get; set; } = new();
|
||||||
public bool IsMultipleChoice { get; set; }
|
public bool IsMultipleChoice { get; set; }
|
||||||
|
public bool IsAnonymous { get; set; }
|
||||||
public DateTime? ExpiresAt { get; set; }
|
public DateTime? ExpiresAt { get; set; }
|
||||||
public bool IsClosed { get; set; }
|
public bool IsClosed { get; set; }
|
||||||
|
|
||||||
public PollMessage() : base() { }
|
public PollMessage() : base() { }
|
||||||
|
|
||||||
public PollMessage(Guid id, Guid chatId, Guid senderId, string? question, List<string>? options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
public PollMessage(Guid id, Guid chatId, Guid senderId, string? question, List<PollOption>? options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||||
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
|
: base(id, chatId, senderId, replyToId, forwardedFromId, createdAt, isImported)
|
||||||
{
|
{
|
||||||
Content = question ?? "Poll";
|
Content = question ?? "Poll";
|
||||||
if (options != null)
|
Options = options ?? new List<PollOption>();
|
||||||
{
|
IsAnonymous = isAnonymous;
|
||||||
foreach (var opt in options)
|
|
||||||
{
|
|
||||||
Options.Add(new PollOption { Text = opt });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
IsMultipleChoice = isMultiple;
|
IsMultipleChoice = isMultiple;
|
||||||
ExpiresAt = expiresAt;
|
ExpiresAt = expiresAt;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static PollMessage Create(Guid id, Guid chatId, Guid senderId, string? question, List<string> options, bool isAnonymous, bool isMultiple, DateTime? expiresAt, Guid? replyToId, Guid? forwardedFromId)
|
||||||
|
{
|
||||||
|
var poll = new PollMessage(id, chatId, senderId, question, null, isAnonymous, isMultiple, expiresAt, replyToId, forwardedFromId, DateTime.UtcNow, false);
|
||||||
|
foreach (var opt in options)
|
||||||
|
{
|
||||||
|
poll.Options.Add(new PollOption { Text = opt });
|
||||||
|
}
|
||||||
|
return poll;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ namespace Knot.Contracts.Messaging.Domain;
|
|||||||
|
|
||||||
public class PollOption
|
public class PollOption
|
||||||
{
|
{
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
public string Text { get; set; } = string.Empty;
|
public string Text { get; set; } = string.Empty;
|
||||||
public int VoteCount { get; set; }
|
public int VoteCount { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace Knot.Contracts.Messaging.Domain;
|
|||||||
|
|
||||||
public class PollVote
|
public class PollVote
|
||||||
{
|
{
|
||||||
public Guid OptionIndex { get; set; }
|
public Guid OptionId { get; set; }
|
||||||
public Guid UserId { get; set; }
|
public Guid UserId { get; set; }
|
||||||
public DateTime VotedAt { get; set; }
|
public DateTime VotedAt { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ using MediatR;
|
|||||||
|
|
||||||
|
|
||||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
||||||
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
var builder = WebApplication.CreateBuilder(args);
|
||||||
|
|
||||||
@@ -48,7 +49,7 @@ var builder = WebApplication.CreateBuilder(args);
|
|||||||
// Маппинг стандартных переменных окружения в иерархию .NET
|
// Маппинг стандартных переменных окружения в иерархию .NET
|
||||||
var envMappings = new Dictionary<string, string?>
|
var envMappings = new Dictionary<string, string?>
|
||||||
{
|
{
|
||||||
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"] ?? "Host=localhost;Database=knot;Username=postgres;Password=postgres",
|
["ConnectionStrings:DefaultConnection"] = builder.Configuration["DATABASE_URL"] ?? builder.Configuration.GetConnectionString("DefaultConnection") ?? "Host=localhost;Database=knot;Username=postgres;Password=postgres",
|
||||||
["ConnectionStrings:MongoConnection"] = builder.Configuration["MONGO_CONNECTION"],
|
["ConnectionStrings:MongoConnection"] = builder.Configuration["MONGO_CONNECTION"],
|
||||||
["Jwt:Secret"] = builder.Configuration["JWT_SECRET"],
|
["Jwt:Secret"] = builder.Configuration["JWT_SECRET"],
|
||||||
["Jwt:Issuer"] = builder.Configuration["JWT_ISSUER"],
|
["Jwt:Issuer"] = builder.Configuration["JWT_ISSUER"],
|
||||||
@@ -81,6 +82,7 @@ builder.Services.AddStoriesModule(builder.Configuration);
|
|||||||
builder.Services.AddKlipyModule();
|
builder.Services.AddKlipyModule();
|
||||||
builder.Services.AddAdminModule();
|
builder.Services.AddAdminModule();
|
||||||
builder.Services.AddWebRtcModule();
|
builder.Services.AddWebRtcModule();
|
||||||
|
builder.Services.AddTelegramImportModule();
|
||||||
builder.Services.AddSharedInfrastructure(builder.Configuration);
|
builder.Services.AddSharedInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
// CQRS / MediatR для команд в Host (например, AdminController)
|
// CQRS / MediatR для команд в Host (например, AdminController)
|
||||||
@@ -93,7 +95,10 @@ builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssemblies(
|
|||||||
typeof(Knot.Modules.Stories.DependencyInjection).Assembly,
|
typeof(Knot.Modules.Stories.DependencyInjection).Assembly,
|
||||||
typeof(Knot.Modules.Klipy.DependencyInjection).Assembly,
|
typeof(Knot.Modules.Klipy.DependencyInjection).Assembly,
|
||||||
typeof(Knot.Modules.Relations.DependencyInjection).Assembly,
|
typeof(Knot.Modules.Relations.DependencyInjection).Assembly,
|
||||||
typeof(Knot.Modules.WebRtc.DependencyInjection).Assembly
|
typeof(Knot.Modules.WebRtc.DependencyInjection).Assembly,
|
||||||
|
typeof(Knot.Modules.TelegramImport.DependencyInjection).Assembly,
|
||||||
|
typeof(Knot.Modules.Auth.Infrastructure.Persistence.AuthDbContext).Assembly,
|
||||||
|
typeof(Knot.Modules.Profiles.DependencyInjection).Assembly
|
||||||
));
|
));
|
||||||
|
|
||||||
// Настройка CORS
|
// Настройка CORS
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ using Knot.Contracts.Auth.Infrastructure.Persistence;
|
|||||||
using Knot.Contracts.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
using Knot.Contracts.Storage.Abstractions;
|
using Knot.Shared.Kernel.Storage;
|
||||||
using Knot.Contracts.Stories.Infrastructure.Persistence;
|
using Knot.Contracts.Stories.Infrastructure.Persistence;
|
||||||
using Knot.Modules.Admin.Application.Admin.DTOs;
|
using Knot.Modules.Admin.Application.Admin.DTOs;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -51,7 +51,7 @@ internal sealed class CleanRunCommandHandler : ICommandHandler<CleanRunCommand,
|
|||||||
var orphanMessages = await _messageQueryService.GetOrphanedMessagesAsync(activeChatIds, cancellationToken);
|
var orphanMessages = await _messageQueryService.GetOrphanedMessagesAsync(activeChatIds, cancellationToken);
|
||||||
|
|
||||||
var keptMessages = allMessages
|
var keptMessages = allMessages
|
||||||
.Where(m => !orphanMessages.Any(om => om.Id == m.Id))
|
.Where(m => !orphanMessages.Any(om => om.Id == m.Id) && !m.IsDeleted)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
|
var allMinioFiles = (await _fileStorage.ListFilesAsync()).ToList();
|
||||||
|
|||||||
+35
-14
@@ -1,4 +1,4 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -8,6 +8,7 @@ using Knot.Contracts.Auth.Infrastructure.Persistence;
|
|||||||
using Knot.Contracts.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel.Storage;
|
||||||
using Knot.Contracts.Stories.Infrastructure.Persistence;
|
using Knot.Contracts.Stories.Infrastructure.Persistence;
|
||||||
using Knot.Modules.Admin.Application.Admin.DTOs;
|
using Knot.Modules.Admin.Application.Admin.DTOs;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -23,17 +24,20 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
|
|||||||
private readonly IAuthDbContext _authDbContext;
|
private readonly IAuthDbContext _authDbContext;
|
||||||
private readonly IChatsDbContext _chatsDbContext;
|
private readonly IChatsDbContext _chatsDbContext;
|
||||||
private readonly IStoryCollection _storyCollection;
|
private readonly IStoryCollection _storyCollection;
|
||||||
|
private readonly IFileStorageService _fileStorage;
|
||||||
|
|
||||||
public CleanDryRunQueryHandler(
|
public CleanDryRunQueryHandler(
|
||||||
Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService messageService,
|
Knot.Contracts.Messaging.Application.Abstractions.IMessageQueryService messageService,
|
||||||
IAuthDbContext authDbContext,
|
IAuthDbContext authDbContext,
|
||||||
IChatsDbContext chatsDbContext,
|
IChatsDbContext chatsDbContext,
|
||||||
IStoryCollection storyCollection)
|
IStoryCollection storyCollection,
|
||||||
|
IFileStorageService fileStorage)
|
||||||
{
|
{
|
||||||
_messageService = messageService;
|
_messageService = messageService;
|
||||||
_authDbContext = authDbContext;
|
_authDbContext = authDbContext;
|
||||||
_chatsDbContext = chatsDbContext;
|
_chatsDbContext = chatsDbContext;
|
||||||
_storyCollection = storyCollection;
|
_storyCollection = storyCollection;
|
||||||
|
_fileStorage = fileStorage;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<CleanDryRunResult>> Handle(CleanDryRunQuery request, CancellationToken ct)
|
public async Task<Result<CleanDryRunResult>> Handle(CleanDryRunQuery request, CancellationToken ct)
|
||||||
@@ -46,25 +50,42 @@ internal sealed class CleanDryRunQueryHandler : IQueryHandler<CleanDryRunQuery,
|
|||||||
var orphanedMediaCount = orphanedMessages.Count(m => m.MediaUrl != null);
|
var orphanedMediaCount = orphanedMessages.Count(m => m.MediaUrl != null);
|
||||||
var orphanedMessageCount = orphanedMessages.Count;
|
var orphanedMessageCount = orphanedMessages.Count;
|
||||||
|
|
||||||
var validIds = new HashSet<string>();
|
var allMessages = await _messageService.GetAllMessagesAsync(ct);
|
||||||
foreach (var msg in orphanedMessages.Where(m => m.MediaUrl != null))
|
var keptMessages = allMessages
|
||||||
{
|
.Where(m => !orphanedMessages.Any(om => om.Id == m.Id) && !m.IsDeleted)
|
||||||
var parts = msg.MediaUrl.Split('/');
|
.ToList();
|
||||||
var fileId = parts.LastOrDefault();
|
var allUsers = await _authDbContext.Users.ToListAsync(ct);
|
||||||
if (!string.IsNullOrEmpty(fileId))
|
|
||||||
{
|
|
||||||
validIds.Add(fileId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var stories = await _storyCollection.GetAllAsync(ct);
|
var stories = await _storyCollection.GetAllAsync(ct);
|
||||||
|
|
||||||
|
var validUrls = new HashSet<string>();
|
||||||
|
|
||||||
|
var activeMessageUrls = keptMessages.Where(m => m.Media != null).SelectMany(m => m.Media!).Select(x => x.Url).Where(u => !string.IsNullOrEmpty(u));
|
||||||
|
var activeChatUrls = _chatsDbContext.Chats.Select(c => c.Avatar).Where(u => !string.IsNullOrEmpty(u));
|
||||||
|
var activeUserUrls = allUsers.Select(u => u.Avatar).Where(u => !string.IsNullOrEmpty(u));
|
||||||
|
var activeStoryUrls = stories.Select(s => s.MediaUrl).Where(u => !string.IsNullOrEmpty(u));
|
||||||
|
|
||||||
|
foreach (var u in activeMessageUrls) validUrls.Add(u!);
|
||||||
|
foreach (var u in activeChatUrls) validUrls.Add(u!);
|
||||||
|
foreach (var u in activeUserUrls) validUrls.Add(u!);
|
||||||
|
foreach (var u in activeStoryUrls) validUrls.Add(u!);
|
||||||
|
|
||||||
|
var validFileIds = validUrls
|
||||||
|
.Where(u => u.Contains("/api/files/"))
|
||||||
|
.Select(u => u.Split('/').Last())
|
||||||
|
.ToHashSet();
|
||||||
|
|
||||||
|
var allMinioFiles = await _fileStorage.ListFilesAsync();
|
||||||
|
long orphanedFileSize = allMinioFiles
|
||||||
|
.Where(f => !validFileIds.Contains(f.FileId))
|
||||||
|
.Sum(f => f.Size);
|
||||||
|
|
||||||
var expiredStoriesCount = stories.Count(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow);
|
var expiredStoriesCount = stories.Count(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow);
|
||||||
var expiredStoriesSize = stories.Where(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow).Sum(s => s.MediaUrl?.Length ?? 0);
|
var expiredStoriesSize = stories.Where(s => s.ExpiresAt.HasValue && s.ExpiresAt.Value < DateTime.UtcNow).Sum(s => s.MediaUrl?.Length ?? 0);
|
||||||
|
|
||||||
return Result.Success(new CleanDryRunResult(
|
return Result.Success(new CleanDryRunResult(
|
||||||
orphanedMessageCount,
|
orphanedMessageCount,
|
||||||
orphanedMediaCount,
|
orphanedMediaCount,
|
||||||
0,
|
orphanedFileSize,
|
||||||
expiredStoriesCount,
|
expiredStoriesCount,
|
||||||
expiredStoriesSize
|
expiredStoriesSize
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -131,6 +131,17 @@ public static class AdminEndpoints
|
|||||||
return Results.Ok(result.Value);
|
return Results.Ok(result.Value);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapPost("clean/run", async (ISender sender, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var result = await sender.Send(new CleanRunCommand(), ct);
|
||||||
|
if (!result.IsSuccess)
|
||||||
|
{
|
||||||
|
Console.WriteLine($"[Admin] Cleanup Run Error: {result.Error.Description}");
|
||||||
|
return Results.BadRequest(new { error = result.Error.Description });
|
||||||
|
}
|
||||||
|
return Results.Ok(result.Value);
|
||||||
|
});
|
||||||
|
|
||||||
group.MapGet("timezones", () =>
|
group.MapGet("timezones", () =>
|
||||||
{
|
{
|
||||||
// Получаем все системные часовые пояса и формируем удобный для фронтенда формат
|
// Получаем все системные часовые пояса и формируем удобный для фронтенда формат
|
||||||
|
|||||||
+106
@@ -0,0 +1,106 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Knot.Modules.Auth.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(AuthDbContext))]
|
||||||
|
[Migration("20260407181656_AddUserInfoFields")]
|
||||||
|
partial class AddUserInfoFields
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("identity")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.4")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Avatar")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("BannedUntil")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Bio")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("Birthday")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("DisplayName")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Domain")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Email")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("HideStatus")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HideStoryViews")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsBanned")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsExternal")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsOnline")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastSeen")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("PasswordHash")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserDomain")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Username")
|
||||||
|
.IsRequired()
|
||||||
|
.HasMaxLength(50)
|
||||||
|
.HasColumnType("character varying(50)");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("Username")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("Users", "identity");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Knot.Modules.Auth.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddUserInfoFields : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.Sql(@"
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='identity' AND table_name='Users' AND column_name='BannedUntil') THEN
|
||||||
|
ALTER TABLE identity.""Users"" ADD ""BannedUntil"" timestamp with time zone;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='identity' AND table_name='Users' AND column_name='PhoneNumber') THEN
|
||||||
|
ALTER TABLE identity.""Users"" ADD ""PhoneNumber"" text;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema='identity' AND table_name='Users' AND column_name='RefreshToken') THEN
|
||||||
|
ALTER TABLE identity.""Users"" ADD ""RefreshToken"" text;
|
||||||
|
END IF;
|
||||||
|
END $$;");
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(name: "BannedUntil", schema: "identity", table: "Users");
|
||||||
|
migrationBuilder.DropColumn(name: "PhoneNumber", schema: "identity", table: "Users");
|
||||||
|
migrationBuilder.DropColumn(name: "RefreshToken", schema: "identity", table: "Users");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using Knot.Modules.Auth.Infrastructure.Persistence;
|
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -23,7 +23,7 @@ namespace Knot.Modules.Auth.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("Knot.Contracts.Auth.Domain.User", b =>
|
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -32,6 +32,9 @@ namespace Knot.Modules.Auth.Migrations
|
|||||||
b.Property<string>("Avatar")
|
b.Property<string>("Avatar")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("BannedUntil")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<string>("Bio")
|
b.Property<string>("Bio")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
@@ -57,10 +60,10 @@ namespace Knot.Modules.Auth.Migrations
|
|||||||
b.Property<bool>("HideStoryViews")
|
b.Property<bool>("HideStoryViews")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("IsExternal")
|
b.Property<bool>("IsBanned")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("IsBanned")
|
b.Property<bool>("IsExternal")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("IsOnline")
|
b.Property<bool>("IsOnline")
|
||||||
@@ -73,6 +76,15 @@ namespace Knot.Modules.Auth.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("PhoneNumber")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("RefreshToken")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("UserDomain")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
b.Property<string>("Username")
|
b.Property<string>("Username")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(50)
|
.HasMaxLength(50)
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
using Knot.Shared.Kernel;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Abstractions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Unit of Work специфичный для модуля Chats.
|
|
||||||
/// </summary>
|
|
||||||
public interface IChatsUnitOfWork : IUnitOfWork
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Knot.Shared.Kernel;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Abstractions;
|
|
||||||
|
|
||||||
public interface IUserDeleterService
|
|
||||||
{
|
|
||||||
Task<Result> DeleteUserAsync(Guid userId, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
using System;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Abstractions;
|
|
||||||
|
|
||||||
public interface IUserStatusService
|
|
||||||
{
|
|
||||||
bool IsUserOnline(string userId);
|
|
||||||
}
|
|
||||||
@@ -4,8 +4,8 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Shared.Kernel.Storage;
|
using Knot.Shared.Kernel.Storage;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
using SixLabors.ImageSharp;
|
using SixLabors.ImageSharp;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ using System;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Chats.Create;
|
namespace Knot.Modules.Conversations.Application.Chats.Create;
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
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.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
@@ -63,6 +63,9 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pinnedMessages = await _messageRepository.GetPinnedMessagesAsync(chat.Id, cancellationToken);
|
||||||
|
foreach (var pm in pinnedMessages) userIdsToFetch.Add(pm.SenderId);
|
||||||
|
|
||||||
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||||
|
|
||||||
var members = new List<ChatMemberDto>();
|
var members = new List<ChatMemberDto>();
|
||||||
@@ -88,55 +91,19 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
|||||||
var messagesList = new List<ChatMessageDto>();
|
var messagesList = new List<ChatMessageDto>();
|
||||||
if (latestMessage != null)
|
if (latestMessage != null)
|
||||||
{
|
{
|
||||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
messagesList.Add(MessageMapper.MapToDto(
|
||||||
|
latestMessage,
|
||||||
|
usersInfo,
|
||||||
|
latestReactions,
|
||||||
|
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId)));
|
||||||
|
}
|
||||||
|
|
||||||
var reactionsWithUser = new List<ReactionDto>();
|
var pinnedDtoList = new List<PinnedMessageDto>();
|
||||||
foreach (var reaction in latestReactions)
|
foreach (var pm in pinnedMessages)
|
||||||
{
|
{
|
||||||
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
pinnedDtoList.Add(new PinnedMessageDto(
|
||||||
reactionsWithUser.Add(new ReactionDto(
|
pm.Id,
|
||||||
reaction.Id,
|
MessageMapper.MapToDto(pm, usersInfo, new List<MessageReaction>(), new List<Guid>())
|
||||||
reaction.Emoji,
|
|
||||||
reaction.UserId,
|
|
||||||
reactionUser != null
|
|
||||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
|
||||||
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
var readByList = chat.Members
|
|
||||||
.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId)
|
|
||||||
.Select(m => new ReadByDto(m.UserId))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
var textMessage = latestMessage as TextMessage;
|
|
||||||
var mediaMessage = latestMessage as MediaMessage;
|
|
||||||
var storyMessage = latestMessage as StoryMessage;
|
|
||||||
|
|
||||||
messagesList.Add(new ChatMessageDto(
|
|
||||||
latestMessage.Id,
|
|
||||||
latestMessage.ChatId,
|
|
||||||
latestMessage.SenderId,
|
|
||||||
latestMessage.Content,
|
|
||||||
latestMessage.Type,
|
|
||||||
latestMessage.ReplyToId,
|
|
||||||
textMessage?.Quote,
|
|
||||||
storyMessage?.StoryId,
|
|
||||||
storyMessage?.StoryMediaUrl,
|
|
||||||
storyMessage?.StoryMediaType,
|
|
||||||
latestMessage.IsEdited,
|
|
||||||
latestMessage.IsDeleted,
|
|
||||||
latestMessage.CreatedAt,
|
|
||||||
latestMessage.SequenceId,
|
|
||||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
|
||||||
senderObj != null ? new MessageSenderDto(
|
|
||||||
senderObj.Id,
|
|
||||||
senderObj.Username,
|
|
||||||
senderObj.DisplayName,
|
|
||||||
senderObj.Avatar
|
|
||||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
|
||||||
reactionsWithUser,
|
|
||||||
readByList
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -152,6 +119,7 @@ internal sealed class GetChatByIdQueryHandler : IQueryHandler<GetChatByIdQuery,
|
|||||||
chat.CreatedAt,
|
chat.CreatedAt,
|
||||||
members,
|
members,
|
||||||
messagesList,
|
messagesList,
|
||||||
|
pinnedDtoList,
|
||||||
unreadCount
|
unreadCount
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
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.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
@@ -59,6 +59,9 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var pinnedMessages = await _messageRepository.GetPinnedMessagesAsync(chat.Id, cancellationToken);
|
||||||
|
foreach (var pm in pinnedMessages) userIdsToFetch.Add(pm.SenderId);
|
||||||
|
|
||||||
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
var usersInfo = await _userProvider.GetUsersInfoAsync(userIdsToFetch, cancellationToken);
|
||||||
|
|
||||||
var members = new List<ChatMemberDto>();
|
var members = new List<ChatMemberDto>();
|
||||||
@@ -85,50 +88,19 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
|||||||
|
|
||||||
if (latestMessage != null)
|
if (latestMessage != null)
|
||||||
{
|
{
|
||||||
usersInfo.TryGetValue(latestMessage.SenderId, out var senderObj);
|
messagesList.Add(MessageMapper.MapToDto(
|
||||||
|
latestMessage,
|
||||||
|
usersInfo,
|
||||||
|
latestReactions,
|
||||||
|
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId)));
|
||||||
|
}
|
||||||
|
|
||||||
var reactionsWithUser = new List<ReactionDto>();
|
var pinnedDtoList = new List<PinnedMessageDto>();
|
||||||
foreach (var reaction in latestReactions)
|
foreach (var pm in pinnedMessages)
|
||||||
{
|
{
|
||||||
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
pinnedDtoList.Add(new PinnedMessageDto(
|
||||||
reactionsWithUser.Add(new ReactionDto(
|
pm.Id,
|
||||||
reaction.Id,
|
MessageMapper.MapToDto(pm, usersInfo, new List<MessageReaction>(), new List<Guid>())
|
||||||
reaction.Emoji,
|
|
||||||
reaction.UserId,
|
|
||||||
reactionUser != null
|
|
||||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
|
||||||
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
var textMessage = latestMessage as TextMessage;
|
|
||||||
var mediaMessage = latestMessage as MediaMessage;
|
|
||||||
var storyMessage = latestMessage as StoryMessage;
|
|
||||||
|
|
||||||
messagesList.Add(new ChatMessageDto(
|
|
||||||
latestMessage.Id,
|
|
||||||
latestMessage.ChatId,
|
|
||||||
latestMessage.SenderId,
|
|
||||||
latestMessage.Content,
|
|
||||||
latestMessage.Type,
|
|
||||||
latestMessage.ReplyToId,
|
|
||||||
textMessage?.Quote,
|
|
||||||
storyMessage?.StoryId,
|
|
||||||
storyMessage?.StoryMediaUrl,
|
|
||||||
storyMessage?.StoryMediaType,
|
|
||||||
latestMessage.IsEdited,
|
|
||||||
latestMessage.IsDeleted,
|
|
||||||
latestMessage.CreatedAt,
|
|
||||||
latestMessage.SequenceId,
|
|
||||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
|
||||||
senderObj != null ? new MessageSenderDto(
|
|
||||||
senderObj.Id,
|
|
||||||
senderObj.Username,
|
|
||||||
senderObj.DisplayName,
|
|
||||||
senderObj.Avatar
|
|
||||||
) : new MessageSenderDto(latestMessage.SenderId, "unknown", "Unknown", null),
|
|
||||||
reactionsWithUser,
|
|
||||||
chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => new ReadByDto(m.UserId)).ToList()
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +116,10 @@ internal sealed class GetChatsQueryHandler : IQueryHandler<GetChatsQuery, List<C
|
|||||||
chat.CreatedAt,
|
chat.CreatedAt,
|
||||||
members,
|
members,
|
||||||
messagesList,
|
messagesList,
|
||||||
unreadCount
|
pinnedDtoList,
|
||||||
|
unreadCount,
|
||||||
|
chat.IsImporting,
|
||||||
|
chat.ImportJobId
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
|
namespace Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
|
||||||
|
|
||||||
|
|||||||
+60
-6
@@ -1,12 +1,13 @@
|
|||||||
|
|
||||||
using System;
|
using System;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel.Storage;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
|
namespace Knot.Modules.Conversations.Application.Chats.LeaveOrDelete;
|
||||||
|
|
||||||
@@ -15,11 +16,19 @@ public record LeaveOrDeleteChatCommand(Guid ChatId, Guid UserId) : ICommand<Succ
|
|||||||
internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrDeleteChatCommand, SuccessResponse>
|
internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrDeleteChatCommand, SuccessResponse>
|
||||||
{
|
{
|
||||||
private readonly IChatRepository _chatRepository;
|
private readonly IChatRepository _chatRepository;
|
||||||
|
private readonly IMessageRepository _messageRepository;
|
||||||
|
private readonly IFileStorageService _fileStorage;
|
||||||
private readonly IChatsUnitOfWork _uow;
|
private readonly IChatsUnitOfWork _uow;
|
||||||
|
|
||||||
public LeaveOrDeleteChatCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork uow)
|
public LeaveOrDeleteChatCommandHandler(
|
||||||
|
IChatRepository chatRepository,
|
||||||
|
IMessageRepository messageRepository,
|
||||||
|
IFileStorageService fileStorage,
|
||||||
|
IChatsUnitOfWork uow)
|
||||||
{
|
{
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
|
_messageRepository = messageRepository;
|
||||||
|
_fileStorage = fileStorage;
|
||||||
_uow = uow;
|
_uow = uow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,13 +45,18 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
|
|||||||
return Result.Failure<SuccessResponse>(ChatErrors.Unauthorized);
|
return Result.Failure<SuccessResponse>(ChatErrors.Unauthorized);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chat.Type == ChatType.Group)
|
// If it's a private chat or the last member leaving a group, delete everything
|
||||||
|
bool shouldDeleteEverything = chat.Type != ChatType.Group || chat.Members.Count <= 1;
|
||||||
|
|
||||||
|
if (chat.Type == ChatType.Group && !shouldDeleteEverything)
|
||||||
{
|
{
|
||||||
chat.RemoveMember(request.UserId);
|
chat.RemoveMember(request.UserId);
|
||||||
_chatRepository.Update(chat);
|
_chatRepository.Update(chat);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
|
// DELETE ALL MESSAGES AND FILES FIRST
|
||||||
|
await DeleteChatMediaAndMessagesAsync(chat.Id, cancellationToken);
|
||||||
_chatRepository.Remove(chat);
|
_chatRepository.Remove(chat);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,5 +64,45 @@ internal sealed class LeaveOrDeleteChatCommandHandler : ICommandHandler<LeaveOrD
|
|||||||
|
|
||||||
return Result.Success(new SuccessResponse(true));
|
return Result.Success(new SuccessResponse(true));
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
private async Task DeleteChatMediaAndMessagesAsync(Guid chatId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Get all messages directly from Mongo (not paged)
|
||||||
|
var messages = await _messageRepository.GetChatMessagesAsync(chatId, int.MaxValue, 0, ct);
|
||||||
|
foreach (var msg in messages)
|
||||||
|
{
|
||||||
|
if (msg is Knot.Contracts.Messaging.Domain.MediaMessage mediaMsg)
|
||||||
|
{
|
||||||
|
foreach (var media in mediaMsg.Media)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(media.Url))
|
||||||
|
{
|
||||||
|
var fileId = ExtractFileId(media.Url);
|
||||||
|
if (!string.IsNullOrEmpty(fileId))
|
||||||
|
{
|
||||||
|
await _fileStorage.DeleteFileAsync(fileId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await _messageRepository.DeleteChatMessagesAsync(chatId, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
// Log if possible, but don't fail chat deletion
|
||||||
|
Console.WriteLine($"[Cleanup] Error deleting chat media: {ex.Message}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ExtractFileId(string url)
|
||||||
|
{
|
||||||
|
var lastSlash = url.LastIndexOf('/');
|
||||||
|
if (lastSlash == -1) return null;
|
||||||
|
var id = url[(lastSlash + 1)..];
|
||||||
|
if (id.Contains('?')) id = id[..id.IndexOf('?')];
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ using System.Collections.Generic;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ using System;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ using System;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,9 @@ public record ChatDto(
|
|||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
List<ChatMemberDto> Members,
|
List<ChatMemberDto> Members,
|
||||||
List<ChatMessageDto> Messages,
|
List<ChatMessageDto> Messages,
|
||||||
int UnreadCount
|
List<PinnedMessageDto> PinnedMessages,
|
||||||
|
int UnreadCount,
|
||||||
|
bool IsImporting = false,
|
||||||
|
Guid? ImportJobId = null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using System;
|
using System;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.DTOs;
|
namespace Knot.Modules.Conversations.Application.DTOs;
|
||||||
|
|||||||
@@ -21,6 +21,17 @@ public record ChatMessageDto(
|
|||||||
List<MediaDto> Media,
|
List<MediaDto> Media,
|
||||||
MessageSenderDto Sender,
|
MessageSenderDto Sender,
|
||||||
List<ReactionDto> Reactions,
|
List<ReactionDto> Reactions,
|
||||||
List<ReadByDto> ReadBy
|
List<ReadByDto> ReadBy,
|
||||||
|
string? CallType = null,
|
||||||
|
string? CallStatus = null,
|
||||||
|
int? Duration = null,
|
||||||
|
List<PollOptionDto>? PollOptions = null,
|
||||||
|
bool? PollIsMultipleChoice = null,
|
||||||
|
bool? PollIsAnonymous = null,
|
||||||
|
bool? PollIsClosed = null,
|
||||||
|
List<Guid>? UserVotedOptionIds = null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
public record PollOptionDto(Guid Id, string Text, int VoteCount, List<MessageSenderDto>? Voters = null, List<Guid>? VoterIds = null);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.DTOs;
|
namespace Knot.Modules.Conversations.Application.DTOs;
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,6 @@ public record MediaDto(
|
|||||||
string Type,
|
string Type,
|
||||||
string? Url,
|
string? Url,
|
||||||
string? Filename,
|
string? Filename,
|
||||||
long? Size
|
long? Size,
|
||||||
|
string? Duration = null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,15 @@ public record MessageDetailDto(
|
|||||||
List<MediaDto> Media,
|
List<MediaDto> Media,
|
||||||
MessageSenderDto? Sender,
|
MessageSenderDto? Sender,
|
||||||
List<ReadByDto> ReadBy,
|
List<ReadByDto> ReadBy,
|
||||||
List<MessageReactionDto> Reactions
|
List<MessageReactionDto> Reactions,
|
||||||
|
string? CallType = null,
|
||||||
|
string? CallStatus = null,
|
||||||
|
int? Duration = null,
|
||||||
|
List<PollOptionDto>? PollOptions = null,
|
||||||
|
bool? PollIsMultipleChoice = null,
|
||||||
|
bool? PollIsAnonymous = null,
|
||||||
|
bool? PollIsClosed = null,
|
||||||
|
List<Guid>? UserVotedOptionIds = null
|
||||||
);
|
);
|
||||||
|
|
||||||
public record ReplyToMessageDto(
|
public record ReplyToMessageDto(
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Contracts.Messaging.Domain;
|
||||||
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Application.DTOs;
|
||||||
|
|
||||||
|
public static class MessageMapper
|
||||||
|
{
|
||||||
|
public static ChatMessageDto MapToDto(
|
||||||
|
Message message,
|
||||||
|
IReadOnlyDictionary<Guid, UserInfo> usersInfo,
|
||||||
|
IEnumerable<MessageReaction> reactions,
|
||||||
|
IEnumerable<Guid> readByUsers,
|
||||||
|
Guid? currentUserId = null)
|
||||||
|
{
|
||||||
|
usersInfo.TryGetValue(message.SenderId, out var senderObj);
|
||||||
|
|
||||||
|
var reactionsWithUser = new List<ReactionDto>();
|
||||||
|
foreach (var reaction in reactions)
|
||||||
|
{
|
||||||
|
usersInfo.TryGetValue(reaction.UserId, out var reactionUser);
|
||||||
|
reactionsWithUser.Add(new ReactionDto(
|
||||||
|
reaction.Id,
|
||||||
|
reaction.Emoji,
|
||||||
|
reaction.UserId,
|
||||||
|
reactionUser != null
|
||||||
|
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
||||||
|
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
var textMessage = message as TextMessage;
|
||||||
|
var mediaMessage = message as MediaMessage;
|
||||||
|
var storyMessage = message as StoryMessage;
|
||||||
|
var callMessage = message as CallMessage;
|
||||||
|
|
||||||
|
return new ChatMessageDto(
|
||||||
|
message.Id,
|
||||||
|
message.ChatId,
|
||||||
|
message.SenderId,
|
||||||
|
message.Content,
|
||||||
|
message.Type,
|
||||||
|
message.ReplyToId,
|
||||||
|
textMessage?.Quote,
|
||||||
|
storyMessage?.StoryId,
|
||||||
|
storyMessage?.StoryMediaUrl,
|
||||||
|
storyMessage?.StoryMediaType,
|
||||||
|
message.IsEdited,
|
||||||
|
message.IsDeleted,
|
||||||
|
message.CreatedAt,
|
||||||
|
message.SequenceId,
|
||||||
|
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size, media.Duration)).ToList() ?? new List<MediaDto>(),
|
||||||
|
senderObj != null ? new MessageSenderDto(
|
||||||
|
senderObj.Id,
|
||||||
|
senderObj.Username,
|
||||||
|
senderObj.DisplayName,
|
||||||
|
senderObj.Avatar
|
||||||
|
) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||||
|
reactionsWithUser,
|
||||||
|
readByUsers.Select(id => new ReadByDto(id)).ToList(),
|
||||||
|
callMessage?.CallType,
|
||||||
|
callMessage?.CallStatus,
|
||||||
|
callMessage?.Duration,
|
||||||
|
message is PollMessage pm ? pm.Options.Select(o => {
|
||||||
|
var voters = pm.IsAnonymous == false
|
||||||
|
? pm.Votes
|
||||||
|
.Where(v => v.OptionId == o.Id)
|
||||||
|
.Select(v => {
|
||||||
|
usersInfo.TryGetValue(v.UserId, out var vu);
|
||||||
|
return vu != null
|
||||||
|
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
|
||||||
|
: new MessageSenderDto(v.UserId, "unknown", "Unknown", null);
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
: null;
|
||||||
|
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
|
||||||
|
}).ToList() : null,
|
||||||
|
(message as PollMessage)?.IsMultipleChoice,
|
||||||
|
(message as PollMessage)?.IsAnonymous,
|
||||||
|
(message as PollMessage)?.IsClosed,
|
||||||
|
(message is PollMessage poll && currentUserId.HasValue) ? poll.Votes.Where(v => v.UserId == currentUserId.Value).Select(v => v.OptionId).ToList() : null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Knot.Modules.Conversations.Application.DTOs;
|
||||||
|
|
||||||
|
public record PinnedMessageDto(
|
||||||
|
Guid Id,
|
||||||
|
ChatMessageDto Message
|
||||||
|
);
|
||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Contracts.Settings.Application.Abstractions;
|
using Knot.Contracts.Settings.Application.Abstractions;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
using global::Knot.Modules.Conversations.Application.Abstractions;
|
using global::Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using global::Knot.Modules.Conversations.Domain;
|
using global::Knot.Contracts.Conversations.Domain;
|
||||||
using global::Knot.Modules.Conversations.Infrastructure.SignalR;
|
using global::Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
using global::Knot.Shared.Kernel;
|
using global::Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Messaging.Domain;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Application.Messages.Edit;
|
||||||
|
|
||||||
|
public sealed record EditMessageCommand(
|
||||||
|
Guid MessageId,
|
||||||
|
Guid ChatId,
|
||||||
|
Guid UserId,
|
||||||
|
string Content) : ICommand;
|
||||||
|
|
||||||
|
public sealed class EditMessageCommandHandler : ICommandHandler<EditMessageCommand>
|
||||||
|
{
|
||||||
|
private readonly IMessageRepository _messageRepository;
|
||||||
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
|
private readonly IHubContext<ChatHub> _hubContext;
|
||||||
|
|
||||||
|
public EditMessageCommandHandler(
|
||||||
|
IMessageRepository messageRepository,
|
||||||
|
IChatsUnitOfWork unitOfWork,
|
||||||
|
IHubContext<ChatHub> hubContext)
|
||||||
|
{
|
||||||
|
_messageRepository = messageRepository;
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_hubContext = hubContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Handle(EditMessageCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
|
||||||
|
|
||||||
|
if (message is null)
|
||||||
|
{
|
||||||
|
return Result.Failure(new Error("Message.NotFound", "Message not found."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.SenderId != request.UserId)
|
||||||
|
{
|
||||||
|
return Result.Failure(new Error("Message.Forbidden", "You can only edit your own messages."));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message.ChatId != request.ChatId)
|
||||||
|
{
|
||||||
|
return Result.Failure(new Error("Message.InvalidChat", "Message does not belong to this chat."));
|
||||||
|
}
|
||||||
|
|
||||||
|
message.Edit(request.Content);
|
||||||
|
|
||||||
|
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||||
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Notify clients
|
||||||
|
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("message_edited", new
|
||||||
|
{
|
||||||
|
messageId = message.Id,
|
||||||
|
chatId = message.ChatId,
|
||||||
|
content = message.Content,
|
||||||
|
isEdited = true
|
||||||
|
});
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
+87
-53
@@ -5,15 +5,15 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
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.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
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) : IQuery<List<MessageDetailDto>>;
|
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
||||||
|
|
||||||
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
||||||
{
|
{
|
||||||
@@ -38,15 +38,34 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
return Result.Failure<List<MessageDetailDto>>(ChatErrors.ChatsForbidden);
|
return Result.Failure<List<MessageDetailDto>>(ChatErrors.ChatsForbidden);
|
||||||
}
|
}
|
||||||
|
|
||||||
DateTime? cursorDate = null;
|
List<Message> messages;
|
||||||
if (!string.IsNullOrEmpty(request.Cursor) && DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
|
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
|
||||||
|
|
||||||
|
if (request.Pivot.HasValue)
|
||||||
{
|
{
|
||||||
cursorDate = parsed.ToUniversalTime();
|
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
DateTime? cursorDate = null;
|
||||||
|
long? cursorSequenceId = null;
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(request.Cursor))
|
||||||
|
{
|
||||||
|
if (long.TryParse(request.Cursor, out var seqId))
|
||||||
|
{
|
||||||
|
cursorSequenceId = seqId;
|
||||||
|
}
|
||||||
|
else if (DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed))
|
||||||
|
{
|
||||||
|
cursorDate = parsed.ToUniversalTime();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
messages = await _messageRepository.GetChatMessagesCursorAsync(request.ChatId, cursorDate, cursorSequenceId, queryLimit, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
var messages = await _messageRepository.GetChatMessagesCursorAsync(request.ChatId, cursorDate, ChatConstants.DefaultMessageQueryLimit, cancellationToken);
|
|
||||||
var result = new List<MessageDetailDto>();
|
var result = new List<MessageDetailDto>();
|
||||||
|
|
||||||
var userIdsToFetch = new HashSet<Guid>();
|
var userIdsToFetch = new HashSet<Guid>();
|
||||||
var replyMessages = new Dictionary<Guid, Message>();
|
var replyMessages = new Dictionary<Guid, Message>();
|
||||||
|
|
||||||
@@ -57,6 +76,14 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
{
|
{
|
||||||
userIdsToFetch.Add(m.SenderId);
|
userIdsToFetch.Add(m.SenderId);
|
||||||
|
|
||||||
|
if (m is PollMessage poll && !poll.IsAnonymous)
|
||||||
|
{
|
||||||
|
foreach (var vote in poll.Votes)
|
||||||
|
{
|
||||||
|
userIdsToFetch.Add(vote.UserId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!m.ReplyToId.HasValue)
|
if (!m.ReplyToId.HasValue)
|
||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
@@ -85,70 +112,77 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
ReplyToMessageDto? replyToObj = null;
|
senders.TryGetValue(message.SenderId, out var sender);
|
||||||
if (message.ReplyToId.HasValue && replyMessages.TryGetValue(message.ReplyToId.Value, out var replyMsg))
|
reactionsByMessage.TryGetValue(message.Id, out var reactions);
|
||||||
{
|
|
||||||
var senderObj = senders.TryGetValue(replyMsg.SenderId, out var rs)
|
|
||||||
? new MessageSenderDto(rs.Id, rs.Username, rs.DisplayName, rs.Avatar)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
replyToObj = new ReplyToMessageDto(
|
Message? replyMsg = null;
|
||||||
replyMsg.Id,
|
if (message.ReplyToId.HasValue)
|
||||||
replyMsg.Content,
|
{
|
||||||
replyMsg.IsDeleted,
|
replyMessages.TryGetValue(message.ReplyToId.Value, out replyMsg);
|
||||||
(replyMsg as MediaMessage)?.Media.Select(rm => new MediaDto(rm.Id, rm.Type, rm.Url, rm.Filename, rm.Size)).ToList() ?? new List<MediaDto>(),
|
|
||||||
senderObj
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var reactionsWithUser = new List<MessageReactionDto>();
|
UserInfo? replySender = null;
|
||||||
var messageReactions = reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr : new List<MessageReaction>();
|
if (replyMsg != null)
|
||||||
foreach (var reaction in messageReactions)
|
|
||||||
{
|
{
|
||||||
var userObj = senders.TryGetValue(reaction.UserId, out var reactionUser)
|
senders.TryGetValue(replyMsg.SenderId, out replySender);
|
||||||
? new MessageSenderDto(reactionUser.Id, reactionUser.Username, reactionUser.DisplayName, reactionUser.Avatar)
|
|
||||||
: new MessageSenderDto(reaction.UserId, "unknown", "Unknown", null);
|
|
||||||
|
|
||||||
reactionsWithUser.Add(new MessageReactionDto(
|
|
||||||
reaction.Id,
|
|
||||||
reaction.Emoji,
|
|
||||||
reaction.UserId,
|
|
||||||
userObj
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var textMessage = message as TextMessage;
|
|
||||||
var mediaMessage = message as MediaMessage;
|
|
||||||
var storyMessage = message as StoryMessage;
|
|
||||||
|
|
||||||
result.Add(new MessageDetailDto(
|
result.Add(new MessageDetailDto(
|
||||||
message.Id,
|
message.Id,
|
||||||
message.ChatId,
|
message.ChatId,
|
||||||
message.SenderId,
|
message.SenderId,
|
||||||
message.Content,
|
message.Content,
|
||||||
message.Type,
|
message.Type.ToLower(),
|
||||||
message.ReplyToId,
|
message.ReplyToId,
|
||||||
replyToObj,
|
replyMsg != null ? new ReplyToMessageDto(
|
||||||
textMessage?.Quote,
|
replyMsg.Id,
|
||||||
|
replyMsg.Content,
|
||||||
|
replyMsg.IsDeleted,
|
||||||
|
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
|
||||||
|
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
|
||||||
|
) : null,
|
||||||
|
message is TextMessage tm ? tm.Quote : null,
|
||||||
message.IsEdited,
|
message.IsEdited,
|
||||||
message.IsDeleted,
|
message.IsDeleted,
|
||||||
message.CreatedAt,
|
message.CreatedAt,
|
||||||
message.SequenceId,
|
message.SequenceId,
|
||||||
message.ForwardedFromId,
|
message.ForwardedFromId,
|
||||||
message.ForwardedFromId.HasValue && senders.TryGetValue(message.ForwardedFromId.Value, out var fwdUser) ? new MessageSenderDto(fwdUser.Id, fwdUser.Username, fwdUser.DisplayName, fwdUser.Avatar) : null,
|
null, // ForwardedFrom details not implemented here yet
|
||||||
storyMessage?.StoryId,
|
(message as StoryMessage)?.StoryId,
|
||||||
storyMessage?.StoryMediaUrl,
|
(message as StoryMessage)?.StoryMediaUrl,
|
||||||
storyMessage?.StoryMediaType,
|
(message as StoryMessage)?.StoryMediaType,
|
||||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).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>(),
|
||||||
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : null,
|
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||||
chat.Members.Where(m => m.LastReadSequenceId >= message.SequenceId && m.UserId != message.SenderId).Select(m => new ReadByDto(m.UserId)).ToList(),
|
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
|
||||||
reactionsWithUser
|
reactions?.Select(r => {
|
||||||
));
|
senders.TryGetValue(r.UserId, out var ru);
|
||||||
|
return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null);
|
||||||
|
}).ToList() ?? new List<MessageReactionDto>(),
|
||||||
|
(message as CallMessage)?.CallType,
|
||||||
|
(message as CallMessage)?.CallStatus,
|
||||||
|
(message as CallMessage)?.Duration,
|
||||||
|
(message as PollMessage)?.Options.Select(o => {
|
||||||
|
var pm = (PollMessage)message;
|
||||||
|
var voters = pm.IsAnonymous == false
|
||||||
|
? pm.Votes
|
||||||
|
.Where(v => v.OptionId == o.Id)
|
||||||
|
.Select(v => {
|
||||||
|
senders.TryGetValue(v.UserId, out var vu);
|
||||||
|
return vu != null
|
||||||
|
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
|
||||||
|
: new MessageSenderDto(v.UserId, "unknown", "Unknown", null);
|
||||||
|
})
|
||||||
|
.ToList()
|
||||||
|
: null;
|
||||||
|
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
|
||||||
|
}).ToList(),
|
||||||
|
(message as PollMessage)?.IsMultipleChoice,
|
||||||
|
(message as PollMessage)?.IsAnonymous,
|
||||||
|
(message as PollMessage)?.IsClosed,
|
||||||
|
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList()
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Success(result);
|
return Result.Success(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+12
-17
@@ -6,9 +6,9 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
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.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
@@ -88,22 +88,17 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
|
|||||||
var filteredMedia = messageMedia.Where(media =>
|
var filteredMedia = messageMedia.Where(media =>
|
||||||
{
|
{
|
||||||
var mType = media.Type?.ToLower() ?? "file";
|
var mType = media.Type?.ToLower() ?? "file";
|
||||||
var isGif = mType == "image" && media.Url != null && (media.Url.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) || media.Url.EndsWith(".gif", StringComparison.OrdinalIgnoreCase));
|
var filename = media.Filename?.ToLower() ?? "";
|
||||||
|
var url = media.Url?.ToLower() ?? "";
|
||||||
|
|
||||||
if (filterType == "gifs")
|
var isGif = mType == "gif" ||
|
||||||
{
|
(mType == "image" && (filename.EndsWith(".mp4") || filename.EndsWith(".gif") || url.EndsWith(".gif") || filename.Contains("gif"))) ||
|
||||||
return isGif;
|
(mType == "video" && (filename.Contains("animation") || filename.Contains("gif")));
|
||||||
}
|
|
||||||
|
|
||||||
if (filterType == "files")
|
if (filterType == "gifs") return isGif;
|
||||||
{
|
if (filterType == "media") return (mType == "image" || mType == "video") && !isGif;
|
||||||
return mType != "image" && mType != "video" && mType != "link";
|
if (filterType == "files") return (mType == "file" || mType == "audio") && !isGif && mType != "image" && mType != "video";
|
||||||
}
|
if (filterType == "links") return mType == "link";
|
||||||
|
|
||||||
if (filterType == "media")
|
|
||||||
{
|
|
||||||
return (mType == "image" || mType == "video") && !isGif;
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}).ToList();
|
}).ToList();
|
||||||
@@ -124,7 +119,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
|
|||||||
storyMessage?.StoryMediaType,
|
storyMessage?.StoryMediaType,
|
||||||
message.IsEdited,
|
message.IsEdited,
|
||||||
message.Type,
|
message.Type,
|
||||||
filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList()
|
filteredMedia.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size, media.Duration)).ToList()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Messaging.Domain;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Application.Messages.Pin;
|
||||||
|
|
||||||
|
public sealed record PinMessageCommand(Guid MessageId, Guid ChatId, Guid UserId) : ICommand<Guid>;
|
||||||
|
|
||||||
|
public sealed class PinMessageCommandHandler : ICommandHandler<PinMessageCommand, Guid>
|
||||||
|
{
|
||||||
|
private readonly IMessageRepository _messageRepository;
|
||||||
|
private readonly IChatRepository _chatRepository;
|
||||||
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
public PinMessageCommandHandler(
|
||||||
|
IMessageRepository messageRepository,
|
||||||
|
IChatRepository chatRepository,
|
||||||
|
IChatsUnitOfWork unitOfWork,
|
||||||
|
IMediator mediator)
|
||||||
|
{
|
||||||
|
_messageRepository = messageRepository;
|
||||||
|
_chatRepository = chatRepository;
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<Guid>> Handle(PinMessageCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||||
|
if (chat is null) return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
|
||||||
|
|
||||||
|
// Security check
|
||||||
|
if (!chat.Members.Any(m => m.UserId == request.UserId))
|
||||||
|
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
|
||||||
|
|
||||||
|
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
|
||||||
|
if (message is null) return Result.Failure<Guid>(ChatErrors.NotFound);
|
||||||
|
|
||||||
|
if (message.ChatId != request.ChatId)
|
||||||
|
return Result.Failure<Guid>(ChatErrors.NotFound);
|
||||||
|
|
||||||
|
message.AddState(MessageState.IsPinned);
|
||||||
|
|
||||||
|
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||||
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Notify chat about pinned message change
|
||||||
|
await _mediator.Publish(new MessagePinnedDomainEvent(message.Id, message.ChatId, message.SenderId, message.Content), cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(message.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MessagePinnedDomainEvent(Guid MessageId, Guid ChatId, Guid SenderId, string? Content) : INotification;
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
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.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
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.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Application.Messages.Read;
|
namespace Knot.Modules.Conversations.Application.Messages.Read;
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ using System.Threading.Tasks;
|
|||||||
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.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
|
|||||||
+20
-4
@@ -1,8 +1,8 @@
|
|||||||
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.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Domain;
|
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;
|
||||||
@@ -27,7 +27,10 @@ public sealed record SendMessageCommand(
|
|||||||
List<string>? PollOptions = null,
|
List<string>? PollOptions = null,
|
||||||
bool? PollIsAnonymous = null,
|
bool? PollIsAnonymous = null,
|
||||||
bool? PollAllowMultipleAnswers = null,
|
bool? PollAllowMultipleAnswers = null,
|
||||||
DateTime? PollExpiresAt = null) : ICommand<Guid>;
|
DateTime? PollExpiresAt = null,
|
||||||
|
string? CallType = null,
|
||||||
|
string? CallStatus = null,
|
||||||
|
int? Duration = null) : ICommand<Guid>;
|
||||||
|
|
||||||
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
||||||
{
|
{
|
||||||
@@ -132,8 +135,9 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
else if (request.Type == "poll")
|
else if (request.Type == "poll")
|
||||||
{
|
{
|
||||||
if (!_messagesSettings.Current.AllowPolls) return Result.Failure<Guid>(ChatErrors.PollsDisabled);
|
if (!_messagesSettings.Current.AllowPolls) return Result.Failure<Guid>(ChatErrors.PollsDisabled);
|
||||||
|
if (chat.Type != ChatType.Group) return Result.Failure<Guid>(new Error("Poll.InvalidChat", "Polls are only allowed in groups."));
|
||||||
|
|
||||||
message = new PollMessage(
|
message = PollMessage.Create(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
request.ChatId,
|
request.ChatId,
|
||||||
request.SenderId,
|
request.SenderId,
|
||||||
@@ -143,6 +147,18 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
|||||||
request.PollAllowMultipleAnswers ?? false,
|
request.PollAllowMultipleAnswers ?? false,
|
||||||
request.PollExpiresAt,
|
request.PollExpiresAt,
|
||||||
request.ReplyToId,
|
request.ReplyToId,
|
||||||
|
request.ForwardedFromId);
|
||||||
|
}
|
||||||
|
else if (request.Type == "call")
|
||||||
|
{
|
||||||
|
message = new CallMessage(
|
||||||
|
Guid.NewGuid(),
|
||||||
|
request.ChatId,
|
||||||
|
request.SenderId,
|
||||||
|
request.CallType ?? "voice",
|
||||||
|
request.CallStatus ?? "completed",
|
||||||
|
request.Duration,
|
||||||
|
request.ReplyToId,
|
||||||
request.ForwardedFromId,
|
request.ForwardedFromId,
|
||||||
DateTime.UtcNow,
|
DateTime.UtcNow,
|
||||||
false);
|
false);
|
||||||
|
|||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Messaging.Domain;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Application.Messages.Unpin;
|
||||||
|
|
||||||
|
public sealed record UnpinMessageCommand(Guid MessageId, Guid ChatId, Guid UserId) : ICommand<Guid>;
|
||||||
|
|
||||||
|
public sealed class UnpinMessageCommandHandler : ICommandHandler<UnpinMessageCommand, Guid>
|
||||||
|
{
|
||||||
|
private readonly IMessageRepository _messageRepository;
|
||||||
|
private readonly IChatRepository _chatRepository;
|
||||||
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
public UnpinMessageCommandHandler(
|
||||||
|
IMessageRepository messageRepository,
|
||||||
|
IChatRepository chatRepository,
|
||||||
|
IChatsUnitOfWork unitOfWork,
|
||||||
|
IMediator mediator)
|
||||||
|
{
|
||||||
|
_messageRepository = messageRepository;
|
||||||
|
_chatRepository = chatRepository;
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_mediator = mediator;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<Guid>> Handle(UnpinMessageCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||||
|
if (chat is null) return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
|
||||||
|
|
||||||
|
// Security check
|
||||||
|
if (!chat.Members.Any(m => m.UserId == request.UserId))
|
||||||
|
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
|
||||||
|
|
||||||
|
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
|
||||||
|
if (message is null) return Result.Failure<Guid>(ChatErrors.NotFound);
|
||||||
|
|
||||||
|
if (message.ChatId != request.ChatId)
|
||||||
|
return Result.Failure<Guid>(ChatErrors.NotFound);
|
||||||
|
|
||||||
|
message.RemoveState(MessageState.IsPinned);
|
||||||
|
|
||||||
|
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||||
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Notify chat about unpinned message change
|
||||||
|
await _mediator.Publish(new MessageUnpinnedDomainEvent(message.Id, message.ChatId), cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(message.Id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public record MessageUnpinnedDomainEvent(Guid MessageId, Guid ChatId) : INotification;
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using System;
|
using System;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Contracts.Messaging.Domain;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using MediatR;
|
||||||
|
using System;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Application.Messages.Vote;
|
||||||
|
|
||||||
|
public sealed record VotePollCommand(
|
||||||
|
Guid MessageId,
|
||||||
|
Guid ChatId,
|
||||||
|
Guid UserId,
|
||||||
|
Guid OptionId) : ICommand;
|
||||||
|
|
||||||
|
public sealed class VotePollCommandHandler : ICommandHandler<VotePollCommand>
|
||||||
|
{
|
||||||
|
private readonly IMessageRepository _messageRepository;
|
||||||
|
private readonly IChatRepository _chatRepository;
|
||||||
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
|
private readonly IMessageNotifier _notifier;
|
||||||
|
private readonly IUserDisplayNameProvider _userProvider;
|
||||||
|
|
||||||
|
public VotePollCommandHandler(
|
||||||
|
IMessageRepository messageRepository,
|
||||||
|
IChatRepository chatRepository,
|
||||||
|
IChatsUnitOfWork unitOfWork,
|
||||||
|
IMessageNotifier notifier,
|
||||||
|
IUserDisplayNameProvider userProvider)
|
||||||
|
{
|
||||||
|
_messageRepository = messageRepository;
|
||||||
|
_chatRepository = chatRepository;
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_notifier = notifier;
|
||||||
|
_userProvider = userProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Handle(VotePollCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var message = await _messageRepository.GetByIdAsync(request.MessageId, cancellationToken);
|
||||||
|
if (message is not PollMessage poll) return Result.Failure(new Error("Poll.NotFound", "Poll not found"));
|
||||||
|
|
||||||
|
if (poll.IsClosed) return Result.Failure(new Error("Poll.Closed", "This poll is closed."));
|
||||||
|
|
||||||
|
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||||
|
if (chat == null || !chat.Members.Any(m => m.UserId == request.UserId)) return Result.Failure(ChatErrors.ChatsForbidden);
|
||||||
|
|
||||||
|
var targetOption = poll.Options.FirstOrDefault(o => o.Id == request.OptionId);
|
||||||
|
if (targetOption == null) return Result.Failure(new Error("Poll.InvalidOption", "Invalid option ID."));
|
||||||
|
|
||||||
|
// Prevent duplicate or changed votes
|
||||||
|
var existingVote = poll.Votes.FirstOrDefault(v => v.UserId == request.UserId && v.OptionId == request.OptionId);
|
||||||
|
if (existingVote != null) return Result.Failure(new Error("Poll.AlreadyVoted", "You have already voted for this option."));
|
||||||
|
|
||||||
|
if (!poll.IsMultipleChoice)
|
||||||
|
{
|
||||||
|
var hasVotedInThisPoll = poll.Votes.Any(v => v.UserId == request.UserId);
|
||||||
|
if (hasVotedInThisPoll) return Result.Failure(new Error("Poll.AlreadyVoted", "You have already voted in this poll."));
|
||||||
|
}
|
||||||
|
|
||||||
|
poll.Votes.Add(new PollVote { UserId = request.UserId, OptionId = request.OptionId, VotedAt = DateTime.UtcNow });
|
||||||
|
targetOption.VoteCount++;
|
||||||
|
|
||||||
|
await _messageRepository.UpdateAsync(poll, cancellationToken);
|
||||||
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Notify updated poll
|
||||||
|
var voterIds = poll.Votes.Select(v => v.UserId).Distinct().ToList();
|
||||||
|
var votersInfo = poll.IsAnonymous == false
|
||||||
|
? await _userProvider.GetUsersInfoAsync(voterIds, cancellationToken)
|
||||||
|
: new Dictionary<Guid, UserInfo>();
|
||||||
|
|
||||||
|
await _notifier.NotifyMessageUpdateAsync(poll.ChatId, "poll_updated", new
|
||||||
|
{
|
||||||
|
id = poll.Id,
|
||||||
|
chatId = poll.ChatId,
|
||||||
|
senderId = poll.SenderId,
|
||||||
|
createdAt = poll.CreatedAt,
|
||||||
|
type = "poll",
|
||||||
|
content = poll.Content,
|
||||||
|
pollOptions = poll.Options.Select(o => new {
|
||||||
|
id = o.Id,
|
||||||
|
text = o.Text,
|
||||||
|
voteCount = o.VoteCount,
|
||||||
|
voters = poll.IsAnonymous == false
|
||||||
|
? poll.Votes.Where(v => v.OptionId == o.Id)
|
||||||
|
.Select(v => {
|
||||||
|
votersInfo.TryGetValue(v.UserId, out var vu);
|
||||||
|
return vu != null
|
||||||
|
? new { id = vu.Id, username = vu.Username, displayName = vu.DisplayName, avatar = vu.Avatar }
|
||||||
|
: new { id = v.UserId, username = "unknown", displayName = "Unknown", avatar = (string?)null };
|
||||||
|
}).ToList()
|
||||||
|
: null,
|
||||||
|
voterIds = poll.IsAnonymous == false
|
||||||
|
? poll.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList()
|
||||||
|
: null
|
||||||
|
}).ToList(),
|
||||||
|
pollIsMultipleChoice = poll.IsMultipleChoice,
|
||||||
|
pollIsClosed = poll.IsClosed,
|
||||||
|
pollIsAnonymous = poll.IsAnonymous
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
-9
@@ -2,8 +2,8 @@ using System.Text.RegularExpressions;
|
|||||||
using Knot.Contracts.Auth.Application.Abstractions;
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
using Knot.Contracts.Auth.Domain;
|
using Knot.Contracts.Auth.Domain;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Shared.Kernel.Storage;
|
using Knot.Shared.Kernel.Storage;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
@@ -55,15 +55,18 @@ internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserComma
|
|||||||
{
|
{
|
||||||
foreach (var media in mediaMsg.Media)
|
foreach (var media in mediaMsg.Media)
|
||||||
{
|
{
|
||||||
bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id &&
|
if (!string.IsNullOrEmpty(media.Url))
|
||||||
m is MediaMessage mm && mm.Media.Any(ame => ame.Url == media.Url));
|
|
||||||
|
|
||||||
if (!isUsedElsewhere)
|
|
||||||
{
|
{
|
||||||
var fileId = ExtractFileId(media.Url);
|
bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id &&
|
||||||
if (!string.IsNullOrEmpty(fileId))
|
m is MediaMessage mm && mm.Media.Any(ame => !string.IsNullOrEmpty(ame.Url) && ame.Url == media.Url));
|
||||||
|
|
||||||
|
if (!isUsedElsewhere)
|
||||||
{
|
{
|
||||||
await _fileStorage.DeleteFileAsync(fileId);
|
var fileId = ExtractFileId(media.Url);
|
||||||
|
if (!string.IsNullOrEmpty(fileId))
|
||||||
|
{
|
||||||
|
await _fileStorage.DeleteFileAsync(fileId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Modules.Conversations.Domain;
|
|
||||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||||
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
||||||
|
using Knot.Modules.Conversations.Infrastructure.Services;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using ConversationsAbstractions = Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations;
|
namespace Knot.Modules.Conversations;
|
||||||
|
|
||||||
@@ -29,21 +27,22 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
|
var mongoConnectionString = configuration.GetConnectionString("MongoConnection") ?? "mongodb://localhost:27017";
|
||||||
|
|
||||||
services.AddScoped<ConversationsAbstractions.IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
|
services.AddScoped<IChatsUnitOfWork>(sp => sp.GetRequiredService<ChatsDbContext>());
|
||||||
services.AddScoped<Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext>(sp => sp.GetRequiredService<ChatsDbContext>());
|
services.AddScoped<Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext>(sp => sp.GetRequiredService<ChatsDbContext>());
|
||||||
services.AddScoped<IChatRepository, ChatRepository>();
|
services.AddScoped<IChatRepository, ChatRepository>();
|
||||||
services.AddScoped<IFolderRepository, FolderRepository>();
|
services.AddScoped<IFolderRepository, FolderRepository>();
|
||||||
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();
|
services.AddScoped<IUserChatSettingsRepository, UserChatSettingsRepository>();
|
||||||
services.AddScoped<IUserFolderSettingsRepository, UserFolderSettingsRepository>();
|
services.AddScoped<Knot.Contracts.Conversations.Domain.IUserFolderSettingsRepository, UserFolderSettingsRepository>();
|
||||||
|
|
||||||
services.AddMediatR(config =>
|
services.AddMediatR(config =>
|
||||||
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
|
config.RegisterServicesFromAssembly(typeof(DependencyInjection).Assembly));
|
||||||
|
|
||||||
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IChatAccessProvider, Knot.Modules.Conversations.Infrastructure.Services.ChatAccessProvider>();
|
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IChatAccessProvider, ChatAccessProvider>();
|
||||||
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
|
services.AddScoped<Knot.Contracts.Messaging.Application.Abstractions.IMessageNotifier, Knot.Modules.Conversations.Infrastructure.SignalR.MessageNotifier>();
|
||||||
services.AddScoped<ConversationsAbstractions.IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
|
services.AddScoped<Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService, UserStatusService>();
|
||||||
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, Knot.Modules.Conversations.Infrastructure.Services.UserStatusService>();
|
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
|
||||||
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, Knot.Modules.Conversations.Infrastructure.Services.UserDeleterService>();
|
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
using Knot.Shared.Kernel;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Domain;
|
|
||||||
|
|
||||||
public sealed record ChatCreatedDomainEvent(Chat Chat) : IDomainEvent;
|
|
||||||
public sealed record ChatMemberAddedDomainEvent(Guid ChatId, Guid UserId) : IDomainEvent;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Тип чата: личный или групповой.
|
|
||||||
/// </summary>
|
|
||||||
public enum ChatType
|
|
||||||
{
|
|
||||||
Personal,
|
|
||||||
Group,
|
|
||||||
Favorites
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Роль участника в чате.
|
|
||||||
/// </summary>
|
|
||||||
public static class ChatRole
|
|
||||||
{
|
|
||||||
public const string Owner = "owner";
|
|
||||||
public const string Admin = "admin";
|
|
||||||
public const string Member = "member";
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Сущность чата (Агрегат).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Chat : AggregateRoot<Guid>
|
|
||||||
{
|
|
||||||
public ChatType Type { get; private set; }
|
|
||||||
public string? Name { get; private set; }
|
|
||||||
public string? Description { get; private set; }
|
|
||||||
public string? Avatar { get; private set; }
|
|
||||||
public DateTime CreatedAt { get; private set; }
|
|
||||||
public long LastMessageSequenceId { get; private set; }
|
|
||||||
|
|
||||||
private readonly List<ChatMember> _members = new();
|
|
||||||
public IReadOnlyCollection<ChatMember> Members => _members.AsReadOnly();
|
|
||||||
|
|
||||||
private Chat(Guid id, ChatType type, string? name, string? avatar, string? description = null) : base(id)
|
|
||||||
{
|
|
||||||
Type = type;
|
|
||||||
Name = name;
|
|
||||||
Avatar = avatar;
|
|
||||||
Description = description;
|
|
||||||
CreatedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Создает личный чат между двумя пользователями.
|
|
||||||
/// </summary>
|
|
||||||
public static Chat CreatePersonal()
|
|
||||||
{
|
|
||||||
var chat = new Chat(Guid.NewGuid(), ChatType.Personal, null, null);
|
|
||||||
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
|
||||||
return chat;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Создает групповой чат.
|
|
||||||
/// </summary>
|
|
||||||
public static Chat CreateGroup(string name, string? avatar = null)
|
|
||||||
{
|
|
||||||
var chat = new Chat(Guid.NewGuid(), ChatType.Group, name, avatar);
|
|
||||||
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
|
||||||
return chat;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Фабричный метод для создания чата.
|
|
||||||
/// </summary>
|
|
||||||
public static Chat Create(string? name, ChatType type, string? avatar = null, string? description = null)
|
|
||||||
{
|
|
||||||
var chat = new Chat(Guid.NewGuid(), type, name, avatar, description);
|
|
||||||
chat.RaiseDomainEvent(new ChatCreatedDomainEvent(chat));
|
|
||||||
return chat;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void AddMember(Guid userId, string role = "member")
|
|
||||||
{
|
|
||||||
if (_members.Any(m => m.UserId == userId))
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_members.Add(new ChatMember(Id, userId, role));
|
|
||||||
RaiseDomainEvent(new ChatMemberAddedDomainEvent(Id, userId));
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RemoveMember(Guid userId)
|
|
||||||
{
|
|
||||||
var member = _members.FirstOrDefault(m => m.UserId == userId);
|
|
||||||
if (member != null)
|
|
||||||
{
|
|
||||||
_members.Remove(member);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateName(string name) => Name = name;
|
|
||||||
|
|
||||||
public void UpdateDescription(string? description) => Description = description;
|
|
||||||
|
|
||||||
public void UpdateAvatar(string? avatarUrl) => Avatar = avatarUrl;
|
|
||||||
|
|
||||||
public long IncrementSequenceId()
|
|
||||||
{
|
|
||||||
return ++LastMessageSequenceId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Участник чата.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ChatMember : Entity<Guid>
|
|
||||||
{
|
|
||||||
public Guid ChatId { get; private set; }
|
|
||||||
public Guid UserId { get; private set; }
|
|
||||||
public string Role { get; private set; }
|
|
||||||
public DateTime JoinedAt { get; private set; }
|
|
||||||
public bool IsPinned { get; private set; }
|
|
||||||
public bool IsMuted { get; private set; }
|
|
||||||
|
|
||||||
public Guid? LastReadMessageId { get; private set; }
|
|
||||||
public long LastReadSequenceId { get; private set; }
|
|
||||||
public Guid? LastDeliveredMessageId { get; private set; }
|
|
||||||
|
|
||||||
// For EF Core
|
|
||||||
private ChatMember() : base(Guid.Empty) { Role = "member"; }
|
|
||||||
|
|
||||||
internal ChatMember(Guid chatId, Guid userId, string role) : base(Guid.NewGuid())
|
|
||||||
{
|
|
||||||
ChatId = chatId;
|
|
||||||
UserId = userId;
|
|
||||||
Role = role;
|
|
||||||
JoinedAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void TogglePin() => IsPinned = !IsPinned;
|
|
||||||
|
|
||||||
public void UpdateReadCursor(Guid messageId, long sequenceId)
|
|
||||||
{
|
|
||||||
if (sequenceId > LastReadSequenceId)
|
|
||||||
{
|
|
||||||
LastReadMessageId = messageId;
|
|
||||||
LastReadSequenceId = sequenceId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void UpdateDeliveredCursor(Guid messageId)
|
|
||||||
{
|
|
||||||
LastDeliveredMessageId = messageId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace Knot.Modules.Conversations.Domain;
|
|
||||||
|
|
||||||
public static class ChatConstants
|
|
||||||
{
|
|
||||||
public const int DefaultMessageQueryLimit = 100;
|
|
||||||
public const int MaxSharedMediaQueryLimit = 300;
|
|
||||||
public const int SearchMessagesLimit = 50;
|
|
||||||
public const int MaxFileUploadSizeMb = 50;
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
using Knot.Shared.Kernel;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Domain;
|
|
||||||
|
|
||||||
public static class ChatErrors
|
|
||||||
{
|
|
||||||
public static readonly Error FileEmpty = new Error("File.Empty", "No file uploaded");
|
|
||||||
public static readonly Error FileInvalidExtension = new Error("File.InvalidExtension", "Must be a ZIP archive");
|
|
||||||
public static readonly Error ImportExpired = new Error("Import.Expired", "Session not found or expired");
|
|
||||||
public static readonly Error ImportMissing = new Error("Import.Missing", "ZIP file lost");
|
|
||||||
public static readonly Error ChatNotFound = new Error("Chat.NotFound", "Chat not found or access denied");
|
|
||||||
public static readonly Error NotFound = new Error("Chat.NotFound", "Chat not found"); // Alias
|
|
||||||
public static readonly Error NotMember = new Error("Chat.NotMember", "You are not a member of this chat");
|
|
||||||
public static readonly Error ChatsForbidden = new Error("Chats.Forbidden", "Вы не являетесь участником этого чата.");
|
|
||||||
public static readonly Error MessagesNotFound = new Error("Messages.NotFound", "Message not found.");
|
|
||||||
public static readonly Error ChatsNotFound = new Error("Chats.NotFound", "Чат не найден.");
|
|
||||||
public static readonly Error Unauthorized = new Error("Chats.Unauthorized", "Access denied");
|
|
||||||
public static readonly Error FoldersDisabled = new Error("Folders.Disabled", "Folders feature is disabled by the administrator.");
|
|
||||||
public static readonly Error PollsDisabled = new Error("Polls.Disabled", "Polls are disabled by the administrator.");
|
|
||||||
public static readonly Error MediaDisabled = new Error("Media.Disabled", "Media messages are disabled by the administrator.");
|
|
||||||
|
|
||||||
public static Error ImportCreateChatFailed(string msg) => new Error("Import.CreateChatFailed", msg);
|
|
||||||
public static Error FileTooLarge(int maxMb) => new Error("File.TooLarge", $"File exceeds the maximum allowed size of {maxMb}MB.");
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using Knot.Shared.Kernel;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Domain;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Сущность папки для группировки чатов.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class Folder : AggregateRoot<Guid>
|
|
||||||
{
|
|
||||||
public string Name { get; private set; }
|
|
||||||
public string? Icon { get; private set; } // URL из хранилища
|
|
||||||
public bool IsDefault { get; private set; }
|
|
||||||
public FolderType Type { get; private set; }
|
|
||||||
|
|
||||||
public Folder(Guid id, string name, string? icon = null, bool isDefault = false, FolderType type = FolderType.Custom)
|
|
||||||
: base(id)
|
|
||||||
{
|
|
||||||
Name = name;
|
|
||||||
Icon = icon;
|
|
||||||
IsDefault = isDefault;
|
|
||||||
Type = type;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void Update(string name, string? icon)
|
|
||||||
{
|
|
||||||
if (IsDefault) throw new InvalidOperationException("Cannot rename default folders.");
|
|
||||||
Name = name;
|
|
||||||
Icon = icon;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public enum FolderType
|
|
||||||
{
|
|
||||||
All, // Все чаты
|
|
||||||
New, // Новые (с непрочитанными)
|
|
||||||
Muted, // Без звука
|
|
||||||
Custom // Пользовательская
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Настройки конкретного чата для конкретного пользователя.
|
|
||||||
/// Хранятся в PostgreSQL (связь User <-> Chat).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class UserChatSettings : Entity<Guid>
|
|
||||||
{
|
|
||||||
public Guid UserId { get; private set; }
|
|
||||||
public Guid ChatId { get; private set; }
|
|
||||||
|
|
||||||
// Список папок, в которые входит чат для этого пользователя
|
|
||||||
private readonly List<Guid> _folderIds = new();
|
|
||||||
public IReadOnlyCollection<Guid> FolderIds => _folderIds.AsReadOnly();
|
|
||||||
|
|
||||||
public bool IsMuted { get; private set; }
|
|
||||||
|
|
||||||
private UserChatSettings() : base(Guid.NewGuid()) { }
|
|
||||||
|
|
||||||
public UserChatSettings(Guid userId, Guid chatId) : base(Guid.NewGuid())
|
|
||||||
{
|
|
||||||
UserId = userId;
|
|
||||||
ChatId = chatId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public static UserChatSettings Create(Guid userId, Guid chatId) => new(userId, chatId);
|
|
||||||
|
|
||||||
public void AddToFolder(Guid folderId)
|
|
||||||
{
|
|
||||||
if (!_folderIds.Contains(folderId)) _folderIds.Add(folderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void RemoveFromFolder(Guid folderId)
|
|
||||||
{
|
|
||||||
_folderIds.Remove(folderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void SetMute(bool isMuted) => IsMuted = isMuted;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Глобальные настройки папок пользователя (скрытие дефолтных и т.д.).
|
|
||||||
/// Будет храниться в MongoDB.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class UserFolderSettings : AggregateRoot<Guid>
|
|
||||||
{
|
|
||||||
public Guid UserId { get; private set; }
|
|
||||||
|
|
||||||
// Список ID папок, которые пользователь скрыл (только для дефолтных)
|
|
||||||
public List<Guid> HiddenDefaultFolderIds { get; private set; } = new();
|
|
||||||
|
|
||||||
// Список пользовательских папок (Guid созданных Folder)
|
|
||||||
public List<Guid> CustomFolderIds { get; private set; } = new();
|
|
||||||
|
|
||||||
public UserFolderSettings(Guid userId) : base(Guid.NewGuid())
|
|
||||||
{
|
|
||||||
UserId = userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void HideFolder(Guid folderId)
|
|
||||||
{
|
|
||||||
if (!HiddenDefaultFolderIds.Contains(folderId)) HiddenDefaultFolderIds.Add(folderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void ShowFolder(Guid folderId)
|
|
||||||
{
|
|
||||||
HiddenDefaultFolderIds.Remove(folderId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
using Knot.Modules.Conversations.Domain;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Domain;
|
|
||||||
|
|
||||||
public interface IChatRepository
|
|
||||||
{
|
|
||||||
void Add(Chat chat);
|
|
||||||
void Update(Chat chat);
|
|
||||||
void Remove(Chat chat);
|
|
||||||
Task<Chat?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
|
||||||
Task<Chat?> GetFavoritesAsync(Guid userId, CancellationToken cancellationToken);
|
|
||||||
Task<List<Chat>> GetUserChatsAsync(Guid userId, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IFolderRepository
|
|
||||||
{
|
|
||||||
void Add(Folder folder);
|
|
||||||
void Update(Folder folder);
|
|
||||||
void Remove(Folder folder);
|
|
||||||
Task<Folder?> GetByIdAsync(Guid id, CancellationToken cancellationToken);
|
|
||||||
Task<List<Folder>> GetUserFoldersAsync(Guid userId, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IUserChatSettingsRepository
|
|
||||||
{
|
|
||||||
void Add(UserChatSettings settings);
|
|
||||||
void Update(UserChatSettings settings);
|
|
||||||
void Remove(UserChatSettings settings);
|
|
||||||
void RemoveRange(IEnumerable<UserChatSettings> settings);
|
|
||||||
Task<UserChatSettings?> GetAsync(Guid userId, Guid chatId, CancellationToken cancellationToken);
|
|
||||||
Task<List<UserChatSettings>> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public interface IUserFolderSettingsRepository
|
|
||||||
{
|
|
||||||
Task<UserFolderSettings?> GetByUserIdAsync(Guid userId, CancellationToken cancellationToken);
|
|
||||||
Task UpdateAsync(UserFolderSettings settings, CancellationToken cancellationToken);
|
|
||||||
Task RemoveByUserIdAsync(Guid userId, CancellationToken cancellationToken);
|
|
||||||
}
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Knot.Modules.Conversations.Domain;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
|
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
|||||||
+5
-4
@@ -4,19 +4,18 @@ using System.Linq;
|
|||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Modules.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Knot.Shared.Kernel.Security;
|
using Knot.Shared.Kernel.Security;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
using DomainChat = Knot.Modules.Conversations.Domain.Chat;
|
using DomainChat = Knot.Contracts.Conversations.Domain.Chat;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
|
namespace Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||||
|
|
||||||
public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Application.Abstractions.IChatsUnitOfWork, Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext
|
public sealed class ChatsDbContext : DbContext, Knot.Contracts.Conversations.Application.Abstractions.IChatsUnitOfWork, Knot.Contracts.Conversations.Infrastructure.Persistence.IChatsDbContext
|
||||||
{
|
{
|
||||||
private readonly IMediator? _mediator;
|
private readonly IMediator? _mediator;
|
||||||
private readonly IEncryptionService? _encryptionService;
|
private readonly IEncryptionService? _encryptionService;
|
||||||
@@ -54,6 +53,8 @@ public sealed class ChatsDbContext : DbContext, Knot.Modules.Conversations.Appli
|
|||||||
{
|
{
|
||||||
builder.ToTable("Chats");
|
builder.ToTable("Chats");
|
||||||
builder.HasKey(c => c.Id);
|
builder.HasKey(c => c.Id);
|
||||||
|
builder.Property(c => c.IsImporting);
|
||||||
|
builder.Property(c => c.ImportJobId);
|
||||||
builder.Property(c => c.Type).HasConversion<string>();
|
builder.Property(c => c.Type).HasConversion<string>();
|
||||||
|
|
||||||
builder.OwnsMany(c => c.Members, mb =>
|
builder.OwnsMany(c => c.Members, mb =>
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using MongoDB.Bson.Serialization;
|
using MongoDB.Bson.Serialization;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
namespace Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using MongoDB.Driver;
|
using MongoDB.Driver;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
namespace Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
using Knot.Modules.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
using Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Infrastructure.Services;
|
namespace Knot.Modules.Conversations.Infrastructure.Services;
|
||||||
|
|
||||||
public sealed class UserStatusService : IUserStatusService, Knot.Contracts.Conversations.Abstractions.IUserStatusService
|
public sealed class UserStatusService :
|
||||||
|
Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService,
|
||||||
|
Knot.Contracts.Conversations.Abstractions.IUserStatusService
|
||||||
{
|
{
|
||||||
public bool IsUserOnline(string userId)
|
public bool IsUserOnline(string userId)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,9 +8,19 @@ using Knot.Modules.Conversations.Application.Messages.Send;
|
|||||||
using Knot.Modules.Conversations.Application.Messages.Read;
|
using Knot.Modules.Conversations.Application.Messages.Read;
|
||||||
using Knot.Modules.Conversations.Application.Messages.Delete;
|
using Knot.Modules.Conversations.Application.Messages.Delete;
|
||||||
using Knot.Modules.Conversations.Application.Messages.React;
|
using Knot.Modules.Conversations.Application.Messages.React;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using Microsoft.Extensions.Caching.Memory;
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
using Knot.Contracts.Auth.Domain;
|
||||||
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Pin;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Unpin;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Vote;
|
||||||
|
using Knot.Modules.Conversations.Application.Messages.Edit;
|
||||||
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Messaging.Domain;
|
||||||
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||||
|
|
||||||
@@ -28,19 +38,38 @@ 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)
|
||||||
|
private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new();
|
||||||
|
// chatId → (startTime, callType)
|
||||||
|
private static readonly ConcurrentDictionary<string, (DateTime StartTime, string CallType)> _activeGroupCalls = new();
|
||||||
|
|
||||||
private readonly ISender _sender;
|
private readonly ISender _sender;
|
||||||
private readonly IUserContext _userContext;
|
private readonly IUserContext _userContext;
|
||||||
private readonly IChatRepository _chatRepository;
|
private readonly IChatRepository _chatRepository;
|
||||||
|
private readonly IUserRepository _userRepository;
|
||||||
|
private readonly IMessageRepository _messageRepository;
|
||||||
private readonly ILogger<ChatHub> _logger;
|
private readonly ILogger<ChatHub> _logger;
|
||||||
private readonly IMemoryCache _cache;
|
private readonly IMemoryCache _cache;
|
||||||
|
private readonly IUserDisplayNameProvider _userProvider;
|
||||||
|
|
||||||
public ChatHub(ISender sender, IUserContext userContext, IChatRepository chatRepository, ILogger<ChatHub> logger, IMemoryCache cache)
|
public ChatHub(
|
||||||
|
ISender sender,
|
||||||
|
IUserContext userContext,
|
||||||
|
IChatRepository chatRepository,
|
||||||
|
IUserRepository userRepository,
|
||||||
|
IMessageRepository messageRepository,
|
||||||
|
ILogger<ChatHub> logger,
|
||||||
|
IMemoryCache cache,
|
||||||
|
IUserDisplayNameProvider userProvider)
|
||||||
{
|
{
|
||||||
_sender = sender;
|
_sender = sender;
|
||||||
_userContext = userContext;
|
_userContext = userContext;
|
||||||
_chatRepository = chatRepository;
|
_chatRepository = chatRepository;
|
||||||
|
_userRepository = userRepository;
|
||||||
|
_messageRepository = messageRepository;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
_userProvider = userProvider;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task OnConnectedAsync()
|
public override async Task OnConnectedAsync()
|
||||||
@@ -103,14 +132,18 @@ public sealed class ChatHub : Hub
|
|||||||
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
|
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
|
||||||
|
|
||||||
var command = new SendMessageCommand(
|
var command = new SendMessageCommand(
|
||||||
request.ChatId,
|
ChatId: request.ChatId,
|
||||||
_userContext.UserId,
|
SenderId: _userContext.UserId,
|
||||||
request.Content,
|
Content: request.Content,
|
||||||
request.Type,
|
Type: request.Type,
|
||||||
attachments,
|
Attachments: attachments,
|
||||||
request.ReplyToId,
|
ReplyToId: request.ReplyToId,
|
||||||
request.Quote,
|
Quote: request.Quote,
|
||||||
request.ForwardedFromId);
|
ForwardedFromId: request.ForwardedFromId,
|
||||||
|
PollOptions: request.PollOptions,
|
||||||
|
PollIsAnonymous: request.PollIsAnonymous,
|
||||||
|
PollAllowMultipleAnswers: request.PollAllowMultipleAnswers
|
||||||
|
);
|
||||||
|
|
||||||
await _sender.Send(command);
|
await _sender.Send(command);
|
||||||
}
|
}
|
||||||
@@ -218,6 +251,62 @@ public sealed class ChatHub : Hub
|
|||||||
_logger.LogInformation("RemoveReaction completed successfully");
|
_logger.LogInformation("RemoveReaction completed successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[HubMethodName("pin_message")]
|
||||||
|
public async Task PinMessage(PinMessageRequest request)
|
||||||
|
{
|
||||||
|
var command = new PinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
|
||||||
|
var result = await _sender.Send(command);
|
||||||
|
var message = await _messageRepository.GetByIdAsync(request.MessageId, Context.ConnectionAborted);
|
||||||
|
if (message != null)
|
||||||
|
{
|
||||||
|
var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId });
|
||||||
|
var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>());
|
||||||
|
|
||||||
|
await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new
|
||||||
|
{
|
||||||
|
chatId = request.ChatId,
|
||||||
|
message = dto,
|
||||||
|
userId = _userContext.UserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HubMethodName("unpin_message")]
|
||||||
|
public async Task UnpinMessage(PinMessageRequest request)
|
||||||
|
{
|
||||||
|
var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
|
||||||
|
var result = await _sender.Send(command);
|
||||||
|
|
||||||
|
await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new
|
||||||
|
{
|
||||||
|
chatId = request.ChatId,
|
||||||
|
messageId = request.MessageId,
|
||||||
|
userId = _userContext.UserId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
[HubMethodName("edit_message")]
|
||||||
|
public async Task EditMessage(EditMessageHubRequest request)
|
||||||
|
{
|
||||||
|
var command = new EditMessageCommand(request.MessageId, request.ChatId, _userContext.UserId, request.Content);
|
||||||
|
var result = await _sender.Send(command);
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
throw new HubException(result.Error.Description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[HubMethodName("vote_poll")]
|
||||||
|
public async Task VotePoll(VotePollRequest request)
|
||||||
|
{
|
||||||
|
var command = new VotePollCommand(request.MessageId, request.ChatId, _userContext.UserId, request.OptionId);
|
||||||
|
var result = await _sender.Send(command);
|
||||||
|
if (result.IsFailure)
|
||||||
|
{
|
||||||
|
throw new HubException(result.Error.Description);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
// Friend signals (Proxy methods for real-time notification)
|
// Friend signals (Proxy methods for real-time notification)
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
@@ -284,10 +373,12 @@ public sealed class ChatHub : Hub
|
|||||||
[HubMethodName("call_offer")]
|
[HubMethodName("call_offer")]
|
||||||
public async Task CallOffer(CallOfferRequest request)
|
public async Task CallOffer(CallOfferRequest request)
|
||||||
{
|
{
|
||||||
// Try to get caller info from current user's claims
|
// Fetch fresh user info from repository instead of relying on potentially stale JWT claims
|
||||||
var displayName = Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
|
var user = await _userRepository.GetByIdAsync(_userContext.UserId);
|
||||||
var avatar = Context.User?.FindFirstValue("avatar");
|
|
||||||
var username = Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name;
|
var displayName = user?.DisplayName ?? Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
|
||||||
|
var avatar = user?.Avatar ?? Context.User?.FindFirstValue("avatar");
|
||||||
|
var username = user?.Username ?? Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name;
|
||||||
|
|
||||||
await SendToUserAsync(request.TargetUserId, "call_incoming", new
|
await SendToUserAsync(request.TargetUserId, "call_incoming", new
|
||||||
{
|
{
|
||||||
@@ -297,18 +388,41 @@ public sealed class ChatHub : Hub
|
|||||||
chatId = request.ChatId,
|
chatId = request.ChatId,
|
||||||
callerInfo = new
|
callerInfo = new
|
||||||
{
|
{
|
||||||
|
|
||||||
id = _userContext.UserId.ToString(),
|
id = _userContext.UserId.ToString(),
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
avatar = avatar,
|
avatar = avatar,
|
||||||
username = username
|
username = username
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Track session for history
|
||||||
|
Guid? chatId = null;
|
||||||
|
if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId;
|
||||||
|
|
||||||
|
if (!chatId.HasValue)
|
||||||
|
{
|
||||||
|
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||||
|
if (Guid.TryParse(request.TargetUserId, out var targetId))
|
||||||
|
{
|
||||||
|
var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId));
|
||||||
|
if (personalChat != null) chatId = personalChat.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow);
|
||||||
|
_activeSessionsByUser[_userContext.UserId.ToString()] = session;
|
||||||
|
_activeSessionsByUser[request.TargetUserId] = session;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HubMethodName("call_answer")]
|
[HubMethodName("call_answer")]
|
||||||
public async Task CallAnswer(CallAnswerRequest request)
|
public async Task CallAnswer(CallAnswerRequest request)
|
||||||
{
|
{
|
||||||
|
if (_activeSessionsByUser.TryGetValue(_userContext.UserId.ToString(), out var session))
|
||||||
|
{
|
||||||
|
session.IsAnswered = true;
|
||||||
|
session.AnswerTime = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
|
||||||
await SendToUserAsync(request.TargetUserId, "call_answered", new
|
await SendToUserAsync(request.TargetUserId, "call_answered", new
|
||||||
{
|
{
|
||||||
from = _userContext.UserId.ToString(),
|
from = _userContext.UserId.ToString(),
|
||||||
@@ -319,6 +433,19 @@ public sealed class ChatHub : Hub
|
|||||||
[HubMethodName("call_decline")]
|
[HubMethodName("call_decline")]
|
||||||
public async Task CallDecline(TargetUserRequest request)
|
public async Task CallDecline(TargetUserRequest request)
|
||||||
{
|
{
|
||||||
|
var currentUserIdStr = _userContext.UserId.ToString();
|
||||||
|
if (_activeSessionsByUser.TryRemove(currentUserIdStr, out var session))
|
||||||
|
{
|
||||||
|
_activeSessionsByUser.TryRemove(request.TargetUserId, out _);
|
||||||
|
if (session.ChatId.HasValue)
|
||||||
|
{
|
||||||
|
// If declined by recipient, it's a "declined" call
|
||||||
|
// If current user is recipient (not the one who started), status is declined
|
||||||
|
string status = _userContext.UserId == session.FromUserId ? "cancelled" : "declined";
|
||||||
|
await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await SendToUserAsync(request.TargetUserId, "call_declined", new
|
await SendToUserAsync(request.TargetUserId, "call_declined", new
|
||||||
{
|
{
|
||||||
from = _userContext.UserId.ToString(),
|
from = _userContext.UserId.ToString(),
|
||||||
@@ -328,12 +455,53 @@ public sealed class ChatHub : Hub
|
|||||||
[HubMethodName("call_end")]
|
[HubMethodName("call_end")]
|
||||||
public async Task CallEnd(TargetUserRequest request)
|
public async Task CallEnd(TargetUserRequest request)
|
||||||
{
|
{
|
||||||
|
var currentUserIdStr = _userContext.UserId.ToString();
|
||||||
|
if (_activeSessionsByUser.TryRemove(currentUserIdStr, out var session))
|
||||||
|
{
|
||||||
|
_activeSessionsByUser.TryRemove(request.TargetUserId, out _);
|
||||||
|
if (session.ChatId.HasValue)
|
||||||
|
{
|
||||||
|
int duration = session.IsAnswered && session.AnswerTime.HasValue
|
||||||
|
? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed");
|
||||||
|
await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await SendToUserAsync(request.TargetUserId, "call_ended", new
|
await SendToUserAsync(request.TargetUserId, "call_ended", new
|
||||||
{
|
{
|
||||||
from = _userContext.UserId.ToString(),
|
from = _userContext.UserId.ToString(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task CreateCallMessage(Guid chatId, Guid senderId, string callType, string status, int duration)
|
||||||
|
{
|
||||||
|
var command = new SendMessageCommand(
|
||||||
|
chatId,
|
||||||
|
senderId,
|
||||||
|
null,
|
||||||
|
"call",
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
callType,
|
||||||
|
status,
|
||||||
|
duration
|
||||||
|
);
|
||||||
|
|
||||||
|
await _sender.Send(command);
|
||||||
|
}
|
||||||
|
|
||||||
[HubMethodName("ice_candidate")]
|
[HubMethodName("ice_candidate")]
|
||||||
public async Task IceCandidate(IceCandidateRequest request)
|
public async Task IceCandidate(IceCandidateRequest request)
|
||||||
{
|
{
|
||||||
@@ -396,14 +564,20 @@ public sealed class ChatHub : Hub
|
|||||||
var chatId = request.ChatId;
|
var chatId = request.ChatId;
|
||||||
var userId = _userContext.UserId.ToString();
|
var userId = _userContext.UserId.ToString();
|
||||||
|
|
||||||
|
// Fetch fresh user info from repository
|
||||||
|
var user = await _userRepository.GetByIdAsync(_userContext.UserId);
|
||||||
|
|
||||||
var displayName = Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
|
var displayName = user?.DisplayName ?? Context.User?.FindFirstValue("name") ?? Context.User?.FindFirstValue(ClaimTypes.Name) ?? "User";
|
||||||
var avatar = Context.User?.FindFirstValue("avatar");
|
var avatar = user?.Avatar ?? Context.User?.FindFirstValue("avatar");
|
||||||
var username = Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name ?? "user";
|
var username = user?.Username ?? Context.User?.FindFirstValue("unique_name") ?? Context.User?.Identity?.Name ?? "user";
|
||||||
|
|
||||||
var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
|
var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
|
||||||
|
|
||||||
var participants = _groupCallParticipants.GetOrAdd(chatId, _ => new ConcurrentDictionary<string, ParticipantInfo>());
|
var participants = _groupCallParticipants.GetOrAdd(chatId, _ =>
|
||||||
|
{
|
||||||
|
_activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType);
|
||||||
|
return new ConcurrentDictionary<string, ParticipantInfo>();
|
||||||
|
});
|
||||||
var isFirst = participants.IsEmpty;
|
var isFirst = participants.IsEmpty;
|
||||||
participants.TryAdd(userId, userInfo);
|
participants.TryAdd(userId, userInfo);
|
||||||
|
|
||||||
@@ -461,6 +635,11 @@ public sealed class ChatHub : Hub
|
|||||||
if (participants.IsEmpty)
|
if (participants.IsEmpty)
|
||||||
{
|
{
|
||||||
_groupCallParticipants.TryRemove(chatId, out _);
|
_groupCallParticipants.TryRemove(chatId, out _);
|
||||||
|
if (_activeGroupCalls.TryRemove(chatId, out var info))
|
||||||
|
{
|
||||||
|
var duration = (int)(DateTime.UtcNow - info.StartTime).TotalSeconds;
|
||||||
|
await CreateCallMessage(Guid.Parse(chatId), _userContext.UserId, info.CallType, "completed", duration);
|
||||||
|
}
|
||||||
await Clients.Group(chatId).SendAsync("group_call_ended", new { chatId = chatId });
|
await Clients.Group(chatId).SendAsync("group_call_ended", new { chatId = chatId });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -549,7 +728,7 @@ public sealed class ChatHub : Hub
|
|||||||
await Clients.Group(request.ChatId).SendAsync("group_call_status_updated", new
|
await Clients.Group(request.ChatId).SendAsync("group_call_status_updated", new
|
||||||
{
|
{
|
||||||
chatId = request.ChatId,
|
chatId = request.ChatId,
|
||||||
userId = Context.UserIdentifier,
|
userId = _userContext.UserId.ToString(),
|
||||||
isMuted = request.IsMuted,
|
isMuted = request.IsMuted,
|
||||||
isVideoOff = request.IsVideoOff
|
isVideoOff = request.IsVideoOff
|
||||||
});
|
});
|
||||||
@@ -576,7 +755,7 @@ public sealed class ChatHub : Hub
|
|||||||
await Clients.Group(chatId).SendAsync("group_call_status_updated", new
|
await Clients.Group(chatId).SendAsync("group_call_status_updated", new
|
||||||
{
|
{
|
||||||
chatId = chatId,
|
chatId = chatId,
|
||||||
userId = Context.UserIdentifier,
|
userId = _userContext.UserId.ToString(),
|
||||||
isMuted = isMuted,
|
isMuted = isMuted,
|
||||||
isVideoOff = isVideoOff
|
isVideoOff = isVideoOff
|
||||||
});
|
});
|
||||||
@@ -660,7 +839,10 @@ public sealed class ChatHub : Hub
|
|||||||
List<AttachmentHubRequest>? Attachments = null,
|
List<AttachmentHubRequest>? Attachments = null,
|
||||||
Guid? ReplyToId = null,
|
Guid? ReplyToId = null,
|
||||||
string? Quote = null,
|
string? Quote = null,
|
||||||
Guid? ForwardedFromId = null);
|
Guid? ForwardedFromId = null,
|
||||||
|
List<string>? PollOptions = null,
|
||||||
|
bool? PollIsAnonymous = null,
|
||||||
|
bool? PollAllowMultipleAnswers = null);
|
||||||
public record ReadMessagesRequest(Guid ChatId, Guid LastReadMessageId, long LastReadSequenceId);
|
public record ReadMessagesRequest(Guid ChatId, Guid LastReadMessageId, long LastReadSequenceId);
|
||||||
public record CallOfferRequest(string TargetUserId, object Offer, string CallType, string? ChatId);
|
public record CallOfferRequest(string TargetUserId, object Offer, string CallType, string? ChatId);
|
||||||
public record CallAnswerRequest(string TargetUserId, object Answer);
|
public record CallAnswerRequest(string TargetUserId, object Answer);
|
||||||
@@ -672,6 +854,7 @@ public sealed class ChatHub : Hub
|
|||||||
public record CallStatusRequest(string TargetUserId, bool IsMuted, bool IsVideoOff);
|
public record CallStatusRequest(string TargetUserId, bool IsMuted, bool IsVideoOff);
|
||||||
public record AddReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
public record AddReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
||||||
public record RemoveReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
public record RemoveReactionRequest(Guid MessageId, Guid ChatId, string Emoji);
|
||||||
|
public record PinMessageRequest(Guid MessageId, Guid ChatId);
|
||||||
public record DeleteMessagesHubRequest(Guid ChatId, List<string> MessageIds, bool DeleteForAll);
|
public record DeleteMessagesHubRequest(Guid ChatId, List<string> MessageIds, bool DeleteForAll);
|
||||||
public record GroupCallJoinRequest(string ChatId, string CallType);
|
public record GroupCallJoinRequest(string ChatId, string CallType);
|
||||||
public record ParticipantInfo(string Id, string Username, string DisplayName, string? Avatar, bool IsSharingScreen = false, bool IsMuted = false, bool IsVideoOff = false);
|
public record ParticipantInfo(string Id, string Username, string DisplayName, string? Avatar, bool IsSharingScreen = false, bool IsMuted = false, bool IsVideoOff = false);
|
||||||
@@ -684,5 +867,27 @@ public sealed class ChatHub : Hub
|
|||||||
public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer);
|
public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer);
|
||||||
public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
||||||
public record FriendSignalRequest(string FriendId);
|
public record FriendSignalRequest(string FriendId);
|
||||||
|
public record VotePollRequest(Guid MessageId, Guid ChatId, Guid OptionId);
|
||||||
|
public record EditMessageHubRequest(Guid MessageId, Guid ChatId, string Content);
|
||||||
|
|
||||||
|
public class CallSession
|
||||||
|
{
|
||||||
|
public Guid? ChatId { get; }
|
||||||
|
public Guid FromUserId { get; }
|
||||||
|
public Guid ToUserId { get; }
|
||||||
|
public string CallType { get; }
|
||||||
|
public DateTime StartTime { get; }
|
||||||
|
public bool IsAnswered { get; set; }
|
||||||
|
public DateTime? AnswerTime { get; set; }
|
||||||
|
|
||||||
|
public CallSession(Guid? chatId, Guid fromUserId, Guid toUserId, string callType, DateTime startTime)
|
||||||
|
{
|
||||||
|
ChatId = chatId;
|
||||||
|
FromUserId = fromUserId;
|
||||||
|
ToUserId = toUserId;
|
||||||
|
CallType = callType;
|
||||||
|
StartTime = startTime;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,4 +5,23 @@ using System.Threading;
|
|||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
public class MessageNotifier : IMessageNotifier { private readonly IHubContext<ChatHub> _hubContext; public MessageNotifier(IHubContext<ChatHub> hubContext) { _hubContext = hubContext; } public Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken) { return _hubContext.Clients.Group(chatId.ToString()).SendAsync("new_message", messagePayload, cancellationToken); } }
|
|
||||||
|
public class MessageNotifier : IMessageNotifier
|
||||||
|
{
|
||||||
|
private readonly IHubContext<ChatHub> _hubContext;
|
||||||
|
|
||||||
|
public MessageNotifier(IHubContext<ChatHub> hubContext)
|
||||||
|
{
|
||||||
|
_hubContext = hubContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyNewMessageAsync(Guid chatId, object messagePayload, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _hubContext.Clients.Group(chatId.ToString()).SendAsync("new_message", messagePayload, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task NotifyMessageUpdateAsync(Guid chatId, string updateType, object updatePayload, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _hubContext.Clients.Group(chatId.ToString()).SendAsync(updateType, updatePayload, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Generated
+3
-3
@@ -26,7 +26,7 @@ namespace Knot.Modules.Conversations.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -56,9 +56,9 @@ namespace Knot.Modules.Conversations.Migrations
|
|||||||
b.ToTable("Chats", "chats");
|
b.ToTable("Chats", "chats");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
|
||||||
{
|
{
|
||||||
b.OwnsMany("Knot.Modules.Conversations.Domain.ChatMember", "Members", b1 =>
|
b.OwnsMany("Knot.Contracts.Conversations.Domain.ChatMember", "Members", b1 =>
|
||||||
{
|
{
|
||||||
b1.Property<Guid>("Id")
|
b1.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
|
|||||||
Generated
+166
@@ -0,0 +1,166 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ChatsDbContext))]
|
||||||
|
[Migration("20260406122703_AddIsImportingToChat")]
|
||||||
|
partial class AddIsImportingToChat
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("chats")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.4")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Avatar")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsImporting")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<long>("LastMessageSequenceId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Chats", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Folder", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Icon")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDefault")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Folders", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.UserChatSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChatId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("FolderIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "ChatId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("UserChatSettings", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
|
||||||
|
{
|
||||||
|
b.OwnsMany("Knot.Contracts.Conversations.Domain.ChatMember", "Members", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<Guid>("ChatId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b1.Property<DateTime>("JoinedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b1.Property<Guid?>("LastDeliveredMessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<Guid?>("LastReadMessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<long>("LastReadSequenceId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b1.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b1.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.HasKey("Id");
|
||||||
|
|
||||||
|
b1.HasIndex("ChatId", "UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b1.ToTable("ChatMembers", "chats");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("ChatId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Members");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddIsImportingToChat : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsImporting",
|
||||||
|
schema: "chats",
|
||||||
|
table: "Chats",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsImporting",
|
||||||
|
schema: "chats",
|
||||||
|
table: "Chats");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+169
@@ -0,0 +1,169 @@
|
|||||||
|
// <auto-generated />
|
||||||
|
using System;
|
||||||
|
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||||
|
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Migrations
|
||||||
|
{
|
||||||
|
[DbContext(typeof(ChatsDbContext))]
|
||||||
|
[Migration("20260406123444_AddImportJobIdToChat")]
|
||||||
|
partial class AddImportJobIdToChat
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
#pragma warning disable 612, 618
|
||||||
|
modelBuilder
|
||||||
|
.HasDefaultSchema("chats")
|
||||||
|
.HasAnnotation("ProductVersion", "10.0.4")
|
||||||
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Avatar")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<DateTime>("CreatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<string>("Description")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ImportJobId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("IsImporting")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<long>("LastMessageSequenceId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Chats", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Folder", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Icon")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDefault")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Folders", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.UserChatSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChatId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("FolderIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "ChatId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("UserChatSettings", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
|
||||||
|
{
|
||||||
|
b.OwnsMany("Knot.Contracts.Conversations.Domain.ChatMember", "Members", b1 =>
|
||||||
|
{
|
||||||
|
b1.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<Guid>("ChatId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b1.Property<DateTime>("JoinedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b1.Property<Guid?>("LastDeliveredMessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<Guid?>("LastReadMessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.Property<long>("LastReadSequenceId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b1.Property<string>("Role")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b1.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b1.HasKey("Id");
|
||||||
|
|
||||||
|
b1.HasIndex("ChatId", "UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b1.ToTable("ChatMembers", "chats");
|
||||||
|
|
||||||
|
b1.WithOwner()
|
||||||
|
.HasForeignKey("ChatId");
|
||||||
|
});
|
||||||
|
|
||||||
|
b.Navigation("Members");
|
||||||
|
});
|
||||||
|
#pragma warning restore 612, 618
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Knot.Modules.Conversations.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddImportJobIdToChat : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<Guid>(
|
||||||
|
name: "ImportJobId",
|
||||||
|
schema: "chats",
|
||||||
|
table: "Chats",
|
||||||
|
type: "uuid",
|
||||||
|
nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "ImportJobId",
|
||||||
|
schema: "chats",
|
||||||
|
table: "Chats");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ namespace Knot.Modules.Conversations.Migrations
|
|||||||
|
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
@@ -38,6 +38,12 @@ namespace Knot.Modules.Conversations.Migrations
|
|||||||
b.Property<string>("Description")
|
b.Property<string>("Description")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<Guid?>("ImportJobId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<bool>("IsImporting")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<long>("LastMessageSequenceId")
|
b.Property<long>("LastMessageSequenceId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
@@ -53,9 +59,61 @@ namespace Knot.Modules.Conversations.Migrations
|
|||||||
b.ToTable("Chats", "chats");
|
b.ToTable("Chats", "chats");
|
||||||
});
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Knot.Modules.Conversations.Domain.Chat", b =>
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Folder", b =>
|
||||||
{
|
{
|
||||||
b.OwnsMany("Knot.Modules.Conversations.Domain.ChatMember", "Members", b1 =>
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("Icon")
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsDefault")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<string>("Name")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<string>("Type")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.ToTable("Folders", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.UserChatSettings", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<Guid>("ChatId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<string>("FolderIds")
|
||||||
|
.IsRequired()
|
||||||
|
.HasColumnType("text");
|
||||||
|
|
||||||
|
b.Property<bool>("IsMuted")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<Guid>("UserId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "ChatId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.ToTable("UserChatSettings", "chats");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Knot.Contracts.Conversations.Domain.Chat", b =>
|
||||||
|
{
|
||||||
|
b.OwnsMany("Knot.Contracts.Conversations.Domain.ChatMember", "Members", b1 =>
|
||||||
{
|
{
|
||||||
b1.Property<Guid>("Id")
|
b1.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ using Knot.Modules.Conversations.Application.Chats.Members;
|
|||||||
using Knot.Modules.Conversations.Application.Chats.TogglePin;
|
using Knot.Modules.Conversations.Application.Chats.TogglePin;
|
||||||
using Knot.Modules.Conversations.Application.Chats.Update;
|
using Knot.Modules.Conversations.Application.Chats.Update;
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
using Knot.Modules.Conversations.Domain;
|
using Knot.Contracts.Conversations.Domain;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
|
|||||||
+8
-1
@@ -87,7 +87,14 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
|
|||||||
readBy = new List<object>(),
|
readBy = new List<object>(),
|
||||||
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,
|
||||||
|
callType = (message as CallMessage)?.CallType,
|
||||||
|
callStatus = (message as CallMessage)?.CallStatus,
|
||||||
|
duration = (message as CallMessage)?.Duration,
|
||||||
|
pollOptions = (message as PollMessage)?.Options.Select(o => new { id = o.Id, text = o.Text, voteCount = o.VoteCount }).ToList(),
|
||||||
|
pollIsMultipleChoice = (message as PollMessage)?.IsMultipleChoice,
|
||||||
|
pollIsClosed = (message as PollMessage)?.IsClosed,
|
||||||
|
pollIsAnonymous = (message as PollMessage)?.IsAnonymous
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,18 @@ public sealed class MessageQueryService : IMessageQueryService
|
|||||||
|
|
||||||
public async Task<List<MessageInfo>> GetOrphanedMessagesAsync(HashSet<Guid> activeChatIds, CancellationToken cancellationToken)
|
public async Task<List<MessageInfo>> GetOrphanedMessagesAsync(HashSet<Guid> activeChatIds, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
if (activeChatIds == null || activeChatIds.Count == 0)
|
||||||
|
{
|
||||||
|
var allMessages = await _messages.Find(_ => true).ToListAsync(cancellationToken);
|
||||||
|
return allMessages.Select(m => new MessageInfo(
|
||||||
|
m.Id,
|
||||||
|
m.ChatId,
|
||||||
|
m.State.HasFlag(MessageState.IsDeleted),
|
||||||
|
m is MediaMessage mm && mm.Media.Any() ? mm.Media.First().Url : null,
|
||||||
|
m is MediaMessage mm2 && mm2.Media.Any() ? mm2.Media.Select(media => new Contracts.Messaging.Application.Abstractions.MediaInfo(media.Url)).ToList() : null
|
||||||
|
)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
var builder = Builders<Message>.Filter;
|
var builder = Builders<Message>.Filter;
|
||||||
var inFilter = builder.In(m => m.ChatId, activeChatIds);
|
var inFilter = builder.In(m => m.ChatId, activeChatIds);
|
||||||
var filter = builder.Not(inFilter);
|
var filter = builder.Not(inFilter);
|
||||||
|
|||||||
@@ -62,22 +62,69 @@ public sealed class MessageRepository : IMessageRepository
|
|||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, int limit, CancellationToken cancellationToken)
|
public async Task<List<Message>> GetPinnedMessagesAsync(Guid chatId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var builder = Builders<Message>.Filter;
|
||||||
|
var filter = builder.And(
|
||||||
|
builder.Eq(m => m.ChatId, chatId),
|
||||||
|
builder.BitsAnySet(m => m.State, (long)MessageState.IsPinned)
|
||||||
|
);
|
||||||
|
|
||||||
|
return await _messages.Find(filter)
|
||||||
|
.SortByDescending(m => m.CreatedAt)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var builder = Builders<Message>.Filter;
|
var builder = Builders<Message>.Filter;
|
||||||
var filter = builder.Eq(m => m.ChatId, chatId);
|
var filter = builder.Eq(m => m.ChatId, chatId);
|
||||||
|
|
||||||
if (cursor.HasValue)
|
if (sequenceId.HasValue)
|
||||||
|
{
|
||||||
|
filter &= builder.Lt(m => m.SequenceId, sequenceId.Value);
|
||||||
|
}
|
||||||
|
else if (cursor.HasValue)
|
||||||
{
|
{
|
||||||
filter &= builder.Lt(m => m.CreatedAt, cursor.Value);
|
filter &= builder.Lt(m => m.CreatedAt, cursor.Value);
|
||||||
}
|
}
|
||||||
|
|
||||||
return await _messages.Find(filter)
|
return await _messages.Find(filter)
|
||||||
.SortByDescending(m => m.CreatedAt)
|
.SortByDescending(m => m.SequenceId)
|
||||||
.Limit(limit)
|
.Limit(limit)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var builder = Builders<Message>.Filter;
|
||||||
|
|
||||||
|
// Target message
|
||||||
|
var targetFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Eq(m => m.SequenceId, sequenceId));
|
||||||
|
var targetMsg = await _messages.Find(targetFilter).FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Older messages
|
||||||
|
var olderFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Lt(m => m.SequenceId, sequenceId));
|
||||||
|
var older = await _messages.Find(olderFilter)
|
||||||
|
.SortByDescending(m => m.SequenceId)
|
||||||
|
.Limit(limit / 2)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// Newer messages
|
||||||
|
var newerFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Gt(m => m.SequenceId, sequenceId));
|
||||||
|
var newer = await _messages.Find(newerFilter)
|
||||||
|
.SortBy(m => m.SequenceId)
|
||||||
|
.Limit(limit / 2)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var result = new List<Message>();
|
||||||
|
result.AddRange(older);
|
||||||
|
if (targetMsg != null) result.Add(targetMsg);
|
||||||
|
result.AddRange(newer);
|
||||||
|
|
||||||
|
return result.OrderBy(m => m.SequenceId).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken)
|
public async Task<List<Message>> SearchMessagesAsync(string query, Guid? chatId, Guid requestingUserId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
// Not ideal for SQL/Mongo combination but keeping the signature
|
// Not ideal for SQL/Mongo combination but keeping the signature
|
||||||
|
|||||||
+7
@@ -71,6 +71,13 @@ public static class MongoDbMapConfigurator
|
|||||||
cm.SetDiscriminator("PollMessage");
|
cm.SetDiscriminator("PollMessage");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
BsonClassMap.RegisterClassMap<CallMessage>(cm =>
|
||||||
|
{
|
||||||
|
cm.AutoMap();
|
||||||
|
cm.SetIgnoreExtraElements(true);
|
||||||
|
cm.SetDiscriminator("CallMessage");
|
||||||
|
});
|
||||||
|
|
||||||
BsonClassMap.RegisterClassMap<PollOption>(cm => cm.AutoMap());
|
BsonClassMap.RegisterClassMap<PollOption>(cm => cm.AutoMap());
|
||||||
BsonClassMap.RegisterClassMap<PollVote>(cm => cm.AutoMap());
|
BsonClassMap.RegisterClassMap<PollVote>(cm => cm.AutoMap());
|
||||||
|
|
||||||
|
|||||||
@@ -10,4 +10,5 @@ public interface ITelegramHtmlParser
|
|||||||
{
|
{
|
||||||
Task<List<TelegramMessage>> ParseMessagesAsync(Stream htmlStream, string baseDirInZip, CancellationToken ct = default);
|
Task<List<TelegramMessage>> ParseMessagesAsync(Stream htmlStream, string baseDirInZip, CancellationToken ct = default);
|
||||||
Task<List<string>> ExtractAllUserNamesAsync(Stream htmlStream, CancellationToken ct = default);
|
Task<List<string>> ExtractAllUserNamesAsync(Stream htmlStream, CancellationToken ct = default);
|
||||||
|
Task<string?> ExtractGroupNameAsync(Stream htmlStream, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
+68
-53
@@ -10,6 +10,7 @@ using AngleSharp.Dom;
|
|||||||
using AngleSharp.Html.Parser;
|
using AngleSharp.Html.Parser;
|
||||||
using Knot.Contracts.Settings.Application.Abstractions;
|
using Knot.Contracts.Settings.Application.Abstractions;
|
||||||
using Knot.Contracts.Settings.Application.DTOs;
|
using Knot.Contracts.Settings.Application.DTOs;
|
||||||
|
using Knot.Modules.TelegramImport.Application.Abstractions;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
@@ -25,18 +26,23 @@ public record ImportConflictDto(string Type, string Message, bool Blocked);
|
|||||||
public record AnalyzeImportResponseDto(
|
public record AnalyzeImportResponseDto(
|
||||||
Guid Token,
|
Guid Token,
|
||||||
List<string> Names,
|
List<string> Names,
|
||||||
List<ImportConflictDto> Conflicts);
|
List<ImportConflictDto> Conflicts,
|
||||||
|
int TotalMessages,
|
||||||
|
string? GroupName);
|
||||||
|
|
||||||
public record AnalyzeImportCommand(Stream FileStream, string FileName) : ICommand<AnalyzeImportResponseDto>;
|
public record AnalyzeImportCommand(Stream FileStream, string FileName) : ICommand<AnalyzeImportResponseDto>;
|
||||||
|
|
||||||
internal sealed class AnalyzeImportCommandHandler : ICommandHandler<AnalyzeImportCommand, AnalyzeImportResponseDto>
|
internal sealed class AnalyzeImportCommandHandler : ICommandHandler<AnalyzeImportCommand, AnalyzeImportResponseDto>
|
||||||
{
|
{
|
||||||
private readonly ISettingsService _settingsService;
|
private readonly ISettingsService _settingsService;
|
||||||
|
private readonly ITelegramHtmlParser _htmlParser;
|
||||||
|
|
||||||
public AnalyzeImportCommandHandler(ISettingsService settingsService)
|
public AnalyzeImportCommandHandler(ISettingsService settingsService, ITelegramHtmlParser htmlParser)
|
||||||
{
|
{
|
||||||
_settingsService = settingsService;
|
_settingsService = settingsService;
|
||||||
|
_htmlParser = htmlParser;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<AnalyzeImportResponseDto>> Handle(AnalyzeImportCommand request, CancellationToken cancellationToken)
|
public async Task<Result<AnalyzeImportResponseDto>> Handle(AnalyzeImportCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (request.FileStream == null || request.FileStream.Length == 0)
|
if (request.FileStream == null || request.FileStream.Length == 0)
|
||||||
@@ -52,68 +58,77 @@ internal sealed class AnalyzeImportCommandHandler : ICommandHandler<AnalyzeImpor
|
|||||||
var token = Guid.NewGuid();
|
var token = Guid.NewGuid();
|
||||||
var tempPath = Path.Combine(Path.GetTempPath(), $"{token}.zip");
|
var tempPath = Path.Combine(Path.GetTempPath(), $"{token}.zip");
|
||||||
|
|
||||||
await using (var fs = new FileStream(tempPath, FileMode.Create))
|
try
|
||||||
{
|
{
|
||||||
await request.FileStream.CopyToAsync(fs, cancellationToken);
|
await using (var fs = new FileStream(tempPath, FileMode.Create))
|
||||||
}
|
|
||||||
|
|
||||||
var names = new HashSet<string>();
|
|
||||||
|
|
||||||
using (var archive = ZipFile.OpenRead(tempPath))
|
|
||||||
{
|
|
||||||
var htmlEntries = archive.Entries
|
|
||||||
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
foreach (var entry in htmlEntries)
|
|
||||||
{
|
{
|
||||||
using var stream = entry.Open();
|
await request.FileStream.CopyToAsync(fs, cancellationToken);
|
||||||
var parser = new HtmlParser();
|
}
|
||||||
var doc = parser.ParseDocument(stream);
|
|
||||||
|
|
||||||
var messageNodes = doc.QuerySelectorAll(".message");
|
var names = new HashSet<string>();
|
||||||
if (messageNodes == null)
|
int totalMessages = 0;
|
||||||
{
|
string? groupName = null;
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (var node in messageNodes)
|
using (var archive = ZipFile.OpenRead(tempPath))
|
||||||
|
{
|
||||||
|
var htmlEntries = archive.Entries
|
||||||
|
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
(e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase) || e.Name == "export_results.html"))
|
||||||
|
.OrderBy(e => e.FullName.Length)
|
||||||
|
.ThenBy(e => e.FullName)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
foreach (var entry in htmlEntries)
|
||||||
{
|
{
|
||||||
var fromNameNode = node.QuerySelector(".from_name");
|
using var stream = entry.Open();
|
||||||
if (fromNameNode != null)
|
|
||||||
|
if (string.IsNullOrEmpty(groupName))
|
||||||
{
|
{
|
||||||
var nameNodeText = (IElement)fromNameNode.Clone();
|
groupName = await _htmlParser.ExtractGroupNameAsync(stream, cancellationToken);
|
||||||
var innerSpans = nameNodeText.QuerySelectorAll("span");
|
// Reset stream position if possible? No, entry.Open() returns a new stream.
|
||||||
foreach (var span in innerSpans)
|
// But wait! ExtractGroupNameAsync consumess the stream!
|
||||||
{
|
// I'll reopen it for messages if it's the same entry.
|
||||||
span.Remove();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var name = nameNodeText.TextContent.Trim();
|
using var stream2 = entry.Open();
|
||||||
if (!string.IsNullOrWhiteSpace(name))
|
var messages = await _htmlParser.ParseMessagesAsync(stream2, "", cancellationToken);
|
||||||
{
|
totalMessages += messages.Count;
|
||||||
names.Add(name);
|
|
||||||
}
|
foreach (var m in messages)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrEmpty(m.SenderName))
|
||||||
|
names.Add(m.SenderName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (names.Count == 0 && totalMessages == 0)
|
||||||
|
{
|
||||||
|
return Result.Failure<AnalyzeImportResponseDto>(new Error("TelegramImport.NoMessagesFound", "В архиве не найдено сообщений Telegram или формат HTML не распознан."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Анализ конфликтов политик
|
||||||
|
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
|
||||||
|
var conflicts = new List<ImportConflictDto>();
|
||||||
|
|
||||||
|
if (!settings.Messages.AllowMedia)
|
||||||
|
conflicts.Add(new ImportConflictDto("Media", "Медиафайлы (фото/видео) отключены на сервере. Они не будут импортированы.", true));
|
||||||
|
|
||||||
|
if (!settings.Messages.AllowVoiceMessages)
|
||||||
|
conflicts.Add(new ImportConflictDto("Voice", "Голосовые сообщения запрещены администратором. Будут пропущены.", true));
|
||||||
|
|
||||||
|
if (!settings.Messages.AllowPolls)
|
||||||
|
conflicts.Add(new ImportConflictDto("Polls", "Опросы не поддерживаются текущими настройками сервера.", true));
|
||||||
|
|
||||||
|
TelegramImportState.TempZips[token] = tempPath;
|
||||||
|
|
||||||
|
return Result.Success(new AnalyzeImportResponseDto(token, names.OrderBy(x => x).ToList(), conflicts, totalMessages, groupName));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
if (File.Exists(tempPath)) File.Delete(tempPath);
|
||||||
|
return Result.Failure<AnalyzeImportResponseDto>(new Error("TelegramImport.ProcessError", $"Ошибка при обработке архива: {ex.Message}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Анализ конфликтов политик
|
|
||||||
var settings = await _settingsService.GetSettingsAsync(cancellationToken);
|
|
||||||
var conflicts = new List<ImportConflictDto>();
|
|
||||||
|
|
||||||
if (!settings.Messages.AllowMedia)
|
|
||||||
conflicts.Add(new ImportConflictDto("Media", "Медиафайлы (фото/видео) отключены на сервере. Они не будут импортированы.", true));
|
|
||||||
|
|
||||||
if (!settings.Messages.AllowVoiceMessages)
|
|
||||||
conflicts.Add(new ImportConflictDto("Voice", "Голосовые сообщения запрещены администратором. Будут пропущены.", true));
|
|
||||||
|
|
||||||
if (!settings.Messages.AllowPolls)
|
|
||||||
conflicts.Add(new ImportConflictDto("Polls", "Опросы не поддерживаются текущими настройками сервера.", true));
|
|
||||||
|
|
||||||
TelegramImportState.TempZips[token] = tempPath;
|
|
||||||
|
|
||||||
return Result.Success(new AnalyzeImportResponseDto(token, names.ToList(), conflicts));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -6,7 +6,8 @@ namespace Knot.Modules.TelegramImport.Application.TelegramImport.DTOs;
|
|||||||
public record ExecuteImportRequest(
|
public record ExecuteImportRequest(
|
||||||
Guid Token,
|
Guid Token,
|
||||||
Dictionary<string, Guid> Mapping,
|
Dictionary<string, Guid> Mapping,
|
||||||
string? GroupName
|
string? GroupName,
|
||||||
|
int TotalMessages = 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -11,8 +11,9 @@ public record TelegramMessage(
|
|||||||
string? ReplyToId = null,
|
string? ReplyToId = null,
|
||||||
string? ForwardedFrom = null,
|
string? ForwardedFrom = null,
|
||||||
List<TelegramMedia>? Media = null,
|
List<TelegramMedia>? Media = null,
|
||||||
List<TelegramReaction>? Reactions = null
|
List<TelegramReaction>? Reactions = null,
|
||||||
|
long OrderIndex = 0
|
||||||
);
|
);
|
||||||
|
|
||||||
public record TelegramMedia(string FilePath, string FileName, string MimeType);
|
public record TelegramMedia(string FilePath, string FileName, string MimeType, string? Duration = null);
|
||||||
public record TelegramReaction(string Emoji, List<string> UserNames);
|
public record TelegramReaction(string Emoji, List<string> UserNames);
|
||||||
|
|||||||
+73
-9
@@ -1,30 +1,47 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.IO.Compression;
|
||||||
|
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.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Conversations.Domain;
|
||||||
|
using Knot.Modules.TelegramImport.Application.Abstractions;
|
||||||
using Knot.Modules.TelegramImport.Infrastructure.Background;
|
using Knot.Modules.TelegramImport.Infrastructure.Background;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
namespace Knot.Modules.TelegramImport.Application.TelegramImport;
|
namespace Knot.Modules.TelegramImport.Application.TelegramImport;
|
||||||
|
|
||||||
public record ExecuteImportResponseDto(Guid JobId, string Status);
|
public record ExecuteImportResponseDto(Guid JobId, string Status, Guid ChatId);
|
||||||
|
|
||||||
public record ExecuteImportCommand(
|
public record ExecuteImportCommand(
|
||||||
Guid CurrentUserId,
|
Guid CurrentUserId,
|
||||||
Guid Token,
|
Guid Token,
|
||||||
|
string? GroupName,
|
||||||
Dictionary<string, Guid> Mapping,
|
Dictionary<string, Guid> Mapping,
|
||||||
string? GroupName
|
int TotalMessages = 0
|
||||||
) : ICommand<ExecuteImportResponseDto>;
|
) : ICommand<ExecuteImportResponseDto>;
|
||||||
|
|
||||||
internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImportCommand, ExecuteImportResponseDto>
|
internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImportCommand, ExecuteImportResponseDto>
|
||||||
{
|
{
|
||||||
private readonly TelegramImportWorker _worker;
|
private readonly TelegramImportWorker _worker;
|
||||||
private readonly IImportJobStore _jobStore;
|
private readonly IImportJobStore _jobStore;
|
||||||
|
private readonly IChatRepository _chatRepository;
|
||||||
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
|
private readonly ITelegramHtmlParser _htmlParser;
|
||||||
|
|
||||||
public ExecuteImportCommandHandler(TelegramImportWorker worker, IImportJobStore jobStore)
|
public ExecuteImportCommandHandler(
|
||||||
|
TelegramImportWorker worker,
|
||||||
|
IImportJobStore jobStore,
|
||||||
|
IChatRepository chatRepository,
|
||||||
|
IChatsUnitOfWork unitOfWork,
|
||||||
|
ITelegramHtmlParser htmlParser)
|
||||||
{
|
{
|
||||||
_worker = worker;
|
_worker = worker;
|
||||||
_jobStore = jobStore;
|
_jobStore = jobStore;
|
||||||
|
_chatRepository = chatRepository;
|
||||||
|
_unitOfWork = unitOfWork;
|
||||||
|
_htmlParser = htmlParser;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<ExecuteImportResponseDto>> Handle(ExecuteImportCommand request, CancellationToken cancellationToken)
|
public async Task<Result<ExecuteImportResponseDto>> Handle(ExecuteImportCommand request, CancellationToken cancellationToken)
|
||||||
@@ -34,19 +51,66 @@ internal sealed class ExecuteImportCommandHandler : ICommandHandler<ExecuteImpor
|
|||||||
return Result.Failure<ExecuteImportResponseDto>(new Error("Import.Expired", "Import session expired or file not found."));
|
return Result.Failure<ExecuteImportResponseDto>(new Error("Import.Expired", "Import session expired or file not found."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var groupName = request.GroupName;
|
||||||
|
if (string.IsNullOrEmpty(groupName))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
using var archive = ZipFile.OpenRead(tempPath);
|
||||||
|
var firstHtml = archive.Entries.FirstOrDefault(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase));
|
||||||
|
if (firstHtml != null)
|
||||||
|
{
|
||||||
|
using var stream = firstHtml.Open();
|
||||||
|
groupName = await _htmlParser.ExtractGroupNameAsync(stream, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Создаем чат сразу (синхронно), чтобы пользователь его увидел
|
||||||
|
// 1. Создаем чат сразу (синхронно), чтобы пользователь его увидел
|
||||||
|
var memberIdList = request.Mapping.Values.Where(v => v != Guid.Empty).Distinct().ToList();
|
||||||
|
if (!memberIdList.Contains(request.CurrentUserId)) memberIdList.Add(request.CurrentUserId);
|
||||||
var jobId = Guid.NewGuid();
|
var jobId = Guid.NewGuid();
|
||||||
|
|
||||||
// Ставим задачу в фоне. Worker сам удалит файл и обновит статус.
|
// Решаем какой тип чата: если 2 участника или 1 участник (Saved Messages)
|
||||||
// Мы не ждем завершения, а возвращаем JobId мгновенно.
|
bool isPersonal = memberIdList.Count <= 2;
|
||||||
_ = _worker.ProcessImportAsync(request, jobId, tempPath, CancellationToken.None);
|
var chatType = isPersonal ? ChatType.Personal : ChatType.Group;
|
||||||
|
|
||||||
|
string finalChatName = groupName ?? "Telegram Import";
|
||||||
|
if (isPersonal)
|
||||||
|
{
|
||||||
|
// Берем имя собеседника из маппинга
|
||||||
|
var otherUserId = memberIdList.FirstOrDefault(id => id != request.CurrentUserId);
|
||||||
|
var otherUserName = request.Mapping.FirstOrDefault(m => m.Value == otherUserId).Key;
|
||||||
|
if (!string.IsNullOrEmpty(otherUserName)) finalChatName = otherUserName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Создание чата (в скрытом состоянии)
|
||||||
|
var chat = Chat.Create(finalChatName, chatType, isImporting: true, importJobId: jobId);
|
||||||
|
chat.AddMember(request.CurrentUserId, ChatRole.Owner);
|
||||||
|
|
||||||
|
foreach (var memberId in memberIdList)
|
||||||
|
{
|
||||||
|
if (memberId != request.CurrentUserId)
|
||||||
|
{
|
||||||
|
chat.AddMember(memberId, ChatRole.Member);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_chatRepository.Add(chat);
|
||||||
|
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 3. Фоновая обработка сообщений
|
||||||
|
_ = _worker.ProcessImportAsync(request, jobId, chat.Id, tempPath, CancellationToken.None);
|
||||||
|
|
||||||
_jobStore.AddOrUpdate(new ImportJobInfo
|
_jobStore.AddOrUpdate(new ImportJobInfo
|
||||||
{
|
{
|
||||||
JobId = jobId,
|
JobId = jobId,
|
||||||
Status = ImportJobStatus.Queued,
|
Status = ImportJobStatus.Queued,
|
||||||
TotalMessages = 0
|
TotalMessages = request.TotalMessages
|
||||||
});
|
});
|
||||||
|
|
||||||
return Result.Success(new ExecuteImportResponseDto(jobId, "Queued"));
|
return Result.Success(new ExecuteImportResponseDto(jobId, "Processing", chat.Id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,6 @@
|
|||||||
|
using Knot.Modules.TelegramImport.Application.Abstractions;
|
||||||
|
using Knot.Modules.TelegramImport.Infrastructure.Background;
|
||||||
|
using Knot.Modules.TelegramImport.Infrastructure.Parser;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace Knot.Modules.TelegramImport;
|
namespace Knot.Modules.TelegramImport;
|
||||||
@@ -6,6 +9,13 @@ public static class DependencyInjection
|
|||||||
{
|
{
|
||||||
public static IServiceCollection AddTelegramImportModule(this IServiceCollection services)
|
public static IServiceCollection AddTelegramImportModule(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
|
services.AddSingleton<ITelegramHtmlParser, TelegramHtmlParser>();
|
||||||
|
services.AddSingleton<IImportJobStore, ImportJobStore>();
|
||||||
|
|
||||||
|
// Register worker as itself and as a hosted service
|
||||||
|
services.AddSingleton<TelegramImportWorker>();
|
||||||
|
services.AddHostedService<TelegramImportWorker>(sp => sp.GetRequiredService<TelegramImportWorker>());
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+171
-25
@@ -1,7 +1,10 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Collections.Generic;
|
||||||
using System.IO;
|
using System.IO;
|
||||||
using System.IO.Compression;
|
using System.IO.Compression;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||||
@@ -10,6 +13,7 @@ using Knot.Contracts.Messaging.Application.Abstractions;
|
|||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Modules.TelegramImport.Application.Abstractions;
|
using Knot.Modules.TelegramImport.Application.Abstractions;
|
||||||
using Knot.Modules.TelegramImport.Application.TelegramImport;
|
using Knot.Modules.TelegramImport.Application.TelegramImport;
|
||||||
|
using Knot.Modules.TelegramImport.Application.TelegramImport.DTOs;
|
||||||
using Knot.Modules.TelegramImport.Infrastructure.Background;
|
using Knot.Modules.TelegramImport.Infrastructure.Background;
|
||||||
using Knot.Shared.Kernel.Storage;
|
using Knot.Shared.Kernel.Storage;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -24,7 +28,10 @@ public class TelegramImportWorker : BackgroundService
|
|||||||
private readonly ILogger<TelegramImportWorker> _logger;
|
private readonly ILogger<TelegramImportWorker> _logger;
|
||||||
private readonly IImportJobStore _jobStore;
|
private readonly IImportJobStore _jobStore;
|
||||||
|
|
||||||
public TelegramImportWorker(IServiceProvider serviceProvider, ILogger<TelegramImportWorker> logger, IImportJobStore jobStore)
|
public TelegramImportWorker(
|
||||||
|
IServiceProvider serviceProvider,
|
||||||
|
ILogger<TelegramImportWorker> logger,
|
||||||
|
IImportJobStore jobStore)
|
||||||
{
|
{
|
||||||
_serviceProvider = serviceProvider;
|
_serviceProvider = serviceProvider;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -34,64 +41,203 @@ public class TelegramImportWorker : BackgroundService
|
|||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Telegram Import Worker started.");
|
_logger.LogInformation("Telegram Import Worker started.");
|
||||||
|
|
||||||
// В реальном проекте здесь будет чтение из Channels или RabbitMQ
|
|
||||||
// Для примера оставим заглушку цикла
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
await Task.Delay(5000, stoppingToken);
|
await Task.Delay(5000, stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task ProcessImportAsync(ExecuteImportCommand request, Guid jobId, string zipPath, CancellationToken ct)
|
public async Task ProcessImportAsync(ExecuteImportCommand request, Guid jobId, Guid chatId, string zipPath, CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
var parser = scope.ServiceProvider.GetRequiredService<ITelegramHtmlParser>();
|
var parser = scope.ServiceProvider.GetRequiredService<ITelegramHtmlParser>();
|
||||||
var msgRepo = scope.ServiceProvider.GetRequiredService<IMessageRepository>();
|
var msgRepo = scope.ServiceProvider.GetRequiredService<IMessageRepository>();
|
||||||
|
var reactionRepo = scope.ServiceProvider.GetRequiredService<IMessageReactionRepository>();
|
||||||
var chatRepo = scope.ServiceProvider.GetRequiredService<IChatRepository>();
|
var chatRepo = scope.ServiceProvider.GetRequiredService<IChatRepository>();
|
||||||
var uow = scope.ServiceProvider.GetRequiredService<IChatsUnitOfWork>();
|
var uow = scope.ServiceProvider.GetRequiredService<IChatsUnitOfWork>();
|
||||||
var fileStorage = scope.ServiceProvider.GetRequiredService<IFileStorageService>();
|
var fileStorage = scope.ServiceProvider.GetRequiredService<IFileStorageService>();
|
||||||
|
|
||||||
var jobInfo = new ImportJobInfo { JobId = jobId, Status = ImportJobStatus.Processing };
|
var jobInfo = new ImportJobInfo
|
||||||
|
{
|
||||||
|
JobId = jobId,
|
||||||
|
Status = ImportJobStatus.Processing,
|
||||||
|
TotalMessages = request.TotalMessages,
|
||||||
|
ProcessedMessages = 0
|
||||||
|
};
|
||||||
_jobStore.AddOrUpdate(jobInfo);
|
_jobStore.AddOrUpdate(jobInfo);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var archive = ZipFile.OpenRead(zipPath);
|
var chat = await chatRepo.GetByIdAsync(chatId, ct);
|
||||||
var entries = archive.Entries.Where(e => e.Name.StartsWith("messages") && e.Name.EndsWith(".html")).ToList();
|
if (chat == null) throw new Exception("Chat not found");
|
||||||
|
|
||||||
// 1. Создание чата (уже было в оригинале, но здесь в фоне)
|
_logger.LogInformation("Processing messages for ChatId: {ChatId}, JobId: {JobId}", chatId, jobId);
|
||||||
Guid targetChatId = Guid.NewGuid(); // Упростим логику для демонстрации рефакторинга
|
|
||||||
|
|
||||||
foreach (var entry in entries)
|
// TRY TO FIND THE BEST ENCODING (UTF8 or CP866)
|
||||||
|
ZipArchive archive;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
using var stream = entry.Open();
|
// Try UTF8 first
|
||||||
var messages = await parser.ParseMessagesAsync(stream, "", ct);
|
archive = ZipFile.OpenRead(zipPath);
|
||||||
|
var messagesHtml = archive.Entries.FirstOrDefault(e => e.Name.Equals("messages.html", StringComparison.OrdinalIgnoreCase));
|
||||||
foreach (var m in messages)
|
if (messagesHtml == null)
|
||||||
{
|
{
|
||||||
Guid senderGuid = request.Mapping.TryGetValue(m.SenderName ?? "", out var sid) ? sid : request.CurrentUserId;
|
// If not found in root, maybe it's CP866
|
||||||
|
archive.Dispose();
|
||||||
var textMsg = new TextMessage(Guid.NewGuid(), targetChatId, senderGuid, m.Content, null, null, null, m.CreatedAt, true);
|
archive = ZipFile.Open(zipPath, ZipArchiveMode.Read, Encoding.GetEncoding(866));
|
||||||
msgRepo.Add(textMsg);
|
|
||||||
|
|
||||||
jobInfo.ProcessedMessages++;
|
|
||||||
_jobStore.AddOrUpdate(jobInfo);
|
|
||||||
}
|
}
|
||||||
await uow.SaveChangesAsync(ct);
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
archive = ZipFile.OpenRead(zipPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
jobInfo.Status = ImportJobStatus.Completed;
|
using (archive)
|
||||||
|
{
|
||||||
|
var entries = archive.Entries
|
||||||
|
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
(e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
e.Name.Equals("export_results.html", StringComparison.OrdinalIgnoreCase)))
|
||||||
|
.OrderBy(e => e.FullName.Length)
|
||||||
|
.ThenBy(e => e.FullName)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var allMessages = new List<Knot.Modules.TelegramImport.Application.TelegramImport.DTOs.TelegramMessage>();
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
var entryPath = entry.FullName.Replace("\\", "/");
|
||||||
|
var lastSlash = entryPath.LastIndexOf('/');
|
||||||
|
var baseDir = lastSlash >= 0 ? entryPath.Substring(0, lastSlash + 1) : "";
|
||||||
|
|
||||||
|
using var stream = entry.Open();
|
||||||
|
var messages = await parser.ParseMessagesAsync(stream, baseDir, ct);
|
||||||
|
allMessages.AddRange(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
var orderedMessages = allMessages
|
||||||
|
.OrderBy(m => m.CreatedAt)
|
||||||
|
.ThenBy(m => m.OrderIndex)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
_logger.LogInformation("Total messages parsed: {Count}", orderedMessages.Count);
|
||||||
|
|
||||||
|
var telegramToKnotIdMap = new Dictionary<string, Guid>();
|
||||||
|
var processedCount = 0;
|
||||||
|
|
||||||
|
foreach (var m in orderedMessages)
|
||||||
|
{
|
||||||
|
Guid senderId = request.Mapping.TryGetValue(m.SenderName ?? "", out var sid) ? sid : request.CurrentUserId;
|
||||||
|
Guid? replyToId = m.ReplyToId != null && telegramToKnotIdMap.TryGetValue(m.ReplyToId, out var rid) ? rid : null;
|
||||||
|
|
||||||
|
Guid knotMsgId = Guid.NewGuid();
|
||||||
|
Message? knotMsg = null;
|
||||||
|
|
||||||
|
if (m.Media != null && m.Media.Any())
|
||||||
|
{
|
||||||
|
var mediaType = m.Media.Any(mi => mi.MimeType.StartsWith("image")) ? MediaType.Image :
|
||||||
|
m.Media.Any(mi => mi.MimeType.StartsWith("video")) ? MediaType.Video :
|
||||||
|
m.Media.Any(mi => mi.MimeType.Contains("voice")) ? MediaType.Voice : MediaType.File;
|
||||||
|
|
||||||
|
var mediaMsg = new MediaMessage(knotMsgId, chat.Id, senderId, mediaType, m.Content, replyToId, null, m.CreatedAt, true);
|
||||||
|
|
||||||
|
foreach (var mediaItem in m.Media)
|
||||||
|
{
|
||||||
|
var targetPath = mediaItem.FilePath.Replace("\\", "/").TrimStart('/');
|
||||||
|
var unescapedPath = Uri.UnescapeDataString(targetPath);
|
||||||
|
|
||||||
|
var zipEntry = archive.Entries.FirstOrDefault(e => {
|
||||||
|
var entryPath = e.FullName.Replace("\\", "/").TrimStart('/');
|
||||||
|
return entryPath.Equals(targetPath, StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
entryPath.Equals(unescapedPath, StringComparison.OrdinalIgnoreCase);
|
||||||
|
});
|
||||||
|
|
||||||
|
// IF NOT FOUND, try a desperate match by stripping path and comparing filenames
|
||||||
|
if (zipEntry == null)
|
||||||
|
{
|
||||||
|
var fileName = Path.GetFileName(targetPath);
|
||||||
|
zipEntry = archive.Entries.FirstOrDefault(e => e.Name.Equals(fileName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (zipEntry != null)
|
||||||
|
{
|
||||||
|
using var mediaStream = zipEntry.Open();
|
||||||
|
string itemType = "file";
|
||||||
|
string mimeToUpload = mediaItem.MimeType;
|
||||||
|
|
||||||
|
if (mediaItem.MimeType.Equals("image/gif", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
itemType = "gif";
|
||||||
|
if (mediaItem.FileName.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase)) mimeToUpload = "video/mp4";
|
||||||
|
}
|
||||||
|
else if (mediaItem.MimeType.StartsWith("image")) itemType = "image";
|
||||||
|
else if (mediaItem.MimeType.StartsWith("video")) itemType = "video";
|
||||||
|
else if (mediaItem.MimeType.Contains("voice") || mediaItem.MimeType.Contains("audio")) itemType = "audio";
|
||||||
|
|
||||||
|
var fileId = await fileStorage.UploadFileAsync(mediaStream, mediaItem.FileName, mimeToUpload);
|
||||||
|
mediaMsg.AddMedia(itemType, $"/api/files/{fileId}", mediaItem.FileName, zipEntry.Length, mediaItem.Duration);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("[Import] Media entry NOT FOUND in ZIP: {Path}", targetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mediaMsg.Media.Any()) knotMsg = mediaMsg;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (knotMsg == null)
|
||||||
|
{
|
||||||
|
knotMsg = new TextMessage(knotMsgId, chat.Id, senderId, m.Content, replyToId, null, null, m.CreatedAt, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
chat.IncrementSequenceId();
|
||||||
|
knotMsg.SetSequenceId(chat.LastMessageSequenceId);
|
||||||
|
msgRepo.Add(knotMsg);
|
||||||
|
|
||||||
|
if (m.Reactions != null)
|
||||||
|
{
|
||||||
|
foreach (var r in m.Reactions)
|
||||||
|
{
|
||||||
|
foreach (var name in r.UserNames)
|
||||||
|
{
|
||||||
|
var userId = request.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty ? mappedId : request.CurrentUserId;
|
||||||
|
var knotReaction = new MessageReaction(knotMsgId, userId, r.Emoji);
|
||||||
|
await reactionRepo.AddAsync(knotReaction, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
telegramToKnotIdMap[m.Id] = knotMsgId;
|
||||||
|
processedCount++;
|
||||||
|
|
||||||
|
if (processedCount % 50 == 0)
|
||||||
|
{
|
||||||
|
await uow.SaveChangesAsync(ct);
|
||||||
|
jobInfo.ProcessedMessages = processedCount;
|
||||||
|
_jobStore.AddOrUpdate(jobInfo);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chat.CompleteImport();
|
||||||
|
await uow.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
_logger.LogInformation("Import Completed. Messages: {Count}", processedCount);
|
||||||
|
jobInfo.ProcessedMessages = processedCount;
|
||||||
|
jobInfo.Status = ImportJobStatus.Completed;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
_logger.LogError(ex, "Error during Telegram import process.");
|
||||||
jobInfo.Status = ImportJobStatus.Failed;
|
jobInfo.Status = ImportJobStatus.Failed;
|
||||||
jobInfo.ErrorMessage = ex.Message;
|
jobInfo.ErrorMessage = ex.Message;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
{
|
{
|
||||||
_jobStore.AddOrUpdate(jobInfo);
|
_jobStore.AddOrUpdate(jobInfo);
|
||||||
try { File.Delete(zipPath); } catch { }
|
try { if (File.Exists(zipPath)) File.Delete(zipPath); } catch { }
|
||||||
|
TelegramImportState.TempZips.TryRemove(request.Token, out _);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,16 +8,19 @@ using System.IO;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
namespace Knot.Modules.TelegramImport.Infrastructure.Parser;
|
namespace Knot.Modules.TelegramImport.Infrastructure.Parser;
|
||||||
|
|
||||||
public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
||||||
{
|
{
|
||||||
private readonly HtmlParser _parser;
|
private readonly HtmlParser _parser;
|
||||||
|
private readonly ILogger<TelegramHtmlParser> _logger;
|
||||||
|
|
||||||
public TelegramHtmlParser()
|
public TelegramHtmlParser(ILogger<TelegramHtmlParser> logger)
|
||||||
{
|
{
|
||||||
_parser = new HtmlParser();
|
_parser = new HtmlParser();
|
||||||
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<List<string>> ExtractAllUserNamesAsync(Stream htmlStream, CancellationToken ct = default)
|
public async Task<List<string>> ExtractAllUserNamesAsync(Stream htmlStream, CancellationToken ct = default)
|
||||||
@@ -39,42 +42,282 @@ public sealed class TelegramHtmlParser : ITelegramHtmlParser
|
|||||||
{
|
{
|
||||||
var doc = await _parser.ParseDocumentAsync(htmlStream, ct);
|
var doc = await _parser.ParseDocumentAsync(htmlStream, ct);
|
||||||
var messages = new List<TelegramMessage>();
|
var messages = new List<TelegramMessage>();
|
||||||
|
string? lastSenderName = null;
|
||||||
|
DateTime? lastCreatedAt = null;
|
||||||
|
|
||||||
var messageNodes = doc.QuerySelectorAll(".message");
|
var messageNodes = doc.QuerySelectorAll(".message");
|
||||||
foreach (var node in messageNodes)
|
foreach (var node in messageNodes)
|
||||||
{
|
{
|
||||||
var msg = ParseSingleMessage(node, baseDirInZip);
|
bool isJoined = node.ClassList.Contains("joined");
|
||||||
if (msg != null) messages.Add(msg);
|
var media = ExtractMedia(node, baseDirInZip);
|
||||||
|
var textNode = node.QuerySelector(".text");
|
||||||
|
var hasText = textNode != null && !string.IsNullOrWhiteSpace(textNode.TextContent);
|
||||||
|
|
||||||
|
var dateNode = node.QuerySelector(".date[title]") ?? node.QuerySelector("[title]");
|
||||||
|
var dateStr = dateNode?.GetAttribute("title") ?? "";
|
||||||
|
DateTime? currentAt = ParseDate(dateStr);
|
||||||
|
|
||||||
|
// REFINED ALBUM MERGING LOGIC:
|
||||||
|
// 1. Current is .joined
|
||||||
|
// 2. Current has media but NO text (important: if it has text, it's a new bubble group)
|
||||||
|
// 3. Sender is the same as previous message
|
||||||
|
// 4. Time difference is minimal (<= 2 seconds). Telegram albums usually share the exact same timestamp.
|
||||||
|
if (isJoined && media.Count > 0 && !hasText && messages.Count > 0)
|
||||||
|
{
|
||||||
|
var prev = messages[messages.Count - 1];
|
||||||
|
bool isSameSender = lastSenderName != null; // Since it's 'joined', it's the same visual group
|
||||||
|
bool isCloseInTime = lastCreatedAt.HasValue && currentAt.HasValue &&
|
||||||
|
Math.Abs((currentAt.Value - lastCreatedAt.Value).TotalSeconds) <= 2;
|
||||||
|
|
||||||
|
if (isSameSender && isCloseInTime)
|
||||||
|
{
|
||||||
|
var mergedMedia = (prev.Media ?? new List<TelegramMedia>()).Concat(media).ToList();
|
||||||
|
messages[messages.Count - 1] = prev with { Media = mergedMedia };
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg = ParseSingleMessage(node, baseDirInZip, ref lastSenderName);
|
||||||
|
if (msg != null)
|
||||||
|
{
|
||||||
|
messages.Add(msg);
|
||||||
|
lastCreatedAt = currentAt;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return messages;
|
return messages;
|
||||||
}
|
}
|
||||||
|
|
||||||
private TelegramMessage? ParseSingleMessage(IElement node, string baseDir)
|
public async Task<string?> ExtractGroupNameAsync(Stream htmlStream, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
var doc = await _parser.ParseDocumentAsync(htmlStream, ct);
|
||||||
|
var header = doc.QuerySelector(".page_header .text");
|
||||||
|
var name = header?.TextContent?.Trim();
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
name = doc.Title?.Replace("Chat Export with ", "")?.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
private TelegramMessage? ParseSingleMessage(IElement node, string baseDir, ref string? lastSenderName)
|
||||||
|
{
|
||||||
|
if (node.ClassList.Contains("service")) return null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var id = node.GetAttribute("id") ?? Guid.NewGuid().ToString();
|
var idAttr = node.GetAttribute("id") ?? Guid.NewGuid().ToString();
|
||||||
var fromNameNode = node.QuerySelector(".from_name");
|
long numericId = 0;
|
||||||
var senderName = fromNameNode != null ? CleanName(fromNameNode) : null;
|
if (idAttr.StartsWith("message")) long.TryParse(idAttr.Replace("message", ""), out numericId);
|
||||||
|
|
||||||
|
var fromNameNode = node.QuerySelector(".body > .from_name");
|
||||||
|
if (fromNameNode != null && fromNameNode.Closest(".forwarded") != null) fromNameNode = null;
|
||||||
|
|
||||||
|
var senderName = fromNameNode != null ? CleanName(fromNameNode) : lastSenderName;
|
||||||
|
if (senderName != null && !node.ClassList.Contains("joined")) lastSenderName = senderName;
|
||||||
|
|
||||||
var textNode = node.QuerySelector(".text");
|
var textNode = node.QuerySelector(".text");
|
||||||
var content = textNode?.TextContent?.Trim() ?? "";
|
var content = textNode?.TextContent?.Trim() ?? "";
|
||||||
|
|
||||||
// Дата (парсинг из title)
|
|
||||||
var dateNode = node.QuerySelector(".date[title]") ?? node.QuerySelector("[title]");
|
var dateNode = node.QuerySelector(".date[title]") ?? node.QuerySelector("[title]");
|
||||||
var dateStr = dateNode?.GetAttribute("title") ?? "";
|
var dateStr = dateNode?.GetAttribute("title") ?? "";
|
||||||
DateTime.TryParse(dateStr.Replace("UTC", "").Trim(), out var createdAt);
|
DateTime createdAt = ParseDate(dateStr) ?? DateTime.UtcNow;
|
||||||
|
|
||||||
return new TelegramMessage(id, senderName, createdAt, content);
|
var mediaList = ExtractMedia(node, baseDir);
|
||||||
|
|
||||||
|
string? replyToId = null;
|
||||||
|
var replyNode = node.QuerySelector(".reply_to a[href^=\"#go_to_message\"]");
|
||||||
|
if (replyNode != null)
|
||||||
|
{
|
||||||
|
var href = replyNode.GetAttribute("href");
|
||||||
|
replyToId = href?.Replace("#go_to_message", "message");
|
||||||
|
}
|
||||||
|
|
||||||
|
string? forwardedFrom = null;
|
||||||
|
var forwardNode = node.QuerySelector(".forwarded .from_name");
|
||||||
|
if (forwardNode != null) forwardedFrom = CleanName(forwardNode);
|
||||||
|
|
||||||
|
var reactions = new List<TelegramReaction>();
|
||||||
|
var reactionNodes = node.QuerySelectorAll(".reactions .reaction");
|
||||||
|
foreach (var r in reactionNodes)
|
||||||
|
{
|
||||||
|
var emoji = r.QuerySelector(".emoji")?.TextContent?.Trim() ?? "";
|
||||||
|
var names = r.QuerySelectorAll(".userpics [title]")
|
||||||
|
.Select(x => x.GetAttribute("title") ?? "")
|
||||||
|
.Where(x => !string.IsNullOrEmpty(x))
|
||||||
|
.ToList();
|
||||||
|
if (!string.IsNullOrEmpty(emoji)) reactions.Add(new TelegramReaction(emoji, names));
|
||||||
|
}
|
||||||
|
|
||||||
|
return new TelegramMessage(idAttr, senderName, createdAt, content, replyToId, forwardedFrom, mediaList.Any() ? mediaList : null, reactions.Any() ? reactions : null, numericId);
|
||||||
}
|
}
|
||||||
catch { return null; }
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error parsing single Telegram message");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime? ParseDate(string? dateStr)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrEmpty(dateStr)) return null;
|
||||||
|
var cleanDateStr = dateStr.Trim();
|
||||||
|
var formats = new[] { "dd.MM.yyyy HH:mm:ss 'UTC'zzz", "dd.MM.yyyy HH:mm:ss", "d.MM.yyyy HH:mm:ss", "dd.M.yyyy HH:mm:ss", "d.M.yyyy HH:mm:ss" };
|
||||||
|
|
||||||
|
if (DateTimeOffset.TryParseExact(cleanDateStr, formats, System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto)) return dto.UtcDateTime;
|
||||||
|
if (DateTimeOffset.TryParse(cleanDateStr.Replace("UTC", ""), System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var dto2)) return dto2.UtcDateTime;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<TelegramMedia> ExtractMedia(IElement node, string baseDir)
|
||||||
|
{
|
||||||
|
var mediaList = new List<TelegramMedia>();
|
||||||
|
var allLinks = node.QuerySelectorAll("a[href]").ToList();
|
||||||
|
|
||||||
|
foreach (var link in allLinks)
|
||||||
|
{
|
||||||
|
var href = NormalizeHref(link.GetAttribute("href"));
|
||||||
|
if (href == null || !href.Contains("/")) continue;
|
||||||
|
|
||||||
|
if (href.EndsWith(".html", StringComparison.OrdinalIgnoreCase)) continue;
|
||||||
|
|
||||||
|
var fullPath = BuildPath(baseDir, href);
|
||||||
|
if (mediaList.Any(m => m.FilePath == fullPath)) continue;
|
||||||
|
|
||||||
|
// Priority 1: Extract real filename from .title or .name or .description or specific body part
|
||||||
|
string fileName = "";
|
||||||
|
var titleNode = link.QuerySelector(".title") ?? link.QuerySelector(".name") ?? link.QuerySelector(".description");
|
||||||
|
|
||||||
|
if (titleNode != null && !titleNode.TextContent.Contains(":") && titleNode.TextContent.Length < 100)
|
||||||
|
{
|
||||||
|
fileName = titleNode.TextContent.Trim();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Try body but exclude status
|
||||||
|
var bodyNode = link.QuerySelector(".body");
|
||||||
|
if (bodyNode != null)
|
||||||
|
{
|
||||||
|
var clone = (IElement)bodyNode.Clone();
|
||||||
|
foreach (var s in clone.QuerySelectorAll(".status, .details, .pull_right")) s.Remove();
|
||||||
|
var candidate = clone.TextContent.Trim();
|
||||||
|
if (candidate.Length > 0 && candidate.Length < 100 && !candidate.Contains(":")) fileName = candidate;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final desperate attempt: link's own content excluding status tags
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName))
|
||||||
|
{
|
||||||
|
var clone = (IElement)link.Clone();
|
||||||
|
foreach (var s in clone.QuerySelectorAll(".status, .details, .pull_right, .details_icon")) s.Remove();
|
||||||
|
var candidate = clone.TextContent.Trim();
|
||||||
|
if (candidate.Length > 0 && candidate.Length < 100 && !candidate.Contains(":")) fileName = candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup & Russian Support
|
||||||
|
if (!string.IsNullOrWhiteSpace(fileName))
|
||||||
|
{
|
||||||
|
// Decrypt potential HTML entities
|
||||||
|
fileName = System.Net.WebUtility.HtmlDecode(fileName);
|
||||||
|
|
||||||
|
// Remove extra lines if any
|
||||||
|
var lines = fileName.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries);
|
||||||
|
fileName = lines.Length > 0 ? lines[0].Trim() : "";
|
||||||
|
|
||||||
|
// If the "name" is just "Animation" or generic, we might want to fallback to path
|
||||||
|
if (fileName.Equals("Animation", StringComparison.OrdinalIgnoreCase)) fileName = "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Priority 2: Fallback to Path filename (e.g. file_1.mp3)
|
||||||
|
if (string.IsNullOrWhiteSpace(fileName) || fileName.Length < 2)
|
||||||
|
{
|
||||||
|
var rawName = Path.GetFileName(href);
|
||||||
|
fileName = System.Net.WebUtility.UrlDecode(rawName);
|
||||||
|
}
|
||||||
|
|
||||||
|
var mimeType = GetMimeType(href);
|
||||||
|
|
||||||
|
bool isVoice = link.ClassList.Contains("media_voice_message") || href.Contains("voice_messages");
|
||||||
|
bool isAudio = link.ClassList.Contains("media_audio_file") || (mimeType.StartsWith("audio") && !isVoice);
|
||||||
|
bool isVideo = link.ClassList.Contains("media_video") || mimeType.StartsWith("video");
|
||||||
|
bool isGif = link.ClassList.Contains("animated_wrap") || link.TextContent.Contains("Animation", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (isGif) mimeType = "image/gif";
|
||||||
|
else if (isVoice) mimeType = "audio/ogg";
|
||||||
|
else if (isAudio && mimeType == "application/octet-stream") mimeType = "audio/mpeg";
|
||||||
|
|
||||||
|
// Extract Duration from .status
|
||||||
|
string? duration = null;
|
||||||
|
var statusNode = link.QuerySelector(".status") ?? link.QuerySelector(".details");
|
||||||
|
if (statusNode != null)
|
||||||
|
{
|
||||||
|
var text = statusNode.TextContent.Trim();
|
||||||
|
// Status is often: "01:23, 1.2 MB" or just "01:23"
|
||||||
|
var parts = text.Split(',');
|
||||||
|
var first = parts[0].Trim();
|
||||||
|
if (first.Contains(":") && first.All(c => char.IsDigit(c) || c == ':'))
|
||||||
|
{
|
||||||
|
duration = first;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mediaList.Add(new TelegramMedia(fullPath, fileName, mimeType, duration));
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var audioEl in node.QuerySelectorAll("audio[src]"))
|
||||||
|
{
|
||||||
|
var href = NormalizeHref(audioEl.GetAttribute("src"));
|
||||||
|
if (href == null) continue;
|
||||||
|
var fullPath = BuildPath(baseDir, href);
|
||||||
|
if (!mediaList.Any(m => m.FilePath == fullPath))
|
||||||
|
mediaList.Add(new TelegramMedia(fullPath, System.Net.WebUtility.UrlDecode(Path.GetFileName(href)), "audio/ogg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
return mediaList;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? NormalizeHref(string? href)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(href)) return null;
|
||||||
|
if (href.Contains('#')) href = href[..href.IndexOf('#')];
|
||||||
|
if (href.Contains('?')) href = href[..href.IndexOf('?')];
|
||||||
|
return string.IsNullOrWhiteSpace(href) ? null : href.Replace("\\", "/");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildPath(string baseDir, string href)
|
||||||
|
{
|
||||||
|
var normalized = href.Replace("\\", "/");
|
||||||
|
return normalized.StartsWith(baseDir, StringComparison.OrdinalIgnoreCase)
|
||||||
|
? normalized
|
||||||
|
: (baseDir.TrimEnd('/') + "/" + normalized.TrimStart('/')).Replace("//", "/");
|
||||||
}
|
}
|
||||||
|
|
||||||
private string CleanName(IElement node)
|
private string CleanName(IElement node)
|
||||||
{
|
{
|
||||||
var clone = (IElement)node.Clone();
|
var clone = (IElement)node.Clone();
|
||||||
foreach (var span in clone.QuerySelectorAll("span")) span.Remove();
|
foreach (var span in clone.QuerySelectorAll("span, a, div")) span.Remove();
|
||||||
return clone.TextContent.Trim();
|
return clone.TextContent.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private string GetMimeType(string path)
|
||||||
|
{
|
||||||
|
var ext = Path.GetExtension(path).ToLowerInvariant();
|
||||||
|
return ext switch
|
||||||
|
{
|
||||||
|
".jpg" or ".jpeg" => "image/jpeg",
|
||||||
|
".png" => "image/png",
|
||||||
|
".webp" => "image/webp",
|
||||||
|
".mp4" => "video/mp4",
|
||||||
|
".mov" => "video/quicktime",
|
||||||
|
".webm" => "video/webm",
|
||||||
|
".mp3" => "audio/mpeg",
|
||||||
|
".ogg" => "audio/ogg",
|
||||||
|
".wav" => "audio/wav",
|
||||||
|
".m4a" => "audio/mp4",
|
||||||
|
".aac" => "audio/aac",
|
||||||
|
_ => "application/octet-stream"
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+12
-2
@@ -1,5 +1,6 @@
|
|||||||
using Knot.Modules.TelegramImport.Application.TelegramImport;
|
using Knot.Modules.TelegramImport.Application.TelegramImport;
|
||||||
using Knot.Modules.TelegramImport.Application.TelegramImport.DTOs;
|
using Knot.Modules.TelegramImport.Application.TelegramImport.DTOs;
|
||||||
|
using Knot.Modules.TelegramImport.Infrastructure.Background;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.Builder;
|
using Microsoft.AspNetCore.Builder;
|
||||||
@@ -25,14 +26,23 @@ public static class TelegramImportEndpoints
|
|||||||
using var stream = file.OpenReadStream();
|
using var stream = file.OpenReadStream();
|
||||||
var result = await sender.Send(new AnalyzeImportCommand(stream, file.FileName), ct);
|
var result = await sender.Send(new AnalyzeImportCommand(stream, file.FileName), ct);
|
||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description ?? result.Error.Code);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description ?? result.Error.Code);
|
||||||
}).DisableAntiforgery();
|
}).DisableAntiforgery().WithMetadata(new Microsoft.AspNetCore.Mvc.RequestSizeLimitAttribute(1000000000));
|
||||||
|
|
||||||
group.MapPost("execute", async ([FromBody] ExecuteImportRequest req, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
group.MapPost("execute", async ([FromBody] ExecuteImportRequest req, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var command = new ExecuteImportCommand(userContext.UserId, req.Token, req.Mapping, req.GroupName);
|
var command = new ExecuteImportCommand(userContext.UserId, req.Token, req.GroupName, req.Mapping, req.TotalMessages);
|
||||||
var result = await sender.Send(command, ct);
|
var result = await sender.Send(command, ct);
|
||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description ?? result.Error.Code);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description ?? result.Error.Code);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group.MapGet("status/{jobId:guid}", (Guid jobId, IImportJobStore jobStore) =>
|
||||||
|
{
|
||||||
|
if (jobStore.TryGet(jobId, out var info))
|
||||||
|
{
|
||||||
|
return Results.Ok(info);
|
||||||
|
}
|
||||||
|
return Results.NotFound();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
// <auto-generated />
|
// <auto-generated />
|
||||||
using System;
|
using System;
|
||||||
using Knot.Shared.Infrastructure.Persistence;
|
using Knot.Shared.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
<html lang="ru" class="dark">
|
<html lang="ru" class="dark">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=0, viewport-fit=cover" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<title>Knot Messenger</title>
|
<title>Knot Messenger</title>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
|||||||
@@ -108,6 +108,14 @@ export interface Message {
|
|||||||
media: MediaItem[];
|
media: MediaItem[];
|
||||||
reactions: Reaction[];
|
reactions: Reaction[];
|
||||||
readBy: Array<{ userId: string }>;
|
readBy: Array<{ userId: string }>;
|
||||||
|
callType?: 'voice' | 'video' | string | null;
|
||||||
|
callStatus?: 'missed' | 'completed' | 'cancelled' | 'declined' | string | null;
|
||||||
|
duration?: number | null;
|
||||||
|
pollOptions?: Array<{ id: string; text: string; voteCount: number; voters?: Array<{ id: string; username: string; displayName: string; avatar?: string | null }>; voterIds?: string[] }>;
|
||||||
|
pollIsMultipleChoice?: boolean;
|
||||||
|
pollIsClosed?: boolean;
|
||||||
|
pollIsAnonymous?: boolean;
|
||||||
|
userVotedOptionIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Chat {
|
export interface Chat {
|
||||||
@@ -121,6 +129,8 @@ export interface Chat {
|
|||||||
members: ChatMember[];
|
members: ChatMember[];
|
||||||
messages: Message[];
|
messages: Message[];
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
|
isImporting?: boolean;
|
||||||
|
importJobId?: string | null;
|
||||||
pinnedMessages?: Array<{
|
pinnedMessages?: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
message: Message;
|
message: Message;
|
||||||
|
|||||||
@@ -44,6 +44,21 @@ const translations = {
|
|||||||
storageAndData: 'Хранилище и данные',
|
storageAndData: 'Хранилище и данные',
|
||||||
importTelegram: 'Импорт истории Telegram',
|
importTelegram: 'Импорт истории Telegram',
|
||||||
importTelegramDesc: 'Перенести сообщения и медиа из Telegram',
|
importTelegramDesc: 'Перенести сообщения и медиа из Telegram',
|
||||||
|
importSuccess: 'Импорт завершен',
|
||||||
|
importStarted: 'Импорт запущен в фоновом режиме',
|
||||||
|
importSelectArchive: 'Загрузите архив',
|
||||||
|
importSelectArchiveDesc: 'Экспортируйте чат из Telegram (HTML формат, вкл. медиа) и загрузите полученный ZIP архив.',
|
||||||
|
importLimit: 'Лимит: до 1 ГБ',
|
||||||
|
importSelectFile: 'Выбрать ZIP-архив',
|
||||||
|
importParticipants: 'Соответствие участников',
|
||||||
|
importParticipantsDesc: 'Назначьте участников из архива пользователям в Knot.',
|
||||||
|
importInArchive: 'В архиве',
|
||||||
|
importSelectContact: '-- Выберите контакт --',
|
||||||
|
importGroupName: 'Название чата',
|
||||||
|
importGroupNameHint: 'Например, Моя группа',
|
||||||
|
importStart: 'Запустить импорт',
|
||||||
|
importRestart: 'Начать заново',
|
||||||
|
importCompletedDesc: 'Мы успешно создали чат и начали перенос сообщений в фоновом режиме.',
|
||||||
// Chat
|
// Chat
|
||||||
chat: 'Чат',
|
chat: 'Чат',
|
||||||
group: 'Группа',
|
group: 'Группа',
|
||||||
@@ -109,6 +124,11 @@ const translations = {
|
|||||||
endCall: 'Завершить',
|
endCall: 'Завершить',
|
||||||
callEnded: 'Звонок завершён',
|
callEnded: 'Звонок завершён',
|
||||||
callDeclined: 'Звонок отклонён',
|
callDeclined: 'Звонок отклонён',
|
||||||
|
audioCall: 'Голосовой звонок',
|
||||||
|
missedCall: 'Пропущенный звонок',
|
||||||
|
declinedCall: 'Отклонённый звонок',
|
||||||
|
cancelledCall: 'Отменённый звонок',
|
||||||
|
completedCall: 'Вызов завершён',
|
||||||
// Photo/video
|
// Photo/video
|
||||||
photoVideo: 'Фото / видео',
|
photoVideo: 'Фото / видео',
|
||||||
fileBtn: 'Файл',
|
fileBtn: 'Файл',
|
||||||
@@ -138,6 +158,21 @@ const translations = {
|
|||||||
pinMessage: 'Закрепить',
|
pinMessage: 'Закрепить',
|
||||||
unpinMessage: 'Открепить',
|
unpinMessage: 'Открепить',
|
||||||
pinnedMessage: 'Закреплённое сообщение',
|
pinnedMessage: 'Закреплённое сообщение',
|
||||||
|
poll: 'Опрос',
|
||||||
|
pollTab: 'Опросы',
|
||||||
|
createPoll: 'Создать опрос',
|
||||||
|
pollQuestion: 'Вопрос',
|
||||||
|
pollQuestionPlaceholder: 'Задайте вопрос...',
|
||||||
|
pollOptions: 'Варианты ответа',
|
||||||
|
pollOption: 'Вариант',
|
||||||
|
addOption: 'Добавить вариант',
|
||||||
|
pollSettings: 'Настройки',
|
||||||
|
anonymousVoting: 'Анонимное голосование',
|
||||||
|
multipleAnswers: 'Выбор нескольких вариантов',
|
||||||
|
singleAnswer: 'Одиночный выбор',
|
||||||
|
anonymous: 'Анонимно',
|
||||||
|
votes: 'голосов',
|
||||||
|
pollButton: 'Опрос',
|
||||||
forwardMessage: 'Переслать сообщение',
|
forwardMessage: 'Переслать сообщение',
|
||||||
forward: 'Переслать',
|
forward: 'Переслать',
|
||||||
forwardedFrom: 'Переслано от',
|
forwardedFrom: 'Переслано от',
|
||||||
@@ -162,7 +197,7 @@ const translations = {
|
|||||||
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
||||||
pinChat: 'Закрепить чат',
|
pinChat: 'Закрепить чат',
|
||||||
unpinChat: 'Открепить чат',
|
unpinChat: 'Открепить чат',
|
||||||
chatCleared: 'Чат очищен',
|
chatCleared: 'Очищено',
|
||||||
typeYourStoryPlaceholder: 'Напишите историю...',
|
typeYourStoryPlaceholder: 'Напишите историю...',
|
||||||
uploadMedia: 'Загрузить медиа',
|
uploadMedia: 'Загрузить медиа',
|
||||||
chooseBackground: 'Цвет фона',
|
chooseBackground: 'Цвет фона',
|
||||||
@@ -213,6 +248,7 @@ const translations = {
|
|||||||
you: 'вы',
|
you: 'вы',
|
||||||
// Stories Editor
|
// Stories Editor
|
||||||
cropVideo: 'Обрезка видео',
|
cropVideo: 'Обрезка видео',
|
||||||
|
cropTool: 'Обрезка',
|
||||||
trimVideo: 'Обрезать видео',
|
trimVideo: 'Обрезать видео',
|
||||||
trim: 'Обрезать',
|
trim: 'Обрезать',
|
||||||
reset: 'Сбросить',
|
reset: 'Сбросить',
|
||||||
@@ -244,6 +280,8 @@ const translations = {
|
|||||||
unmuteVideo: 'Включить звук',
|
unmuteVideo: 'Включить звук',
|
||||||
interactive: 'Интерактив',
|
interactive: 'Интерактив',
|
||||||
apply: 'Применить',
|
apply: 'Применить',
|
||||||
|
zoom: 'Масштаб',
|
||||||
|
rotation: 'Поворот',
|
||||||
// User profile
|
// User profile
|
||||||
mediaTab: 'Медиа',
|
mediaTab: 'Медиа',
|
||||||
gifs: 'GIF',
|
gifs: 'GIF',
|
||||||
@@ -408,6 +446,21 @@ const translations = {
|
|||||||
storageAndData: 'Storage and Data',
|
storageAndData: 'Storage and Data',
|
||||||
importTelegram: 'Import Telegram History',
|
importTelegram: 'Import Telegram History',
|
||||||
importTelegramDesc: 'Move messages and media from Telegram',
|
importTelegramDesc: 'Move messages and media from Telegram',
|
||||||
|
importSuccess: 'Import completed',
|
||||||
|
importStarted: 'Import started in background',
|
||||||
|
importSelectArchive: 'Upload archive',
|
||||||
|
importSelectArchiveDesc: 'Export chat from Telegram (HTML format, incl. media) and upload the resulting ZIP archive.',
|
||||||
|
importLimit: 'Limit: up to 1 GB',
|
||||||
|
importSelectFile: 'Select ZIP archive',
|
||||||
|
importParticipants: 'Participant mapping',
|
||||||
|
importParticipantsDesc: 'Assign participants from the archive to Knot users.',
|
||||||
|
importInArchive: 'In archive',
|
||||||
|
importSelectContact: '-- Select contact --',
|
||||||
|
importGroupName: 'Chat name',
|
||||||
|
importGroupNameHint: 'e.g., My Group',
|
||||||
|
importStart: 'Start import',
|
||||||
|
importRestart: 'Start over',
|
||||||
|
importCompletedDesc: 'We successfully created the chat and started importing messages in the background.',
|
||||||
searchChats: 'Search chats...',
|
searchChats: 'Search chats...',
|
||||||
chat: 'Chat',
|
chat: 'Chat',
|
||||||
group: 'Group',
|
group: 'Group',
|
||||||
@@ -470,6 +523,11 @@ const translations = {
|
|||||||
endCall: 'End call',
|
endCall: 'End call',
|
||||||
callEnded: 'Call ended',
|
callEnded: 'Call ended',
|
||||||
callDeclined: 'Call declined',
|
callDeclined: 'Call declined',
|
||||||
|
audioCall: 'Audio call',
|
||||||
|
missedCall: 'Missed call',
|
||||||
|
declinedCall: 'Declined call',
|
||||||
|
cancelledCall: 'Cancelled call',
|
||||||
|
completedCall: 'Call completed',
|
||||||
photoVideo: 'Photo / video',
|
photoVideo: 'Photo / video',
|
||||||
fileBtn: 'File',
|
fileBtn: 'File',
|
||||||
sendError: 'Send error',
|
sendError: 'Send error',
|
||||||
@@ -520,6 +578,21 @@ const translations = {
|
|||||||
pinChat: 'Pin chat',
|
pinChat: 'Pin chat',
|
||||||
unpinChat: 'Unpin chat',
|
unpinChat: 'Unpin chat',
|
||||||
chatCleared: 'Chat cleared',
|
chatCleared: 'Chat cleared',
|
||||||
|
poll: 'Poll',
|
||||||
|
pollTab: 'Polls',
|
||||||
|
createPoll: 'Create Poll',
|
||||||
|
pollQuestion: 'Question',
|
||||||
|
pollQuestionPlaceholder: 'Ask a question...',
|
||||||
|
pollOptions: 'Options',
|
||||||
|
pollOption: 'Option',
|
||||||
|
addOption: 'Add Option',
|
||||||
|
pollSettings: 'Settings',
|
||||||
|
anonymousVoting: 'Anonymous Voting',
|
||||||
|
multipleAnswers: 'Multiple Answers',
|
||||||
|
singleAnswer: 'Single Answer',
|
||||||
|
anonymous: 'Anonymous',
|
||||||
|
votes: 'votes',
|
||||||
|
pollButton: 'Poll',
|
||||||
groupSettings: 'Group settings',
|
groupSettings: 'Group settings',
|
||||||
editGroupName: 'Edit name',
|
editGroupName: 'Edit name',
|
||||||
addMember: 'Add member',
|
addMember: 'Add member',
|
||||||
@@ -552,6 +625,7 @@ const translations = {
|
|||||||
you: 'you',
|
you: 'you',
|
||||||
// Stories Editor
|
// Stories Editor
|
||||||
cropVideo: 'Video Crop',
|
cropVideo: 'Video Crop',
|
||||||
|
cropTool: 'Crop',
|
||||||
trimVideo: 'Trim Video',
|
trimVideo: 'Trim Video',
|
||||||
trim: 'Trim',
|
trim: 'Trim',
|
||||||
reset: 'Reset',
|
reset: 'Reset',
|
||||||
@@ -583,6 +657,8 @@ const translations = {
|
|||||||
unmuteVideo: 'Unmute Video',
|
unmuteVideo: 'Unmute Video',
|
||||||
interactive: 'Interactive',
|
interactive: 'Interactive',
|
||||||
apply: 'Apply',
|
apply: 'Apply',
|
||||||
|
zoom: 'Zoom',
|
||||||
|
rotation: 'Rotation',
|
||||||
// User profile
|
// User profile
|
||||||
mediaTab: 'Media',
|
mediaTab: 'Media',
|
||||||
gifs: 'GIF',
|
gifs: 'GIF',
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarP
|
|||||||
const gradientClass = generateAvatarColor(name || '');
|
const gradientClass = generateAvatarColor(name || '');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={`relative shrink-0 ${sizeClass} ${rounding} ${className} border-2 border-outline-variant/10 overflow-hidden shadow-inner`}>
|
<div className={`relative shrink-0 ${sizeClass} ${rounding} ${className} overflow-hidden`}>
|
||||||
{src ? (
|
{src ? (
|
||||||
<img
|
<img
|
||||||
src={src}
|
src={src}
|
||||||
@@ -55,7 +55,7 @@ function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarP
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`w-full h-full ${gradientClass} flex items-center justify-center text-on-primary font-black tracking-tighter shadow-inner`}
|
className={`w-full h-full ${gradientClass} flex items-center justify-center text-on-primary font-black tracking-tighter`}
|
||||||
>
|
>
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useLang } from '../../../infrastructure/i18n';
|
import { useLang } from '../../../infrastructure/i18n';
|
||||||
|
|
||||||
interface ConfirmModalProps {
|
interface ConfirmModalProps {
|
||||||
@@ -25,49 +26,50 @@ export default function ConfirmModal({
|
|||||||
}: ConfirmModalProps) {
|
}: ConfirmModalProps) {
|
||||||
const { t } = useLang();
|
const { t } = useLang();
|
||||||
|
|
||||||
return (
|
const content = (
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{open && (
|
{open && (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="fixed inset-0 z-[200] flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/80 backdrop-blur-md px-4"
|
||||||
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
onClick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
|
||||||
>
|
>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ scale: 0.9, opacity: 0, y: 20 }}
|
initial={{ scale: 0.95, opacity: 0, y: 15 }}
|
||||||
animate={{ scale: 1, opacity: 1, y: 0 }}
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
||||||
exit={{ scale: 0.9, opacity: 0, y: 20 }}
|
exit={{ scale: 0.95, opacity: 0, y: 15 }}
|
||||||
transition={{ type: 'spring', duration: 0.35, bounce: 0.2 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 450 }}
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={title}
|
className="w-full max-w-[400px] rounded-[2.5rem] bg-[#1c1c1c] border border-white/10 shadow-[0_40px_100px_-20px_rgba(0,0,0,0.8)] overflow-hidden"
|
||||||
className="w-full max-w-[360px] mx-4 rounded-2xl bg-surface-secondary border border-border/50 shadow-2xl overflow-hidden"
|
|
||||||
>
|
>
|
||||||
<div className="p-5 flex flex-col items-center text-center">
|
<div className="p-8 pb-6 flex flex-col items-center text-center">
|
||||||
<div className={`w-12 h-12 rounded-full flex items-center justify-center mb-3 ${danger ? 'bg-red-500/15' : 'bg-accent/15'}`}>
|
<div className={`w-16 h-16 rounded-2xl flex items-center justify-center mb-6 ${danger ? 'bg-error/15' : 'bg-primary/15'}`}>
|
||||||
<AlertTriangle size={24} className={danger ? 'text-red-400' : 'text-accent'} />
|
<AlertTriangle size={32} className={danger ? 'text-error' : 'text-primary'} />
|
||||||
</div>
|
</div>
|
||||||
{title && (
|
{title && (
|
||||||
<h3 className="text-white text-base font-semibold mb-1">{title}</h3>
|
<h3 className="text-white text-xl font-black mb-3 tracking-tight leading-tight">{title}</h3>
|
||||||
)}
|
)}
|
||||||
<p className="text-zinc-400 text-sm leading-relaxed">{message}</p>
|
<p className="text-zinc-400 text-[16px] leading-relaxed font-medium px-2">
|
||||||
|
{message}
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex border-t border-border/40">
|
|
||||||
|
<div className="grid grid-cols-2 p-6 pt-2 gap-4">
|
||||||
<button
|
<button
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
className="flex-1 py-3 text-sm font-medium text-zinc-400 hover:bg-surface-hover hover:text-white transition-colors"
|
className="py-4 px-6 rounded-2xl text-[15px] font-bold text-zinc-400 bg-white/5 hover:bg-white/10 transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
{cancelText || t('cancel')}
|
{cancelText || t('cancel')}
|
||||||
</button>
|
</button>
|
||||||
<div className="w-px bg-border/40" />
|
|
||||||
<button
|
<button
|
||||||
onClick={onConfirm}
|
onClick={onConfirm}
|
||||||
className={`flex-1 py-3 text-sm font-medium transition-colors ${
|
className={`py-4 px-6 rounded-2xl text-[15px] font-black transition-all active:scale-95 shadow-lg ${
|
||||||
danger
|
danger
|
||||||
? 'text-red-400 hover:bg-red-500/10 hover:text-red-300'
|
? 'bg-error text-on-error hover:brightness-110 shadow-error/20'
|
||||||
: 'text-accent hover:bg-accent/10'
|
: 'bg-gradient-to-br from-primary to-primary-container text-on-primary hover:brightness-110 shadow-primary/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{confirmText || t('confirm')}
|
{confirmText || t('confirm')}
|
||||||
@@ -78,4 +80,6 @@ export default function ConfirmModal({
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
return createPortal(content, document.body);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,14 +99,24 @@ export default function ImageLightbox({ url, images, initialIndex = 0, onClose }
|
|||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
exit={{ scale: 0.8, opacity: 0 }}
|
exit={{ scale: 0.8, opacity: 0 }}
|
||||||
transition={{ duration: 0.2 }}
|
transition={{ duration: 0.2 }}
|
||||||
|
drag={gallery && total > 1 ? "x" : false}
|
||||||
|
dragConstraints={{ left: 0, right: 0 }}
|
||||||
|
dragElastic={0.4}
|
||||||
|
onDragEnd={(_, info) => {
|
||||||
|
const swipeThreshold = 50;
|
||||||
|
if (info.offset.x > swipeThreshold) goPrev();
|
||||||
|
else if (info.offset.x < -swipeThreshold) goNext();
|
||||||
|
}}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
className="absolute inset-x-0 inset-y-12 flex items-center justify-center p-4"
|
className="absolute inset-x-0 inset-y-12 flex items-center justify-center p-4 touch-none"
|
||||||
>
|
>
|
||||||
{currentType === 'video' ? (
|
{(currentType === 'video' || currentType === 'gif') ? (
|
||||||
<video
|
<video
|
||||||
src={currentUrl}
|
src={currentUrl}
|
||||||
controls
|
controls={currentType === 'video'}
|
||||||
autoPlay
|
autoPlay
|
||||||
|
loop={currentType === 'gif'}
|
||||||
|
muted={currentType === 'gif'}
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
preload="metadata"
|
||||||
className="w-full h-full object-contain outline-none bg-black/50"
|
className="w-full h-full object-contain outline-none bg-black/50"
|
||||||
|
|||||||
@@ -16,41 +16,43 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<nav className="fixed left-0 top-0 h-full z-40 bg-surface-container w-20 flex flex-col items-center py-6 slide-on-ice border-none shadow-xl">
|
<nav
|
||||||
<div className="mb-10 flex flex-col items-center">
|
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:py-8 lg:gap-4 z-50 transition-all safe-area-bottom"
|
||||||
|
>
|
||||||
|
<div className="hidden lg:flex mb-10 flex-col items-center">
|
||||||
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
|
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col gap-6 w-full px-2">
|
<div className="flex flex-row lg:flex-col gap-2 lg:gap-6 w-full lg:px-2 items-center justify-around lg:justify-start">
|
||||||
{menuItems.map((item) => (
|
{menuItems.map((item) => (
|
||||||
<button
|
<button
|
||||||
key={item.id}
|
key={item.id}
|
||||||
onClick={() => onTabChange(item.id)}
|
onClick={() => onTabChange(item.id)}
|
||||||
className={`flex flex-col items-center justify-center p-3 rounded-xl transition-all duration-300 scale-95 active:scale-90 ${
|
className={`flex flex-col items-center justify-center p-2 lg:p-3 rounded-xl transition-all duration-300 scale-95 lg:scale-100 active:scale-90 flex-1 lg:flex-none ${
|
||||||
activeTab === item.id
|
activeTab === item.id
|
||||||
? 'bg-primary/10 text-primary'
|
? 'bg-primary/10 text-primary'
|
||||||
: 'text-on-surface-variant hover:bg-surface-container-high hover:text-primary'
|
: 'text-on-surface-variant hover:bg-surface-container-high hover:text-primary'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`material-symbols-outlined mb-1 ${activeTab === item.id ? 'fill-1' : ''}`}
|
className={`material-symbols-outlined mb-0.5 lg:mb-1 ${activeTab === item.id ? 'fill-1' : ''}`}
|
||||||
style={{ fontVariationSettings: `'FILL' ${activeTab === item.id ? 1 : 0}` }}
|
style={{ fontVariationSettings: `'FILL' ${activeTab === item.id ? 1 : 0}` }}
|
||||||
>
|
>
|
||||||
{item.icon}
|
{item.icon}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-medium">{item.label}</span>
|
<span className="text-[9px] lg:text-[10px] font-bold uppercase tracking-wider">{item.label}</span>
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
className="mt-auto group cursor-pointer relative"
|
className="lg:mt-auto group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
|
||||||
onClick={() => onTabChange('settings')}
|
onClick={() => onTabChange('settings')}
|
||||||
>
|
>
|
||||||
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
||||||
<img
|
<img
|
||||||
alt="User Profile"
|
alt="User Profile"
|
||||||
className="relative w-10 h-10 rounded-2xl border-2 border-outline-variant/10 hover:border-primary/50 transition-colors object-cover"
|
className="relative w-8 h-8 lg:w-10 lg:h-10 rounded-2xl border-2 border-outline-variant/10 hover:border-primary/50 transition-colors object-cover"
|
||||||
src={user?.avatar || `https://ui-avatars.com/api/?name=${user?.username || 'user'}&background=3096e5&color=fff`}
|
src={user?.avatar || `https://ui-avatars.com/api/?name=${user?.username || 'user'}&background=3096e5&color=fff`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ import { getSocket } from '../../infrastructure/socket';
|
|||||||
import { useLang } from '../../infrastructure/i18n';
|
import { useLang } from '../../infrastructure/i18n';
|
||||||
import { useThemeStore, ChatTheme } from '../../application/stores/themeStore';
|
import { useThemeStore, ChatTheme } from '../../application/stores/themeStore';
|
||||||
import DatePicker from '../components/ui/DatePicker';
|
import DatePicker from '../components/ui/DatePicker';
|
||||||
import TelegramImportModal from '../../../modules/users/presentation/components/TelegramImportModal';
|
|
||||||
import type { User as UserType, UserPresence, FriendRequest, FriendWithId } from '../../domain/types';
|
import type { User as UserType, UserPresence, FriendRequest, FriendWithId } from '../../domain/types';
|
||||||
|
|
||||||
import { getInitials } from '../../utils/utils';
|
import { getInitials } from '../../utils/utils';
|
||||||
@@ -48,9 +47,10 @@ interface SideMenuProps {
|
|||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onOpenProfile: () => void;
|
onOpenProfile: () => void;
|
||||||
|
onOpenTelegramImport: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuProps) {
|
export default function SideMenu({ isOpen, onClose, onOpenProfile, onOpenTelegramImport }: SideMenuProps) {
|
||||||
const { user, updateUser, logout } = useAuthStore();
|
const { user, updateUser, logout } = useAuthStore();
|
||||||
const { clearStore } = useChatStore();
|
const { clearStore } = useChatStore();
|
||||||
const { chatTheme, setChatTheme } = useThemeStore();
|
const { chatTheme, setChatTheme } = useThemeStore();
|
||||||
@@ -61,8 +61,6 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
|
|||||||
const [themeIndex, setThemeIndex] = useState(0);
|
const [themeIndex, setThemeIndex] = useState(0);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const [showImportModal, setShowImportModal] = useState(false);
|
|
||||||
|
|
||||||
// Friends state
|
// Friends state
|
||||||
const {
|
const {
|
||||||
friends,
|
friends,
|
||||||
@@ -325,7 +323,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
|
|||||||
<div className="px-5 py-3 border-t border-border mt-2">
|
<div className="px-5 py-3 border-t border-border mt-2">
|
||||||
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">Хранилище и данные</h4>
|
<h4 className="text-xs text-zinc-500 uppercase tracking-wide mb-3">Хранилище и данные</h4>
|
||||||
<button
|
<button
|
||||||
onClick={() => { loadFriends(); setShowImportModal(true); }}
|
onClick={() => { onOpenTelegramImport(); }}
|
||||||
className="w-full flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50 hover:bg-surface-hover transition-colors"
|
className="w-full flex items-center gap-4 px-3 py-3 rounded-xl bg-surface-tertiary/50 hover:bg-surface-hover transition-colors"
|
||||||
>
|
>
|
||||||
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
<div className="w-8 h-8 rounded-lg bg-blue-500/20 flex items-center justify-center">
|
||||||
@@ -664,7 +662,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
|
|||||||
animate={{ x: 0, opacity: 1 }}
|
animate={{ x: 0, opacity: 1 }}
|
||||||
exit={{ x: -320, opacity: 0 }}
|
exit={{ x: -320, opacity: 0 }}
|
||||||
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
|
||||||
className="fixed left-3 top-3 bottom-3 w-[340px] max-w-[calc(100vw-24px)] bg-surface-secondary/95 backdrop-blur-3xl shadow-[0_0_100px_rgba(0,0,0,0.5)] border border-border/50 rounded-3xl z-50 flex flex-col overflow-hidden"
|
className="fixed inset-0 lg:left-3 lg:top-3 lg:bottom-3 w-full lg:w-[340px] lg:max-w-[calc(100vw-24px)] bg-surface-secondary/95 backdrop-blur-3xl shadow-[0_0_100px_rgba(0,0,0,0.5)] border-none lg:border lg:border-border/50 lg:rounded-3xl z-50 flex flex-col overflow-hidden"
|
||||||
>
|
>
|
||||||
<AnimatePresence mode="wait" custom={slideDir}>
|
<AnimatePresence mode="wait" custom={slideDir}>
|
||||||
{view === 'main' && renderMain()}
|
{view === 'main' && renderMain()}
|
||||||
@@ -674,7 +672,6 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
|
|||||||
{view === 'about' && renderAbout()}
|
{view === 'about' && renderAbout()}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
<TelegramImportModal isOpen={showImportModal} onClose={() => setShowImportModal(false)} friends={friends} />
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useAuthStore } from '../../../modules/auth/application/authStore';
|
|||||||
import { useChatStore } from '../../../modules/chats/application/chatStore';
|
import { useChatStore } from '../../../modules/chats/application/chatStore';
|
||||||
import { useNotificationStore } from '../../application/stores/notificationStore';
|
import { useNotificationStore } from '../../application/stores/notificationStore';
|
||||||
import { useLang } from '../../infrastructure/i18n';
|
import { useLang } from '../../infrastructure/i18n';
|
||||||
|
import { useFriendStore } from '../../../modules/friends/application/friendStore';
|
||||||
import { StoryApi } from '../../../modules/stories/infrastructure/storyApi';
|
import { StoryApi } from '../../../modules/stories/infrastructure/storyApi';
|
||||||
import { getSocket } from '../../infrastructure/socket';
|
import { getSocket } from '../../infrastructure/socket';
|
||||||
import { getInitials, generateAvatarColor } from '../../utils/utils';
|
import { getInitials, generateAvatarColor } from '../../utils/utils';
|
||||||
@@ -24,6 +25,7 @@ import SideMenu from './SideMenu';
|
|||||||
import StoryViewer from '../../../modules/stories/presentation/components/StoryViewer';
|
import StoryViewer from '../../../modules/stories/presentation/components/StoryViewer';
|
||||||
import { CreateStoryModal } from '../../../modules/stories/presentation/components/CreateStoryModal';
|
import { CreateStoryModal } from '../../../modules/stories/presentation/components/CreateStoryModal';
|
||||||
import { useStoryStore } from '../../../modules/stories/application/storyStore';
|
import { useStoryStore } from '../../../modules/stories/application/storyStore';
|
||||||
|
import TelegramImportModal from '../../../modules/users/presentation/components/TelegramImportModal';
|
||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL || '';
|
const API_URL = import.meta.env.VITE_API_URL || '';
|
||||||
|
|
||||||
@@ -36,6 +38,8 @@ export default function Sidebar() {
|
|||||||
const [showSideMenu, setShowSideMenu] = useState(false);
|
const [showSideMenu, setShowSideMenu] = useState(false);
|
||||||
const { storyGroups, setStoryGroups, viewerIndex, viewerStoryIndex, openViewer, closeViewer } = useStoryStore();
|
const { storyGroups, setStoryGroups, viewerIndex, viewerStoryIndex, openViewer, closeViewer } = useStoryStore();
|
||||||
const [showCreateStory, setShowCreateStory] = useState(false);
|
const [showCreateStory, setShowCreateStory] = useState(false);
|
||||||
|
const [showImportModal, setShowImportModal] = useState(false);
|
||||||
|
const { friends, loadFriends } = useFriendStore();
|
||||||
|
|
||||||
const loadStories = () => {
|
const loadStories = () => {
|
||||||
StoryApi.getStories()
|
StoryApi.getStories()
|
||||||
@@ -63,14 +67,17 @@ export default function Sidebar() {
|
|||||||
|
|
||||||
const handleOpenNewChat = () => setShowNewChat(true);
|
const handleOpenNewChat = () => setShowNewChat(true);
|
||||||
const handleOpenSideMenu = () => setShowSideMenu(true);
|
const handleOpenSideMenu = () => setShowSideMenu(true);
|
||||||
|
const handleOpenImport = () => { loadFriends(); setShowImportModal(true); };
|
||||||
window.addEventListener('OPEN_NEW_CHAT', handleOpenNewChat);
|
window.addEventListener('OPEN_NEW_CHAT', handleOpenNewChat);
|
||||||
window.addEventListener('OPEN_SIDE_MENU', handleOpenSideMenu);
|
window.addEventListener('OPEN_SIDE_MENU', handleOpenSideMenu);
|
||||||
|
window.addEventListener('OPEN_TELEGRAM_IMPORT', handleOpenImport);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
socket?.off('story_viewed', onStoryViewed);
|
socket?.off('story_viewed', onStoryViewed);
|
||||||
window.removeEventListener('OPEN_NEW_CHAT', handleOpenNewChat);
|
window.removeEventListener('OPEN_NEW_CHAT', handleOpenNewChat);
|
||||||
window.removeEventListener('OPEN_SIDE_MENU', handleOpenSideMenu);
|
window.removeEventListener('OPEN_SIDE_MENU', handleOpenSideMenu);
|
||||||
|
window.removeEventListener('OPEN_TELEGRAM_IMPORT', handleOpenImport);
|
||||||
};
|
};
|
||||||
}, [user?.id]);
|
}, [user?.id]);
|
||||||
|
|
||||||
@@ -80,9 +87,9 @@ export default function Sidebar() {
|
|||||||
if (chat.name?.toLowerCase().includes(q)) return true;
|
if (chat.name?.toLowerCase().includes(q)) return true;
|
||||||
return chat.members.some(
|
return chat.members.some(
|
||||||
(m) =>
|
(m) =>
|
||||||
m.user.id !== user?.id &&
|
m.user?.id !== user?.id &&
|
||||||
((m.user.username || m.user.userName || '').toLowerCase().includes(q) ||
|
((m.user?.username || m.user?.userName || '').toLowerCase().includes(q) ||
|
||||||
(m.user.displayName || '').toLowerCase().includes(q))
|
(m.user?.displayName || '').toLowerCase().includes(q))
|
||||||
);
|
);
|
||||||
}).sort((a, b) => {
|
}).sort((a, b) => {
|
||||||
// 1. Favorites chat always on top
|
// 1. Favorites chat always on top
|
||||||
@@ -90,13 +97,19 @@ export default function Sidebar() {
|
|||||||
if (b.type === 'favorites') return 1;
|
if (b.type === 'favorites') return 1;
|
||||||
|
|
||||||
// 2. Pinned chats next
|
// 2. Pinned chats next
|
||||||
const aPinned = a.members.find(m => m.user.id === user?.id)?.isPinned ?? false;
|
const aPinned = a.members?.find(m => m.user?.id === user?.id)?.isPinned ?? false;
|
||||||
const bPinned = b.members.find(m => m.user.id === user?.id)?.isPinned ?? false;
|
const bPinned = b.members?.find(m => m.user?.id === user?.id)?.isPinned ?? false;
|
||||||
if (aPinned && !bPinned) return -1;
|
if (aPinned && !bPinned) return -1;
|
||||||
if (!aPinned && bPinned) return 1;
|
if (!aPinned && bPinned) return 1;
|
||||||
|
|
||||||
// 3. Last message timestamp (if available) - though currently we don't have it on top level
|
// 3. Importing chats next
|
||||||
return 0;
|
if (a.isImporting && !b.isImporting) return -1;
|
||||||
|
if (!a.isImporting && b.isImporting) return 1;
|
||||||
|
|
||||||
|
// 4. Last message timestamp or CreatedAt
|
||||||
|
const aTime = new Date(a.messages?.[0]?.createdAt || a.createdAt).getTime();
|
||||||
|
const bTime = new Date(b.messages?.[0]?.createdAt || b.createdAt).getTime();
|
||||||
|
return bTime - aTime;
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
@@ -187,7 +200,7 @@ export default function Sidebar() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Список чатов */}
|
{/* Список чатов */}
|
||||||
<div className="flex-1 overflow-y-auto px-2 custom-scrollbar">
|
<div className="flex-1 overflow-y-auto px-2 custom-scrollbar pb-28 lg:pb-4">
|
||||||
{filteredChats.length === 0 ? (
|
{filteredChats.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant/40 gap-3 px-6">
|
<div className="flex flex-col items-center justify-center h-full text-on-surface-variant/40 gap-3 px-6">
|
||||||
<span className="material-symbols-outlined text-5xl opacity-20">chat_bubble</span>
|
<span className="material-symbols-outlined text-5xl opacity-20">chat_bubble</span>
|
||||||
@@ -205,16 +218,16 @@ export default function Sidebar() {
|
|||||||
{/* Float Action Button equivalent for Web */}
|
{/* Float Action Button equivalent for Web */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewChat(true)}
|
onClick={() => setShowNewChat(true)}
|
||||||
className="absolute bottom-6 right-6 w-14 h-14 rounded-2xl bg-primary text-black shadow-lg shadow-primary/20 flex items-center justify-center hover:scale-105 active:scale-95 transition-transform slide-on-ice"
|
className="absolute bottom-20 lg:bottom-10 right-4 lg:right-10 w-12 h-12 lg:w-16 lg:h-16 rounded-2xl lg:rounded-3xl bg-primary text-black shadow-[0_10px_30px_-5px_rgba(var(--primary-rgb),0.4)] flex items-center justify-center hover:scale-105 active:scale-95 transition-all slide-on-ice z-30"
|
||||||
title={t('newChat')}
|
title={t('newChat')}
|
||||||
>
|
>
|
||||||
<span className="material-symbols-outlined text-2xl">add</span>
|
<span className="material-symbols-outlined text-2xl lg:text-3xl">add</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Модалки */}
|
{/* Модалки */}
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showNewChat && <NewChatModal onClose={() => setShowNewChat(false)} />}
|
{showNewChat && <NewChatModal onClose={() => setShowNewChat(false)} onOpenTelegramImport={() => { loadFriends(); setShowImportModal(true); }} />}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{showProfile && user && <UserProfile userId={user.id} onClose={() => setShowProfile(false)} isSelf />}
|
{showProfile && user && <UserProfile userId={user.id} onClose={() => setShowProfile(false)} isSelf />}
|
||||||
@@ -223,6 +236,12 @@ export default function Sidebar() {
|
|||||||
isOpen={showSideMenu}
|
isOpen={showSideMenu}
|
||||||
onClose={() => setShowSideMenu(false)}
|
onClose={() => setShowSideMenu(false)}
|
||||||
onOpenProfile={() => setShowProfile(true)}
|
onOpenProfile={() => setShowProfile(true)}
|
||||||
|
onOpenTelegramImport={() => { loadFriends(); setShowImportModal(true); }}
|
||||||
|
/>
|
||||||
|
<TelegramImportModal
|
||||||
|
isOpen={showImportModal}
|
||||||
|
onClose={() => setShowImportModal(false)}
|
||||||
|
friends={friends}
|
||||||
/>
|
/>
|
||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{viewerIndex !== null && storyGroups.length > 0 && (
|
{viewerIndex !== null && storyGroups.length > 0 && (
|
||||||
|
|||||||
@@ -238,9 +238,60 @@ input:-webkit-autofill:active {
|
|||||||
100% { background-color: transparent; }
|
100% { background-color: transparent; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes callWave {
|
||||||
|
0% { transform: scale(1); opacity: 0.5; }
|
||||||
|
100% { transform: scale(1.5); opacity: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wiggle {
|
||||||
|
0%, 100% { transform: rotate(0); }
|
||||||
|
25% { transform: rotate(-10deg); }
|
||||||
|
75% { transform: rotate(10deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-call-wave {
|
||||||
|
animation: callWave 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-call-wave-delayed {
|
||||||
|
animation: callWave 2s cubic-bezier(0, 0, 0.2, 1) infinite;
|
||||||
|
animation-delay: 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.animate-wiggle {
|
||||||
|
animation: wiggle 0.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
.highlight-message {
|
.highlight-message {
|
||||||
animation: highlightFlash 2s cubic-bezier(0.4, 0, 0.2, 1) forwards !important;
|
animation: highlightFlash 2s cubic-bezier(0.4, 0, 0.2, 1) forwards !important;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Mobile & Safe Area Utilities */
|
||||||
|
.safe-area-bottom {
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
}
|
||||||
|
|
||||||
|
.safe-area-top {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none; /* IE and Edge */
|
||||||
|
scrollbar-width: none; /* Firefox */
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (padding: env(safe-area-inset-top)) {
|
||||||
|
body {
|
||||||
|
padding-top: env(safe-area-inset-top);
|
||||||
|
padding-bottom: env(safe-area-inset-bottom);
|
||||||
|
padding-left: env(safe-area-inset-left);
|
||||||
|
padding-right: env(safe-area-inset-right);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -244,6 +244,7 @@ const translations = {
|
|||||||
telegramImport: 'Telegram Import',
|
telegramImport: 'Telegram Import',
|
||||||
serverDesc: 'Server Description',
|
serverDesc: 'Server Description',
|
||||||
successSave: 'Settings saved',
|
successSave: 'Settings saved',
|
||||||
|
successClean: 'Cleanup completed',
|
||||||
errorSave: 'Save failed',
|
errorSave: 'Save failed',
|
||||||
errorInvalidLogin: 'Invalid credentials',
|
errorInvalidLogin: 'Invalid credentials',
|
||||||
errorDelete: 'Delete failed',
|
errorDelete: 'Delete failed',
|
||||||
@@ -414,6 +415,7 @@ const translations = {
|
|||||||
telegramImport: 'Импорт Telegram',
|
telegramImport: 'Импорт Telegram',
|
||||||
serverDesc: 'Описание сервера',
|
serverDesc: 'Описание сервера',
|
||||||
successSave: 'Сохранено',
|
successSave: 'Сохранено',
|
||||||
|
successClean: 'Очищено',
|
||||||
errorSave: 'Ошибка сохранения',
|
errorSave: 'Ошибка сохранения',
|
||||||
errorInvalidLogin: 'Неверный логин или пароль',
|
errorInvalidLogin: 'Неверный логин или пароль',
|
||||||
errorDelete: 'Ошибка удаления',
|
errorDelete: 'Ошибка удаления',
|
||||||
@@ -745,7 +747,7 @@ export default function AdminPage() {
|
|||||||
const handleRunCleanup = async () => {
|
const handleRunCleanup = async () => {
|
||||||
try {
|
try {
|
||||||
await httpClient.request('/admin/clean/run', { method: 'POST' });
|
await httpClient.request('/admin/clean/run', { method: 'POST' });
|
||||||
showToast(t.successSave, 'success');
|
showToast(t.successClean, 'success');
|
||||||
setCleanStats(null);
|
setCleanStats(null);
|
||||||
fetchDashboard();
|
fetchDashboard();
|
||||||
} catch { showToast(t.errorSave, 'error'); }
|
} catch { showToast(t.errorSave, 'error'); }
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user