diff --git a/backend/Tests/Users/UnitTests/UpdateProfileCommandHandlerTests.cs b/backend/Tests/Users/UnitTests/UpdateProfileCommandHandlerTests.cs index 3739a53..f930c4b 100644 --- a/backend/Tests/Users/UnitTests/UpdateProfileCommandHandlerTests.cs +++ b/backend/Tests/Users/UnitTests/UpdateProfileCommandHandlerTests.cs @@ -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()).Returns((ProfileDocument?)null); + var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null, null); + _profileRepository.GetAsync(command.UserId, Arg.Any()).Returns((UserProfileDto?)null); - // Act var result = await _handler.Handle(command, CancellationToken.None); - // Assert result.IsFailure.Should().BeTrue(); } } diff --git a/backend/src/Contracts/Profiles/Application/DTOs/ProfilesErrors.cs b/backend/src/Contracts/Profiles/Application/DTOs/ProfilesErrors.cs index 79483ea..a08010a 100644 --- a/backend/src/Contracts/Profiles/Application/DTOs/ProfilesErrors.cs +++ b/backend/src/Contracts/Profiles/Application/DTOs/ProfilesErrors.cs @@ -11,4 +11,6 @@ public static class ProfilesErrors 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"); } diff --git a/backend/src/Contracts/Profiles/Application/DTOs/StatusPresetDto.cs b/backend/src/Contracts/Profiles/Application/DTOs/StatusPresetDto.cs new file mode 100644 index 0000000..04eacd2 --- /dev/null +++ b/backend/src/Contracts/Profiles/Application/DTOs/StatusPresetDto.cs @@ -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; } = ""; +} diff --git a/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs b/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs index 89a5db8..b65bfb7 100644 --- a/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs +++ b/backend/src/Contracts/Profiles/Application/DTOs/UserProfileDto.cs @@ -12,6 +12,10 @@ 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; } diff --git a/backend/src/Contracts/Profiles/Application/DTOs/UserStatusDto.cs b/backend/src/Contracts/Profiles/Application/DTOs/UserStatusDto.cs new file mode 100644 index 0000000..140fda8 --- /dev/null +++ b/backend/src/Contracts/Profiles/Application/DTOs/UserStatusDto.cs @@ -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; } +} diff --git a/backend/src/Contracts/Profiles/Domain/IProfileStatusWriter.cs b/backend/src/Contracts/Profiles/Domain/IProfileStatusWriter.cs new file mode 100644 index 0000000..537e1ca --- /dev/null +++ b/backend/src/Contracts/Profiles/Domain/IProfileStatusWriter.cs @@ -0,0 +1,11 @@ +using Knot.Contracts.Profiles.Application.DTOs; +using Knot.Shared.Kernel; + +namespace Knot.Contracts.Profiles.Domain; + +public interface IProfileStatusWriter +{ + Task> SetCustomAsync(Guid userId, string emoji, string text, DateTime? expiresAt, string? presetKey, CancellationToken cancellationToken = default); + + Task> ClearAsync(Guid userId, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Contracts/Profiles/Domain/IUserStatusRepository.cs b/backend/src/Contracts/Profiles/Domain/IUserStatusRepository.cs new file mode 100644 index 0000000..3c36efd --- /dev/null +++ b/backend/src/Contracts/Profiles/Domain/IUserStatusRepository.cs @@ -0,0 +1,14 @@ +using Knot.Contracts.Profiles.Application.DTOs; + +namespace Knot.Contracts.Profiles.Domain; + +public interface IUserStatusRepository +{ + Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default); + + Task> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default); + + Task InsertAsync(UserStatusDto dto, Guid userId, CancellationToken cancellationToken = default); + + Task DeleteAsync(Guid id, CancellationToken cancellationToken = default); +} diff --git a/backend/src/Host/Program.cs b/backend/src/Host/Program.cs index 6f1384e..90378f7 100644 --- a/backend/src/Host/Program.cs +++ b/backend/src/Host/Program.cs @@ -250,6 +250,7 @@ app.MapStoriesEndpoints(); app.MapContactsEndpoints(); app.MapSettingsEndpoints(); app.MapProfilesEndpoints(); +app.MapStatusesEndpoints(); app.MapFederationEndpoints(); app.MapKlipyEndpoints(); app.MapChatsEndpoints(); diff --git a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs index d965f3a..415f9d7 100644 --- a/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs +++ b/backend/src/Modules/Conversations/Infrastructure/SignalR/ChatHub.cs @@ -125,7 +125,6 @@ public sealed class ChatHub : Hub _userConnections.TryRemove(userId, out _); try { - // Connection is tearing down: ConnectionAborted is already signaled and will cancel I/O. var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, CancellationToken.None); if (!isInvisible) { diff --git a/backend/src/Modules/Profiles/Application/Profiles/DTOs/ProfilesRequests.cs b/backend/src/Modules/Profiles/Application/Profiles/DTOs/ProfilesRequests.cs index faba9eb..ca38da9 100644 --- a/backend/src/Modules/Profiles/Application/Profiles/DTOs/ProfilesRequests.cs +++ b/backend/src/Modules/Profiles/Application/Profiles/DTOs/ProfilesRequests.cs @@ -6,8 +6,5 @@ public record UpdateProfileRequest( string? DisplayName, string? Bio, DateTime? Birthday, - string? StatusText, - string? StatusEmoji, - DateTime? StatusExpiresAt, bool? IsInvisible); public record UpdateSettingsRequest(bool? HideStoryViews); diff --git a/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs b/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs index b43cc1b..fd9c3b9 100644 --- a/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs +++ b/backend/src/Modules/Profiles/Application/Profiles/UpdateProfile/UpdateProfile.cs @@ -12,9 +12,6 @@ public sealed record UpdateProfileCommand( string? DisplayName, string? Bio, DateTime? Birthday, - string? StatusText, - string? StatusEmoji, - DateTime? StatusExpiresAt, bool? IsInvisible) : ICommand; internal sealed class UpdateProfileCommandHandler : ICommandHandler @@ -35,15 +32,9 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler 200) return Result.Failure(ProfilesErrors.BioTooLong); - if ((request.StatusText?.Length ?? 0) > 50) - return Result.Failure(ProfilesErrors.StatusTextTooLong); - profile.DisplayName = request.DisplayName ?? profile.DisplayName; profile.About = request.Bio; profile.Birthday = request.Birthday; - profile.StatusText = request.StatusText; - profile.StatusEmoji = request.StatusEmoji; - profile.StatusExpiresAt = request.StatusExpiresAt; profile.IsInvisible = request.IsInvisible ?? profile.IsInvisible; var result = await _repository.UpdateAsync(profile, cancellationToken); diff --git a/backend/src/Modules/Profiles/Application/Statuses/ClearUserStatusCommand.cs b/backend/src/Modules/Profiles/Application/Statuses/ClearUserStatusCommand.cs new file mode 100644 index 0000000..eca542b --- /dev/null +++ b/backend/src/Modules/Profiles/Application/Statuses/ClearUserStatusCommand.cs @@ -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>; + +public sealed class ClearUserStatusCommandHandler : IRequestHandler> +{ + private readonly IProfileStatusWriter _writer; + + public ClearUserStatusCommandHandler(IProfileStatusWriter writer) + { + _writer = writer; + } + + public Task> Handle(ClearUserStatusCommand request, CancellationToken cancellationToken) + => _writer.ClearAsync(request.UserId, cancellationToken); +} diff --git a/backend/src/Modules/Profiles/Application/Statuses/GetStatusPresetsQuery.cs b/backend/src/Modules/Profiles/Application/Statuses/GetStatusPresetsQuery.cs new file mode 100644 index 0000000..2ee26a2 --- /dev/null +++ b/backend/src/Modules/Profiles/Application/Statuses/GetStatusPresetsQuery.cs @@ -0,0 +1,12 @@ +using Knot.Contracts.Profiles.Application.DTOs; +using MediatR; + +namespace Knot.Modules.Profiles.Application.Statuses; + +public sealed record GetStatusPresetsQuery : IRequest>; + +public sealed class GetStatusPresetsQueryHandler : IRequestHandler> +{ + public Task> Handle(GetStatusPresetsQuery request, CancellationToken cancellationToken) + => Task.FromResult(StatusPresetCatalog.All); +} diff --git a/backend/src/Modules/Profiles/Application/Statuses/SetCustomUserStatusCommand.cs b/backend/src/Modules/Profiles/Application/Statuses/SetCustomUserStatusCommand.cs new file mode 100644 index 0000000..859b71b --- /dev/null +++ b/backend/src/Modules/Profiles/Application/Statuses/SetCustomUserStatusCommand.cs @@ -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>; + +public sealed class SetCustomUserStatusCommandHandler : IRequestHandler> +{ + private readonly IProfileStatusWriter _writer; + + public SetCustomUserStatusCommandHandler(IProfileStatusWriter writer) + { + _writer = writer; + } + + public async Task> 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(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(ProfilesErrors.StatusEmpty); + + if (text.Length > 50) + return Result.Failure(ProfilesErrors.StatusTextTooLong); + + var presetForWriter = string.IsNullOrWhiteSpace(key) ? null : key; + return await _writer.SetCustomAsync(request.UserId, emoji, text, request.ExpiresAt, presetForWriter, cancellationToken); + } +} diff --git a/backend/src/Modules/Profiles/Application/Statuses/StatusPresetCatalog.cs b/backend/src/Modules/Profiles/Application/Statuses/StatusPresetCatalog.cs new file mode 100644 index 0000000..42da108 --- /dev/null +++ b/backend/src/Modules/Profiles/Application/Statuses/StatusPresetCatalog.cs @@ -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 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)); + } +} diff --git a/backend/src/Modules/Profiles/DependencyInjection.cs b/backend/src/Modules/Profiles/DependencyInjection.cs index e6241ea..573d482 100644 --- a/backend/src/Modules/Profiles/DependencyInjection.cs +++ b/backend/src/Modules/Profiles/DependencyInjection.cs @@ -11,10 +11,11 @@ public static class DependencyInjection public static IServiceCollection AddProfilesModule(this IServiceCollection services, IConfiguration configuration) { services.AddScoped(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); - // MongoDB Registration var mongoConnection = configuration.GetConnectionString("MongoConnection") ?? configuration["MONGO_URL"] ?? "mongodb://mongo:27017"; diff --git a/backend/src/Modules/Profiles/Domain/ProfileDocument.cs b/backend/src/Modules/Profiles/Domain/ProfileDocument.cs index 10a82b3..5713759 100644 --- a/backend/src/Modules/Profiles/Domain/ProfileDocument.cs +++ b/backend/src/Modules/Profiles/Domain/ProfileDocument.cs @@ -3,10 +3,6 @@ using MongoDB.Bson.Serialization.Attributes; namespace Knot.Modules.Profiles.Domain; -/// -/// MongoDB-документ профиля пользователя. -/// Id совпадает с UserId из модуля Auth (Postgres). -/// public sealed class ProfileDocument { [BsonId] @@ -25,6 +21,9 @@ public sealed class ProfileDocument 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; } @@ -83,6 +82,16 @@ public sealed class ProfileDocument 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; diff --git a/backend/src/Modules/Profiles/Domain/UserStatusDocument.cs b/backend/src/Modules/Profiles/Domain/UserStatusDocument.cs new file mode 100644 index 0000000..b92856d --- /dev/null +++ b/backend/src/Modules/Profiles/Domain/UserStatusDocument.cs @@ -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(); +} diff --git a/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs b/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs index 126bd3f..bb8318c 100644 --- a/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs +++ b/backend/src/Modules/Profiles/Infrastructure/Database/ProfileRepository.cs @@ -10,29 +10,39 @@ namespace Knot.Modules.Profiles.Infrastructure.Database; internal class ProfileRepository : IProfileRepository { private readonly IMongoCollection _profiles; + private readonly IUserStatusRepository _statuses; - public ProfileRepository(IMongoDatabase database) + public ProfileRepository(IMongoDatabase database, IUserStatusRepository statuses) { _profiles = database.GetCollection("profiles"); + _statuses = statuses; } public async Task 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 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> GetAsync(IEnumerable 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(); + return profiles.Select(p => ProfileMappings.ToDtoWithStatusMap(p, map)).ToList(); } public async Task> SearchAsync(string query, int limit = 20, CancellationToken ct = default) @@ -57,7 +67,16 @@ internal class ProfileRepository : IProfileRepository var combinedFilter = Builders.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(); + return docs.Select(p => ProfileMappings.ToDtoWithStatusMap(p, map)).ToList(); } public async Task> 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> UpdateAsync(UserProfileDto dto, CancellationToken ct = default) @@ -80,11 +99,13 @@ internal class ProfileRepository : IProfileRepository dto.Birthday); document.UpdateAvatar(dto.Avatar); - document.UpdateStatus(dto.StatusText, dto.StatusEmoji, dto.StatusExpiresAt); 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(ProfilesErrors.ProfileNotFound); + return Result.Success(await fresh.ToDtoAsync(_statuses, ct)); } public async Task UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default) diff --git a/backend/src/Modules/Profiles/Infrastructure/Database/ProfileStatusWriter.cs b/backend/src/Modules/Profiles/Infrastructure/Database/ProfileStatusWriter.cs new file mode 100644 index 0000000..52cf91c --- /dev/null +++ b/backend/src/Modules/Profiles/Infrastructure/Database/ProfileStatusWriter.cs @@ -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 _profiles; + private readonly IUserStatusRepository _statuses; + + public ProfileStatusWriter(IMongoDatabase database, IUserStatusRepository statuses) + { + _profiles = database.GetCollection("profiles"); + _statuses = statuses; + } + + public async Task> 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(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(ProfilesErrors.ProfileNotFound); + + return Result.Success(await fresh.ToDtoAsync(_statuses, cancellationToken)); + } + + public async Task> ClearAsync(Guid userId, CancellationToken cancellationToken = default) + { + var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken); + if (profile is null) + return Result.Failure(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(ProfilesErrors.ProfileNotFound); + + return Result.Success(await fresh.ToDtoAsync(_statuses, cancellationToken)); + } +} diff --git a/backend/src/Modules/Profiles/Infrastructure/Database/UserStatusMongoRepository.cs b/backend/src/Modules/Profiles/Infrastructure/Database/UserStatusMongoRepository.cs new file mode 100644 index 0000000..17ef9c0 --- /dev/null +++ b/backend/src/Modules/Profiles/Infrastructure/Database/UserStatusMongoRepository.cs @@ -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 _collection; + + public UserStatusMongoRepository(IMongoDatabase database) + { + _collection = database.GetCollection("user_statuses"); + } + + public async Task 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> GetByIdsAsync(IEnumerable ids, CancellationToken cancellationToken = default) + { + var idList = ids.Distinct().ToList(); + if (idList.Count == 0) + return new Dictionary(); + + 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 + }; +} diff --git a/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs b/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs index b967197..19e6610 100644 --- a/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs +++ b/backend/src/Modules/Profiles/Infrastructure/Mappings/ProfileMappings.cs @@ -1,16 +1,14 @@ using Knot.Contracts.Profiles.Application.DTOs; +using Knot.Contracts.Profiles.Domain; using Knot.Modules.Profiles.Domain; -using System; namespace Knot.Modules.Profiles.Infrastructure.Mappings; public static class ProfileMappings { - public static UserProfileDto ToDto(this ProfileDocument document) + public static UserProfileDto ToDtoWithStatusMap(ProfileDocument document, IReadOnlyDictionary statusMap) { - var hasActiveStatus = !document.StatusExpiresAt.HasValue || document.StatusExpiresAt.Value > DateTime.UtcNow; - - return new UserProfileDto + var dto = new UserProfileDto { UserId = document.Id, DisplayName = document.DisplayName, @@ -21,11 +19,59 @@ public static class ProfileMappings LastSeen = null, Birthday = document.Birthday, IsPremium = false, - StatusText = hasActiveStatus ? document.StatusText : null, - StatusEmoji = hasActiveStatus ? document.StatusEmoji : null, - StatusExpiresAt = hasActiveStatus ? document.StatusExpiresAt : null, IsInvisible = document.IsInvisible, - CreatedAt = document.CreatedAt + 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 ToDtoAsync(this ProfileDocument document, IUserStatusRepository statuses, CancellationToken cancellationToken) + { + if (document.CurrentStatusId is not Guid sid) + return ToDtoWithStatusMap(document, new Dictionary()); + var map = await statuses.GetByIdsAsync(new[] { sid }, cancellationToken); + return ToDtoWithStatusMap(document, map); } } diff --git a/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs b/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs index 6e228b5..4ebe42c 100644 --- a/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs +++ b/backend/src/Modules/Profiles/Presentation/Endpoints/ProfilesEndpoints.cs @@ -81,9 +81,6 @@ public static class ProfilesEndpoints request.DisplayName, request.Bio, request.Birthday, - request.StatusText, - request.StatusEmoji, - request.StatusExpiresAt, request.IsInvisible), ct); return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error); diff --git a/backend/src/Modules/Profiles/Presentation/Endpoints/StatusesEndpoints.cs b/backend/src/Modules/Profiles/Presentation/Endpoints/StatusesEndpoints.cs new file mode 100644 index 0000000..34c3588 --- /dev/null +++ b/backend/src/Modules/Profiles/Presentation/Endpoints/StatusesEndpoints.cs @@ -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; } + } +} diff --git a/client-web/src/core/domain/types.ts b/client-web/src/core/domain/types.ts index 023c16d..d3ae338 100644 --- a/client-web/src/core/domain/types.ts +++ b/client-web/src/core/domain/types.ts @@ -15,9 +15,19 @@ 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; @@ -26,6 +36,13 @@ export interface User extends UserPresence { hideStoryViews?: boolean; } +export interface StatusPreset { + id: string; + emoji: string; + textRu: string; + textEn: string; +} + // ─── Chat types ──────────────────────────────────────────────────────── export interface ChatMember { diff --git a/client-web/src/core/utils/normalize.ts b/client-web/src/core/utils/normalize.ts index cd2d762..6e77626 100644 --- a/client-web/src/core/utils/normalize.ts +++ b/client-web/src/core/utils/normalize.ts @@ -26,6 +26,13 @@ export function normalizeUser(user: any): any { } + 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; } @@ -43,6 +50,14 @@ export function normalizeUser(user: any): any { 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; + 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') { @@ -73,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) { diff --git a/client-web/src/modules/chats/presentation/components/ChatListItem.tsx b/client-web/src/modules/chats/presentation/components/ChatListItem.tsx index b815f86..77294c2 100644 --- a/client-web/src/modules/chats/presentation/components/ChatListItem.tsx +++ b/client-web/src/modules/chats/presentation/components/ChatListItem.tsx @@ -15,6 +15,12 @@ import { UserApi } from '../../../users/infrastructure/userApi'; const userStatusCache = new Map(); +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; isActive: boolean; @@ -108,7 +114,8 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) { UserApi.getUser(otherId) .then((profile) => { if (cancelled) return; - const status = { emoji: profile.statusEmoji, text: profile.statusText, isInvisible: !!profile.isInvisible }; + const st = profileStatusEmojiText(profile); + const status = { emoji: st.emoji, text: st.text, isInvisible: !!profile.isInvisible }; userStatusCache.set(otherId, status); setOtherUserStatus(status); }) @@ -235,8 +242,16 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) { bookmark ) : ( -
+
+ {chat.type === 'personal' && otherUserStatus?.emoji ? ( + + {otherUserStatus.emoji} + + ) : null}
)}
@@ -246,14 +261,6 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
{isPinned && push_pin} - {chat.type === 'personal' && otherUserStatus?.emoji && ( - - {otherUserStatus.emoji} - - )} {chatName}
{timeStr && {timeStr}} diff --git a/client-web/src/modules/chats/presentation/components/ChatView.tsx b/client-web/src/modules/chats/presentation/components/ChatView.tsx index 2f16cf2..620bb14 100644 --- a/client-web/src/modules/chats/presentation/components/ChatView.tsx +++ b/client-web/src/modules/chats/presentation/components/ChatView.tsx @@ -77,6 +77,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal const [scrollReady, setScrollReady] = useState(false); const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState([]); const [otherUserInvisible, setOtherUserInvisible] = useState(false); + const [otherUserStatusHeader, setOtherUserStatusHeader] = useState<{ emoji?: string | null; text?: string | null } | null>(null); const messagesEndRef = useRef(null); const scrollContainerRef = useRef(null); @@ -188,6 +189,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal useEffect(() => { if (chat?.type !== 'personal' || !otherMember?.user.id) { setOtherUserInvisible(false); + setOtherUserStatusHeader(null); return; } @@ -196,11 +198,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal .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); } }); @@ -869,17 +875,33 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal bookmark
) : ( - +
+ + {chat.type === 'personal' && otherUserStatusHeader?.emoji ? ( + + {otherUserStatusHeader.emoji} + + ) : null} +
)}

{chatName}

+ {chat.type === 'personal' && otherUserStatusHeader?.text ? ( +

+ {otherUserStatusHeader.emoji ? `${otherUserStatusHeader.emoji} ` : ''} + {otherUserStatusHeader.text} +

+ ) : null}

{isFavorites ? t('favoritesDescription') diff --git a/client-web/src/modules/users/infrastructure/statusApi.ts b/client-web/src/modules/users/infrastructure/statusApi.ts new file mode 100644 index 0000000..7f42197 --- /dev/null +++ b/client-web/src/modules/users/infrastructure/statusApi.ts @@ -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 { + return httpClient.request('/statuses/presets'); + } + + static async setCustom(body: { + emoji?: string | null; + text?: string | null; + expiresAt?: string | null; + presetKey?: string | null; + }): Promise { + return httpClient.request('/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 { + return httpClient.request('/statuses/', { + method: 'DELETE', + }); + } +} diff --git a/client-web/src/modules/users/infrastructure/userApi.ts b/client-web/src/modules/users/infrastructure/userApi.ts index 3109a5e..bf2b9d0 100644 --- a/client-web/src/modules/users/infrastructure/userApi.ts +++ b/client-web/src/modules/users/infrastructure/userApi.ts @@ -16,9 +16,6 @@ export class UserApi { displayName?: string; bio?: string; birthday?: string | null; - statusText?: string | null; - statusEmoji?: string | null; - statusExpiresAt?: string | null; isInvisible?: boolean; }) { diff --git a/client-web/src/modules/users/presentation/components/SettingsPage.tsx b/client-web/src/modules/users/presentation/components/SettingsPage.tsx index 39d4298..c7908c1 100644 --- a/client-web/src/modules/users/presentation/components/SettingsPage.tsx +++ b/client-web/src/modules/users/presentation/components/SettingsPage.tsx @@ -10,6 +10,7 @@ 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'; @@ -17,18 +18,13 @@ 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; -const STATUS_PRESETS = [ - { emoji: '📞', textRu: 'На созвоне', textEn: 'On a call' }, - { emoji: '🍔', textRu: 'Обед', textEn: 'Lunch' }, - { emoji: '💻', textRu: 'Работаю', textEn: 'Working' }, - { emoji: '💤', textRu: 'Сплю', textEn: 'Sleeping' }, -]; export default function SettingsPage() { - const { user, updateUser, logout } = useAuthStore(); + const { user, updateUser, logout, checkAuth } = useAuthStore(); const { applyInvisiblePrivacyMode } = useChatStore(); const { t, lang, setLang } = useLang(); @@ -37,8 +33,8 @@ export default function SettingsPage() { displayName: user?.displayName || '', bio: user?.bio || '', birthday: user?.birthday || '', - statusText: user?.statusText || '', - statusEmoji: user?.statusEmoji || '💬', + statusText: (user?.status?.text ?? user?.statusText) || '', + statusEmoji: (user?.status?.emoji ?? user?.statusEmoji) || '💬', statusDuration: 'none' as 'none' | '1h' | '1d', isInvisible: !!user?.isInvisible }); @@ -46,8 +42,8 @@ export default function SettingsPage() { const [showStatusPicker, setShowStatusPicker] = useState(false); const [showChangePasswordModal, setShowChangePasswordModal] = useState(false); const [updatingInvisible, setUpdatingInvisible] = useState(false); - - // Avatar cropping state + const [statusPresets, setStatusPresets] = useState([]); + const [cropModal, setCropModal] = useState<{ open: boolean, image: string, @@ -56,14 +52,20 @@ export default function SettingsPage() { const [uploadingAvatar, setUploadingAvatar] = useState(false); const fileInputRef = useRef(null); + useEffect(() => { + StatusApi.getPresets() + .then(setStatusPresets) + .catch(() => setStatusPresets([])); + }, []); + useEffect(() => { if (user) { setFormData({ displayName: user.displayName || '', bio: user.bio || '', birthday: user.birthday || '', - statusText: user.statusText || '', - statusEmoji: user.statusEmoji || '💬', + statusText: (user?.status?.text ?? user.statusText) || '', + statusEmoji: (user?.status?.emoji ?? user.statusEmoji) || '💬', statusDuration: 'none', isInvisible: !!user.isInvisible }); @@ -73,22 +75,33 @@ export default function SettingsPage() { const handleSaveProfile = async () => { setSaving(true); try { - const payload = { - ...formData, + await UserApi.updateProfile({ + displayName: formData.displayName, bio: formData.bio.slice(0, BIO_MAX_LENGTH), - statusText: formData.statusText.trim() ? formData.statusText.slice(0, STATUS_MAX_LENGTH) : null, - statusEmoji: formData.statusText.trim() ? formData.statusEmoji : null, - statusExpiresAt: - formData.statusText.trim() && formData.statusDuration !== 'none' - ? new Date( - Date.now() + (formData.statusDuration === '1h' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000) - ).toISOString() - : null, - birthday: formData.birthday || null - }; - const updatedUser = await UserApi.updateProfile(payload); - updateUser(updatedUser); - if (payload.isInvisible) { + 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); @@ -151,9 +164,6 @@ export default function SettingsPage() { displayName: user.displayName || '', bio: user.bio || '', birthday: user.birthday || null, - statusText: user.statusText || null, - statusEmoji: user.statusEmoji || null, - statusExpiresAt: user.statusExpiresAt || null, isInvisible: nextInvisible, }); updateUser(updatedUser); @@ -170,6 +180,20 @@ export default function SettingsPage() { 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 (

@@ -343,18 +367,16 @@ export default function SettingsPage() { {editing ? (
-
- {STATUS_PRESETS.map((preset) => ( +
+ {statusPresets.map((preset) => ( ))}
@@ -400,7 +422,16 @@ export default function SettingsPage() { {formData.statusText.length}/{STATUS_MAX_LENGTH}

) : (
- {user?.statusEmoji || '💬'} {user?.statusText || (lang === 'ru' ? 'Статус не указан' : 'No status')} + {(user?.status?.emoji ?? user?.statusEmoji) || '💬'}{' '} + {(user?.status?.text ?? user?.statusText) || (lang === 'ru' ? 'Статус не указан' : 'No status')}
)}
diff --git a/client-web/src/modules/users/presentation/components/UserProfile.tsx b/client-web/src/modules/users/presentation/components/UserProfile.tsx index 2584f4d..df8b65b 100644 --- a/client-web/src/modules/users/presentation/components/UserProfile.tsx +++ b/client-web/src/modules/users/presentation/components/UserProfile.tsx @@ -242,10 +242,12 @@ export default function UserProfile({ userId, onClose, onMessage, isSelf: isSelf
@{user.username || 'user_' + userId.slice(0, 5)}
- {(user.statusEmoji || user.statusText) && ( + {(Boolean(user?.status?.text ?? user?.statusText) || + Boolean((user?.status?.emoji ?? user?.statusEmoji) && (user?.status?.emoji ?? user?.statusEmoji) !== '💬')) && (
- {user.statusEmoji || '💬'} {user.statusText || (lang === 'ru' ? 'Статус' : 'Status')} + {(user?.status?.emoji ?? user?.statusEmoji) || '💬'}{' '} + {(user?.status?.text ?? user?.statusText) || (lang === 'ru' ? 'Статус' : 'Status')}
)}