diff --git a/apps/server-net/src/Modules/Chats/Application/Messages/Send/SendMessageCommandHandler.cs b/apps/server-net/src/Modules/Chats/Application/Messages/Send/SendMessageCommandHandler.cs index 729ad00..8720959 100644 --- a/apps/server-net/src/Modules/Chats/Application/Messages/Send/SendMessageCommandHandler.cs +++ b/apps/server-net/src/Modules/Chats/Application/Messages/Send/SendMessageCommandHandler.cs @@ -17,7 +17,10 @@ public sealed record SendMessageCommand( List? Attachments = null, Guid? ReplyToId = null, string? Quote = null, - Guid? ForwardedFromId = null) : ICommand; + Guid? ForwardedFromId = null, + Guid? StoryId = null, + string? StoryMediaUrl = null, + string? StoryMediaType = null) : ICommand; public sealed class SendMessageCommandHandler : ICommandHandler { @@ -55,7 +58,10 @@ public sealed class SendMessageCommandHandler : ICommandHandler messageIds, CancellationToken cancellationToken); Task AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken); Task RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken); + Task GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken); } diff --git a/apps/server-net/src/Modules/Chats/Domain/Message.cs b/apps/server-net/src/Modules/Chats/Domain/Message.cs index d47ee1d..0efefd7 100644 --- a/apps/server-net/src/Modules/Chats/Domain/Message.cs +++ b/apps/server-net/src/Modules/Chats/Domain/Message.cs @@ -21,6 +21,9 @@ public sealed class Message : AggregateRoot public bool IsEdited { get; private set; } public bool IsDeleted { get; private set; } public Guid? ForwardedFromId { get; private set; } + public Guid? StoryId { get; private set; } + public string? StoryMediaUrl { get; private set; } + public string? StoryMediaType { get; private set; } public DateTime CreatedAt { get; private set; } private readonly List _media = new(); @@ -32,7 +35,7 @@ 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) : base(id) + 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) { ChatId = chatId; SenderId = senderId; @@ -41,14 +44,17 @@ public sealed class Message : AggregateRoot ReplyToId = replyToId; Quote = quote; ForwardedFromId = forwardedFromId; + StoryId = storyId; + StoryMediaUrl = storyMediaUrl; + StoryMediaType = storyMediaType; CreatedAt = DateTime.UtcNow; 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) + 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); + return new Message(Guid.NewGuid(), chatId, senderId, content, type, replyToId, quote, forwardedFromId, storyId, storyMediaUrl, storyMediaType); } public void AddMedia(string type, string url, string? filename, long? size) diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs index 7aede53..e945149 100644 --- a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/MessageRepository.cs @@ -111,4 +111,12 @@ public sealed class MessageRepository : IMessageRepository return true; } + + public async Task GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken) + { + return await _dbContext.Messages + .Where(m => m.ChatId == chatId && m.StoryId == storyId) + .OrderByDescending(m => m.CreatedAt) + .FirstOrDefaultAsync(cancellationToken); + } } diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313183634_AddStoryIdToMessages.Designer.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313183634_AddStoryIdToMessages.Designer.cs new file mode 100644 index 0000000..64db092 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313183634_AddStoryIdToMessages.Designer.cs @@ -0,0 +1,254 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Vortex.Modules.Chats.Infrastructure.Persistence; + +#nullable disable + +namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ChatsDbContext))] + [Migration("20260313183634_AddStoryIdToMessages")] + partial class AddStoryIdToMessages + { + /// + 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("Vortex.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("Name") + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Chats", "chats"); + }); + + modelBuilder.Entity("Vortex.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("Quote") + .HasColumnType("text"); + + b.Property("ReplyToId") + .HasColumnType("uuid"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("StoryId") + .HasColumnType("uuid"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Messages", "chats"); + }); + + modelBuilder.Entity("Vortex.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("Vortex.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("Vortex.Modules.Chats.Domain.Chat", b => + { + b.OwnsMany("Vortex.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("Vortex.Modules.Chats.Domain.Message", b => + { + b.OwnsMany("Vortex.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("Vortex.Modules.Chats.Domain.Reaction", b => + { + b.HasOne("Vortex.Modules.Chats.Domain.Message", null) + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Vortex.Modules.Chats.Domain.ReadReceipt", b => + { + b.HasOne("Vortex.Modules.Chats.Domain.Message", null) + .WithMany("ReadBy") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Vortex.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/20260313183634_AddStoryIdToMessages.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313183634_AddStoryIdToMessages.cs new file mode 100644 index 0000000..930d56c --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313183634_AddStoryIdToMessages.cs @@ -0,0 +1,31 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddStoryIdToMessages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "StoryId", + schema: "chats", + table: "Messages", + type: "uuid", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "StoryId", + schema: "chats", + table: "Messages"); + } + } +} diff --git a/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313201532_AddStoryMediaInfoToMessages.Designer.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313201532_AddStoryMediaInfoToMessages.Designer.cs new file mode 100644 index 0000000..2167c10 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313201532_AddStoryMediaInfoToMessages.Designer.cs @@ -0,0 +1,260 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Vortex.Modules.Chats.Infrastructure.Persistence; + +#nullable disable + +namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(ChatsDbContext))] + [Migration("20260313201532_AddStoryMediaInfoToMessages")] + partial class AddStoryMediaInfoToMessages + { + /// + 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("Vortex.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("Name") + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("Chats", "chats"); + }); + + modelBuilder.Entity("Vortex.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("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("Vortex.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("Vortex.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("Vortex.Modules.Chats.Domain.Chat", b => + { + b.OwnsMany("Vortex.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("Vortex.Modules.Chats.Domain.Message", b => + { + b.OwnsMany("Vortex.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("Vortex.Modules.Chats.Domain.Reaction", b => + { + b.HasOne("Vortex.Modules.Chats.Domain.Message", null) + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Vortex.Modules.Chats.Domain.ReadReceipt", b => + { + b.HasOne("Vortex.Modules.Chats.Domain.Message", null) + .WithMany("ReadBy") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Vortex.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/20260313201532_AddStoryMediaInfoToMessages.cs b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313201532_AddStoryMediaInfoToMessages.cs new file mode 100644 index 0000000..8eada32 --- /dev/null +++ b/apps/server-net/src/Modules/Chats/Infrastructure/Persistence/Migrations/20260313201532_AddStoryMediaInfoToMessages.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddStoryMediaInfoToMessages : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "StoryMediaType", + schema: "chats", + table: "Messages", + type: "text", + nullable: true); + + migrationBuilder.AddColumn( + name: "StoryMediaUrl", + schema: "chats", + table: "Messages", + type: "text", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "StoryMediaType", + schema: "chats", + table: "Messages"); + + migrationBuilder.DropColumn( + name: "StoryMediaUrl", + schema: "chats", + table: "Messages"); + } + } +} 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 78e469f..ec6064e 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 @@ -85,6 +85,15 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations 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"); diff --git a/apps/server-net/src/Modules/Identity/Domain/Story.cs b/apps/server-net/src/Modules/Identity/Domain/Story.cs index 8222048..1e9dc63 100644 --- a/apps/server-net/src/Modules/Identity/Domain/Story.cs +++ b/apps/server-net/src/Modules/Identity/Domain/Story.cs @@ -2,7 +2,7 @@ using Vortex.Shared.Kernel; namespace Vortex.Modules.Identity.Domain; -public sealed class Story : Entity +public class Story : Entity { public Guid UserId { get; private set; } public string Type { get; private set; } // text, image, video @@ -13,9 +13,16 @@ public sealed class Story : Entity public DateTime ExpiresAt { get; private set; } private readonly List _viewers = new(); - public IReadOnlyCollection Viewers => _viewers.AsReadOnly(); + private readonly List _reactions = new(); + private readonly List _replies = new(); - private Story(Guid id, Guid userId, string type, string? mediaUrl, string? content, string? bgColor) : base(id) + public IReadOnlyCollection Viewers => _viewers.AsReadOnly(); + public IReadOnlyCollection Reactions => _reactions.AsReadOnly(); + public IReadOnlyCollection Replies => _replies.AsReadOnly(); + + protected Story() : base(Guid.NewGuid()) { } + + internal Story(Guid id, Guid userId, string type, string? mediaUrl, string? content, string? bgColor) : base(id) { UserId = userId; Type = type; @@ -36,4 +43,21 @@ public sealed class Story : Entity if (_viewers.Any(v => v.UserId == userId)) return; _viewers.Add(new StoryViewer(Id, userId)); } + + public void AddReaction(Guid userId, string emoji) + { + if (_reactions.Any(r => r.UserId == userId && r.Emoji == emoji)) return; + _reactions.Add(new StoryReaction(Id, userId, emoji)); + } + + public void RemoveReaction(Guid userId, string emoji) + { + var reaction = _reactions.FirstOrDefault(r => r.UserId == userId && r.Emoji == emoji); + if (reaction != null) _reactions.Remove(reaction); + } + + public void AddReply(Guid userId, string content) + { + _replies.Add(new StoryReply(Id, userId, content)); + } } diff --git a/apps/server-net/src/Modules/Identity/Domain/StoryReaction.cs b/apps/server-net/src/Modules/Identity/Domain/StoryReaction.cs new file mode 100644 index 0000000..43353b6 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Domain/StoryReaction.cs @@ -0,0 +1,21 @@ +using Vortex.Shared.Kernel; + +namespace Vortex.Modules.Identity.Domain; + +public sealed class StoryReaction : Entity +{ + public Guid StoryId { get; set; } + public Guid UserId { get; set; } + public string Emoji { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + + private StoryReaction() : base(Guid.NewGuid()) { } + + public StoryReaction(Guid storyId, Guid userId, string emoji) : base(Guid.NewGuid()) + { + StoryId = storyId; + UserId = userId; + Emoji = emoji; + CreatedAt = DateTime.UtcNow; + } +} diff --git a/apps/server-net/src/Modules/Identity/Domain/StoryReply.cs b/apps/server-net/src/Modules/Identity/Domain/StoryReply.cs new file mode 100644 index 0000000..eeed099 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Domain/StoryReply.cs @@ -0,0 +1,21 @@ +using Vortex.Shared.Kernel; + +namespace Vortex.Modules.Identity.Domain; + +public sealed class StoryReply : Entity +{ + public Guid StoryId { get; set; } + public Guid UserId { get; set; } + public string Content { get; set; } = string.Empty; + public DateTime CreatedAt { get; set; } + + private StoryReply() : base(Guid.NewGuid()) { } + + public StoryReply(Guid storyId, Guid userId, string content) : base(Guid.NewGuid()) + { + StoryId = storyId; + UserId = userId; + Content = content; + CreatedAt = DateTime.UtcNow; + } +} diff --git a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs index eb69b24..9a7b794 100644 --- a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs +++ b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/IdentityDbContext.cs @@ -1,10 +1,10 @@ using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; -using Vortex.Shared.Kernel; -using Vortex.Modules.Identity.Domain; - +using Microsoft.EntityFrameworkCore.Metadata; using Vortex.Modules.Identity.Application.Abstractions; +using Vortex.Modules.Identity.Domain; +using Vortex.Shared.Kernel; namespace Vortex.Modules.Identity.Infrastructure.Persistence; @@ -24,8 +24,11 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork public DbSet Users => Set(); public DbSet Stories => Set(); public DbSet StoryViewers => Set(); + public DbSet StoryReactions => Set(); + public DbSet StoryReplies => Set(); public DbSet Friendships => Set(); - + + protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) { base.OnConfiguring(optionsBuilder); @@ -48,10 +51,26 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork builder.ToTable("Stories"); builder.HasKey(s => s.Id); builder.Property(s => s.Type).IsRequired(); + + // Configure backing fields for collections + + builder.Metadata.FindNavigation(nameof(Story.Viewers))?.SetPropertyAccessMode(PropertyAccessMode.Field); + builder.Metadata.FindNavigation(nameof(Story.Reactions))?.SetPropertyAccessMode(PropertyAccessMode.Field); + builder.Metadata.FindNavigation(nameof(Story.Replies))?.SetPropertyAccessMode(PropertyAccessMode.Field); + + builder.HasMany(s => s.Viewers) .WithOne() .HasForeignKey(v => v.StoryId) .OnDelete(DeleteBehavior.Cascade); + builder.HasMany(s => s.Reactions) + .WithOne() + .HasForeignKey(r => r.StoryId) + .OnDelete(DeleteBehavior.Cascade); + builder.HasMany(s => s.Replies) + .WithOne() + .HasForeignKey(r => r.StoryId) + .OnDelete(DeleteBehavior.Cascade); }); modelBuilder.Entity(builder => @@ -61,6 +80,21 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork builder.HasIndex(v => new { v.StoryId, v.UserId }).IsUnique(); }); + modelBuilder.Entity(builder => + { + builder.ToTable("StoryReactions"); + builder.HasKey(r => r.Id); + builder.Property(r => r.Emoji).IsRequired().HasMaxLength(10); + builder.HasIndex(r => new { r.StoryId, r.UserId, r.Emoji }).IsUnique(); + }); + + modelBuilder.Entity(builder => + { + builder.ToTable("StoryReplies"); + builder.HasKey(r => r.Id); + builder.Property(r => r.Content).IsRequired().HasMaxLength(500); + }); + modelBuilder.Entity(builder => { builder.ToTable("Users"); @@ -82,7 +116,8 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork { var domainEvents = ChangeTracker .Entries() - .SelectMany(x => + .SelectMany(x => + { if (x.Entity is AggregateRoot root) { diff --git a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260313115319_AddStoryReactionsReplies.Designer.cs b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260313115319_AddStoryReactionsReplies.Designer.cs new file mode 100644 index 0000000..c2ee07b --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260313115319_AddStoryReactionsReplies.Designer.cs @@ -0,0 +1,255 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Vortex.Modules.Identity.Infrastructure.Persistence; + +#nullable disable + +namespace Vortex.Modules.Identity.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(IdentityDbContext))] + [Migration("20260313115319_AddStoryReactionsReplies")] + partial class AddStoryReactionsReplies + { + /// + 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("Vortex.Modules.Identity.Domain.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FriendId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "FriendId") + .IsUnique(); + + b.ToTable("Friendships", "identity"); + }); + + modelBuilder.Entity("Vortex.Modules.Identity.Domain.Story", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BgColor") + .HasColumnType("text"); + + b.Property("Content") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("MediaUrl") + .HasColumnType("text"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("Stories", "identity"); + }); + + modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Emoji") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("StoryId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("StoryId", "UserId", "Emoji") + .IsUnique(); + + b.ToTable("StoryReactions", "identity"); + }); + + modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReply", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StoryId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("StoryId"); + + b.ToTable("StoryReplies", "identity"); + }); + + modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryViewer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("StoryId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("ViewedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("StoryId", "UserId") + .IsUnique(); + + b.ToTable("StoryViewers", "identity"); + }); + + modelBuilder.Entity("Vortex.Modules.Identity.Domain.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Avatar") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Bio") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Birthday") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Email") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("HideStoryViews") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("text"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", "identity"); + }); + + modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReaction", b => + { + b.HasOne("Vortex.Modules.Identity.Domain.Story", null) + .WithMany("Reactions") + .HasForeignKey("StoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReply", b => + { + b.HasOne("Vortex.Modules.Identity.Domain.Story", null) + .WithMany("Replies") + .HasForeignKey("StoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryViewer", b => + { + b.HasOne("Vortex.Modules.Identity.Domain.Story", null) + .WithMany("Viewers") + .HasForeignKey("StoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Vortex.Modules.Identity.Domain.Story", b => + { + b.Navigation("Reactions"); + + b.Navigation("Replies"); + + b.Navigation("Viewers"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260313115319_AddStoryReactionsReplies.cs b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260313115319_AddStoryReactionsReplies.cs new file mode 100644 index 0000000..40f8ff0 --- /dev/null +++ b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/20260313115319_AddStoryReactionsReplies.cs @@ -0,0 +1,86 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Vortex.Modules.Identity.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddStoryReactionsReplies : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "StoryReactions", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + StoryId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Emoji = table.Column(type: "character varying(10)", maxLength: 10, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StoryReactions", x => x.Id); + table.ForeignKey( + name: "FK_StoryReactions_Stories_StoryId", + column: x => x.StoryId, + principalSchema: "identity", + principalTable: "Stories", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "StoryReplies", + schema: "identity", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + StoryId = table.Column(type: "uuid", nullable: false), + UserId = table.Column(type: "uuid", nullable: false), + Content = table.Column(type: "character varying(500)", maxLength: 500, nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_StoryReplies", x => x.Id); + table.ForeignKey( + name: "FK_StoryReplies_Stories_StoryId", + column: x => x.StoryId, + principalSchema: "identity", + principalTable: "Stories", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_StoryReactions_StoryId_UserId_Emoji", + schema: "identity", + table: "StoryReactions", + columns: new[] { "StoryId", "UserId", "Emoji" }, + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_StoryReplies_StoryId", + schema: "identity", + table: "StoryReplies", + column: "StoryId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "StoryReactions", + schema: "identity"); + + migrationBuilder.DropTable( + name: "StoryReplies", + schema: "identity"); + } + } +} diff --git a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs index 412c5ba..c5ac68f 100644 --- a/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs +++ b/apps/server-net/src/Modules/Identity/Infrastructure/Persistence/Migrations/IdentityDbContextModelSnapshot.cs @@ -82,6 +82,61 @@ namespace Vortex.Modules.Identity.Infrastructure.Persistence.Migrations b.ToTable("Stories", "identity"); }); + modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Emoji") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("character varying(10)"); + + b.Property("StoryId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("StoryId", "UserId", "Emoji") + .IsUnique(); + + b.ToTable("StoryReactions", "identity"); + }); + + modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReply", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Content") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("StoryId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("StoryId"); + + b.ToTable("StoryReplies", "identity"); + }); + modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryViewer", b => { b.Property("Id") @@ -156,6 +211,24 @@ namespace Vortex.Modules.Identity.Infrastructure.Persistence.Migrations b.ToTable("Users", "identity"); }); + modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReaction", b => + { + b.HasOne("Vortex.Modules.Identity.Domain.Story", null) + .WithMany("Reactions") + .HasForeignKey("StoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReply", b => + { + b.HasOne("Vortex.Modules.Identity.Domain.Story", null) + .WithMany("Replies") + .HasForeignKey("StoryId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryViewer", b => { b.HasOne("Vortex.Modules.Identity.Domain.Story", null) @@ -167,6 +240,10 @@ namespace Vortex.Modules.Identity.Infrastructure.Persistence.Migrations modelBuilder.Entity("Vortex.Modules.Identity.Domain.Story", b => { + b.Navigation("Reactions"); + + b.Navigation("Replies"); + b.Navigation("Viewers"); }); #pragma warning restore 612, 618 diff --git a/apps/server-net/src/Modules/Identity/migrations.sql b/apps/server-net/src/Modules/Identity/migrations.sql new file mode 100644 index 0000000..457553b --- /dev/null +++ b/apps/server-net/src/Modules/Identity/migrations.sql @@ -0,0 +1,229 @@ +CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" ( + "MigrationId" character varying(150) NOT NULL, + "ProductVersion" character varying(32) NOT NULL, + CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId") +); + +START TRANSACTION; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311180816_InitialIdentity') THEN + IF NOT EXISTS(SELECT 1 FROM pg_namespace WHERE nspname = 'identity') THEN + CREATE SCHEMA identity; + END IF; + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311180816_InitialIdentity') THEN + CREATE TABLE identity."Users" ( + "Id" uuid NOT NULL, + "Username" character varying(50) NOT NULL, + "PasswordHash" text NOT NULL, + "DisplayName" character varying(100) NOT NULL, + "Email" character varying(255), + CONSTRAINT "PK_Users" PRIMARY KEY ("Id") + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311180816_InitialIdentity') THEN + CREATE UNIQUE INDEX "IX_Users_Username" ON identity."Users" ("Username"); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311180816_InitialIdentity') THEN + INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260311180816_InitialIdentity', '10.0.4'); + END IF; +END $EF$; +COMMIT; + +START TRANSACTION; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN + ALTER TABLE identity."Users" ADD "Avatar" character varying(500); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN + ALTER TABLE identity."Users" ADD "Bio" character varying(500); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN + ALTER TABLE identity."Users" ADD "Birthday" timestamp with time zone; + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN + ALTER TABLE identity."Users" ADD "CreatedAt" timestamp with time zone NOT NULL DEFAULT TIMESTAMPTZ '-infinity'; + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN + CREATE TABLE identity."Friendships" ( + "Id" uuid NOT NULL, + "UserId" uuid NOT NULL, + "FriendId" uuid NOT NULL, + "Status" integer NOT NULL, + "CreatedAt" timestamp with time zone NOT NULL, + CONSTRAINT "PK_Friendships" PRIMARY KEY ("Id") + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN + CREATE TABLE identity."Stories" ( + "Id" uuid NOT NULL, + "UserId" uuid NOT NULL, + "Type" text NOT NULL, + "MediaUrl" text, + "Content" text, + "BgColor" text, + "CreatedAt" timestamp with time zone NOT NULL, + "ExpiresAt" timestamp with time zone NOT NULL, + CONSTRAINT "PK_Stories" PRIMARY KEY ("Id") + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN + CREATE UNIQUE INDEX "IX_Friendships_UserId_FriendId" ON identity."Friendships" ("UserId", "FriendId"); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311200839_UpdateIdentityWithNewFieldsAndTables') THEN + INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260311200839_UpdateIdentityWithNewFieldsAndTables', '10.0.4'); + END IF; +END $EF$; +COMMIT; + +START TRANSACTION; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311215347_AddHideStoryViewsToUser') THEN + ALTER TABLE identity."Users" ADD "HideStoryViews" boolean NOT NULL DEFAULT FALSE; + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260311215347_AddHideStoryViewsToUser') THEN + INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260311215347_AddHideStoryViewsToUser', '10.0.4'); + END IF; +END $EF$; +COMMIT; + +START TRANSACTION; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260312174453_AddHideStoryViewsAndFixStoryViewer') THEN + CREATE TABLE identity."StoryViewers" ( + "Id" uuid NOT NULL, + "StoryId" uuid NOT NULL, + "UserId" uuid NOT NULL, + "ViewedAt" timestamp with time zone NOT NULL, + CONSTRAINT "PK_StoryViewers" PRIMARY KEY ("Id"), + CONSTRAINT "FK_StoryViewers_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES identity."Stories" ("Id") ON DELETE CASCADE + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260312174453_AddHideStoryViewsAndFixStoryViewer') THEN + CREATE UNIQUE INDEX "IX_StoryViewers_StoryId_UserId" ON identity."StoryViewers" ("StoryId", "UserId"); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260312174453_AddHideStoryViewsAndFixStoryViewer') THEN + INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260312174453_AddHideStoryViewsAndFixStoryViewer', '10.0.4'); + END IF; +END $EF$; +COMMIT; + +START TRANSACTION; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN + CREATE TABLE identity."StoryReactions" ( + "Id" uuid NOT NULL, + "StoryId" uuid NOT NULL, + "UserId" uuid NOT NULL, + "Emoji" character varying(10) NOT NULL, + "CreatedAt" timestamp with time zone NOT NULL, + CONSTRAINT "PK_StoryReactions" PRIMARY KEY ("Id"), + CONSTRAINT "FK_StoryReactions_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES identity."Stories" ("Id") ON DELETE CASCADE + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN + CREATE TABLE identity."StoryReplies" ( + "Id" uuid NOT NULL, + "StoryId" uuid NOT NULL, + "UserId" uuid NOT NULL, + "Content" character varying(500) NOT NULL, + "CreatedAt" timestamp with time zone NOT NULL, + CONSTRAINT "PK_StoryReplies" PRIMARY KEY ("Id"), + CONSTRAINT "FK_StoryReplies_Stories_StoryId" FOREIGN KEY ("StoryId") REFERENCES identity."Stories" ("Id") ON DELETE CASCADE + ); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN + CREATE UNIQUE INDEX "IX_StoryReactions_StoryId_UserId_Emoji" ON identity."StoryReactions" ("StoryId", "UserId", "Emoji"); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN + CREATE INDEX "IX_StoryReplies_StoryId" ON identity."StoryReplies" ("StoryId"); + END IF; +END $EF$; + +DO $EF$ +BEGIN + IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20260313115319_AddStoryReactionsReplies') THEN + INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") + VALUES ('20260313115319_AddStoryReactionsReplies', '10.0.4'); + END IF; +END $EF$; +COMMIT; + diff --git a/apps/server-net/src/Vortex.Host/Controllers/MessagesController.cs b/apps/server-net/src/Vortex.Host/Controllers/MessagesController.cs index 8af1dc1..92c4757 100644 --- a/apps/server-net/src/Vortex.Host/Controllers/MessagesController.cs +++ b/apps/server-net/src/Vortex.Host/Controllers/MessagesController.cs @@ -6,6 +6,8 @@ using Vortex.Shared.Kernel; using Vortex.Modules.Chats.Application.Messages.Send; using Vortex.Modules.Identity.Domain; using Vortex.Modules.Identity.Application.Abstractions; +using System.Linq; +using System.Text.RegularExpressions; namespace Vortex.Host.Controllers; @@ -58,6 +60,7 @@ public sealed class MessagesController : ControllerBase { id = replyMsg.Id, content = replyMsg.Content, + isDeleted = replyMsg.IsDeleted, media = replyMsg.Media.Select(rm => new { rm.Id, rm.Type, rm.Url }).ToList(), sender = replySender != null ? new { id = replySender.Id, username = replySender.Username, displayName = replySender.DisplayName } : null }; @@ -99,6 +102,7 @@ public sealed class MessagesController : ControllerBase displayName = fwd.DisplayName, avatar = fwd.Avatar } : null, + storyId = m.StoryId, media = m.Media.Select(media => new { media.Id, media.Type, @@ -196,6 +200,76 @@ public sealed class MessagesController : ControllerBase return Ok(new { url = $"/uploads/{fileName}", filename = file.FileName, size = file.Length }); } + [HttpGet("chat/{chatId:guid}/shared")] + public async Task GetSharedMedia(Guid chatId, [FromServices] IMessageRepository messageRepository, [FromQuery] string? type, CancellationToken ct) + { + var messages = await messageRepository.GetChatMessagesAsync(chatId, 300, 0, ct); + + // Filter out deleted messages + messages = messages.Where(m => !m.IsDeleted && !m.DeletedByUsers.Contains(_userContext.UserId)).ToList(); + + var result = new List(); + var filterType = type?.ToLower(); + + foreach (var m in messages) + { + if (filterType == "links") + { + var linkRegex = new Regex(@"https?://[^\s]+", RegexOptions.IgnoreCase); + var contentLinks = !string.IsNullOrEmpty(m.Content) ? linkRegex.Matches(m.Content).Select(match => match.Value).ToList() : new List(); + var mediaLinks = m.Media.Where(media => media.Type?.ToLower() == "link").Select(media => media.Url).ToList(); + var allLinks = contentLinks.Concat(mediaLinks).Distinct().ToList(); + + if (allLinks.Any()) + { + var sender = await _userRepository.GetByIdAsync(m.SenderId, ct); + result.Add(new + { + m.Id, + m.Content, + m.CreatedAt, + links = allLinks, + sender = sender != null ? new { sender.Id, sender.Username, sender.DisplayName, sender.Avatar } : null + }); + } + continue; + } + + if (m.Media == null || !m.Media.Any()) continue; + + var filteredMedia = m.Media.Where(media => { + var mediaType = media.Type?.ToLower() ?? "file"; + if (filterType == "media") return mediaType == "image" || mediaType == "video"; + if (filterType == "files") return mediaType != "image" && mediaType != "video" && mediaType != "link"; + return true; + }).ToList(); + + if (filteredMedia.Any()) + { + var sender = await _userRepository.GetByIdAsync(m.SenderId, ct); + result.Add(new + { + m.Id, + m.ChatId, + m.SenderId, + m.Content, + m.Type, + m.CreatedAt, + media = filteredMedia.Select(media => new { + media.Id, + media.Type, + media.Url, + filename = media.Filename, + size = media.Size + }).ToList(), + sender = sender != null ? new { sender.Id, sender.Username, sender.DisplayName, sender.Avatar } : null + }); + } + } + + return Ok(result.OrderByDescending(x => ((dynamic)x).CreatedAt).ToList()); + } + [HttpPost("chat/{chatId:guid}")] public async Task SendMessage(Guid chatId, [FromBody] SendMessageRequest request) { diff --git a/apps/server-net/src/Vortex.Host/Controllers/StoriesController.cs b/apps/server-net/src/Vortex.Host/Controllers/StoriesController.cs index c18ad03..2112263 100644 --- a/apps/server-net/src/Vortex.Host/Controllers/StoriesController.cs +++ b/apps/server-net/src/Vortex.Host/Controllers/StoriesController.cs @@ -6,6 +6,10 @@ using Vortex.Modules.Identity.Domain; using Vortex.Modules.Identity.Infrastructure.Persistence; using Vortex.Shared.Kernel; using Vortex.Modules.Identity.Application.Abstractions; +using Microsoft.Extensions.Logging; +using MediatR; +using Vortex.Modules.Chats.Domain; +using Vortex.Modules.Chats.Application.Messages.Send; namespace Vortex.Host.Controllers; @@ -17,18 +21,30 @@ public sealed class StoriesController : ControllerBase private readonly IdentityDbContext _context; private readonly IUserContext _userContext; private readonly IUserRepository _userRepository; - private readonly Microsoft.AspNetCore.SignalR.IHubContext _hubContext; + private readonly IChatRepository _chatRepository; + private readonly ISender _sender; + private readonly IHubContext _hubContext; + private readonly IMessageRepository _messageRepository; + private readonly ILogger _logger; public StoriesController( IdentityDbContext context, IUserContext userContext, IUserRepository userRepository, - Microsoft.AspNetCore.SignalR.IHubContext hubContext) + IChatRepository chatRepository, + IMessageRepository messageRepository, + ISender sender, + IHubContext hubContext, + ILogger logger) { _context = context; _userContext = userContext; _userRepository = userRepository; + _chatRepository = chatRepository; + _messageRepository = messageRepository; + _sender = sender; _hubContext = hubContext; + _logger = logger; } [HttpGet] @@ -45,6 +61,8 @@ public sealed class StoriesController : ControllerBase var stories = await _context.Stories .Include(s => s.Viewers) + .Include(s => s.Reactions) + .Include(s => s.Replies) .Where(s => s.ExpiresAt > DateTime.UtcNow && friendIds.Contains(s.UserId)) .OrderByDescending(s => s.CreatedAt) .ToListAsync(ct); @@ -83,7 +101,15 @@ public sealed class StoriesController : ControllerBase createdAt = s.CreatedAt, expiresAt = s.ExpiresAt, viewCount = s.Viewers.Count, - viewed = s.Viewers.Any(v => v.UserId == currentUserId) + viewed = s.Viewers.Any(v => v.UserId == currentUserId), + reactions = s.Reactions.Select(r => new + { + id = r.Id, + userId = r.UserId, + emoji = r.Emoji, + createdAt = r.CreatedAt + }).ToList(), + replyCount = s.Replies.Count }).OrderBy(s => s.createdAt).ToList(), hasUnviewed = group.Any(s => !s.Viewers.Any(v => v.UserId == currentUserId)) }); @@ -115,26 +141,94 @@ public sealed class StoriesController : ControllerBase return Ok(new { id = story.Id }); } + [HttpGet("user/{userId}")] + public async Task GetUserStories(Guid userId, CancellationToken ct) + { + var currentUserId = _userContext.UserId; + var stories = await _context.Stories + .Include(s => s.Viewers) + .Include(s => s.Reactions) + .Include(s => s.Replies) + .Where(s => s.UserId == userId) + .OrderByDescending(s => s.CreatedAt) + .ToListAsync(ct); + + var user = await _userRepository.GetByIdAsync(userId, ct); + if (user == null) return NotFound(); + + var result = new + { + user = new + { + id = user.Id, + username = user.Username, + displayName = user.DisplayName, + avatar = user.Avatar + }, + stories = stories.Select(s => new + { + id = s.Id, + type = s.Type, + mediaUrl = s.MediaUrl, + content = s.Content, + bgColor = s.BgColor, + createdAt = s.CreatedAt, + expiresAt = s.ExpiresAt, + viewCount = s.Viewers.Count, + viewed = s.Viewers.Any(v => v.UserId == currentUserId), + reactions = s.Reactions.Select(r => new + { + id = r.Id, + userId = r.UserId, + emoji = r.Emoji, + createdAt = r.CreatedAt + }).ToList(), + replyCount = s.Replies.Count + }).ToList() + }; + + return Ok(result); + } + [HttpPost("{id}/view")] public async Task ViewStory(Guid id, CancellationToken ct) { + _logger.LogInformation("ViewStory called: StoryId={StoryId}, UserId={UserId}", id, _userContext.UserId); + try { var story = await _context.Stories .Include(s => s.Viewers) .FirstOrDefaultAsync(s => s.Id == id, ct); - if (story == null) return NotFound(); - if (story.UserId == _userContext.UserId) return Ok(new { message = "Owner view" }); + if (story == null) + { + _logger.LogWarning("Story not found: {StoryId}", id); + return NotFound(); + } + + if (story.UserId == _userContext.UserId) + { + _logger.LogInformation("Owner view, skipping"); + return Ok(new { message = "Owner view" }); + } if (!story.Viewers.Any(v => v.UserId == _userContext.UserId)) { story.AddViewer(_userContext.UserId); await _context.SaveChangesAsync(ct); + + _logger.LogInformation("Viewer added. Total viewers: {Count}", story.Viewers.Count); - // Notify owner via SignalR targetedly + // Notify owner via SignalR - send to all, filter on client var viewer = await _userRepository.GetByIdAsync(_userContext.UserId, ct); - await _hubContext.Clients.User(story.UserId.ToString()).SendAsync("story_viewed", new + + _logger.LogInformation("Sending story_viewed to owner {OwnerId}", story.UserId); + + // Get updated story with viewers to be sure count is accurate + var updatedStory = await _context.Stories.Include(s => s.Viewers).FirstAsync(s => s.Id == story.Id, ct); + + await _hubContext.Clients.All.SendAsync("story_viewed", new { storyId = story.Id, userId = _userContext.UserId, @@ -142,20 +236,27 @@ public sealed class StoriesController : ControllerBase displayName = viewer?.DisplayName, avatar = viewer?.Avatar, viewedAt = DateTime.UtcNow, - viewCount = story.Viewers.Count, + viewCount = updatedStory.Viewers.Count, ownerId = story.UserId }, ct); + + _logger.LogInformation("story_viewed sent successfully"); + } + else + { + _logger.LogInformation("User already viewed this story"); } return Ok(new { message = "Story viewed" }); } - catch (DbUpdateException) + catch (DbUpdateException ex) { - // Likely a unique constraint violation (already viewed) + _logger.LogError(ex, "DbUpdateException in ViewStory"); return Ok(new { message = "Story already viewed" }); } catch (Exception ex) { + _logger.LogError(ex, "ViewStory error"); return StatusCode(500, ex.Message); } } @@ -163,12 +264,25 @@ public sealed class StoriesController : ControllerBase [HttpGet("{id}/viewers")] public async Task GetStoryViewers(Guid id, CancellationToken ct) { + _logger.LogInformation("GetStoryViewers called: StoryId={StoryId}, UserId={UserId}", id, _userContext.UserId); + var story = await _context.Stories .Include(s => s.Viewers) .FirstOrDefaultAsync(s => s.Id == id, ct); - if (story == null) return NotFound(); - if (story.UserId != _userContext.UserId) return Forbid(); + if (story == null) + { + _logger.LogWarning("Story not found: {StoryId}", id); + return NotFound(); + } + + if (story.UserId != _userContext.UserId) + { + _logger.LogWarning("Forbidden: User {UserId} is not owner of story {StoryId}", _userContext.UserId, id); + return Forbid(); + } + + _logger.LogInformation("Story has {Count} viewers", story.Viewers.Count); var viewerIds = story.Viewers.Select(v => v.UserId).ToList(); var viewers = new List(); @@ -190,9 +304,213 @@ public sealed class StoriesController : ControllerBase }); } + _logger.LogInformation("Returning {Count} viewers", viewers.Count); return Ok(viewers); } + private string GetStoryQuote(Story story) + { + if (!string.IsNullOrEmpty(story.Content)) return story.Content; + return story.Type.ToLower() switch + { + "image" => "🖼 Фото", + "video" => "🎬 Видео", + _ => "История" + }; + } + + [HttpPost("{id}/reaction")] + public async Task AddReaction(Guid id, [FromBody] AddStoryReactionRequest request, CancellationToken ct) + { + _logger.LogInformation("AddReaction called: StoryId={StoryId}, UserId={UserId}, Emoji={Emoji}", id, _userContext.UserId, request.Emoji); + + try + { + var story = await _context.Stories.FindAsync(new object[] { id }, ct); + if (story == null) + { + _logger.LogWarning("Story not found: {StoryId}", id); + return NotFound(); + } + + // Check if reaction already exists + var existing = await _context.StoryReactions + .FirstOrDefaultAsync(r => r.StoryId == id && r.UserId == _userContext.UserId && r.Emoji == request.Emoji, ct); + + if (existing != null) + { + _logger.LogInformation("Reaction already exists"); + return Ok(new { message = "Reaction already exists" }); + } + + // Add reaction directly via DbSet + var reaction = new StoryReaction(id, _userContext.UserId, request.Emoji); + _context.StoryReactions.Add(reaction); + await _context.SaveChangesAsync(ct); + + _logger.LogInformation("Reaction saved successfully"); + + // 1. Create/Find chat + var chatId = await GetOrCreatePersonalChatIdAsync(_userContext.UserId, story.UserId, ct); + + // 2. Threading: find last story message in this chat + var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, ct); + + if (lastStoryMessage != null) + { + // If message already exists for this story, add a reaction to it + var addReactionCommand = new Vortex.Modules.Chats.Application.Messages.React.AddReactionCommand( + lastStoryMessage.Id, _userContext.UserId, request.Emoji, chatId); + await _sender.Send(addReactionCommand, ct); + } + else + { + // Create new message for the story + var storyQuote = GetStoryQuote(story); + var messageCommand = new SendMessageCommand( + ChatId: chatId, + SenderId: _userContext.UserId, + Content: request.Emoji, + Type: "text", + Quote: storyQuote, + StoryId: story.Id, + StoryMediaUrl: story.MediaUrl, + StoryMediaType: story.Type); + await _sender.Send(messageCommand, ct); + } + + // 3. Notify story owner in real-time (existing StoryViewer listeners) + var reactor = await _userRepository.GetByIdAsync(_userContext.UserId, ct); + await _hubContext.Clients.All.SendAsync("story_reaction", new + { + storyId = story.Id, + userId = _userContext.UserId, + username = reactor?.Username, + displayName = reactor?.DisplayName, + avatar = reactor?.Avatar, + emoji = request.Emoji, + createdAt = DateTime.UtcNow, + ownerId = story.UserId + }, ct); + + return Ok(new { message = "Reaction added" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "AddReaction error"); + return StatusCode(500, ex.Message); + } + } + + [HttpDelete("{id}/reaction")] + public async Task RemoveReaction(Guid id, [FromBody] RemoveStoryReactionRequest request, CancellationToken ct) + { + var reaction = await _context.StoryReactions + .FirstOrDefaultAsync(r => r.StoryId == id && r.UserId == _userContext.UserId && r.Emoji == request.Emoji, ct); + + if (reaction == null) return Ok(new { message = "Reaction not found" }); + + _context.StoryReactions.Remove(reaction); + await _context.SaveChangesAsync(ct); + + return Ok(new { message = "Reaction removed" }); + } + + [HttpPost("{id}/reply")] + public async Task AddReply(Guid id, [FromBody] AddStoryReplyRequest request, CancellationToken ct) + { + _logger.LogInformation("AddReply called: StoryId={StoryId}, UserId={UserId}, Content={Content}", id, _userContext.UserId, request.Content); + + try + { + var story = await _context.Stories.FindAsync(new object[] { id }, ct); + if (story == null) + { + _logger.LogWarning("Story not found: {StoryId}", id); + return NotFound(); + } + + // Add reply directly via DbSet + var reply = new StoryReply(id, _userContext.UserId, request.Content); + _context.StoryReplies.Add(reply); + await _context.SaveChangesAsync(ct); + + _logger.LogInformation("Reply saved successfully"); + + // 1. Create/Find chat + var chatId = await GetOrCreatePersonalChatIdAsync(_userContext.UserId, story.UserId, ct); + + // 2. Find last message for threading + var lastStoryMessage = await _messageRepository.GetLastStoryMessageAsync(chatId, story.Id, ct); + + // 3. Send message to chat + var storyQuote = GetStoryQuote(story); + var messageCommand = new SendMessageCommand( + ChatId: chatId, + SenderId: _userContext.UserId, + Content: request.Content, + Type: "text", + Quote: storyQuote, + ReplyToId: lastStoryMessage?.Id, + StoryId: story.Id, + StoryMediaUrl: story.MediaUrl, + StoryMediaType: story.Type); + await _sender.Send(messageCommand, ct); + + // 3. Notify story owner in real-time (existing StoryViewer listeners) + var replier = await _userRepository.GetByIdAsync(_userContext.UserId, ct); + await _hubContext.Clients.All.SendAsync("story_reply", new + { + storyId = story.Id, + userId = _userContext.UserId, + username = replier?.Username, + displayName = replier?.DisplayName, + avatar = replier?.Avatar, + content = request.Content, + createdAt = DateTime.UtcNow, + ownerId = story.UserId + }, ct); + + return Ok(new { message = "Reply added" }); + } + catch (Exception ex) + { + _logger.LogError(ex, "AddReply error"); + return StatusCode(500, ex.Message); + } + } + + [HttpGet("{id}/replies")] + public async Task GetReplies(Guid id, CancellationToken ct) + { + var story = await _context.Stories + .Include(s => s.Replies) + .FirstOrDefaultAsync(s => s.Id == id, ct); + + if (story == null) return NotFound(); + if (story.UserId != _userContext.UserId) return Forbid(); + + var replies = new List(); + foreach (var reply in story.Replies.OrderBy(r => r.CreatedAt)) + { + var user = await _userRepository.GetByIdAsync(reply.UserId, ct); + if (user == null) continue; + + replies.Add(new + { + id = reply.Id, + userId = user.Id, + username = user.Username, + displayName = user.DisplayName, + avatar = user.Avatar, + content = reply.Content, + createdAt = reply.CreatedAt + }); + } + + return Ok(replies); + } + [HttpDelete("{id}")] public async Task DeleteStory(Guid id, CancellationToken ct) { @@ -205,6 +523,22 @@ public sealed class StoriesController : ControllerBase return Ok(new { message = "Story deleted" }); } + private async Task GetOrCreatePersonalChatIdAsync(Guid userId1, Guid userId2, CancellationToken ct) + { + var userChats = await _chatRepository.GetUserChatsAsync(userId1, ct); + var personalChat = userChats.FirstOrDefault(c => + c.Type == ChatType.Personal && + c.Members.Any(m => m.UserId == userId2)); + + if (personalChat != null) return personalChat.Id; + + var command = new Vortex.Modules.Chats.Application.Chats.Create.CreateChatCommand(string.Empty, ChatType.Personal, new List { userId1, userId2 }); + var result = await _sender.Send(command, ct); + return result.Value; + } } public sealed record CreateStoryRequest(string Type, string? MediaUrl, string? Content, string? BgColor); +public sealed record AddStoryReactionRequest(string Emoji); +public sealed record RemoveStoryReactionRequest(string Emoji); +public sealed record AddStoryReplyRequest(string Content); diff --git a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Host.dll b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Host.dll index caba2dc..3f0ecdb 100644 Binary files a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Host.dll and b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Host.dll differ diff --git a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Host.exe b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Host.exe index d19854b..ae9eded 100644 Binary files a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Host.exe and b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Host.exe differ diff --git a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Host.pdb b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Host.pdb index f00d269..5ed2e10 100644 Binary files a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Host.pdb and b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Host.pdb differ diff --git a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Chats.dll b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Chats.dll index 4055ad9..1b4ab28 100644 Binary files a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Chats.dll and b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Chats.dll differ diff --git a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Chats.pdb b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Chats.pdb index 294411c..660b954 100644 Binary files a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Chats.pdb and b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Chats.pdb differ diff --git a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Identity.dll b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Identity.dll index 5ea84ed..e2b46a1 100644 Binary files a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Identity.dll and b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Identity.dll differ diff --git a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Identity.pdb b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Identity.pdb index 6f515cb..75e99cf 100644 Binary files a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Identity.pdb and b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Modules.Identity.pdb differ diff --git a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Infrastructure.dll b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Infrastructure.dll index b443b93..865990a 100644 Binary files a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Infrastructure.dll and b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Infrastructure.dll differ diff --git a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Infrastructure.pdb b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Infrastructure.pdb index 321a293..d79bd5c 100644 Binary files a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Infrastructure.pdb and b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Infrastructure.pdb differ diff --git a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Kernel.dll b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Kernel.dll index c28b7cf..eb1c91e 100644 Binary files a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Kernel.dll and b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Kernel.dll differ diff --git a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Kernel.pdb b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Kernel.pdb index 1300c10..2d5f470 100644 Binary files a/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Kernel.pdb and b/apps/server-net/src/Vortex.Host/bin/Debug/net10.0/Vortex.Shared.Kernel.pdb differ diff --git a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.AssemblyInfo.cs b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.AssemblyInfo.cs index 7e23a53..2b6c20d 100644 --- a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.AssemblyInfo.cs +++ b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.AssemblyInfo.cs @@ -13,7 +13,7 @@ using System.Reflection; [assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Host")] [assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")] [assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")] -[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9daafae9c5956290dda1c718bb49ff72c77fafb8")] +[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+ca1d88191ca628dbe2371abab56fb3cdfe386d65")] [assembly: System.Reflection.AssemblyProductAttribute("Vortex.Host")] [assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Host")] [assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")] diff --git a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.AssemblyInfoInputs.cache b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.AssemblyInfoInputs.cache index 8260d49..40c03ba 100644 --- a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.AssemblyInfoInputs.cache +++ b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.AssemblyInfoInputs.cache @@ -1 +1 @@ -2503d90bfacb43b23a8199c8a1cb6d660f9b8abff8c009ccb8b71721eacb6d50 +f3d52bfd2b858d5d383380a9e4100efe419d613a4ecf13cf06c4ad546122d06b diff --git a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.csproj.AssemblyReference.cache b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.csproj.AssemblyReference.cache index cd9082e..5934eb1 100644 Binary files a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.csproj.AssemblyReference.cache and b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.csproj.AssemblyReference.cache differ diff --git a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.dll b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.dll index caba2dc..3f0ecdb 100644 Binary files a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.dll and b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.dll differ diff --git a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.pdb b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.pdb index f00d269..5ed2e10 100644 Binary files a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.pdb and b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/Vortex.Host.pdb differ diff --git a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/apphost.exe b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/apphost.exe index d19854b..ae9eded 100644 Binary files a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/apphost.exe and b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/apphost.exe differ diff --git a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/ref/Vortex.Host.dll b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/ref/Vortex.Host.dll index f42ffbb..c826413 100644 Binary files a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/ref/Vortex.Host.dll and b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/ref/Vortex.Host.dll differ diff --git a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/refint/Vortex.Host.dll b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/refint/Vortex.Host.dll index f42ffbb..c826413 100644 Binary files a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/refint/Vortex.Host.dll and b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/refint/Vortex.Host.dll differ diff --git a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/rjsmcshtml.dswa.cache.json b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/rjsmcshtml.dswa.cache.json index 4cc3d41..7182b40 100644 --- a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/rjsmcshtml.dswa.cache.json +++ b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/rjsmcshtml.dswa.cache.json @@ -1 +1 @@ -{"GlobalPropertiesHash":"2XkPbXFkjVRjCUBTJbQq8CDd7pjsUOS1j3gPHS73xDA=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","6IHvDihzP7nz2jPiGaen/UwCBL1mH\u002BfY\u002BnUJBq/hoXU="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file +{"GlobalPropertiesHash":"2XkPbXFkjVRjCUBTJbQq8CDd7pjsUOS1j3gPHS73xDA=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","GsJcYsaPdTiZszOuaIQXvBoHa2G\u002B8z3ZDvEk9/eI47g="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file diff --git a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/rjsmrazor.dswa.cache.json b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/rjsmrazor.dswa.cache.json index 63edb94..66fb2e1 100644 --- a/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/rjsmrazor.dswa.cache.json +++ b/apps/server-net/src/Vortex.Host/obj/Debug/net10.0/rjsmrazor.dswa.cache.json @@ -1 +1 @@ -{"GlobalPropertiesHash":"2L4HBRt/ChGO9rohi3wtSDqo95X47fme5m8OHgJIZL0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","6IHvDihzP7nz2jPiGaen/UwCBL1mH\u002BfY\u002BnUJBq/hoXU="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file +{"GlobalPropertiesHash":"2L4HBRt/ChGO9rohi3wtSDqo95X47fme5m8OHgJIZL0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","GsJcYsaPdTiZszOuaIQXvBoHa2G\u002B8z3ZDvEk9/eI47g="],"CachedAssets":{},"CachedCopyCandidates":{}} \ No newline at end of file diff --git a/apps/server-net/uploads/00b60c16-44cf-4874-b8c3-ff4c2c12cdeb.jpg b/apps/server-net/uploads/00b60c16-44cf-4874-b8c3-ff4c2c12cdeb.jpg new file mode 100644 index 0000000..e9fb2f0 Binary files /dev/null and b/apps/server-net/uploads/00b60c16-44cf-4874-b8c3-ff4c2c12cdeb.jpg differ diff --git a/apps/server-net/uploads/02e66d3a-e503-434e-b712-f62985d49a9a.png b/apps/server-net/uploads/02e66d3a-e503-434e-b712-f62985d49a9a.png new file mode 100644 index 0000000..c058244 Binary files /dev/null and b/apps/server-net/uploads/02e66d3a-e503-434e-b712-f62985d49a9a.png differ diff --git a/apps/server-net/uploads/16f8242f-1f76-4c5a-b518-4595b37f6b43.png b/apps/server-net/uploads/16f8242f-1f76-4c5a-b518-4595b37f6b43.png new file mode 100644 index 0000000..d7f3e6e Binary files /dev/null and b/apps/server-net/uploads/16f8242f-1f76-4c5a-b518-4595b37f6b43.png differ diff --git a/apps/server-net/uploads/4f416ad7-b5ee-4328-bbd4-14d8279c6174.mp4 b/apps/server-net/uploads/4f416ad7-b5ee-4328-bbd4-14d8279c6174.mp4 new file mode 100644 index 0000000..24a55e0 Binary files /dev/null and b/apps/server-net/uploads/4f416ad7-b5ee-4328-bbd4-14d8279c6174.mp4 differ diff --git a/apps/server-net/uploads/5841ec62-44a6-402c-a84d-4f8df68ccb15.mp4 b/apps/server-net/uploads/5841ec62-44a6-402c-a84d-4f8df68ccb15.mp4 new file mode 100644 index 0000000..24a55e0 Binary files /dev/null and b/apps/server-net/uploads/5841ec62-44a6-402c-a84d-4f8df68ccb15.mp4 differ diff --git a/apps/server-net/uploads/6d0e82ac-268e-4e5c-9397-3c7eb98437c8.jpg b/apps/server-net/uploads/6d0e82ac-268e-4e5c-9397-3c7eb98437c8.jpg new file mode 100644 index 0000000..410b340 Binary files /dev/null and b/apps/server-net/uploads/6d0e82ac-268e-4e5c-9397-3c7eb98437c8.jpg differ diff --git a/apps/server-net/uploads/9ea3d58b-d519-47f0-a240-2eeb73e99ffd.png b/apps/server-net/uploads/9ea3d58b-d519-47f0-a240-2eeb73e99ffd.png new file mode 100644 index 0000000..9d904e5 Binary files /dev/null and b/apps/server-net/uploads/9ea3d58b-d519-47f0-a240-2eeb73e99ffd.png differ diff --git a/apps/server-net/uploads/avatars/10482c45-a17d-44b6-baa1-e9baf9c4c053.png b/apps/server-net/uploads/avatars/10482c45-a17d-44b6-baa1-e9baf9c4c053.png new file mode 100644 index 0000000..f8dd784 Binary files /dev/null and b/apps/server-net/uploads/avatars/10482c45-a17d-44b6-baa1-e9baf9c4c053.png differ diff --git a/apps/server-net/uploads/b07615ef-3ddb-47fb-948f-0a804414aed0.mp4 b/apps/server-net/uploads/b07615ef-3ddb-47fb-948f-0a804414aed0.mp4 new file mode 100644 index 0000000..24a55e0 Binary files /dev/null and b/apps/server-net/uploads/b07615ef-3ddb-47fb-948f-0a804414aed0.mp4 differ diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 23540c9..7e512e7 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -76,7 +76,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const chatViewRef = useRef(null); const chat = chats.find((c) => c.id === activeChat); - const chatMessages = activeChat ? messages[activeChat] || [] : []; + const allChatMessages = activeChat ? messages[activeChat] || [] : []; + // Filter out deleted messages to prevent layout shifts + const chatMessages = allChatMessages.filter(m => !m.isDeleted); const pinnedMsg = activeChat ? pinnedMessages[activeChat] : null; // Количество непрочитанных сообщений (для бейджика) @@ -194,7 +196,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal useEffect(() => { if (!activeChat || !user?.id) return; - + // Cleanup previous observer if (observerRef.current) observerRef.current.disconnect(); sentReadIdsRef.current.clear(); @@ -917,6 +919,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal userId={profileUserId} chatId={activeChat || undefined} onClose={() => setProfileUserId(null)} + onGoToMessage={(msgId) => { + const el = document.getElementById(`msg-${msgId}`); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('bg-vortex-500/20'); + setTimeout(() => el.classList.remove('bg-vortex-500/20'), 2000); + setProfileUserId(null); + } + }} isSelf={profileUserId === user?.id} /> )} diff --git a/apps/web/src/components/MessageBubble.tsx b/apps/web/src/components/MessageBubble.tsx index d987e58..e21df1d 100644 --- a/apps/web/src/components/MessageBubble.tsx +++ b/apps/web/src/components/MessageBubble.tsx @@ -279,28 +279,8 @@ function MessageBubble({ }; }, [showContext]); - const [deletedVisible, setDeletedVisible] = useState(true); - useEffect(() => { - if (message.isDeleted) { - const timer = setTimeout(() => setDeletedVisible(false), 5000); - return () => clearTimeout(timer); - } - }, [message.isDeleted]); - if (message.isDeleted) { - if (!deletedVisible) return null; - return ( - -
- {t('messageDeleted')} -
-
- ); + return null; } const media = message.media || []; @@ -419,18 +399,54 @@ function MessageBubble({ {message.replyTo.sender?.displayName || message.replyTo.sender?.username}

- {message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && ( + {message.replyTo.isDeleted ? ( +

{t('messageDeleted')}

+ ) : ( + <> + {message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && ( +
+ {message.replyTo.media[0].type === 'image' ? ( + + ) : message.replyTo.media[0].type === 'video' ? ( +
+ ) : ( +
+ )} +
+ )} +

{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? t('media') : '')}

+ + )} +
+ + )} + + {/* Story Reply Quote */} + {message.storyId && ( +
+

+ {t('story')} +

+
+ {message.storyMediaUrl && (
- {message.replyTo.media[0].type === 'image' ? ( - - ) : message.replyTo.media[0].type === 'video' ? ( -
+ {message.storyMediaType === 'video' ? ( +
+
+ ) : message.storyMediaType === 'image' ? ( + ) : ( -
+
)}
)} -

{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? t('media') : '')}

+

+ {message.quote} +

)} @@ -442,10 +458,10 @@ function MessageBubble({ onDoubleClick={handleReply} title={t('reply') ? `${t('reply')} (Double Click)` : 'Double click to reply'} className={`cursor-pointer rounded-[1.25rem] overflow-hidden transition-all duration-300 ${hasImage && !message.content - ? 'p-0 shadow-none border-none' - : isMine - ? 'bubble-sent text-white shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105' - : 'bubble-received text-zinc-100 shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105' + ? 'p-0 shadow-none border-none' + : isMine + ? 'bubble-sent text-white shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105' + : 'bubble-received text-zinc-100 shadow-sm px-4 py-2.5 hover:shadow-md hover:brightness-105' }`} > {/* Рендер пересланного сообщения */} @@ -467,10 +483,10 @@ function MessageBubble({ {(hasImage || hasVideo) && (
m.type === 'image' || m.type === 'video').length >= 3 - ? 'grid-cols-3' - : media.filter(m => m.type === 'image' || m.type === 'video').length === 2 - ? 'grid-cols-2' - : 'grid-cols-1' + ? 'grid-cols-3' + : media.filter(m => m.type === 'image' || m.type === 'video').length === 2 + ? 'grid-cols-2' + : 'grid-cols-1' }`}> {media .filter((m) => m.type === 'image' || m.type === 'video') diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 717eaf3..6d9470b 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -22,6 +22,7 @@ import NewChatModal from './NewChatModal'; import UserProfile from './UserProfile'; import SideMenu from './SideMenu'; import StoryViewer, { CreateStoryModal } from './StoryViewer'; +import { useStoryStore } from '../stores/useStoryStore'; const API_URL = import.meta.env.VITE_API_URL || ''; @@ -32,8 +33,7 @@ export default function Sidebar() { const [showNewChat, setShowNewChat] = useState(false); const [showProfile, setShowProfile] = useState(false); const [showSideMenu, setShowSideMenu] = useState(false); - const [storyGroups, setStoryGroups] = useState([]); - const [storyViewerIndex, setStoryViewerIndex] = useState(null); + const { storyGroups, setStoryGroups, viewerIndex, viewerStoryIndex, openViewer, closeViewer } = useStoryStore(); const [showCreateStory, setShowCreateStory] = useState(false); const loadStories = () => { @@ -165,7 +165,7 @@ export default function Sidebar() { return ( )} - {/* Viewers panel */} + {/* Bottom actions */} +
+ {/* Sound toggle for video - show for everyone */} + {isVideo && ( + + )} + + {/* Reply and reactions - only for non-owners */} + {currentUser.user.id !== user?.id && ( + <> + + + + + )} +
+ + + {showReactions && currentUser.user.id !== user?.id && ( + e.stopPropagation()} + > + {STORY_EMOJIS.map(emoji => ( + + ))} + + )} + + + + {showReplyInput && currentUser.user.id !== user?.id && ( + e.stopPropagation()} + > +
+ setReplyText(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSendReply(); }} + placeholder={t('replyToStory') || 'Reply to story...'} + className="flex-1 bg-black/70 backdrop-blur-sm border border-white/20 rounded-full px-4 py-2 text-sm text-white placeholder-white/50 focus:outline-none focus:border-accent" + autoFocus + /> + +
+
+ )} +
+ {showViewers && currentUser.user.id === user?.id && ( void; onCreated: () => void; @@ -432,8 +617,8 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps) if (!file) return; setImageFile(file); if (file.type.startsWith('video/')) { - setImagePreview(URL.createObjectURL(file)); - setMode('image'); // We use 'image' mode for both media types for now or we can rename it to 'media' + setImagePreview(URL.createObjectURL(file)); + setMode('image'); } else { const reader = new FileReader(); reader.onload = () => setImagePreview(reader.result as string); @@ -491,7 +676,6 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
- {/* Mode tabs */}
- ) : activeTab === 'media' ? ( - sharedMedia.length > 0 ? ( + ) : activeTab === 'stories' ? ( + userStories && userStories.stories.length > 0 ? (
- {(() => { - const allMedia = sharedMedia.flatMap((msg) => (msg.media || [])); - return allMedia.map((m, idx) => ( -
setLightboxIndex(idx)} - className="relative aspect-square bg-zinc-900 overflow-hidden group cursor-pointer" - > - {m.type === 'video' ? ( - <> - -
- -
- - ) : ( - - )} + {userStories.stories.map((story, idx) => ( +
{ + const group: StoryGroup = { + user: profile!, + stories: userStories.stories, + hasUnviewed: false + }; + openViewer(0, idx, [group]); + }} + className="relative aspect-[9/16] bg-zinc-900 overflow-hidden group border border-white/5 rounded-md cursor-pointer" + > + {story.type === 'video' ? ( +
+ {story.mediaUrl &&
+ ) : story.type === 'image' ? ( + + ) : ( +
+

{story.content}

+
+ )} +
+ + {new Date(story.createdAt).toLocaleDateString()}
- )); - })()} +
+ ))}
) : (
-

{t('sharedPhotos')}

+

{(t('noStories') || 'No stories yet') as string}

+
+ ) + ) : activeTab === 'media' ? ( + allMedia.length > 0 ? ( +
+ {allMedia.map((m, idx) => ( +
+ {m.type === 'video' ? ( + <> + setLightboxIndex(idx)} + className="w-full h-full object-cover" + /> +
+ +
+ + ) : ( + setLightboxIndex(idx)} + className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200" + /> + )} + {/* Navigation button */} + +
+ ))} +
+ ) : ( +
+

{t('sharedPhotos') as string}

) ) : activeTab === 'files' ? ( @@ -475,38 +553,46 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
{sharedFiles.flatMap((msg) => (msg.media || []).map((m) => ( - -
- -
-
-

{m.filename || 'file'}

-

- {m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''} - {msg.sender ? ` · ${msg.sender.displayName || msg.sender.username}` : ''} -

-
- -
+
+ +
+ +
+
+

{m.filename || 'file'}

+

+ {m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''} + {msg.sender ? ` · ${msg.sender.displayName || msg.sender.username}` : ''} +

+
+ +
+ +
)) )}
) : (
-

{t('sharedFiles')}

+

{t('sharedFiles') as string}

) ) : ( sharedLinks.length > 0 ? (
{sharedLinks.map((msg) => ( -
-

+

+

{msg.sender?.displayName || msg.sender?.username} · {new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US')}

{(msg.links || []).map((link: string, i: number) => ( @@ -524,12 +610,19 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro {msg.content && (

{msg.content}

)} +
))}
) : (
-

{t('sharedLinks')}

+

{t('sharedLinks') as string}

) )} @@ -547,7 +640,7 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro {lightboxIndex !== null && ( (msg.media || []).map((m) => ({ url: m.url, type: m.type })))} + images={allMedia.map((m) => ({ url: m.url, type: m.type }))} initialIndex={lightboxIndex} onClose={() => setLightboxIndex(null)} /> diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 977531c..18dc299 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -242,6 +242,10 @@ class ApiClient { return this.request('/stories'); } + async getUserStories(userId: string) { + return this.request(`/stories/user/${userId}`); + } + async createStory(data: { type: string; mediaUrl?: string; content?: string; bgColor?: string }) { return this.request<{ id: string }>('/stories', { method: 'POST', @@ -277,6 +281,31 @@ class ApiClient { return this.request>(`/stories/${storyId}/viewers`); } + async addStoryReaction(storyId: string, emoji: string) { + return this.request<{ message: string }>(`/stories/${storyId}/reaction`, { + method: 'POST', + body: JSON.stringify({ emoji }), + }); + } + + async removeStoryReaction(storyId: string, emoji: string) { + return this.request<{ message: string }>(`/stories/${storyId}/reaction`, { + method: 'DELETE', + body: JSON.stringify({ emoji }), + }); + } + + async addStoryReply(storyId: string, content: string) { + return this.request<{ message: string }>(`/stories/${storyId}/reply`, { + method: 'POST', + body: JSON.stringify({ content }), + }); + } + + async getStoryReplies(storyId: string) { + return this.request>(`/stories/${storyId}/replies`); + } + // Favorites chat async getOrCreateFavorites() { return this.request('/chats/favorites', { method: 'POST' }); diff --git a/apps/web/src/lib/i18n.ts b/apps/web/src/lib/i18n.ts index f226c60..585159c 100644 --- a/apps/web/src/lib/i18n.ts +++ b/apps/web/src/lib/i18n.ts @@ -179,6 +179,10 @@ const translations = { sharedFiles: 'Общие файлы будут здесь', sharedLinks: 'Общие ссылки будут здесь', profileNotFound: 'Профиль не найден', + storiesTab: 'Публикации', + noStories: 'Публикаций пока нет', + goToMessage: 'Перейти к сообщению', + story: 'История', // Typing typingText: 'печатает', // Emoji @@ -247,6 +251,8 @@ const translations = { // Story viewers storyViewers: 'Кто просмотрел', noViewers: 'Пока никто не посмотрел', + replyToStory: 'Ответить на историю...', + send: 'Отправить', // Favorites favorites: 'Избранное', favoritesDescription: 'Сохраняйте важные сообщения здесь', @@ -435,6 +441,10 @@ const translations = { sharedFiles: 'Shared files will appear here', sharedLinks: 'Shared links will appear here', profileNotFound: 'Profile not found', + storiesTab: 'Stories', + noStories: 'No stories yet', + goToMessage: 'Go to message', + story: 'Story', typingText: 'typing', emojiFrequent: 'Frequent', emojiGestures: 'Gestures', @@ -492,6 +502,8 @@ const translations = { minCharsHint: 'Enter at least 3 characters after @', storyViewers: 'Who viewed', noViewers: 'No one viewed yet', + replyToStory: 'Reply to story...', + send: 'Send', favorites: 'Favorites', favoritesDescription: 'Save important messages here', privacy: 'Privacy', diff --git a/apps/web/src/lib/socket.ts b/apps/web/src/lib/socket.ts index 8eb7b89..41c2bf9 100644 --- a/apps/web/src/lib/socket.ts +++ b/apps/web/src/lib/socket.ts @@ -27,7 +27,7 @@ export function connectSocket(token: string): SocketCompat { // Обертка для совместимости с Socket.io API socketWrapper = { on: (event: string, callback: (...args: any[]) => void) => { - console.log(`[SignalR] Registering handler for: ${event}`); + // console.log(`[SignalR] Registering handler for: ${event}`); connection?.on(event, callback); }, off: (event: string, callback?: (...args: any[]) => void) => { @@ -39,15 +39,15 @@ export function connectSocket(token: string): SocketCompat { }, emit: (event: string, ...args: any[]) => { const state = connection?.state; - console.log(`[SignalR] emit('${event}') called, connection state: ${state}`); + // console.log(`[SignalR] emit('${event}') called, connection state: ${state}`); if (connection?.state === 'Connected') { // В SignalR invoke возвращает Promise, но Socket.io emit - нет. // Мы просто запускаем и логируем ошибки. connection.invoke(event, ...args) - .then(() => console.log(`[SignalR] invoke('${event}') completed successfully`)) + // .then(() => console.log(`[SignalR] invoke('${event}') completed successfully`)) .catch(err => console.error(`SignalR emit error (${event}):`, err)); } else { - console.warn(`SignalR emit skipped (${event}): connection state is ${state}`); + // console.warn(`SignalR emit skipped (${event}): connection state is ${state}`); } }, disconnect: () => { diff --git a/apps/web/src/lib/types.ts b/apps/web/src/lib/types.ts index 91ee96b..b731881 100644 --- a/apps/web/src/lib/types.ts +++ b/apps/web/src/lib/types.ts @@ -67,6 +67,9 @@ export interface Message { replyToId: string | null; quote?: string | null; forwardedFromId?: string | null; + storyId?: string | null; + storyMediaUrl?: string | null; + storyMediaType?: string | null; isEdited: boolean; isDeleted: boolean; scheduledAt?: string | null; @@ -76,6 +79,7 @@ export interface Message { replyTo?: { id: string; content: string | null; + isDeleted?: boolean; quote?: string | null; media?: MediaItem[]; sender: { id: string; username: string; displayName: string }; @@ -128,6 +132,8 @@ export interface Story { expiresAt: string; viewCount: number; viewed: boolean; + reactions?: Array<{ id: string; userId: string; emoji: string; createdAt: string }>; + replyCount?: number; } export interface StoryViewer { diff --git a/apps/web/src/pages/ChatPage.tsx b/apps/web/src/pages/ChatPage.tsx index 6477892..3b13b08 100644 --- a/apps/web/src/pages/ChatPage.tsx +++ b/apps/web/src/pages/ChatPage.tsx @@ -96,7 +96,7 @@ export default function ChatPage() { } addMessage(message); // Play notification sound for messages from others - if (message.senderId !== user?.id && !isChatMuted(message.chatId)) { + if (message.senderId !== user?.id && !message.storyId && !isChatMuted(message.chatId)) { playNotificationSound(); } }); @@ -219,6 +219,31 @@ export default function ChatPage() { setCallOpen(true); }); + // Story events - registered globally so they work even when StoryViewer is closed + socket.on('story_viewed', (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string; viewCount: number; ownerId: string }) => { + console.log('[Socket] story_viewed received:', data); + // Only process if this user is the owner + if (data.ownerId === user?.id) { + console.log('[Socket] This is my story, updating view count'); + } + }); + + socket.on('story_reply', (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string; ownerId: string }) => { + console.log('[Socket] story_reply received:', data); + // Only process if this user is the owner + if (data.ownerId === user?.id) { + console.log('[Socket] This is my story, got reply:', data.content); + } + }); + + socket.on('story_reaction', (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; emoji: string; createdAt: string; ownerId: string }) => { + console.log('[Socket] story_reaction received:', data); + // Only process if this user is the owner + if (data.ownerId === user?.id) { + console.log('[Socket] This is my story, got reaction:', data.emoji); + } + }); + return () => { socket.off('new_message'); socket.off('scheduled_delivered'); @@ -237,6 +262,9 @@ export default function ChatPage() { socket.off('message_pinned'); socket.off('message_unpinned'); socket.off('call_incoming'); + socket.off('story_viewed'); + socket.off('story_reply'); + socket.off('story_reaction'); }; }, [user?.id]); diff --git a/apps/web/src/stores/chatStore.ts b/apps/web/src/stores/chatStore.ts index 95ac3f2..ae44a4e 100644 --- a/apps/web/src/stores/chatStore.ts +++ b/apps/web/src/stores/chatStore.ts @@ -152,7 +152,7 @@ export const useChatStore = create((set, get) => ({ return { ...chat, messages: [message], - unreadCount: chat.id === state.activeChat ? chat.unreadCount : chat.unreadCount + 1, + unreadCount: (chat.id === state.activeChat || message.senderId === userId || message.storyId) ? chat.unreadCount : chat.unreadCount + 1, }; } return chat; diff --git a/apps/web/src/stores/useStoryStore.ts b/apps/web/src/stores/useStoryStore.ts new file mode 100644 index 0000000..93b309a --- /dev/null +++ b/apps/web/src/stores/useStoryStore.ts @@ -0,0 +1,24 @@ +import { create } from 'zustand'; +import { StoryGroup } from '../lib/types'; + +interface StoryState { + storyGroups: StoryGroup[]; + viewerIndex: number | null; + viewerStoryIndex: number; + setStoryGroups: (groups: StoryGroup[]) => void; + openViewer: (userIndex: number, storyIndex?: number, groups?: StoryGroup[]) => void; + closeViewer: () => void; +} + +export const useStoryStore = create((set, get) => ({ + storyGroups: [], + viewerIndex: null, + viewerStoryIndex: 0, + setStoryGroups: (storyGroups) => set({ storyGroups }), + openViewer: (userIndex, storyIndex = 0, groups) => set({ + storyGroups: groups || get().storyGroups, + viewerIndex: userIndex, + viewerStoryIndex: storyIndex + }), + closeViewer: () => set({ viewerIndex: null, viewerStoryIndex: 0 }), +})); diff --git a/fix-migration.sql b/fix-migration.sql new file mode 100644 index 0000000..0fa4ed6 --- /dev/null +++ b/fix-migration.sql @@ -0,0 +1 @@ +INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ('20260313115319_AddStoryReactionsReplies', '10.0.4') ON CONFLICT DO NOTHING;