Ответы и реакции истории, публикации в профиле. Фиксы
This commit is contained in:
@@ -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())
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
21
apps/server-net/src/Modules/Identity/Domain/StoryReaction.cs
Normal file
21
apps/server-net/src/Modules/Identity/Domain/StoryReaction.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
21
apps/server-net/src/Modules/Identity/Domain/StoryReply.cs
Normal file
21
apps/server-net/src/Modules/Identity/Domain/StoryReply.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
229
apps/server-net/src/Modules/Identity/migrations.sql
Normal file
229
apps/server-net/src/Modules/Identity/migrations.sql
Normal 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;
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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);
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
2503d90bfacb43b23a8199c8a1cb6d660f9b8abff8c009ccb8b71721eacb6d50
|
||||
f3d52bfd2b858d5d383380a9e4100efe419d613a4ecf13cf06c4ad546122d06b
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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":{}}
|
||||
@@ -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":{}}
|
||||
BIN
apps/server-net/uploads/00b60c16-44cf-4874-b8c3-ff4c2c12cdeb.jpg
Normal file
BIN
apps/server-net/uploads/00b60c16-44cf-4874-b8c3-ff4c2c12cdeb.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 108 KiB |
BIN
apps/server-net/uploads/02e66d3a-e503-434e-b712-f62985d49a9a.png
Normal file
BIN
apps/server-net/uploads/02e66d3a-e503-434e-b712-f62985d49a9a.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 350 KiB |
BIN
apps/server-net/uploads/16f8242f-1f76-4c5a-b518-4595b37f6b43.png
Normal file
BIN
apps/server-net/uploads/16f8242f-1f76-4c5a-b518-4595b37f6b43.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 268 KiB |
BIN
apps/server-net/uploads/4f416ad7-b5ee-4328-bbd4-14d8279c6174.mp4
Normal file
BIN
apps/server-net/uploads/4f416ad7-b5ee-4328-bbd4-14d8279c6174.mp4
Normal file
Binary file not shown.
BIN
apps/server-net/uploads/5841ec62-44a6-402c-a84d-4f8df68ccb15.mp4
Normal file
BIN
apps/server-net/uploads/5841ec62-44a6-402c-a84d-4f8df68ccb15.mp4
Normal file
Binary file not shown.
BIN
apps/server-net/uploads/6d0e82ac-268e-4e5c-9397-3c7eb98437c8.jpg
Normal file
BIN
apps/server-net/uploads/6d0e82ac-268e-4e5c-9397-3c7eb98437c8.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 157 KiB |
BIN
apps/server-net/uploads/9ea3d58b-d519-47f0-a240-2eeb73e99ffd.png
Normal file
BIN
apps/server-net/uploads/9ea3d58b-d519-47f0-a240-2eeb73e99ffd.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 612 KiB |
BIN
apps/server-net/uploads/b07615ef-3ddb-47fb-948f-0a804414aed0.mp4
Normal file
BIN
apps/server-net/uploads/b07615ef-3ddb-47fb-948f-0a804414aed0.mp4
Normal file
Binary file not shown.
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
24
apps/web/src/stores/useStoryStore.ts
Normal file
24
apps/web/src/stores/useStoryStore.ts
Normal 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
1
fix-migration.sql
Normal file
@@ -0,0 +1 @@
|
||||
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion") VALUES ('20260313115319_AddStoryReactionsReplies', '10.0.4') ON CONFLICT DO NOTHING;
|
||||
Reference in New Issue
Block a user