Мелкие правки
This commit is contained in:
+8
-7
@@ -1,8 +1,9 @@
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Vortex.Modules.Chats.Domain;
|
using global::Vortex.Modules.Chats.Domain;
|
||||||
using Vortex.Modules.Chats.Infrastructure.SignalR;
|
using global::Vortex.Modules.Chats.Infrastructure.SignalR;
|
||||||
using Vortex.Shared.Kernel;
|
using global::Vortex.Shared.Kernel;
|
||||||
|
using global::Vortex.Modules.Chats.Application.Abstractions;
|
||||||
|
|
||||||
namespace Vortex.Modules.Chats.Application.Messages.Delete;
|
namespace Vortex.Modules.Chats.Application.Messages.Delete;
|
||||||
|
|
||||||
@@ -10,17 +11,17 @@ public sealed record DeleteMessagesCommand(
|
|||||||
Guid ChatId,
|
Guid ChatId,
|
||||||
Guid UserId,
|
Guid UserId,
|
||||||
List<Guid> MessageIds,
|
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 IMessageRepository _messageRepository;
|
||||||
private readonly Vortex.Modules.Chats.Application.Abstractions.IChatsUnitOfWork _unitOfWork;
|
private readonly IChatsUnitOfWork _unitOfWork;
|
||||||
private readonly IHubContext<ChatHub> _hubContext;
|
private readonly IHubContext<ChatHub> _hubContext;
|
||||||
|
|
||||||
public DeleteMessagesCommandHandler(
|
public DeleteMessagesCommandHandler(
|
||||||
IMessageRepository messageRepository,
|
IMessageRepository messageRepository,
|
||||||
Vortex.Modules.Chats.Application.Abstractions.IChatsUnitOfWork unitOfWork,
|
IChatsUnitOfWork unitOfWork,
|
||||||
IHubContext<ChatHub> hubContext)
|
IHubContext<ChatHub> hubContext)
|
||||||
{
|
{
|
||||||
_messageRepository = messageRepository;
|
_messageRepository = messageRepository;
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ public sealed class ChatMember : Entity<Guid>
|
|||||||
public Guid UserId { get; private set; }
|
public Guid UserId { get; private set; }
|
||||||
public string Role { get; private set; }
|
public string Role { get; private set; }
|
||||||
public DateTime JoinedAt { get; private set; }
|
public DateTime JoinedAt { get; private set; }
|
||||||
|
public bool IsPinned { get; private set; }
|
||||||
public bool IsMuted { get; private set; }
|
public bool IsMuted { get; private set; }
|
||||||
|
|
||||||
// For EF Core
|
// For EF Core
|
||||||
@@ -115,4 +116,6 @@ public sealed class ChatMember : Entity<Guid>
|
|||||||
Role = role;
|
Role = role;
|
||||||
JoinedAt = DateTime.UtcNow;
|
JoinedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void TogglePin() => IsPinned = !IsPinned;
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,8 +1,8 @@
|
|||||||
using MediatR;
|
using Vortex.Shared.Kernel;
|
||||||
using Microsoft.AspNetCore.SignalR;
|
|
||||||
using Vortex.Modules.Chats.Domain;
|
using Vortex.Modules.Chats.Domain;
|
||||||
using Vortex.Modules.Chats.Infrastructure.SignalR;
|
using Vortex.Modules.Chats.Infrastructure.SignalR;
|
||||||
using Vortex.Shared.Kernel;
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
|
||||||
namespace Vortex.Modules.Chats.Infrastructure.Handlers;
|
namespace Vortex.Modules.Chats.Infrastructure.Handlers;
|
||||||
|
|
||||||
|
|||||||
@@ -61,12 +61,12 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
|
|||||||
mb.WithOwner().HasForeignKey(x => x.MessageId);
|
mb.WithOwner().HasForeignKey(x => x.MessageId);
|
||||||
}).Navigation(m => m.Media).UsePropertyAccessMode(PropertyAccessMode.Field);
|
}).Navigation(m => m.Media).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||||
|
|
||||||
builder.OwnsMany(m => m.Reactions, mb =>
|
builder.HasMany(m => m.Reactions)
|
||||||
{
|
.WithOne()
|
||||||
mb.ToTable("MessageReactions");
|
.HasForeignKey(x => x.MessageId)
|
||||||
mb.HasKey(x => x.Id);
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
mb.HasIndex(x => new { x.MessageId, x.UserId, x.Emoji }).IsUnique();
|
|
||||||
}).Navigation(m => m.Reactions).UsePropertyAccessMode(PropertyAccessMode.Field);
|
builder.Navigation(m => m.Reactions).UsePropertyAccessMode(PropertyAccessMode.Field);
|
||||||
|
|
||||||
builder.PrimitiveCollection(m => m.DeletedByUsers)
|
builder.PrimitiveCollection(m => m.DeletedByUsers)
|
||||||
.HasColumnName("DeletedByUsers")
|
.HasColumnName("DeletedByUsers")
|
||||||
@@ -80,6 +80,13 @@ public sealed class ChatsDbContext : DbContext, IChatsUnitOfWork
|
|||||||
builder.Navigation(m => m.ReadBy).UsePropertyAccessMode(PropertyAccessMode.Field);
|
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 =>
|
modelBuilder.Entity<ReadReceipt>(builder =>
|
||||||
{
|
{
|
||||||
builder.ToTable("ReadReceipts");
|
builder.ToTable("ReadReceipts");
|
||||||
|
|||||||
+251
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+64
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+41
-34
@@ -94,6 +94,30 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("Messages", "chats");
|
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 =>
|
modelBuilder.Entity("Vortex.Modules.Chats.Domain.ReadReceipt", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -131,6 +155,9 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
|||||||
b1.Property<bool>("IsMuted")
|
b1.Property<bool>("IsMuted")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b1.Property<bool>("IsPinned")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b1.Property<DateTime>("JoinedAt")
|
b1.Property<DateTime>("JoinedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.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 =>
|
b.OwnsMany("Vortex.Modules.Chats.Domain.Media", "Media", b1 =>
|
||||||
{
|
{
|
||||||
|
b1.Property<Guid>("MessageId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b1.Property<Guid>("Id")
|
b1.Property<Guid>("Id")
|
||||||
.ValueGeneratedOnAdd()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
@@ -166,9 +196,6 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
|||||||
b1.Property<string>("Filename")
|
b1.Property<string>("Filename")
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b1.Property<Guid>("MessageId")
|
|
||||||
.HasColumnType("uuid");
|
|
||||||
|
|
||||||
b1.Property<long?>("Size")
|
b1.Property<long?>("Size")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
@@ -180,9 +207,7 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
|||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasColumnType("text");
|
.HasColumnType("text");
|
||||||
|
|
||||||
b1.HasKey("Id");
|
b1.HasKey("MessageId", "Id");
|
||||||
|
|
||||||
b1.HasIndex("MessageId");
|
|
||||||
|
|
||||||
b1.ToTable("MessageMedia", "chats");
|
b1.ToTable("MessageMedia", "chats");
|
||||||
|
|
||||||
@@ -190,36 +215,16 @@ namespace Vortex.Modules.Chats.Infrastructure.Persistence.Migrations
|
|||||||
.HasForeignKey("MessageId");
|
.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("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 =>
|
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 =>
|
modelBuilder.Entity("Vortex.Modules.Chats.Domain.Message", b =>
|
||||||
{
|
{
|
||||||
|
b.Navigation("Reactions");
|
||||||
|
|
||||||
b.Navigation("ReadBy");
|
b.Navigation("ReadBy");
|
||||||
});
|
});
|
||||||
#pragma warning restore 612, 618
|
#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
|
// WebRTC signaling
|
||||||
// ────────────────────────────────────────────────────────────────
|
// ────────────────────────────────────────────────────────────────
|
||||||
@@ -470,4 +492,5 @@ public sealed class ChatHub : Hub
|
|||||||
public record GroupIceCandidateRequest(string ChatId, string TargetUserId, object Candidate);
|
public record GroupIceCandidateRequest(string ChatId, string TargetUserId, object Candidate);
|
||||||
public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer);
|
public record GroupRenegotiateRequest(string ChatId, string TargetUserId, object Offer);
|
||||||
public record GroupRenegotiateAnswerRequest(string ChatId, string TargetUserId, object Answer);
|
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.
+1
-1
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Modules.Chats")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Modules.Chats")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[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.AssemblyProductAttribute("Vortex.Modules.Chats")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Modules.Chats")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Modules.Chats")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
f9ffb401f31277f8b221e98682e16484fff43d34c6ed6bf8ad0e087ed2774d78
|
a2ba88a9d7ac278056acbcb427c11fabed9db58aac98411359499b8b1b34811c
|
||||||
|
|||||||
BIN
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.
+1
-1
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Modules.Identity")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Modules.Identity")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[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.AssemblyProductAttribute("Vortex.Modules.Identity")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Modules.Identity")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Modules.Identity")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
9d9ffd258fd1015aa25ef1b7c83a7e79e77e5f90a31d4742aa8a559cc7698555
|
2a9c951494821db92633ba9e6d674467f449af4a90ac9fed0ea49175b5b7917b
|
||||||
|
|||||||
BIN
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Shared.Infrastructure")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Shared.Infrastructure")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[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.AssemblyProductAttribute("Vortex.Shared.Infrastructure")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Shared.Infrastructure")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Shared.Infrastructure")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
e0bbb37b072e496f3944a8671d38c2aa23b6d62d5e51acb6547971f52a1ef575
|
745538ea2e4bf4b47bee0e7c23a71f34c85ffd4593e17f36ed6617330cf934ed
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+1
-1
@@ -13,7 +13,7 @@ using System.Reflection;
|
|||||||
[assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Shared.Kernel")]
|
[assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Shared.Kernel")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[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.AssemblyProductAttribute("Vortex.Shared.Kernel")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Shared.Kernel")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Shared.Kernel")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
b12266e8303643e0701f21b3366bc23363791cc5e63972b801eea2f6405398dc
|
375a1236f22989deedeec81190d9f49c1e30becc8b5382dbeed7e3232f8c1b05
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
@@ -56,6 +56,7 @@ public sealed class ChatsController : ControllerBase
|
|||||||
id = m.Id,
|
id = m.Id,
|
||||||
userId = m.UserId,
|
userId = m.UserId,
|
||||||
role = m.Role,
|
role = m.Role,
|
||||||
|
isPinned = m.IsPinned,
|
||||||
user = user != null ? new {
|
user = user != null ? new {
|
||||||
id = user.Id,
|
id = user.Id,
|
||||||
username = user.Username,
|
username = user.Username,
|
||||||
@@ -226,12 +227,19 @@ public sealed class ChatsController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost("{id:guid}/pin")]
|
[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);
|
var chat = await chatRepository.GetByIdAsync(id, ct);
|
||||||
if (chat == null || !chat.Members.Any(m => m.UserId == _userContext.UserId)) return NotFound();
|
if (chat == null) return NotFound();
|
||||||
// Pin logic not stored in domain yet — return stub
|
|
||||||
return Ok(new { isPinned = false });
|
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")]
|
[HttpPost("{id:guid}/members")]
|
||||||
@@ -314,6 +322,7 @@ public sealed class ChatsController : ControllerBase
|
|||||||
id = m.Id,
|
id = m.Id,
|
||||||
userId = m.UserId,
|
userId = m.UserId,
|
||||||
role = m.Role,
|
role = m.Role,
|
||||||
|
isPinned = m.IsPinned,
|
||||||
user = user != null ? new {
|
user = user != null ? new {
|
||||||
id = user.Id,
|
id = user.Id,
|
||||||
username = user.Username,
|
username = user.Username,
|
||||||
|
|||||||
@@ -118,33 +118,46 @@ public sealed class StoriesController : ControllerBase
|
|||||||
[HttpPost("{id}/view")]
|
[HttpPost("{id}/view")]
|
||||||
public async Task<IActionResult> ViewStory(Guid id, CancellationToken ct)
|
public async Task<IActionResult> ViewStory(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var story = await _context.Stories
|
try
|
||||||
.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))
|
|
||||||
{
|
{
|
||||||
story.AddViewer(_userContext.UserId);
|
var story = await _context.Stories
|
||||||
await _context.SaveChangesAsync(ct);
|
.Include(s => s.Viewers)
|
||||||
|
.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||||||
|
|
||||||
// Notify owner via SignalR
|
if (story == null) return NotFound();
|
||||||
var viewer = await _userRepository.GetByIdAsync(_userContext.UserId, ct);
|
if (story.UserId == _userContext.UserId) return Ok(new { message = "Owner view" });
|
||||||
await _hubContext.Clients.User(story.UserId.ToString()).SendAsync("story_viewed", new
|
|
||||||
|
if (!story.Viewers.Any(v => v.UserId == _userContext.UserId))
|
||||||
{
|
{
|
||||||
storyId = story.Id,
|
story.AddViewer(_userContext.UserId);
|
||||||
userId = _userContext.UserId,
|
await _context.SaveChangesAsync(ct);
|
||||||
username = viewer?.Username,
|
|
||||||
displayName = viewer?.DisplayName,
|
|
||||||
avatar = viewer?.Avatar,
|
|
||||||
viewedAt = DateTime.UtcNow,
|
|
||||||
viewCount = story.Viewers.Count
|
|
||||||
}, 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")]
|
[HttpGet("{id}/viewers")]
|
||||||
|
|||||||
@@ -12,6 +12,10 @@ using System.IdentityModel.Tokens.Jwt;
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using Microsoft.Extensions.FileProviders;
|
using Microsoft.Extensions.FileProviders;
|
||||||
|
using Microsoft.AspNetCore.SignalR;
|
||||||
|
using System.Security.Claims;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
|
||||||
|
|
||||||
@@ -83,6 +87,8 @@ builder.Services.AddSignalR()
|
|||||||
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
options.PayloadSerializerOptions.Converters.Add(new JsonStringEnumConverter());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
builder.Services.AddSingleton<IUserIdProvider, CustomUserIdProvider>();
|
||||||
|
|
||||||
// Настройка JWT Аутентификации
|
// Настройка JWT Аутентификации
|
||||||
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||||
.AddJwtBearer(options =>
|
.AddJwtBearer(options =>
|
||||||
@@ -163,3 +169,11 @@ app.MapControllers();
|
|||||||
app.MapHub<ChatHub>("/hubs/chat");
|
app.MapHub<ChatHub>("/hubs/chat");
|
||||||
|
|
||||||
app.Run();
|
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.AssemblyCompanyAttribute("Vortex.Host")]
|
||||||
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
|
||||||
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
|
[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.AssemblyProductAttribute("Vortex.Host")]
|
||||||
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Host")]
|
[assembly: System.Reflection.AssemblyTitleAttribute("Vortex.Host")]
|
||||||
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
[assembly: System.Reflection.AssemblyVersionAttribute("1.0.0.0")]
|
||||||
|
|||||||
+1
-1
@@ -1 +1 @@
|
|||||||
c8308646f5647671185661b28c5c3eabe4dd11328d5c7402bbee505991c08d6d
|
2503d90bfacb43b23a8199c8a1cb6d660f9b8abff8c009ccb8b71721eacb6d50
|
||||||
|
|||||||
BIN
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="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
|
<motion.div
|
||||||
initial={{ scale: 0.8, opacity: 0 }}
|
initial={{ scale: 0.8, opacity: 0 }}
|
||||||
animate={{ scale: 1, opacity: 1 }}
|
animate={{ scale: 1, opacity: 1 }}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useChatStore } from '../stores/chatStore';
|
|||||||
import { useNotificationStore } from '../stores/notificationStore';
|
import { useNotificationStore } from '../stores/notificationStore';
|
||||||
import { useLang } from '../lib/i18n';
|
import { useLang } from '../lib/i18n';
|
||||||
import { api } from '../lib/api';
|
import { api } from '../lib/api';
|
||||||
|
import { getSocket } from '../lib/socket';
|
||||||
import { getInitials, generateAvatarColor } from '../lib/utils';
|
import { getInitials, generateAvatarColor } from '../lib/utils';
|
||||||
import Avatar from './Avatar';
|
import Avatar from './Avatar';
|
||||||
import { StoryGroup } from '../lib/types';
|
import { StoryGroup } from '../lib/types';
|
||||||
@@ -48,8 +49,22 @@ export default function Sidebar() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadStories();
|
loadStories();
|
||||||
const interval = setInterval(loadStories, 30000); // refresh every 30s
|
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) => {
|
const filteredChats = chats.filter((chat) => {
|
||||||
if (!searchQuery) return true;
|
if (!searchQuery) return true;
|
||||||
@@ -62,9 +77,17 @@ export default function Sidebar() {
|
|||||||
m.user.displayName.toLowerCase().includes(q))
|
m.user.displayName.toLowerCase().includes(q))
|
||||||
);
|
);
|
||||||
}).sort((a, b) => {
|
}).sort((a, b) => {
|
||||||
// Favorites chat always on top
|
// 1. Favorites chat always on top
|
||||||
if (a.type === 'favorites') return -1;
|
if (a.type === 'favorites') return -1;
|
||||||
if (b.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;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -85,11 +108,11 @@ export default function Sidebar() {
|
|||||||
>
|
>
|
||||||
<Menu size={20} />
|
<Menu size={20} />
|
||||||
</button>
|
</button>
|
||||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
<div className="flex items-center gap-2.5 min-w-0">
|
||||||
<div className="w-8 h-8 rounded-lg bg-accent flex items-center justify-center text-white">
|
<div className="flex items-center justify-center w-9 h-9 rounded-xl overflow-hidden shadow-lg border border-white/5">
|
||||||
<MessageSquare size={18} />
|
<img src="/logo.png" className="w-full h-full object-cover" alt="" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowNewChat(true)}
|
onClick={() => setShowNewChat(true)}
|
||||||
|
|||||||
@@ -171,16 +171,37 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
|||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
if (!currentStory) return;
|
if (!currentStory) return;
|
||||||
|
const storyId = currentStory.id;
|
||||||
try {
|
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();
|
onRefresh();
|
||||||
goNext();
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!currentUser || !currentStory) return null;
|
if (!currentUser || !currentStory) {
|
||||||
|
onClose();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const timeAgo = (date: string) => {
|
const timeAgo = (date: string) => {
|
||||||
const diff = (Date.now() - new Date(date).getTime()) / 1000;
|
const diff = (Date.now() - new Date(date).getTime()) / 1000;
|
||||||
@@ -213,7 +234,20 @@ export default function StoryViewer({ stories, initialUserIndex, onClose, onRefr
|
|||||||
onTouchEnd={() => setPaused(false)}
|
onTouchEnd={() => setPaused(false)}
|
||||||
>
|
>
|
||||||
{/* Story content */}
|
{/* 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">
|
<div className="w-full h-full bg-black flex items-center justify-center">
|
||||||
<img
|
<img
|
||||||
src={currentStory.mediaUrl.startsWith('http') ? currentStory.mediaUrl : `${API_URL}${currentStory.mediaUrl}`}
|
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];
|
const file = e.target.files?.[0];
|
||||||
if (!file) return;
|
if (!file) return;
|
||||||
setImageFile(file);
|
setImageFile(file);
|
||||||
const reader = new FileReader();
|
if (file.type.startsWith('video/')) {
|
||||||
reader.onload = () => setImagePreview(reader.result as string);
|
setImagePreview(URL.createObjectURL(file));
|
||||||
reader.readAsDataURL(file);
|
setMode('image'); // We use 'image' mode for both media types for now or we can rename it to 'media'
|
||||||
setMode('image');
|
} else {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = () => setImagePreview(reader.result as string);
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
setMode('image');
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCreate = async () => {
|
const handleCreate = async () => {
|
||||||
@@ -416,7 +455,7 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
}
|
}
|
||||||
|
|
||||||
await api.createStory({
|
await api.createStory({
|
||||||
type: mode,
|
type: imageFile?.type.startsWith('video/') ? 'video' : mode,
|
||||||
content: mode === 'text' ? text.trim() : undefined,
|
content: mode === 'text' ? text.trim() : undefined,
|
||||||
bgColor: mode === 'text' ? bgColor : undefined,
|
bgColor: mode === 'text' ? bgColor : undefined,
|
||||||
mediaUrl,
|
mediaUrl,
|
||||||
@@ -464,14 +503,14 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
onClick={() => setMode('image')}
|
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'}`}
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<input
|
<input
|
||||||
ref={fileInputRef}
|
ref={fileInputRef}
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/*"
|
accept="image/*,video/*"
|
||||||
className="hidden"
|
className="hidden"
|
||||||
onChange={handleImageSelect}
|
onChange={handleImageSelect}
|
||||||
/>
|
/>
|
||||||
@@ -512,8 +551,12 @@ export function CreateStoryModal({ onClose, onCreated }: CreateStoryModalProps)
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{imagePreview ? (
|
{imagePreview ? (
|
||||||
<div className="relative w-full h-48 rounded-xl mb-4 overflow-hidden">
|
<div className="relative w-full h-48 rounded-xl mb-4 overflow-hidden bg-black flex items-center justify-center">
|
||||||
<img src={imagePreview} className="w-full h-full object-cover" alt="preview" />
|
{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
|
<button
|
||||||
onClick={() => { setImageFile(null); setImagePreview(null); }}
|
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"
|
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 [friendStatus, setFriendStatus] = useState<FriendshipStatus | null>(null);
|
||||||
const [friendLoading, setFriendLoading] = useState(false);
|
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(() => {
|
useEffect(() => {
|
||||||
loadProfile();
|
loadProfile();
|
||||||
if (!isSelf) {
|
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 || '??')
|
const initials = (profile?.displayName || profile?.username || '??')
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((w: string) => w[0])
|
.map((w: string) => w[0])
|
||||||
@@ -208,6 +252,18 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
|||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Имя */}
|
{/* Имя */}
|
||||||
@@ -497,6 +553,65 @@ export default function UserProfile({ userId, chatId, onClose, isSelf }: UserPro
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</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>;
|
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() {
|
async removeAvatar() {
|
||||||
return this.request<User>('/users/avatar', { method: 'DELETE' });
|
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) {
|
async viewStory(storyId: string) {
|
||||||
return this.request<{ message: string }>(`/stories/${storyId}/view`, { method: 'POST' });
|
return this.request<{ message: string }>(`/stories/${storyId}/view`, { method: 'POST' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -126,6 +126,7 @@ const translations = {
|
|||||||
newStory: 'Новая история',
|
newStory: 'Новая история',
|
||||||
textStory: 'Текст',
|
textStory: 'Текст',
|
||||||
imageStory: 'Фото',
|
imageStory: 'Фото',
|
||||||
|
mediaStory: 'Медиа',
|
||||||
typeYourStory: 'Напишите историю...',
|
typeYourStory: 'Напишите историю...',
|
||||||
publishStory: 'Опубликовать',
|
publishStory: 'Опубликовать',
|
||||||
stories: 'Истории',
|
stories: 'Истории',
|
||||||
@@ -382,6 +383,7 @@ const translations = {
|
|||||||
newStory: 'New story',
|
newStory: 'New story',
|
||||||
textStory: 'Text',
|
textStory: 'Text',
|
||||||
imageStory: 'Photo',
|
imageStory: 'Photo',
|
||||||
|
mediaStory: 'Media',
|
||||||
typeYourStory: 'Write your story...',
|
typeYourStory: 'Write your story...',
|
||||||
publishStory: 'Publish',
|
publishStory: 'Publish',
|
||||||
stories: 'Stories',
|
stories: 'Stories',
|
||||||
|
|||||||
@@ -65,12 +65,12 @@ export default function AuthPage() {
|
|||||||
initial={{ rotate: -180, scale: 0 }}
|
initial={{ rotate: -180, scale: 0 }}
|
||||||
animate={{ rotate: 0, scale: 1 }}
|
animate={{ rotate: 0, scale: 1 }}
|
||||||
transition={{ duration: 0.6, type: 'spring', bounce: 0.4 }}
|
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>
|
</motion.div>
|
||||||
<h1 className="text-2xl font-bold gradient-text mt-4">SelfHost</h1>
|
<h1 className="text-3xl font-bold gradient-text mt-6">SelfHost Messenger</h1>
|
||||||
<p className="text-zinc-500 text-sm mt-1">
|
<p className="text-zinc-500 text-sm mt-2 tracking-wide uppercase">
|
||||||
{isLogin ? t('login') : t('register')}
|
{isLogin ? t('login') : t('register')}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user