Рабочий чат
This commit is contained in:
@@ -0,0 +1,7 @@
|
|||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Knot.Contracts.Auth.Application.Abstractions;
|
||||||
|
|
||||||
|
public record GetUsersExistenceQuery(List<Guid> UserIds) : IQuery<List<Guid>>;
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
namespace Knot.Contracts.Profiles.Application.DTOs;
|
namespace Knot.Contracts.Profiles.Application.DTOs;
|
||||||
|
|
||||||
public class UserProfileDto
|
public class UserProfileDto
|
||||||
{
|
{
|
||||||
public Guid UserId { get; set; }
|
public Guid UserId { get; set; }
|
||||||
|
public Guid Id { get => UserId; set => UserId = value; }
|
||||||
public string? DisplayName { get; set; }
|
public string? DisplayName { get; set; }
|
||||||
public string? Username { get; set; }
|
public string? Username { get; set; }
|
||||||
public string? About { get; set; }
|
public string? About { get; set; }
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Knot.Contracts.Profiles.Application.DTOs;
|
using Knot.Contracts.Profiles.Application.DTOs;
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
|
|
||||||
namespace Knot.Contracts.Profiles.Domain;
|
namespace Knot.Contracts.Profiles.Domain;
|
||||||
@@ -12,4 +12,5 @@ public interface IProfileRepository
|
|||||||
Task<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken cancellationToken = default);
|
Task<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken cancellationToken = default);
|
||||||
Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken cancellationToken = default);
|
Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken cancellationToken = default);
|
||||||
Task<Result> DeleteAsync(Guid userId, CancellationToken cancellationToken = default);
|
Task<Result> DeleteAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||||
|
Task<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Knot.Contracts.Relations.Application.Contacts;
|
||||||
|
|
||||||
|
public record CheckBlockedStatusQuery(Guid UserId, List<Guid> CandidateIds) : IQuery<List<Guid>>;
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
|
||||||
|
namespace Knot.Contracts.Relations.Application.Contacts;
|
||||||
|
|
||||||
|
public record GetBlockedUserIdsQuery(Guid UserId) : IQuery<List<Guid>>;
|
||||||
@@ -36,6 +36,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer;
|
|||||||
using Microsoft.AspNetCore.SignalR;
|
using Microsoft.AspNetCore.SignalR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.IdentityModel.Tokens;
|
using Microsoft.IdentityModel.Tokens;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -211,6 +212,10 @@ using (var scope = app.Services.CreateScope())
|
|||||||
{
|
{
|
||||||
concreteSettings.Initialize();
|
concreteSettings.Initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sync user replicas for Relations module on startup
|
||||||
|
var mediator = scope.ServiceProvider.GetRequiredService<IMediator>();
|
||||||
|
await mediator.Send(new Knot.Modules.Relations.Application.Contacts.SyncReplicasCommand());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Настройка конвейера запросов
|
// Настройка конвейера запросов
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
|
using Knot.Contracts.Auth.Domain;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Auth.Application.Users;
|
||||||
|
|
||||||
|
internal sealed class GetUsersExistenceQueryHandler : IQueryHandler<GetUsersExistenceQuery, List<Guid>>
|
||||||
|
{
|
||||||
|
private readonly IAuthDbContext _context;
|
||||||
|
|
||||||
|
public GetUsersExistenceQueryHandler(IAuthDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<List<Guid>>> Handle(GetUsersExistenceQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existingIds = await _context.Set<Knot.Modules.Auth.Domain.User>()
|
||||||
|
.Where(u => request.UserIds.Contains(u.Id))
|
||||||
|
.Select(u => u.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(existingIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,8 +28,14 @@ public sealed class User : AggregateRoot<Guid>
|
|||||||
public string? RefreshToken { get; private set; }
|
public string? RefreshToken { get; private set; }
|
||||||
public DateTime? BannedUntil { get; private set; }
|
public DateTime? BannedUntil { get; private set; }
|
||||||
|
|
||||||
public void Ban() => IsBanned = true;
|
public void Ban() {
|
||||||
public void Unban() => IsBanned = false;
|
IsBanned = true;
|
||||||
|
RaiseDomainEvent(new UserBannedDomainEvent(Id, true));
|
||||||
|
}
|
||||||
|
public void Unban() {
|
||||||
|
IsBanned = false;
|
||||||
|
RaiseDomainEvent(new UserBannedDomainEvent(Id, false));
|
||||||
|
}
|
||||||
|
|
||||||
public void SetOnline(bool isOnline, DateTime? lastSeen = null)
|
public void SetOnline(bool isOnline, DateTime? lastSeen = null)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -225,18 +225,31 @@ public sealed class ChatHub : Hub
|
|||||||
[HubMethodName("friend_request")]
|
[HubMethodName("friend_request")]
|
||||||
public async Task FriendRequest(FriendSignalRequest request)
|
public async Task FriendRequest(FriendSignalRequest request)
|
||||||
{
|
{
|
||||||
|
if (request == null || string.IsNullOrEmpty(request.FriendId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("FriendRequest called with null request or empty FriendId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("Signaling friend_request_received to {FriendId} from {UserId}", request.FriendId, _userContext.UserId);
|
||||||
await SendToUserAsync(request.FriendId, "friend_request_received", new { userId = _userContext.UserId });
|
await SendToUserAsync(request.FriendId, "friend_request_received", new { userId = _userContext.UserId });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HubMethodName("friend_accepted")]
|
[HubMethodName("friend_accepted")]
|
||||||
public async Task FriendAccepted(FriendSignalRequest request)
|
public async Task FriendAccepted(FriendSignalRequest request)
|
||||||
{
|
{
|
||||||
|
if (request == null || string.IsNullOrEmpty(request.FriendId)) return;
|
||||||
|
|
||||||
|
_logger.LogInformation("Signaling friend_request_accepted to {FriendId} from {UserId}", request.FriendId, _userContext.UserId);
|
||||||
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
|
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
|
||||||
}
|
}
|
||||||
|
|
||||||
[HubMethodName("friend_removed")]
|
[HubMethodName("friend_removed")]
|
||||||
public async Task FriendRemoved(FriendSignalRequest request)
|
public async Task FriendRemoved(FriendSignalRequest request)
|
||||||
{
|
{
|
||||||
|
if (request == null || string.IsNullOrEmpty(request.FriendId)) return;
|
||||||
|
|
||||||
|
_logger.LogInformation("Signaling friend_removed_notify to {FriendId} from {UserId}", request.FriendId, _userContext.UserId);
|
||||||
await SendToUserAsync(request.FriendId, "friend_removed_notify", new { userId = _userContext.UserId });
|
await SendToUserAsync(request.FriendId, "friend_removed_notify", new { userId = _userContext.UserId });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,15 +259,26 @@ public sealed class ChatHub : Hub
|
|||||||
|
|
||||||
private async Task SendToUserAsync(string targetUserId, string method, object payload)
|
private async Task SendToUserAsync(string targetUserId, string method, object payload)
|
||||||
{
|
{
|
||||||
|
if (string.IsNullOrEmpty(targetUserId))
|
||||||
|
{
|
||||||
|
_logger.LogWarning("SendToUserAsync called with null or empty targetUserId");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
|
if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
|
||||||
{
|
{
|
||||||
string[] ids;
|
string[] ids;
|
||||||
lock (connectionIds) { ids = connectionIds.ToArray(); }
|
lock (connectionIds) { ids = connectionIds.ToArray(); }
|
||||||
|
_logger.LogDebug("Sending {Method} to user {TargetUserId} ({ConnectionCount} connections)", method, targetUserId, ids.Length);
|
||||||
foreach (var connId in ids)
|
foreach (var connId in ids)
|
||||||
{
|
{
|
||||||
await Clients.Client(connId).SendAsync(method, payload);
|
await Clients.Client(connId).SendAsync(method, payload);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("User {TargetUserId} not online, skipping {Method} signal", targetUserId, method);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
[HubMethodName("call_offer")]
|
[HubMethodName("call_offer")]
|
||||||
|
|||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Shared.Kernel.Events;
|
||||||
|
using Knot.Contracts.Profiles.Domain;
|
||||||
|
using Knot.Modules.Profiles.Domain;
|
||||||
|
using MediatR;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Profiles.Application.Profiles.Integration;
|
||||||
|
|
||||||
|
internal sealed class UserStatusChangedHandler :
|
||||||
|
INotificationHandler<UserBannedDomainEvent>,
|
||||||
|
INotificationHandler<UserDeletedDomainEvent>
|
||||||
|
{
|
||||||
|
private readonly IProfileRepository _repository;
|
||||||
|
|
||||||
|
public UserStatusChangedHandler(IProfileRepository repository)
|
||||||
|
{
|
||||||
|
_repository = repository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(UserBannedDomainEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _repository.UpdateStatusAsync(notification.UserId, notification.IsBanned, false, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(UserDeletedDomainEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await _repository.DeleteAsync(notification.UserId, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,23 +5,69 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using MediatR;
|
||||||
|
using System;
|
||||||
|
using Knot.Contracts.Relations.Application.Contacts;
|
||||||
|
using Knot.Contracts.Auth.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Profiles.Application.Profiles.Search;
|
namespace Knot.Modules.Profiles.Application.Profiles.Search;
|
||||||
|
|
||||||
public sealed record SearchProfilesQuery(string Query) : IQuery<List<UserProfileDto>>;
|
public sealed record SearchProfilesQuery(string Query, Guid UserId) : IQuery<List<UserProfileDto>>;
|
||||||
|
|
||||||
internal sealed class SearchProfilesQueryHandler : IQueryHandler<SearchProfilesQuery, List<UserProfileDto>>
|
internal sealed class SearchProfilesQueryHandler : IQueryHandler<SearchProfilesQuery, List<UserProfileDto>>
|
||||||
{
|
{
|
||||||
private readonly IProfileRepository _repository;
|
private readonly IProfileRepository _repository;
|
||||||
|
private readonly ISender _sender;
|
||||||
|
|
||||||
public SearchProfilesQueryHandler(IProfileRepository repository)
|
public SearchProfilesQueryHandler(IProfileRepository repository, ISender sender)
|
||||||
{
|
{
|
||||||
_repository = repository;
|
_repository = repository;
|
||||||
|
_sender = sender;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result<List<UserProfileDto>>> Handle(SearchProfilesQuery request, CancellationToken cancellationToken)
|
public async Task<Result<List<UserProfileDto>>> Handle(SearchProfilesQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// 1. Fetch candidates from MongoDB (which might have orphans)
|
||||||
var profiles = await _repository.SearchAsync(request.Query, 20, cancellationToken);
|
var profiles = await _repository.SearchAsync(request.Query, 20, cancellationToken);
|
||||||
return Result.Success(profiles);
|
|
||||||
|
var candidates = profiles.Where(p => p.UserId != request.UserId).ToList();
|
||||||
|
if (!candidates.Any()) return Result.Success(new List<UserProfileDto>());
|
||||||
|
|
||||||
|
var candidateIds = candidates.Select(p => p.UserId).ToList();
|
||||||
|
|
||||||
|
// 2. Validate existence in Auth module (to filter out orphans from deleted accounts)
|
||||||
|
var existingIds = new List<Guid>();
|
||||||
|
try {
|
||||||
|
var existenceResult = await _sender.Send(new GetUsersExistenceQuery(candidateIds), cancellationToken);
|
||||||
|
if (existenceResult.IsSuccess) existingIds = existenceResult.Value;
|
||||||
|
} catch {
|
||||||
|
// If Auth module is not available, we assume all exist to avoid empty results
|
||||||
|
existingIds = candidateIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Remove orphans (and physically delete them from Mongo if they don't exist in Auth)
|
||||||
|
var validCandidates = candidates.Where(p => existingIds.Contains(p.UserId)).ToList();
|
||||||
|
var orphanIds = candidateIds.Except(existingIds).ToList();
|
||||||
|
foreach (var orphanId in orphanIds)
|
||||||
|
{
|
||||||
|
// Background cleanup (fire and forget or just do it since it's only a few)
|
||||||
|
_ = _repository.DeleteAsync(orphanId, CancellationToken.None);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!validCandidates.Any()) return Result.Success(new List<UserProfileDto>());
|
||||||
|
|
||||||
|
// 4. Check for blocked users among valid candidates
|
||||||
|
var blockedIds = new List<Guid>();
|
||||||
|
try {
|
||||||
|
var blockedResult = await _sender.Send(new CheckBlockedStatusQuery(request.UserId, validCandidates.Select(v => v.UserId).ToList()), cancellationToken);
|
||||||
|
if (blockedResult.IsSuccess) blockedIds = blockedResult.Value;
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
// final filter
|
||||||
|
var filtered = validCandidates
|
||||||
|
.Where(p => !blockedIds.Contains(p.UserId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Result.Success(filtered);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ public sealed class ProfileDocument
|
|||||||
|
|
||||||
public bool HideStoryViews { get; private set; }
|
public bool HideStoryViews { get; private set; }
|
||||||
|
|
||||||
|
public bool IsBanned { get; private set; }
|
||||||
|
|
||||||
|
public bool IsDeleted { get; private set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; private set; }
|
public DateTime CreatedAt { get; private set; }
|
||||||
|
|
||||||
#pragma warning disable CS8618
|
#pragma warning disable CS8618
|
||||||
@@ -37,6 +41,8 @@ public sealed class ProfileDocument
|
|||||||
Username = username;
|
Username = username;
|
||||||
DisplayName = displayName;
|
DisplayName = displayName;
|
||||||
Bio = bio;
|
Bio = bio;
|
||||||
|
IsBanned = false;
|
||||||
|
IsDeleted = false;
|
||||||
CreatedAt = DateTime.UtcNow;
|
CreatedAt = DateTime.UtcNow;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -58,4 +64,10 @@ public sealed class ProfileDocument
|
|||||||
|
|
||||||
public void UpdateSettings(bool hideStoryViews)
|
public void UpdateSettings(bool hideStoryViews)
|
||||||
=> HideStoryViews = hideStoryViews;
|
=> HideStoryViews = hideStoryViews;
|
||||||
|
|
||||||
|
public void UpdateStatus(bool isBanned, bool isDeleted)
|
||||||
|
{
|
||||||
|
IsBanned = isBanned;
|
||||||
|
IsDeleted = isDeleted;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,18 +43,25 @@ internal class ProfileRepository : IProfileRepository
|
|||||||
|
|
||||||
public async Task<List<UserProfileDto>> SearchAsync(string query, int limit = 20, CancellationToken ct = default)
|
public async Task<List<UserProfileDto>> SearchAsync(string query, int limit = 20, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
|
var baseFilter = Builders<ProfileDocument>.Filter.And(
|
||||||
|
Builders<ProfileDocument>.Filter.Ne(p => p.IsBanned, true),
|
||||||
|
Builders<ProfileDocument>.Filter.Ne(p => p.IsDeleted, true)
|
||||||
|
);
|
||||||
|
|
||||||
List<ProfileDocument> docs;
|
List<ProfileDocument> docs;
|
||||||
if (string.IsNullOrWhiteSpace(query))
|
if (string.IsNullOrWhiteSpace(query))
|
||||||
{
|
{
|
||||||
docs = await _profiles.Find(_ => true).Limit(limit).ToListAsync(ct);
|
docs = await _profiles.Find(baseFilter).Limit(limit).ToListAsync(ct);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var filter = Builders<ProfileDocument>.Filter.Or(
|
var searchFilter = Builders<ProfileDocument>.Filter.Or(
|
||||||
Builders<ProfileDocument>.Filter.Regex(p => p.Username, new BsonRegularExpression(query, "i")),
|
Builders<ProfileDocument>.Filter.Regex(p => p.Username, new BsonRegularExpression(query, "i")),
|
||||||
Builders<ProfileDocument>.Filter.Regex(p => p.DisplayName, new BsonRegularExpression(query, "i"))
|
Builders<ProfileDocument>.Filter.Regex(p => p.DisplayName, new BsonRegularExpression(query, "i"))
|
||||||
);
|
);
|
||||||
docs = await _profiles.Find(filter).Limit(limit).ToListAsync(ct);
|
|
||||||
|
var combinedFilter = Builders<ProfileDocument>.Filter.And(baseFilter, searchFilter);
|
||||||
|
docs = await _profiles.Find(combinedFilter).Limit(limit).ToListAsync(ct);
|
||||||
}
|
}
|
||||||
return docs.Select(p => p.ToDto()).ToList();
|
return docs.Select(p => p.ToDto()).ToList();
|
||||||
}
|
}
|
||||||
@@ -82,6 +89,16 @@ internal class ProfileRepository : IProfileRepository
|
|||||||
return Result.Success(profile.ToDto());
|
return Result.Success(profile.ToDto());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct);
|
||||||
|
if (profile is null) return Result.Failure(ProfilesErrors.ProfileNotFound);
|
||||||
|
|
||||||
|
profile.UpdateStatus(isBanned, isDeleted);
|
||||||
|
await _profiles.ReplaceOneAsync(p => p.Id == userId, profile, cancellationToken: ct);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Result> DeleteAsync(Guid userId, CancellationToken ct = default)
|
public async Task<Result> DeleteAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var result = await _profiles.DeleteOneAsync(p => p.Id == userId, ct);
|
var result = await _profiles.DeleteOneAsync(p => p.Id == userId, ct);
|
||||||
|
|||||||
@@ -10,6 +10,8 @@
|
|||||||
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
||||||
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
|
||||||
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
|
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Contracts\Relations\Knot.Contracts.Relations.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -18,9 +18,9 @@ public static class ProfilesEndpoints
|
|||||||
{
|
{
|
||||||
var group = app.MapGroup("api/profiles").RequireAuthorization();
|
var group = app.MapGroup("api/profiles").RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("search", async ([FromQuery] string q, ISender sender, CancellationToken ct) =>
|
group.MapGet("search", async ([FromQuery] string q, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new SearchProfilesQuery(q), ct);
|
var result = await sender.Send(new SearchProfilesQuery(q, userContext.UserId), ct);
|
||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Knot.Modules.Relations.Application.Abstractions;
|
||||||
|
using Knot.Modules.Relations.Domain;
|
||||||
|
using Knot.Contracts.Relations.Application.Contacts;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Relations.Application.Contacts;
|
||||||
|
|
||||||
|
internal sealed class CheckBlockedStatusQueryHandler : IQueryHandler<CheckBlockedStatusQuery, List<Guid>>
|
||||||
|
{
|
||||||
|
private readonly IContactsDbContext _context;
|
||||||
|
|
||||||
|
public CheckBlockedStatusQueryHandler(IContactsDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<List<Guid>>> Handle(CheckBlockedStatusQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// Find which candidate IDs are in a "Blocked" relationship with the current user.
|
||||||
|
// We check both directions (currentUser blocks candidate OR candidate blocks currentUser).
|
||||||
|
var blockedIds = await _context.Contacts
|
||||||
|
.Where(c => (c.UserId == request.UserId || c.ContactId == request.UserId)
|
||||||
|
&& c.Status == ContactStatus.Blocked
|
||||||
|
&& (request.CandidateIds.Contains(c.UserId) || request.CandidateIds.Contains(c.ContactId)))
|
||||||
|
.Select(c => c.UserId == request.UserId ? c.ContactId : c.UserId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(blockedIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,9 +22,11 @@ internal sealed class DeclineContactRequestCommandHandler : ICommandHandler<Decl
|
|||||||
public async Task<Result<bool>> Handle(DeclineContactRequestCommand request, CancellationToken cancellationToken)
|
public async Task<Result<bool>> Handle(DeclineContactRequestCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == request.RequestId, cancellationToken);
|
var contact = await _context.Contacts.FirstOrDefaultAsync(c => c.Id == request.RequestId, cancellationToken);
|
||||||
if (contact == null || contact.ContactId != request.UserId)
|
|
||||||
|
// Allow BOTH receiver (to decline) AND sender (to cancel)
|
||||||
|
if (contact == null || (contact.ContactId != request.UserId && contact.UserId != request.UserId))
|
||||||
{
|
{
|
||||||
return Result.Failure<bool>(new Error("Contacts.NotFound", "Contact request not found."));
|
return Result.Failure<bool>(new Error("Contacts.NotFound", "Contact request not found or you don't have permission."));
|
||||||
}
|
}
|
||||||
|
|
||||||
contact.Decline();
|
contact.Decline();
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Knot.Modules.Relations.Application.Abstractions;
|
||||||
|
using Knot.Modules.Relations.Domain;
|
||||||
|
|
||||||
|
using Knot.Contracts.Relations.Application.Contacts;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Relations.Application.Contacts;
|
||||||
|
|
||||||
|
internal sealed class GetBlockedUserIdsQueryHandler : IQueryHandler<GetBlockedUserIdsQuery, List<Guid>>
|
||||||
|
{
|
||||||
|
private readonly IContactsDbContext _context;
|
||||||
|
|
||||||
|
public GetBlockedUserIdsQueryHandler(IContactsDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<List<Guid>>> Handle(GetBlockedUserIdsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var blockedIds = await _context.Contacts
|
||||||
|
.Where(c => (c.UserId == request.UserId || c.ContactId == request.UserId) && c.Status == ContactStatus.Blocked)
|
||||||
|
.Select(c => c.UserId == request.UserId ? c.ContactId : c.UserId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return Result.Success(blockedIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Knot.Modules.Relations.Domain;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Knot.Modules.Relations.Application.Abstractions;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Relations.Application.Contacts;
|
||||||
|
|
||||||
|
public record ContactUserDto(Guid Id, string Username, string DisplayName, string Avatar);
|
||||||
|
public record ContactRequestDto(Guid Id, ContactUserDto User, DateTime CreatedAt, bool IsOutgoing);
|
||||||
|
|
||||||
|
public record GetContactRequestsQuery(Guid UserId) : IQuery<List<ContactRequestDto>>;
|
||||||
|
|
||||||
|
internal sealed class GetContactRequestsQueryHandler : IQueryHandler<GetContactRequestsQuery, List<ContactRequestDto>>
|
||||||
|
{
|
||||||
|
private readonly IContactsDbContext _context;
|
||||||
|
|
||||||
|
public GetContactRequestsQueryHandler(IContactsDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result<List<ContactRequestDto>>> Handle(GetContactRequestsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. Fetch ALL pending requests where current user is either sender or receiver
|
||||||
|
var contacts = await _context.Contacts
|
||||||
|
.Where(c => (c.ContactId == request.UserId || c.UserId == request.UserId) && c.Status == ContactStatus.Pending)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 2. Fetch all unique IDs for users we need replicas for
|
||||||
|
var userIds = contacts
|
||||||
|
.Select(c => c.UserId == request.UserId ? c.ContactId : c.UserId)
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
// 3. Fetch replicas
|
||||||
|
var replicas = await _context.UserReplicas
|
||||||
|
.Where(r => userIds.Contains(r.Id))
|
||||||
|
.ToDictionaryAsync(r => r.Id, cancellationToken);
|
||||||
|
|
||||||
|
// 4. Transform into DTOs
|
||||||
|
var result = contacts
|
||||||
|
.Select(c =>
|
||||||
|
{
|
||||||
|
var isOutgoing = c.UserId == request.UserId;
|
||||||
|
var otherUserId = isOutgoing ? c.ContactId : c.UserId;
|
||||||
|
|
||||||
|
// If replica is missing, we try to at least return the record (Visibility fix part 1)
|
||||||
|
// We will handle replica creation in SendContactRequest proactively.
|
||||||
|
if (!replicas.TryGetValue(otherUserId, out var user))
|
||||||
|
{
|
||||||
|
return new ContactRequestDto(
|
||||||
|
c.Id,
|
||||||
|
new ContactUserDto(otherUserId, "Unknown", "Unknown", ""),
|
||||||
|
c.CreatedAt,
|
||||||
|
isOutgoing
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new ContactRequestDto(
|
||||||
|
c.Id,
|
||||||
|
new ContactUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
|
||||||
|
c.CreatedAt,
|
||||||
|
isOutgoing
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return Result.Success(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,34 +23,57 @@ internal sealed class GetContactsQueryHandler : IQueryHandler<GetContactsQuery,
|
|||||||
|
|
||||||
public async Task<Result<List<ContactDto>>> Handle(GetContactsQuery request, CancellationToken cancellationToken)
|
public async Task<Result<List<ContactDto>>> Handle(GetContactsQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
// 1. Fetch ALL accepted relations for current user
|
||||||
var relations = await _context.Contacts
|
var relations = await _context.Contacts
|
||||||
.Where(c => (c.UserId == request.UserId || c.ContactId == request.UserId) && c.Status == ContactStatus.Accepted)
|
.Where(c => (c.UserId == request.UserId || c.ContactId == request.UserId) && c.Status == ContactStatus.Accepted)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var contactIds = relations.Select(c => c.UserId == request.UserId ? c.ContactId : c.UserId).ToList();
|
// 2. Fetch all unique IDs for users we need replicas for
|
||||||
|
var contactIds = relations.Select(c => c.UserId == request.UserId ? c.ContactId : c.UserId).Distinct().ToList();
|
||||||
|
|
||||||
|
// 3. Fetch replicas
|
||||||
var replicas = await _context.UserReplicas
|
var replicas = await _context.UserReplicas
|
||||||
.Where(r => contactIds.Contains(r.Id))
|
.Where(r => contactIds.Contains(r.Id))
|
||||||
.ToListAsync(cancellationToken);
|
.ToDictionaryAsync(r => r.Id, cancellationToken);
|
||||||
|
|
||||||
var result = new List<ContactDto>();
|
var result = new List<ContactDto>();
|
||||||
|
|
||||||
foreach (var replica in replicas)
|
// 4. Iterate over RELATIONS (to ensure we don't skip people with missing replicas)
|
||||||
|
foreach (var rel in relations)
|
||||||
{
|
{
|
||||||
var rel = relations.First(c => c.UserId == replica.Id || c.ContactId == replica.Id);
|
var otherUserId = rel.UserId == request.UserId ? rel.ContactId : rel.UserId;
|
||||||
|
|
||||||
result.Add(new ContactDto(
|
if (replicas.TryGetValue(otherUserId, out var replica))
|
||||||
replica.Id,
|
{
|
||||||
replica.Username,
|
result.Add(new ContactDto(
|
||||||
replica.DisplayName,
|
replica.Id,
|
||||||
replica.Avatar,
|
replica.Username,
|
||||||
false,
|
replica.DisplayName,
|
||||||
null,
|
replica.Avatar,
|
||||||
rel.Id,
|
false, // isOnline - current user query doesn't handle this here
|
||||||
rel.Status == ContactStatus.Blocked,
|
null, // lastSeen
|
||||||
replica.IsExternal,
|
rel.Id,
|
||||||
replica.Domain
|
rel.Status == ContactStatus.Blocked,
|
||||||
));
|
replica.IsExternal,
|
||||||
|
replica.Domain
|
||||||
|
));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// Return placeholder but ensure it's in the list
|
||||||
|
result.Add(new ContactDto(
|
||||||
|
otherUserId,
|
||||||
|
"Unknown",
|
||||||
|
"Unknown",
|
||||||
|
"",
|
||||||
|
false,
|
||||||
|
null,
|
||||||
|
rel.Id,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
null
|
||||||
|
));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Result.Success(result);
|
return Result.Success(result);
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
using System;
|
|
||||||
using System.Collections.Generic;
|
|
||||||
using System.Linq;
|
|
||||||
using System.Threading;
|
|
||||||
using System.Threading.Tasks;
|
|
||||||
using Knot.Modules.Relations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Knot.Modules.Relations.Application.Abstractions;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Relations.Application.Contacts;
|
|
||||||
|
|
||||||
public record ContactUserDto(Guid Id, string Username, string DisplayName, string Avatar);
|
|
||||||
public record ContactRequestDto(Guid Id, ContactUserDto User, DateTime CreatedAt);
|
|
||||||
|
|
||||||
public record GetIncomingRequestsQuery(Guid UserId) : IQuery<List<ContactRequestDto>>;
|
|
||||||
|
|
||||||
internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler<GetIncomingRequestsQuery, List<ContactRequestDto>>
|
|
||||||
{
|
|
||||||
private readonly IContactsDbContext _context;
|
|
||||||
|
|
||||||
public GetIncomingRequestsQueryHandler(IContactsDbContext context)
|
|
||||||
{
|
|
||||||
_context = context;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<Result<List<ContactRequestDto>>> Handle(GetIncomingRequestsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var contacts = await _context.Contacts
|
|
||||||
.Where(c => c.ContactId == request.UserId && c.Status == ContactStatus.Pending)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var requesterIds = contacts.Select(c => c.UserId).ToList();
|
|
||||||
var replicas = await _context.UserReplicas
|
|
||||||
.Where(r => requesterIds.Contains(r.Id))
|
|
||||||
.ToDictionaryAsync(r => r.Id, cancellationToken);
|
|
||||||
|
|
||||||
var result = contacts
|
|
||||||
.Where(c => replicas.ContainsKey(c.UserId))
|
|
||||||
.Select(c =>
|
|
||||||
{
|
|
||||||
var user = replicas[c.UserId];
|
|
||||||
return new ContactRequestDto(
|
|
||||||
c.Id,
|
|
||||||
new ContactUserDto(user.Id, user.Username, user.DisplayName, user.Avatar),
|
|
||||||
c.CreatedAt
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return Result.Success(result);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Knot.Shared.Kernel.Events;
|
||||||
|
using Knot.Modules.Relations.Application.Abstractions;
|
||||||
|
using Knot.Modules.Relations.Domain;
|
||||||
|
using MediatR;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Relations.Application.Contacts.Integration;
|
||||||
|
|
||||||
|
internal sealed class UserRegisteredHandler : INotificationHandler<UserRegisteredDomainEvent>
|
||||||
|
{
|
||||||
|
private readonly IContactsDbContext _context;
|
||||||
|
|
||||||
|
public UserRegisteredHandler(IContactsDbContext context)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task Handle(UserRegisteredDomainEvent notification, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existing = await _context.UserReplicas.AnyAsync(r => r.Id == notification.UserId, cancellationToken);
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
var replica = UserReplica.Create(
|
||||||
|
notification.UserId,
|
||||||
|
notification.Username,
|
||||||
|
notification.DisplayName,
|
||||||
|
string.Empty, // Avatar will be synced on update
|
||||||
|
false,
|
||||||
|
null
|
||||||
|
);
|
||||||
|
|
||||||
|
_context.UserReplicas.Add(replica);
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
using Knot.Modules.Relations.Domain;
|
using Knot.Modules.Relations.Domain;
|
||||||
@@ -13,10 +14,12 @@ public record SendContactRequestCommand(Guid UserId, Guid ContactId) : ICommand;
|
|||||||
internal sealed class SendContactRequestCommandHandler : ICommandHandler<SendContactRequestCommand>
|
internal sealed class SendContactRequestCommandHandler : ICommandHandler<SendContactRequestCommand>
|
||||||
{
|
{
|
||||||
private readonly IContactsDbContext _context;
|
private readonly IContactsDbContext _context;
|
||||||
|
private readonly Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext _authContext;
|
||||||
|
|
||||||
public SendContactRequestCommandHandler(IContactsDbContext context)
|
public SendContactRequestCommandHandler(IContactsDbContext context, Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext authContext)
|
||||||
{
|
{
|
||||||
_context = context;
|
_context = context;
|
||||||
|
_authContext = authContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Result> Handle(SendContactRequestCommand request, CancellationToken cancellationToken)
|
public async Task<Result> Handle(SendContactRequestCommand request, CancellationToken cancellationToken)
|
||||||
@@ -26,6 +29,10 @@ internal sealed class SendContactRequestCommandHandler : ICommandHandler<SendCon
|
|||||||
return Result.Failure(new Error("Contacts.Self", "You cannot add yourself to contacts."));
|
return Result.Failure(new Error("Contacts.Self", "You cannot add yourself to contacts."));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 1. Proactively ensure replicas exist for both ends
|
||||||
|
await EnsureUserReplicaExists(request.UserId, cancellationToken);
|
||||||
|
await EnsureUserReplicaExists(request.ContactId, cancellationToken);
|
||||||
|
|
||||||
var existing = await _context.Contacts
|
var existing = await _context.Contacts
|
||||||
.FirstOrDefaultAsync(f => (f.UserId == request.UserId && f.ContactId == request.ContactId) ||
|
.FirstOrDefaultAsync(f => (f.UserId == request.UserId && f.ContactId == request.ContactId) ||
|
||||||
(f.UserId == request.ContactId && f.ContactId == request.UserId), cancellationToken);
|
(f.UserId == request.ContactId && f.ContactId == request.UserId), cancellationToken);
|
||||||
@@ -41,4 +48,25 @@ internal sealed class SendContactRequestCommandHandler : ICommandHandler<SendCon
|
|||||||
|
|
||||||
return Result.Success();
|
return Result.Success();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task EnsureUserReplicaExists(Guid userId, CancellationToken ct)
|
||||||
|
{
|
||||||
|
var existing = await _context.UserReplicas.AnyAsync(r => r.Id == userId, ct);
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
|
var user = await _authContext.Users.FirstOrDefaultAsync(u => u.Id == userId, ct);
|
||||||
|
if (user == null) return; // User might be external or not found
|
||||||
|
|
||||||
|
var replica = UserReplica.Create(
|
||||||
|
user.Id,
|
||||||
|
user.Username,
|
||||||
|
user.DisplayName,
|
||||||
|
user.Avatar ?? string.Empty,
|
||||||
|
user.IsExternal,
|
||||||
|
user.Domain
|
||||||
|
);
|
||||||
|
|
||||||
|
_context.UserReplicas.Add(replica);
|
||||||
|
await _context.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,53 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections.Generic;
|
||||||
|
using System.Linq;
|
||||||
|
using Knot.Shared.Kernel;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Knot.Modules.Relations.Application.Abstractions;
|
||||||
|
using Knot.Modules.Relations.Domain;
|
||||||
|
|
||||||
|
namespace Knot.Modules.Relations.Application.Contacts;
|
||||||
|
|
||||||
|
public record SyncReplicasCommand() : ICommand;
|
||||||
|
|
||||||
|
internal sealed class SyncReplicasCommandHandler : ICommandHandler<SyncReplicasCommand>
|
||||||
|
{
|
||||||
|
private readonly IContactsDbContext _context;
|
||||||
|
private readonly Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext _authContext;
|
||||||
|
|
||||||
|
public SyncReplicasCommandHandler(IContactsDbContext context, Knot.Contracts.Auth.Infrastructure.Persistence.IAuthDbContext authContext)
|
||||||
|
{
|
||||||
|
_context = context;
|
||||||
|
_authContext = authContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<Result> Handle(SyncReplicasCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
// 1. Fetch ALL users from Auth (since it's a repair operation)
|
||||||
|
var users = await _authContext.Users.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 2. Fetch existing IDs in Replicas
|
||||||
|
var existingIds = await _context.UserReplicas.Select(r => r.Id).ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
// 3. Find missing ones
|
||||||
|
var missing = users.Where(u => !existingIds.Contains(u.Id)).ToList();
|
||||||
|
|
||||||
|
foreach (var user in missing)
|
||||||
|
{
|
||||||
|
var replica = UserReplica.Create(
|
||||||
|
user.Id,
|
||||||
|
user.Username,
|
||||||
|
user.DisplayName,
|
||||||
|
user.Avatar ?? string.Empty,
|
||||||
|
user.IsExternal,
|
||||||
|
user.Domain
|
||||||
|
);
|
||||||
|
_context.UserReplicas.Add(replica);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _context.SaveChangesAsync(cancellationToken);
|
||||||
|
return Result.Success();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ public static class DependencyInjection
|
|||||||
services.AddDbContext<RelationsDbContext>(options =>
|
services.AddDbContext<RelationsDbContext>(options =>
|
||||||
options.UseNpgsql(connectionString));
|
options.UseNpgsql(connectionString));
|
||||||
|
|
||||||
|
services.AddScoped<IContactsDbContext>(sp => sp.GetRequiredService<RelationsDbContext>());
|
||||||
services.AddScoped<Knot.Contracts.Relations.Application.Abstractions.IFriendshipRepository, FriendshipRepository>();
|
services.AddScoped<Knot.Contracts.Relations.Application.Abstractions.IFriendshipRepository, FriendshipRepository>();
|
||||||
|
|
||||||
services.AddMediatR(config =>
|
services.AddMediatR(config =>
|
||||||
|
|||||||
@@ -7,10 +7,11 @@ using Knot.Shared.Kernel;
|
|||||||
using MediatR;
|
using MediatR;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||||
|
using Knot.Modules.Relations.Application.Abstractions;
|
||||||
|
|
||||||
namespace Knot.Modules.Relations.Infrastructure.Persistence;
|
namespace Knot.Modules.Relations.Infrastructure.Persistence;
|
||||||
|
|
||||||
public sealed class RelationsDbContext : DbContext
|
public sealed class RelationsDbContext : DbContext, IContactsDbContext
|
||||||
{
|
{
|
||||||
private readonly IMediator _mediator;
|
private readonly IMediator _mediator;
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\Contracts\Relations\Knot.Contracts.Relations.csproj" />
|
<ProjectReference Include="..\..\Contracts\Relations\Knot.Contracts.Relations.csproj" />
|
||||||
|
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
|
||||||
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
<ProjectReference Include="..\..\Shared\Knot.Shared.Kernel\Knot.Shared.Kernel.csproj" />
|
||||||
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\Shared\Knot.Shared.Infrastructure\Knot.Shared.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ public static class ContactsEndpoints
|
|||||||
|
|
||||||
group.MapGet("requests", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
|
group.MapGet("requests", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new GetIncomingRequestsQuery(userContext.UserId), ct);
|
var result = await sender.Send(new GetContactRequestsQuery(userContext.UserId), ct);
|
||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
|
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -86,6 +86,7 @@ public static class ContactsEndpoints
|
|||||||
return result.IsSuccess ? Results.Ok(new { success = true }) : Results.NotFound(result.Error.Description);
|
return result.IsSuccess ? Results.Ok(new { success = true }) : Results.NotFound(result.Error.Description);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
group.MapDelete("{id:guid}", async ([FromRoute] Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
group.MapDelete("{id:guid}", async ([FromRoute] Guid id, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||||
{
|
{
|
||||||
var result = await sender.Send(new RemoveContactCommand(userContext.UserId, id), ct);
|
var result = await sender.Send(new RemoveContactCommand(userContext.UserId, id), ct);
|
||||||
|
|||||||
@@ -13,3 +13,7 @@ public sealed record UserRegisteredDomainEvent(
|
|||||||
string Username,
|
string Username,
|
||||||
string DisplayName,
|
string DisplayName,
|
||||||
string? Bio) : IDomainEvent;
|
string? Bio) : IDomainEvent;
|
||||||
|
|
||||||
|
public sealed record UserBannedDomainEvent(Guid UserId, bool IsBanned) : IDomainEvent;
|
||||||
|
|
||||||
|
public sealed record UserDeletedDomainEvent(Guid UserId) : IDomainEvent;
|
||||||
|
|||||||
@@ -178,6 +178,7 @@ export interface FriendRequest {
|
|||||||
id: string;
|
id: string;
|
||||||
user: User;
|
user: User;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
isOutgoing?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FriendWithId extends UserPresence {
|
export interface FriendWithId extends UserPresence {
|
||||||
|
|||||||
@@ -43,8 +43,10 @@ export class HttpClient {
|
|||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const errorData = await response.json().catch(() => ({}));
|
const errorData = await response.json().catch(() => ({}));
|
||||||
const errorMessage = errorData.error || errorData.message || 'Ошибка запроса';
|
const errorMessage = errorData.error || errorData.message || `Request failed with status ${response.status}`;
|
||||||
throw new Error(errorMessage);
|
const err = new Error(errorMessage);
|
||||||
|
(err as any).status = response.status;
|
||||||
|
throw err;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle empty responses (e.g., 200 OK with no body)
|
// Handle empty responses (e.g., 200 OK with no body)
|
||||||
|
|||||||
@@ -136,6 +136,8 @@ const translations = {
|
|||||||
stories: 'Истории',
|
stories: 'Истории',
|
||||||
clearChat: 'Очистить чат',
|
clearChat: 'Очистить чат',
|
||||||
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
|
clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.',
|
||||||
|
clearHistory: 'Очистить историю',
|
||||||
|
clearHistoryConfirm: 'Очистить историю?',
|
||||||
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.',
|
||||||
pinChat: 'Закрепить чат',
|
pinChat: 'Закрепить чат',
|
||||||
unpinChat: 'Открепить чат',
|
unpinChat: 'Открепить чат',
|
||||||
@@ -257,8 +259,11 @@ const translations = {
|
|||||||
removeFriend: 'Удалить из контактов',
|
removeFriend: 'Удалить из контактов',
|
||||||
requestSent: 'Заявка отправлена',
|
requestSent: 'Заявка отправлена',
|
||||||
searchFriends: 'Поиск по @username (мин. 3 символа)',
|
searchFriends: 'Поиск по @username (мин. 3 символа)',
|
||||||
|
searchResults: 'Результаты поиска',
|
||||||
noSearchResults: 'Пользователи не найдены',
|
noSearchResults: 'Пользователи не найдены',
|
||||||
minCharsHint: 'Введите минимум 3 символа после @',
|
minCharsHint: 'Введите минимум 3 символа после @',
|
||||||
|
selectContactToChat: 'Выберите контакт из списка, чтобы начать общение, или воспользуйтесь поиском для поиска новых людей.',
|
||||||
|
backToChats: 'Назад к чатам',
|
||||||
// Story viewers
|
// Story viewers
|
||||||
storyViewers: 'Кто просмотрел',
|
storyViewers: 'Кто просмотрел',
|
||||||
noViewers: 'Пока никто не посмотрел',
|
noViewers: 'Пока никто не посмотрел',
|
||||||
@@ -412,6 +417,8 @@ const translations = {
|
|||||||
stories: 'Stories',
|
stories: 'Stories',
|
||||||
clearChat: 'Clear chat',
|
clearChat: 'Clear chat',
|
||||||
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
|
clearChatConfirm: 'Clear chat history for yourself? The other person will keep their history.',
|
||||||
|
clearHistory: 'Clear history',
|
||||||
|
clearHistoryConfirm: 'Clear history?',
|
||||||
deleteChatConfirm: 'Delete this chat? This action cannot be undone.',
|
deleteChatConfirm: 'Delete this chat? This action cannot be undone.',
|
||||||
pinChat: 'Pin chat',
|
pinChat: 'Pin chat',
|
||||||
unpinChat: 'Unpin chat',
|
unpinChat: 'Unpin chat',
|
||||||
@@ -522,8 +529,11 @@ const translations = {
|
|||||||
removeFriend: 'Remove from contacts',
|
removeFriend: 'Remove from contacts',
|
||||||
requestSent: 'Request sent',
|
requestSent: 'Request sent',
|
||||||
searchFriends: 'Search by @username (min. 3 chars)',
|
searchFriends: 'Search by @username (min. 3 chars)',
|
||||||
|
searchResults: 'Search Results',
|
||||||
noSearchResults: 'No users found',
|
noSearchResults: 'No users found',
|
||||||
minCharsHint: 'Enter at least 3 characters after @',
|
minCharsHint: 'Enter at least 3 characters after @',
|
||||||
|
selectContactToChat: 'Select a contact from the list to start a conversation or use search to find new people.',
|
||||||
|
backToChats: 'Back to Chats',
|
||||||
storyViewers: 'Who viewed',
|
storyViewers: 'Who viewed',
|
||||||
noViewers: 'No one viewed yet',
|
noViewers: 'No one viewed yet',
|
||||||
replyToStory: 'Reply to story...',
|
replyToStory: 'Reply to story...',
|
||||||
|
|||||||
@@ -12,9 +12,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
|||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ id: 'chats', icon: 'chat', label: t('chats') },
|
{ id: 'chats', icon: 'chat', label: t('chats') },
|
||||||
{ id: 'calls', icon: 'call', label: t('calls') },
|
|
||||||
{ id: 'contacts', icon: 'contacts', label: t('contacts') },
|
{ id: 'contacts', icon: 'contacts', label: t('contacts') },
|
||||||
{ id: 'archive', icon: 'archive', label: t('archive') },
|
|
||||||
{ id: 'settings', icon: 'settings', label: t('settings') },
|
{ id: 'settings', icon: 'settings', label: t('settings') },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -524,7 +524,7 @@ export default function AdminPage() {
|
|||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [config, setConfig] = useState<Conf | null>(null);
|
const [config, setConfig] = useState<Conf | null>(null);
|
||||||
const [cleanStats, setCleanStats] = useState<CleanStats | null>(null);
|
const [cleanStats, setCleanStats] = useState<CleanStats | null>(null);
|
||||||
const [authHeader, setAuthHeader] = useState('');
|
const [authHeader, setAuthHeader] = useState(localStorage.getItem('knot_admin_auth') || '');
|
||||||
const [creds, setCreds] = useState({ user: '', pass: '' });
|
const [creds, setCreds] = useState({ user: '', pass: '' });
|
||||||
const [authenticated, setAuthenticated] = useState(false);
|
const [authenticated, setAuthenticated] = useState(false);
|
||||||
|
|
||||||
@@ -818,17 +818,47 @@ export default function AdminPage() {
|
|||||||
try {
|
try {
|
||||||
await httpClient.request('/admin/dashboard');
|
await httpClient.request('/admin/dashboard');
|
||||||
setAuthHeader(header);
|
setAuthHeader(header);
|
||||||
|
localStorage.setItem('knot_admin_auth', header);
|
||||||
setAuthenticated(true);
|
setAuthenticated(true);
|
||||||
fetchDashboard();
|
fetchDashboard();
|
||||||
fetchSettings();
|
fetchSettings();
|
||||||
fetchTimezones();
|
fetchTimezones();
|
||||||
searchUsers('');
|
searchUsers('');
|
||||||
} catch {
|
} catch (err: any) {
|
||||||
showToast(t.errorInvalidLogin, 'error');
|
showToast(t.errorInvalidLogin, 'error');
|
||||||
httpClient.setToken(null);
|
if (err.status === 401 || err.status === 403) {
|
||||||
|
httpClient.setToken(null);
|
||||||
|
localStorage.removeItem('knot_admin_auth');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const initAuth = async () => {
|
||||||
|
const savedHeader = localStorage.getItem('knot_admin_auth');
|
||||||
|
if (savedHeader) {
|
||||||
|
httpClient.setToken(savedHeader);
|
||||||
|
try {
|
||||||
|
await httpClient.request('/admin/dashboard');
|
||||||
|
setAuthenticated(true);
|
||||||
|
fetchDashboard();
|
||||||
|
fetchSettings();
|
||||||
|
fetchTimezones();
|
||||||
|
searchUsers('');
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('Session verify failed', err);
|
||||||
|
if (err.status === 401 || err.status === 403 || (err.message && err.message.includes('auth'))) {
|
||||||
|
localStorage.removeItem('knot_admin_auth');
|
||||||
|
httpClient.setToken(null);
|
||||||
|
setAuthenticated(false);
|
||||||
|
setAuthHeader('');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
initAuth();
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!authenticated || !authHeader) return;
|
if (!authenticated || !authHeader) return;
|
||||||
const interval = setInterval(() => {
|
const interval = setInterval(() => {
|
||||||
|
|||||||
@@ -18,7 +18,11 @@ interface AuthState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const useAuthStore = create<AuthState>((set, get) => ({
|
export const useAuthStore = create<AuthState>((set, get) => ({
|
||||||
token: localStorage.getItem('knot_token'),
|
token: (() => {
|
||||||
|
const t = localStorage.getItem('knot_token');
|
||||||
|
if (t) AuthApi.setToken(t);
|
||||||
|
return t;
|
||||||
|
})(),
|
||||||
user: null,
|
user: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
error: null,
|
error: null,
|
||||||
@@ -83,7 +87,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
try {
|
try {
|
||||||
AuthApi.setToken(token);
|
AuthApi.setToken(token);
|
||||||
const { user } = await AuthApi.getMe();
|
const { user, token: newToken } = await AuthApi.getMe();
|
||||||
|
if (newToken && newToken.length > 0) {
|
||||||
|
localStorage.setItem('knot_token', newToken);
|
||||||
|
AuthApi.setToken(newToken);
|
||||||
|
set({ token: newToken });
|
||||||
|
}
|
||||||
connectSocket(token);
|
connectSocket(token);
|
||||||
set({ user, isLoading: false });
|
set({ user, isLoading: false });
|
||||||
await get().fetchConfig();
|
await get().fetchConfig();
|
||||||
@@ -101,8 +110,23 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
console.warn('checkAuth failed:', lastError);
|
console.warn('checkAuth failed:', lastError);
|
||||||
localStorage.removeItem('knot_token');
|
// Explicitly check for 401 Unauthorized or 403 Forbidden
|
||||||
set({ token: null, user: null, isLoading: false });
|
const status = (lastError as any)?.status;
|
||||||
|
const errorMsg = lastError instanceof Error ? lastError.message : String(lastError);
|
||||||
|
|
||||||
|
if (
|
||||||
|
status === 401 ||
|
||||||
|
status === 403 ||
|
||||||
|
errorMsg.includes('auth') ||
|
||||||
|
errorMsg.includes('Недействительный токен') ||
|
||||||
|
errorMsg.includes('Требуется авторизация')
|
||||||
|
) {
|
||||||
|
localStorage.removeItem('knot_token');
|
||||||
|
set({ token: null, user: null, isLoading: false });
|
||||||
|
} else {
|
||||||
|
// Keep the token but stop loading if we're just offline/network error/500
|
||||||
|
set({ isLoading: false });
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
updateUser: (data) => {
|
updateUser: (data) => {
|
||||||
|
|||||||
@@ -37,7 +37,16 @@ export class AuthApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async getMe() {
|
static async getMe() {
|
||||||
return httpClient.request<{ user: User }>('/auth/me');
|
const response = await httpClient.request<{ userId: string; username: string; displayName: string; avatar: string | null; accessToken?: string }>('/auth/me');
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
id: response.userId,
|
||||||
|
username: response.username,
|
||||||
|
displayName: response.displayName,
|
||||||
|
avatar: response.avatar
|
||||||
|
} as User,
|
||||||
|
token: response.accessToken
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async getConfig() {
|
static async getConfig() {
|
||||||
|
|||||||
@@ -7,12 +7,13 @@ import { ChatApi } from '../infrastructure/chatApi';
|
|||||||
import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../../../core/utils/sounds';
|
import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../../../core/utils/sounds';
|
||||||
import { useLang } from '../../../core/infrastructure/i18n';
|
import { useLang } from '../../../core/infrastructure/i18n';
|
||||||
import type { Message, UserBasic, CallInfo, ChatMember } from '../../../core/domain/types';
|
import type { Message, UserBasic, CallInfo, ChatMember } from '../../../core/domain/types';
|
||||||
import { Send, Check, Phone, PhoneOff } from 'lucide-react';
|
import { Send, Check, Phone, PhoneOff, Users } from 'lucide-react';
|
||||||
import Sidebar from '../../../core/presentation/layouts/Sidebar';
|
import Sidebar from '../../../core/presentation/layouts/Sidebar';
|
||||||
import GlobalNavBar from '../../../core/presentation/layouts/GlobalNavBar';
|
import GlobalNavBar from '../../../core/presentation/layouts/GlobalNavBar';
|
||||||
import ChatView from './components/ChatView';
|
import ChatView from './components/ChatView';
|
||||||
import CallModal from '../../calls/presentation/components/CallModal';
|
import CallModal from '../../calls/presentation/components/CallModal';
|
||||||
import GroupCallModal from '../../calls/presentation/components/GroupCallModal';
|
import GroupCallModal from '../../calls/presentation/components/GroupCallModal';
|
||||||
|
import ContactsSidebar from '../../friends/presentation/components/ContactsSidebar';
|
||||||
|
|
||||||
export default function ChatPage() {
|
export default function ChatPage() {
|
||||||
const {
|
const {
|
||||||
@@ -57,6 +58,7 @@ export default function ChatPage() {
|
|||||||
const groupCallOpenRef = useRef(false);
|
const groupCallOpenRef = useRef(false);
|
||||||
const groupCallChatIdRef = useRef('');
|
const groupCallChatIdRef = useRef('');
|
||||||
|
|
||||||
|
const [activeTab, setActiveTab] = useState('chats');
|
||||||
const { t } = useLang();
|
const { t } = useLang();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -365,22 +367,58 @@ export default function ChatPage() {
|
|||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
className="h-screen w-screen flex bg-surface-dim overflow-hidden antialiased font-body selection:bg-primary/30"
|
className="h-screen w-screen flex bg-surface-dim overflow-hidden antialiased font-body selection:bg-primary/30"
|
||||||
>
|
>
|
||||||
<GlobalNavBar activeTab="chats" onTabChange={() => {}} />
|
<GlobalNavBar activeTab={activeTab} onTabChange={setActiveTab} />
|
||||||
|
|
||||||
<main className="ml-20 flex-1 flex flex-row relative h-full">
|
<main className="ml-20 flex-1 flex flex-row relative h-full">
|
||||||
{/* Chat List (Sidebar) */}
|
{activeTab === 'chats' ? (
|
||||||
<div
|
<>
|
||||||
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low tonal-transition-no-border h-full`}
|
{/* Chat List (Sidebar) */}
|
||||||
>
|
<div
|
||||||
<Sidebar />
|
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`}
|
||||||
</div>
|
>
|
||||||
|
<Sidebar />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Selected Chat View (Main Area) */}
|
{/* Selected Chat View (Main Area) */}
|
||||||
<div
|
<div
|
||||||
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest m-4 rounded-3xl overflow-hidden relative group slide-on-ice`}
|
className={`${activeChat ? 'block' : 'hidden lg:block'} flex-1 h-full min-w-0 bg-surface-container-lowest m-4 rounded-3xl overflow-hidden relative group slide-on-ice`}
|
||||||
>
|
>
|
||||||
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
|
) : activeTab === 'contacts' ? (
|
||||||
|
<div className="flex-1 flex flex-row h-full overflow-hidden">
|
||||||
|
{/* Contacts Sidebar List */}
|
||||||
|
<div className="w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden antialiased">
|
||||||
|
<ContactsSidebar onSwitchToChat={() => setActiveTab('chats')} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right side placeholder / Profile detail */}
|
||||||
|
<div className="hidden lg:flex flex-1 items-center justify-center bg-surface-base m-4 rounded-3xl overflow-hidden border border-white/5 relative slide-on-ice">
|
||||||
|
<div className="flex flex-col items-center gap-6 max-w-sm text-center">
|
||||||
|
<div className="w-24 h-24 rounded-3xl bg-primary/10 flex items-center justify-center text-primary shadow-inner">
|
||||||
|
<Users size={48} className="knot-logo-spin opacity-50" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white mb-2">{t('contacts')}</h2>
|
||||||
|
<p className="text-sm text-zinc-500 leading-relaxed max-w-[280px]">
|
||||||
|
{t('selectContactToChat')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('chats')}
|
||||||
|
className="px-8 py-3 rounded-2xl bg-primary text-on-primary shadow-lg shadow-primary/20 hover:scale-105 active:scale-95 transition-all text-sm font-bold tracking-tight"
|
||||||
|
>
|
||||||
|
{t('backToChats')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<p className="text-zinc-500">Coming soon</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
<CallModal
|
<CallModal
|
||||||
key={callSessionId}
|
key={callSessionId}
|
||||||
|
|||||||
@@ -193,30 +193,34 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
{ctxMenu && (
|
{ctxMenu && (
|
||||||
<div
|
<div
|
||||||
ref={ctxRef}
|
ref={ctxRef}
|
||||||
className="fixed z-[9999] min-w-[180px] py-1 rounded-xl bg-surface-secondary border border-border shadow-xl animate-in fade-in zoom-in-95 duration-100"
|
className="fixed z-[9999] min-w-[200px] py-1.5 rounded-[1.25rem] bg-[#1a1a1a] border border-white/10 shadow-[0_20px_50px_rgba(0,0,0,0.5)] animate-in fade-in zoom-in-95 duration-100"
|
||||||
style={{ top: ctxMenu.y, left: ctxMenu.x }}
|
style={{ top: ctxMenu.y, left: ctxMenu.x }}
|
||||||
>
|
>
|
||||||
<button
|
{!isFavorites && (
|
||||||
onClick={handlePin}
|
<>
|
||||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
<button
|
||||||
>
|
onClick={handlePin}
|
||||||
<Pin size={16} className={isPinned ? 'rotate-45' : ''} />
|
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||||
{isPinned ? t('unpinChat') : t('pinChat')}
|
>
|
||||||
</button>
|
<Pin size={16} className={isPinned ? 'rotate-45' : ''} />
|
||||||
<div className="border-t border-border my-1" />
|
{isPinned ? t('unpinChat') : t('pinChat')}
|
||||||
|
</button>
|
||||||
|
<div className="border-t border-border my-1" />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
{t('deleteChat')}
|
{isFavorites ? t('clearHistory') : t('deleteChat')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmModal
|
<ConfirmModal
|
||||||
open={showDeleteConfirm}
|
open={showDeleteConfirm}
|
||||||
message={t('deleteChatConfirm')}
|
message={isFavorites ? t('clearHistoryConfirm') : t('deleteChatConfirm')}
|
||||||
onConfirm={confirmDelete}
|
onConfirm={confirmDelete}
|
||||||
onCancel={() => setShowDeleteConfirm(false)}
|
onCancel={() => setShowDeleteConfirm(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -516,7 +516,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
<div className="relative" ref={deleteMenuRef}>
|
<div className="relative" ref={deleteMenuRef}>
|
||||||
<button
|
<button
|
||||||
disabled={selectedMessages.size === 0}
|
disabled={selectedMessages.size === 0}
|
||||||
onClick={() => setShowDeleteMenu(!showDeleteMenu)}
|
onClick={() => isFavorites ? handleBulkDelete(false) : setShowDeleteMenu(!showDeleteMenu)}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-red-500/90 text-white font-medium rounded-xl hover:bg-red-600 transition-colors disabled:opacity-50"
|
className="flex items-center gap-2 px-4 py-2 bg-red-500/90 text-white font-medium rounded-xl hover:bg-red-600 transition-colors disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Trash2 size={18} />
|
<Trash2 size={18} />
|
||||||
@@ -529,7 +529,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="absolute right-0 top-full mt-2 w-56 rounded-2xl bg-surface-secondary/95 backdrop-blur-2xl shadow-2xl z-50 py-1.5 ring-1 ring-border/50 overflow-hidden"
|
className="absolute right-0 top-full mt-2 w-56 rounded-2xl bg-[#1a1a1a] shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-50 py-1.5 ring-1 ring-white/10 overflow-hidden"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleBulkDelete(false)}
|
onClick={() => handleBulkDelete(false)}
|
||||||
@@ -690,7 +690,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
animate={{ opacity: 1, scale: 1, y: 0 }}
|
||||||
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
exit={{ opacity: 0, scale: 0.95, y: -5 }}
|
||||||
transition={{ duration: 0.15 }}
|
transition={{ duration: 0.15 }}
|
||||||
className="absolute right-0 top-full mt-2 w-56 rounded-2xl glass-strong shadow-2xl z-50 py-1.5 ring-1 ring-border/50 backdrop-blur-2xl"
|
className="absolute right-0 top-full mt-2 w-56 rounded-2xl bg-[#1a1a1a] shadow-[0_20px_50px_rgba(0,0,0,0.5)] z-50 py-1.5 ring-1 ring-white/10"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
onClick={openSearch}
|
onClick={openSearch}
|
||||||
@@ -699,7 +699,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
<Search size={16} />
|
<Search size={16} />
|
||||||
{t('searchMessages')}
|
{t('searchMessages')}
|
||||||
</button>
|
</button>
|
||||||
{chat.type === 'personal' && otherMember && (
|
{!isFavorites && chat.type === 'personal' && otherMember && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowTopMenu(false);
|
setShowTopMenu(false);
|
||||||
@@ -711,19 +711,21 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
{t('userProfile')}
|
{t('userProfile')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button
|
{!isFavorites && (
|
||||||
onClick={() => {
|
<button
|
||||||
if (activeChat) {
|
onClick={() => {
|
||||||
const nowMuted = toggleMuteChat(activeChat);
|
if (activeChat) {
|
||||||
setMuted(nowMuted);
|
const nowMuted = toggleMuteChat(activeChat);
|
||||||
}
|
setMuted(nowMuted);
|
||||||
}}
|
}
|
||||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
}}
|
||||||
>
|
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||||
{muted ? <Bell size={16} /> : <BellOff size={16} />}
|
>
|
||||||
{muted ? t('enableSound') : t('disableSound')}
|
{muted ? <Bell size={16} /> : <BellOff size={16} />}
|
||||||
</button>
|
{muted ? t('enableSound') : t('disableSound')}
|
||||||
{chat.type === 'group' && (
|
</button>
|
||||||
|
)}
|
||||||
|
{!isFavorites && chat.type === 'group' && (
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowTopMenu(false);
|
setShowTopMenu(false);
|
||||||
@@ -735,13 +737,13 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
{t('groupSettings')}
|
{t('groupSettings')}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<div className="border-t border-border my-1" />
|
<div className="border-t border-white/5 my-1" />
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowTopMenu(false);
|
setShowTopMenu(false);
|
||||||
if (activeChat) {
|
if (activeChat) {
|
||||||
setConfirmAction({
|
setConfirmAction({
|
||||||
message: t('clearChatConfirm'),
|
message: isFavorites ? t('clearHistoryConfirm') : t('clearChatConfirm'),
|
||||||
action: async () => {
|
action: async () => {
|
||||||
try {
|
try {
|
||||||
await ChatApi.clearChat(activeChat);
|
await ChatApi.clearChat(activeChat);
|
||||||
@@ -756,30 +758,32 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-zinc-300 hover:bg-surface-hover hover:text-white transition-colors"
|
||||||
>
|
>
|
||||||
<Eraser size={16} />
|
<Eraser size={16} />
|
||||||
{t('clearChat')}
|
{isFavorites ? t('clearHistory') : t('clearChat')}
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowTopMenu(false);
|
|
||||||
if (activeChat) {
|
|
||||||
setConfirmAction({
|
|
||||||
message: t('deleteChatConfirm'),
|
|
||||||
action: async () => {
|
|
||||||
try {
|
|
||||||
await ChatApi.deleteChat(activeChat);
|
|
||||||
useChatStore.getState().removeChat(activeChat);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
|
||||||
>
|
|
||||||
<Trash2 size={16} />
|
|
||||||
{t('deleteChat')}
|
|
||||||
</button>
|
</button>
|
||||||
|
{!isFavorites && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setShowTopMenu(false);
|
||||||
|
if (activeChat) {
|
||||||
|
setConfirmAction({
|
||||||
|
message: t('deleteChatConfirm'),
|
||||||
|
action: async () => {
|
||||||
|
try {
|
||||||
|
await ChatApi.deleteChat(activeChat);
|
||||||
|
useChatStore.getState().removeChat(activeChat);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
|
>
|
||||||
|
<Trash2 size={16} />
|
||||||
|
{t('deleteChat')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, Search } from 'lucide-react';
|
import { X, Search } from 'lucide-react';
|
||||||
@@ -32,14 +33,14 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
|
|||||||
return 0;
|
return 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return createPortal(
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-4">
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
exit={{ opacity: 0 }}
|
exit={{ opacity: 0 }}
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
className="absolute inset-0 bg-black/60 backdrop-blur-md"
|
||||||
/>
|
/>
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
initial={{ opacity: 0, scale: 0.95, y: 20 }}
|
||||||
@@ -48,31 +49,31 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
|
|||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
aria-label={t('forward')}
|
aria-label={t('forward')}
|
||||||
className="relative w-full max-w-md bg-surface-secondary/90 glass-strong rounded-3xl overflow-hidden shadow-2xl border border-border"
|
className="relative w-full max-w-md bg-[#1a1a1a] rounded-[2rem] overflow-hidden shadow-[0_20px_50px_rgba(0,0,0,0.5)] border border-white/10"
|
||||||
>
|
>
|
||||||
<div className="p-4 flex items-center justify-between border-b border-white/5">
|
<div className="p-5 flex items-center justify-between border-b border-white/5">
|
||||||
<h2 className="text-lg font-semibold text-white">{t('forwardMessage')}</h2>
|
<h2 className="text-xl font-bold font-headline text-white">{t('forwardMessage')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 transition-colors"
|
className="w-10 h-10 flex items-center justify-center rounded-xl hover:bg-white/10 transition-colors"
|
||||||
>
|
>
|
||||||
<X size={20} className="text-zinc-400" />
|
<X size={20} className="text-zinc-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-4">
|
<div className="p-5">
|
||||||
<div className="relative mb-4">
|
<div className="relative mb-5">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={18} />
|
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" size={18} />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder={t('searchChats') || 'Поиск чатов'}
|
placeholder={t('searchChats') || 'Поиск чатов'}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
className="w-full bg-black/20 border border-white/10 rounded-xl py-2.5 pl-10 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:border-knot-500 transition-colors"
|
className="w-full bg-black/40 border border-white/10 rounded-2xl py-3 pl-12 pr-4 text-white placeholder-zinc-500 focus:outline-none focus:ring-2 focus:ring-primary/20 transition-all"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="max-h-80 overflow-y-auto space-y-1 pr-2 custom-scrollbar">
|
<div className="max-h-96 overflow-y-auto space-y-1.5 pr-2 custom-scrollbar">
|
||||||
{filteredChats.map((chat) => {
|
{filteredChats.map((chat) => {
|
||||||
const otherMember = chat.members.find((m) => m.userId !== user?.id);
|
const otherMember = chat.members.find((m) => m.userId !== user?.id);
|
||||||
const chatName =
|
const chatName =
|
||||||
@@ -88,19 +89,33 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
|
|||||||
<button
|
<button
|
||||||
key={chat.id}
|
key={chat.id}
|
||||||
onClick={() => onForward(chat.id)}
|
onClick={() => onForward(chat.id)}
|
||||||
className="w-full flex items-center gap-3 p-2 rounded-xl hover:bg-white/5 transition-colors text-left"
|
className="w-full flex items-center gap-4 p-3 rounded-2xl hover:bg-white/5 transition-all text-left group"
|
||||||
>
|
>
|
||||||
<Avatar src={chatAvatar} name={chatName} size="md" />
|
<div className="relative group-hover:scale-105 transition-transform duration-300">
|
||||||
<span className="text-white font-medium flex-1 truncate">{chatName}</span>
|
{chat.type === 'favorites' ? (
|
||||||
|
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-primary to-primary-container flex items-center justify-center shadow-lg shadow-primary/10 border-2 border-outline-variant/10">
|
||||||
|
<span className="material-symbols-outlined text-on-primary-container text-[24px]">bookmark</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Avatar src={chatAvatar} name={chatName} size="lg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-white font-bold truncate group-hover:text-primary transition-colors">{chatName}</div>
|
||||||
|
<div className="text-[11px] text-zinc-500 font-bold uppercase tracking-widest mt-0.5">
|
||||||
|
{chat.type === 'favorites' ? t('favoritesDescription') : chat.type === 'personal' ? t('chat') : `${chat.members.length} ${t('members')}`}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{filteredChats.length === 0 && (
|
{filteredChats.length === 0 && (
|
||||||
<p className="text-center text-zinc-500 py-4 text-sm">{t('nothingFound')}</p>
|
<p className="text-center text-zinc-500 py-8 text-sm">{t('nothingFound')}</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>,
|
||||||
|
document.body
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,6 +59,9 @@ function MessageBubble({
|
|||||||
const [contextPos, setContextPos] = useState({ x: 0, y: 0 });
|
const [contextPos, setContextPos] = useState({ x: 0, y: 0 });
|
||||||
const [deleteMenuMode, setDeleteMenuMode] = useState(false);
|
const [deleteMenuMode, setDeleteMenuMode] = useState(false);
|
||||||
const [lightboxData, setLightboxData] = useState<{ index: number } | null>(null);
|
const [lightboxData, setLightboxData] = useState<{ index: number } | null>(null);
|
||||||
|
const activeChatId = useChatStore(s => s.activeChat);
|
||||||
|
const activeChat = useChatStore(s => s.chats.find(c => c.id === activeChatId));
|
||||||
|
const isFavorites = activeChat?.type === 'favorites';
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [audioProgress, setAudioProgress] = useState(0);
|
const [audioProgress, setAudioProgress] = useState(0);
|
||||||
const [audioDuration, setAudioDuration] = useState(0);
|
const [audioDuration, setAudioDuration] = useState(0);
|
||||||
@@ -862,7 +865,7 @@ function MessageBubble({
|
|||||||
initial={{ opacity: 0, scale: 0.9 }}
|
initial={{ opacity: 0, scale: 0.9 }}
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
animate={{ opacity: 1, scale: 1 }}
|
||||||
exit={{ opacity: 0, scale: 0.9 }}
|
exit={{ opacity: 0, scale: 0.9 }}
|
||||||
className="fixed z-[9999] w-52 rounded-[1.25rem] bg-[#1a1a1a]/95 backdrop-blur-2xl shadow-[0_20px_50px_rgba(0,0,0,0.5)] py-1.5 overflow-hidden border border-white/10"
|
className="fixed z-[9999] w-52 rounded-[1.25rem] bg-[#1a1a1a] shadow-[0_20px_50px_rgba(0,0,0,0.5)] py-1.5 overflow-hidden border border-white/10"
|
||||||
style={{ left: contextPos.x, top: contextPos.y }}
|
style={{ left: contextPos.x, top: contextPos.y }}
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onContextMenu={(e) => {
|
onContextMenu={(e) => {
|
||||||
@@ -972,7 +975,7 @@ function MessageBubble({
|
|||||||
|
|
||||||
<div className="border-t border-border my-1" />
|
<div className="border-t border-border my-1" />
|
||||||
<button
|
<button
|
||||||
onClick={() => setDeleteMenuMode(true)}
|
onClick={() => isFavorites ? handleDeleteForMe() : setDeleteMenuMode(true)}
|
||||||
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
className="flex items-center gap-3 w-full px-4 py-2.5 text-sm text-red-400 hover:bg-red-500/10 transition-colors"
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
|
import {
|
||||||
|
Search,
|
||||||
|
UserPlus,
|
||||||
|
UserCheck,
|
||||||
|
X,
|
||||||
|
UserMinus,
|
||||||
|
Loader2,
|
||||||
|
Users,
|
||||||
|
User as UserIcon,
|
||||||
|
MessageCircle,
|
||||||
|
ArrowRight
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useFriendStore } from '../../application/friendStore';
|
||||||
|
import { useAuthStore } from '../../../auth/application/authStore';
|
||||||
|
import { useChatStore } from '../../../../modules/chats/application/chatStore';
|
||||||
|
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||||
|
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||||||
|
import { ChatApi } from '../../../../modules/chats/infrastructure/chatApi';
|
||||||
|
|
||||||
|
interface ContactsSidebarProps {
|
||||||
|
onSwitchToChat: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ContactsSidebar({ onSwitchToChat }: ContactsSidebarProps) {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const { t } = useLang();
|
||||||
|
const {
|
||||||
|
friends,
|
||||||
|
friendRequests,
|
||||||
|
isLoading,
|
||||||
|
searchQuery,
|
||||||
|
searchResults,
|
||||||
|
isSearching,
|
||||||
|
setSearchQuery,
|
||||||
|
loadFriends,
|
||||||
|
acceptRequest,
|
||||||
|
declineRequest,
|
||||||
|
removeFriend,
|
||||||
|
sendRequest,
|
||||||
|
searchFriends,
|
||||||
|
clearSearch,
|
||||||
|
initializeSocketEvents
|
||||||
|
} = useFriendStore();
|
||||||
|
|
||||||
|
const { addChat, setActiveChat } = useChatStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadFriends();
|
||||||
|
const cleanup = initializeSocketEvents();
|
||||||
|
return cleanup;
|
||||||
|
}, [loadFriends, initializeSocketEvents]);
|
||||||
|
|
||||||
|
// Global search effect
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
if (searchQuery.trim().length >= 3) {
|
||||||
|
searchFriends(searchQuery, user?.id);
|
||||||
|
}
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [searchQuery, user?.id, searchFriends]);
|
||||||
|
|
||||||
|
const handleStartChat = async (friendId: string) => {
|
||||||
|
try {
|
||||||
|
// Logic to find or create DM
|
||||||
|
const allChats = await ChatApi.getChats();
|
||||||
|
let chat = allChats.find(c =>
|
||||||
|
c.type === 'personal' && c.members.some(m => m.user.id === friendId)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!chat) {
|
||||||
|
// Create new DM
|
||||||
|
chat = await ChatApi.createPersonalChat(friendId);
|
||||||
|
addChat(chat);
|
||||||
|
}
|
||||||
|
|
||||||
|
setActiveChat(chat.id);
|
||||||
|
onSwitchToChat();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start chat:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full h-full flex flex-col bg-surface-container-low overflow-hidden border-none relative z-10 slide-on-ice">
|
||||||
|
{/* Title */}
|
||||||
|
<div className="px-6 py-8 pb-4">
|
||||||
|
<h1 className="text-2xl font-bold font-headline text-white tracking-tight leading-none">{t('contacts')}</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Global Search */}
|
||||||
|
<div className="px-6 mb-6">
|
||||||
|
<div className="relative group">
|
||||||
|
<Search size={18} className="absolute left-4 top-1/2 -translate-y-1/2 text-on-surface-variant/40 group-focus-within:text-primary transition-colors" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('searchFriends')}
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full pl-12 pr-4 py-3 rounded-xl bg-surface-container-highest text-sm text-on-surface placeholder-on-surface-variant/30 border-none focus:ring-2 focus:ring-primary/20 hover:bg-surface-bright transition-all outline-none"
|
||||||
|
/>
|
||||||
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
onClick={() => { clearSearch(); setSearchQuery(''); }}
|
||||||
|
className="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-white"
|
||||||
|
>
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto px-4 custom-scrollbar space-y-6 pb-6">
|
||||||
|
{/* Search Results */}
|
||||||
|
{searchQuery.trim().length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="px-2 mb-3 text-[11px] font-bold text-zinc-500 uppercase tracking-[0.08em]">{t('searchResults')}</h3>
|
||||||
|
{isSearching ? (
|
||||||
|
<div className="flex items-center justify-center py-6">
|
||||||
|
<Loader2 size={24} className="animate-spin text-primary" />
|
||||||
|
</div>
|
||||||
|
) : searchResults.length > 0 ? (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{searchResults.map((u) => (
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 5 }}
|
||||||
|
animate={{ opacity: 1, y: 0 }}
|
||||||
|
key={u.id}
|
||||||
|
className="flex items-center gap-3 p-3 rounded-2xl bg-surface-container-high/40 border border-white/5 group"
|
||||||
|
>
|
||||||
|
<Avatar src={u.avatar} name={u.displayName || u.username || ''} size="md" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-bold text-zinc-100 truncate">{u.displayName || u.username || ''}</p>
|
||||||
|
<p className="text-[11px] text-zinc-500">@{u.username || ''}</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => sendRequest(u.id)}
|
||||||
|
className="p-2.5 rounded-xl bg-primary/10 text-primary hover:bg-primary/20 transition-all active:scale-90"
|
||||||
|
title={t('addFriend')}
|
||||||
|
>
|
||||||
|
<UserPlus size={18} />
|
||||||
|
</button>
|
||||||
|
</motion.div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-center py-6 text-zinc-600 text-xs">{t('noSearchResults')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Friend Requests */}
|
||||||
|
{friendRequests.length > 0 && !searchQuery && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between px-2 mb-3">
|
||||||
|
<h3 className="text-[11px] font-bold text-primary uppercase tracking-[0.08em]">{t('friendRequests')}</h3>
|
||||||
|
<span className="w-5 h-5 rounded-full bg-primary text-[10px] font-black text-on-primary flex items-center justify-center animate-pulse">
|
||||||
|
{friendRequests.filter(r => !r.isOutgoing).length}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{friendRequests.map((req) => (
|
||||||
|
<div key={req.id} className={`flex items-center gap-3 p-3 rounded-2xl border ${req.isOutgoing ? 'bg-surface-container-high/40 border-white/5 opacity-80' : 'bg-primary/5 border-primary/10'}`}>
|
||||||
|
<Avatar src={req.user.avatar} name={req.user.displayName || req.user.username || ''} size="sm" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<p className="text-xs font-bold text-zinc-100 truncate">{req.user.displayName || req.user.username || ''}</p>
|
||||||
|
{req.isOutgoing && <span className="text-[10px] px-1.5 py-0.5 rounded-full bg-zinc-800 text-zinc-500 font-medium lowercase">sent</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-zinc-500">@{req.user.username || ''}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5 text-shimmer">
|
||||||
|
{!req.isOutgoing && (
|
||||||
|
<button
|
||||||
|
onClick={() => acceptRequest(req.id)}
|
||||||
|
className="w-8 h-8 rounded-lg bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20 flex items-center justify-center transition-all"
|
||||||
|
title={t('accept') || 'Accept'}
|
||||||
|
>
|
||||||
|
<UserCheck size={14} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => declineRequest(req.id)}
|
||||||
|
className="w-8 h-8 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 flex items-center justify-center transition-all"
|
||||||
|
title={req.isOutgoing ? (t('cancel') || 'Cancel') : (t('decline') || 'Decline')}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Contacts List */}
|
||||||
|
{!searchQuery && (
|
||||||
|
<div>
|
||||||
|
<h3 className="px-2 mb-3 text-[11px] font-bold text-zinc-500 uppercase tracking-[0.08em]">{t('friendsList')}</h3>
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<Loader2 size={24} className="animate-spin text-zinc-700" />
|
||||||
|
</div>
|
||||||
|
) : friends.length === 0 ? (
|
||||||
|
<div className="flex flex-col items-center justify-center py-12 text-zinc-600 gap-4">
|
||||||
|
<Users size={48} className="opacity-10" />
|
||||||
|
<p className="text-xs max-w-[200px] text-center leading-relaxed">
|
||||||
|
{t('noFriends') || 'Your contact list is empty. Use search to find people.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1">
|
||||||
|
{friends.map((friend) => (
|
||||||
|
<div key={friend.id} className="group relative">
|
||||||
|
<button
|
||||||
|
onClick={() => handleStartChat(friend.id)}
|
||||||
|
className="w-full flex items-center gap-3 p-3 rounded-2xl hover:bg-surface-container-highest/40 transition-all active:scale-[0.98] group-hover:pr-12"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<Avatar src={friend.avatar} name={friend.displayName || friend.username || ''} size="md" />
|
||||||
|
{friend.isOnline && (
|
||||||
|
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 border-2 border-surface-container-low rounded-full shadow-lg" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 text-left">
|
||||||
|
<p className="text-sm font-bold text-zinc-100 truncate group-hover:text-primary transition-colors">
|
||||||
|
{friend.displayName || friend.username || ''}
|
||||||
|
</p>
|
||||||
|
<p className="text-[11px] text-zinc-500">
|
||||||
|
{friend.isOnline ? t('online') : (friend as any).status || `@${friend.username || ''}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<ArrowRight size={14} className="opacity-0 -translate-x-2 group-hover:opacity-40 group-hover:translate-x-0 transition-all text-zinc-400" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); removeFriend(friend.friendshipId); }}
|
||||||
|
className="absolute right-3 top-1/2 -translate-y-1/2 p-2 rounded-xl text-zinc-600 opacity-0 group-hover:opacity-100 hover:bg-red-500/10 hover:text-red-400 transition-all"
|
||||||
|
title={t('removeFriend')}
|
||||||
|
>
|
||||||
|
<UserMinus size={16} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user