diff --git a/apps/server-net/src/Host/Controllers/TelegramImportController.cs b/apps/server-net/src/Host/Controllers/TelegramImportController.cs new file mode 100644 index 0000000..6779f55 --- /dev/null +++ b/apps/server-net/src/Host/Controllers/TelegramImportController.cs @@ -0,0 +1,282 @@ +using System; +using System.Collections.Concurrent; +using System.Collections.Generic; +using System.IO; +using System.IO.Compression; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using HtmlAgilityPack; +using MediatR; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.SignalR; +using Knot.Modules.Chats.Application.Abstractions; +using Knot.Modules.Chats.Domain; +using Knot.Modules.Identity.Domain; +using Knot.Shared.Kernel; +using Knot.Shared.Kernel.Storage; +using Knot.Modules.Chats.Infrastructure.SignalR; +using Knot.Modules.Chats.Application.Chats.Create; + +namespace Host.Controllers; + +[Authorize] +[ApiController] +[Route("api/import/telegram")] +public sealed class TelegramImportController : ControllerBase +{ + private readonly ISender _sender; + private readonly IUserContext _userContext; + private readonly IChatsUnitOfWork _unitOfWork; + private readonly IChatRepository _chatRepository; + private readonly IMessageRepository _messageRepository; + private readonly IFileStorageService _fileStorage; + private readonly IHubContext _hubContext; + + // In-memory store for uploaded zips (good enough for typical cases, ideally should be removed after use or by timer) + private static readonly ConcurrentDictionary _tempZips = new(); + + public TelegramImportController( + ISender sender, + IUserContext userContext, + IChatsUnitOfWork unitOfWork, + IChatRepository chatRepository, + IMessageRepository messageRepository, + IFileStorageService fileStorage, + IHubContext hubContext) + { + _sender = sender; + _userContext = userContext; + _unitOfWork = unitOfWork; + _chatRepository = chatRepository; + _messageRepository = messageRepository; + _fileStorage = fileStorage; + _hubContext = hubContext; + } + + [HttpPost("analyze")] + [DisableRequestSizeLimit] + [RequestFormLimits(MultipartBodyLengthLimit = 10L * 1024 * 1024 * 1024)] // 10GB for big exports + public async Task Analyze(IFormFile file, CancellationToken ct) + { + if (file == null || file.Length == 0) return BadRequest("No file uploaded"); + if (!file.FileName.EndsWith(".zip", StringComparison.OrdinalIgnoreCase)) return BadRequest("Must be a ZIP archive"); + + var token = Guid.NewGuid(); + var tempPath = Path.Combine(Path.GetTempPath(), $"{token}.zip"); + + await using (var fs = new FileStream(tempPath, FileMode.Create)) + { + await file.CopyToAsync(fs, ct); + } + + var names = new HashSet(); + + // Open zip and quickly scan messages.html + using (var archive = ZipFile.OpenRead(tempPath)) + { + var htmlEntries = archive.Entries.Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase)); + + foreach (var entry in htmlEntries) + { + using var stream = entry.Open(); + var doc = new HtmlDocument(); + doc.Load(stream); + + var messageNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'message ')]"); + if (messageNodes == null) continue; + + foreach (var node in messageNodes) + { + var fromNameNode = node.SelectSingleNode(".//div[contains(@class, 'from_name')]"); + if (fromNameNode != null) + { + var name = fromNameNode.InnerText.Trim(); + // Ignore standard system names if obvious (for now everything is recorded) + if (!string.IsNullOrWhiteSpace(name)) + { + names.Add(name); + } + } + } + } + } + + _tempZips[token] = tempPath; + + return Ok(new + { + token, + names = names.ToList() + }); + } + + public sealed record ExecuteImportRequest(Guid Token, Dictionary Mapping); + + [HttpPost("execute")] + public async Task Execute([FromBody] ExecuteImportRequest req, CancellationToken ct) + { + if (!_tempZips.TryGetValue(req.Token, out var tempPath)) + return BadRequest("Session not found or expired"); + + if (!System.IO.File.Exists(tempPath)) + return BadRequest("ZIP file lost"); + + var myId = _userContext.UserId; + // Collect targeted users to check whose chat it is. Find the friend. + // Usually, the mapping contains MyId and FriendId. + var targetUserIds = req.Mapping.Values.Distinct().Where(id => id != Guid.Empty).ToList(); + if (!targetUserIds.Contains(myId)) targetUserIds.Add(myId); + + Guid chatId = Guid.Empty; + var chatMembers = targetUserIds; + + if (chatMembers.Count <= 2) + { + // Find existing personal chat + var existingChats = await _chatRepository.GetUserChatsAsync(myId, ct); + var personalChat = existingChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.All(m => chatMembers.Contains(m.UserId)) && c.Members.Count == chatMembers.Count); + + if (personalChat != null) + { + chatId = personalChat.Id; + } + else + { + // Create personal chat + var friendId = chatMembers.FirstOrDefault(id => id != myId); + if (friendId == Guid.Empty) friendId = myId; // Notes to self + var command = new CreateChatCommand(string.Empty, ChatType.Personal, new List { myId, friendId }); + var res = await _sender.Send(command, ct); + if (res.IsFailure) return BadRequest(res.Error); + chatId = res.Value; + } + } + else + { + // Create a group + var command = new CreateChatCommand("Импортированный чат", ChatType.Group, chatMembers); + var res = await _sender.Send(command, ct); + if (res.IsFailure) return BadRequest(res.Error); + chatId = res.Value; + } + + int importedCount = 0; + + using (var archive = ZipFile.OpenRead(tempPath)) + { + var htmlEntries = archive.Entries + .Where(e => e.FullName.EndsWith(".html", StringComparison.OrdinalIgnoreCase) && e.Name.StartsWith("messages", StringComparison.OrdinalIgnoreCase)) + .OrderBy(e => e.FullName); // Read chronologically + + foreach (var entry in htmlEntries) + { + using var stream = entry.Open(); + var doc = new HtmlDocument(); + doc.Load(stream); + + var messageNodes = doc.DocumentNode.SelectNodes("//div[contains(@class, 'message ')]"); + if (messageNodes == null) continue; + + var baseDir = Path.GetDirectoryName(entry.FullName)?.Replace("\\", "/") ?? ""; + if (!string.IsNullOrEmpty(baseDir) && !baseDir.EndsWith("/")) baseDir += "/"; + + foreach (var node in messageNodes) + { + try + { + var fromNameNode = node.SelectSingleNode(".//div[contains(@class, 'from_name')]"); + var textNode = node.SelectSingleNode(".//div[contains(@class, 'text')]"); + var dateNode = node.SelectSingleNode(".//div[contains(@class, 'pull_right date')]"); + + // Default values + var senderGuid = myId; // Fallback + if (fromNameNode != null) + { + var name = fromNameNode.InnerText.Trim(); + if (req.Mapping.TryGetValue(name, out var mappedId) && mappedId != Guid.Empty) + senderGuid = mappedId; + } + + var content = textNode?.InnerText?.Trim() ?? ""; + + DateTime createdAt = DateTime.UtcNow; + if (dateNode != null && dateNode.Attributes["title"] != null) + { + var dateStr = dateNode.Attributes["title"].Value; + if (DateTime.TryParse(dateStr, out var d)) + createdAt = d.ToUniversalTime(); + } + + var mediaNodes = node.SelectNodes(".//a[contains(@class, 'photo_wrap')] | .//video | .//a[contains(@class, 'document')] | .//a[contains(@class, 'media_voice_message')]"); + + var messageType = "text"; + if (mediaNodes != null && mediaNodes.Count > 0) + { + var firstHref = mediaNodes[0].Attributes["href"]?.Value ?? mediaNodes[0].Attributes["src"]?.Value; + if (firstHref != null) + { + if (firstHref.EndsWith(".jpg") || firstHref.EndsWith(".png")) messageType = "image"; + else if (firstHref.EndsWith(".mp4")) messageType = "video"; + else if (firstHref.EndsWith(".ogg")) messageType = "voice"; + else messageType = "file"; + } + } + + var newMessage = Message.Import(chatId, senderGuid, content, messageType, createdAt); + + if (mediaNodes != null) + { + foreach (var mediaNode in mediaNodes) + { + string? href = mediaNode.Attributes["href"]?.Value ?? mediaNode.Attributes["src"]?.Value; + if (!string.IsNullOrEmpty(href) && !href.StartsWith("http")) + { + // Local file in zip + var zipPath = baseDir + href.Replace("\\", "/"); + var zipEntry = archive.GetEntry(zipPath); + if (zipEntry != null) + { + using var ms = new MemoryStream(); + using var zipfs = zipEntry.Open(); + await zipfs.CopyToAsync(ms, ct); + ms.Position = 0; + + string cType = "application/octet-stream"; + var mType = "file"; + if (href.EndsWith(".jpg") || href.EndsWith(".png")) { cType = "image/jpeg"; mType = "image"; messageType = "image"; } + else if (href.EndsWith(".mp4")) { cType = "video/mp4"; mType = "video"; messageType = "video"; } + else if (href.EndsWith(".ogg")) { cType = "audio/ogg"; mType = "voice"; messageType = "voice"; } + + var fileId = await _fileStorage.UploadFileAsync(ms, Path.GetFileName(href), cType); + newMessage.AddMedia(mType, $"/api/files/{fileId}", Path.GetFileName(href), zipEntry.Length); + } + } + } + } + + // Save text message without media if content is just empty, but wait, if it's empty and has no media, it was probably a system message (like pinned, joined). + if (!string.IsNullOrEmpty(content) || newMessage.Media.Any()) + { + _messageRepository.Add(newMessage); + importedCount++; + } + } + catch { /* ignore single message parse error */ } + } + } + } + + await _unitOfWork.SaveChangesAsync(ct); + + // Delete temp zip + try { System.IO.File.Delete(tempPath); _tempZips.TryRemove(req.Token, out _); } catch { } + + // Notify UI for all members of the chat + await _hubContext.Clients.Users(chatMembers.Select(x => x.ToString())).SendAsync("history_updated", new { chatId }); + + return Ok(new { success = true, messagesImported = importedCount, chatId }); + } +} diff --git a/apps/server-net/src/Host/Host.csproj b/apps/server-net/src/Host/Host.csproj index 1885ac4..ef55eab 100644 --- a/apps/server-net/src/Host/Host.csproj +++ b/apps/server-net/src/Host/Host.csproj @@ -8,6 +8,7 @@ + diff --git a/apps/server-net/src/Modules/Chats/Domain/Message.cs b/apps/server-net/src/Modules/Chats/Domain/Message.cs index 6a6e38c..1092559 100644 --- a/apps/server-net/src/Modules/Chats/Domain/Message.cs +++ b/apps/server-net/src/Modules/Chats/Domain/Message.cs @@ -25,6 +25,7 @@ public sealed class Message : AggregateRoot public string? StoryMediaUrl { get; private set; } public string? StoryMediaType { get; private set; } public DateTime CreatedAt { get; private set; } + public bool IsImported { get; private set; } private readonly List _media = new(); public IReadOnlyCollection Media => _media.AsReadOnly(); @@ -35,7 +36,9 @@ public sealed class Message : AggregateRoot private readonly List _deletedByUsers = new(); public IReadOnlyCollection DeletedByUsers => _deletedByUsers.AsReadOnly(); - private Message(Guid id, Guid chatId, Guid senderId, string? content, string type, Guid? replyToId = null, string? quote = null, Guid? forwardedFromId = null, Guid? storyId = null, string? storyMediaUrl = null, string? storyMediaType = null) : base(id) + private Message() : base(Guid.Empty) { Type = "text"; } + + private Message(Guid id, Guid chatId, Guid senderId, string? content, string type, Guid? replyToId, string? quote, Guid? forwardedFromId, Guid? storyId, string? storyMediaUrl, string? storyMediaType, DateTime createdAt, bool isImported) : base(id) { ChatId = chatId; SenderId = senderId; @@ -47,14 +50,23 @@ public sealed class Message : AggregateRoot StoryId = storyId; StoryMediaUrl = storyMediaUrl; StoryMediaType = storyMediaType; - CreatedAt = DateTime.UtcNow; + CreatedAt = createdAt; + IsImported = isImported; - RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content)); + if (!isImported) // Don't trigger realtime events for historic messages + { + RaiseDomainEvent(new MessageSentDomainEvent(Id, ChatId, SenderId, Content)); + } } public static Message Create(Guid chatId, Guid senderId, string? content, string type, Guid? replyToId = null, string? quote = null, Guid? forwardedFromId = null, Guid? storyId = null, string? storyMediaUrl = null, string? storyMediaType = null) { - return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, quote, forwardedFromId, storyId, storyMediaUrl, storyMediaType); + return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, quote, forwardedFromId, storyId, storyMediaUrl, storyMediaType, DateTime.UtcNow, false); + } + + public static Message Import(Guid chatId, Guid senderId, string? content, string type, DateTime createdAt, Guid? replyToId = null) + { + return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, null, null, null, null, null, createdAt, true); } public void AddMedia(string type, string url, string? filename, long? size) diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260316142303_AddIsImportedToMessage.Designer.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260316142303_AddIsImportedToMessage.Designer.cs new file mode 100644 index 0000000..7c6ba72 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260316142303_AddIsImportedToMessage.Designer.cs @@ -0,0 +1,266 @@ +// +using System; +using Knot.Modules.Chats.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.Chats.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ChatsDbContext))] + [Migration("20260316142303_AddIsImportedToMessage")] + partial class AddIsImportedToMessage + { + /// + 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.Modules.Chats.Domain.Chat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Chats", "chats"); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection("DeletedByUsers") + .IsRequired() + .HasColumnType("uuid[]") + .HasColumnName("DeletedByUsers"); + + b.Property("ForwardedFromId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEdited") + .HasColumnType("boolean"); + + b.Property("IsImported") + .HasColumnType("boolean"); + + b.Property("Quote") + .HasColumnType("text"); + + b.Property("ReplyToId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("StoryId") + .HasColumnType("uuid"); + + b.Property("StoryMediaType") + .HasColumnType("text"); + + b.Property("StoryMediaUrl") + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Messages", "chats"); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Emoji") + .IsRequired() + .HasColumnType("text"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MessageId", "UserId", "Emoji") + .IsUnique(); + + b.ToTable("MessageReactions", "chats"); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("ReadReceipts", "chats"); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b => + { + b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("ChatId") + .HasColumnType("uuid"); + + b1.Property("IsMuted") + .HasColumnType("boolean"); + + b1.Property("IsPinned") + .HasColumnType("boolean"); + + b1.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b1.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b1.Property("UserId") + .HasColumnType("uuid"); + + b1.HasKey("Id"); + + b1.HasIndex("ChatId", "UserId") + .IsUnique(); + + b1.ToTable("ChatMembers", "chats"); + + b1.WithOwner() + .HasForeignKey("ChatId"); + }); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b => + { + b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 => + { + b1.Property("MessageId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("Filename") + .HasColumnType("text"); + + b1.Property("Size") + .HasColumnType("bigint"); + + b1.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MessageId", "Id"); + + b1.ToTable("MessageMedia", "chats"); + + b1.WithOwner() + .HasForeignKey("MessageId"); + }); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b => + { + b.HasOne("Knot.Modules.Chats.Domain.Message", null) + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b => + { + b.HasOne("Knot.Modules.Chats.Domain.Message", null) + .WithMany("ReadBy") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b => + { + b.Navigation("Reactions"); + + b.Navigation("ReadBy"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260316142303_AddIsImportedToMessage.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260316142303_AddIsImportedToMessage.cs new file mode 100644 index 0000000..2350d9d --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260316142303_AddIsImportedToMessage.cs @@ -0,0 +1,31 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddIsImportedToMessage : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsImported", + schema: "chats", + table: "Messages", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsImported", + schema: "chats", + table: "Messages"); + } + } +} diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260316143533_RemoveIsImportedDefault.Designer.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260316143533_RemoveIsImportedDefault.Designer.cs new file mode 100644 index 0000000..732d4c7 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260316143533_RemoveIsImportedDefault.Designer.cs @@ -0,0 +1,266 @@ +// +using System; +using Knot.Modules.Chats.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.Chats.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ChatsDbContext))] + [Migration("20260316143533_RemoveIsImportedDefault")] + partial class RemoveIsImportedDefault + { + /// + 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.Modules.Chats.Domain.Chat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Chats", "chats"); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("uuid"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.PrimitiveCollection("DeletedByUsers") + .IsRequired() + .HasColumnType("uuid[]") + .HasColumnName("DeletedByUsers"); + + b.Property("ForwardedFromId") + .HasColumnType("uuid"); + + b.Property("IsDeleted") + .HasColumnType("boolean"); + + b.Property("IsEdited") + .HasColumnType("boolean"); + + b.Property("IsImported") + .HasColumnType("boolean"); + + b.Property("Quote") + .HasColumnType("text"); + + b.Property("ReplyToId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("StoryId") + .HasColumnType("uuid"); + + b.Property("StoryMediaType") + .HasColumnType("text"); + + b.Property("StoryMediaUrl") + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Messages", "chats"); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Emoji") + .IsRequired() + .HasColumnType("text"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MessageId", "UserId", "Emoji") + .IsUnique(); + + b.ToTable("MessageReactions", "chats"); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReadAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("ReadReceipts", "chats"); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.Chat", b => + { + b.OwnsMany("Knot.Modules.Chats.Domain.ChatMember", "Members", b1 => + { + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("ChatId") + .HasColumnType("uuid"); + + b1.Property("IsMuted") + .HasColumnType("boolean"); + + b1.Property("IsPinned") + .HasColumnType("boolean"); + + b1.Property("JoinedAt") + .HasColumnType("timestamp with time zone"); + + b1.Property("Role") + .IsRequired() + .HasColumnType("text"); + + b1.Property("UserId") + .HasColumnType("uuid"); + + b1.HasKey("Id"); + + b1.HasIndex("ChatId", "UserId") + .IsUnique(); + + b1.ToTable("ChatMembers", "chats"); + + b1.WithOwner() + .HasForeignKey("ChatId"); + }); + + b.Navigation("Members"); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b => + { + b.OwnsMany("Knot.Modules.Chats.Domain.Media", "Media", b1 => + { + b1.Property("MessageId") + .HasColumnType("uuid"); + + b1.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b1.Property("Filename") + .HasColumnType("text"); + + b1.Property("Size") + .HasColumnType("bigint"); + + b1.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b1.Property("Url") + .IsRequired() + .HasColumnType("text"); + + b1.HasKey("MessageId", "Id"); + + b1.ToTable("MessageMedia", "chats"); + + b1.WithOwner() + .HasForeignKey("MessageId"); + }); + + b.Navigation("Media"); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.Reaction", b => + { + b.HasOne("Knot.Modules.Chats.Domain.Message", null) + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.ReadReceipt", b => + { + b.HasOne("Knot.Modules.Chats.Domain.Message", null) + .WithMany("ReadBy") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Knot.Modules.Chats.Domain.Message", b => + { + b.Navigation("Reactions"); + + b.Navigation("ReadBy"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260316143533_RemoveIsImportedDefault.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260316143533_RemoveIsImportedDefault.cs new file mode 100644 index 0000000..eb0162e --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260316143533_RemoveIsImportedDefault.cs @@ -0,0 +1,22 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations +{ + /// + public partial class RemoveIsImportedDefault : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + + } + } +} diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/ChatsDbContextModelSnapshot.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/ChatsDbContextModelSnapshot.cs index 1c2ee7b..128b775 100644 --- a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/ChatsDbContextModelSnapshot.cs +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/ChatsDbContextModelSnapshot.cs @@ -1,10 +1,10 @@ -// +// using System; +using Knot.Modules.Chats.Infrastructure.Persistence; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; -using Knot.Modules.Chats.Infrastructure.Persistence; #nullable disable @@ -79,6 +79,9 @@ namespace Knot.Modules.Chats.Infrastructure.Persistence.Migrations b.Property("IsEdited") .HasColumnType("boolean"); + b.Property("IsImported") + .HasColumnType("boolean"); + b.Property("Quote") .HasColumnType("text"); diff --git a/apps/server-net/src/Modules/Chats/Knot.Modules.Chats.csproj b/apps/server-net/src/Modules/Chats/Knot.Modules.Chats.csproj index ce36859..2352201 100644 --- a/apps/server-net/src/Modules/Chats/Knot.Modules.Chats.csproj +++ b/apps/server-net/src/Modules/Chats/Knot.Modules.Chats.csproj @@ -5,6 +5,7 @@ + diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 789e62c..861f4c0 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -102,12 +102,25 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const typingInChat = typingUsers.filter((t) => t.chatId === activeChat && t.userId !== user?.id); + // Refs and logic for tracking session's first unread message to show the divider exactly once per load + const sessionUnreadRef = useRef<{ chatId: string, msgId: string | null }>({ chatId: '', msgId: null }); + + if (activeChat && activeChat !== sessionUnreadRef.current.chatId && !isLoadingMessages) { + const firstUnreadMsg = chatMessages.find( + (m) => m.senderId !== user?.id && !m.readBy?.some((r) => r.userId === user?.id) + ); + sessionUnreadRef.current = { chatId: activeChat, msgId: firstUnreadMsg ? firstUnreadMsg.id : null }; + } + const firstUnreadId = activeChat === sessionUnreadRef.current.chatId ? sessionUnreadRef.current.msgId : null; + const lastObservedMessageIdRef = useRef(null); + // Load muted state useEffect(() => { if (activeChat) { setMuted(isChatMuted(activeChat)); setScrollReady(false); setActiveGroupCallParticipants([]); + lastObservedMessageIdRef.current = null; } }, [activeChat]); @@ -167,7 +180,23 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal // Первичная прокрутка при открытии чата или после загрузки (layout effect — до отрисовки) useLayoutEffect(() => { if (!isLoadingMessages && messagesContainerRef.current) { - messagesContainerRef.current.scrollTop = messagesContainerRef.current.scrollHeight; + const container = messagesContainerRef.current; + const unreadId = sessionUnreadRef.current.msgId; + + if (unreadId && activeChat === sessionUnreadRef.current.chatId) { + const dividerEl = document.getElementById('unread-divider'); + const unreadEl = document.getElementById(`msg-${unreadId}`); + const targetEl = dividerEl || unreadEl; + + if (targetEl) { + // Вычитаем отступ сверху, чтобы начало непрочитанных было под шапкой + container.scrollTop = targetEl.offsetTop - 60; + } else { + container.scrollTop = container.scrollHeight; + } + } else { + container.scrollTop = container.scrollHeight; + } setScrollReady(true); } }, [activeChat, isLoadingMessages]); @@ -176,17 +205,25 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal useEffect(() => { if (chatMessages.length > 0) { const lastMsg = chatMessages[chatMessages.length - 1]; - if (lastMsg.senderId === user?.id) { - setTimeout(() => scrollToBottom(true), 50); - } else { - // Если пользователь внизу — прокрутить - const container = messagesContainerRef.current; - if (container) { - const isNearBottom = - container.scrollHeight - container.scrollTop - container.clientHeight < 250; - if (isNearBottom) setTimeout(() => scrollToBottom(true), 50); + const prevId = lastObservedMessageIdRef.current; + lastObservedMessageIdRef.current = lastMsg.id; + + // Scroll ONLY if a genuinely new message was added (not during initial chat load) + if (prevId && prevId !== lastMsg.id) { + if (lastMsg.senderId === user?.id) { + setTimeout(() => scrollToBottom(true), 50); + } else { + // Если пользователь внизу — прокрутить + const container = messagesContainerRef.current; + if (container) { + const isNearBottom = + container.scrollHeight - container.scrollTop - container.clientHeight < 300; + if (isNearBottom) setTimeout(() => scrollToBottom(true), 50); + } } } + } else { + lastObservedMessageIdRef.current = null; } }, [chatMessages.length, user?.id, scrollToBottom]); @@ -273,7 +310,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const container = messagesContainerRef.current; if (!container) return; const isNearBottom = - container.scrollHeight - container.scrollTop - container.clientHeight < 200; + container.scrollHeight - container.scrollTop - container.clientHeight < 300; setShowScrollDown(!isNearBottom); }, []); @@ -321,29 +358,20 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal if (!activeChat || !chat) { return (
- {/* Slowly pulsing purple background as requested */} -
-
-
-
- -
-
-
- + Knot Messenger @@ -351,7 +379,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal initial={{ y: 20, opacity: 0 }} animate={{ y: 0, opacity: 1 }} transition={{ duration: 0.6, delay: 0.2, ease: [0.16, 1, 0.3, 1] }} - className="text-sm font-medium text-zinc-300 bg-white/5 backdrop-blur-lg py-2.5 px-6 rounded-full inline-flex border border-white/10 shadow-lg" + className="text-sm text-zinc-400 bg-surface-hover backdrop-blur-lg py-2 px-4 rounded-full inline-flex border border-border/50" > {t('selectChat')} @@ -743,8 +771,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const el = document.getElementById(`msg-${msg.id}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - el.classList.add('bg-knot-500/20'); - setTimeout(() => el.classList.remove('bg-knot-500/20'), 2000); + el.classList.add('highlight-message'); + setTimeout(() => el.classList.remove('highlight-message'), 3000); } setShowSearch(false); setSearchText(''); @@ -790,8 +818,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const el = document.getElementById(`msg-${pinnedMsg.id}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - el.classList.add('bg-knot-500/20'); - setTimeout(() => el.classList.remove('bg-knot-500/20'), 2000); + el.classList.add('highlight-message'); + setTimeout(() => el.classList.remove('highlight-message'), 3000); } }} className="flex items-center gap-3 px-4 py-2 border-b border-border bg-surface-secondary/60 hover:bg-surface-hover transition-colors text-left w-full flex-shrink-0" @@ -839,6 +867,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const showDate = !prevMsg || new Date(msg.createdAt).toDateString() !== new Date(prevMsg.createdAt).toDateString(); + + const isFirstUnread = firstUnreadId === msg.id; return (
r.userId === user?.id) ? 'unread-detector' : ''}`} > + {isFirstUnread && ( +
+
+ + {t('unreadMessages')} + +
+
+ )} {showDate && (
@@ -886,9 +925,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal animate={{ scale: 1, opacity: 1 }} exit={{ scale: 0, opacity: 0 }} onClick={() => scrollToBottom()} - className="absolute bottom-24 right-6 w-11 h-11 rounded-full bg-surface-tertiary/90 backdrop-blur-md border border-border shadow-2xl flex items-center justify-center text-zinc-400 hover:text-white hover:bg-surface-hover hover:scale-105 transition-all z-10" + className="absolute bottom-24 right-5 w-12 h-12 rounded-full bg-surface-secondary/95 backdrop-blur-md border border-border shadow-xl flex items-center justify-center text-zinc-400 hover:text-white hover:bg-surface-hover hover:scale-105 transition-all z-10" > - + {unreadCount > 0 && ( el.classList.remove('bg-knot-500/20'), 2000); + el.classList.add('highlight-message'); + setTimeout(() => el.classList.remove('highlight-message'), 3000); setProfileUserId(null); } }} @@ -943,8 +982,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const el = document.getElementById(`msg-${msgId}`); if (el) { el.scrollIntoView({ behavior: 'smooth', block: 'center' }); - el.classList.add('bg-knot-500/20'); - setTimeout(() => el.classList.remove('bg-knot-500/20'), 2000); + el.classList.add('highlight-message'); + setTimeout(() => el.classList.remove('highlight-message'), 3000); setShowGroupSettings(false); } }} diff --git a/apps/web/src/components/EmojiPicker.tsx b/apps/web/src/components/EmojiPicker.tsx index f5880d7..00f9e1c 100644 --- a/apps/web/src/components/EmojiPicker.tsx +++ b/apps/web/src/components/EmojiPicker.tsx @@ -141,28 +141,28 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic <>
{/* Tabs */} -
+
{config?.enableKlipy && onSelectGif && ( @@ -183,7 +183,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic emojiSize={28} emojiButtonSize={36} maxFrequentRows={2} - navPosition="bottom" + navPosition="top" dynamicWidth={false} /> )} diff --git a/apps/web/src/components/MessageInput.tsx b/apps/web/src/components/MessageInput.tsx index e2312ae..41b569a 100644 --- a/apps/web/src/components/MessageInput.tsx +++ b/apps/web/src/components/MessageInput.tsx @@ -733,14 +733,14 @@ export default function MessageInput({ chatId }: MessageInputProps) {
) : ( -
+
{/* Attach */} -
+
{showAttachMenu && ( @@ -852,17 +852,17 @@ export default function MessageInput({ chatId }: MessageInputProps) { onContextMenu={handleInputContextMenu} placeholder={attachments.length > 0 ? t('addCaption') : t('message')} rows={1} - className="w-full resize-none bg-transparent text-[15px] text-white placeholder-white/40 leading-relaxed py-2.5 px-2 border-none focus:ring-0 max-h-[150px] outline-none" + className="w-full resize-none bg-transparent text-[15px] text-white placeholder-zinc-500 leading-[22px] py-[10px] px-2 border-none focus:ring-0 max-h-[150px] outline-none" />
{/* Emoji */} -
+
{showEmoji && ( @@ -904,7 +904,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
{/* Send / Mic */} -
+
{hasContent ? ( <> )}
diff --git a/apps/web/src/components/SideMenu.tsx b/apps/web/src/components/SideMenu.tsx index 19dd5e5..32d5034 100644 --- a/apps/web/src/components/SideMenu.tsx +++ b/apps/web/src/components/SideMenu.tsx @@ -36,6 +36,7 @@ import { getSocket } from '../lib/socket'; import { useLang } from '../lib/i18n'; import { useThemeStore, ChatTheme } from '../stores/themeStore'; import DatePicker from './DatePicker'; +import TelegramImportModal from './TelegramImportModal'; import type { User as UserType, UserPresence, FriendRequest, FriendWithId } from '../lib/types'; type SideView = 'main' | 'profile' | 'settings' | 'about' | 'themes' | 'friends'; @@ -56,6 +57,8 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro const [prevView, setPrevView] = useState('main'); const [themeIndex, setThemeIndex] = useState(0); const fileInputRef = useRef(null); + + const [showImportModal, setShowImportModal] = useState(false); // Friends state const [friends, setFriends] = useState([]); @@ -425,6 +428,23 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
+
+

Хранилище и данные

+ +
+

{t('about')}

@@ -760,6 +780,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro {view === 'about' && renderAbout()} + setShowImportModal(false)} friends={friends} /> )} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index ef27738..a9fbeed 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -100,45 +100,47 @@ export default function Sidebar() { <>
{/* Шапка */} -
- -
-
-
- +
+
+ +
+
+ +
+

Knot Messenger

-

Knot Messenger

{/* Поиск */} -
+
- + setSearchQuery(e.target.value)} - className="w-full pl-11 pr-10 py-3 rounded-2xl bg-surface-tertiary/80 text-[15px] font-medium text-white placeholder-zinc-500 border border-border/30 hover:border-border/60 focus:border-accent transition-all outline-none shadow-inner" + className="w-full pl-9 pr-8 py-1.5 rounded-[10px] bg-surface-tertiary text-[14px] text-white placeholder-zinc-500 border border-transparent focus:border-accent/50 hover:bg-surface-hover transition-all outline-none" /> {searchQuery && ( @@ -148,16 +150,16 @@ export default function Sidebar() { {/* Story circles */} {(storyGroups.length > 0 || true) && ( -
+
{/* Add story circle */} {storyGroups.map((group, idx) => { @@ -167,25 +169,25 @@ export default function Sidebar() { diff --git a/apps/web/src/components/TelegramImportModal.tsx b/apps/web/src/components/TelegramImportModal.tsx new file mode 100644 index 0000000..8a24727 --- /dev/null +++ b/apps/web/src/components/TelegramImportModal.tsx @@ -0,0 +1,249 @@ +import { useState, useRef } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, Upload, Check, Loader2, MessageSquare, AlertCircle } from 'lucide-react'; +import { api } from '../lib/api'; +import { useLang } from '../lib/i18n'; +import type { User as UserType, FriendWithId } from '../lib/types'; +import { useAuthStore } from '../stores/authStore'; + +interface TelegramImportModalProps { + isOpen: boolean; + onClose: () => void; + friends: FriendWithId[]; +} + +export default function TelegramImportModal({ isOpen, onClose, friends }: TelegramImportModalProps) { + const { t } = useLang(); + const { user } = useAuthStore(); + const fileInputRef = useRef(null); + + const [step, setStep] = useState<1 | 2 | 3>(1); // 1: upload, 2: map, 3: loading/done + const [file, setFile] = useState(null); + const [token, setToken] = useState(null); + const [names, setNames] = useState([]); + const [mapping, setMapping] = useState>({}); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [importedState, setImportedState] = useState<{ count: number; text: string } | null>(null); + + const handleFileChange = async (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (!selectedFile) return; + + if (!selectedFile.name.endsWith('.zip')) { + setError('Пожалуйста, выберите ZIP-архив экспорта Telegram.'); + return; + } + + setFile(selectedFile); + setError(null); + setLoading(true); + + try { + const data = await api.analyzeTelegramImport(selectedFile) as any; + setToken(data.token); + setNames(data.names); + + // Auto-map if possible + const initialMap: Record = {}; + data.names.forEach((name: string) => { + initialMap[name] = ''; + }); + setMapping(initialMap); + setStep(2); + } catch (err: any) { + console.error(err); + setError(err.message || 'Ошибка загрузки файла'); + } finally { + setLoading(false); + } + }; + + const handleExecute = async () => { + if (!token) return; + setLoading(true); + setError(null); + + try { + const res = await api.executeTelegramImport({ token, mapping }) as any; + setImportedState({ count: res.messagesImported, text: 'Успешно импортировано' }); + setStep(3); + } catch (err: any) { + console.error(err); + setError(err.message || 'Ошибка импорта'); + setStep(1); + } finally { + setLoading(false); + } + }; + + const reset = () => { + setStep(1); + setFile(null); + setToken(null); + setNames([]); + setMapping({}); + setError(null); + setImportedState(null); + if (fileInputRef.current) fileInputRef.current.value = ''; + }; + + const handleClose = () => { + reset(); + onClose(); + }; + + return ( + + {isOpen && ( +
+ + + {/* Header */} +
+
+ +

Импорт из Telegram

+
+ +
+ +
+ {error && ( +
+ +

{error}

+
+ )} + + {step === 1 && ( +
+
+ +
+
+

Загрузите архив с историей

+

+ Скачайте историю чата из Telegram в формате HTML (сняв галочку с формата JSON). Убедитесь, что медиафайлы тоже скачаны, если хотите перенести их. Загрузите полученный ZIP архив. +

+
+ + + + +
+ )} + + {step === 2 && ( +
+
+

Кто есть кто?

+

+ Мы нашли {names.length} имён в архиве. Укажите, какому контакту в Knot они соответствуют. Одно из имён должно принадлежать вам. +

+
+ +
+ {names.map((name) => ( +
+ Сообщения от: "{name}" + +
+ ))} +
+ +
+ + +
+
+ )} + + {step === 3 && importedState && ( +
+
+ +
+
+

Готово!

+

+ Импорт завершен. Сообщений: {importedState.count}. +

+
+ +
+ )} +
+
+
+ )} +
+ ); +} diff --git a/apps/web/src/components/UserProfile.tsx b/apps/web/src/components/UserProfile.tsx index 3b44c79..aee7a19 100644 --- a/apps/web/src/components/UserProfile.tsx +++ b/apps/web/src/components/UserProfile.tsx @@ -374,9 +374,8 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is className="fixed right-3 top-3 bottom-3 w-[650px] max-w-[calc(100%-24px)] bg-surface-secondary/90 backdrop-blur-3xl shadow-2xl shadow-black/80 border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden ring-1 ring-white/10" > {/* Шапка */} -
-
-

+
+

{(isSelf ? t('myProfile') : t('profileTitle')) as string}

@@ -417,23 +416,18 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is
{/* Аватар */}
-
- -
- {/* Spinning gradient glow ring */} -
+
{profile.avatar ? ( ) : ( -
-
- {initials} +
+ {initials}
)}
@@ -477,15 +471,15 @@ export default function UserProfile({ userId, chatId, onClose, onGoToMessage, is />
) : ( -

+

{profile.displayName || profile.username}

)} {/* Username (неизменяемый) */} -
- - {profile.username} +
+ + {profile.username}
{/* Онлайн статус */} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 6b74a82..ec74c9d 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -65,8 +65,8 @@ html, body, #root { } body { - font-family: 'Inter', system-ui, -apple-system, sans-serif; - background-color: #09090b; + font-family: var(--font-sans, -apple-system, BlinkMacSystemFont, 'Roboto', 'Helvetica Neue', Arial, sans-serif); + background-color: var(--color-surface, #17212b); color: #fafafa; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; @@ -300,18 +300,39 @@ a:focus:not(:focus-visible) { /* emoji-mart dark theme overrides */ em-emoji-picker { - --em-rgb-background: 17, 17, 19; - --em-rgb-input: 26, 26, 30; + --em-rgb-background: 23, 33, 43; /* Telegram surface color */ + --em-rgb-input: 30, 44, 58; --em-rgb-color: 240, 240, 240; --border-radius: 0 0 16px 16px; border: none !important; + + /* Overrides for category navigation to remove blue line and add soft bg */ + --category-icon-active-border-color: transparent !important; + --category-icon-active-color: #5288c1 !important; +} + +em-emoji-picker::part(category-icons) { + padding-top: 4px; +} + +em-emoji-picker::part(category-icon) { + border-radius: 6px; + transition: background 0.2s ease; +} + +em-emoji-picker::part(category-icon):hover { + background-color: rgba(255, 255, 255, 0.05); +} + +em-emoji-picker::part(category-icon-active) { + background-color: rgba(82, 136, 193, 0.15); /* Accent light bg */ } /* Highlight for quoted messages */ @keyframes highlight-glow { - 0% { box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.8), 0 0 20px rgba(99, 102, 241, 0.6); } - 20% { box-shadow: 0 0 0 4px rgba(99, 102, 241, 1), 0 0 30px rgba(99, 102, 241, 0.8); } - 100% { box-shadow: 0 0 0 0 rgba(99, 102, 241, 0), 0 0 0 rgba(99, 102, 241, 0); } + 0% { box-shadow: 0 0 0 3px rgba(82, 136, 193, 0.8), 0 0 20px rgba(82, 136, 193, 0.6); } + 20% { box-shadow: 0 0 0 4px rgba(82, 136, 193, 1), 0 0 30px rgba(82, 136, 193, 0.8); } + 100% { box-shadow: 0 0 0 0 rgba(82, 136, 193, 0), 0 0 0 rgba(82, 136, 193, 0); } } .highlight-message { diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 07c9778..5b20f4e 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -14,17 +14,21 @@ class ApiClient { const controller = new AbortController(); const timer = timeout > 0 ? setTimeout(() => controller.abort(), timeout) : undefined; - const headers: HeadersInit = { - 'Content-Type': 'application/json', + const isFormData = fetchOptions.body instanceof FormData; + const computedHeaders: Record = { ...(this.token ? { Authorization: `Bearer ${this.token}` } : {}), - ...fetchOptions.headers, + ...(fetchOptions.headers as Record), }; + if (!isFormData && !computedHeaders['Content-Type']) { + computedHeaders['Content-Type'] = 'application/json'; + } + let response: Response; try { response = await fetch(`${API_BASE}${endpoint}`, { ...fetchOptions, - headers, + headers: computedHeaders, signal: controller.signal, }); } catch (err) { @@ -336,11 +340,30 @@ class ApiClient { } // User settings - async updateSettings(data: { hideStoryViews?: boolean }) { - return this.request('/users/settings', { + async updateSettings(settings: any) { + const res = await this.request('/users/settings', { method: 'PUT', + body: JSON.stringify(settings), + }); + return res; + } + + async analyzeTelegramImport(file: File) { + const formData = new FormData(); + formData.append('file', file); + const res = await this.request('/import/telegram/analyze', { + method: 'POST', + body: formData, + }); + return res; + } + + async executeTelegramImport(data: { token: string; mapping: Record }) { + const res = await this.request('/import/telegram/execute', { + method: 'POST', body: JSON.stringify(data), }); + return res; } // Friends diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts index 7f50cae..65a6f74 100644 --- a/apps/web/src/lib/i18n.ts +++ b/apps/web/src/lib/i18n.ts @@ -283,6 +283,7 @@ const translations = { loadStoriesError: 'Ошибка загрузки историй', loadChatsError: 'Ошибка загрузки чатов', loadMessagesError: 'Ошибка загрузки сообщений', + unreadMessages: 'Непрочитанные сообщения', }, en: { myProfile: 'My Profile', @@ -536,6 +537,7 @@ const translations = { loadStoriesError: 'Error loading stories', loadChatsError: 'Error loading chats', loadMessagesError: 'Error loading messages', + unreadMessages: 'Unread messages', }, } as const; diff --git a/docker-compose.yml b/docker-compose.yml index 58eb627..b46691a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,10 +3,8 @@ services: image: postgres:15-alpine container_name: knot-db restart: always - environment: - POSTGRES_USER: ${POSTGRES_USER} - POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} - POSTGRES_DB: ${POSTGRES_DB} + env_file: + - .env volumes: - postgres_data:/var/lib/postgresql/data ports: @@ -18,22 +16,8 @@ services: dockerfile: Dockerfile.server-net container_name: knot-server restart: always - environment: - DATABASE_URL: ${DATABASE_URL} - DOMAIN: ${DOMAIN} - JWT_SECRET: ${JWT_SECRET} - JWT_ISSUER: ${JWT_ISSUER} - JWT_AUDIENCE: ${JWT_AUDIENCE} - TURN_URL: ${TURN_URL} - TURN_USERNAME: ${TURN_USERNAME} - TURN_PASSWORD: ${TURN_PASSWORD} - S3_ENDPOINT: ${S3_ENDPOINT} - S3_ACCESS_KEY: ${S3_ACCESS_KEY} - S3_SECRET_KEY: ${S3_SECRET_KEY} - S3_BUCKET: ${S3_BUCKET} - KNOT_MASTER_ENCRYPTION_KEY: ${KNOT_MASTER_ENCRYPTION_KEY} - KNOT_ADMIN_USER: ${KNOT_ADMIN_USER} - KNOT_ADMIN_PASSWORD: ${KNOT_ADMIN_PASSWORD} + env_file: + - .env depends_on: - db - minio @@ -44,9 +28,8 @@ services: image: minio/minio container_name: knot-minio restart: always - environment: - MINIO_ROOT_USER: ${S3_ACCESS_KEY} - MINIO_ROOT_PASSWORD: ${S3_SECRET_KEY} + env_file: + - .env ports: - "9000:9000" - "9001:9001" @@ -64,8 +47,8 @@ services: restart: always ports: - "9090:80" - environment: - - VITE_KLIPY_API_KEY=${VITE_KLIPY_API_KEY} + env_file: + - .env depends_on: - server