Ответы и реакции истории, публикации в профиле. Фиксы

This commit is contained in:
Халимов Рустам
2026-03-13 23:34:32 +03:00
parent ca1d88191c
commit 010b96d362
62 changed files with 2365 additions and 189 deletions

View File

@@ -17,7 +17,10 @@ public sealed record SendMessageCommand(
List<AttachmentRequest>? Attachments = null,
Guid? ReplyToId = null,
string? Quote = null,
Guid? ForwardedFromId = null) : ICommand<Guid>;
Guid? ForwardedFromId = null,
Guid? StoryId = null,
string? StoryMediaUrl = null,
string? StoryMediaType = null) : ICommand<Guid>;
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
{
@@ -55,7 +58,10 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
request.Type,
request.ReplyToId,
request.Quote,
request.ForwardedFromId);
request.ForwardedFromId,
request.StoryId,
request.StoryMediaUrl,
request.StoryMediaType);
if (request.Attachments != null && request.Attachments.Any())
{

View File

@@ -11,4 +11,5 @@ public interface IMessageRepository
Task AddReadReceiptsAsync(Guid userId, List<Guid> messageIds, CancellationToken cancellationToken);
Task<bool> AddReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
Task<bool> RemoveReactionAsync(Guid messageId, Guid userId, string emoji, CancellationToken cancellationToken);
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
}

View File

@@ -21,6 +21,9 @@ public sealed class Message : AggregateRoot<Guid>
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> _media = new();
@@ -32,7 +35,7 @@ public sealed class Message : AggregateRoot<Guid>
private readonly List<Guid> _deletedByUsers = new();
public IReadOnlyCollection<Guid> 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<Guid>
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)

View File

@@ -111,4 +111,12 @@ public sealed class MessageRepository : IMessageRepository
return true;
}
public async Task<Message?> 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);
}
}

View File

@@ -0,0 +1,254 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
.IsRequired()
.HasColumnType("uuid[]")
.HasColumnName("DeletedByUsers");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<Guid?>("StoryId")
.HasColumnType("uuid");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Reaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Vortex.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("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
}
}
}

View File

@@ -0,0 +1,31 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStoryIdToMessages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "StoryId",
schema: "chats",
table: "Messages",
type: "uuid",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "StoryId",
schema: "chats",
table: "Messages");
}
}
}

View File

@@ -0,0 +1,260 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("chats")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Chat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Chats", "chats");
});
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ChatId")
.HasColumnType("uuid");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<Guid[]>("DeletedByUsers")
.IsRequired()
.HasColumnType("uuid[]")
.HasColumnName("DeletedByUsers");
b.Property<Guid?>("ForwardedFromId")
.HasColumnType("uuid");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.Property<bool>("IsEdited")
.HasColumnType("boolean");
b.Property<string>("Quote")
.HasColumnType("text");
b.Property<Guid?>("ReplyToId")
.HasColumnType("uuid");
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<Guid?>("StoryId")
.HasColumnType("uuid");
b.Property<string>("StoryMediaType")
.HasColumnType("text");
b.Property<string>("StoryMediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.HasKey("Id");
b.ToTable("Messages", "chats");
});
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Reaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Emoji")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("MessageId")
.HasColumnType("uuid");
b.Property<DateTime>("ReadAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<Guid>("ChatId")
.HasColumnType("uuid");
b1.Property<bool>("IsMuted")
.HasColumnType("boolean");
b1.Property<bool>("IsPinned")
.HasColumnType("boolean");
b1.Property<DateTime>("JoinedAt")
.HasColumnType("timestamp with time zone");
b1.Property<string>("Role")
.IsRequired()
.HasColumnType("text");
b1.Property<Guid>("UserId")
.HasColumnType("uuid");
b1.HasKey("Id");
b1.HasIndex("ChatId", "UserId")
.IsUnique();
b1.ToTable("ChatMembers", "chats");
b1.WithOwner()
.HasForeignKey("ChatId");
});
b.Navigation("Members");
});
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
{
b.OwnsMany("Vortex.Modules.Chats.Domain.Media", "Media", b1 =>
{
b1.Property<Guid>("MessageId")
.HasColumnType("uuid");
b1.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b1.Property<string>("Filename")
.HasColumnType("text");
b1.Property<long?>("Size")
.HasColumnType("bigint");
b1.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b1.Property<string>("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
}
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStoryMediaInfoToMessages : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "StoryMediaType",
schema: "chats",
table: "Messages",
type: "text",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "StoryMediaUrl",
schema: "chats",
table: "Messages",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "StoryMediaType",
schema: "chats",
table: "Messages");
migrationBuilder.DropColumn(
name: "StoryMediaUrl",
schema: "chats",
table: "Messages");
}
}
}

View File

@@ -85,6 +85,15 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
b.Property<Guid>("SenderId")
.HasColumnType("uuid");
b.Property<Guid?>("StoryId")
.HasColumnType("uuid");
b.Property<string>("StoryMediaType")
.HasColumnType("text");
b.Property<string>("StoryMediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");

View File

@@ -2,7 +2,7 @@ using Vortex.Shared.Kernel;
namespace Vortex.Modules.Identity.Domain;
public sealed class Story : Entity<Guid>
public class Story : Entity<Guid>
{
public Guid UserId { get; private set; }
public string Type { get; private set; } // text, image, video
@@ -13,9 +13,16 @@ public sealed class Story : Entity<Guid>
public DateTime ExpiresAt { get; private set; }
private readonly List<StoryViewer> _viewers = new();
public IReadOnlyCollection<StoryViewer> Viewers => _viewers.AsReadOnly();
private readonly List<StoryReaction> _reactions = new();
private readonly List<StoryReply> _replies = new();
private Story(Guid id, Guid userId, string type, string? mediaUrl, string? content, string? bgColor) : base(id)
public IReadOnlyCollection<StoryViewer> Viewers => _viewers.AsReadOnly();
public IReadOnlyCollection<StoryReaction> Reactions => _reactions.AsReadOnly();
public IReadOnlyCollection<StoryReply> 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<Guid>
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));
}
}

View File

@@ -0,0 +1,21 @@
using Vortex.Shared.Kernel;
namespace Vortex.Modules.Identity.Domain;
public sealed class StoryReaction : Entity<Guid>
{
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;
}
}

View File

@@ -0,0 +1,21 @@
using Vortex.Shared.Kernel;
namespace Vortex.Modules.Identity.Domain;
public sealed class StoryReply : Entity<Guid>
{
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;
}
}

View File

@@ -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<User> Users => Set<User>();
public DbSet<Story> Stories => Set<Story>();
public DbSet<StoryViewer> StoryViewers => Set<StoryViewer>();
public DbSet<StoryReaction> StoryReactions => Set<StoryReaction>();
public DbSet<StoryReply> StoryReplies => Set<StoryReply>();
public DbSet<Friendship> Friendships => Set<Friendship>();
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<StoryViewer>(builder =>
@@ -61,6 +80,21 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
builder.HasIndex(v => new { v.StoryId, v.UserId }).IsUnique();
});
modelBuilder.Entity<StoryReaction>(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<StoryReply>(builder =>
{
builder.ToTable("StoryReplies");
builder.HasKey(r => r.Id);
builder.Property(r => r.Content).IsRequired().HasMaxLength(500);
});
modelBuilder.Entity<User>(builder =>
{
builder.ToTable("Users");
@@ -82,7 +116,8 @@ public sealed class IdentityDbContext : DbContext, IIdentityUnitOfWork
{
var domainEvents = ChangeTracker
.Entries<IAggregateRoot>()
.SelectMany(x =>
.SelectMany(x =>
{
if (x.Entity is AggregateRoot<Guid> root)
{

View File

@@ -0,0 +1,255 @@
// <auto-generated />
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
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("identity")
.HasAnnotation("ProductVersion", "10.0.4")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("Vortex.Modules.Identity.Domain.Friendship", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("FriendId")
.HasColumnType("uuid");
b.Property<int>("Status")
.HasColumnType("integer");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BgColor")
.HasColumnType("text");
b.Property<string>("Content")
.HasColumnType("text");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("MediaUrl")
.HasColumnType("text");
b.Property<string>("Type")
.IsRequired()
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("Stories", "identity");
});
modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryReaction", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Emoji")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("StoryId");
b.ToTable("StoryReplies", "identity");
});
modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryViewer", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<DateTime>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Avatar")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Bio")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime?>("Birthday")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("DisplayName")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Email")
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<bool>("HideStoryViews")
.ValueGeneratedOnAdd()
.HasColumnType("boolean")
.HasDefaultValue(false);
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("text");
b.Property<string>("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
}
}
}

View File

@@ -0,0 +1,86 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Vortex.Modules.Identity.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddStoryReactionsReplies : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "StoryReactions",
schema: "identity",
columns: table => new
{
Id = table.Column<Guid>(type: "uuid", nullable: false),
StoryId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Emoji = table.Column<string>(type: "character varying(10)", maxLength: 10, nullable: false),
CreatedAt = table.Column<DateTime>(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<Guid>(type: "uuid", nullable: false),
StoryId = table.Column<Guid>(type: "uuid", nullable: false),
UserId = table.Column<Guid>(type: "uuid", nullable: false),
Content = table.Column<string>(type: "character varying(500)", maxLength: 500, nullable: false),
CreatedAt = table.Column<DateTime>(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");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "StoryReactions",
schema: "identity");
migrationBuilder.DropTable(
name: "StoryReplies",
schema: "identity");
}
}
}

View File

@@ -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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Emoji")
.IsRequired()
.HasMaxLength(10)
.HasColumnType("character varying(10)");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Content")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("StoryId")
.HasColumnType("uuid");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("StoryId");
b.ToTable("StoryReplies", "identity");
});
modelBuilder.Entity("Vortex.Modules.Identity.Domain.StoryViewer", b =>
{
b.Property<Guid>("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

View File

@@ -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;

View File

@@ -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<IActionResult> 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<object>();
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<string>();
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<IActionResult> SendMessage(Guid chatId, [FromBody] SendMessageRequest request)
{

View File

@@ -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<Vortex.Modules.Chats.Infrastructure.SignalR.ChatHub> _hubContext;
private readonly IChatRepository _chatRepository;
private readonly ISender _sender;
private readonly IHubContext<Vortex.Modules.Chats.Infrastructure.SignalR.ChatHub> _hubContext;
private readonly IMessageRepository _messageRepository;
private readonly ILogger<StoriesController> _logger;
public StoriesController(
IdentityDbContext context,
IUserContext userContext,
IUserRepository userRepository,
Microsoft.AspNetCore.SignalR.IHubContext<Vortex.Modules.Chats.Infrastructure.SignalR.ChatHub> hubContext)
IChatRepository chatRepository,
IMessageRepository messageRepository,
ISender sender,
IHubContext<Vortex.Modules.Chats.Infrastructure.SignalR.ChatHub> hubContext,
ILogger<StoriesController> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<object>();
@@ -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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<object>();
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<IActionResult> DeleteStory(Guid id, CancellationToken ct)
{
@@ -205,6 +523,22 @@ public sealed class StoriesController : ControllerBase
return Ok(new { message = "Story deleted" });
}
private async Task<Guid> 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<Guid> { 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);

View File

@@ -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")]

View File

@@ -1 +1 @@
2503d90bfacb43b23a8199c8a1cb6d660f9b8abff8c009ccb8b71721eacb6d50
f3d52bfd2b858d5d383380a9e4100efe419d613a4ecf13cf06c4ad546122d06b

View File

@@ -1 +1 @@
{"GlobalPropertiesHash":"2XkPbXFkjVRjCUBTJbQq8CDd7pjsUOS1j3gPHS73xDA=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","6IHvDihzP7nz2jPiGaen/UwCBL1mH\u002BfY\u002BnUJBq/hoXU="],"CachedAssets":{},"CachedCopyCandidates":{}}
{"GlobalPropertiesHash":"2XkPbXFkjVRjCUBTJbQq8CDd7pjsUOS1j3gPHS73xDA=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","GsJcYsaPdTiZszOuaIQXvBoHa2G\u002B8z3ZDvEk9/eI47g="],"CachedAssets":{},"CachedCopyCandidates":{}}

View File

@@ -1 +1 @@
{"GlobalPropertiesHash":"2L4HBRt/ChGO9rohi3wtSDqo95X47fme5m8OHgJIZL0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","6IHvDihzP7nz2jPiGaen/UwCBL1mH\u002BfY\u002BnUJBq/hoXU="],"CachedAssets":{},"CachedCopyCandidates":{}}
{"GlobalPropertiesHash":"2L4HBRt/ChGO9rohi3wtSDqo95X47fme5m8OHgJIZL0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","GsJcYsaPdTiZszOuaIQXvBoHa2G\u002B8z3ZDvEk9/eI47g="],"CachedAssets":{},"CachedCopyCandidates":{}}

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 350 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 268 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 157 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 612 KiB

View File

@@ -76,7 +76,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
const chatViewRef = useRef<HTMLDivElement>(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}
/>
)}

View File

@@ -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 (
<motion.div
initial={{ opacity: 1, height: 'auto' }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, height: 0 }}
className={`flex ${isMine ? 'justify-end' : 'justify-start'} mb-1`}
>
<div className="px-4 py-2 rounded-2xl text-sm italic text-zinc-600 bg-surface-tertiary/50">
{t('messageDeleted')}
</div>
</motion.div>
);
return null;
}
const media = message.media || [];
@@ -419,18 +399,54 @@ function MessageBubble({
{message.replyTo.sender?.displayName || message.replyTo.sender?.username}
</p>
<div className="flex items-center gap-1">
{message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (
{message.replyTo.isDeleted ? (
<p className="text-xs text-zinc-500 italic truncate">{t('messageDeleted')}</p>
) : (
<>
{message.replyTo.media && message.replyTo.media.length > 0 && !message.quote && (
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0">
{message.replyTo.media[0].type === 'image' ? (
<img src={message.replyTo.media[0].url} className="w-full h-full object-cover" alt="" />
) : message.replyTo.media[0].type === 'video' ? (
<div className="w-full h-full flex items-center justify-center bg-black/40"><Play size={10} className="text-white" /></div>
) : (
<div className="w-full h-full flex items-center justify-center"><FileText size={10} className="text-zinc-500" /></div>
)}
</div>
)}
<p className="text-xs text-zinc-400 truncate">{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? t('media') : '')}</p>
</>
)}
</div>
</div>
)}
{/* Story Reply Quote */}
{message.storyId && (
<div
className={`mx-3 mb-1 px-3 py-1.5 rounded-lg border-l-2 ${isMine ? 'border-white/50 bg-white/10' : 'border-accent bg-accent/10'} max-w-full`}
>
<p className={`text-xs font-bold uppercase tracking-wider ${isMine ? 'text-white' : 'text-accent'}`}>
{t('story')}
</p>
<div className="flex items-center gap-2">
{message.storyMediaUrl && (
<div className="w-8 h-8 rounded bg-black/20 overflow-hidden flex-shrink-0">
{message.replyTo.media[0].type === 'image' ? (
<img src={message.replyTo.media[0].url} className="w-full h-full object-cover" alt="" />
) : message.replyTo.media[0].type === 'video' ? (
<div className="w-full h-full flex items-center justify-center bg-black/40"><Play size={10} className="text-white" /></div>
{message.storyMediaType === 'video' ? (
<div className="w-full h-full relative">
<video src={message.storyMediaUrl.startsWith('http') ? message.storyMediaUrl : `${import.meta.env.VITE_API_URL}${message.storyMediaUrl}`} className="w-full h-full object-cover" />
<div className="absolute inset-0 flex items-center justify-center bg-black/20"><Play size={10} className="text-white fill-white" /></div>
</div>
) : message.storyMediaType === 'image' ? (
<img src={message.storyMediaUrl.startsWith('http') ? message.storyMediaUrl : `${import.meta.env.VITE_API_URL}${message.storyMediaUrl}`} className="w-full h-full object-cover" alt="" />
) : (
<div className="w-full h-full flex items-center justify-center"><FileText size={10} className="text-zinc-500" /></div>
<div className="w-full h-full flex items-center justify-center bg-vortex-500/20"><FileText size={10} className="text-vortex-400" /></div>
)}
</div>
)}
<p className="text-xs text-zinc-400 truncate">{message.quote || message.replyTo.content || (message.replyTo.media && message.replyTo.media.length > 0 ? t('media') : '')}</p>
<p className={`text-xs truncate ${isMine ? 'text-white/80' : 'text-zinc-400'}`}>
{message.quote}
</p>
</div>
</div>
)}
@@ -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) && (
<div className={`${message.content ? 'mb-2 -mx-4 -mt-2.5' : ''} bg-black/20 overflow-hidden`}>
<div className={`grid gap-[2px] ${media.filter(m => 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')

View File

@@ -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<StoryGroup[]>([]);
const [storyViewerIndex, setStoryViewerIndex] = useState<number | null>(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 (
<button
key={group.user.id}
onClick={() => setStoryViewerIndex(idx)}
onClick={() => openViewer(idx)}
className="flex flex-col items-center gap-1 flex-shrink-0 group"
>
<div className={`w-14 h-14 rounded-full p-[2.5px] transition-transform group-hover:scale-105 ${
@@ -222,11 +222,12 @@ export default function Sidebar() {
onClose={() => setShowSideMenu(false)}
/>
<AnimatePresence>
{storyViewerIndex !== null && storyGroups.length > 0 && (
{viewerIndex !== null && storyGroups.length > 0 && (
<StoryViewer
stories={storyGroups}
initialUserIndex={storyViewerIndex}
onClose={() => { setStoryViewerIndex(null); loadStories(); }}
initialUserIndex={viewerIndex}
initialStoryIndex={viewerStoryIndex}
onClose={() => { closeViewer(); loadStories(); }}
onRefresh={loadStories}
/>
)}

View File

@@ -1,11 +1,10 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp } from 'lucide-react';
import { X, ChevronLeft, ChevronRight, Eye, Trash2, Plus, ChevronUp, Volume2, VolumeX, MessageCircle, Smile } from 'lucide-react';
import { useAuthStore } from '../stores/authStore';
import { api } from '../lib/api';
import { getSocket } from '../lib/socket';
import { useLang } from '../lib/i18n';
import { getInitials, generateAvatarColor } from '../lib/utils';
import Avatar from './Avatar';
import { StoryGroup } from '../lib/types';
@@ -17,14 +16,17 @@ const STORY_BG_COLORS = [
'#3b82f6', '#1e1e2e',
];
const STORY_EMOJIS = ['❤️', '🔥', '😂', '😮', '😢', '👏', '🎉', '💪'];
interface StoryViewerProps {
stories: StoryGroup[];
initialUserIndex: number;
initialStoryIndex?: number;
onClose: () => void;
onRefresh: () => void;
}
export default function StoryViewer({ stories, initialUserIndex, onClose, onRefresh }: StoryViewerProps) {
export default function StoryViewer({ stories, initialUserIndex, initialStoryIndex, onClose, onRefresh }: StoryViewerProps) {
const { user } = useAuthStore();
const { t } = useLang();
const [userIndex, setUserIndex] = useState(initialUserIndex);
@@ -32,29 +34,76 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
const [progress, setProgress] = useState(0);
const [paused, setPaused] = useState(false);
const timerRef = useRef<ReturnType<typeof setInterval>>(undefined);
const viewedRef = useRef<Set<string>>(new Set()); // track viewed in this session
const viewedRef = useRef<Set<string>>(new Set());
const [viewOverrides, setViewOverrides] = useState<Record<string, { viewCount: number; viewed: boolean }>>({});
const videoRef = useRef<HTMLVideoElement>(null);
const [isMuted, setIsMuted] = useState(true);
const STORY_DURATION = 5000; // 5 seconds per story
const STORY_DURATION = 5000;
const TICK = 50;
const [showViewers, setShowViewers] = useState(false);
const [viewers, setViewers] = useState<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>([]);
const [viewersLoading, setViewersLoading] = useState(false);
const [showReplyInput, setShowReplyInput] = useState(false);
const [replyText, setReplyText] = useState('');
const [sendingReply, setSendingReply] = useState(false);
const [showReactions, setShowReactions] = useState(false);
const [localReactions, setLocalReactions] = useState<Record<string, Array<{ id: string; userId: string; emoji: string; createdAt: string }>>>({});
const currentUser = stories[userIndex];
const rawStory = currentUser?.stories?.[storyIndex];
// Merge prop data with local overrides to avoid mutating props
const currentStory = rawStory ? { ...rawStory, ...viewOverrides[rawStory.id] } : null;
const currentStory = rawStory ? {
...rawStory,
...viewOverrides[rawStory.id],
reactions: localReactions[rawStory.id] || rawStory.reactions || []
} : null;
// Calculate isVideo before using it in effects
const isVideo = currentStory?.type === 'video' || (currentStory?.mediaUrl && (currentStory.mediaUrl.endsWith('.mp4') || currentStory.mediaUrl.endsWith('.mov') || currentStory.mediaUrl.endsWith('.webm')));
// Pause when showing reactions or reply input
useEffect(() => {
if (showReactions || showReplyInput || showViewers) {
setPaused(true);
if (videoRef.current && !videoRef.current.paused) {
videoRef.current.pause();
}
} else {
setPaused(false);
if (videoRef.current && videoRef.current.paused && isVideo) {
videoRef.current.play().catch(() => { });
}
}
}, [showReactions, showReplyInput, showViewers, isVideo]);
// Handle video play/pause sync with paused state
useEffect(() => {
if (!videoRef.current || !isVideo) return;
if (paused) {
videoRef.current.pause();
} else {
videoRef.current.play().catch(() => { });
}
}, [paused, isVideo]);
// Mute by default for viewers, unmute for story owner
useEffect(() => {
if (currentUser?.user.id === user?.id) {
setIsMuted(false);
}
}, [currentUser?.user.id, user?.id]);
// Reset when viewer opens with different user
useEffect(() => {
setUserIndex(initialUserIndex);
setStoryIndex(0);
setStoryIndex(initialStoryIndex || 0);
setProgress(0);
setPaused(false);
viewedRef.current.clear();
setViewOverrides({});
}, [initialUserIndex]);
}, [initialUserIndex, initialStoryIndex]);
const goNext = useCallback(() => {
if (!currentUser) return;
@@ -85,15 +134,18 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
const canGoPrev = storyIndex > 0 || userIndex > 0;
const canGoNext = (currentUser && storyIndex < currentUser.stories.length - 1) || userIndex < stories.length - 1;
// Mark viewed
useEffect(() => {
if (!currentStory || !currentStory.id) return;
if (currentUser.user.id === user?.id) return;
if (currentStory.viewed || viewedRef.current.has(currentStory.id)) return;
// console.log('[StoryViewer] Calling viewStory for:', currentStory.id);
viewedRef.current.add(currentStory.id);
const storyId = currentStory.id;
const viewCount = currentStory.viewCount || 0;
api.viewStory(storyId).then(() => {
// console.log('[StoryViewer] viewStory success, updating count to', viewCount + 1);
setViewOverrides(prev => ({
...prev,
[storyId]: {
@@ -101,14 +153,17 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
viewed: true,
},
}));
}).catch(console.error);
// eslint-disable-next-line react-hooks/exhaustive-deps
}).catch(e => {
console.error('[StoryViewer] viewStory error:', e);
});
}, [currentStory?.id, currentUser?.user?.id, user?.id]);
// Progress timer - use a key to force restart
useEffect(() => {
setProgress(0);
}, [storyIndex, userIndex]);
useEffect(() => {
if (paused || !currentStory) return;
setProgress(0);
const step = (TICK / STORY_DURATION) * 100;
timerRef.current = setInterval(() => {
setProgress(prev => {
@@ -125,7 +180,6 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
};
}, [storyIndex, userIndex, paused, goNext]);
// Keyboard and Socket
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
@@ -134,9 +188,13 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
};
const socket = getSocket();
const handleStoryViewed = (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string; viewCount: number }) => {
const handleStoryViewed = (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string; viewCount: number; ownerId: string }) => {
// console.log('[StoryViewer] story_viewed received:', data);
if (!currentStory || data.storyId !== currentStory.id) return;
// Only process if this user is the owner
if (data.ownerId !== user?.id) return;
setViewOverrides(prev => ({
...prev,
[data.storyId]: {
@@ -145,7 +203,6 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
}
}));
// Update viewers list if visible
if (showViewers) {
setViewers(prev => {
if (prev.some(v => v.userId === data.userId)) return prev;
@@ -160,12 +217,30 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
}
};
const handleStoryReply = (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string; ownerId: string }) => {
// console.log('[StoryViewer] story_reply received:', data);
// Only process if this user is the owner
if (data.ownerId !== user?.id) return;
// Could show notification or update UI
};
const handleStoryReaction = (data: { storyId: string; userId: string; username: string; displayName: string; avatar: string | null; emoji: string; createdAt: string; ownerId: string }) => {
// console.log('[StoryViewer] story_reaction received:', data);
// Only process if this user is the owner
if (data.ownerId !== user?.id) return;
// Could show notification or update UI
};
window.addEventListener('keydown', onKey);
socket?.on('story_viewed', handleStoryViewed);
socket?.on('story_reply', handleStoryReply);
socket?.on('story_reaction', handleStoryReaction);
return () => {
window.removeEventListener('keydown', onKey);
socket?.off('story_viewed', handleStoryViewed);
socket?.off('story_reply', handleStoryReply);
socket?.off('story_reaction', handleStoryReaction);
};
}, [goNext, goPrev, onClose, currentStory?.id, showViewers]);
@@ -174,16 +249,12 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
const storyId = currentStory.id;
try {
await api.deleteStory(storyId);
// Update local state immediately to avoid black screen
if (currentUser.stories.length > 1) {
// Just move to next if there are more stories for this user
if (storyIndex >= currentUser.stories.length - 1) {
setStoryIndex(s => s - 1);
}
// Props will refresh and re-render correctly
} else {
// Last story for this user, move to next user or close
if (userIndex < stories.length - 1) {
setUserIndex(u => u + 1);
setStoryIndex(0);
@@ -191,13 +262,56 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
onClose();
}
}
onRefresh();
} catch (e) {
console.error(e);
}
};
const toggleMute = () => {
setIsMuted(!isMuted);
if (videoRef.current) {
videoRef.current.muted = !isMuted;
}
};
const handleAddReaction = async (emoji: string) => {
if (!currentStory) return;
setShowReactions(false);
setLocalReactions(prev => ({
...prev,
[currentStory.id]: [...(prev[currentStory.id] || []), {
id: `${currentStory.id}-${user?.id}-${emoji}`,
userId: user?.id || '',
emoji,
createdAt: new Date().toISOString()
}]
}));
try {
await api.addStoryReaction(currentStory.id, emoji);
} catch (e) {
console.error('Add reaction error:', e);
}
};
const handleSendReply = async () => {
if (!currentStory || !replyText.trim() || sendingReply) return;
setSendingReply(true);
try {
await api.addStoryReply(currentStory.id, replyText.trim());
setReplyText('');
setShowReplyInput(false);
} catch (e) {
console.error('Send reply error:', e);
} finally {
setSendingReply(false);
}
};
if (!currentUser || !currentStory) {
onClose();
return null;
@@ -224,27 +338,19 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
if (e.target === e.currentTarget) onClose();
}}
>
{/* Story container */}
<div
className="relative w-full max-w-[420px] h-full max-h-[85vh] rounded-2xl overflow-hidden select-none"
onMouseDown={() => setPaused(true)}
onMouseUp={() => setPaused(false)}
onMouseLeave={() => setPaused(false)}
onTouchStart={() => setPaused(true)}
onTouchEnd={() => setPaused(false)}
>
{/* Story content */}
{currentStory.type === 'video' || (currentStory.mediaUrl && (currentStory.mediaUrl.endsWith('.mp4') || currentStory.mediaUrl.endsWith('.mov') || currentStory.mediaUrl.endsWith('.webm'))) ? (
{isVideo ? (
<div className="w-full h-full bg-black flex items-center justify-center">
<video
ref={videoRef}
src={currentStory.mediaUrl?.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
className="w-full h-full object-contain"
autoPlay
muted
muted={isMuted}
playsInline
onEnded={goNext}
onPlay={() => setPaused(false)}
onPause={() => setPaused(true)}
/>
</div>
) : currentStory.type === 'image' && currentStory.mediaUrl ? (
@@ -268,7 +374,6 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
</div>
)}
{/* Progress bars */}
<div className="absolute top-0 left-0 right-0 flex gap-1 p-2 z-10">
{currentUser.stories.map((_, i) => (
<div key={i} className="flex-1 h-[3px] bg-white/30 rounded-full overflow-hidden">
@@ -282,7 +387,6 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
))}
</div>
{/* Header */}
<div className="absolute top-4 left-0 right-0 flex items-center gap-3 px-4 pt-2 z-10">
<Avatar
src={avatarUrl}
@@ -331,14 +435,12 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
</div>
</div>
{/* Left/Right click zones */}
<div className="absolute inset-0 flex z-[5]">
<div className="w-1/3 h-full cursor-pointer" onClick={goPrev} />
<div className="w-1/3 h-full" />
<div className="w-1/3 h-full cursor-pointer" onClick={goNext} />
</div>
{/* Navigation arrows */}
{canGoPrev && (
<button
onClick={(e) => { e.stopPropagation(); goPrev(); }}
@@ -356,7 +458,91 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
</button>
)}
{/* Viewers panel */}
{/* Bottom actions */}
<div className="absolute bottom-4 left-0 right-0 flex items-center justify-center gap-4 z-10 px-4">
{/* Sound toggle for video - show for everyone */}
{isVideo && (
<button
onClick={(e) => { e.stopPropagation(); toggleMute(); }}
className="w-10 h-10 rounded-full bg-black/50 backdrop-blur-sm flex items-center justify-center text-white/70 hover:text-white transition-colors"
>
{isMuted ? <VolumeX size={20} /> : <Volume2 size={20} />}
</button>
)}
{/* Reply and reactions - only for non-owners */}
{currentUser.user.id !== user?.id && (
<>
<button
onClick={(e) => { e.stopPropagation(); setShowReplyInput(!showReplyInput); setShowReactions(false); }}
className={`w-10 h-10 rounded-full backdrop-blur-sm flex items-center justify-center transition-colors ${showReplyInput ? 'bg-accent text-white' : 'bg-black/50 text-white/70 hover:text-white'}`}
>
<MessageCircle size={20} />
</button>
<button
onClick={(e) => { e.stopPropagation(); setShowReactions(!showReactions); setShowReplyInput(false); }}
className={`w-10 h-10 rounded-full backdrop-blur-sm flex items-center justify-center transition-colors ${showReactions ? 'bg-accent text-white' : 'bg-black/50 text-white/70 hover:text-white'}`}
>
<Smile size={20} />
</button>
</>
)}
</div>
<AnimatePresence>
{showReactions && currentUser.user.id !== user?.id && (
<motion.div
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 50, opacity: 0 }}
className="absolute bottom-20 left-0 right-0 z-20 flex justify-center gap-2 px-4"
onClick={(e) => e.stopPropagation()}
>
{STORY_EMOJIS.map(emoji => (
<button
key={emoji}
onClick={(e) => { e.stopPropagation(); handleAddReaction(emoji); }}
className="w-12 h-12 rounded-full bg-black/70 backdrop-blur-sm flex items-center justify-center text-2xl hover:scale-125 transition-transform"
>
{emoji}
</button>
))}
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showReplyInput && currentUser.user.id !== user?.id && (
<motion.div
initial={{ y: 50, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 50, opacity: 0 }}
className="absolute bottom-20 left-0 right-0 z-20 px-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex gap-2">
<input
type="text"
value={replyText}
onChange={(e) => 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
/>
<button
onClick={handleSendReply}
disabled={!replyText.trim() || sendingReply}
className="px-4 py-2 rounded-full bg-accent text-white text-sm font-medium disabled:opacity-50"
>
{t('send') || 'Send'}
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{showViewers && currentUser.user.id === user?.id && (
<motion.div
@@ -411,7 +597,6 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
);
}
// Story creation modal
interface CreateStoryModalProps {
onClose: () => 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)
</button>
</div>
{/* Mode tabs */}
<div className="flex border-b border-white/10">
<button
onClick={() => setMode('text')}
@@ -518,7 +702,6 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
<div className="p-4">
{mode === 'text' ? (
<>
{/* Preview */}
<div
className="w-full h-48 rounded-xl flex items-center justify-center p-4 mb-4 transition-colors"
style={{ background: bgColor }}
@@ -536,7 +719,6 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
className="w-full bg-white/5 border border-white/10 rounded-xl px-3 py-2 text-sm text-zinc-200 resize-none h-20 mb-3 focus:outline-none focus:border-vortex-500/50"
/>
{/* Color picker */}
<div className="flex flex-wrap gap-2 mb-3">
{STORY_BG_COLORS.map(c => (
<button

View File

@@ -4,20 +4,22 @@ import { X, Calendar, AtSign, Edit3, Check, Loader2, Image as ImageIcon, FileTex
import { api } from '../lib/api';
import { useAuthStore } from '../stores/authStore';
import { useLang } from '../lib/i18n';
import { User, Message, FriendshipStatus } from '../lib/types';
import { User, Message, FriendshipStatus, StoryGroup } from '../lib/types';
import ImageLightbox from './ImageLightbox';
import { getSocket } from '../lib/socket';
import { useStoryStore } from '../stores/useStoryStore';
interface UserProfileProps {
userId: string;
chatId?: string;
onClose: () => void;
onGoToMessage?: (messageId: string) => void;
isSelf?: boolean;
}
type MediaTab = 'media' | 'files' | 'links';
type MediaTab = 'stories' | 'media' | 'files' | 'links';
export default function UserProfile({ userId, chatId, onClose, isSelf }: UserProfileProps) {
export default function UserProfile({ userId, chatId, onClose, onGoToMessage, isSelf }: UserProfileProps) {
const { user: authUser } = useAuthStore();
const { t, lang } = useLang();
const [profile, setProfile] = useState<User | null>(null);
@@ -29,8 +31,10 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
const [sharedFiles, setSharedFiles] = useState<Message[]>([]);
const [sharedLinks, setSharedLinks] = useState<Array<Message & { links?: string[] }>>([]);
const [tabLoading, setTabLoading] = useState(false);
const [userStories, setUserStories] = useState<StoryGroup | null>(null);
const [loadedTabs, setLoadedTabs] = useState<Set<MediaTab>>(new Set());
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const { openViewer } = useStoryStore();
// Friend state
const [friendStatus, setFriendStatus] = useState<FriendshipStatus | null>(null);
@@ -43,6 +47,12 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
const [cropPosition, setCropPosition] = useState({ x: 0, y: 0, scale: 1 });
const [isCropping, setIsCropping] = useState(false);
const allMedia = sharedMedia.flatMap(msg => (msg.media || []).map(m => ({
...m,
url: m.url.startsWith('http') ? m.url : `${import.meta.env.VITE_API_URL}${m.url}`,
messageId: msg.id
})));
useEffect(() => {
loadProfile();
if (!isSelf) {
@@ -52,6 +62,21 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
// Load shared media/files/links when tab changes
const loadTabData = useCallback(async (tab: MediaTab) => {
if (tab === 'stories') {
if (loadedTabs.has(tab)) return;
setTabLoading(true);
try {
const data = await api.getUserStories(userId);
setUserStories(data);
setLoadedTabs(prev => new Set(prev).add(tab));
} catch (e) {
console.error('Failed to load user stories', e);
} finally {
setTabLoading(false);
}
return;
}
if (!chatId || loadedTabs.has(tab)) return;
setTabLoading(true);
try {
@@ -65,7 +90,7 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
} finally {
setTabLoading(false);
}
}, [chatId, loadedTabs]);
}, [chatId, userId, loadedTabs]);
useEffect(() => {
loadTabData(activeTab);
@@ -181,9 +206,10 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
.toUpperCase();
const tabs: { key: MediaTab; label: string; icon: React.ElementType }[] = [
{ key: 'media', label: t('mediaTab'), icon: ImageIcon },
{ key: 'files', label: t('filesTab'), icon: FileText },
{ key: 'links', label: t('linksTab'), icon: LinkIcon },
{ key: 'stories', label: (t('storiesTab') || 'Stories') as string, icon: ImageIcon },
{ key: 'media', label: t('mediaTab') as string, icon: ImageIcon },
{ key: 'files', label: t('filesTab') as string, icon: FileText },
{ key: 'links', label: t('linksTab') as string, icon: LinkIcon },
];
return (
@@ -200,13 +226,13 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
animate={{ opacity: 1, x: 0, filter: 'blur(0px)' }}
exit={{ opacity: 0, x: 50, filter: 'blur(20px)' }}
transition={{ type: 'spring', damping: 25, stiffness: 300, mass: 0.8 }}
className="fixed right-3 top-3 bottom-3 w-[360px] max-w-[calc(100%-24px)] bg-surface-secondary/80 backdrop-blur-2xl shadow-[0_0_120px_rgba(0,0,0,0.6)] border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden"
className="fixed right-3 top-3 bottom-3 w-[500px] max-w-[calc(100%-24px)] bg-surface-secondary/80 backdrop-blur-2xl shadow-[0_0_120px_rgba(0,0,0,0.6)] border border-white/5 rounded-[2rem] z-50 flex flex-col overflow-hidden"
>
{/* Шапка */}
<div className="flex items-center justify-between p-5 border-b border-white/5 bg-white/5 relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-vortex-500/20 to-purple-500/10 pointer-events-none" />
<h2 className="text-xl font-bold tracking-tight text-white drop-shadow-sm relative z-10">
{isSelf ? t('myProfile') : t('profileTitle')}
{(isSelf ? t('myProfile') : t('profileTitle')) as string}
</h2>
<button
onClick={onClose}
@@ -432,42 +458,94 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
<div className="flex items-center justify-center py-8">
<Loader2 size={20} className="animate-spin text-zinc-500" />
</div>
) : activeTab === 'media' ? (
sharedMedia.length > 0 ? (
) : activeTab === 'stories' ? (
userStories && userStories.stories.length > 0 ? (
<div className="grid grid-cols-3 gap-0.5 p-1">
{(() => {
const allMedia = sharedMedia.flatMap((msg) => (msg.media || []));
return allMedia.map((m, idx) => (
<div
key={m.id}
onClick={() => setLightboxIndex(idx)}
className="relative aspect-square bg-zinc-900 overflow-hidden group cursor-pointer"
>
{m.type === 'video' ? (
<>
<img
src={m.thumbnail || m.url}
alt=""
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30">
<Play size={24} className="text-white fill-white" />
</div>
</>
) : (
<img
src={m.url}
alt=""
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
)}
{userStories.stories.map((story, idx) => (
<div
key={story.id}
onClick={() => {
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' ? (
<div className="w-full h-full relative">
{story.mediaUrl && <video src={story.mediaUrl.startsWith('http') ? story.mediaUrl : `${import.meta.env.VITE_API_URL}${story.mediaUrl}`} className="w-full h-full object-cover opacity-60" />}
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<Play size={20} className="text-white fill-white" />
</div>
</div>
) : story.type === 'image' ? (
<img
src={story.mediaUrl?.startsWith('http') ? story.mediaUrl : `${import.meta.env.VITE_API_URL}${story.mediaUrl}`}
alt=""
className="w-full h-full object-cover opacity-80"
/>
) : (
<div className="w-full h-full flex items-center justify-center p-2 text-center overflow-hidden" style={{ background: story.bgColor || 'var(--vortex-500)' }}>
<p className="text-[10px] text-white line-clamp-4 font-bold">{story.content}</p>
</div>
)}
<div className="absolute top-1 right-1 bg-black/50 backdrop-blur-sm px-1 rounded flex items-center gap-0.5">
<Clock size={8} className="text-zinc-300" />
<span className="text-[8px] text-zinc-300">{new Date(story.createdAt).toLocaleDateString()}</span>
</div>
));
})()}
</div>
))}
</div>
) : (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-zinc-600 italic">{t('sharedPhotos')}</p>
<p className="text-xs text-zinc-600 italic">{(t('noStories') || 'No stories yet') as string}</p>
</div>
)
) : activeTab === 'media' ? (
allMedia.length > 0 ? (
<div className="grid grid-cols-3 gap-0.5 p-1">
{allMedia.map((m, idx) => (
<div
key={m.id}
className="relative aspect-square bg-zinc-900 overflow-hidden group cursor-pointer"
>
{m.type === 'video' ? (
<>
<img
src={m.thumbnail ? (m.thumbnail.startsWith('http') ? m.thumbnail : `${import.meta.env.VITE_API_URL}${m.thumbnail}`) : m.url}
alt=""
onClick={() => setLightboxIndex(idx)}
className="w-full h-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/30 pointer-events-none">
<Play size={24} className="text-white fill-white" />
</div>
</>
) : (
<img
src={m.url}
alt=""
onClick={() => setLightboxIndex(idx)}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-200"
/>
)}
{/* Navigation button */}
<button
onClick={(e) => { e.stopPropagation(); onGoToMessage?.(m.messageId); }}
className="absolute bottom-1 right-1 w-6 h-6 rounded-md bg-black/60 backdrop-blur-sm flex items-center justify-center text-white opacity-0 group-hover:opacity-100 transition-opacity"
title={(t('goToMessage') || 'Go to message') as string}
>
<ExternalLink size={12} />
</button>
</div>
))}
</div>
) : (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-zinc-600 italic">{t('sharedPhotos') as string}</p>
</div>
)
) : activeTab === 'files' ? (
@@ -475,38 +553,46 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
<div className="divide-y divide-border">
{sharedFiles.flatMap((msg) =>
(msg.media || []).map((m) => (
<a
key={m.id}
href={m.url}
download={m.filename || 'file'}
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors group/file"
>
<div className="w-10 h-10 rounded-xl bg-vortex-500/20 flex items-center justify-center flex-shrink-0 border border-vortex-500/30 group-hover/file:scale-105 transition-transform">
<FileText size={18} className="text-vortex-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{m.filename || 'file'}</p>
<p className="text-xs text-zinc-500">
{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}
{msg.sender ? ` · ${msg.sender.displayName || msg.sender.username}` : ''}
</p>
</div>
<Download size={16} className="text-zinc-500 flex-shrink-0" />
</a>
<div key={m.id} className="relative group/file">
<a
href={m.url}
download={m.filename || 'file'}
className="flex items-center gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
>
<div className="w-10 h-10 rounded-xl bg-vortex-500/20 flex items-center justify-center flex-shrink-0 border border-vortex-500/30 group-hover/file:scale-105 transition-transform">
<FileText size={18} className="text-vortex-400" />
</div>
<div className="flex-1 min-w-0">
<p className="text-sm text-white truncate">{m.filename || 'file'}</p>
<p className="text-xs text-zinc-500">
{m.size ? `${(m.size / 1024).toFixed(1)} KB` : ''}
{msg.sender ? ` · ${msg.sender.displayName || msg.sender.username}` : ''}
</p>
</div>
<Download size={16} className="text-zinc-500 flex-shrink-0" />
</a>
<button
onClick={() => onGoToMessage?.(msg.id)}
className="absolute right-12 top-1/2 -translate-y-1/2 w-8 h-8 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-500 opacity-0 group-hover/file:opacity-100 transition-opacity"
title={(t('goToMessage') || 'Go to message') as string}
>
<ExternalLink size={14} />
</button>
</div>
))
)}
</div>
) : (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-zinc-600 italic">{t('sharedFiles')}</p>
<p className="text-xs text-zinc-600 italic">{t('sharedFiles') as string}</p>
</div>
)
) : (
sharedLinks.length > 0 ? (
<div className="divide-y divide-border">
{sharedLinks.map((msg) => (
<div key={msg.id} className="px-4 py-3 hover:bg-white/5 transition-colors">
<p className="text-xs text-zinc-500 mb-1.5 font-medium">
<div key={msg.id} className="px-4 py-3 hover:bg-white/5 transition-colors relative group/link">
<p className="text-xs text-zinc-500 mb-1.5 font-medium pr-8">
{msg.sender?.displayName || msg.sender?.username} · {new Date(msg.createdAt).toLocaleDateString(lang === 'ru' ? 'ru-RU' : 'en-US')}
</p>
{(msg.links || []).map((link: string, i: number) => (
@@ -524,12 +610,19 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
{msg.content && (
<p className="text-xs text-zinc-400 mt-1 line-clamp-2">{msg.content}</p>
)}
<button
onClick={() => onGoToMessage?.(msg.id)}
className="absolute right-2 top-2 w-8 h-8 rounded-lg hover:bg-white/10 flex items-center justify-center text-zinc-500 opacity-0 group-hover/link:opacity-100 transition-opacity"
title={(t('goToMessage') || 'Go to message') as string}
>
<ExternalLink size={14} />
</button>
</div>
))}
</div>
) : (
<div className="flex items-center justify-center py-8">
<p className="text-xs text-zinc-600 italic">{t('sharedLinks')}</p>
<p className="text-xs text-zinc-600 italic">{t('sharedLinks') as string}</p>
</div>
)
)}
@@ -547,7 +640,7 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
<AnimatePresence>
{lightboxIndex !== null && (
<ImageLightbox
images={sharedMedia.flatMap((msg) => (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)}
/>

View File

@@ -242,6 +242,10 @@ class ApiClient {
return this.request<StoryGroup[]>('/stories');
}
async getUserStories(userId: string) {
return this.request<StoryGroup>(`/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<Array<{ userId: string; username: string; displayName: string; avatar: string | null; viewedAt: string }>>(`/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<Array<{ id: string; userId: string; username: string; displayName: string; avatar: string | null; content: string; createdAt: string }>>(`/stories/${storyId}/replies`);
}
// Favorites chat
async getOrCreateFavorites() {
return this.request<Chat>('/chats/favorites', { method: 'POST' });

View File

@@ -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',

View File

@@ -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: () => {

View File

@@ -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 {

View File

@@ -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]);

View File

@@ -152,7 +152,7 @@ export const useChatStore = create<ChatState>((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;

View File

@@ -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<StoryState>((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 }),
}));

1
fix-migration.sql Normal file
View File

@@ -0,0 +1 @@
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ('20260313115319_AddStoryReactionsReplies', '10.0.4') ON CONFLICT DO NOTHING;