16 Commits
Author SHA1 Message Date
Халимов Рустам 8399d32490 Редактирование и плеер 2026-04-08 15:23:10 +03:00
Халимов Рустам 3905094ff4 Правка миграций 2026-04-07 21:22:38 +03:00
Халимов Рустам 9e8625aea1 Миграции 2026-04-07 21:20:06 +03:00
Халимов Рустам 5b905c94da Компоуз 2026-04-07 21:12:34 +03:00
Халимов Рустам 0eecc01374 Сборка 2026-04-07 21:05:23 +03:00
Халимов Рустам c37e1723d4 Фикс .env 2026-04-07 17:29:22 +03:00
Халимов Рустам 02043d4d97 Минимальная адаптация под мобилку 2026-04-07 12:29:41 +03:00
Халимов Рустам 852efa090e Опросы 2026-04-07 11:11:35 +03:00
Халимов Рустам c45f4db61c Заготовка опросов 2026-04-07 01:01:29 +03:00
Халимов Рустам 02a85fc587 Прокрутка 2026-04-06 23:42:46 +03:00
Халимов Рустам e09860700c Удален мусор 2026-04-06 23:36:42 +03:00
Халимов Рустам 32c9bc43cf Починка импорта, плеер для аудио 2026-04-06 23:35:45 +03:00
Халимов Рустам d96e4ec7d4 Правка импорта 2026-04-06 22:35:54 +03:00
Халимов Рустам 1558b20470 Импорт 2026-04-06 22:20:13 +03:00
Халимов Рустам fa185afc73 Информация о звонках в чате 2026-04-06 01:51:48 +03:00
Халимов Рустам 90096ce2bc Звонки 2026-04-06 01:27:27 +03:00
146 changed files with 4479 additions and 5398 deletions
@@ -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; }
} }
+7 -2
View File
@@ -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();
@@ -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", () =>
{ {
// Получаем все системные часовые пояса и формируем удобный для фронтенда формат // Получаем все системные часовые пояса и формируем удобный для фронтенда формат
@@ -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;
@@ -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,
var reactionsWithUser = new List<ReactionDto>(); usersInfo,
foreach (var reaction in latestReactions) latestReactions,
{ chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId)));
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 readByList = chat.Members var pinnedDtoList = new List<PinnedMessageDto>();
.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId) foreach (var pm in pinnedMessages)
.Select(m => new ReadByDto(m.UserId)) {
.ToList(); pinnedDtoList.Add(new PinnedMessageDto(
pm.Id,
var textMessage = latestMessage as TextMessage; MessageMapper.MapToDto(pm, usersInfo, new List<MessageReaction>(), new List<Guid>())
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,
var reactionsWithUser = new List<ReactionDto>(); usersInfo,
foreach (var reaction in latestReactions) latestReactions,
{ chat.Members.Where(m => m.LastReadSequenceId >= latestMessage.SequenceId && m.UserId != latestMessage.SenderId).Select(m => m.UserId)));
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 = latestMessage as TextMessage; var pinnedDtoList = new List<PinnedMessageDto>();
var mediaMessage = latestMessage as MediaMessage; foreach (var pm in pinnedMessages)
var storyMessage = latestMessage as StoryMessage; {
pinnedDtoList.Add(new PinnedMessageDto(
messagesList.Add(new ChatMessageDto( pm.Id,
latestMessage.Id, MessageMapper.MapToDto(pm, usersInfo, new List<MessageReaction>(), new List<Guid>())
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
)); ));
} }
@@ -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;
@@ -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
);
@@ -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;
@@ -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;
@@ -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();
}
}
@@ -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);
} }
List<Message> messages;
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
if (request.Pivot.HasValue)
{
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
}
else
{
DateTime? cursorDate = null; DateTime? cursorDate = null;
if (!string.IsNullOrEmpty(request.Cursor) && DateTime.TryParse(request.Cursor, null, System.Globalization.DateTimeStyles.RoundtripKind, out var parsed)) 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(); 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);
} }
} }
@@ -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()
)); ));
} }
} }
@@ -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;
@@ -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;
@@ -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;
@@ -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;
@@ -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);
@@ -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,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();
}
}
@@ -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;
@@ -54,9 +54,11 @@ internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserComma
if (msg is MediaMessage mediaMsg) if (msg is MediaMessage mediaMsg)
{ {
foreach (var media in mediaMsg.Media) foreach (var media in mediaMsg.Media)
{
if (!string.IsNullOrEmpty(media.Url))
{ {
bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id && bool isUsedElsewhere = allMessages.Any(m => m.Id != msg.Id &&
m is MediaMessage mm && mm.Media.Any(ame => ame.Url == media.Url)); m is MediaMessage mm && mm.Media.Any(ame => !string.IsNullOrEmpty(ame.Url) && ame.Url == media.Url));
if (!isUsedElsewhere) if (!isUsedElsewhere)
{ {
@@ -69,6 +71,7 @@ internal sealed class DeleteUserCommandHandler : ICommandHandler<DeleteUserComma
} }
} }
} }
}
await _messageRepository.DeleteUserMessagesAsync(request.UserId, cancellationToken); await _messageRepository.DeleteUserMessagesAsync(request.UserId, cancellationToken);
@@ -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,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;
@@ -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,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,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);
}
}
@@ -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()
@@ -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");
}
}
}
@@ -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;
@@ -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
@@ -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);
} }
@@ -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,50 +58,53 @@ 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");
try
{
await using (var fs = new FileStream(tempPath, FileMode.Create)) await using (var fs = new FileStream(tempPath, FileMode.Create))
{ {
await request.FileStream.CopyToAsync(fs, cancellationToken); await request.FileStream.CopyToAsync(fs, cancellationToken);
} }
var names = new HashSet<string>(); var names = new HashSet<string>();
int totalMessages = 0;
string? groupName = null;
using (var archive = ZipFile.OpenRead(tempPath)) using (var archive = ZipFile.OpenRead(tempPath))
{ {
var htmlEntries = archive.Entries var htmlEntries = archive.Entries
.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase)); .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) foreach (var entry in htmlEntries)
{ {
using var stream = entry.Open(); using var stream = entry.Open();
var parser = new HtmlParser();
var doc = parser.ParseDocument(stream);
var messageNodes = doc.QuerySelectorAll(".message"); if (string.IsNullOrEmpty(groupName))
if (messageNodes == null)
{ {
continue; groupName = await _htmlParser.ExtractGroupNameAsync(stream, cancellationToken);
// Reset stream position if possible? No, entry.Open() returns a new stream.
// But wait! ExtractGroupNameAsync consumess the stream!
// I'll reopen it for messages if it's the same entry.
} }
foreach (var node in messageNodes) using var stream2 = entry.Open();
var messages = await _htmlParser.ParseMessagesAsync(stream2, "", cancellationToken);
totalMessages += messages.Count;
foreach (var m in messages)
{ {
var fromNameNode = node.QuerySelector(".from_name"); if (!string.IsNullOrEmpty(m.SenderName))
if (fromNameNode != null) names.Add(m.SenderName);
{ }
var nameNodeText = (IElement)fromNameNode.Clone(); }
var innerSpans = nameNodeText.QuerySelectorAll("span");
foreach (var span in innerSpans)
{
span.Remove();
} }
var name = nameNodeText.TextContent.Trim(); if (names.Count == 0 && totalMessages == 0)
if (!string.IsNullOrWhiteSpace(name))
{ {
names.Add(name); return Result.Failure<AnalyzeImportResponseDto>(new Error("TelegramImport.NoMessagesFound", "В архиве не найдено сообщений Telegram или формат HTML не распознан."));
}
}
}
}
} }
// Анализ конфликтов политик // Анализ конфликтов политик
@@ -113,7 +122,13 @@ internal sealed class AnalyzeImportCommandHandler : ICommandHandler<AnalyzeImpor
TelegramImportState.TempZips[token] = tempPath; TelegramImportState.TempZips[token] = tempPath;
return Result.Success(new AnalyzeImportResponseDto(token, names.ToList(), conflicts)); 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}"));
}
} }
} }
@@ -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
); );
@@ -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);
@@ -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;
} }
} }
@@ -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(); // Упростим логику для демонстрации рефакторинга
// TRY TO FIND THE BEST ENCODING (UTF8 or CP866)
ZipArchive archive;
try
{
// Try UTF8 first
archive = ZipFile.OpenRead(zipPath);
var messagesHtml = archive.Entries.FirstOrDefault(e => e.Name.Equals("messages.html", StringComparison.OrdinalIgnoreCase));
if (messagesHtml == null)
{
// If not found in root, maybe it's CP866
archive.Dispose();
archive = ZipFile.Open(zipPath, ZipArchiveMode.Read, Encoding.GetEncoding(866));
}
}
catch
{
archive = ZipFile.OpenRead(zipPath);
}
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) 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(); using var stream = entry.Open();
var messages = await parser.ParseMessagesAsync(stream, "", ct); var messages = await parser.ParseMessagesAsync(stream, baseDir, ct);
allMessages.AddRange(messages);
}
foreach (var m in 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 senderGuid = request.Mapping.TryGetValue(m.SenderName ?? "", out var sid) ? sid : request.CurrentUserId; 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;
var textMsg = new TextMessage(Guid.NewGuid(), targetChatId, senderGuid, m.Content, null, null, null, m.CreatedAt, true); Guid knotMsgId = Guid.NewGuid();
msgRepo.Add(textMsg); Message? knotMsg = null;
jobInfo.ProcessedMessages++; 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); _jobStore.AddOrUpdate(jobInfo);
} }
await uow.SaveChangesAsync(ct);
} }
chat.CompleteImport();
await uow.SaveChangesAsync(ct);
_logger.LogInformation("Import Completed. Messages: {Count}", processedCount);
jobInfo.ProcessedMessages = processedCount;
jobInfo.Status = ImportJobStatus.Completed; 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");
} }
catch { return null; }
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 (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"
};
}
} }
@@ -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,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
View File
@@ -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 />
+10
View File
@@ -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;
+77 -1
View File
@@ -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 && (
+51
View File
@@ -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