Compare commits
26 Commits
main
...
bugfix_web
| Author | SHA1 | Date | |
|---|---|---|---|
| f4f61071ed | |||
| e8c9a55fe9 | |||
| bccbf12a11 | |||
|
|
c8b0384392 | ||
| 5245e8b7ae | |||
| b346372555 | |||
| d2e6eb578a | |||
| ae710c3d43 | |||
|
|
600e43eec5 | ||
|
|
7b251686b2 | ||
| 00682b2977 | |||
| e05572fc3f | |||
|
|
c264b7df27 | ||
|
|
56f75ae32b | ||
|
|
ca9cf27716 | ||
|
|
7225e3272e | ||
|
|
86ae06beb6 | ||
|
|
454f70f716 | ||
|
|
e700609d30 | ||
|
|
88f39aa51f | ||
|
|
c3dbbaa7b8 | ||
|
|
2eb4f48ca0 | ||
|
|
0c1adaab6c | ||
|
|
a52726d0e6 | ||
|
|
d812a7a40c | ||
|
|
c8b4fed25a |
@@ -1,13 +1,13 @@
|
||||
using FluentAssertions;
|
||||
using Knot.Modules.Profiles.Application.Profiles.UpdateProfile;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Modules.Profiles.Application.Abstractions;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
namespace Knot.Modules.Profiles.UnitTests;
|
||||
|
||||
@@ -25,14 +25,11 @@ public class UpdateProfileCommandHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_ShouldReturnError_WhenProfileNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null);
|
||||
_profileRepository.GetByIdAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((ProfileDocument?)null);
|
||||
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null, null);
|
||||
_profileRepository.GetAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((UserProfileDto?)null);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsFailure.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,4 +8,7 @@ public static class AuthErrors
|
||||
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
|
||||
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
|
||||
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
|
||||
public static Error PasswordConfirmationMismatch => new("Auth.PasswordConfirmationMismatch", "Passwords do not match");
|
||||
public static Error PasswordTooShort => new("Auth.PasswordTooShort", "Password must be at least 8 characters");
|
||||
public static Error OldPasswordInvalid => new("Auth.OldPasswordInvalid", "Current password is incorrect");
|
||||
}
|
||||
|
||||
@@ -9,4 +9,8 @@ public static class ProfilesErrors
|
||||
public static Error AvatarNotFound => new("Profiles.AvatarNotFound", "Avatar not found");
|
||||
public static Error InvalidAvatarFormat => new("Profiles.InvalidAvatarFormat", "Invalid avatar format");
|
||||
public static Error AvatarUploadFailed => new("Profiles.AvatarUploadFailed", "Avatar upload failed");
|
||||
public static Error BioTooLong => new("Profiles.BioTooLong", "Bio must be 200 characters or less");
|
||||
public static Error StatusTextTooLong => new("Profiles.StatusTextTooLong", "Status text must be 50 characters or less");
|
||||
public static Error StatusEmpty => new("Statuses.Empty", "Status emoji or text is required");
|
||||
public static Error InvalidPreset => new("Statuses.InvalidPreset", "Unknown status preset");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
public sealed class StatusPresetDto
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Emoji { get; set; } = "";
|
||||
public string TextRu { get; set; } = "";
|
||||
public string TextEn { get; set; } = "";
|
||||
}
|
||||
@@ -12,5 +12,13 @@ public class UserProfileDto
|
||||
public DateTime? LastSeen { get; set; }
|
||||
public DateTime? Birthday { get; set; }
|
||||
public bool IsPremium { get; set; }
|
||||
public UserStatusDto? Status { get; set; }
|
||||
|
||||
public Guid? CurrentStatusId { get; set; }
|
||||
|
||||
public string? StatusText { get; set; }
|
||||
public string? StatusEmoji { get; set; }
|
||||
public DateTime? StatusExpiresAt { get; set; }
|
||||
public bool IsInvisible { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
public sealed class UserStatusDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = "Custom";
|
||||
public string Emoji { get; set; } = "";
|
||||
public string Text { get; set; } = "";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Contracts.Profiles.Domain;
|
||||
|
||||
public interface IProfileStatusWriter
|
||||
{
|
||||
Task<Result<UserProfileDto>> SetCustomAsync(Guid userId, string emoji, string text, DateTime? expiresAt, string? presetKey, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Result<UserProfileDto>> ClearAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
namespace Knot.Contracts.Profiles.Domain;
|
||||
|
||||
public interface IUserStatusRepository
|
||||
{
|
||||
Task<UserStatusDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyDictionary<Guid, UserStatusDto>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default);
|
||||
|
||||
Task InsertAsync(UserStatusDto dto, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -250,6 +250,7 @@ app.MapStoriesEndpoints();
|
||||
app.MapContactsEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapProfilesEndpoints();
|
||||
app.MapStatusesEndpoints();
|
||||
app.MapFederationEndpoints();
|
||||
app.MapKlipyEndpoints();
|
||||
app.MapChatsEndpoints();
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using BCrypt.Net;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Modules.Auth.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.ChangePassword;
|
||||
|
||||
public sealed record ChangePasswordCommand(
|
||||
Guid UserId,
|
||||
string OldPassword,
|
||||
string NewPassword,
|
||||
string ConfirmPassword) : ICommand;
|
||||
|
||||
internal sealed class ChangePasswordCommandHandler : ICommandHandler<ChangePasswordCommand>
|
||||
{
|
||||
private readonly IAuthDbContext _dbContext;
|
||||
|
||||
public ChangePasswordCommandHandler(IAuthDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(ChangePasswordCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.NewPassword != request.ConfirmPassword)
|
||||
return Result.Failure(AuthErrors.PasswordConfirmationMismatch);
|
||||
|
||||
if (request.NewPassword.Length < 8)
|
||||
return Result.Failure(AuthErrors.PasswordTooShort);
|
||||
|
||||
var user = await _dbContext.Set<User>()
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
return Result.Failure(AuthErrors.UserNotFound);
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(request.OldPassword, user.PasswordHash))
|
||||
return Result.Failure(AuthErrors.OldPasswordInvalid);
|
||||
|
||||
user.ChangePassword(BCrypt.Net.BCrypt.HashPassword(request.NewPassword));
|
||||
user.SetRefreshToken(null);
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Auth.Application.Users.Login;
|
||||
using Knot.Modules.Auth.Application.Users.Register;
|
||||
using Knot.Modules.Auth.Application.Users.GetMe;
|
||||
using Knot.Modules.Auth.Application.Users.ChangePassword;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -12,6 +13,8 @@ namespace Knot.Modules.Auth.Presentation.Endpoints;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public sealed record ChangePasswordRequest(string OldPassword, string NewPassword, string ConfirmPassword);
|
||||
|
||||
public static void MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("api/auth");
|
||||
@@ -33,5 +36,19 @@ public static class AuthEndpoints
|
||||
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
|
||||
}).RequireAuthorization();
|
||||
|
||||
group.MapPost("change-password", async ([FromBody] ChangePasswordRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ChangePasswordCommand(userContext.UserId, request.OldPassword, request.NewPassword, request.ConfirmPassword),
|
||||
ct);
|
||||
|
||||
if (result.IsSuccess) return Results.Ok();
|
||||
|
||||
if (result.Error.Code == "Auth.OldPasswordInvalid")
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
|
||||
return Results.BadRequest(new { error = result.Error.Code ?? result.Error.Description });
|
||||
}).RequireAuthorization();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ using Knot.Modules.Conversations.Application.DTOs;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
|
||||
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
|
||||
@@ -51,6 +52,7 @@ public sealed class ChatHub : Hub
|
||||
private readonly ILogger<ChatHub> _logger;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
private readonly IProfileRepository _profileRepository;
|
||||
|
||||
public ChatHub(
|
||||
ISender sender,
|
||||
@@ -60,7 +62,8 @@ public sealed class ChatHub : Hub
|
||||
IMessageRepository messageRepository,
|
||||
ILogger<ChatHub> logger,
|
||||
IMemoryCache cache,
|
||||
IUserDisplayNameProvider userProvider)
|
||||
IUserDisplayNameProvider userProvider,
|
||||
IProfileRepository profileRepository)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
@@ -70,6 +73,7 @@ public sealed class ChatHub : Hub
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_userProvider = userProvider;
|
||||
_profileRepository = profileRepository;
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
@@ -95,7 +99,15 @@ public sealed class ChatHub : Hub
|
||||
|
||||
userId, Context.ConnectionId, userChats.Count);
|
||||
|
||||
await Clients.Others.SendAsync("user_online", new { userId });
|
||||
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||
if (!isInvisible)
|
||||
{
|
||||
await BroadcastPresenceToVisibleUsersAsync(
|
||||
"user_online",
|
||||
new { userId },
|
||||
_userContext.UserId,
|
||||
Context.ConnectionAborted);
|
||||
}
|
||||
}
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
@@ -111,7 +123,22 @@ public sealed class ChatHub : Hub
|
||||
if (set.Count == 0)
|
||||
{
|
||||
_userConnections.TryRemove(userId, out _);
|
||||
await Clients.Others.SendAsync("user_offline", new { userId, lastSeen = DateTime.UtcNow });
|
||||
try
|
||||
{
|
||||
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, CancellationToken.None);
|
||||
if (!isInvisible)
|
||||
{
|
||||
await BroadcastPresenceToVisibleUsersAsync(
|
||||
"user_offline",
|
||||
new { userId, lastSeen = DateTime.UtcNow },
|
||||
_userContext.UserId,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Presence broadcast on disconnect failed for {UserId}", userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
_cache.Set("Global_OnlineUsersCount", _userConnections.Count);
|
||||
@@ -120,6 +147,56 @@ public sealed class ChatHub : Hub
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
private async Task<bool> IsUserInvisibleAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var profile = await _profileRepository.GetAsync(userId, ct);
|
||||
return profile?.IsInvisible ?? false;
|
||||
}
|
||||
|
||||
private async Task BroadcastPresenceToVisibleUsersAsync(
|
||||
string eventName,
|
||||
object payload,
|
||||
Guid sourceUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var recipients = _userConnections.Keys
|
||||
.Where(id => id != sourceUserId.ToString())
|
||||
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (recipients.Count == 0)
|
||||
return;
|
||||
|
||||
var recipientProfiles = await _profileRepository.GetAsync(recipients, cancellationToken);
|
||||
var invisibleRecipientIds = recipientProfiles
|
||||
.Where(p => p.IsInvisible)
|
||||
.Select(p => p.UserId)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var kvp in _userConnections)
|
||||
{
|
||||
if (!Guid.TryParse(kvp.Key, out var recipientId))
|
||||
continue;
|
||||
if (recipientId == sourceUserId)
|
||||
continue;
|
||||
if (invisibleRecipientIds.Contains(recipientId))
|
||||
continue;
|
||||
|
||||
string[] connectionIds;
|
||||
lock (kvp.Value)
|
||||
{
|
||||
connectionIds = kvp.Value.ToArray();
|
||||
}
|
||||
|
||||
foreach (var connectionId in connectionIds)
|
||||
{
|
||||
await Clients.Client(connectionId).SendAsync(eventName, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Chat methods
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
|
||||
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,5 +2,9 @@ using System;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
|
||||
|
||||
public record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
|
||||
public record UpdateProfileRequest(
|
||||
string? DisplayName,
|
||||
string? Bio,
|
||||
DateTime? Birthday,
|
||||
bool? IsInvisible);
|
||||
public record UpdateSettingsRequest(bool? HideStoryViews);
|
||||
|
||||
@@ -11,7 +11,8 @@ public sealed record UpdateProfileCommand(
|
||||
Guid UserId,
|
||||
string? DisplayName,
|
||||
string? Bio,
|
||||
DateTime? Birthday) : ICommand<UserProfileDto>;
|
||||
DateTime? Birthday,
|
||||
bool? IsInvisible) : ICommand<UserProfileDto>;
|
||||
|
||||
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
|
||||
{
|
||||
@@ -28,9 +29,13 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfil
|
||||
if (profile is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
if ((request.Bio?.Length ?? 0) > 200)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.BioTooLong);
|
||||
|
||||
profile.DisplayName = request.DisplayName ?? profile.DisplayName;
|
||||
profile.About = request.Bio;
|
||||
profile.Birthday = request.Birthday;
|
||||
profile.IsInvisible = request.IsInvisible ?? profile.IsInvisible;
|
||||
|
||||
var result = await _repository.UpdateAsync(profile, cancellationToken);
|
||||
if (result.IsFailure)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Statuses;
|
||||
|
||||
public sealed record ClearUserStatusCommand(Guid UserId) : IRequest<Result<UserProfileDto>>;
|
||||
|
||||
public sealed class ClearUserStatusCommandHandler : IRequestHandler<ClearUserStatusCommand, Result<UserProfileDto>>
|
||||
{
|
||||
private readonly IProfileStatusWriter _writer;
|
||||
|
||||
public ClearUserStatusCommandHandler(IProfileStatusWriter writer)
|
||||
{
|
||||
_writer = writer;
|
||||
}
|
||||
|
||||
public Task<Result<UserProfileDto>> Handle(ClearUserStatusCommand request, CancellationToken cancellationToken)
|
||||
=> _writer.ClearAsync(request.UserId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Statuses;
|
||||
|
||||
public sealed record GetStatusPresetsQuery : IRequest<IReadOnlyList<StatusPresetDto>>;
|
||||
|
||||
public sealed class GetStatusPresetsQueryHandler : IRequestHandler<GetStatusPresetsQuery, IReadOnlyList<StatusPresetDto>>
|
||||
{
|
||||
public Task<IReadOnlyList<StatusPresetDto>> Handle(GetStatusPresetsQuery request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(StatusPresetCatalog.All);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Statuses;
|
||||
|
||||
public sealed record SetCustomUserStatusCommand(
|
||||
Guid UserId,
|
||||
string? Emoji,
|
||||
string? Text,
|
||||
DateTime? ExpiresAt,
|
||||
string? PresetKey) : IRequest<Result<UserProfileDto>>;
|
||||
|
||||
public sealed class SetCustomUserStatusCommandHandler : IRequestHandler<SetCustomUserStatusCommand, Result<UserProfileDto>>
|
||||
{
|
||||
private readonly IProfileStatusWriter _writer;
|
||||
|
||||
public SetCustomUserStatusCommandHandler(IProfileStatusWriter writer)
|
||||
{
|
||||
_writer = writer;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> Handle(SetCustomUserStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = request.PresetKey?.Trim();
|
||||
if (!string.IsNullOrEmpty(key) && key.Equals("online", StringComparison.OrdinalIgnoreCase))
|
||||
return await _writer.ClearAsync(request.UserId, cancellationToken);
|
||||
|
||||
var emoji = request.Emoji?.Trim() ?? string.Empty;
|
||||
var text = request.Text?.Trim() ?? string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
var preset = StatusPresetCatalog.Find(key);
|
||||
if (preset is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.InvalidPreset);
|
||||
if (string.IsNullOrEmpty(emoji))
|
||||
emoji = preset.Emoji;
|
||||
if (string.IsNullOrEmpty(text))
|
||||
text = preset.TextRu;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(emoji) && string.IsNullOrEmpty(text))
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusEmpty);
|
||||
|
||||
if (text.Length > 50)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusTextTooLong);
|
||||
|
||||
var presetForWriter = string.IsNullOrWhiteSpace(key) ? null : key;
|
||||
return await _writer.SetCustomAsync(request.UserId, emoji, text, request.ExpiresAt, presetForWriter, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Statuses;
|
||||
|
||||
public static class StatusPresetCatalog
|
||||
{
|
||||
private static readonly StatusPresetDto[] Items =
|
||||
[
|
||||
new() { Id = "online", Emoji = "", TextRu = "Онлайн (стандарт)", TextEn = "Online (standard)" },
|
||||
new() { Id = "away", Emoji = "🕒", TextRu = "В отъезде", TextEn = "Away" },
|
||||
new() { Id = "dnd", Emoji = "⛔", TextRu = "Не беспокоить", TextEn = "Do not disturb" },
|
||||
new() { Id = "sick", Emoji = "🤒", TextRu = "Болен", TextEn = "Sick" },
|
||||
new() { Id = "angry", Emoji = "💢", TextRu = "Злой", TextEn = "Angry" }
|
||||
];
|
||||
|
||||
public static IReadOnlyList<StatusPresetDto> All => Items;
|
||||
|
||||
public static StatusPresetDto? Find(string presetKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(presetKey))
|
||||
return null;
|
||||
return Items.FirstOrDefault(x => x.Id.Equals(presetKey.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,11 @@ public static class DependencyInjection
|
||||
public static IServiceCollection AddProfilesModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileRepository, ProfileRepository>();
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IUserStatusRepository, UserStatusMongoRepository>();
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileStatusWriter, ProfileStatusWriter>();
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfilesUnitOfWork, ProfilesUnitOfWork>();
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IAvatarStorageService, AvatarStorageService>();
|
||||
|
||||
// MongoDB Registration
|
||||
var mongoConnection = configuration.GetConnectionString("MongoConnection")
|
||||
?? configuration["MONGO_URL"]
|
||||
?? "mongodb://mongo:27017";
|
||||
|
||||
@@ -3,10 +3,6 @@ using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Knot.Modules.Profiles.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB-документ профиля пользователя.
|
||||
/// Id совпадает с UserId из модуля Auth (Postgres).
|
||||
/// </summary>
|
||||
public sealed class ProfileDocument
|
||||
{
|
||||
[BsonId]
|
||||
@@ -19,12 +15,23 @@ public sealed class ProfileDocument
|
||||
|
||||
public string? Bio { get; private set; }
|
||||
|
||||
public string? StatusText { get; private set; }
|
||||
|
||||
public string? StatusEmoji { get; private set; }
|
||||
|
||||
public DateTime? StatusExpiresAt { get; private set; }
|
||||
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid? CurrentStatusId { get; private set; }
|
||||
|
||||
public string? AvatarUrl { get; private set; }
|
||||
|
||||
public DateTime? Birthday { get; private set; }
|
||||
|
||||
public bool HideStoryViews { get; private set; }
|
||||
|
||||
public bool IsInvisible { get; private set; }
|
||||
|
||||
public bool IsBanned { get; private set; }
|
||||
|
||||
public bool IsDeleted { get; private set; }
|
||||
@@ -65,6 +72,26 @@ public sealed class ProfileDocument
|
||||
public void UpdateSettings(bool hideStoryViews)
|
||||
=> HideStoryViews = hideStoryViews;
|
||||
|
||||
public void UpdateInvisible(bool isInvisible)
|
||||
=> IsInvisible = isInvisible;
|
||||
|
||||
public void UpdateStatus(string? statusText, string? statusEmoji, DateTime? statusExpiresAt)
|
||||
{
|
||||
StatusText = statusText;
|
||||
StatusEmoji = statusEmoji;
|
||||
StatusExpiresAt = statusExpiresAt;
|
||||
}
|
||||
|
||||
public void SetCurrentStatusId(Guid? statusId)
|
||||
=> CurrentStatusId = statusId;
|
||||
|
||||
public void ClearMoodStatusFields()
|
||||
{
|
||||
StatusText = null;
|
||||
StatusEmoji = null;
|
||||
StatusExpiresAt = null;
|
||||
}
|
||||
|
||||
public void UpdateStatus(bool isBanned, bool isDeleted)
|
||||
{
|
||||
IsBanned = isBanned;
|
||||
|
||||
26
backend/src/Modules/Profiles/Domain/UserStatusDocument.cs
Normal file
26
backend/src/Modules/Profiles/Domain/UserStatusDocument.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Knot.Modules.Profiles.Domain;
|
||||
|
||||
public sealed class UserStatusDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
public string Type { get; set; } = "Custom";
|
||||
|
||||
public string Emoji { get; set; } = "";
|
||||
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
public BsonDocument Metadata { get; set; } = new();
|
||||
}
|
||||
@@ -10,29 +10,39 @@ namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||
internal class ProfileRepository : IProfileRepository
|
||||
{
|
||||
private readonly IMongoCollection<ProfileDocument> _profiles;
|
||||
private readonly IUserStatusRepository _statuses;
|
||||
|
||||
public ProfileRepository(IMongoDatabase database)
|
||||
public ProfileRepository(IMongoDatabase database, IUserStatusRepository statuses)
|
||||
{
|
||||
_profiles = database.GetCollection<ProfileDocument>("profiles");
|
||||
_statuses = statuses;
|
||||
}
|
||||
|
||||
public async Task<UserProfileDto?> GetAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct);
|
||||
return profile?.ToDto();
|
||||
return profile is null ? null : await profile.ToDtoAsync(_statuses, ct);
|
||||
}
|
||||
|
||||
public async Task<UserProfileDto?> GetByUsernameAsync(string username, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Username == username).FirstOrDefaultAsync(ct);
|
||||
return profile?.ToDto();
|
||||
return profile is null ? null : await profile.ToDtoAsync(_statuses, ct);
|
||||
}
|
||||
|
||||
public async Task<List<UserProfileDto>> GetAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = userIds.ToList();
|
||||
var profiles = await _profiles.Find(p => ids.Contains(p.Id)).ToListAsync(ct);
|
||||
return profiles.Select(p => p.ToDto()).ToList();
|
||||
var statusIds = profiles
|
||||
.Where(p => p.CurrentStatusId.HasValue)
|
||||
.Select(p => p.CurrentStatusId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var map = statusIds.Count > 0
|
||||
? await _statuses.GetByIdsAsync(statusIds, ct)
|
||||
: new Dictionary<Guid, UserStatusDto>();
|
||||
return profiles.Select(p => ProfileMappings.ToDtoWithStatusMap(p, map)).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<UserProfileDto>> SearchAsync(string query, int limit = 20, CancellationToken ct = default)
|
||||
@@ -57,7 +67,16 @@ internal class ProfileRepository : IProfileRepository
|
||||
var combinedFilter = Builders<ProfileDocument>.Filter.And(baseFilter, searchFilter);
|
||||
docs = await _profiles.Find(combinedFilter).Limit(limit).ToListAsync(ct);
|
||||
}
|
||||
return docs.Select(p => p.ToDto()).ToList();
|
||||
|
||||
var statusIds = docs
|
||||
.Where(p => p.CurrentStatusId.HasValue)
|
||||
.Select(p => p.CurrentStatusId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var map = statusIds.Count > 0
|
||||
? await _statuses.GetByIdsAsync(statusIds, ct)
|
||||
: new Dictionary<Guid, UserStatusDto>();
|
||||
return docs.Select(p => ProfileMappings.ToDtoWithStatusMap(p, map)).ToList();
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken ct = default)
|
||||
@@ -65,7 +84,7 @@ internal class ProfileRepository : IProfileRepository
|
||||
var profile = ProfileDocument.Create(dto.UserId, dto.Username ?? string.Empty, dto.DisplayName ?? string.Empty, dto.About);
|
||||
|
||||
await _profiles.InsertOneAsync(profile, null, ct);
|
||||
return Result.Success(profile.ToDto());
|
||||
return Result.Success(await profile.ToDtoAsync(_statuses, ct));
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken ct = default)
|
||||
@@ -80,9 +99,13 @@ internal class ProfileRepository : IProfileRepository
|
||||
dto.Birthday);
|
||||
|
||||
document.UpdateAvatar(dto.Avatar);
|
||||
document.UpdateInvisible(dto.IsInvisible);
|
||||
|
||||
await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct);
|
||||
return Result.Success(document.ToDto());
|
||||
var fresh = await _profiles.Find(p => p.Id == dto.UserId).FirstOrDefaultAsync(ct);
|
||||
if (fresh is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
return Result.Success(await fresh.ToDtoAsync(_statuses, ct));
|
||||
}
|
||||
|
||||
public async Task<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Infrastructure.Mappings;
|
||||
using Knot.Shared.Kernel;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||
|
||||
internal sealed class ProfileStatusWriter : IProfileStatusWriter
|
||||
{
|
||||
private readonly IMongoCollection<ProfileDocument> _profiles;
|
||||
private readonly IUserStatusRepository _statuses;
|
||||
|
||||
public ProfileStatusWriter(IMongoDatabase database, IUserStatusRepository statuses)
|
||||
{
|
||||
_profiles = database.GetCollection<ProfileDocument>("profiles");
|
||||
_statuses = statuses;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> SetCustomAsync(Guid userId, string emoji, string text, DateTime? expiresAt, string? presetKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
if (profile.CurrentStatusId is Guid oldId)
|
||||
await _statuses.DeleteAsync(oldId, cancellationToken);
|
||||
|
||||
var newId = Guid.NewGuid();
|
||||
var type = string.IsNullOrWhiteSpace(presetKey) ? "Custom" : "Preset";
|
||||
var dto = new UserStatusDto
|
||||
{
|
||||
Id = newId,
|
||||
Type = type,
|
||||
Emoji = emoji,
|
||||
Text = text,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
await _statuses.InsertAsync(dto, userId, cancellationToken);
|
||||
|
||||
profile.SetCurrentStatusId(newId);
|
||||
profile.ClearMoodStatusFields();
|
||||
await _profiles.ReplaceOneAsync(p => p.Id == userId, profile, cancellationToken: cancellationToken);
|
||||
|
||||
var fresh = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
|
||||
if (fresh is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
return Result.Success(await fresh.ToDtoAsync(_statuses, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> ClearAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
if (profile.CurrentStatusId is Guid oldId)
|
||||
await _statuses.DeleteAsync(oldId, cancellationToken);
|
||||
|
||||
profile.SetCurrentStatusId(null);
|
||||
profile.ClearMoodStatusFields();
|
||||
await _profiles.ReplaceOneAsync(p => p.Id == userId, profile, cancellationToken: cancellationToken);
|
||||
|
||||
var fresh = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
|
||||
if (fresh is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
return Result.Success(await fresh.ToDtoAsync(_statuses, cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||
|
||||
internal sealed class UserStatusMongoRepository : IUserStatusRepository
|
||||
{
|
||||
private readonly IMongoCollection<UserStatusDocument> _collection;
|
||||
|
||||
public UserStatusMongoRepository(IMongoDatabase database)
|
||||
{
|
||||
_collection = database.GetCollection<UserStatusDocument>("user_statuses");
|
||||
}
|
||||
|
||||
public async Task<UserStatusDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = await _collection.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken);
|
||||
return doc is null ? null : ToDto(doc);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<Guid, UserStatusDto>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var idList = ids.Distinct().ToList();
|
||||
if (idList.Count == 0)
|
||||
return new Dictionary<Guid, UserStatusDto>();
|
||||
|
||||
var docs = await _collection.Find(x => idList.Contains(x.Id)).ToListAsync(cancellationToken);
|
||||
return docs.ToDictionary(d => d.Id, ToDto);
|
||||
}
|
||||
|
||||
public async Task InsertAsync(UserStatusDto dto, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = new UserStatusDocument
|
||||
{
|
||||
Id = dto.Id,
|
||||
UserId = userId,
|
||||
Type = dto.Type,
|
||||
Emoji = dto.Emoji,
|
||||
Text = dto.Text,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
ExpiresAt = dto.ExpiresAt,
|
||||
Metadata = new BsonDocument()
|
||||
};
|
||||
await _collection.InsertOneAsync(doc, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _collection.DeleteOneAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
private static UserStatusDto ToDto(UserStatusDocument d) => new()
|
||||
{
|
||||
Id = d.Id,
|
||||
Type = d.Type,
|
||||
Emoji = d.Emoji,
|
||||
Text = d.Text,
|
||||
CreatedAt = d.CreatedAt,
|
||||
ExpiresAt = d.ExpiresAt
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
|
||||
namespace Knot.Modules.Profiles.Infrastructure.Mappings;
|
||||
|
||||
public static class ProfileMappings
|
||||
{
|
||||
public static UserProfileDto ToDto(this ProfileDocument document)
|
||||
public static UserProfileDto ToDtoWithStatusMap(ProfileDocument document, IReadOnlyDictionary<Guid, UserStatusDto> statusMap)
|
||||
{
|
||||
return new UserProfileDto
|
||||
var dto = new UserProfileDto
|
||||
{
|
||||
UserId = document.Id,
|
||||
DisplayName = document.DisplayName,
|
||||
@@ -18,7 +19,59 @@ public static class ProfileMappings
|
||||
LastSeen = null,
|
||||
Birthday = document.Birthday,
|
||||
IsPremium = false,
|
||||
CreatedAt = document.CreatedAt
|
||||
IsInvisible = document.IsInvisible,
|
||||
CreatedAt = document.CreatedAt,
|
||||
Status = null,
|
||||
StatusText = null,
|
||||
StatusEmoji = null,
|
||||
StatusExpiresAt = null,
|
||||
CurrentStatusId = null
|
||||
};
|
||||
|
||||
UserStatusDto? active = null;
|
||||
if (document.CurrentStatusId is Guid sid && statusMap.TryGetValue(sid, out var loaded) && loaded is not null)
|
||||
{
|
||||
var exp = loaded.ExpiresAt;
|
||||
if (!exp.HasValue || exp.Value > DateTime.UtcNow)
|
||||
active = loaded;
|
||||
}
|
||||
|
||||
if (active is not null)
|
||||
{
|
||||
dto.Status = active;
|
||||
dto.StatusText = active.Text;
|
||||
dto.StatusEmoji = active.Emoji;
|
||||
dto.StatusExpiresAt = active.ExpiresAt;
|
||||
dto.CurrentStatusId = active.Id;
|
||||
return dto;
|
||||
}
|
||||
|
||||
var legacyExpired = document.StatusExpiresAt.HasValue && document.StatusExpiresAt.Value <= DateTime.UtcNow;
|
||||
if (!legacyExpired && (!string.IsNullOrEmpty(document.StatusText) || !string.IsNullOrEmpty(document.StatusEmoji)))
|
||||
{
|
||||
dto.StatusText = document.StatusText;
|
||||
dto.StatusEmoji = document.StatusEmoji;
|
||||
dto.StatusExpiresAt = document.StatusExpiresAt;
|
||||
dto.Status = new UserStatusDto
|
||||
{
|
||||
Id = Guid.Empty,
|
||||
Type = "Custom",
|
||||
Emoji = document.StatusEmoji ?? string.Empty,
|
||||
Text = document.StatusText ?? string.Empty,
|
||||
CreatedAt = document.CreatedAt,
|
||||
ExpiresAt = document.StatusExpiresAt
|
||||
};
|
||||
return dto;
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
public static async Task<UserProfileDto> ToDtoAsync(this ProfileDocument document, IUserStatusRepository statuses, CancellationToken cancellationToken)
|
||||
{
|
||||
if (document.CurrentStatusId is not Guid sid)
|
||||
return ToDtoWithStatusMap(document, new Dictionary<Guid, UserStatusDto>());
|
||||
var map = await statuses.GetByIdsAsync(new[] { sid }, cancellationToken);
|
||||
return ToDtoWithStatusMap(document, map);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ public static class ProfilesEndpoints
|
||||
public static void MapProfilesEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("api/profiles").RequireAuthorization();
|
||||
var userGroup = app.MapGroup("api/user").RequireAuthorization();
|
||||
var usersGroup = app.MapGroup("api/users").RequireAuthorization();
|
||||
|
||||
group.MapGet("search", async ([FromQuery] string q, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
{
|
||||
@@ -67,16 +69,37 @@ public static class ProfilesEndpoints
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
|
||||
group.MapPut("profile", async ([FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
async Task<IResult> UpdateProfileHandler(
|
||||
[FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request,
|
||||
ISender sender,
|
||||
IUserContext userContext,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await sender.Send(new UpdateProfileCommand(userContext.UserId, request.DisplayName, request.Bio, request.Birthday), ct);
|
||||
var result = await sender.Send(
|
||||
new UpdateProfileCommand(
|
||||
userContext.UserId,
|
||||
request.DisplayName,
|
||||
request.Bio,
|
||||
request.Birthday,
|
||||
request.IsInvisible),
|
||||
ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
}
|
||||
|
||||
group.MapPut("profile", UpdateProfileHandler);
|
||||
group.MapPatch("profile", UpdateProfileHandler);
|
||||
userGroup.MapPatch("profile", UpdateProfileHandler);
|
||||
|
||||
group.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new GetProfileQuery(id), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
|
||||
usersGroup.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new GetProfileQuery(id), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Knot.Modules.Profiles.Application.Statuses;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Knot.Modules.Profiles.Presentation.Endpoints;
|
||||
|
||||
public static class StatusesEndpoints
|
||||
{
|
||||
public static void MapStatusesEndpoints(this WebApplication app)
|
||||
{
|
||||
var g = app.MapGroup("api/statuses").RequireAuthorization();
|
||||
|
||||
g.MapGet("presets", async (ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var list = await sender.Send(new GetStatusPresetsQuery(), ct);
|
||||
return Results.Ok(list);
|
||||
});
|
||||
|
||||
g.MapPost("custom", async ([FromBody] SetCustomStatusBody body, ISender sender, IUserContext ctx, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new SetCustomUserStatusCommand(ctx.UserId, body.Emoji, body.Text, body.ExpiresAt, body.PresetKey),
|
||||
ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
|
||||
});
|
||||
|
||||
g.MapDelete("/", async (ISender sender, IUserContext ctx, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new ClearUserStatusCommand(ctx.UserId), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class SetCustomStatusBody
|
||||
{
|
||||
public string? Emoji { get; set; }
|
||||
public string? Text { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? PresetKey { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -15,13 +15,34 @@ export interface UserPresence extends UserBasic {
|
||||
lastSeen: string;
|
||||
}
|
||||
|
||||
export interface UserStatus {
|
||||
id: string;
|
||||
type: string;
|
||||
emoji?: string | null;
|
||||
text?: string | null;
|
||||
createdAt?: string;
|
||||
expiresAt?: string | null;
|
||||
}
|
||||
|
||||
export interface User extends UserPresence {
|
||||
bio: string | null;
|
||||
birthday: string | null;
|
||||
status?: UserStatus | null;
|
||||
statusText?: string | null;
|
||||
statusEmoji?: string | null;
|
||||
statusExpiresAt?: string | null;
|
||||
isInvisible?: boolean;
|
||||
createdAt: string;
|
||||
hideStoryViews?: boolean;
|
||||
}
|
||||
|
||||
export interface StatusPreset {
|
||||
id: string;
|
||||
emoji: string;
|
||||
textRu: string;
|
||||
textEn: string;
|
||||
}
|
||||
|
||||
// ─── Chat types ────────────────────────────────────────────────────────
|
||||
|
||||
export interface ChatMember {
|
||||
|
||||
@@ -286,7 +286,7 @@ const translations = {
|
||||
mediaTab: 'Медиа',
|
||||
gifs: 'GIF',
|
||||
filesTab: 'Файлы',
|
||||
linksTab: 'Ссылки',
|
||||
linksTab: 'Ссылкии',
|
||||
profileTitle: 'Профиль',
|
||||
removeAvatar: 'Удалить аватар',
|
||||
namePlaceholder: 'Имя',
|
||||
|
||||
@@ -17,7 +17,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:py-8 lg:gap-4 z-50 transition-all safe-area-bottom"
|
||||
className="lg:fixed lg:left-0 lg:top-0 lg:h-[100dvh] lg:w-20 w-full h-16 fixed bottom-0 left-0 bg-surface-container-low border-t lg:border-t-0 lg:border-r border-white/5 flex lg:flex-col flex-row items-center justify-around lg:justify-start lg:pt-8 lg:pb-14 lg:gap-4 z-50 transition-all safe-area-bottom"
|
||||
>
|
||||
<div className="hidden lg:flex mb-10 flex-col items-center">
|
||||
<span className="text-2xl font-black text-primary tracking-tighter italic knot-logo-spin">Knot</span>
|
||||
@@ -46,7 +46,7 @@ export default function GlobalNavBar({ activeTab, onTabChange }: GlobalNavBarPro
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="lg:mt-auto group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
|
||||
className="lg:mt-auto lg:mb-4 group cursor-pointer relative flex items-center justify-center px-4 lg:px-0"
|
||||
onClick={() => onTabChange('settings')}
|
||||
>
|
||||
<div className="absolute -inset-1 bg-primary/20 rounded-2xl opacity-0 group-hover:opacity-100 blur transition-opacity" />
|
||||
|
||||
@@ -17,7 +17,7 @@ export function normalizeUser(user: any): any {
|
||||
result.username = result.userName;
|
||||
}
|
||||
|
||||
// 2. Приведение avatar (Auth -> avatar, Profiles -> avatarUrl)
|
||||
|
||||
if (!result.avatarUrl && result.avatar) {
|
||||
result.avatarUrl = result.avatar;
|
||||
}
|
||||
@@ -25,6 +25,45 @@ export function normalizeUser(user: any): any {
|
||||
result.avatar = result.avatarUrl;
|
||||
}
|
||||
|
||||
|
||||
if (!result.id && result.userId != null) {
|
||||
result.id = String(result.userId);
|
||||
}
|
||||
if (!result.id && result.UserId != null) {
|
||||
result.id = String(result.UserId);
|
||||
}
|
||||
|
||||
if (!result.bio && result.about) {
|
||||
result.bio = result.about;
|
||||
}
|
||||
if (!result.about && result.bio) {
|
||||
result.about = result.bio;
|
||||
}
|
||||
|
||||
|
||||
if (!result.statusText && result.status_text) {
|
||||
result.statusText = result.status_text;
|
||||
}
|
||||
if (!result.statusEmoji && result.status_emoji) {
|
||||
result.statusEmoji = result.status_emoji;
|
||||
}
|
||||
if (!result.statusExpiresAt && result.status_expires_at) {
|
||||
result.statusExpiresAt = result.status_expires_at;
|
||||
}
|
||||
if (!result.status && result.Status) {
|
||||
result.status = result.Status;
|
||||
}
|
||||
if (result.status && typeof result.status === 'object') {
|
||||
const s = result.status as Record<string, unknown>;
|
||||
if (s.expiresAt == null && s.expires_at != null) s.expiresAt = s.expires_at;
|
||||
if (s.createdAt == null && s.created_at != null) s.createdAt = s.created_at;
|
||||
}
|
||||
|
||||
// 5. Privacy flag
|
||||
if (typeof result.isInvisible === 'undefined' && typeof result.is_invisible !== 'undefined') {
|
||||
result.isInvisible = result.is_invisible;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -49,7 +88,13 @@ export function deepNormalize(obj: any): any {
|
||||
const result = { ...obj };
|
||||
|
||||
// Если это объект, похожий на пользователя (есть id и userName/username)
|
||||
if (result.id && (result.userName || result.username)) {
|
||||
if ((result.id || result.userId != null || result.UserId != null) && (result.userName || result.username)) {
|
||||
if (!result.id && result.userId != null) {
|
||||
result.id = String(result.userId);
|
||||
}
|
||||
if (!result.id && result.UserId != null) {
|
||||
result.id = String(result.UserId);
|
||||
}
|
||||
const normalized = normalizeUser(result);
|
||||
// Продолжаем рекурсию по остальным полям (например, если у пользователя есть вложенные объекты)
|
||||
for (const key in normalized) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { User } from '../../../core/domain/types';
|
||||
|
||||
export class AuthApi {
|
||||
static async login(username: string, password: string) {
|
||||
const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/login', {
|
||||
const response = await httpClient.request<any>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
@@ -11,16 +11,17 @@ export class AuthApi {
|
||||
return {
|
||||
token: response.accessToken,
|
||||
user: {
|
||||
id: response.userId,
|
||||
username: response.username,
|
||||
...response,
|
||||
id: response.userId || (response as any).id,
|
||||
username: response.username || (response as any).userName,
|
||||
displayName: response.displayName,
|
||||
avatar: null
|
||||
} as User
|
||||
avatar: response.avatar || null
|
||||
} as unknown as User
|
||||
};
|
||||
}
|
||||
|
||||
static async register(username: string, displayName: string, password: string, bio?: string) {
|
||||
const response = await httpClient.request<{ accessToken: string; userId: string; username: string; displayName: string }>('/auth/register', {
|
||||
const response = await httpClient.request<any>('/auth/register', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ username, displayName, password, bio }),
|
||||
});
|
||||
@@ -28,24 +29,32 @@ export class AuthApi {
|
||||
return {
|
||||
token: response.accessToken,
|
||||
user: {
|
||||
id: response.userId,
|
||||
username: response.username,
|
||||
...response,
|
||||
id: response.userId || (response as any).id,
|
||||
username: response.username || (response as any).userName,
|
||||
displayName: response.displayName,
|
||||
avatar: null
|
||||
} as User
|
||||
avatar: response.avatar || null
|
||||
} as unknown as User
|
||||
};
|
||||
}
|
||||
|
||||
static async getMe() {
|
||||
const response = await httpClient.request<{ userId: string; username: string; displayName: string; avatar: string | null; accessToken?: string }>('/auth/me');
|
||||
const authRes = await httpClient.request<any>('/auth/me');
|
||||
const userId = authRes.userId || authRes.id;
|
||||
|
||||
// Пытаемся получить расширенный профиль (био, дата рождения)
|
||||
const profileRes = await httpClient.request<any>(`/profiles/${userId}`).catch(() => ({}));
|
||||
|
||||
return {
|
||||
user: {
|
||||
id: response.userId,
|
||||
username: response.username,
|
||||
displayName: response.displayName,
|
||||
avatar: response.avatar
|
||||
} as User,
|
||||
token: response.accessToken
|
||||
...authRes,
|
||||
...profileRes,
|
||||
id: userId,
|
||||
username: authRes.username || authRes.userName || profileRes.userName,
|
||||
displayName: authRes.displayName || profileRes.displayName,
|
||||
avatar: authRes.avatar || authRes.avatarUrl || profileRes.avatarUrl
|
||||
} as unknown as User,
|
||||
token: authRes.accessToken
|
||||
};
|
||||
}
|
||||
|
||||
@@ -53,6 +62,13 @@ export class AuthApi {
|
||||
return httpClient.request<any>('/config');
|
||||
}
|
||||
|
||||
static async changePassword(data: { oldPassword: string; newPassword: string; confirmPassword: string }) {
|
||||
return httpClient.request<void>('/auth/change-password', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
});
|
||||
}
|
||||
|
||||
static setToken(token: string | null) {
|
||||
httpClient.setToken(token);
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ interface ChatState {
|
||||
addTypingUser: (chatId: string, userId: string) => void;
|
||||
removeTypingUser: (chatId: string, userId: string) => void;
|
||||
updateUserOnlineStatus: (userId: string, isOnline: boolean, lastSeen?: string) => void;
|
||||
applyInvisiblePrivacyMode: (enabled: boolean) => void;
|
||||
setReplyTo: (message: Message | null) => void;
|
||||
setEditingMessage: (message: Message | null) => void;
|
||||
addChat: (chat: Chat) => void;
|
||||
@@ -124,7 +125,11 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
|
||||
set({ isLoadingMessages: true });
|
||||
const currentMessages = state.messages[chatId] || [];
|
||||
const cursor = !reset && currentMessages.length > 0 ? currentMessages[0].sequenceId.toString() : undefined;
|
||||
const firstSeq = currentMessages[0]?.sequenceId;
|
||||
const cursor =
|
||||
!reset && currentMessages.length > 0 && firstSeq != null
|
||||
? String(firstSeq)
|
||||
: undefined;
|
||||
|
||||
const fetched = await ChatApi.getMessages(chatId, cursor);
|
||||
|
||||
@@ -457,6 +462,9 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
},
|
||||
|
||||
updateUserOnlineStatus: (userId, isOnline, lastSeen) => {
|
||||
if (useAuthStore.getState().user?.isInvisible) {
|
||||
return;
|
||||
}
|
||||
set((state) => ({
|
||||
chats: state.chats.map((chat) => ({
|
||||
...chat,
|
||||
@@ -469,6 +477,22 @@ export const useChatStore = create<ChatState>((set, get) => ({
|
||||
}));
|
||||
},
|
||||
|
||||
applyInvisiblePrivacyMode: (enabled) => {
|
||||
if (!enabled) return;
|
||||
set((state) => ({
|
||||
chats: state.chats.map((chat) => ({
|
||||
...chat,
|
||||
members: chat.members.map((member) => ({
|
||||
...member,
|
||||
user: {
|
||||
...member.user,
|
||||
isOnline: false,
|
||||
},
|
||||
})),
|
||||
})),
|
||||
}));
|
||||
},
|
||||
|
||||
setReplyTo: (message) => set({ replyTo: message, editingMessage: null }),
|
||||
setEditingMessage: (message) => set({ editingMessage: message, replyTo: null }),
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ export default function ChatPage() {
|
||||
addTypingUser,
|
||||
removeTypingUser,
|
||||
updateUserOnlineStatus,
|
||||
applyInvisiblePrivacyMode,
|
||||
setPinnedMessage,
|
||||
removePinnedMessage,
|
||||
clearStore,
|
||||
@@ -76,6 +77,12 @@ export default function ChatPage() {
|
||||
loadChats();
|
||||
}, [loadChats]);
|
||||
|
||||
useEffect(() => {
|
||||
if (user?.isInvisible) {
|
||||
applyInvisiblePrivacyMode(true);
|
||||
}
|
||||
}, [user?.isInvisible, applyInvisiblePrivacyMode]);
|
||||
|
||||
// Обработка закрытия вкладки — отправить disconnect
|
||||
useEffect(() => {
|
||||
const handleBeforeUnload = () => {
|
||||
@@ -195,10 +202,12 @@ export default function ChatPage() {
|
||||
});
|
||||
|
||||
socket.on('user_online', (data: { userId: string }) => {
|
||||
if (useAuthStore.getState().user?.isInvisible) return;
|
||||
updateUserOnlineStatus(data.userId, true);
|
||||
});
|
||||
|
||||
socket.on('user_offline', (data: { userId: string; lastSeen?: string }) => {
|
||||
if (useAuthStore.getState().user?.isInvisible) return;
|
||||
updateUserOnlineStatus(data.userId, false, data.lastSeen);
|
||||
});
|
||||
|
||||
|
||||
@@ -11,6 +11,15 @@ import { ChatApi } from '../../infrastructure/chatApi';
|
||||
import ConfirmModal from '../../../../core/presentation/components/ui/ConfirmModal';
|
||||
import Avatar from '../../../../core/presentation/components/ui/Avatar';
|
||||
import type { Chat } from '../../../../core/domain/types';
|
||||
import { UserApi } from '../../../users/infrastructure/userApi';
|
||||
|
||||
const userStatusCache = new Map<string, { emoji?: string | null; text?: string | null; isInvisible?: boolean }>();
|
||||
|
||||
function profileStatusEmojiText(profile: { status?: { emoji?: string | null; text?: string | null } | null; statusEmoji?: string | null; statusText?: string | null }) {
|
||||
const emoji = profile?.status?.emoji ?? profile?.statusEmoji ?? null;
|
||||
const text = profile?.status?.text ?? profile?.statusText ?? null;
|
||||
return { emoji, text };
|
||||
}
|
||||
|
||||
interface ChatListItemProps {
|
||||
chat: Chat;
|
||||
@@ -24,6 +33,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
|
||||
const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [otherUserStatus, setOtherUserStatus] = useState<{ emoji?: string | null; text?: string | null; isInvisible?: boolean } | null>(null);
|
||||
const ctxRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const myMember = chat.members.find((m) => m.user.id === user?.id);
|
||||
@@ -45,7 +55,7 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
? otherMember?.user.avatarUrl || otherMember?.user.avatar || null
|
||||
: chat.avatarUrl || chat.avatar || null;
|
||||
|
||||
const isOnline = chat.type === 'personal' && !!otherMember?.user.isOnline;
|
||||
const isOnline = chat.type === 'personal' && !user?.isInvisible && !otherUserStatus?.isInvisible && !!otherMember?.user.isOnline;
|
||||
|
||||
// Check if someone is typing in this chat
|
||||
const typingInChat = typingUsers.filter((t) => t.chatId === chat.id && t.userId !== user?.id);
|
||||
@@ -87,6 +97,39 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
const [showAttachmentConfirm, setShowAttachmentConfirm] = useState(false);
|
||||
const [importStatus, setImportStatus] = useState<{ processed: number, total: number } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (chat.type !== 'personal' || !otherMember?.user?.id) {
|
||||
setOtherUserStatus(null);
|
||||
return;
|
||||
}
|
||||
|
||||
const otherId = otherMember.user.id;
|
||||
const cached = userStatusCache.get(otherId);
|
||||
if (cached) {
|
||||
setOtherUserStatus(cached);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
UserApi.getUser(otherId)
|
||||
.then((profile) => {
|
||||
if (cancelled) return;
|
||||
const st = profileStatusEmojiText(profile);
|
||||
const status = { emoji: st.emoji, text: st.text, isInvisible: !!profile.isInvisible };
|
||||
userStatusCache.set(otherId, status);
|
||||
setOtherUserStatus(status);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setOtherUserStatus(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chat.type, otherMember?.user?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!chat.isImporting || !chat.importJobId) {
|
||||
setImportStatus(null);
|
||||
@@ -199,8 +242,16 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
||||
<span className="material-symbols-outlined text-on-primary-container text-[24px]">bookmark</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="avatar-knot-container group-hover:scale-105 transition-transform">
|
||||
<div className="avatar-knot-container group-hover:scale-105 transition-transform relative">
|
||||
<Avatar src={chatAvatar || undefined} name={chatName || '??'} size="lg" online={isOnline ? true : false} />
|
||||
{chat.type === 'personal' && otherUserStatus?.emoji ? (
|
||||
<span
|
||||
className="absolute -bottom-0.5 -right-0.5 min-w-[1.25rem] h-5 px-0.5 flex items-center justify-center rounded-lg bg-surface-container text-[13px] leading-none shadow-md ring-2 ring-surface-container-low"
|
||||
title={otherUserStatus.text || undefined}
|
||||
>
|
||||
{otherUserStatus.emoji}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -24,6 +24,7 @@ import { useChatStore } from '../../application/chatStore';
|
||||
import { httpClient } from '../../../../core/infrastructure/httpClient';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { ChatApi } from '../../infrastructure/chatApi';
|
||||
import { UserApi } from '../../../users/infrastructure/userApi';
|
||||
import { getSocket } from '../../../../core/infrastructure/socket';
|
||||
import { isChatMuted, toggleMuteChat } from '../../../../core/utils/sounds';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
@@ -75,6 +76,8 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
const [confirmAction, setConfirmAction] = useState<{ message: string; action: () => void } | null>(null);
|
||||
const [scrollReady, setScrollReady] = useState(false);
|
||||
const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]);
|
||||
const [otherUserInvisible, setOtherUserInvisible] = useState(false);
|
||||
const [otherUserStatusHeader, setOtherUserStatusHeader] = useState<{ emoji?: string | null; text?: string | null } | null>(null);
|
||||
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
@@ -181,6 +184,37 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
? otherMember?.user.avatarUrl || otherMember?.user.avatar || null
|
||||
: chat?.avatarUrl || chat?.avatar || null;
|
||||
const isOnline = chat?.type === 'personal' && otherMember?.user.isOnline;
|
||||
const privacyExchangeMode = !!user?.isInvisible;
|
||||
const shouldMaskOtherUserPresence = privacyExchangeMode || otherUserInvisible;
|
||||
useEffect(() => {
|
||||
if (chat?.type !== 'personal' || !otherMember?.user.id) {
|
||||
setOtherUserInvisible(false);
|
||||
setOtherUserStatusHeader(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
UserApi.getUser(otherMember.user.id)
|
||||
.then((profile) => {
|
||||
if (!cancelled) {
|
||||
setOtherUserInvisible(!!profile.isInvisible);
|
||||
const em = profile?.status?.emoji ?? profile?.statusEmoji ?? null;
|
||||
const tx = profile?.status?.text ?? profile?.statusText ?? null;
|
||||
setOtherUserStatusHeader(em || tx ? { emoji: em, text: tx } : null);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setOtherUserInvisible(false);
|
||||
setOtherUserStatusHeader(null);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [chat?.type, otherMember?.user.id]);
|
||||
|
||||
|
||||
const typingInChat = typingUsers.filter((t) => t.chatId === activeChat && t.userId !== user?.id);
|
||||
|
||||
@@ -841,17 +875,33 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
<span className="material-symbols-outlined text-on-primary-container text-[20px]">bookmark</span>
|
||||
</div>
|
||||
) : (
|
||||
<Avatar
|
||||
src={chatAvatar}
|
||||
name={chatName}
|
||||
size="md"
|
||||
online={isOnline ? true : undefined}
|
||||
className="avatar-knot-container group-hover:border-primary/30"
|
||||
/>
|
||||
<div className="relative">
|
||||
<Avatar
|
||||
src={chatAvatar}
|
||||
name={chatName}
|
||||
size="md"
|
||||
online={!shouldMaskOtherUserPresence && isOnline ? true : undefined}
|
||||
className="avatar-knot-container group-hover:border-primary/30"
|
||||
/>
|
||||
{chat.type === 'personal' && otherUserStatusHeader?.emoji ? (
|
||||
<span
|
||||
className="absolute -bottom-0.5 -right-0.5 min-w-[1.1rem] h-[1.1rem] flex items-center justify-center rounded-md bg-surface-container text-[11px] leading-none shadow ring-2 ring-surface-container-lowest"
|
||||
title={otherUserStatusHeader.text || undefined}
|
||||
>
|
||||
{otherUserStatusHeader.emoji}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="min-w-0 text-left">
|
||||
<h3 className="text-lg font-bold font-headline text-on-surface truncate group-hover:text-primary transition-colors leading-none mb-1">{chatName}</h3>
|
||||
{chat.type === 'personal' && otherUserStatusHeader?.text ? (
|
||||
<p className="text-[11px] font-medium text-on-surface-variant/70 truncate mb-0.5 normal-case tracking-normal">
|
||||
{otherUserStatusHeader.emoji ? `${otherUserStatusHeader.emoji} ` : ''}
|
||||
{otherUserStatusHeader.text}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-[11px] font-bold uppercase tracking-widest text-on-surface-variant/50">
|
||||
{isFavorites
|
||||
? t('favoritesDescription')
|
||||
@@ -859,7 +909,9 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
? <span className="text-primary font-black animate-pulse">{t('typing')}</span>
|
||||
: isOnline
|
||||
? <span className="text-success">{t('online')}</span>
|
||||
: chat.type === 'personal' && otherMember?.user.lastSeen
|
||||
: chat.type === 'personal' && shouldMaskOtherUserPresence
|
||||
? t('wasRecently')
|
||||
: chat.type === 'personal' && otherMember?.user.lastSeen
|
||||
? `${formatLastSeen(otherMember.user.lastSeen, lang)}`
|
||||
: chat.type === 'group'
|
||||
? `${chat.members.length} ${t('members')}`
|
||||
@@ -1377,11 +1429,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
||||
</>
|
||||
)}
|
||||
|
||||
{typingInChat.length > 0 && (
|
||||
<div className="px-4 pb-1">
|
||||
<TypingIndicator />
|
||||
</div>
|
||||
)}
|
||||
{/* Typing indicator is already shown in the header, removed from here to prevent layout jumping */}
|
||||
|
||||
{(() => {
|
||||
return (
|
||||
|
||||
@@ -76,6 +76,7 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const getSearchedUserId = (u: UserPresence & { userId?: string }) => u.id || u.userId || '';
|
||||
|
||||
// Keep local state in sync with chat prop
|
||||
useEffect(() => {
|
||||
@@ -95,7 +96,10 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
const results = await UserApi.searchUsers(searchQuery);
|
||||
// Filter out users already in the group
|
||||
const memberIds = new Set(chat.members.map((m) => m.user.id));
|
||||
setSearchResults(results.filter((u) => !memberIds.has(u.id)));
|
||||
setSearchResults(results.filter((u) => {
|
||||
const candidateId = getSearchedUserId(u as UserPresence & { userId?: string });
|
||||
return !!candidateId && !memberIds.has(candidateId);
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
@@ -565,10 +569,13 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
<Loader2 size={16} className="text-zinc-500 animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{searchResults.map((u) => (
|
||||
{searchResults.map((u) => {
|
||||
const candidateId = getSearchedUserId(u as UserPresence & { userId?: string });
|
||||
if (!candidateId) return null;
|
||||
return (
|
||||
<button
|
||||
key={u.id}
|
||||
onClick={() => handleAddMember(u.id)}
|
||||
key={candidateId}
|
||||
onClick={() => handleAddMember(candidateId)}
|
||||
className="flex items-center gap-3 w-full px-3 py-2 rounded-xl hover:bg-surface-hover transition-colors"
|
||||
>
|
||||
<Avatar
|
||||
@@ -583,7 +590,8 @@ export default function GroupSettings({ chat, onClose, onGoToMessage }: GroupSet
|
||||
</div>
|
||||
<UserPlus size={14} className="text-knot-400 flex-shrink-0" />
|
||||
</button>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
{searchQuery.trim() && !isSearching && searchResults.length === 0 && (
|
||||
<p className="text-xs text-zinc-500 text-center py-2">{t('usersNotFound')}</p>
|
||||
)}
|
||||
|
||||
@@ -455,7 +455,7 @@ function MessageBubble({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isMine && (
|
||||
{!isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
|
||||
<div className="w-8 flex-shrink-0 mr-2 self-end">
|
||||
{showAvatar ? (
|
||||
<button onClick={() => onViewProfile?.(message.senderId)}>
|
||||
@@ -472,7 +472,7 @@ function MessageBubble({
|
||||
)}
|
||||
|
||||
<div className={`max-[500px]:max-w-[85%] max-w-[75%] lg:max-w-[65%] min-w-0 ${isMine ? 'items-end' : 'items-start'} flex flex-col`}>
|
||||
{!isMine && showAvatar && (
|
||||
{!isMine && showAvatar && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
|
||||
<button
|
||||
className="text-xs font-medium text-knot-400 ml-3 mb-0.5 hover:underline"
|
||||
onClick={() => onViewProfile?.(message.senderId)}
|
||||
@@ -613,7 +613,7 @@ function MessageBubble({
|
||||
|
||||
return (
|
||||
<div className={`
|
||||
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content ? 'mb-2' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''}
|
||||
${needsFrame ? `-mx-[14px] ${message.forwardedFrom || message.replyTo || message.storyId ? 'mt-2' : '-mt-[8px]'} ${message.content || hasVoice || hasAudio || hasFile || message.type === 'poll' ? 'mb-3' : hasReactions ? 'mb-1' : '-mb-[8px]'}` : ''}
|
||||
${isSingleGif ? 'max-w-[260px]' : ''}
|
||||
overflow-hidden relative rounded-[1.25rem]
|
||||
`}>
|
||||
@@ -709,7 +709,7 @@ function MessageBubble({
|
||||
|
||||
{/* Голосовое - Optimized Kinetic Layout */}
|
||||
{hasVoice && (
|
||||
<div className="flex items-center gap-3 min-w-[200px] py-0.5">
|
||||
<div className={`flex items-center gap-3 min-w-[200px] py-0.5 ${hasImage || hasVideo || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
|
||||
<audio
|
||||
ref={audioRef}
|
||||
src={media.find((m) => m.type === 'voice')?.url}
|
||||
@@ -778,7 +778,7 @@ function MessageBubble({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-w-[220px]">
|
||||
<div className={`min-w-[220px] ${hasImage || hasVideo || hasVoice || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
|
||||
{audioMedia?.filename && (
|
||||
<div className="flex items-center gap-2 mb-2 min-w-0">
|
||||
<Volume2 size={14} className={isMine ? 'text-[#0a0a0a]/60' : 'text-zinc-400'} />
|
||||
@@ -875,7 +875,7 @@ function MessageBubble({
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={`flex items-center gap-3 p-3 rounded-2xl ${isMine ? 'bg-[#0a0a0a]/5 hover:bg-[#0a0a0a]/10' : 'bg-zinc-900/50 hover:bg-zinc-800/80 border border-white/5'
|
||||
} transition-all mb-1 group/file`}
|
||||
} transition-all mb-1 group/file ${hasImage || hasVideo || hasVoice || hasAudio || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}
|
||||
>
|
||||
<div className={`w-11 h-11 rounded-xl flex items-center justify-center ${isMine ? 'bg-[#0a0a0a]/10' : 'bg-primary/20'
|
||||
} group-hover/file:scale-110 transition-transform`}>
|
||||
@@ -1034,7 +1034,7 @@ function MessageBubble({
|
||||
const onlyEmojiRegex = /^[\p{Extended_Pictographic}\s]+$/u;
|
||||
const isOnlyEmojis = onlyEmojiRegex.test(message.content) && message.content.length <= 15;
|
||||
return (
|
||||
<div className="flex items-end gap-2 text-sm w-full">
|
||||
<div className={`flex items-end gap-2 text-sm w-full ${hasImage || hasVideo || hasVoice || hasAudio || hasFile || message.forwardedFrom || message.replyTo || message.storyId ? 'mt-3' : ''}`}>
|
||||
<div className="flex-1 min-w-0 w-full">
|
||||
<p className={`whitespace-pre-wrap break-words leading-relaxed w-full ${isOnlyEmojis ? 'text-5xl my-1' : ''} ${isMine ? 'text-[#0a0a0a] font-normal' : 'text-zinc-200'}`}>
|
||||
{renderFormattedText(message.content)}
|
||||
@@ -1088,7 +1088,7 @@ function MessageBubble({
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isMine && (
|
||||
{isMine && activeChat?.type !== 'personal' && activeChat?.type !== 'favorites' && (
|
||||
<div className="w-8 flex-shrink-0 ml-2 self-end">
|
||||
{showAvatar ? (
|
||||
<button onClick={() => onViewProfile?.(message.senderId)}>
|
||||
|
||||
@@ -331,65 +331,70 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
});
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0) {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const newAttachments: Attachment[] = [];
|
||||
const processFiles = useCallback((files: File[]) => {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
let tooLarge = false;
|
||||
let limitExceeded = false;
|
||||
let tooLarge = false;
|
||||
let limitExceeded = false;
|
||||
|
||||
for (const file of files) {
|
||||
if (attachments.length + newAttachments.length >= 20) {
|
||||
limitExceeded = true;
|
||||
break;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
tooLarge = true;
|
||||
continue;
|
||||
}
|
||||
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||
newAttachments.push({ file, type: isAudio ? 'audio' : 'file' });
|
||||
for (const file of files) {
|
||||
if (attachments.length + newAttachments.length >= 20) {
|
||||
limitExceeded = true;
|
||||
break;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
tooLarge = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const isAudio = file.type.startsWith('audio/') || AUDIO_EXTENSIONS.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
|
||||
const preview = isImage ? URL.createObjectURL(file) : undefined;
|
||||
|
||||
newAttachments.push({ file, type, preview });
|
||||
}
|
||||
|
||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
}, [attachments, t]);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0) processFiles(files);
|
||||
e.target.value = '';
|
||||
setShowAttachMenu(false);
|
||||
};
|
||||
|
||||
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
if (files.length > 0) {
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
let limitExceeded = false;
|
||||
|
||||
for (const file of files) {
|
||||
if (attachments.length + newAttachments.length >= 20) {
|
||||
limitExceeded = true;
|
||||
break;
|
||||
}
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const preview = file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined;
|
||||
newAttachments.push({ file, preview, type: isVideo ? 'video' : 'image' });
|
||||
}
|
||||
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
}
|
||||
if (files.length > 0) processFiles(files);
|
||||
e.target.value = '';
|
||||
setShowAttachMenu(false);
|
||||
};
|
||||
|
||||
const handlePaste = (e: React.ClipboardEvent) => {
|
||||
const items = Array.from(e.clipboardData.items);
|
||||
const files = items
|
||||
.filter(item => item.kind === 'file')
|
||||
.map(item => item.getAsFile())
|
||||
.filter((f): f is File => f !== null);
|
||||
|
||||
if (files.length > 0) {
|
||||
processFiles(files);
|
||||
// If we only pasted files, don't paste the filename/text representation in the textarea
|
||||
if (items.every(item => item.kind === 'file')) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Запись голосового
|
||||
const startRecording = async () => {
|
||||
try {
|
||||
@@ -569,41 +574,11 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
|
||||
if (e.dataTransfer.files && e.dataTransfer.files.length > 0) {
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
const { addNotification } = useNotificationStore.getState();
|
||||
const newAttachments: Attachment[] = [];
|
||||
|
||||
let tooLarge = false;
|
||||
let limitExceeded = false;
|
||||
|
||||
for (const file of files) {
|
||||
if (attachments.length + newAttachments.length >= 20) {
|
||||
limitExceeded = true;
|
||||
break;
|
||||
}
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
tooLarge = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const isVideo = file.type.startsWith('video/');
|
||||
const isImage = file.type.startsWith('image/');
|
||||
const audioExts = ['.mp3', '.wav', '.ogg', '.m4a', '.aac', '.flac', '.wma', '.opus'];
|
||||
const isAudio = file.type.startsWith('audio/') || audioExts.some(ext => file.name.toLowerCase().endsWith(ext));
|
||||
|
||||
const type = isImage ? 'image' : isVideo ? 'video' : isAudio ? 'audio' : 'file';
|
||||
const preview = isImage ? URL.createObjectURL(file) : undefined;
|
||||
|
||||
newAttachments.push({ file, type, preview });
|
||||
}
|
||||
|
||||
if (tooLarge) addNotification('warning', t('fileTooLarge') || 'Some files are too large');
|
||||
if (limitExceeded) addNotification('warning', t('maxFilesLimit' as any) || 'Maximum 20 files');
|
||||
|
||||
setAttachments(prev => [...prev, ...newAttachments]);
|
||||
inputRef.current?.focus();
|
||||
processFiles(files);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const hasContent = text.trim() || attachments.length > 0;
|
||||
|
||||
return (
|
||||
@@ -856,6 +831,7 @@ export default function MessageInput({ chatId }: MessageInputProps) {
|
||||
}}
|
||||
onKeyDown={handleKeyDown}
|
||||
onContextMenu={handleInputContextMenu}
|
||||
onPaste={handlePaste}
|
||||
rows={1}
|
||||
className="w-full bg-transparent border-none focus:ring-0 text-[#efeff3] placeholder-on-surface-variant/30 text-[16px] leading-[1.3] resize-none max-h-[140px] custom-scrollbar outline-none py-1 px-0"
|
||||
placeholder={attachments.length > 0 ? t('addCaption') : t('messagePlaceholder') || 'Сообщение...'}
|
||||
|
||||
31
client-web/src/modules/users/infrastructure/statusApi.ts
Normal file
31
client-web/src/modules/users/infrastructure/statusApi.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { httpClient } from '../../../core/infrastructure/httpClient';
|
||||
import type { User, StatusPreset } from '../../../core/domain/types';
|
||||
|
||||
export class StatusApi {
|
||||
static async getPresets(): Promise<StatusPreset[]> {
|
||||
return httpClient.request<StatusPreset[]>('/statuses/presets');
|
||||
}
|
||||
|
||||
static async setCustom(body: {
|
||||
emoji?: string | null;
|
||||
text?: string | null;
|
||||
expiresAt?: string | null;
|
||||
presetKey?: string | null;
|
||||
}): Promise<User> {
|
||||
return httpClient.request<User>('/statuses/custom', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
emoji: body.emoji ?? '',
|
||||
text: body.text ?? '',
|
||||
expiresAt: body.expiresAt ?? null,
|
||||
presetKey: body.presetKey ?? null,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
static async clear(): Promise<User> {
|
||||
return httpClient.request<User>('/statuses/', {
|
||||
method: 'DELETE',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -8,20 +8,29 @@ export class UserApi {
|
||||
}
|
||||
|
||||
static async getUser(id: string) {
|
||||
// Конечная точка в новом бэкенде: GET /api/profiles/{id}
|
||||
return httpClient.request<User>(`/profiles/${id}`);
|
||||
// Основной путь по ТЗ: GET /api/users/{id}; старый профильный путь оставлен на бэкенде.
|
||||
return httpClient.request<User>(`/users/${id}`);
|
||||
}
|
||||
|
||||
static async updateProfile(data: { displayName?: string; bio?: string; birthday?: string | null }) {
|
||||
// Конечная точка в новом бэкенде: PUT /api/profiles/profile
|
||||
return httpClient.request<User>('/profiles/profile', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
static async updateProfile(data: {
|
||||
displayName?: string;
|
||||
bio?: string;
|
||||
birthday?: string | null;
|
||||
isInvisible?: boolean;
|
||||
}) {
|
||||
|
||||
const payload = {
|
||||
...data,
|
||||
about: data.bio
|
||||
};
|
||||
return httpClient.request<User>('/user/profile', {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
static async updateSettings(settings: any) {
|
||||
// Конечная точка в новом бэкенде: PUT /api/profiles/settings
|
||||
|
||||
return httpClient.request('/profiles/settings', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(settings),
|
||||
@@ -29,13 +38,13 @@ export class UserApi {
|
||||
}
|
||||
|
||||
static async uploadAvatar(file: File) {
|
||||
// Конечная точка в новом бэкенде: POST /api/profiles/avatar
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('avatar', file);
|
||||
return httpClient.request<User>('/profiles/avatar', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
timeout: 120_000, // Аватар может быть большим, даем время на обработку
|
||||
timeout: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { AnimatePresence, motion } from 'framer-motion';
|
||||
import { Loader2, X } from 'lucide-react';
|
||||
import { AuthApi } from '../../../auth/infrastructure/authApi';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
|
||||
interface ChangePasswordModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function ChangePasswordModal({ open, onClose, onSuccess }: ChangePasswordModalProps) {
|
||||
const { lang } = useLang();
|
||||
const [oldPassword, setOldPassword] = useState('');
|
||||
const [newPassword, setNewPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const minLengthOk = newPassword.length >= 8;
|
||||
const matchOk = newPassword.length > 0 && confirmPassword.length > 0 && newPassword === confirmPassword;
|
||||
const canSubmit = useMemo(
|
||||
() => oldPassword.length > 0 && minLengthOk && matchOk && !loading,
|
||||
[oldPassword.length, minLengthOk, matchOk, loading]
|
||||
);
|
||||
|
||||
const resetState = () => {
|
||||
setOldPassword('');
|
||||
setNewPassword('');
|
||||
setConfirmPassword('');
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
resetState();
|
||||
onClose();
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!canSubmit) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await AuthApi.changePassword({ oldPassword, newPassword, confirmPassword });
|
||||
resetState();
|
||||
onSuccess();
|
||||
} catch (e: any) {
|
||||
const message = e?.message || (lang === 'ru' ? 'Не удалось сменить пароль' : 'Failed to change password');
|
||||
setError(message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<AnimatePresence>
|
||||
{open && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="fixed inset-0 z-[12000] flex items-center justify-center p-4"
|
||||
>
|
||||
<div className="absolute inset-0 bg-black/70 backdrop-blur-sm" onClick={close} />
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||
animate={{ opacity: 1, y: 0, scale: 1 }}
|
||||
exit={{ opacity: 0, y: 12, scale: 0.98 }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="relative w-full max-w-md rounded-[2rem] border border-white/10 bg-[#111111] p-6 shadow-2xl"
|
||||
>
|
||||
<button
|
||||
onClick={close}
|
||||
className="absolute right-4 top-4 p-2 rounded-xl text-white/50 hover:text-white hover:bg-white/10"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
|
||||
<h3 className="text-lg font-black text-white mb-5">
|
||||
{lang === 'ru' ? 'Смена пароля' : 'Change password'}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<input
|
||||
type="password"
|
||||
value={oldPassword}
|
||||
onChange={(e) => setOldPassword(e.target.value)}
|
||||
placeholder={lang === 'ru' ? 'Текущий пароль' : 'Current password'}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-sm text-white outline-none focus:border-primary/60"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
placeholder={lang === 'ru' ? 'Новый пароль' : 'New password'}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-sm text-white outline-none focus:border-primary/60"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder={lang === 'ru' ? 'Подтвердите пароль' : 'Confirm password'}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-4 py-3 text-sm text-white outline-none focus:border-primary/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 space-y-1">
|
||||
<p className={`text-xs ${minLengthOk ? 'text-emerald-400' : 'text-white/45'}`}>
|
||||
{lang === 'ru' ? 'Минимум 8 символов' : 'At least 8 characters'}
|
||||
</p>
|
||||
<p className={`text-xs ${matchOk ? 'text-emerald-400' : 'text-white/45'}`}>
|
||||
{lang === 'ru' ? 'Пароли совпадают' : 'Passwords match'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
|
||||
|
||||
<div className="mt-6 flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={close}
|
||||
className="px-4 py-2 rounded-xl text-xs font-black uppercase tracking-wider text-white/60 hover:text-white"
|
||||
>
|
||||
{lang === 'ru' ? 'Отмена' : 'Cancel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={submit}
|
||||
disabled={!canSubmit}
|
||||
className="px-5 py-2 rounded-xl bg-primary text-on-primary text-xs font-black uppercase tracking-wider disabled:opacity-40 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading ? <Loader2 size={14} className="animate-spin" /> : (lang === 'ru' ? 'Сменить' : 'Change')}
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
@@ -4,30 +4,46 @@ import {
|
||||
User, Camera, Edit3, Calendar, Info,
|
||||
MapPin, AtSign, Check, Loader2, LogOut,
|
||||
ChevronRight, ArrowLeft, Languages, Palette, Trash2,
|
||||
Bell, Shield, Eye
|
||||
Bell, Shield, Eye, Smile
|
||||
} from 'lucide-react';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { useChatStore } from '../../../chats/application/chatStore';
|
||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import { UserApi } from '../../infrastructure/userApi';
|
||||
import { StatusApi } from '../../infrastructure/statusApi';
|
||||
import { getMediaUrl, getInitials } from '../../../../core/utils/utils';
|
||||
import DatePicker from '../../../../core/presentation/components/ui/DatePicker';
|
||||
import AvatarCropModal from './AvatarCropModal';
|
||||
import { Area } from 'react-easy-crop';
|
||||
import { getCroppedImg } from '../../../../core/utils/cropImage';
|
||||
import EmojiOnlyPicker from '../../../stories/presentation/components/EmojiOnlyPicker';
|
||||
import ChangePasswordModal from './ChangePasswordModal';
|
||||
import type { StatusPreset } from '../../../../core/domain/types';
|
||||
|
||||
const BIO_MAX_LENGTH = 200;
|
||||
const STATUS_MAX_LENGTH = 50;
|
||||
|
||||
export default function SettingsPage() {
|
||||
const { user, updateUser, logout } = useAuthStore();
|
||||
const { user, updateUser, logout, checkAuth } = useAuthStore();
|
||||
const { applyInvisiblePrivacyMode } = useChatStore();
|
||||
const { t, lang, setLang } = useLang();
|
||||
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
displayName: user?.displayName || '',
|
||||
bio: user?.bio || '',
|
||||
birthday: user?.birthday || ''
|
||||
birthday: user?.birthday || '',
|
||||
statusText: (user?.status?.text ?? user?.statusText) || '',
|
||||
statusEmoji: (user?.status?.emoji ?? user?.statusEmoji) || '💬',
|
||||
statusDuration: 'none' as 'none' | '1h' | '1d',
|
||||
isInvisible: !!user?.isInvisible
|
||||
});
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showStatusPicker, setShowStatusPicker] = useState(false);
|
||||
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
||||
const [updatingInvisible, setUpdatingInvisible] = useState(false);
|
||||
const [statusPresets, setStatusPresets] = useState<StatusPreset[]>([]);
|
||||
|
||||
// Avatar cropping state
|
||||
const [cropModal, setCropModal] = useState<{
|
||||
open: boolean,
|
||||
image: string,
|
||||
@@ -36,12 +52,22 @@ export default function SettingsPage() {
|
||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
StatusApi.getPresets()
|
||||
.then(setStatusPresets)
|
||||
.catch(() => setStatusPresets([]));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setFormData({
|
||||
displayName: user.displayName || '',
|
||||
bio: user.bio || '',
|
||||
birthday: user.birthday || ''
|
||||
birthday: user.birthday || '',
|
||||
statusText: (user?.status?.text ?? user.statusText) || '',
|
||||
statusEmoji: (user?.status?.emoji ?? user.statusEmoji) || '💬',
|
||||
statusDuration: 'none',
|
||||
isInvisible: !!user.isInvisible
|
||||
});
|
||||
}
|
||||
}, [user]);
|
||||
@@ -49,12 +75,35 @@ export default function SettingsPage() {
|
||||
const handleSaveProfile = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload = {
|
||||
...formData,
|
||||
birthday: formData.birthday || null
|
||||
};
|
||||
const updatedUser = await UserApi.updateProfile(payload);
|
||||
updateUser(updatedUser);
|
||||
await UserApi.updateProfile({
|
||||
displayName: formData.displayName,
|
||||
bio: formData.bio.slice(0, BIO_MAX_LENGTH),
|
||||
birthday: formData.birthday || null,
|
||||
isInvisible: formData.isInvisible,
|
||||
});
|
||||
const hasText = formData.statusText.trim().length > 0;
|
||||
const em = (formData.statusEmoji || '').trim();
|
||||
const hasEmoji = em.length > 0 && em !== '💬';
|
||||
if (!hasText && !hasEmoji) {
|
||||
await StatusApi.clear();
|
||||
} else {
|
||||
await StatusApi.setCustom({
|
||||
emoji: em || '💬',
|
||||
text: formData.statusText.trim().slice(0, STATUS_MAX_LENGTH),
|
||||
expiresAt:
|
||||
formData.statusText.trim() && formData.statusDuration !== 'none'
|
||||
? new Date(
|
||||
Date.now() +
|
||||
(formData.statusDuration === '1h' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000)
|
||||
).toISOString()
|
||||
: null,
|
||||
presetKey: null,
|
||||
});
|
||||
}
|
||||
await checkAuth();
|
||||
if (formData.isInvisible) {
|
||||
applyInvisiblePrivacyMode(true);
|
||||
}
|
||||
setEditing(false);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
@@ -106,8 +155,45 @@ export default function SettingsPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const handleToggleInvisible = async () => {
|
||||
if (!user || updatingInvisible) return;
|
||||
const nextInvisible = !formData.isInvisible;
|
||||
setUpdatingInvisible(true);
|
||||
try {
|
||||
const updatedUser = await UserApi.updateProfile({
|
||||
displayName: user.displayName || '',
|
||||
bio: user.bio || '',
|
||||
birthday: user.birthday || null,
|
||||
isInvisible: nextInvisible,
|
||||
});
|
||||
updateUser(updatedUser);
|
||||
setFormData((p) => ({ ...p, isInvisible: nextInvisible }));
|
||||
if (nextInvisible) {
|
||||
applyInvisiblePrivacyMode(true);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to update privacy status', err);
|
||||
} finally {
|
||||
setUpdatingInvisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
const initials = getInitials(user?.displayName || user?.username || '??');
|
||||
|
||||
const applyPresetQuick = async (p: StatusPreset) => {
|
||||
try {
|
||||
if (p.id === 'online') {
|
||||
await StatusApi.clear();
|
||||
} else {
|
||||
const text = lang === 'ru' ? p.textRu : p.textEn;
|
||||
await StatusApi.setCustom({ presetKey: p.id, emoji: p.emoji, text });
|
||||
}
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex-1 h-full overflow-y-auto px-12 py-16 custom-scrollbar bg-surface-container-lowest">
|
||||
<div className="max-w-[700px] mx-auto space-y-12">
|
||||
@@ -237,7 +323,7 @@ export default function SettingsPage() {
|
||||
{editing ? (
|
||||
<textarea
|
||||
value={formData.bio}
|
||||
onChange={(e) => setFormData(p => ({ ...p, bio: e.target.value }))}
|
||||
onChange={(e) => setFormData(p => ({ ...p, bio: e.target.value.slice(0, BIO_MAX_LENGTH) }))}
|
||||
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-3 text-sm font-medium text-white/80 focus:border-primary/50 transition-all outline-none resize-none h-32"
|
||||
placeholder={t('tellAboutBio')}
|
||||
/>
|
||||
@@ -246,6 +332,11 @@ export default function SettingsPage() {
|
||||
{user?.bio || t('noBio')}
|
||||
</p>
|
||||
)}
|
||||
{editing && (
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-white/35 text-right">
|
||||
{formData.bio.length}/{BIO_MAX_LENGTH}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
|
||||
@@ -266,6 +357,95 @@ export default function SettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3 p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 transition-colors hover:bg-white/[0.04]">
|
||||
<div className="flex items-center gap-3 text-primary mb-1">
|
||||
<Smile size={16} />
|
||||
<span className="text-[10px] font-black uppercase tracking-[0.2em]">
|
||||
{lang === 'ru' ? 'Статус' : 'Status'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editing ? (
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
||||
{statusPresets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
onClick={() => applyPresetQuick(preset)}
|
||||
className="px-3 py-2 rounded-xl bg-white/5 border border-white/10 text-xs font-bold text-white/80 hover:border-primary/40"
|
||||
>
|
||||
{preset.emoji ? `${preset.emoji} ` : ''}
|
||||
{lang === 'ru' ? preset.textRu : preset.textEn}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowStatusPicker(true)}
|
||||
className="px-4 py-2 rounded-xl bg-white/5 border border-white/10 text-white text-sm font-bold"
|
||||
>
|
||||
{formData.statusEmoji}
|
||||
</button>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.statusText}
|
||||
onChange={(e) => setFormData((p) => ({ ...p, statusText: e.target.value.slice(0, STATUS_MAX_LENGTH) }))}
|
||||
className="flex-1 bg-white/5 border border-white/10 rounded-2xl px-4 py-2.5 text-sm font-medium text-white/90 focus:border-primary/50 transition-all outline-none"
|
||||
placeholder={lang === 'ru' ? 'Что у вас сейчас?' : 'What are you up to?'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
{[
|
||||
{ id: 'none', label: lang === 'ru' ? 'Без таймера' : 'No timer' },
|
||||
{ id: '1h', label: lang === 'ru' ? '1 час' : '1 hour' },
|
||||
{ id: '1d', label: lang === 'ru' ? '1 день' : '1 day' },
|
||||
].map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
onClick={() => setFormData((p) => ({ ...p, statusDuration: opt.id as 'none' | '1h' | '1d' }))}
|
||||
className={`px-3 py-2 rounded-xl text-xs font-black uppercase tracking-wider border ${
|
||||
formData.statusDuration === opt.id
|
||||
? 'bg-primary/20 border-primary/40 text-primary'
|
||||
: 'bg-white/5 border-white/10 text-white/60'
|
||||
}`}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-[10px] font-black uppercase tracking-widest text-white/35">
|
||||
{formData.statusText.length}/{STATUS_MAX_LENGTH}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
setFormData((p) => ({ ...p, statusText: '', statusEmoji: '💬', statusDuration: 'none' }));
|
||||
try {
|
||||
await StatusApi.clear();
|
||||
await checkAuth();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
}
|
||||
}}
|
||||
className="text-[10px] font-black uppercase tracking-widest text-red-400/80 hover:text-red-400"
|
||||
>
|
||||
{lang === 'ru' ? 'Очистить статус' : 'Clear status'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="inline-flex px-4 py-2 rounded-full bg-white/5 border border-white/10 text-sm font-bold text-white">
|
||||
{(user?.status?.emoji ?? user?.statusEmoji) || '💬'}{' '}
|
||||
{(user?.status?.text ?? user?.statusText) || (lang === 'ru' ? 'Статус не указан' : 'No status')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* App Settings */}
|
||||
<div className="space-y-6 pt-6">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
@@ -298,6 +478,48 @@ export default function SettingsPage() {
|
||||
|
||||
{/* Account Section */}
|
||||
<div className="p-6 rounded-3xl bg-white/[0.02] border border-white/5 flex flex-col justify-between">
|
||||
<button
|
||||
onClick={handleToggleInvisible}
|
||||
disabled={updatingInvisible}
|
||||
className={`w-full mb-3 flex items-center justify-between p-4 rounded-2xl border transition-all group ${
|
||||
formData.isInvisible
|
||||
? 'bg-primary/10 border-primary/30'
|
||||
: 'bg-white/5 hover:bg-white/10 border-white/10'
|
||||
} ${updatingInvisible ? 'opacity-60 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className={`w-9 h-9 rounded-xl flex items-center justify-center transition-transform ${
|
||||
formData.isInvisible ? 'bg-primary/20 text-primary' : 'bg-white/10 text-white/70'
|
||||
}`}>
|
||||
<Eye size={16} />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<span className="block text-[11px] font-black uppercase tracking-widest text-white/80">
|
||||
{lang === 'ru' ? 'Скрывать мой статус онлайн' : 'Hide my online status'}
|
||||
</span>
|
||||
<span className="block text-[10px] text-white/40 mt-0.5">
|
||||
{lang === 'ru' ? 'Если включено, вы тоже не видите точный статус других' : 'If enabled, you also cannot see exact status of others'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={`w-11 h-6 rounded-full p-1 transition-colors ${formData.isInvisible ? 'bg-primary' : 'bg-white/15'}`}>
|
||||
<div className={`w-4 h-4 rounded-full bg-white transition-transform ${formData.isInvisible ? 'translate-x-5' : 'translate-x-0'}`} />
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowChangePasswordModal(true)}
|
||||
className="w-full mb-3 flex items-center justify-between p-4 rounded-2xl bg-white/5 hover:bg-white/10 border border-white/10 transition-all group"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-9 h-9 rounded-xl bg-white/10 flex items-center justify-center text-primary group-hover:scale-105 transition-transform">
|
||||
<Shield size={16} />
|
||||
</div>
|
||||
<span className="text-[11px] font-black uppercase tracking-widest text-white/70 group-hover:text-white transition-colors">
|
||||
{lang === 'ru' ? 'Сменить пароль' : 'Change password'}
|
||||
</span>
|
||||
</div>
|
||||
<ChevronRight size={16} className="text-white/30" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { logout(); }}
|
||||
className="w-full flex items-center justify-between p-4 rounded-2xl bg-red-500/5 hover:bg-red-500/15 border border-red-500/10 transition-all group"
|
||||
@@ -324,6 +546,26 @@ export default function SettingsPage() {
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<AnimatePresence>
|
||||
{showStatusPicker && (
|
||||
<EmojiOnlyPicker
|
||||
onSelect={(emoji) => {
|
||||
setFormData((p) => ({ ...p, statusEmoji: emoji }));
|
||||
setShowStatusPicker(false);
|
||||
}}
|
||||
onClose={() => setShowStatusPicker(false)}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
<ChangePasswordModal
|
||||
open={showChangePasswordModal}
|
||||
onClose={() => setShowChangePasswordModal(false)}
|
||||
onSuccess={() => {
|
||||
setShowChangePasswordModal(false);
|
||||
// Access token remains valid; ask user to re-login to ensure security on all devices.
|
||||
logout();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useLang } from '../../../../core/infrastructure/i18n';
|
||||
import { useAuthStore } from '../../../auth/application/authStore';
|
||||
import { useFriendStore } from '../../../friends/application/friendStore';
|
||||
import { ChatApi } from '../../../chats/infrastructure/chatApi';
|
||||
import { httpClient } from '../../../../core/infrastructure/httpClient';
|
||||
import { UserApi } from '../../infrastructure/userApi';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import ImageLightbox from '../../../../core/presentation/components/ui/ImageLightbox';
|
||||
import type { FriendWithId, FriendRequest } from '../../../../core/domain/types';
|
||||
@@ -51,7 +51,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
|
||||
const fetchUser = useCallback(async () => {
|
||||
try {
|
||||
const res = await httpClient.request<any>(`/profiles/${userId}`);
|
||||
const res = await UserApi.getUser(userId);
|
||||
setUser(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -242,6 +242,15 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
<div className="px-4 py-1.5 rounded-full bg-primary/15 border border-primary/25">
|
||||
<span className="text-xs font-black text-primary uppercase tracking-widest">@{user.username || 'user_' + userId.slice(0, 5)}</span>
|
||||
</div>
|
||||
{(Boolean(user?.status?.text ?? user?.statusText) ||
|
||||
Boolean((user?.status?.emoji ?? user?.statusEmoji) && (user?.status?.emoji ?? user?.statusEmoji) !== '💬')) && (
|
||||
<div className="px-4 py-1.5 rounded-full bg-white/5 border border-white/10">
|
||||
<span className="text-xs font-black text-white tracking-wide">
|
||||
{(user?.status?.emoji ?? user?.statusEmoji) || '💬'}{' '}
|
||||
{(user?.status?.text ?? user?.statusText) || (lang === 'ru' ? 'Статус' : 'Status')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-xs text-white/40 font-black uppercase tracking-widest"><Calendar size={14} className="text-primary/50" /><span>{t('joined')} {formatRegistrationDate(user.createdAt)}</span></div>
|
||||
</div>
|
||||
|
||||
@@ -263,7 +272,7 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
|
||||
{/* About Section */}
|
||||
<div className="p-8 rounded-[2.5rem] bg-white/[0.02] border border-white/5 mb-10 group relative transition-colors hover:bg-white/[0.04]">
|
||||
<div className="flex items-center gap-3 mb-4 text-primary"><Info size={16} /><span className="text-xs font-black uppercase tracking-[0.2em]">{t('aboutMe')}</span></div>
|
||||
<p className="text-base text-white/80 leading-relaxed font-medium">{user.about || t('noBio')}</p>
|
||||
<p className="text-base text-white/80 leading-relaxed font-medium">{user.bio || t('noBio')}</p>
|
||||
</div>
|
||||
|
||||
{/* Tabs Nav */}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
services:
|
||||
db:
|
||||
image: postgres:15-alpine
|
||||
container_name: knot-db
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER
|
||||
@@ -10,20 +9,19 @@ services:
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5432:5432"
|
||||
- "${DB_PORT:-5432}:5432"
|
||||
|
||||
server:
|
||||
build:
|
||||
context: ../backend
|
||||
dockerfile: Dockerfile
|
||||
container_name: knot-server
|
||||
restart: always
|
||||
depends_on:
|
||||
- db
|
||||
- minio
|
||||
- mongo
|
||||
ports:
|
||||
- "5059:8080"
|
||||
- "${SERVER_PORT:-5059}:8080"
|
||||
environment:
|
||||
- DATABASE_URL
|
||||
- MONGO_CONNECTION
|
||||
@@ -41,23 +39,21 @@ services:
|
||||
|
||||
minio:
|
||||
image: minio/minio
|
||||
container_name: knot-minio
|
||||
restart: always
|
||||
environment:
|
||||
- MINIO_ROOT_USER
|
||||
- MINIO_ROOT_PASSWORD
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
- "${MINIO_API_PORT:-9000}:9000"
|
||||
- "${MINIO_CONSOLE_PORT:-9001}:9001"
|
||||
command: server /data --console-address ":9001"
|
||||
volumes:
|
||||
- minio_data:/data
|
||||
mongo:
|
||||
image: mongo:6-jammy
|
||||
container_name: knot-mongo
|
||||
restart: always
|
||||
ports:
|
||||
- "27017:27017"
|
||||
- "${MONGO_PORT:-27017}:27017"
|
||||
volumes:
|
||||
- mongo_data:/data/db
|
||||
|
||||
@@ -65,10 +61,9 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: client-web/Dockerfile
|
||||
container_name: knot-web
|
||||
restart: always
|
||||
ports:
|
||||
- "9090:80"
|
||||
- "${WEB_PORT:-9090}:80"
|
||||
depends_on:
|
||||
- server
|
||||
|
||||
|
||||
Reference in New Issue
Block a user