Мелкие правки
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Vortex.Modules.Chats.Domain;
|
||||
using Vortex.Modules.Chats.Infrastructure.SignalR;
|
||||
using Vortex.Shared.Kernel;
|
||||
using global::Vortex.Modules.Chats.Domain;
|
||||
using global::Vortex.Modules.Chats.Infrastructure.SignalR;
|
||||
using global::Vortex.Shared.Kernel;
|
||||
using global::Vortex.Modules.Chats.Application.Abstractions;
|
||||
|
||||
namespace Vortex.Modules.Chats.Application.Messages.Delete;
|
||||
|
||||
@@ -10,17 +11,17 @@ public sealed record DeleteMessagesCommand(
|
||||
Guid ChatId,
|
||||
Guid UserId,
|
||||
List<Guid> MessageIds,
|
||||
bool DeleteForAll) : global::Vortex.Shared.Kernel.ICommand;
|
||||
bool DeleteForAll) : ICommand;
|
||||
|
||||
public sealed class DeleteMessagesCommandHandler : global::Vortex.Shared.Kernel.ICommandHandler<DeleteMessagesCommand>
|
||||
public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessagesCommand>
|
||||
{
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
private readonly Vortex.Modules.Chats.Application.Abstractions.IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IHubContext<ChatHub> _hubContext;
|
||||
|
||||
public DeleteMessagesCommandHandler(
|
||||
IMessageRepository messageRepository,
|
||||
Vortex.Modules.Chats.Application.Abstractions.IChatsUnitOfWork unitOfWork,
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
IHubContext<ChatHub> hubContext)
|
||||
{
|
||||
_messageRepository = messageRepository;
|
||||
|
||||
@@ -103,6 +103,7 @@ public sealed class ChatMember : Entity<Guid>
|
||||
public Guid UserId { get; private set; }
|
||||
public string Role { get; private set; }
|
||||
public DateTime JoinedAt { get; private set; }
|
||||
public bool IsPinned { get; private set; }
|
||||
public bool IsMuted { get; private set; }
|
||||
|
||||
// For EF Core
|
||||
@@ -115,4 +116,6 @@ public sealed class ChatMember : Entity<Guid>
|
||||
Role = role;
|
||||
JoinedAt = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public void TogglePin() => IsPinned = !IsPinned;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Vortex.Shared.Kernel;
|
||||
using Vortex.Modules.Chats.Domain;
|
||||
using Vortex.Modules.Chats.Infrastructure.SignalR;
|
||||
using Vortex.Shared.Kernel;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
|
||||
namespace Vortex.Modules.Chats.Infrastructure.Handlers;
|
||||
|
||||
|
||||
@@ -61,12 +61,12 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
|
||||
mb.WithOwner().HasForeignKey(x => x.MessageId);
|
||||
}).Navigation(m => m.Media).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
builder.OwnsMany(m => m.Reactions, mb =>
|
||||
{
|
||||
mb.ToTable("MessageReactions");
|
||||
mb.HasKey(x => x.Id);
|
||||
mb.HasIndex(x => new { x.MessageId, x.UserId, x.Emoji }).IsUnique();
|
||||
}).Navigation(m => m.Reactions).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||
builder.HasMany(m => m.Reactions)
|
||||
.WithOne()
|
||||
.HasForeignKey(x => x.MessageId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
builder.Navigation(m => m.Reactions).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||
|
||||
builder.PrimitiveCollection(m => m.DeletedByUsers)
|
||||
.HasColumnName("DeletedByUsers")
|
||||
@@ -80,6 +80,13 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
|
||||
builder.Navigation(m => m.ReadBy).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Reaction>(builder =>
|
||||
{
|
||||
builder.ToTable("MessageReactions");
|
||||
builder.HasKey(r => r.Id);
|
||||
builder.HasIndex(r => new { r.MessageId, r.UserId, r.Emoji }).IsUnique();
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ReadReceipt>(builder =>
|
||||
{
|
||||
builder.ToTable("ReadReceipts");
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
// <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("20260312204842_SupportPinningAndMuting")]
|
||||
partial class SupportPinningAndMuting
|
||||
{
|
||||
/// <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<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,64 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class SupportPinningAndMuting : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropPrimaryKey(
|
||||
name: "PK_MessageMedia",
|
||||
schema: "chats",
|
||||
table: "MessageMedia");
|
||||
|
||||
migrationBuilder.DropIndex(
|
||||
name: "IX_MessageMedia_MessageId",
|
||||
schema: "chats",
|
||||
table: "MessageMedia");
|
||||
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsPinned",
|
||||
schema: "chats",
|
||||
table: "ChatMembers",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddPrimaryKey(
|
||||
name: "PK_MessageMedia",
|
||||
schema: "chats",
|
||||
table: "MessageMedia",
|
||||
columns: new[] { "MessageId", "Id" });
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropPrimaryKey(
|
||||
name: "PK_MessageMedia",
|
||||
schema: "chats",
|
||||
table: "MessageMedia");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsPinned",
|
||||
schema: "chats",
|
||||
table: "ChatMembers");
|
||||
|
||||
migrationBuilder.AddPrimaryKey(
|
||||
name: "PK_MessageMedia",
|
||||
schema: "chats",
|
||||
table: "MessageMedia",
|
||||
column: "Id");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_MessageMedia_MessageId",
|
||||
schema: "chats",
|
||||
table: "MessageMedia",
|
||||
column: "MessageId");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,30 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
||||
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")
|
||||
@@ -131,6 +155,9 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
||||
b1.Property<bool>("IsMuted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<bool>("IsPinned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<DateTime>("JoinedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -159,6 +186,9 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
b.OwnsMany("Vortex.Modules.Chats.Domain.Media", "Media", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
@@ -166,9 +196,6 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
||||
b1.Property<string>("Filename")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<long?>("Size")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
@@ -180,9 +207,7 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.HasKey("Id");
|
||||
|
||||
b1.HasIndex("MessageId");
|
||||
b1.HasKey("MessageId", "Id");
|
||||
|
||||
b1.ToTable("MessageMedia", "chats");
|
||||
|
||||
@@ -190,36 +215,16 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
||||
.HasForeignKey("MessageId");
|
||||
});
|
||||
|
||||
b.OwnsMany("Vortex.Modules.Chats.Domain.Reaction", "Reactions", b1 =>
|
||||
{
|
||||
b1.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("Emoji")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<Guid>("MessageId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.HasKey("Id");
|
||||
|
||||
b1.HasIndex("MessageId", "UserId", "Emoji")
|
||||
.IsUnique();
|
||||
|
||||
b1.ToTable("MessageReactions", "chats");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("MessageId");
|
||||
});
|
||||
|
||||
b.Navigation("Media");
|
||||
});
|
||||
|
||||
b.Navigation("Reactions");
|
||||
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 =>
|
||||
@@ -233,6 +238,8 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
||||
|
||||
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
|
||||
{
|
||||
b.Navigation("Reactions");
|
||||
|
||||
b.Navigation("ReadBy");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
|
||||
@@ -186,6 +186,28 @@ public sealed class ChatHub : Hub
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Friend signals (Proxy methods for real-time notification)
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
[HubMethodName("friend_request")]
|
||||
public async Task FriendRequest(FriendSignalRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.FriendId, "friend_request_received", new { userId = _userContext.UserId });
|
||||
}
|
||||
|
||||
[HubMethodName("friend_accepted")]
|
||||
public async Task FriendAccepted(FriendSignalRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
|
||||
}
|
||||
|
||||
[HubMethodName("friend_removed")]
|
||||
public async Task FriendRemoved(FriendSignalRequest request)
|
||||
{
|
||||
await SendToUserAsync(request.FriendId, "friend_removed_notify", new { userId = _userContext.UserId });
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// WebRTC signaling
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
@@ -470,4 +492,5 @@ public sealed class ChatHub : Hub
|
||||
public record GroupIceCandidateRequest(string ChatId, string TargetUserId, object Candidate);
|
||||
public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer);
|
||||
public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
||||
public record FriendSignalRequest(string FriendId);
|
||||
}
|
||||
|
||||
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.Modules.Chats")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+1ec6b438d29413482e1393c8ee5717f1211bd2c1")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9daafae9c5956290dda1c718bb49ff72c77fafb8")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Vortex.Modules.Chats")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Modules.Chats")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
f9ffb401f31277f8b221e98682e16484fff43d34c6ed6bf8ad0e087ed2774d78
|
||||
a2ba88a9d7ac278056acbcb427c11fabed9db58aac98411359499b8b1b34811c
|
||||
|
||||
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.Modules.Identity")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+1ec6b438d29413482e1393c8ee5717f1211bd2c1")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9daafae9c5956290dda1c718bb49ff72c77fafb8")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Vortex.Modules.Identity")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Modules.Identity")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
9d9ffd258fd1015aa25ef1b7c83a7e79e77e5f90a31d4742aa8a559cc7698555
|
||||
2a9c951494821db92633ba9e6d674467f449af4a90ac9fed0ea49175b5b7917b
|
||||
|
||||
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.Shared.Infrastructure")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+1ec6b438d29413482e1393c8ee5717f1211bd2c1")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9daafae9c5956290dda1c718bb49ff72c77fafb8")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Vortex.Shared.Infrastructure")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Shared.Infrastructure")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
e0bbb37b072e496f3944a8671d38c2aa23b6d62d5e51acb6547971f52a1ef575
|
||||
745538ea2e4bf4b47bee0e7c23a71f34c85ffd4593e17f36ed6617330cf934ed
|
||||
|
||||
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.Shared.Kernel")]
|
||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+1ec6b438d29413482e1393c8ee5717f1211bd2c1")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9daafae9c5956290dda1c718bb49ff72c77fafb8")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Vortex.Shared.Kernel")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Shared.Kernel")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
b12266e8303643e0701f21b3366bc23363791cc5e63972b801eea2f6405398dc
|
||||
375a1236f22989deedeec81190d9f49c1e30becc8b5382dbeed7e3232f8c1b05
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -56,6 +56,7 @@ public sealed class ChatsController : ControllerBase
|
||||
id = m.Id,
|
||||
userId = m.UserId,
|
||||
role = m.Role,
|
||||
isPinned = m.IsPinned,
|
||||
user = user != null ? new {
|
||||
id = user.Id,
|
||||
username = user.Username,
|
||||
@@ -226,12 +227,19 @@ public sealed class ChatsController : ControllerBase
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/pin")]
|
||||
public async Task<IActionResult> TogglePin(Guid id, [FromServices] IChatRepository chatRepository, CancellationToken ct)
|
||||
public async Task<IActionResult> TogglePin(Guid id, [FromServices] IChatRepository chatRepository, [FromServices] IChatsUnitOfWork uow, CancellationToken ct)
|
||||
{
|
||||
var chat = await chatRepository.GetByIdAsync(id, ct);
|
||||
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
|
||||
// Pin logic not stored in domain yet — return stub
|
||||
return Ok(new { isPinned = false });
|
||||
if (chat == null) return NotFound();
|
||||
|
||||
var member = chat.Members.FirstOrDefault(m => m.UserId == _userContext.UserId);
|
||||
if (member == null) return NotFound();
|
||||
|
||||
member.TogglePin();
|
||||
chatRepository.Update(chat);
|
||||
await uow.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new { isPinned = member.IsPinned });
|
||||
}
|
||||
|
||||
[HttpPost("{id:guid}/members")]
|
||||
@@ -314,6 +322,7 @@ public sealed class ChatsController : ControllerBase
|
||||
id = m.Id,
|
||||
userId = m.UserId,
|
||||
role = m.Role,
|
||||
isPinned = m.IsPinned,
|
||||
user = user != null ? new {
|
||||
id = user.Id,
|
||||
username = user.Username,
|
||||
|
||||
@@ -118,33 +118,46 @@ public sealed class StoriesController : ControllerBase
|
||||
[HttpPost("{id}/view")]
|
||||
public async Task<IActionResult> ViewStory(Guid id, CancellationToken ct)
|
||||
{
|
||||
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.Viewers.Any(v => v.UserId == _userContext.UserId))
|
||||
try
|
||||
{
|
||||
story.AddViewer(_userContext.UserId);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
var story = await _context.Stories
|
||||
.Include(s => s.Viewers)
|
||||
.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||
|
||||
// Notify owner via SignalR
|
||||
var viewer = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
|
||||
await _hubContext.Clients.User(story.UserId.ToString()).SendAsync("story_viewed", new
|
||||
if (story == null) return NotFound();
|
||||
if (story.UserId == _userContext.UserId) return Ok(new { message = "Owner view" });
|
||||
|
||||
if (!story.Viewers.Any(v => v.UserId == _userContext.UserId))
|
||||
{
|
||||
storyId = story.Id,
|
||||
userId = _userContext.UserId,
|
||||
username = viewer?.Username,
|
||||
displayName = viewer?.DisplayName,
|
||||
avatar = viewer?.Avatar,
|
||||
viewedAt = DateTime.UtcNow,
|
||||
viewCount = story.Viewers.Count
|
||||
}, ct);
|
||||
}
|
||||
story.AddViewer(_userContext.UserId);
|
||||
await _context.SaveChangesAsync(ct);
|
||||
|
||||
return Ok(new { message = "Story viewed" });
|
||||
// Notify owner via SignalR targetedly
|
||||
var viewer = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
|
||||
await _hubContext.Clients.User(story.UserId.ToString()).SendAsync("story_viewed", new
|
||||
{
|
||||
storyId = story.Id,
|
||||
userId = _userContext.UserId,
|
||||
username = viewer?.Username,
|
||||
displayName = viewer?.DisplayName,
|
||||
avatar = viewer?.Avatar,
|
||||
viewedAt = DateTime.UtcNow,
|
||||
viewCount = story.Viewers.Count,
|
||||
ownerId = story.UserId
|
||||
}, ct);
|
||||
}
|
||||
|
||||
return Ok(new { message = "Story viewed" });
|
||||
}
|
||||
catch (DbUpdateException)
|
||||
{
|
||||
// Likely a unique constraint violation (already viewed)
|
||||
return Ok(new { message = "Story already viewed" });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return StatusCode(500, ex.Message);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpGet("{id}/viewers")]
|
||||
|
||||
@@ -12,6 +12,10 @@ using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using System.Security.Claims;
|
||||
|
||||
|
||||
|
||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
||||
|
||||
@@ -83,6 +87,8 @@ builder.Services.AddSignalR()
|
||||
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||
});
|
||||
|
||||
builder.Services.AddSingleton<IUserIdProvider, CustomUserIdProvider>();
|
||||
|
||||
// Настройка JWT Аутентификации
|
||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(options =>
|
||||
@@ -163,3 +169,11 @@ app.MapControllers();
|
||||
app.MapHub<ChatHub>("/hubs/chat");
|
||||
|
||||
app.Run();
|
||||
|
||||
public class CustomUserIdProvider : IUserIdProvider
|
||||
{
|
||||
public string? GetUserId(HubConnectionContext connection)
|
||||
{
|
||||
return connection.User?.FindFirstValue("sub") ?? connection.User?.FindFirstValue(ClaimTypes.NameIdentifier);
|
||||
}
|
||||
}
|
||||
|
||||
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+1ec6b438d29413482e1393c8ee5717f1211bd2c1")]
|
||||
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+9daafae9c5956290dda1c718bb49ff72c77fafb8")]
|
||||
[assembly: System.Reflection.AssemblyProductAttribute("Vortex.Host")]
|
||||
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Host")]
|
||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||
|
||||
@@ -1 +1 @@
|
||||
c8308646f5647671185661b28c5c3eabe4dd11328d5c7402bbee505991c08d6d
|
||||
2503d90bfacb43b23a8199c8a1cb6d660f9b8abff8c009ccb8b71721eacb6d50
|
||||
|
||||
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=","HLBqD\u002BLr6lExIdmRzMVhSCOgnqBnWCEhelB/vqt5YM0="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
{"GlobalPropertiesHash":"2XkPbXFkjVRjCUBTJbQq8CDd7pjsUOS1j3gPHS73xDA=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","6IHvDihzP7nz2jPiGaen/UwCBL1mH\u002BfY\u002BnUJBq/hoXU="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
@@ -1 +1 @@
|
||||
{"GlobalPropertiesHash":"2L4HBRt/ChGO9rohi3wtSDqo95X47fme5m8OHgJIZL0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","HLBqD\u002BLr6lExIdmRzMVhSCOgnqBnWCEhelB/vqt5YM0="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
{"GlobalPropertiesHash":"2L4HBRt/ChGO9rohi3wtSDqo95X47fme5m8OHgJIZL0=","FingerprintPatternsHash":"gq3WsqcKBUGTSNle7RKKyXRIwh7M8ccEqOqYvIzoM04=","PropertyOverridesHash":"8ZRc1sGeVrPBx4lD717BgRaQekyh78QKV9SKsdt638U=","InputHashes":["uOUIBvk7jdHp7c93RIuECwNykpOmiZnVmQxETsngeR0=","ToV71GGCtcAptkaGqQYXeNtDQnAJEljvYAkAKnIRZ\u002Bs=","2fjU\u002BjCBiHEuKj2uQgoWti0oZXkpAdICFty/O9cJSyc=","aTLUi985DoL1wb7QmuhiHoO7J3hi30WEVAUbYVkXxss=","6IHvDihzP7nz2jPiGaen/UwCBL1mH\u002BfY\u002BnUJBq/hoXU="],"CachedAssets":{},"CachedCopyCandidates":{}}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 147 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
@@ -327,7 +327,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
|
||||
<div className="absolute inset-0 bg-[url('data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAiIGhlaWdodD0iMjAiIHhtbG5zPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwL3N2ZyI+PGNpcmNsZSBjeD0iMSIgY3k9IjEiIHI9IjEiIGZpbGw9InJnYmEoMjU1LDI1NSwyNTUsMC4wMSkvPjwvc3ZnPg==')] [mask-image:radial-gradient(ellipse_at_center,black_40%,transparent_100%)] opacity-20 pointer-events-none" />
|
||||
|
||||
<div className="text-center relative z-10 w-full max-w-sm px-6">
|
||||
<div className="text-center relative z-10 w-full max-w-sm px-6 mx-auto">
|
||||
<motion.div
|
||||
initial={{ scale: 0.8, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useChatStore } from '../stores/chatStore';
|
||||
import { useNotificationStore } from '../stores/notificationStore';
|
||||
import { useLang } from '../lib/i18n';
|
||||
import { api } from '../lib/api';
|
||||
import { getSocket } from '../lib/socket';
|
||||
import { getInitials, generateAvatarColor } from '../lib/utils';
|
||||
import Avatar from './Avatar';
|
||||
import { StoryGroup } from '../lib/types';
|
||||
@@ -48,8 +49,22 @@ export default function Sidebar() {
|
||||
useEffect(() => {
|
||||
loadStories();
|
||||
const interval = setInterval(loadStories, 30000); // refresh every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
const socket = getSocket();
|
||||
const onStoryViewed = (data: any) => {
|
||||
// Refresh stories if I'm the owner or the viewer
|
||||
if (data.ownerId === user?.id || data.userId === user?.id) {
|
||||
loadStories();
|
||||
}
|
||||
};
|
||||
|
||||
socket?.on('story_viewed', onStoryViewed);
|
||||
|
||||
return () => {
|
||||
clearInterval(interval);
|
||||
socket?.off('story_viewed', onStoryViewed);
|
||||
};
|
||||
}, [user?.id]);
|
||||
|
||||
const filteredChats = chats.filter((chat) => {
|
||||
if (!searchQuery) return true;
|
||||
@@ -62,9 +77,17 @@ export default function Sidebar() {
|
||||
m.user.displayName.toLowerCase().includes(q))
|
||||
);
|
||||
}).sort((a, b) => {
|
||||
// Favorites chat always on top
|
||||
// 1. Favorites chat always on top
|
||||
if (a.type === 'favorites') return -1;
|
||||
if (b.type === 'favorites') return 1;
|
||||
|
||||
// 2. Pinned chats next
|
||||
const aPinned = a.members.find(m => m.user.id === user?.id)?.isPinned;
|
||||
const bPinned = b.members.find(m => m.user.id === user?.id)?.isPinned;
|
||||
if (aPinned && !bPinned) return -1;
|
||||
if (!aPinned && bPinned) return 1;
|
||||
|
||||
// 3. Last message timestamp (if available) - though currently we don't have it on top level
|
||||
return 0;
|
||||
});
|
||||
|
||||
@@ -85,11 +108,11 @@ export default function Sidebar() {
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<div className="w-8 h-8 rounded-lg bg-accent flex items-center justify-center text-white">
|
||||
<MessageSquare size={18} />
|
||||
<div className="flex items-center gap-2.5 min-w-0">
|
||||
<div className="flex items-center justify-center w-9 h-9 rounded-xl overflow-hidden shadow-lg border border-white/5">
|
||||
<img src="/logo.png" className="w-full h-full object-cover" alt="" />
|
||||
</div>
|
||||
<h1 className="text-lg font-bold gradient-text truncate">SelfHost</h1>
|
||||
<h1 className="text-lg font-bold gradient-text truncate">SelfHost Messenger</h1>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setShowNewChat(true)}
|
||||
|
||||
@@ -171,16 +171,37 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!currentStory) return;
|
||||
const storyId = currentStory.id;
|
||||
try {
|
||||
await api.deleteStory(currentStory.id);
|
||||
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);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
onRefresh();
|
||||
goNext();
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (!currentUser || !currentStory) return null;
|
||||
if (!currentUser || !currentStory) {
|
||||
onClose();
|
||||
return null;
|
||||
}
|
||||
|
||||
const timeAgo = (date: string) => {
|
||||
const diff = (Date.now() - new Date(date).getTime()) / 1000;
|
||||
@@ -213,7 +234,20 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
||||
onTouchEnd={() => setPaused(false)}
|
||||
>
|
||||
{/* Story content */}
|
||||
{currentStory.type === 'image' && currentStory.mediaUrl ? (
|
||||
{currentStory.type === 'video' || (currentStory.mediaUrl && (currentStory.mediaUrl.endsWith('.mp4') || currentStory.mediaUrl.endsWith('.mov') || currentStory.mediaUrl.endsWith('.webm'))) ? (
|
||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||
<video
|
||||
src={currentStory.mediaUrl?.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
|
||||
className="w-full h-full object-contain"
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
onEnded={goNext}
|
||||
onPlay={() => setPaused(false)}
|
||||
onPause={() => setPaused(true)}
|
||||
/>
|
||||
</div>
|
||||
) : currentStory.type === 'image' && currentStory.mediaUrl ? (
|
||||
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||
<img
|
||||
src={currentStory.mediaUrl.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
|
||||
@@ -397,10 +431,15 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setImageFile(file);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setImagePreview(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
setMode('image');
|
||||
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'
|
||||
} else {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => setImagePreview(reader.result as string);
|
||||
reader.readAsDataURL(file);
|
||||
setMode('image');
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreate = async () => {
|
||||
@@ -416,7 +455,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
}
|
||||
|
||||
await api.createStory({
|
||||
type: mode,
|
||||
type: imageFile?.type.startsWith('video/') ? 'video' : mode,
|
||||
content: mode === 'text' ? text.trim() : undefined,
|
||||
bgColor: mode === 'text' ? bgColor : undefined,
|
||||
mediaUrl,
|
||||
@@ -464,14 +503,14 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
onClick={() => setMode('image')}
|
||||
className={`flex-1 py-2.5 text-sm font-medium transition-colors ${mode === 'image' ? 'text-vortex-400 border-b-2 border-vortex-400' : 'text-zinc-400'}`}
|
||||
>
|
||||
{t('imageStory')}
|
||||
{t('mediaStory')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
accept="image/*,video/*"
|
||||
className="hidden"
|
||||
onChange={handleImageSelect}
|
||||
/>
|
||||
@@ -512,8 +551,12 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
||||
) : (
|
||||
<>
|
||||
{imagePreview ? (
|
||||
<div className="relative w-full h-48 rounded-xl mb-4 overflow-hidden">
|
||||
<img src={imagePreview} className="w-full h-full object-cover" alt="preview" />
|
||||
<div className="relative w-full h-48 rounded-xl mb-4 overflow-hidden bg-black flex items-center justify-center">
|
||||
{imageFile?.type.startsWith('video/') ? (
|
||||
<video src={imagePreview} className="w-full h-full object-contain" />
|
||||
) : (
|
||||
<img src={imagePreview} className="w-full h-full object-cover" alt="preview" />
|
||||
)}
|
||||
<button
|
||||
onClick={() => { setImageFile(null); setImagePreview(null); }}
|
||||
className="absolute top-2 right-2 w-7 h-7 rounded-full bg-black/50 flex items-center justify-center text-white"
|
||||
|
||||
@@ -36,6 +36,13 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
const [friendStatus, setFriendStatus] = useState<FriendshipStatus | null>(null);
|
||||
const [friendLoading, setFriendLoading] = useState(false);
|
||||
|
||||
// Avatar edit state
|
||||
const [isEditingAvatar, setIsEditingAvatar] = useState(false);
|
||||
const [cropImage, setCropImage] = useState<string | null>(null);
|
||||
const [cropFile, setCropFile] = useState<File | null>(null);
|
||||
const [cropPosition, setCropPosition] = useState({ x: 0, y: 0, scale: 1 });
|
||||
const [isCropping, setIsCropping] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadProfile();
|
||||
if (!isSelf) {
|
||||
@@ -129,6 +136,43 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setCropFile(file);
|
||||
const url = URL.createObjectURL(file);
|
||||
setCropImage(url);
|
||||
setIsCropping(true);
|
||||
};
|
||||
|
||||
const handleCropSave = async () => {
|
||||
if (!cropFile || !cropImage) return;
|
||||
|
||||
try {
|
||||
setTabLoading(true);
|
||||
// For now, we'll just send the file and the crop data as separate fields
|
||||
// In a real app, we might crop on the client via canvas
|
||||
const cropData = {
|
||||
x: Math.round(cropPosition.x),
|
||||
y: Math.round(cropPosition.y),
|
||||
width: 400, // Fixed size for simplicity
|
||||
height: 400
|
||||
};
|
||||
|
||||
const updatedUser = await api.cropAvatar(cropFile, cropData);
|
||||
setProfile(updatedUser);
|
||||
useAuthStore.getState().updateUser(updatedUser);
|
||||
setIsCropping(false);
|
||||
setCropImage(null);
|
||||
setCropFile(null);
|
||||
} catch (e) {
|
||||
console.error('Failed to save avatar', e);
|
||||
} finally {
|
||||
setTabLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initials = (profile?.displayName || profile?.username || '??')
|
||||
.split(' ')
|
||||
.map((w: string) => w[0])
|
||||
@@ -208,6 +252,18 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelf && (
|
||||
<label className="absolute bottom-0 right-0 w-10 h-10 bg-accent hover:bg-accent-light text-white rounded-full border-4 border-surface-secondary shadow-lg flex items-center justify-center cursor-pointer transition-all hover:scale-110 z-20">
|
||||
<Edit3 size={18} />
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/*,video/*"
|
||||
onChange={handleAvatarSelect}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
{/* Имя */}
|
||||
@@ -497,6 +553,65 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Avatar Cropper Modal */}
|
||||
<AnimatePresence>
|
||||
{isCropping && cropImage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[100] bg-black/90 backdrop-blur-xl flex flex-col items-center justify-center p-6"
|
||||
>
|
||||
<div className="w-full max-w-[400px] bg-surface-secondary rounded-[2rem] border border-white/10 overflow-hidden shadow-2xl">
|
||||
<div className="p-6 border-b border-white/5 flex items-center justify-between">
|
||||
<h3 className="text-xl font-bold text-white">{t('changePhoto')}</h3>
|
||||
<button onClick={() => setIsCropping(false)} className="text-zinc-400 hover:text-white transition-colors">
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="p-8 flex flex-col items-center">
|
||||
<div className="relative w-64 h-64 rounded-full overflow-hidden border-4 border-accent/30 bg-black group">
|
||||
{cropFile?.type.startsWith('video/') ? (
|
||||
<video src={cropImage} className="w-full h-full object-cover opacity-80" autoPlay muted loop />
|
||||
) : (
|
||||
<img src={cropImage} className="w-full h-full object-cover opacity-80" alt="" />
|
||||
)}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-48 h-48 rounded-full border-2 border-dashed border-white/50 animate-pulse pointer-events-none" />
|
||||
</div>
|
||||
{/* Fake cropping area overlay */}
|
||||
<div className="absolute inset-0 bg-black/40 pointer-events-none" style={{
|
||||
clipPath: 'circle(48% at 50% 50%)'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-sm text-zinc-400 text-center px-4 leading-relaxed">
|
||||
{t('photoVideo')}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3 mt-8 w-full">
|
||||
<button
|
||||
onClick={() => setIsCropping(false)}
|
||||
className="flex-1 py-3.5 rounded-2xl bg-white/5 hover:bg-white/10 text-white font-semibold transition-all"
|
||||
>
|
||||
{t('cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCropSave}
|
||||
disabled={tabLoading}
|
||||
className="flex-1 py-3.5 rounded-2xl bg-accent hover:bg-accent-light text-white font-bold transition-all shadow-lg shadow-accent/20 flex items-center justify-center gap-2"
|
||||
>
|
||||
{tabLoading ? <Loader2 size={18} className="animate-spin" /> : <Check size={18} />}
|
||||
{t('save')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -100,6 +100,26 @@ class ApiClient {
|
||||
return response.json() as Promise<User>;
|
||||
}
|
||||
|
||||
async cropAvatar(file: File, cropData: { x: number; y: number; width: number; height: number }) {
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
formData.append('cropX', cropData.x.toString());
|
||||
formData.append('cropY', cropData.y.toString());
|
||||
formData.append('cropWidth', cropData.width.toString());
|
||||
formData.append('cropHeight', cropData.height.toString());
|
||||
|
||||
const response = await fetch(`${API_BASE}/users/avatar/crop`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Ошибка кропа аватара');
|
||||
return response.json() as Promise<User>;
|
||||
}
|
||||
|
||||
async removeAvatar() {
|
||||
return this.request<User>('/users/avatar', { method: 'DELETE' });
|
||||
}
|
||||
@@ -229,6 +249,22 @@ class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async uploadVideoToStory(file: File) {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
const response = await fetch(`${API_BASE}/stories/video`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...(this.token ? { Authorization: `Bearer ${this.token}` } : {}),
|
||||
},
|
||||
body: formData,
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Ошибка загрузки видео истории');
|
||||
return response.json() as Promise<{ url: string }>;
|
||||
}
|
||||
|
||||
async viewStory(storyId: string) {
|
||||
return this.request<{ message: string }>(`/stories/${storyId}/view`, { method: 'POST' });
|
||||
}
|
||||
|
||||
@@ -126,6 +126,7 @@ const translations = {
|
||||
newStory: 'Новая история',
|
||||
textStory: 'Текст',
|
||||
imageStory: 'Фото',
|
||||
mediaStory: 'Медиа',
|
||||
typeYourStory: 'Напишите историю...',
|
||||
publishStory: 'Опубликовать',
|
||||
stories: 'Истории',
|
||||
@@ -382,6 +383,7 @@ const translations = {
|
||||
newStory: 'New story',
|
||||
textStory: 'Text',
|
||||
imageStory: 'Photo',
|
||||
mediaStory: 'Media',
|
||||
typeYourStory: 'Write your story...',
|
||||
publishStory: 'Publish',
|
||||
stories: 'Stories',
|
||||
|
||||
@@ -65,12 +65,12 @@ export default function AuthPage() {
|
||||
initial={{ rotate: -180, scale: 0 }}
|
||||
animate={{ rotate: 0, scale: 1 }}
|
||||
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
|
||||
className="w-20 h-20 rounded-2xl bg-accent flex items-center justify-center text-white shadow-lg shadow-accent/30"
|
||||
className="w-24 h-24 rounded-[2rem] overflow-hidden shadow-2xl shadow-accent/20 border-2 border-white/10"
|
||||
>
|
||||
<MessageSquare size={40} />
|
||||
<img src="/logo.png" className="w-full h-full object-cover" alt="Logo" />
|
||||
</motion.div>
|
||||
<h1 className="text-2xl font-bold gradient-text mt-4">SelfHost</h1>
|
||||
<p className="text-zinc-500 text-sm mt-1">
|
||||
<h1 className="text-3xl font-bold gradient-text mt-6">SelfHost Messenger</h1>
|
||||
<p className="text-zinc-500 text-sm mt-2 tracking-wide uppercase">
|
||||
{isLogin ? t('login') : t('register')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user