diff --git a/backend/src/Contracts/Auth/Application/Abstractions/GetUsersExistence.cs b/backend/src/Contracts/Auth/Application/Abstractions/GetUsersExistence.cs new file mode 100644 index 0000000..48d81a6 --- /dev/null +++ b/backend/src/Contracts/Auth/Application/Abstractions/GetUsersExistence.cs @@ -0,0 +1,7 @@ +using Knot.Shared.Kernel; +using System; +using System.Collections.Generic; + +namespace Knot.Contracts.Auth.Application.Abstractions; + +public record GetUsersExistenceQuery(List UserIds) : IQuery>; diff --git a/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs b/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs index 33067ba..cf4a731 100644 --- a/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs +++ b/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs @@ -1,8 +1,9 @@ -namespace Knot.Contracts.Profiles.Application.DTOs; +namespace Knot.Contracts.Profiles.Application.DTOs; public class UserProfileDto { public Guid UserId { get; set; } + public Guid Id { get => UserId; set => UserId = value; } public string? DisplayName { get; set; } public string? Username { get; set; } public string? About { get; set; } diff --git a/backend/src/Contracts/Profiles/Domain/IProfileRepository.cs b/backend/src/Contracts/Profiles/Domain/IProfileRepository.cs index 08b40ad..7be77c2 100644 --- a/backend/src/Contracts/Profiles/Domain/IProfileRepository.cs +++ b/backend/src/Contracts/Profiles/Domain/IProfileRepository.cs @@ -1,4 +1,4 @@ -using Knot.Contracts.Profiles.Application.DTOs; +using Knot.Contracts.Profiles.Application.DTOs; using Knot.Shared.Kernel; namespace Knot.Contracts.Profiles.Domain; @@ -12,4 +12,5 @@ public interface IProfileRepository Task> CreateAsync(UserProfileDto dto, CancellationToken cancellationToken = default); Task> UpdateAsync(UserProfileDto dto, CancellationToken cancellationToken = default); Task DeleteAsync(Guid userId, CancellationToken cancellationToken = default); + Task UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default); } diff --git a/backend/src/Contracts/Relations/Application/Contacts/CheckBlockedStatus.cs b/backend/src/Contracts/Relations/Application/Contacts/CheckBlockedStatus.cs new file mode 100644 index 0000000..2f8ab90 --- /dev/null +++ b/backend/src/Contracts/Relations/Application/Contacts/CheckBlockedStatus.cs @@ -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 CandidateIds) : IQuery>; diff --git a/backend/src/Contracts/Relations/Application/Contacts/GetBlockedUserIds.cs b/backend/src/Contracts/Relations/Application/Contacts/GetBlockedUserIds.cs new file mode 100644 index 0000000..fc89e45 --- /dev/null +++ b/backend/src/Contracts/Relations/Application/Contacts/GetBlockedUserIds.cs @@ -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>; diff --git a/backend/src/Host/Program.cs b/backend/src/Host/Program.cs index 9188727..8cedf06 100644 --- a/backend/src/Host/Program.cs +++ b/backend/src/Host/Program.cs @@ -36,6 +36,7 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.IdentityModel.Tokens; +using MediatR; @@ -211,6 +212,10 @@ using (var scope = app.Services.CreateScope()) { concreteSettings.Initialize(); } + + // Sync user replicas for Relations module on startup + var mediator = scope.ServiceProvider.GetRequiredService(); + await mediator.Send(new Knot.Modules.Relations.Application.Contacts.SyncReplicasCommand()); } // Настройка конвейера запросов diff --git a/backend/src/Modules/Auth/Application/Users/GetUsersExistenceHandler.cs b/backend/src/Modules/Auth/Application/Users/GetUsersExistenceHandler.cs new file mode 100644 index 0000000..b3c4aed --- /dev/null +++ b/backend/src/Modules/Auth/Application/Users/GetUsersExistenceHandler.cs @@ -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> +{ + private readonly IAuthDbContext _context; + + public GetUsersExistenceQueryHandler(IAuthDbContext context) + { + _context = context; + } + + public async Task>> Handle(GetUsersExistenceQuery request, CancellationToken cancellationToken) + { + var existingIds = await _context.Set() + .Where(u => request.UserIds.Contains(u.Id)) + .Select(u => u.Id) + .ToListAsync(cancellationToken); + + return Result.Success(existingIds); + } +} diff --git a/backend/src/Modules/Auth/Domain/User.cs b/backend/src/Modules/Auth/Domain/User.cs index 97749fe..f45a174 100644 --- a/backend/src/Modules/Auth/Domain/User.cs +++ b/backend/src/Modules/Auth/Domain/User.cs @@ -28,8 +28,14 @@ public sealed class User : AggregateRoot public string? RefreshToken { get; private set; } public DateTime? BannedUntil { get; private set; } - public void Ban() => IsBanned = true; - public void Unban() => IsBanned = false; + public void Ban() { + 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) { diff --git a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs index 7de3736..bdc59b9 100644 --- a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs +++ b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs @@ -225,18 +225,31 @@ public sealed class ChatHub : Hub [HubMethodName("friend_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 }); } [HubMethodName("friend_accepted")] 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 }); } [HubMethodName("friend_removed")] 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 }); } @@ -246,15 +259,26 @@ public sealed class ChatHub : Hub 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)) { string[] ids; lock (connectionIds) { ids = connectionIds.ToArray(); } + _logger.LogDebug("Sending {Method} to user {TargetUserId} ({ConnectionCount} connections)", method, targetUserId, ids.Length); foreach (var connId in ids) { await Clients.Client(connId).SendAsync(method, payload); } } + else + { + _logger.LogDebug("User {TargetUserId} not online, skipping {Method} signal", targetUserId, method); + } } [HubMethodName("call_offer")] diff --git a/backend/src/Modules/Profiles/Application/Profiles/Integration/UserStatusChangedHandler.cs b/backend/src/Modules/Profiles/Application/Profiles/Integration/UserStatusChangedHandler.cs new file mode 100644 index 0000000..c34c853 --- /dev/null +++ b/backend/src/Modules/Profiles/Application/Profiles/Integration/UserStatusChangedHandler.cs @@ -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, + INotificationHandler +{ + 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); + } +} diff --git a/backend/src/Modules/Profiles/Application/Profiles/Search/SearchUsers.cs b/backend/src/Modules/Profiles/Application/Profiles/Search/SearchUsers.cs index ea7ca47..5492238 100644 --- a/backend/src/Modules/Profiles/Application/Profiles/Search/SearchUsers.cs +++ b/backend/src/Modules/Profiles/Application/Profiles/Search/SearchUsers.cs @@ -5,23 +5,69 @@ using System.Collections.Generic; using System.Linq; using System.Threading; 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; -public sealed record SearchProfilesQuery(string Query) : IQuery>; +public sealed record SearchProfilesQuery(string Query, Guid UserId) : IQuery>; internal sealed class SearchProfilesQueryHandler : IQueryHandler> { private readonly IProfileRepository _repository; + private readonly ISender _sender; - public SearchProfilesQueryHandler(IProfileRepository repository) + public SearchProfilesQueryHandler(IProfileRepository repository, ISender sender) { _repository = repository; + _sender = sender; } public async Task>> Handle(SearchProfilesQuery request, CancellationToken cancellationToken) { + // 1. Fetch candidates from MongoDB (which might have orphans) 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()); + + 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(); + 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()); + + // 4. Check for blocked users among valid candidates + var blockedIds = new List(); + 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); } } diff --git a/backend/src/Modules/Profiles/Domain/ProfileDocument.cs b/backend/src/Modules/Profiles/Domain/ProfileDocument.cs index 48b0138..17c2c55 100644 --- a/backend/src/Modules/Profiles/Domain/ProfileDocument.cs +++ b/backend/src/Modules/Profiles/Domain/ProfileDocument.cs @@ -25,6 +25,10 @@ public sealed class ProfileDocument public bool HideStoryViews { get; private set; } + public bool IsBanned { get; private set; } + + public bool IsDeleted { get; private set; } + public DateTime CreatedAt { get; private set; } #pragma warning disable CS8618 @@ -37,6 +41,8 @@ public sealed class ProfileDocument Username = username; DisplayName = displayName; Bio = bio; + IsBanned = false; + IsDeleted = false; CreatedAt = DateTime.UtcNow; } @@ -58,4 +64,10 @@ public sealed class ProfileDocument public void UpdateSettings(bool hideStoryViews) => HideStoryViews = hideStoryViews; + + public void UpdateStatus(bool isBanned, bool isDeleted) + { + IsBanned = isBanned; + IsDeleted = isDeleted; + } } diff --git a/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs b/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs index f9e90eb..2fd24c7 100644 --- a/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs +++ b/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs @@ -43,18 +43,25 @@ internal class ProfileRepository : IProfileRepository public async Task> SearchAsync(string query, int limit = 20, CancellationToken ct = default) { + var baseFilter = Builders.Filter.And( + Builders.Filter.Ne(p => p.IsBanned, true), + Builders.Filter.Ne(p => p.IsDeleted, true) + ); + List docs; if (string.IsNullOrWhiteSpace(query)) { - docs = await _profiles.Find(_ => true).Limit(limit).ToListAsync(ct); + docs = await _profiles.Find(baseFilter).Limit(limit).ToListAsync(ct); } else { - var filter = Builders.Filter.Or( + var searchFilter = Builders.Filter.Or( Builders.Filter.Regex(p => p.Username, new BsonRegularExpression(query, "i")), Builders.Filter.Regex(p => p.DisplayName, new BsonRegularExpression(query, "i")) ); - docs = await _profiles.Find(filter).Limit(limit).ToListAsync(ct); + + var combinedFilter = Builders.Filter.And(baseFilter, searchFilter); + docs = await _profiles.Find(combinedFilter).Limit(limit).ToListAsync(ct); } return docs.Select(p => p.ToDto()).ToList(); } @@ -82,6 +89,16 @@ internal class ProfileRepository : IProfileRepository return Result.Success(profile.ToDto()); } + public async Task 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 DeleteAsync(Guid userId, CancellationToken ct = default) { var result = await _profiles.DeleteOneAsync(p => p.Id == userId, ct); diff --git a/backend/src/Modules/Profiles/Knot.Modules.Profiles.csproj b/backend/src/Modules/Profiles/Knot.Modules.Profiles.csproj index 99be9c4..7f93526 100644 --- a/backend/src/Modules/Profiles/Knot.Modules.Profiles.csproj +++ b/backend/src/Modules/Profiles/Knot.Modules.Profiles.csproj @@ -10,6 +10,8 @@ + + diff --git a/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs b/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs index 86483f7..21568bd 100644 --- a/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs +++ b/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs @@ -18,9 +18,9 @@ public static class ProfilesEndpoints { 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); }); diff --git a/backend/src/Modules/Relations/Application/Contacts/CheckBlockedStatus.cs b/backend/src/Modules/Relations/Application/Contacts/CheckBlockedStatus.cs new file mode 100644 index 0000000..c7a57db --- /dev/null +++ b/backend/src/Modules/Relations/Application/Contacts/CheckBlockedStatus.cs @@ -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> +{ + private readonly IContactsDbContext _context; + + public CheckBlockedStatusQueryHandler(IContactsDbContext context) + { + _context = context; + } + + public async Task>> 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); + } +} diff --git a/backend/src/Modules/Relations/Application/Contacts/DeclineContactRequest.cs b/backend/src/Modules/Relations/Application/Contacts/DeclineContactRequest.cs index f73298a..2935ea8 100644 --- a/backend/src/Modules/Relations/Application/Contacts/DeclineContactRequest.cs +++ b/backend/src/Modules/Relations/Application/Contacts/DeclineContactRequest.cs @@ -22,9 +22,11 @@ internal sealed class DeclineContactRequestCommandHandler : ICommandHandler> Handle(DeclineContactRequestCommand request, CancellationToken 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(new Error("Contacts.NotFound", "Contact request not found.")); + return Result.Failure(new Error("Contacts.NotFound", "Contact request not found or you don't have permission.")); } contact.Decline(); diff --git a/backend/src/Modules/Relations/Application/Contacts/GetBlockedUserIds.cs b/backend/src/Modules/Relations/Application/Contacts/GetBlockedUserIds.cs new file mode 100644 index 0000000..8ac92b5 --- /dev/null +++ b/backend/src/Modules/Relations/Application/Contacts/GetBlockedUserIds.cs @@ -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> +{ + private readonly IContactsDbContext _context; + + public GetBlockedUserIdsQueryHandler(IContactsDbContext context) + { + _context = context; + } + + public async Task>> 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); + } +} diff --git a/backend/src/Modules/Relations/Application/Contacts/GetContactRequests.cs b/backend/src/Modules/Relations/Application/Contacts/GetContactRequests.cs new file mode 100644 index 0000000..585ab38 --- /dev/null +++ b/backend/src/Modules/Relations/Application/Contacts/GetContactRequests.cs @@ -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>; + +internal sealed class GetContactRequestsQueryHandler : IQueryHandler> +{ + private readonly IContactsDbContext _context; + + public GetContactRequestsQueryHandler(IContactsDbContext context) + { + _context = context; + } + + public async Task>> 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); + } +} diff --git a/backend/src/Modules/Relations/Application/Contacts/GetContacts.cs b/backend/src/Modules/Relations/Application/Contacts/GetContacts.cs index 43c627a..c1ad6b4 100644 --- a/backend/src/Modules/Relations/Application/Contacts/GetContacts.cs +++ b/backend/src/Modules/Relations/Application/Contacts/GetContacts.cs @@ -23,34 +23,57 @@ internal sealed class GetContactsQueryHandler : IQueryHandler>> Handle(GetContactsQuery request, CancellationToken cancellationToken) { + // 1. Fetch ALL accepted relations for current user var relations = await _context.Contacts .Where(c => (c.UserId == request.UserId || c.ContactId == request.UserId) && c.Status == ContactStatus.Accepted) .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 .Where(r => contactIds.Contains(r.Id)) - .ToListAsync(cancellationToken); + .ToDictionaryAsync(r => r.Id, cancellationToken); var result = new List(); - 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( - replica.Id, - replica.Username, - replica.DisplayName, - replica.Avatar, - false, - null, - rel.Id, - rel.Status == ContactStatus.Blocked, - replica.IsExternal, - replica.Domain - )); + if (replicas.TryGetValue(otherUserId, out var replica)) + { + result.Add(new ContactDto( + replica.Id, + replica.Username, + replica.DisplayName, + replica.Avatar, + false, // isOnline - current user query doesn't handle this here + null, // lastSeen + rel.Id, + 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); diff --git a/backend/src/Modules/Relations/Application/Contacts/GetIncomingRequests.cs b/backend/src/Modules/Relations/Application/Contacts/GetIncomingRequests.cs deleted file mode 100644 index e742d39..0000000 --- a/backend/src/Modules/Relations/Application/Contacts/GetIncomingRequests.cs +++ /dev/null @@ -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>; - -internal sealed class GetIncomingRequestsQueryHandler : IQueryHandler> -{ - private readonly IContactsDbContext _context; - - public GetIncomingRequestsQueryHandler(IContactsDbContext context) - { - _context = context; - } - - public async Task>> 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); - } -} diff --git a/backend/src/Modules/Relations/Application/Contacts/Integration/UserRegisteredHandler.cs b/backend/src/Modules/Relations/Application/Contacts/Integration/UserRegisteredHandler.cs new file mode 100644 index 0000000..963ade5 --- /dev/null +++ b/backend/src/Modules/Relations/Application/Contacts/Integration/UserRegisteredHandler.cs @@ -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 +{ + 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); + } +} diff --git a/backend/src/Modules/Relations/Application/Contacts/SendContactRequest.cs b/backend/src/Modules/Relations/Application/Contacts/SendContactRequest.cs index f2add88..2f28b65 100644 --- a/backend/src/Modules/Relations/Application/Contacts/SendContactRequest.cs +++ b/backend/src/Modules/Relations/Application/Contacts/SendContactRequest.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using System.Threading; using System.Threading.Tasks; using Knot.Modules.Relations.Domain; @@ -13,10 +14,12 @@ public record SendContactRequestCommand(Guid UserId, Guid ContactId) : ICommand; internal sealed class SendContactRequestCommandHandler : ICommandHandler { 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; + _authContext = authContext; } public async Task Handle(SendContactRequestCommand request, CancellationToken cancellationToken) @@ -26,6 +29,10 @@ internal sealed class SendContactRequestCommandHandler : ICommandHandler (f.UserId == request.UserId && f.ContactId == request.ContactId) || (f.UserId == request.ContactId && f.ContactId == request.UserId), cancellationToken); @@ -41,4 +48,25 @@ internal sealed class SendContactRequestCommandHandler : ICommandHandler 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); + } } diff --git a/backend/src/Modules/Relations/Application/Contacts/SyncReplicas.cs b/backend/src/Modules/Relations/Application/Contacts/SyncReplicas.cs new file mode 100644 index 0000000..cac3b78 --- /dev/null +++ b/backend/src/Modules/Relations/Application/Contacts/SyncReplicas.cs @@ -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 +{ + 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 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(); + } +} diff --git a/backend/src/Modules/Relations/DependencyInjection.cs b/backend/src/Modules/Relations/DependencyInjection.cs index 86feb2b..3d7d8ae 100644 --- a/backend/src/Modules/Relations/DependencyInjection.cs +++ b/backend/src/Modules/Relations/DependencyInjection.cs @@ -15,6 +15,7 @@ public static class DependencyInjection services.AddDbContext(options => options.UseNpgsql(connectionString)); + services.AddScoped(sp => sp.GetRequiredService()); services.AddScoped(); services.AddMediatR(config => diff --git a/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs b/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs index 1b7196d..4e78bd3 100644 --- a/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs +++ b/backend/src/Modules/Relations/Infrastructure/Persistence/RelationsDbContext.cs @@ -7,10 +7,11 @@ using Knot.Shared.Kernel; using MediatR; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Diagnostics; +using Knot.Modules.Relations.Application.Abstractions; namespace Knot.Modules.Relations.Infrastructure.Persistence; -public sealed class RelationsDbContext : DbContext +public sealed class RelationsDbContext : DbContext, IContactsDbContext { private readonly IMediator _mediator; diff --git a/backend/src/Modules/Relations/Knot.Modules.Relations.csproj b/backend/src/Modules/Relations/Knot.Modules.Relations.csproj index f985fdc..39f42a2 100644 --- a/backend/src/Modules/Relations/Knot.Modules.Relations.csproj +++ b/backend/src/Modules/Relations/Knot.Modules.Relations.csproj @@ -8,6 +8,7 @@ + diff --git a/backend/src/Modules/Relations/Presentation/Endpoints/ContactsEndpoints.cs b/backend/src/Modules/Relations/Presentation/Endpoints/ContactsEndpoints.cs index 2956312..51c8f16 100644 --- a/backend/src/Modules/Relations/Presentation/Endpoints/ContactsEndpoints.cs +++ b/backend/src/Modules/Relations/Presentation/Endpoints/ContactsEndpoints.cs @@ -25,7 +25,7 @@ public static class ContactsEndpoints 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); }); @@ -86,6 +86,7 @@ public static class ContactsEndpoints 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) => { var result = await sender.Send(new RemoveContactCommand(userContext.UserId, id), ct); diff --git a/backend/src/Shared/Knot.Shared.Kernel/Events/IdentityEvents.cs b/backend/src/Shared/Knot.Shared.Kernel/Events/IdentityEvents.cs index 5f0b6d6..51e93d2 100644 --- a/backend/src/Shared/Knot.Shared.Kernel/Events/IdentityEvents.cs +++ b/backend/src/Shared/Knot.Shared.Kernel/Events/IdentityEvents.cs @@ -13,3 +13,7 @@ public sealed record UserRegisteredDomainEvent( string Username, string DisplayName, string? Bio) : IDomainEvent; + +public sealed record UserBannedDomainEvent(Guid UserId, bool IsBanned) : IDomainEvent; + +public sealed record UserDeletedDomainEvent(Guid UserId) : IDomainEvent; diff --git a/client-web/src/core/domain/types.ts b/client-web/src/core/domain/types.ts index 8708666..3bf51bc 100644 --- a/client-web/src/core/domain/types.ts +++ b/client-web/src/core/domain/types.ts @@ -178,6 +178,7 @@ export interface FriendRequest { id: string; user: User; createdAt: string; + isOutgoing?: boolean; } export interface FriendWithId extends UserPresence { diff --git a/client-web/src/core/infrastructure/httpClient.ts b/client-web/src/core/infrastructure/httpClient.ts index 5488759..1cdefd9 100644 --- a/client-web/src/core/infrastructure/httpClient.ts +++ b/client-web/src/core/infrastructure/httpClient.ts @@ -43,8 +43,10 @@ export class HttpClient { if (!response.ok) { const errorData = await response.json().catch(() => ({})); - const errorMessage = errorData.error || errorData.message || 'Ошибка запроса'; - throw new Error(errorMessage); + const errorMessage = errorData.error || errorData.message || `Request failed with status ${response.status}`; + const err = new Error(errorMessage); + (err as any).status = response.status; + throw err; } // Handle empty responses (e.g., 200 OK with no body) diff --git a/client-web/src/core/infrastructure/i18n.ts b/client-web/src/core/infrastructure/i18n.ts index 6a2bc7f..2ffec33 100644 --- a/client-web/src/core/infrastructure/i18n.ts +++ b/client-web/src/core/infrastructure/i18n.ts @@ -136,6 +136,8 @@ const translations = { stories: 'Истории', clearChat: 'Очистить чат', clearChatConfirm: 'Очистить историю чата для себя? Собеседник сохранит свою историю.', + clearHistory: 'Очистить историю', + clearHistoryConfirm: 'Очистить историю?', deleteChatConfirm: 'Удалить чат? Это действие нельзя отменить.', pinChat: 'Закрепить чат', unpinChat: 'Открепить чат', @@ -257,8 +259,11 @@ const translations = { removeFriend: 'Удалить из контактов', requestSent: 'Заявка отправлена', searchFriends: 'Поиск по @username (мин. 3 символа)', + searchResults: 'Результаты поиска', noSearchResults: 'Пользователи не найдены', minCharsHint: 'Введите минимум 3 символа после @', + selectContactToChat: 'Выберите контакт из списка, чтобы начать общение, или воспользуйтесь поиском для поиска новых людей.', + backToChats: 'Назад к чатам', // Story viewers storyViewers: 'Кто просмотрел', noViewers: 'Пока никто не посмотрел', @@ -412,6 +417,8 @@ const translations = { stories: 'Stories', clearChat: 'Clear chat', 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.', pinChat: 'Pin chat', unpinChat: 'Unpin chat', @@ -522,8 +529,11 @@ const translations = { removeFriend: 'Remove from contacts', requestSent: 'Request sent', searchFriends: 'Search by @username (min. 3 chars)', + searchResults: 'Search Results', noSearchResults: 'No users found', 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', noViewers: 'No one viewed yet', replyToStory: 'Reply to story...', diff --git a/client-web/src/core/presentation/layouts/GlobalNavBar.tsx b/client-web/src/core/presentation/layouts/GlobalNavBar.tsx index df81ea6..a679437 100644 --- a/client-web/src/core/presentation/layouts/GlobalNavBar.tsx +++ b/client-web/src/core/presentation/layouts/GlobalNavBar.tsx @@ -12,9 +12,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro const menuItems = [ { id: 'chats', icon: 'chat', label: t('chats') }, - { id: 'calls', icon: 'call', label: t('calls') }, { id: 'contacts', icon: 'contacts', label: t('contacts') }, - { id: 'archive', icon: 'archive', label: t('archive') }, { id: 'settings', icon: 'settings', label: t('settings') }, ]; diff --git a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx index a3472d0..24f1d48 100644 --- a/client-web/src/modules/admin/presentation/pages/AdminPage.tsx +++ b/client-web/src/modules/admin/presentation/pages/AdminPage.tsx @@ -524,7 +524,7 @@ export default function AdminPage() { const [stats, setStats] = useState(null); const [config, setConfig] = useState(null); const [cleanStats, setCleanStats] = useState(null); - const [authHeader, setAuthHeader] = useState(''); + const [authHeader, setAuthHeader] = useState(localStorage.getItem('knot_admin_auth') || ''); const [creds, setCreds] = useState({ user: '', pass: '' }); const [authenticated, setAuthenticated] = useState(false); @@ -818,17 +818,47 @@ export default function AdminPage() { try { await httpClient.request('/admin/dashboard'); setAuthHeader(header); + localStorage.setItem('knot_admin_auth', header); setAuthenticated(true); fetchDashboard(); fetchSettings(); fetchTimezones(); searchUsers(''); - } catch { + } catch (err: any) { 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(() => { if (!authenticated || !authHeader) return; const interval = setInterval(() => { diff --git a/client-web/src/modules/auth/application/authStore.ts b/client-web/src/modules/auth/application/authStore.ts index 58da00a..6889797 100644 --- a/client-web/src/modules/auth/application/authStore.ts +++ b/client-web/src/modules/auth/application/authStore.ts @@ -18,7 +18,11 @@ interface AuthState { } export const useAuthStore = create((set, get) => ({ - token: localStorage.getItem('knot_token'), + token: (() => { + const t = localStorage.getItem('knot_token'); + if (t) AuthApi.setToken(t); + return t; + })(), user: null, isLoading: true, error: null, @@ -83,7 +87,12 @@ export const useAuthStore = create((set, get) => ({ for (let attempt = 0; attempt < 3; attempt++) { try { 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); set({ user, isLoading: false }); await get().fetchConfig(); @@ -101,8 +110,23 @@ export const useAuthStore = create((set, get) => ({ } } console.warn('checkAuth failed:', lastError); - localStorage.removeItem('knot_token'); - set({ token: null, user: null, isLoading: false }); + // Explicitly check for 401 Unauthorized or 403 Forbidden + 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) => { diff --git a/client-web/src/modules/auth/infrastructure/authApi.ts b/client-web/src/modules/auth/infrastructure/authApi.ts index fc1ae46..babc2e9 100644 --- a/client-web/src/modules/auth/infrastructure/authApi.ts +++ b/client-web/src/modules/auth/infrastructure/authApi.ts @@ -37,7 +37,16 @@ export class AuthApi { } 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() { diff --git a/client-web/src/modules/chats/presentation/ChatPage.tsx b/client-web/src/modules/chats/presentation/ChatPage.tsx index a64dcda..95aea6f 100644 --- a/client-web/src/modules/chats/presentation/ChatPage.tsx +++ b/client-web/src/modules/chats/presentation/ChatPage.tsx @@ -7,12 +7,13 @@ import { ChatApi } from '../infrastructure/chatApi'; import { playNotificationSound, isChatMuted, playCallRingtone, stopCallRingtone } from '../../../core/utils/sounds'; import { useLang } from '../../../core/infrastructure/i18n'; 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 GlobalNavBar from '../../../core/presentation/layouts/GlobalNavBar'; import ChatView from './components/ChatView'; import CallModal from '../../calls/presentation/components/CallModal'; import GroupCallModal from '../../calls/presentation/components/GroupCallModal'; +import ContactsSidebar from '../../friends/presentation/components/ContactsSidebar'; export default function ChatPage() { const { @@ -57,6 +58,7 @@ export default function ChatPage() { const groupCallOpenRef = useRef(false); const groupCallChatIdRef = useRef(''); + const [activeTab, setActiveTab] = useState('chats'); const { t } = useLang(); useEffect(() => { @@ -365,22 +367,58 @@ export default function ChatPage() { exit={{ opacity: 0 }} className="h-screen w-screen flex bg-surface-dim overflow-hidden antialiased font-body selection:bg-primary/30" > - {}} /> +
- {/* Chat List (Sidebar) */} -
- -
+ {activeTab === 'chats' ? ( + <> + {/* Chat List (Sidebar) */} +
+ +
- {/* Selected Chat View (Main Area) */} -
- -
+ {/* Selected Chat View (Main Area) */} +
+ +
+ + ) : activeTab === 'contacts' ? ( +
+ {/* Contacts Sidebar List */} +
+ setActiveTab('chats')} /> +
+ + {/* Right side placeholder / Profile detail */} +
+
+
+ +
+
+

{t('contacts')}

+

+ {t('selectContactToChat')} +

+
+ +
+
+
+ ) : ( +
+

Coming soon

+
+ )}
- -
+ {!isFavorites && ( + <> + +
+ + )}
)} setShowDeleteConfirm(false)} /> diff --git a/client-web/src/modules/chats/presentation/components/ChatView.tsx b/client-web/src/modules/chats/presentation/components/ChatView.tsx index 1266526..b940705 100644 --- a/client-web/src/modules/chats/presentation/components/ChatView.tsx +++ b/client-web/src/modules/chats/presentation/components/ChatView.tsx @@ -516,7 +516,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
- {chat.type === 'personal' && otherMember && ( + {!isFavorites && chat.type === 'personal' && otherMember && ( )} - - {chat.type === 'group' && ( + {!isFavorites && ( + + )} + {!isFavorites && chat.type === 'group' && ( )} -
+
- + {!isFavorites && ( + + )} )} diff --git a/client-web/src/modules/chats/presentation/components/ForwardModal.tsx b/client-web/src/modules/chats/presentation/components/ForwardModal.tsx index e9aeb2d..a59e9eb 100644 --- a/client-web/src/modules/chats/presentation/components/ForwardModal.tsx +++ b/client-web/src/modules/chats/presentation/components/ForwardModal.tsx @@ -1,3 +1,4 @@ +import { createPortal } from 'react-dom'; import { useState } from 'react'; import { motion, AnimatePresence } from 'framer-motion'; import { X, Search } from 'lucide-react'; @@ -32,14 +33,14 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps) return 0; }); - return ( -
+ return createPortal( +
-
-

{t('forwardMessage')}

+
+

{t('forwardMessage')}

-
-
- +
+
+ 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" />
-
+
{filteredChats.map((chat) => { const otherMember = chat.members.find((m) => m.userId !== user?.id); const chatName = @@ -88,19 +89,33 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps) ); })} {filteredChats.length === 0 && ( -

{t('nothingFound')}

+

{t('nothingFound')}

)}
-
+
, + document.body ); } diff --git a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx index 9c3b30f..efab3be 100644 --- a/client-web/src/modules/chats/presentation/components/MessageBubble.tsx +++ b/client-web/src/modules/chats/presentation/components/MessageBubble.tsx @@ -59,6 +59,9 @@ function MessageBubble({ const [contextPos, setContextPos] = useState({ x: 0, y: 0 }); const [deleteMenuMode, setDeleteMenuMode] = useState(false); 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 [audioProgress, setAudioProgress] = useState(0); const [audioDuration, setAudioDuration] = useState(0); @@ -862,7 +865,7 @@ function MessageBubble({ initial={{ opacity: 0, scale: 0.9 }} animate={{ opacity: 1, scale: 1 }} 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 }} onClick={(e) => e.stopPropagation()} onContextMenu={(e) => { @@ -972,7 +975,7 @@ function MessageBubble({
+ )} +
+
+ +
+ {/* Search Results */} + {searchQuery.trim().length > 0 && ( +
+

{t('searchResults')}

+ {isSearching ? ( +
+ +
+ ) : searchResults.length > 0 ? ( +
+ {searchResults.map((u) => ( + + +
+

{u.displayName || u.username || ''}

+

@{u.username || ''}

+
+ +
+ ))} +
+ ) : ( +

{t('noSearchResults')}

+ )} +
+ )} + + {/* Friend Requests */} + {friendRequests.length > 0 && !searchQuery && ( +
+
+

{t('friendRequests')}

+ + {friendRequests.filter(r => !r.isOutgoing).length} + +
+
+ {friendRequests.map((req) => ( +
+ +
+
+

{req.user.displayName || req.user.username || ''}

+ {req.isOutgoing && sent} +
+

@{req.user.username || ''}

+
+
+ {!req.isOutgoing && ( + + )} + +
+
+ ))} +
+
+ )} + + {/* Contacts List */} + {!searchQuery && ( +
+

{t('friendsList')}

+ {isLoading ? ( +
+ +
+ ) : friends.length === 0 ? ( +
+ +

+ {t('noFriends') || 'Your contact list is empty. Use search to find people.'} +

+
+ ) : ( +
+ {friends.map((friend) => ( +
+ + + +
+ ))} +
+ )} +
+ )} +
+
+ ); +}