2 Commits
63 changed files with 2381 additions and 1711 deletions
@@ -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 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; }
@@ -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<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken cancellationToken = default);
Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, 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>>;
+5
View File
@@ -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<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);
}
}
+8 -2
View File
@@ -28,8 +28,14 @@ public sealed class User : AggregateRoot<Guid>
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)
{
@@ -95,7 +95,7 @@ internal sealed class GetSharedMediaQueryHandler : IQueryHandler<GetSharedMediaQ
if (filterType == "media")
{
return mediaType == "image" || mediaType == "video";
return (mediaType == "image" || mediaType == "video") && !isGif;
}
return true;
@@ -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")]
@@ -0,0 +1,71 @@
using Knot.Shared.Kernel;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Shared.Kernel.Constants;
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
namespace Knot.Modules.Klipy.Application.Klipy.Commands;
public record MarkGifSharedCommand(string GifId, string Query = "") : ICommand;
internal sealed class MarkGifSharedCommandHandler : ICommandHandler<MarkGifSharedCommand>
{
private readonly IKlipySettings _settings;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IUserContext _userContext;
public MarkGifSharedCommandHandler(IKlipySettings settings, IHttpClientFactory httpClientFactory, IUserContext userContext)
{
_settings = settings;
_httpClientFactory = httpClientFactory;
_userContext = userContext;
}
public async Task<Result> Handle(MarkGifSharedCommand request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.Enabled || string.IsNullOrEmpty(conf.ApiKey))
return Result.Failure(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
var customerId = _userContext.IsAuthenticated ? _userContext.UserId.ToString().ToLowerInvariant() : Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId;
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Add("Accept", "application/json");
// Correct URL according to docs: POST https://api.klipy.com/api/v1/{app_key}/gifs/share/{gif_id}
var baseUrl = "https://api.klipy.com/api/v1";
var url = $"{baseUrl}/{conf.ApiKey}/gifs/share/{request.GifId}";
// According to docs, q and customer_id must be in the JSON body
var body = new
{
customer_id = customerId,
q = request.Query ?? "" // Must be empty string if not from search
};
using var content = JsonContent.Create(body);
var response = await client.PostAsync(url, content, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCo = url.Replace("api.klipy.com", "api.klipy.co");
using var contentCo = JsonContent.Create(body);
response = await client.PostAsync(urlCo, contentCo, cancellationToken);
}
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
return Result.Failure(new Error(Errors.KlipyApiError, $"Klipy Share API Error: {response.StatusCode} {errorBody}"));
}
}
return Result.Success();
}
}
@@ -0,0 +1,72 @@
using Knot.Shared.Kernel;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Shared.Kernel.Constants;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
namespace Knot.Modules.Klipy.Application.Klipy.Queries;
public record GetGifCategoriesQuery(string CountryCode = "RU") : IQuery<JsonElement?>;
internal sealed class GetGifCategoriesQueryHandler : IQueryHandler<GetGifCategoriesQuery, JsonElement?>
{
private readonly IKlipySettings _settings;
private readonly IMemoryCache _cache;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IUserContext _userContext;
public GetGifCategoriesQueryHandler(IKlipySettings settings, IMemoryCache cache, IHttpClientFactory httpClientFactory, IUserContext userContext)
{
_settings = settings;
_cache = cache;
_httpClientFactory = httpClientFactory;
_userContext = userContext;
}
public async Task<Result<JsonElement?>> Handle(GetGifCategoriesQuery request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.Enabled || string.IsNullOrEmpty(conf.ApiKey))
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
var locale = string.IsNullOrEmpty(request.CountryCode) ? "ru_RU" : (request.CountryCode.Length == 2 ? $"{request.CountryCode.ToLowerInvariant()}_{request.CountryCode.ToUpperInvariant()}" : request.CountryCode);
var customerId = _userContext.IsAuthenticated ? _userContext.UserId.ToString().ToLowerInvariant() : Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId;
var cacheKeyCat = $"klipy_categories_{conf.ApiKey}_{locale}";
if (_cache.TryGetValue(cacheKeyCat, out JsonElement cachedResult))
return Result.Success<JsonElement?>(cachedResult);
var client = _httpClientFactory.CreateClient();
// Correct URL according to docs: GET https://api.klipy.com/api/v1/{app_key}/gifs/categories?locale={locale}&customer_id={customer_id}
var baseUrl = "https://api.klipy.com/api/v1";
var url = $"{baseUrl}/{conf.ApiKey}/gifs/categories?locale={locale}&customer_id={customerId}";
var response = await client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCo = url.Replace("api.klipy.com", "api.klipy.co");
response = await client.GetAsync(urlCo, cancellationToken);
}
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
return Result.Failure<JsonElement?>(new Error(Errors.KlipyApiError, $"Klipy Categories API Error: {response.StatusCode} {errorBody}"));
}
}
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: cancellationToken);
_cache.Set(cacheKeyCat, result, TimeSpan.FromMinutes(Knot.Shared.Kernel.Constants.Klipy.CategoriesCacheMinutes));
return Result.Success<JsonElement?>(result);
}
}
@@ -0,0 +1,70 @@
using Knot.Shared.Kernel;
using Knot.Contracts.Settings.Application.Abstractions;
using Knot.Shared.Kernel.Constants;
using Microsoft.Extensions.Caching.Memory;
using System;
using System.Net.Http;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using MediatR;
namespace Knot.Modules.Klipy.Application.Klipy.Queries;
public record GetRecentGifsQuery(int Page = 1) : IQuery<JsonElement?>;
internal sealed class GetRecentGifsQueryHandler : IQueryHandler<GetRecentGifsQuery, JsonElement?>
{
private readonly IKlipySettings _settings;
private readonly IMemoryCache _cache;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IUserContext _userContext;
public GetRecentGifsQueryHandler(IKlipySettings settings, IMemoryCache cache, IHttpClientFactory httpClientFactory, IUserContext userContext)
{
_settings = settings;
_cache = cache;
_httpClientFactory = httpClientFactory;
_userContext = userContext;
}
public async Task<Result<JsonElement?>> Handle(GetRecentGifsQuery request, CancellationToken cancellationToken)
{
var conf = _settings.Current;
if (!conf.Enabled || string.IsNullOrEmpty(conf.ApiKey))
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
if (!_userContext.IsAuthenticated)
return Result.Failure<JsonElement?>(new Error("AuthError", "User not authenticated"));
var customerId = _userContext.UserId.ToString().ToLowerInvariant();
// Disable cache for history to ensure it updates immediately
var client = _httpClientFactory.CreateClient();
// Correct URL according to docs: GET https://api.klipy.com/api/v1/{app_key}/gifs/recent/{customer_id}?page={page}&per_page={per_page}
var baseUrl = "https://api.klipy.com/api/v1";
var url = $"{baseUrl}/{conf.ApiKey}/gifs/recent/{customerId}?page={request.Page}&per_page=24";
var response = await client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCo = url.Replace("api.klipy.com", "api.klipy.co");
response = await client.GetAsync(urlCo, cancellationToken);
}
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(cancellationToken);
return Result.Failure<JsonElement?>(new Error(Errors.KlipyApiError, $"Klipy Recent API Error: {response.StatusCode} {errorBody}"));
}
}
var result = await response.Content.ReadFromJsonAsync<JsonElement>(cancellationToken: cancellationToken);
return Result.Success<JsonElement?>(result);
}
}
@@ -13,7 +13,7 @@ using MediatR;
namespace Knot.Modules.Klipy.Application.Klipy.Queries;
public record GetTrendingGifsQuery : IQuery<JsonElement?>;
public record GetTrendingGifsQuery(int Page = 1) : IQuery<JsonElement?>;
internal sealed class GetTrendingGifsQueryHandler : IQueryHandler<GetTrendingGifsQuery, JsonElement?>
{
@@ -34,21 +34,21 @@ internal sealed class GetTrendingGifsQueryHandler : IQueryHandler<GetTrendingGif
if (!conf.Enabled || string.IsNullOrEmpty(conf.ApiKey))
return Result.Failure<JsonElement?>(new Error(Errors.KlipyNotConfigured, "Klipy is not configured"));
var cacheKeyTrending = $"klipy_trending_{conf.ApiKey}";
var cacheKeyTrending = $"klipy_trending_{conf.ApiKey}_{request.Page}";
if (_cache.TryGetValue(cacheKeyTrending, out JsonElement cachedResult))
return Result.Success<JsonElement?>(cachedResult);
var customerId = Knot.Shared.Kernel.Constants.Klipy.DefaultCustomerId;
var client = _httpClientFactory.CreateClient();
var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId);
var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, request.Page, 30, "", customerId);
var response = await client.GetAsync(urlCo, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, "", customerId);
var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceTrending, request.Page, 30, "", customerId);
response = await client.GetAsync(urlCom, cancellationToken);
}
@@ -13,7 +13,7 @@ using MediatR;
namespace Knot.Modules.Klipy.Application.Klipy.Queries;
public record SearchGifsQuery(string Query) : IQuery<JsonElement?>;
public record SearchGifsQuery(string Query, int Page = 1) : IQuery<JsonElement?>;
internal sealed class SearchGifsQueryHandler : IQueryHandler<SearchGifsQuery, JsonElement?>
{
@@ -37,7 +37,7 @@ internal sealed class SearchGifsQueryHandler : IQueryHandler<SearchGifsQuery, Js
if (string.IsNullOrWhiteSpace(request.Query))
return Result.Failure<JsonElement?>(new Error(Errors.InvalidQuery, "Invalid query parameter"));
var cacheKey = $"klipy_search_{conf.ApiKey}_{request.Query.ToLowerInvariant()}";
var cacheKey = $"klipy_search_{conf.ApiKey}_{request.Query.ToLowerInvariant()}_{request.Page}";
if (_cache.TryGetValue(cacheKey, out JsonElement cachedResult))
return Result.Success<JsonElement?>(cachedResult);
@@ -45,14 +45,14 @@ internal sealed class SearchGifsQueryHandler : IQueryHandler<SearchGifsQuery, Js
var client = _httpClientFactory.CreateClient();
var queryParam = $"q={Uri.EscapeDataString(request.Query)}";
var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, queryParam, customerId);
var urlCo = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCo, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, request.Page, 30, queryParam, customerId);
var response = await client.GetAsync(urlCo, cancellationToken);
if (!response.IsSuccessStatusCode)
{
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, queryParam, customerId);
var urlCom = string.Format(Knot.Shared.Kernel.Constants.Klipy.ApiUrlCom, conf.ApiKey, Knot.Shared.Kernel.Constants.Klipy.ResourceSearch, request.Page, 30, queryParam, customerId);
response = await client.GetAsync(urlCom, cancellationToken);
}
@@ -18,16 +18,34 @@ public static class KlipyEndpoints
{
var group = app.MapGroup(Routes.ApiKlipy).RequireAuthorization();
group.MapGet("/trending", async (ISender sender, CancellationToken ct) =>
group.MapGet("/trending", async ([FromQuery] int page, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.GetTrendingGifsQuery(), ct);
var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.GetTrendingGifsQuery(page > 0 ? page : 1), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("/search", async ([FromQuery] string q, ISender sender, CancellationToken ct) =>
group.MapGet("/search", async ([FromQuery] string q, [FromQuery] int page, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.SearchGifsQuery(q), ct);
var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.SearchGifsQuery(q, page > 0 ? page : 1), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("/recent", async ([FromQuery] int page, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.GetRecentGifsQuery(page > 0 ? page : 1), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
group.MapGet("/categories", async ([FromQuery] string? countryCode, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Queries.GetGifCategoriesQuery(countryCode ?? "RU"), ct);
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(new { error = result.Error.Description });
});
group.MapPost("/{gifId}/share", async (string gifId, [FromQuery] string? q, ISender sender, CancellationToken ct) =>
{
var result = await sender.Send(new Knot.Modules.Klipy.Application.Klipy.Commands.MarkGifSharedCommand(gifId, q ?? ""), ct);
return result.IsSuccess ? Results.Ok() : Results.BadRequest(new { error = result.Error.Description });
});
}
}
@@ -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.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<List<UserProfileDto>>;
public sealed record SearchProfilesQuery(string Query, Guid UserId) : IQuery<List<UserProfileDto>>;
internal sealed class SearchProfilesQueryHandler : IQueryHandler<SearchProfilesQuery, List<UserProfileDto>>
{
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<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);
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 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;
}
}
@@ -43,18 +43,25 @@ internal class ProfileRepository : IProfileRepository
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;
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<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.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();
}
@@ -82,6 +89,16 @@ internal class ProfileRepository : IProfileRepository
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)
{
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.Infrastructure\Knot.Shared.Infrastructure.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>
@@ -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);
});
@@ -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)
{
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();
@@ -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,35 +23,58 @@ internal sealed class GetContactsQueryHandler : IQueryHandler<GetContactsQuery,
public async Task<Result<List<ContactDto>>> 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<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;
if (replicas.TryGetValue(otherUserId, out var replica))
{
result.Add(new ContactDto(
replica.Id,
replica.Username,
replica.DisplayName,
replica.Avatar,
false,
null,
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);
}
@@ -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);
}
}
@@ -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.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<SendContactRequestCommand>
{
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<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."));
}
// 1. Proactively ensure replicas exist for both ends
await EnsureUserReplicaExists(request.UserId, cancellationToken);
await EnsureUserReplicaExists(request.ContactId, cancellationToken);
var existing = await _context.Contacts
.FirstOrDefaultAsync(f => (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<SendCon
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 =>
options.UseNpgsql(connectionString));
services.AddScoped<IContactsDbContext>(sp => sp.GetRequiredService<RelationsDbContext>());
services.AddScoped<Knot.Contracts.Relations.Application.Abstractions.IFriendshipRepository, FriendshipRepository>();
services.AddMediatR(config =>
@@ -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;
@@ -8,6 +8,7 @@
<ItemGroup>
<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.Infrastructure\Knot.Shared.Infrastructure.csproj" />
</ItemGroup>
@@ -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);
@@ -2,14 +2,27 @@ namespace Knot.Shared.Kernel.Constants;
public static class Klipy
{
public const string ApiUrlCo = "https://api.klipy.co/api/v1/{0}/{1}?page=1&per_page=30&{2}&customer_id={3}";
public const string ApiUrlCom = "https://api.klipy.com/api/v1/{0}/{1}?page=1&per_page=30&{2}&customer_id={3}";
public const string ResourceTrending = "gifs/trending";
public const string ResourceSearch = "gifs/search";
public const string ResourceRecent = "gifs/recent";
public const string ResourceCategories = "gifs/categories";
public const string ResourceShare = "gifs/{0}/share";
public const string ApiUrlCo = "https://api.klipy.co/api/v1/{0}/{1}?page={2}&per_page={3}&{4}&customer_id={5}";
public const string ApiUrlCom = "https://api.klipy.com/api/v1/{0}/{1}?page={2}&per_page={3}&{4}&customer_id={5}";
// Categories use country_code
public const string ApiUrlCategoriesCo = "https://api.klipy.co/api/v1/{0}/{1}?country_code={2}&customer_id={3}";
public const string ApiUrlCategoriesCom = "https://api.klipy.com/api/v1/{0}/{1}?country_code={2}&customer_id={3}";
// Share is POST
public const string ApiUrlShareCo = "https://api.klipy.co/api/v1/{0}/gifs/{1}/share?q={2}&customer_id={3}";
public const string ApiUrlShareCom = "https://api.klipy.com/api/v1/{0}/gifs/{1}/share?q={2}&customer_id={3}";
public const int TrendingCacheMinutes = 60;
public const int SearchCacheMinutes = 15;
public const int CategoriesCacheMinutes = 1440; // 24 hours
public const int RecentCacheMinutes = 5; // Frequent changes
public const string DefaultCustomerId = "anonymous";
}
@@ -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;
+1
View File
@@ -178,6 +178,7 @@ export interface FriendRequest {
id: string;
user: User;
createdAt: string;
isOutgoing?: boolean;
}
export interface FriendWithId extends UserPresence {
+18 -4
View File
@@ -17,11 +17,25 @@ export class AppApi {
});
}
static async getTrendingGifs() {
return httpClient.request<any>('/klipy/trending');
static async getTrendingGifs(page: number = 1) {
return httpClient.request<any>(`/klipy/trending?page=${page}`);
}
static async searchKlipyGifs(query: string) {
return httpClient.request<any>(`/klipy/search?q=${encodeURIComponent(query)}`);
static async searchKlipyGifs(query: string, page: number = 1) {
return httpClient.request<any>(`/klipy/search?q=${encodeURIComponent(query)}&page=${page}`);
}
static async getRecentGifs(page: number = 1) {
return httpClient.request<any>(`/klipy/recent?page=${page}`);
}
static async getGifCategories(countryCode: string = 'RU') {
return httpClient.request<any>(`/klipy/categories?countryCode=${countryCode}`);
}
static async markGifShared(gifId: string, query: string = '') {
return httpClient.request<void>(`/klipy/${gifId}/share?q=${encodeURIComponent(query)}`, {
method: 'POST'
});
}
}
@@ -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)
@@ -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...',
@@ -40,7 +40,7 @@ function AvatarInner({ src, name, size = 'md', className = '', online }: AvatarP
/>
) : (
<div
className={`${sizeClass} rounded-full bg-gradient-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary font-bold tracking-tighter`}
className={`${sizeClass} rounded-full ${gradientClass} flex items-center justify-center text-on-primary font-black tracking-tighter shadow-inner`}
>
{initials}
</div>
@@ -46,7 +46,7 @@ export default function ImageLightbox({ url, images, initialIndex = 0, onClose }
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-[9999] bg-black flex items-center justify-center"
className="fixed inset-0 z-[20000] bg-black flex items-center justify-center"
onClick={onClose}
>
{/* Top bar */}
@@ -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') },
];
@@ -165,9 +165,9 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
{/* ── Premium header with avatar ── */}
<div className="relative overflow-hidden flex-shrink-0">
{/* Animated gradient backdrop */}
<div className="absolute inset-0 bg-gradient-to-br from-knot-500/40 via-purple-600/25 to-transparent pointer-events-none" />
<div className="absolute -top-20 -right-20 w-56 h-56 bg-knot-500/15 rounded-full blur-[80px] pointer-events-none" />
<div className="absolute -bottom-10 -left-10 w-40 h-40 bg-purple-600/10 rounded-full blur-[60px] pointer-events-none" />
<div className="absolute inset-0 bg-gradient-to-br from-primary/30 via-primary-container/20 to-transparent pointer-events-none" />
<div className="absolute -top-20 -right-20 w-56 h-56 bg-primary/10 rounded-full blur-[80px] pointer-events-none" />
<div className="absolute -bottom-10 -left-10 w-40 h-40 bg-primary-container/10 rounded-full blur-[60px] pointer-events-none" />
<div className="relative p-6 pb-5">
<div className="flex items-start justify-between mb-5">
@@ -178,9 +178,8 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
{user?.avatar ? (
<img src={user.avatar} alt="" className="w-[72px] h-[72px] rounded-full object-cover ring-[3px] ring-surface" />
) : (
<div className="w-[72px] h-[72px] rounded-full bg-gradient-to-br from-surface to-surface-secondary flex items-center justify-center ring-[3px] ring-surface relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-tr from-accent/20 to-purple-500/20" />
<span className="relative z-10 text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-br from-white to-zinc-400 drop-shadow-md">{initials}</span>
<div className="w-[72px] h-[72px] rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center ring-[3px] ring-surface relative overflow-hidden">
<span className="relative z-10 text-2xl font-black text-on-primary drop-shadow-md">{initials}</span>
</div>
)}
</div>
@@ -223,7 +222,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
{item.subtitle && <p className="text-[11px] text-zinc-500 mt-0.5">{item.subtitle}</p>}
</div>
{'badge' in item && item.badge ? (
<span className="bg-gradient-to-r from-knot-500 to-purple-600 text-white text-[11px] font-bold min-w-[22px] h-[22px] px-1.5 rounded-full flex items-center justify-center flex-shrink-0 shadow-[0_0_12px_rgba(168,85,247,0.4)]">
<span className="bg-linear-to-r from-primary to-primary-container text-on-primary text-[11px] font-black min-w-[22px] h-[22px] px-1.5 rounded-full flex items-center justify-center flex-shrink-0 shadow-[0_0_12px_rgba(var(--color-primary),0.4)]">
{item.badge}
</span>
) : (
@@ -521,7 +520,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
{u.avatar ? (
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
<div className="w-10 h-10 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary font-black text-sm">
{(u.displayName || u.username || '?')[0].toUpperCase()}
</div>
)}
@@ -556,7 +555,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
{req.user.avatar ? (
<img src={req.user.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
<div className="w-10 h-10 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary font-black text-sm">
{(req.user.displayName || req.user.username || '?')[0].toUpperCase()}
</div>
)}
@@ -601,7 +600,7 @@ export default function SideMenu({ isOpen, onClose, onOpenProfile }: SideMenuPro
{friend.avatar ? (
<img src={friend.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
<div className="w-10 h-10 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary font-black text-sm">
{(friend.displayName || friend.username || '?')[0].toUpperCase()}
</div>
)}
+8 -8
View File
@@ -71,14 +71,14 @@ export function getInitials(name: string): string {
export function generateAvatarColor(name: string): string {
const colors = [
'from-violet-500 to-purple-600',
'from-blue-500 to-indigo-600',
'from-emerald-500 to-teal-600',
'from-rose-500 to-pink-600',
'from-amber-500 to-orange-600',
'from-cyan-500 to-blue-600',
'from-fuchsia-500 to-purple-600',
'from-lime-500 to-green-600',
'bg-linear-to-br from-blue-400 to-blue-600',
'bg-linear-to-br from-sky-400 to-sky-600',
'bg-linear-to-br from-cyan-400 to-blue-600',
'bg-linear-to-br from-blue-500 to-indigo-600',
'bg-linear-to-br from-emerald-400 to-blue-600',
'bg-linear-to-br from-blue-600 to-primary-container',
'bg-linear-to-br from-primary to-primary-container',
'bg-linear-to-br from-blue-400 to-cyan-500',
];
let hash = 0;
+12
View File
@@ -232,3 +232,15 @@ input:-webkit-autofill:active {
-webkit-text-fill-color: #f5f5fa !important;
transition: background-color 5000s ease-in-out 0s;
}
@keyframes highlightFlash {
0% { background-color: rgba(0, 163, 255, 0.3); }
100% { background-color: transparent; }
}
.highlight-message {
animation: highlightFlash 2s cubic-bezier(0.4, 0, 0.2, 1) forwards !important;
border-radius: 12px;
position: relative;
z-index: 10;
}
@@ -524,7 +524,7 @@ export default function AdminPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [config, setConfig] = useState<Conf | 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 [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');
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(() => {
@@ -18,7 +18,11 @@ interface AuthState {
}
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,
isLoading: true,
error: null,
@@ -83,7 +87,12 @@ export const useAuthStore = create<AuthState>((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<AuthState>((set, get) => ({
}
}
console.warn('checkAuth failed:', lastError);
// 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) => {
@@ -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() {
@@ -1591,7 +1591,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
{displayAvatar ? (
<img src={displayAvatar} alt="" className="relative w-10 h-10 rounded-full object-cover" />
) : (
<div className="relative w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-sm">
<div className="relative w-10 h-10 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary font-black text-sm shadow-inner">
{initials}
</div>
)}
@@ -1735,7 +1735,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
{displayAvatar ? (
<img src={displayAvatar} alt="" className="relative w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover border-4 border-knot-500/30" />
) : (
<div className="relative w-24 h-24 sm:w-32 sm:h-32 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-3xl sm:text-4xl font-bold border-4 border-knot-500/30">
<div className="relative w-24 h-24 sm:w-32 sm:h-32 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-3xl sm:text-4xl font-black border-4 border-primary/30 shadow-2xl">
{initials}
</div>
)}
@@ -1851,7 +1851,7 @@ export default function CallModal({ isOpen, onClose, targetUser, callType: initi
{displayAvatar ? (
<img src={displayAvatar} alt="" className="w-24 h-24 sm:w-32 sm:h-32 rounded-full object-cover shadow-inner" />
) : (
<div className="w-24 h-24 sm:w-32 sm:h-32 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-3xl sm:text-4xl shadow-inner">
<div className="w-24 h-24 sm:w-32 sm:h-32 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary font-black text-3xl sm:text-4xl shadow-2xl border-4 border-primary/20">
{initials}
</div>
)}
@@ -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,12 +367,14 @@ 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"
>
<GlobalNavBar activeTab="chats" onTabChange={() => {}} />
<GlobalNavBar activeTab={activeTab} onTabChange={setActiveTab} />
<main className="ml-20 flex-1 flex flex-row relative h-full">
{activeTab === 'chats' ? (
<>
{/* Chat List (Sidebar) */}
<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`}
className={`${activeChat ? 'hidden lg:block' : 'block'} w-full lg:w-[360px] flex-shrink-0 bg-surface-container-low h-full overflow-hidden`}
>
<Sidebar />
</div>
@@ -381,6 +385,40 @@ export default function ChatPage() {
>
<ChatView onStartCall={handleStartCall} onStartGroupCall={handleStartGroupCall} />
</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>
<CallModal
key={callSessionId}
@@ -433,7 +471,7 @@ export default function ChatPage() {
>
<div className="relative mb-6">
<div className="absolute inset-0 rounded-full bg-emerald-500/20 animate-call-wave" />
<div className="relative w-24 h-24 rounded-full bg-gradient-to-br from-indigo-500 to-purple-600 flex items-center justify-center text-4xl font-bold text-white uppercase overflow-hidden">
<div className="relative w-24 h-24 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-4xl font-black text-on-primary uppercase overflow-hidden shadow-2xl">
{incomingGroupCall.callerInfo?.avatar ? (
<img src={incomingGroupCall.callerInfo.avatar} className="w-full h-full object-cover" />
) : (
@@ -193,9 +193,11 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
{ctxMenu && (
<div
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 }}
>
{!isFavorites && (
<>
<button
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"
@@ -204,19 +206,21 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
{isPinned ? t('unpinChat') : t('pinChat')}
</button>
<div className="border-t border-border my-1" />
</>
)}
<button
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"
>
<Trash2 size={16} />
{t('deleteChat')}
{isFavorites ? t('clearHistory') : t('deleteChat')}
</button>
</div>
)}
<ConfirmModal
open={showDeleteConfirm}
message={t('deleteChatConfirm')}
message={isFavorites ? t('clearHistoryConfirm') : t('deleteChatConfirm')}
onConfirm={confirmDelete}
onCancel={() => setShowDeleteConfirm(false)}
/>
@@ -516,7 +516,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<div className="relative" ref={deleteMenuRef}>
<button
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"
>
<Trash2 size={18} />
@@ -529,7 +529,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -5 }}
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
onClick={() => handleBulkDelete(false)}
@@ -649,7 +649,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{showSearch ? <span className="material-symbols-outlined">close</span> : <span className="material-symbols-outlined">search</span>}
</button>
{!isFavorites && config?.enableCalls && (
{!isFavorites && config?.webRtc?.enabled && (
<>
<button
onClick={() => {
@@ -690,7 +690,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: -5 }}
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
onClick={openSearch}
@@ -699,7 +699,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<Search size={16} />
{t('searchMessages')}
</button>
{chat.type === 'personal' && otherMember && (
{!isFavorites && chat.type === 'personal' && otherMember && (
<button
onClick={() => {
setShowTopMenu(false);
@@ -711,6 +711,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{t('userProfile')}
</button>
)}
{!isFavorites && (
<button
onClick={() => {
if (activeChat) {
@@ -723,7 +724,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{muted ? <Bell size={16} /> : <BellOff size={16} />}
{muted ? t('enableSound') : t('disableSound')}
</button>
{chat.type === 'group' && (
)}
{!isFavorites && chat.type === 'group' && (
<button
onClick={() => {
setShowTopMenu(false);
@@ -735,13 +737,13 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{t('groupSettings')}
</button>
)}
<div className="border-t border-border my-1" />
<div className="border-t border-white/5 my-1" />
<button
onClick={() => {
setShowTopMenu(false);
if (activeChat) {
setConfirmAction({
message: t('clearChatConfirm'),
message: isFavorites ? t('clearHistoryConfirm') : t('clearChatConfirm'),
action: async () => {
try {
await ChatApi.clearChat(activeChat);
@@ -756,8 +758,9 @@ 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"
>
<Eraser size={16} />
{t('clearChat')}
{isFavorites ? t('clearHistory') : t('clearChat')}
</button>
{!isFavorites && (
<button
onClick={() => {
setShowTopMenu(false);
@@ -780,6 +783,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
<Trash2 size={16} />
{t('deleteChat')}
</button>
)}
</motion.div>
)}
</AnimatePresence>
@@ -998,7 +1002,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
{activeChat && <MessageInput chatId={activeChat} />}
{(() => {
const handleJumpToMessage = async (msgId: string, cleanup?: () => void) => {
const handleJumpToMessage = async (msgId: string, cleanup?: () => void, targetCreatedAt?: string) => {
cleanup?.();
const tryScroll = () => {
const el = document.getElementById(`msg-${msgId}`);
@@ -1015,21 +1019,45 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
if (!activeChat) return;
const NotificationStore = await import('../../../../core/application/stores/notificationStore');
NotificationStore.useNotificationStore.getState().addNotification('info', 'Поиск сообщения в истории...');
NotificationStore.useNotificationStore.getState().addNotification('info', lang === 'ru' ? 'Поиск сообщения в истории...' : 'Searching message in history...');
const chatStore = useChatStore.getState();
let found = false;
for (let i = 0; i < 5; i++) {
if (chatStore.hasMoreMessages[activeChat] === false) break;
const targetDate = targetCreatedAt ? new Date(targetCreatedAt).getTime() : 0;
// Smart timeline-based search
for (let i = 0; i < 100; i++) {
const chatMessages = chatStore.messages[activeChat] || [];
const oldestLoaded = chatMessages.length > 0 ? new Date(chatMessages[0].createdAt).getTime() : Date.now();
// If target is newer than oldest loaded, and not found, maybe it's in a gap or we need to keep loading?
// Actually target is almost always older if not found.
// If we don't have targetCreatedAt, we guess (up to 100 attempts)
if (targetDate && targetDate > oldestLoaded && chatMessages.some(m => m.id === msgId)) {
// Should have been found by tryScroll, but lets try one last time
if (tryScroll()) { found = true; break; }
}
if (chatStore.hasMoreMessages[activeChat] === false && oldestLoaded <= targetDate) break;
await chatStore.loadMessages(activeChat, false, true);
// Give React 150ms to render the new messages
await new Promise(resolve => setTimeout(resolve, 150));
if (tryScroll()) {
found = true;
break;
}
// Stop if we have gone way past the target date
if (targetDate && oldestLoaded < (targetDate - 1000 * 60 * 60)) {
// We are 1 hour before the message and still haven't found it? might be deleted
if (i > 10) break;
}
}
if (!found) {
NotificationStore.useNotificationStore.getState().addNotification('warning', 'Сообщение слишком старое');
NotificationStore.useNotificationStore.getState().addNotification('warning', lang === 'ru' ? 'Сообщение не найдено' : 'Message not found');
}
};
@@ -1042,7 +1070,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
userId={profileUserId}
chatId={activeChat || undefined}
onClose={() => setProfileUserId(null)}
onGoToMessage={(msgId) => handleJumpToMessage(msgId, () => setProfileUserId(null))}
onGoToMessage={(msgId: any, createdAt: string) => handleJumpToMessage(msgId, () => setProfileUserId(null), createdAt)}
isSelf={profileUserId === user?.id}
/>
)}
@@ -2,7 +2,7 @@ import { useState, useRef, useEffect, useCallback } from 'react';
import { createPortal } from 'react-dom';
import Picker from '@emoji-mart/react';
import data from '@emoji-mart/data';
import { Search, TrendingUp, Loader2 } from 'lucide-react';
import { Search, TrendingUp, Loader2, Clock, Grid } from 'lucide-react';
import { useLang } from '../../../../core/infrastructure/i18n';
import { useAuthStore } from '../../../auth/application/authStore';
import { AppApi } from '../../../../core/infrastructure/appApi';
@@ -17,77 +17,227 @@ interface KlipyGif {
title?: string;
}
interface GifCategory {
category: string;
query: string;
preview_url: string;
}
interface EmojiPickerProps {
onSelect: (emoji: string) => void;
onSelectGif?: (url: string, preview: string) => void;
onClose: () => void;
}
type GifMode = 'recent' | 'trending' | 'categories' | 'search';
const CATEGORY_TRANSLATIONS: Record<string, string> = {
'hello': 'Привет',
'lol': 'Лол',
'love': 'Любовь',
'happy birthday': 'С днем рождения',
'thank you': 'Спасибо',
'excited': 'Восторг',
'smile': 'Улыбка',
'aww': 'Мило',
'high five': 'Дай пять',
'good morning': 'Доброе утро',
'good night': 'Спокойной ночи',
'yes': 'Да',
'no': 'Нет',
'ok': 'Ок',
'sorry': 'Прости',
'please': 'Пожалуйста',
'wow': 'Вау',
'dance': 'Танцы',
'hungry': 'Голоден',
'scared': 'Страшно',
'tired': 'Устал',
'sad': 'Грустно',
'party': 'Вечеринка',
'cry': 'Плачу',
'cool': 'Круто'
};
export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPickerProps) {
const { lang, t } = useLang();
const { config } = useAuthStore();
const [tab, setTab] = useState<'emoji' | 'gif'>('emoji');
// Persist only the main tab type, but reset GIF sub-mode to 'recent' on entry
const [tab, setTab] = useState<'emoji' | 'gif'>(() => {
return (localStorage.getItem('emojiPicker_tab') as any) || 'emoji';
});
const [gifMode, setGifMode] = useState<GifMode>('recent');
useEffect(() => {
localStorage.setItem('emojiPicker_tab', tab);
}, [tab]);
const [gifQuery, setGifQuery] = useState('');
const [gifs, setGifs] = useState<KlipyGif[]>([]);
const [gifLoading, setGifLoading] = useState(false);
const [trendingGifs, setTrendingGifs] = useState<KlipyGif[]>([]);
const gifSearchRef = useRef<HTMLInputElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const initialFetchDone = useRef(false);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [trendingGifs, setTrendingGifs] = useState<KlipyGif[]>([]);
const [trendingPage, setTrendingPage] = useState(1);
const [trendingHasMore, setTrendingHasMore] = useState(true);
const [recentGifs, setRecentGifs] = useState<KlipyGif[]>([]);
const [recentPage, setRecentPage] = useState(1);
const [recentHasMore, setRecentHasMore] = useState(true);
const [categories, setCategories] = useState<GifCategory[]>([]);
const [categoriesLoading, setCategoriesLoading] = useState(false);
const gifSearchRef = useRef<HTMLInputElement>(null);
const scrollContainerRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout>>(undefined);
const initialFetchDone = useRef({ trending: false, categories: false, recent: false });
// Helper to safely extract GIF array from various possible Klipy API responses
const extractGifs = (d: any): KlipyGif[] => {
if (!d) return [];
if (d.data && Array.isArray(d.data.data)) return d.data.data;
if (Array.isArray(d)) return d;
if (Array.isArray(d.data)) return d.data;
if (Array.isArray(d.result)) return d.result;
if (d.result && Array.isArray(d.result.data)) return d.result.data;
if (Array.isArray(d.gifs)) return d.gifs;
return [];
let list: any[] = [];
if (d.data && Array.isArray(d.data.data)) list = d.data.data;
else if (Array.isArray(d.data)) list = d.data;
else if (Array.isArray(d.result)) list = d.result;
else if (d.result && Array.isArray(d.result.data)) list = d.result.data;
else if (Array.isArray(d)) list = d;
else if (Array.isArray(d.gifs)) list = d.gifs;
return list.filter(g => g && typeof g === 'object' && (g.id || g.media || g.files || g.file));
};
// Load trending GIFs (Klipy)
useEffect(() => {
if (tab === 'gif' && config?.enableKlipy && !initialFetchDone.current) {
initialFetchDone.current = true;
setGifLoading(true);
AppApi.getTrendingGifs()
.then(d => {
setTrendingGifs(extractGifs(d));
setGifLoading(false);
})
.catch((e) => {
console.error('Klipy trending error:', e);
setTrendingGifs([]);
setGifLoading(false);
});
const checkHasMore = (d: any): boolean => {
if (!d) return false;
if (d.data && typeof d.data.has_next === 'boolean') return d.data.has_next;
const meta = d.data?.meta || d.meta || d.result?.meta;
if (meta) {
if (typeof meta.has_more === 'boolean') return meta.has_more;
if (typeof meta.has_next === 'boolean') return meta.has_next;
if (typeof meta.last_page === 'number' && typeof meta.current_page === 'number') {
return meta.current_page < meta.last_page;
}
}, [tab, config?.enableKlipy]);
}
const count = extractGifs(d).length;
return count >= 20;
};
const searchGifs = useCallback((q: string) => {
if (!config?.enableKlipy || !q.trim()) {
const fetchTrending = useCallback((p: number) => {
if (!config?.klipy?.enabled) return;
setGifLoading(true);
AppApi.getTrendingGifs(p)
.then(d => {
const newGifs = extractGifs(d);
setTrendingGifs(prev => p === 1 ? newGifs : [...prev, ...newGifs]);
setTrendingHasMore(checkHasMore(d));
})
.catch(console.error)
.finally(() => setGifLoading(false));
}, [config?.klipy?.enabled]);
const fetchRecent = useCallback((p: number) => {
if (!config?.klipy?.enabled) return;
setGifLoading(true);
AppApi.getRecentGifs(p)
.then(d => {
const newGifs = extractGifs(d);
setRecentGifs(prev => p === 1 ? newGifs : [...prev, ...newGifs]);
setRecentHasMore(checkHasMore(d));
// Auto-switch to trending if history is empty on first load
if (p === 1 && newGifs.length === 0 && gifMode === 'recent') {
setGifMode('trending');
}
})
.catch(console.error)
.finally(() => setGifLoading(false));
}, [config?.klipy?.enabled, gifMode]);
const fetchCategories = useCallback(() => {
if (!config?.klipy?.enabled) return;
setCategoriesLoading(true);
AppApi.getGifCategories('RU')
.then(d => {
if (d.data && Array.isArray(d.data.categories)) {
setCategories(d.data.categories);
}
})
.catch(console.error)
.finally(() => setCategoriesLoading(false));
}, [config?.klipy?.enabled]);
const searchGifs = useCallback((q: string, p: number) => {
if (!config?.klipy?.enabled || !q.trim()) {
setGifs([]);
setGifLoading(false);
return;
}
setGifLoading(true);
AppApi.searchKlipyGifs(q)
AppApi.searchKlipyGifs(q, p)
.then(d => {
setGifs(extractGifs(d));
setGifLoading(false);
const newGifs = extractGifs(d);
setGifs(prev => p === 1 ? newGifs : [...prev, ...newGifs]);
setHasMore(checkHasMore(d));
})
.catch((e) => {
console.error('Klipy search error:', e);
setGifs([]);
setGifLoading(false);
});
}, [config?.enableKlipy]);
.catch(console.error)
.finally(() => setGifLoading(false));
}, [config?.klipy?.enabled]);
useEffect(() => {
if (tab === 'gif' && config?.klipy?.enabled) {
if (!initialFetchDone.current.trending) {
initialFetchDone.current.trending = true;
fetchTrending(1);
}
if (!initialFetchDone.current.categories) {
initialFetchDone.current.categories = true;
fetchCategories();
}
if (!initialFetchDone.current.recent) {
initialFetchDone.current.recent = true;
fetchRecent(1);
}
}
}, [tab, config?.klipy?.enabled, fetchTrending, fetchCategories, fetchRecent]);
// Handle switching to recent tab to refresh it
useEffect(() => {
if (tab === 'gif' && gifMode === 'recent') {
fetchRecent(1);
}
}, [gifMode, tab, fetchRecent]);
const handleGifSearch = (q: string) => {
setGifQuery(q);
setPage(1);
setHasMore(true);
if (!q.trim()) {
setGifMode('trending');
return;
}
setGifMode('search');
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => searchGifs(q), 400);
debounceRef.current = setTimeout(() => searchGifs(q, 1), 400);
};
const handleScroll = (e: React.UIEvent<HTMLDivElement>) => {
if (gifLoading) return;
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollTop + clientHeight >= scrollHeight - 400) {
if (gifMode === 'search' && hasMore) {
const nextPage = page + 1;
setPage(nextPage);
searchGifs(gifQuery, nextPage);
} else if (gifMode === 'trending' && trendingHasMore) {
const n = trendingPage + 1;
setTrendingPage(n);
fetchTrending(n);
} else if (gifMode === 'recent' && recentHasMore) {
const n = recentPage + 1;
setRecentPage(n);
fetchRecent(n);
}
}
};
const getGifUrl = (gif: any): string => {
@@ -109,11 +259,13 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
const preview = getGifPreview(gif, url);
if (onSelectGif && url) {
onSelectGif(url, preview);
// Mark as shared with Klipy trigger API
AppApi.markGifShared(gif.id, gifMode === 'search' ? gifQuery : '')
.then(() => fetchRecent(1)) // Refresh history immediately after sharing
.catch(console.error);
}
};
const displayGifs = gifQuery.trim() ? gifs : trendingGifs;
const anchorRef = useRef<HTMLDivElement>(null);
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
@@ -122,7 +274,7 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
const el = anchorRef.current?.parentElement;
if (!el) return;
const rect = el.getBoundingClientRect();
const w = tab === 'gif' ? 360 : 352;
const w = 400;
let left = rect.right - w;
if (left < 8) left = 8;
setPos({ top: rect.top - 8, left });
@@ -132,7 +284,13 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
return () => window.removeEventListener('resize', update);
}, [tab]);
const pickerWidth = tab === 'gif' ? 360 : 352;
const displayGifs = gifMode === 'search' ? gifs : gifMode === 'recent' ? recentGifs : trendingGifs;
const currentHasMore = gifMode === 'search' ? hasMore : gifMode === 'recent' ? recentHasMore : trendingHasMore;
const translateCategory = (cat: string) => {
if (lang !== 'ru') return cat;
return CATEGORY_TRANSLATIONS[cat.toLowerCase()] || cat;
};
return (
<>
@@ -141,36 +299,37 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
<>
<div className="fixed inset-0 z-[9990]" onClick={onClose} />
<div
className="fixed z-[9991] rounded-xl shadow-2xl border border-border/40"
className="fixed z-[9991] rounded-[2rem] shadow-[0_32px_64px_-16px_rgba(0,0,0,0.6)] border border-white/10 overflow-hidden flex flex-col backdrop-blur-3xl"
onClick={(e) => e.stopPropagation()}
style={{
width: pickerWidth,
height: tab === 'gif' ? 435 : undefined,
width: 400,
height: 520,
bottom: pos ? `${window.innerHeight - pos.top}px` : undefined,
left: pos ? pos.left : undefined,
background: '#17212b',
background: 'rgba(19, 19, 19, 0.85)',
visibility: pos ? 'visible' : 'hidden',
}}
>
{/* Tabs */}
<div className="flex border-b border-border/40">
{/* Main Tabs */}
<div className="flex p-2 gap-1 bg-black/20 backdrop-blur-md flex-shrink-0">
<button
onClick={() => setTab('emoji')}
className={`flex-1 py-3 text-[14px] font-medium transition-colors ${tab === 'emoji' ? 'text-accent border-b-[2px] border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
className={`flex-1 py-3 text-[12px] font-black uppercase tracking-widest rounded-xl transition-all duration-300 ${tab === 'emoji' ? 'bg-primary/20 text-primary shadow-[0_0_20px_rgba(154,203,255,0.15)]' : 'text-zinc-500 hover:text-white hover:bg-white/5'}`}
>
{lang === 'ru' ? 'Эмодзи' : 'Emoji'}
{lang === 'ru' ? 'Эмодзи' : 'Emojis'}
</button>
{config?.enableKlipy && onSelectGif && (
{config?.klipy?.enabled && (
<button
onClick={() => { setTab('gif'); setTimeout(() => gifSearchRef.current?.focus(), 100); }}
className={`flex-1 py-3 text-[14px] font-medium transition-colors ${tab === 'gif' ? 'text-accent border-b-[2px] border-accent' : 'text-zinc-500 hover:text-zinc-300'}`}
className={`flex-1 py-3 text-[12px] font-black uppercase tracking-widest rounded-xl transition-all duration-300 ${tab === 'gif' ? 'bg-primary/20 text-primary shadow-[0_0_20px_rgba(154,203,255,0.15)]' : 'text-zinc-500 hover:text-white hover:bg-white/5'}`}
>
GIF
</button>
)}
</div>
{/* Emoji tab */}
{tab === 'emoji' && (
<div className="flex-1 overflow-hidden">
<Picker
data={data}
onEmojiSelect={(e: { native: string }) => onSelect(e.native)}
@@ -179,60 +338,124 @@ export default function EmojiPicker({ onSelect, onSelectGif, onClose }: EmojiPic
set="native"
previewPosition="none"
skinTonePosition="search"
perLine={9}
emojiSize={28}
emojiButtonSize={36}
perLine={10}
emojiSize={26}
emojiButtonSize={34}
maxFrequentRows={2}
navPosition="top"
dynamicWidth={false}
background="transparent"
/>
</div>
)}
{/* GIF tab */}
{config?.enableKlipy && tab === 'gif' && (
<div className="flex flex-col h-[calc(100%-41px)]">
<div className="p-2">
{config?.klipy?.enabled && tab === 'gif' && (
<div className="flex flex-col flex-1 min-h-0">
{/* Search Bar */}
<div className="px-3 pt-2 pb-1 flex-shrink-0">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<input
ref={gifSearchRef}
value={gifQuery}
onChange={(e) => handleGifSearch(e.target.value)}
placeholder={t('searchGifs')}
className="w-full pl-8 pr-3 py-2 rounded-lg bg-surface-tertiary/80 text-sm text-white placeholder-zinc-500 border border-border/30 focus:border-accent/50 outline-none transition-colors"
placeholder={lang === 'ru' ? 'Поиск GIF...' : 'Search GIFs...'}
className="w-full pl-9 pr-3 py-2.5 rounded-xl bg-white/5 text-xs text-white placeholder-zinc-500 border border-white/5 focus:border-primary/50 outline-none transition-all shadow-inner"
/>
</div>
</div>
{!gifQuery.trim() && !gifLoading && (
<div className="flex items-center gap-1.5 px-3 pb-1">
<TrendingUp size={12} className="text-zinc-500" />
<span className="text-[10px] text-zinc-500 uppercase tracking-wider font-semibold">{t('trending')}</span>
{/* Sub-Tabs */}
<div className="flex items-center gap-1 px-3 py-1 bg-black/10 flex-shrink-0">
<button
onClick={() => { setGifMode('recent'); setGifQuery(''); }}
className={`p-2 rounded-lg transition-all ${gifMode === 'recent' ? 'text-primary bg-primary/20 shadow-lg' : 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'}`}
title={lang === 'ru' ? 'История' : 'History'}
>
<Clock size={16} />
</button>
<button
onClick={() => { setGifMode('trending'); setGifQuery(''); }}
className={`p-2 rounded-lg transition-all ${gifMode === 'trending' ? 'text-primary bg-primary/20 shadow-lg' : 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'}`}
title={lang === 'ru' ? 'Тренды' : 'Trending'}
>
<TrendingUp size={16} />
</button>
<button
onClick={() => { setGifMode('categories'); setGifQuery(''); }}
className={`p-2 rounded-lg transition-all ${gifMode === 'categories' ? 'text-primary bg-primary/20 shadow-lg' : 'text-zinc-500 hover:text-zinc-300 hover:bg-white/5'}`}
title={lang === 'ru' ? 'Категории' : 'Categories'}
>
<Grid size={16} />
</button>
{gifMode === 'search' && (
<div className="flex items-center gap-2 px-3 py-1 ml-auto rounded-full bg-primary/10 border border-primary/20 animate-in fade-in slide-in-from-right-2">
<Search size={10} className="text-primary" />
<span className="text-[10px] text-primary font-black uppercase truncate max-w-[100px] tracking-widest">{gifQuery}</span>
</div>
)}
<div className="flex-1 overflow-y-auto p-1.5">
{gifLoading ? (
<div className="flex items-center justify-center py-10">
<Loader2 size={24} className="text-zinc-500 animate-spin" />
</div>
) : displayGifs.length === 0 ? (
<p className="text-center text-xs text-zinc-500 py-10">{gifQuery ? t('nothingFound') : ''}</p>
{/* Content Area */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto px-2 pt-1 pb-2 scroll-smooth custom-scrollbar"
>
{gifMode === 'categories' ? (
categoriesLoading ? (
<div className="flex items-center justify-center py-20"><Loader2 size={32} className="text-primary animate-spin opacity-50" /></div>
) : (
<div className="columns-4 gap-1.5">
{displayGifs.map((gif) => (
<div className="grid grid-cols-2 gap-2 p-1">
{categories.map((cat, i) => (
<button
key={gif.id}
key={i}
onClick={() => { setGifQuery(cat.query); handleGifSearch(cat.query); }}
className="relative aspect-[16/9] rounded-xl overflow-hidden group hover:ring-2 ring-primary/50 transition-all border border-white/5 bg-zinc-900 shadow-lg"
>
<img src={cat.preview_url} className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-700 opacity-60" />
<div className="absolute inset-0 flex items-center justify-center bg-black/20 group-hover:bg-black/80 transition-all duration-300">
<span className="text-[10px] font-black uppercase tracking-[0.2em] text-white drop-shadow-[0_2px_4px_rgba(0,0,0,0.8)]">{translateCategory(cat.category)}</span>
</div>
<div className="absolute inset-0 bg-primary/10 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
))}
</div>
)
) : displayGifs.length === 0 && gifLoading ? (
<div className="flex items-center justify-center py-10"><Loader2 size={32} className="text-primary animate-spin opacity-50" /></div>
) : (
<>
<div className="grid grid-cols-3 gap-1">
{displayGifs.map((gif, idx) => (
<button
key={`${gif.id}-${idx}-${gifMode}`}
onClick={() => { pickGif(gif); onClose(); }}
className="w-full mb-1.5 rounded-lg overflow-hidden hover:opacity-80 transition-opacity block"
className="aspect-square w-full rounded-xl overflow-hidden hover:brightness-110 active:scale-95 transition-all bg-white/5 shadow-md border border-white/5 relative group"
>
<img
src={getGifPreview(gif, getGifUrl(gif))}
alt={gif.title || 'GIF'}
className="w-full h-auto rounded-lg"
className="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
loading="lazy"
/>
<div className="absolute inset-0 bg-primary/20 opacity-0 group-hover:opacity-100 transition-opacity" />
</button>
))}
</div>
{gifLoading && (
<div className="flex items-center justify-center py-6"><Loader2 size={24} className="text-primary animate-spin" /></div>
)}
{!currentHasMore && !gifLoading && displayGifs.length > 0 && (
<p className="text-center text-[10px] text-zinc-600 py-4 uppercase tracking-widest font-black leading-none opacity-50">{lang === 'ru' ? 'Больше нет результатов' : 'No more results'}</p>
)}
{!gifLoading && displayGifs.length === 0 && (
<div className="flex flex-col items-center justify-center py-16 text-zinc-600 animate-in fade-in zoom-in-95 duration-500">
<Clock size={32} className="opacity-20 mb-3" />
<p className="text-xs font-bold uppercase tracking-widest opacity-40">{gifMode === 'recent' ? (lang === 'ru' ? 'Пусто в истории' : 'Empty history') : (lang === 'ru' ? 'Ничего не найдено' : 'Nothing found')}</p>
</div>
)}
</>
)}
</div>
</div>
@@ -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 (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
return createPortal(
<div className="fixed inset-0 z-[99999] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
className="absolute inset-0 bg-black/60 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 20 }}
@@ -48,31 +49,31 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
role="dialog"
aria-modal="true"
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">
<h2 className="text-lg font-semibold text-white">{t('forwardMessage')}</h2>
<div className="p-5 flex items-center justify-between border-b border-white/5">
<h2 className="text-xl font-bold font-headline text-white">{t('forwardMessage')}</h2>
<button
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" />
</button>
</div>
<div className="p-4">
<div className="relative mb-4">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" size={18} />
<div className="p-5">
<div className="relative mb-5">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500" size={18} />
<input
type="text"
placeholder={t('searchChats') || 'Поиск чатов'}
value={search}
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 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) => {
const otherMember = chat.members.find((m) => m.userId !== user?.id);
const chatName =
@@ -88,19 +89,33 @@ export default function ForwardModal({ onClose, onForward }: ForwardModalProps)
<button
key={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" />
<span className="text-white font-medium flex-1 truncate">{chatName}</span>
<div className="relative group-hover:scale-105 transition-transform duration-300">
{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>
);
})}
{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>
</motion.div>
</div>
</div>,
document.body
);
}
@@ -362,7 +362,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
className="w-32 h-32 rounded-full object-cover shadow-inner"
/>
) : (
<div className="w-32 h-32 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-bold text-4xl shadow-inner">
<div className="w-32 h-32 rounded-full bg-linear-to-br from-primary to-primary-container flex items-center justify-center text-on-primary font-black text-4xl shadow-inner">
{initials}
</div>
)}
@@ -577,7 +577,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
{u.avatar ? (
<img src={getMediaUrl(u.avatar)} alt="" className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
<div className="w-8 h-8 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
{(u.displayName || u.username || '?')[0].toUpperCase()}
</div>
)}
@@ -616,7 +616,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
{member.user.avatar ? (
<img src={getMediaUrl(member.user.avatar)} alt="" className="w-9 h-9 rounded-full object-cover" />
) : (
<div className="w-9 h-9 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-bold">
<div className="w-9 h-9 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
{(member.user.displayName || member.user.username || '?')[0].toUpperCase()}
</div>
)}
@@ -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);
@@ -311,7 +314,7 @@ function MessageBubble({
reactionGroups[r.emoji].avatars.push({
url: r.user?.avatar,
initials: displayName[0].toUpperCase(),
colorClass: generateAvatarColor(displayName)
colorClass: 'from-primary/80 to-primary-container'
});
}
if (r.userId === user?.id) reactionGroups[r.emoji].isMine = true;
@@ -335,7 +338,7 @@ function MessageBubble({
href={part}
target="_blank"
rel="noopener noreferrer"
className="text-sky-400 hover:underline"
className="text-white underline decoration-white/30 underline-offset-2 hover:decoration-white transition-all"
onClick={(e) => e.stopPropagation()}
>
{part}
@@ -397,7 +400,7 @@ function MessageBubble({
{senderAvatar ? (
<img src={senderAvatar} alt="" className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
<div className="w-8 h-8 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
{senderName[0]?.toUpperCase() || '?'}
</div>
)}
@@ -844,7 +847,7 @@ function MessageBubble({
{senderAvatar ? (
<img src={senderAvatar} alt="" className="w-8 h-8 rounded-full object-cover" />
) : (
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-xs font-semibold">
<div className="w-8 h-8 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-xs font-black shadow-inner">
{senderName[0]?.toUpperCase() || '?'}
</div>
)}
@@ -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({
<div className="border-t border-border my-1" />
<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"
>
<Trash2 size={16} />
@@ -764,7 +764,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
onClick={() => imageInputRef.current?.click()}
className="flex items-center gap-4 w-full px-3 py-3 rounded-xl text-sm font-medium text-zinc-200 hover:bg-white/5 hover:text-white transition-all group"
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-400/20 to-purple-500/20 flex items-center justify-center ring-1 ring-knot-400/30 group-hover:scale-110 transition-transform shadow-inner">
<div className="w-10 h-10 rounded-full bg-linear-to-br from-primary/20 to-primary-container/20 flex items-center justify-center ring-1 ring-primary/30 group-hover:scale-110 transition-transform shadow-inner">
<ImageIcon size={18} className="text-knot-400" />
</div>
{t('photoVideo')}
@@ -833,7 +833,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
i === mentionIndex ? 'bg-primary/20 text-white' : 'text-zinc-300 hover:bg-white/5'
}`}
>
<div className="w-7 h-7 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[10px] font-bold">
<div className="w-7 h-7 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-[10px] font-black shadow-inner">
{(m.user.displayName || m.user.userName || m.user.username || '?')[0]?.toUpperCase()}
</div>
<div className="min-w-0">
@@ -1,6 +1,6 @@
import { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight } from 'lucide-react';
import { X, Search, MessageSquare, Users, Check, ArrowLeft, ArrowRight, Loader2 } from 'lucide-react';
import { ChatApi } from '../../infrastructure/chatApi';
import { UserApi } from '../../../users/infrastructure/userApi';
import { FriendApi } from '../../../friends/infrastructure/friendApi';
@@ -112,10 +112,10 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
className="fixed inset-0 z-50 flex items-center justify-center p-4"
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div className="w-full max-w-md rounded-2xl glass-strong shadow-2xl overflow-hidden" role="dialog" aria-modal="true" aria-label={t('newChat')}>
<div className="w-full max-w-md rounded-[2.5rem] bg-surface-container/60 backdrop-blur-3xl shadow-[0_48px_80px_-16px_rgba(0,0,0,0.7)] border border-white/5 overflow-hidden flex flex-col" role="dialog" aria-modal="true" aria-label={t('newChat')}>
{/* Шапка */}
<div className="flex items-center justify-between p-4 border-b border-border">
<div className="flex items-center gap-2">
<div className="flex items-center justify-between p-7 pb-4">
<div className="flex items-center gap-4">
{mode !== 'personal' && (
<button
onClick={() => {
@@ -125,12 +125,12 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
setSelectedUsers([]);
}
}}
className="p-1 rounded-lg text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
className="p-2 rounded-xl text-on-surface-variant/40 hover:text-white hover:bg-white/10 transition-all active:scale-95"
>
<ArrowLeft size={18} />
</button>
)}
<h2 className="text-lg font-semibold text-white">
<h2 className="text-xl font-black uppercase tracking-tight text-white/90">
{mode === 'personal'
? t('newChatTitle')
: mode === 'group-select'
@@ -140,23 +140,25 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-zinc-400 hover:text-white hover:bg-surface-hover transition-colors"
className="p-2 rounded-xl text-on-surface-variant/40 hover:text-white hover:bg-white/10 transition-all active:scale-95"
>
<X size={18} />
<X size={20} />
</button>
</div>
{mode === 'group-name' ? (
/* Шаг 2: Назвать группу */
<div className="p-4 space-y-4">
<div className="group relative">
<input
type="text"
placeholder={t('groupNamePlaceholder')}
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
className="w-full px-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
className="w-full px-6 py-4 rounded-2xl bg-surface-container-highest/30 text-sm text-white placeholder-zinc-500 border border-white/5 focus:border-primary/40 focus:bg-surface-container-highest/50 outline-none transition-all shadow-inner"
autoFocus
/>
</div>
<div>
<p className="text-xs text-zinc-500 mb-2">
{t('membersCount')} ({selectedUsers.length}):
@@ -170,7 +172,7 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
{u.avatar ? (
<img src={u.avatar} alt="" className="w-5 h-5 rounded-full object-cover" />
) : (
<div className="w-5 h-5 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white text-[9px] font-semibold">
<div className="w-5 h-5 rounded-full bg-linear-to-br from-primary/80 to-primary flex items-center justify-center text-on-primary text-[9px] font-black shadow-inner">
{(u.displayName || u.userName || u.username || '?')[0]?.toUpperCase()}
</div>
)}
@@ -188,13 +190,13 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
<button
onClick={handleCreateGroup}
disabled={!groupName.trim() || isCreating}
className="w-full py-2.5 rounded-xl bg-accent hover:bg-accent-hover text-white text-sm font-medium transition-colors disabled:opacity-50 flex items-center justify-center gap-2"
className="w-full py-4 rounded-2xl bg-primary text-on-primary text-[13px] font-black uppercase tracking-widest transition-all hover:brightness-110 active:scale-[0.98] disabled:opacity-50 flex items-center justify-center gap-2 shadow-[0_20px_30px_-10px_rgba(154,203,255,0.2)]"
>
{isCreating ? (
<div className="w-4 h-4 border-2 border-white border-t-transparent rounded-full animate-spin" />
<Loader2 className="w-5 h-5 animate-spin" />
) : (
<>
<Users size={16} />
<Users size={18} />
{t('createGroup')}
</>
)}
@@ -207,15 +209,15 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
{mode === 'personal' && (
<button
onClick={() => setMode('group-select')}
className="w-full flex items-center gap-3 px-3 py-2.5 rounded-xl bg-surface-tertiary hover:bg-surface-hover transition-colors border border-border"
className="w-full flex items-center gap-4 px-4 py-3.5 rounded-2xl bg-surface-container-highest/20 hover:bg-surface-container-highest/40 transition-all border border-white/5 active:scale-[0.98] group"
>
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center">
<Users size={18} className="text-white" />
<div className="w-11 h-11 rounded-full bg-linear-to-br from-primary to-primary-container flex items-center justify-center shadow-lg group-hover:scale-105 transition-transform">
<Users size={20} className="text-on-primary" />
</div>
<div className="text-left">
<p className="text-sm font-medium text-white">{t('createGroup')}</p>
<p className="text-xs text-zinc-500">
{t('upTo200').replace('200', String(config?.maxGroupMembers || 500))}
<p className="text-[13px] font-black uppercase tracking-tight text-white/90">{t('createGroup')}</p>
<p className="text-[11px] text-zinc-500 font-medium tracking-wide">
{t('upTo200').replace('200', String(config?.chats?.maxGroupParticipants || 500))}
</p>
</div>
</button>
@@ -237,8 +239,8 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
</div>
)}
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-500" />
<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={
@@ -248,7 +250,7 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
}
value={query}
onChange={(e) => setQuery(e.target.value)}
className="w-full pl-9 pr-4 py-2.5 rounded-xl bg-surface-tertiary text-sm text-white placeholder-zinc-500 border border-border focus:border-accent transition-colors"
className="w-full pl-12 pr-4 py-4 rounded-2xl bg-surface-container-highest/20 text-sm text-white placeholder-zinc-500 border border-white/5 focus:border-primary/40 outline-none transition-all focus:bg-surface-container-highest/30"
autoFocus
/>
</div>
@@ -261,45 +263,47 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
<div className="w-5 h-5 border-2 border-knot-500 border-t-transparent rounded-full animate-spin" />
</div>
) : query.trim().length >= 3 && users.length > 0 ? (
users.map((u) => (
<div className="space-y-1 px-1">
{users.map((u) => (
<button
key={u.id}
onClick={() => handleSelectUser(u)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors ${
className={`w-full flex items-center gap-4 px-4 py-3 rounded-2xl transition-all active:scale-[0.98] ${
isSelected(u.id)
? 'bg-knot-500/15 border border-knot-500/30'
: 'hover:bg-surface-hover border border-transparent'
? 'bg-primary/10 border border-primary/20'
: 'hover:bg-white/5 border border-transparent'
}`}
>
<div className="relative flex-shrink-0">
{u.avatar ? (
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
<img src={u.avatar} alt="" className="w-11 h-11 rounded-2xl object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-semibold text-sm">
<div className="w-11 h-11 rounded-2xl bg-linear-to-br from-primary to-primary-container flex items-center justify-center text-on-primary font-black text-sm shadow-inner">
{(u.displayName || u.userName || u.username || '?')[0]?.toUpperCase() || '?'}
</div>
)}
{u.isOnline && (
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
<span className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-emerald-500 rounded-full border-[3px] border-surface-container shadow-lg" />
)}
</div>
<div className="min-w-0 text-left flex-1">
<p className="text-sm font-medium text-white truncate">
<p className="text-[14px] font-bold text-white/90 truncate">
{u.displayName || u.userName || u.username || ''}
</p>
<p className="text-xs text-zinc-500 truncate">@{u.userName || u.username || ''}</p>
<p className="text-[11px] text-zinc-500 font-medium truncate">@{u.userName || u.username || ''}</p>
</div>
{mode === 'group-select' && (
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
<div className={`w-6 h-6 rounded-lg border-2 flex items-center justify-center flex-shrink-0 transition-all ${
isSelected(u.id)
? 'bg-knot-500 border-knot-500'
: 'border-zinc-600'
? 'bg-primary border-primary shadow-[0_0_15px_rgba(154,203,255,0.3)]'
: 'border-white/10'
}`}>
{isSelected(u.id) && <Check size={12} className="text-white" />}
{isSelected(u.id) && <Check size={14} className="text-on-primary" strokeWidth={4} />}
</div>
)}
</button>
))
))}
</div>
) : query.trim().length >= 3 && users.length === 0 ? (
<div className="text-center py-8 text-zinc-500">
<p className="text-sm">{t('usersNotFound')}</p>
@@ -310,46 +314,48 @@ export default function NewChatModal({ onClose }: NewChatModalProps) {
</div>
) : friends.length > 0 ? (
<>
<p className="text-xs text-zinc-500 uppercase tracking-wider px-2 mb-2 font-semibold">{t('friends')}</p>
<p className="text-[10px] text-zinc-500 uppercase tracking-[0.2em] px-4 mb-3 font-bold">{t('friends')}</p>
<div className="space-y-1 px-1">
{friends.map((u) => (
<button
key={u.id}
onClick={() => handleSelectUser(u)}
className={`w-full flex items-center gap-3 px-3 py-2.5 rounded-xl transition-colors ${
className={`w-full flex items-center gap-4 px-4 py-3 rounded-2xl transition-all active:scale-[0.98] ${
isSelected(u.id)
? 'bg-knot-500/15 border border-knot-500/30'
: 'hover:bg-surface-hover border border-transparent'
? 'bg-primary/10 border border-primary/20'
: 'hover:bg-white/5 border border-transparent'
}`}
>
<div className="relative flex-shrink-0">
{u.avatar ? (
<img src={u.avatar} alt="" className="w-10 h-10 rounded-full object-cover" />
<img src={u.avatar} alt="" className="w-11 h-11 rounded-2xl object-cover" />
) : (
<div className="w-10 h-10 rounded-full bg-gradient-to-br from-knot-500 to-purple-600 flex items-center justify-center text-white font-semibold text-sm">
<div className="w-11 h-11 rounded-2xl bg-linear-to-br from-primary to-primary-container flex items-center justify-center text-on-primary font-black text-sm shadow-inner">
{(u.displayName || u.userName || u.username || '?')[0]?.toUpperCase() || '?'}
</div>
)}
{u.isOnline && (
<span className="absolute bottom-0 right-0 w-3 h-3 bg-emerald-500 rounded-full border-2 border-surface-secondary" />
<span className="absolute -bottom-0.5 -right-0.5 w-3.5 h-3.5 bg-emerald-500 rounded-full border-[3px] border-surface-container shadow-lg" />
)}
</div>
<div className="min-w-0 text-left flex-1">
<p className="text-sm font-medium text-white truncate">
<p className="text-[14px] font-bold text-white/90 truncate">
{u.displayName || u.userName || u.username || ''}
</p>
<p className="text-xs text-zinc-500 truncate">@{u.userName || u.username || ''}</p>
<p className="text-[11px] text-zinc-500 font-medium truncate">@{u.userName || u.username || ''}</p>
</div>
{mode === 'group-select' && (
<div className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 transition-colors ${
<div className={`w-6 h-6 rounded-lg border-2 flex items-center justify-center flex-shrink-0 transition-all ${
isSelected(u.id)
? 'bg-knot-500 border-knot-500'
: 'border-zinc-600'
? 'bg-primary border-primary shadow-[0_0_15px_rgba(154,203,255,0.3)]'
: 'border-white/10'
}`}>
{isSelected(u.id) && <Check size={12} className="text-white" />}
{isSelected(u.id) && <Check size={14} className="text-on-primary" strokeWidth={4} />}
</div>
)}
</button>
))}
</div>
</>
) : (
<div className="flex flex-col items-center gap-2 py-8 text-zinc-500">
@@ -95,9 +95,7 @@ export const useFriendStore = create<FriendState>((set, get) => ({
const socket = getSocket();
if (socket) socket.emit('friend_request', { friendId });
if (result.status === 'accepted') {
await get().loadFriends();
}
set((state) => ({
searchResults: state.searchResults.filter(u => u.id !== friendId)
}));
@@ -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>
);
}
File diff suppressed because it is too large Load Diff