Мелкие правки

This commit is contained in:
Халимов Рустам
2026-03-13 00:46:40 +03:00
parent 9daafae9c5
commit 7670a3b787
82 changed files with 725 additions and 114 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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");
}
}
}

View File

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

View File

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

View File

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

View File

@@ -1 +1 @@
f9ffb401f31277f8b221e98682e16484fff43d34c6ed6bf8ad0e087ed2774d78
a2ba88a9d7ac278056acbcb427c11fabed9db58aac98411359499b8b1b34811c

View File

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

View File

@@ -1 +1 @@
9d9ffd258fd1015aa25ef1b7c83a7e79e77e5f90a31d4742aa8a559cc7698555
2a9c951494821db92633ba9e6d674467f449af4a90ac9fed0ea49175b5b7917b

View File

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

View File

@@ -1 +1 @@
e0bbb37b072e496f3944a8671d38c2aa23b6d62d5e51acb6547971f52a1ef575
745538ea2e4bf4b47bee0e7c23a71f34c85ffd4593e17f36ed6617330cf934ed

View File

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

View File

@@ -1 +1 @@
b12266e8303643e0701f21b3366bc23363791cc5e63972b801eea2f6405398dc
375a1236f22989deedeec81190d9f49c1e30becc8b5382dbeed7e3232f8c1b05

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ using System.Reflection;
[assembly: System.Reflection.AssemblyCompanyAttribute("Vortex.Host")]
[assembly: System.Reflection.AssemblyConfigurationAttribute("Debug")]
[assembly: System.Reflection.AssemblyFileVersionAttribute("1.0.0.0")]
[assembly: System.Reflection.AssemblyInformationalVersionAttribute("1.0.0+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")]

View File

@@ -1 +1 @@
c8308646f5647671185661b28c5c3eabe4dd11328d5c7402bbee505991c08d6d
2503d90bfacb43b23a8199c8a1cb6d660f9b8abff8c009ccb8b71721eacb6d50

View File

@@ -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":{}}

View File

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

View File

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

View File

@@ -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)}

View File

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

View File

@@ -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>
</>
);
}

View File

@@ -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' });
}

View File

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

View File

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