внедрил пресеты статусов и модульную архитектуру
This commit is contained in:
@@ -1,13 +1,13 @@
|
|||||||
using FluentAssertions;
|
using FluentAssertions;
|
||||||
using Knot.Modules.Profiles.Application.Profiles.UpdateProfile;
|
using Knot.Modules.Profiles.Application.Profiles.UpdateProfile;
|
||||||
using Knot.Modules.Profiles.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
using System;
|
using System;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
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;
|
namespace Knot.Modules.Profiles.UnitTests;
|
||||||
|
|
||||||
@@ -25,14 +25,11 @@ public class UpdateProfileCommandHandlerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_ShouldReturnError_WhenProfileNotFound()
|
public async Task Handle_ShouldReturnError_WhenProfileNotFound()
|
||||||
{
|
{
|
||||||
// Arrange
|
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null, null);
|
||||||
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null);
|
_profileRepository.GetAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((UserProfileDto?)null);
|
||||||
_profileRepository.GetByIdAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((ProfileDocument?)null);
|
|
||||||
|
|
||||||
// Act
|
|
||||||
var result = await _handler.Handle(command, CancellationToken.None);
|
var result = await _handler.Handle(command, CancellationToken.None);
|
||||||
|
|
||||||
// Assert
|
|
||||||
result.IsFailure.Should().BeTrue();
|
result.IsFailure.Should().BeTrue();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,4 +11,6 @@ public static class ProfilesErrors
|
|||||||
public static Error AvatarUploadFailed => new("Profiles.AvatarUploadFailed", "Avatar upload failed");
|
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 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 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,6 +12,10 @@ public class UserProfileDto
|
|||||||
public DateTime? LastSeen { get; set; }
|
public DateTime? LastSeen { get; set; }
|
||||||
public DateTime? Birthday { get; set; }
|
public DateTime? Birthday { get; set; }
|
||||||
public bool IsPremium { get; set; }
|
public bool IsPremium { get; set; }
|
||||||
|
public UserStatusDto? Status { get; set; }
|
||||||
|
|
||||||
|
public Guid? CurrentStatusId { get; set; }
|
||||||
|
|
||||||
public string? StatusText { get; set; }
|
public string? StatusText { get; set; }
|
||||||
public string? StatusEmoji { get; set; }
|
public string? StatusEmoji { get; set; }
|
||||||
public DateTime? StatusExpiresAt { get; set; }
|
public DateTime? StatusExpiresAt { 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.MapContactsEndpoints();
|
||||||
app.MapSettingsEndpoints();
|
app.MapSettingsEndpoints();
|
||||||
app.MapProfilesEndpoints();
|
app.MapProfilesEndpoints();
|
||||||
|
app.MapStatusesEndpoints();
|
||||||
app.MapFederationEndpoints();
|
app.MapFederationEndpoints();
|
||||||
app.MapKlipyEndpoints();
|
app.MapKlipyEndpoints();
|
||||||
app.MapChatsEndpoints();
|
app.MapChatsEndpoints();
|
||||||
|
|||||||
@@ -125,7 +125,6 @@ public sealed class ChatHub : Hub
|
|||||||
_userConnections.TryRemove(userId, out _);
|
_userConnections.TryRemove(userId, out _);
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Connection is tearing down: ConnectionAborted is already signaled and will cancel I/O.
|
|
||||||
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, CancellationToken.None);
|
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, CancellationToken.None);
|
||||||
if (!isInvisible)
|
if (!isInvisible)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,8 +6,5 @@ public record UpdateProfileRequest(
|
|||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? Bio,
|
string? Bio,
|
||||||
DateTime? Birthday,
|
DateTime? Birthday,
|
||||||
string? StatusText,
|
|
||||||
string? StatusEmoji,
|
|
||||||
DateTime? StatusExpiresAt,
|
|
||||||
bool? IsInvisible);
|
bool? IsInvisible);
|
||||||
public record UpdateSettingsRequest(bool? HideStoryViews);
|
public record UpdateSettingsRequest(bool? HideStoryViews);
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ public sealed record UpdateProfileCommand(
|
|||||||
string? DisplayName,
|
string? DisplayName,
|
||||||
string? Bio,
|
string? Bio,
|
||||||
DateTime? Birthday,
|
DateTime? Birthday,
|
||||||
string? StatusText,
|
|
||||||
string? StatusEmoji,
|
|
||||||
DateTime? StatusExpiresAt,
|
|
||||||
bool? IsInvisible) : ICommand<UserProfileDto>;
|
bool? IsInvisible) : ICommand<UserProfileDto>;
|
||||||
|
|
||||||
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
|
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
|
||||||
@@ -35,15 +32,9 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfil
|
|||||||
if ((request.Bio?.Length ?? 0) > 200)
|
if ((request.Bio?.Length ?? 0) > 200)
|
||||||
return Result.Failure<UserProfileDto>(ProfilesErrors.BioTooLong);
|
return Result.Failure<UserProfileDto>(ProfilesErrors.BioTooLong);
|
||||||
|
|
||||||
if ((request.StatusText?.Length ?? 0) > 50)
|
|
||||||
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusTextTooLong);
|
|
||||||
|
|
||||||
profile.DisplayName = request.DisplayName ?? profile.DisplayName;
|
profile.DisplayName = request.DisplayName ?? profile.DisplayName;
|
||||||
profile.About = request.Bio;
|
profile.About = request.Bio;
|
||||||
profile.Birthday = request.Birthday;
|
profile.Birthday = request.Birthday;
|
||||||
profile.StatusText = request.StatusText;
|
|
||||||
profile.StatusEmoji = request.StatusEmoji;
|
|
||||||
profile.StatusExpiresAt = request.StatusExpiresAt;
|
|
||||||
profile.IsInvisible = request.IsInvisible ?? profile.IsInvisible;
|
profile.IsInvisible = request.IsInvisible ?? profile.IsInvisible;
|
||||||
|
|
||||||
var result = await _repository.UpdateAsync(profile, cancellationToken);
|
var result = await _repository.UpdateAsync(profile, cancellationToken);
|
||||||
|
|||||||
@@ -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)
|
public static IServiceCollection AddProfilesModule(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileRepository, ProfileRepository>();
|
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.IProfilesUnitOfWork, ProfilesUnitOfWork>();
|
||||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IAvatarStorageService, AvatarStorageService>();
|
services.AddScoped<Knot.Contracts.Profiles.Domain.IAvatarStorageService, AvatarStorageService>();
|
||||||
|
|
||||||
// MongoDB Registration
|
|
||||||
var mongoConnection = configuration.GetConnectionString("MongoConnection")
|
var mongoConnection = configuration.GetConnectionString("MongoConnection")
|
||||||
?? configuration["MONGO_URL"]
|
?? configuration["MONGO_URL"]
|
||||||
?? "mongodb://mongo:27017";
|
?? "mongodb://mongo:27017";
|
||||||
|
|||||||
@@ -3,10 +3,6 @@ using MongoDB.Bson.Serialization.Attributes;
|
|||||||
|
|
||||||
namespace Knot.Modules.Profiles.Domain;
|
namespace Knot.Modules.Profiles.Domain;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// MongoDB-документ профиля пользователя.
|
|
||||||
/// Id совпадает с UserId из модуля Auth (Postgres).
|
|
||||||
/// </summary>
|
|
||||||
public sealed class ProfileDocument
|
public sealed class ProfileDocument
|
||||||
{
|
{
|
||||||
[BsonId]
|
[BsonId]
|
||||||
@@ -25,6 +21,9 @@ public sealed class ProfileDocument
|
|||||||
|
|
||||||
public DateTime? StatusExpiresAt { 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 string? AvatarUrl { get; private set; }
|
||||||
|
|
||||||
public DateTime? Birthday { get; private set; }
|
public DateTime? Birthday { get; private set; }
|
||||||
@@ -83,6 +82,16 @@ public sealed class ProfileDocument
|
|||||||
StatusExpiresAt = statusExpiresAt;
|
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)
|
public void UpdateStatus(bool isBanned, bool isDeleted)
|
||||||
{
|
{
|
||||||
IsBanned = isBanned;
|
IsBanned = isBanned;
|
||||||
|
|||||||
@@ -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
|
internal class ProfileRepository : IProfileRepository
|
||||||
{
|
{
|
||||||
private readonly IMongoCollection<ProfileDocument> _profiles;
|
private readonly IMongoCollection<ProfileDocument> _profiles;
|
||||||
|
private readonly IUserStatusRepository _statuses;
|
||||||
|
|
||||||
public ProfileRepository(IMongoDatabase database)
|
public ProfileRepository(IMongoDatabase database, IUserStatusRepository statuses)
|
||||||
{
|
{
|
||||||
_profiles = database.GetCollection<ProfileDocument>("profiles");
|
_profiles = database.GetCollection<ProfileDocument>("profiles");
|
||||||
|
_statuses = statuses;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<UserProfileDto?> GetAsync(Guid userId, CancellationToken ct = default)
|
public async Task<UserProfileDto?> GetAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct);
|
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)
|
public async Task<UserProfileDto?> GetByUsernameAsync(string username, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var profile = await _profiles.Find(p => p.Username == username).FirstOrDefaultAsync(ct);
|
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)
|
public async Task<List<UserProfileDto>> GetAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var ids = userIds.ToList();
|
var ids = userIds.ToList();
|
||||||
var profiles = await _profiles.Find(p => ids.Contains(p.Id)).ToListAsync(ct);
|
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)
|
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);
|
var combinedFilter = Builders<ProfileDocument>.Filter.And(baseFilter, searchFilter);
|
||||||
docs = await _profiles.Find(combinedFilter).Limit(limit).ToListAsync(ct);
|
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)
|
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);
|
var profile = ProfileDocument.Create(dto.UserId, dto.Username ?? string.Empty, dto.DisplayName ?? string.Empty, dto.About);
|
||||||
|
|
||||||
await _profiles.InsertOneAsync(profile, null, ct);
|
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)
|
public async Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken ct = default)
|
||||||
@@ -80,11 +99,13 @@ internal class ProfileRepository : IProfileRepository
|
|||||||
dto.Birthday);
|
dto.Birthday);
|
||||||
|
|
||||||
document.UpdateAvatar(dto.Avatar);
|
document.UpdateAvatar(dto.Avatar);
|
||||||
document.UpdateStatus(dto.StatusText, dto.StatusEmoji, dto.StatusExpiresAt);
|
|
||||||
document.UpdateInvisible(dto.IsInvisible);
|
document.UpdateInvisible(dto.IsInvisible);
|
||||||
|
|
||||||
await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct);
|
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)
|
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,16 +1,14 @@
|
|||||||
using Knot.Contracts.Profiles.Application.DTOs;
|
using Knot.Contracts.Profiles.Application.DTOs;
|
||||||
|
using Knot.Contracts.Profiles.Domain;
|
||||||
using Knot.Modules.Profiles.Domain;
|
using Knot.Modules.Profiles.Domain;
|
||||||
using System;
|
|
||||||
|
|
||||||
namespace Knot.Modules.Profiles.Infrastructure.Mappings;
|
namespace Knot.Modules.Profiles.Infrastructure.Mappings;
|
||||||
|
|
||||||
public static class ProfileMappings
|
public static class ProfileMappings
|
||||||
{
|
{
|
||||||
public static UserProfileDto ToDto(this ProfileDocument document)
|
public static UserProfileDto ToDtoWithStatusMap(ProfileDocument document, IReadOnlyDictionary<Guid, UserStatusDto> statusMap)
|
||||||
{
|
{
|
||||||
var hasActiveStatus = !document.StatusExpiresAt.HasValue || document.StatusExpiresAt.Value > DateTime.UtcNow;
|
var dto = new UserProfileDto
|
||||||
|
|
||||||
return new UserProfileDto
|
|
||||||
{
|
{
|
||||||
UserId = document.Id,
|
UserId = document.Id,
|
||||||
DisplayName = document.DisplayName,
|
DisplayName = document.DisplayName,
|
||||||
@@ -21,11 +19,59 @@ public static class ProfileMappings
|
|||||||
LastSeen = null,
|
LastSeen = null,
|
||||||
Birthday = document.Birthday,
|
Birthday = document.Birthday,
|
||||||
IsPremium = false,
|
IsPremium = false,
|
||||||
StatusText = hasActiveStatus ? document.StatusText : null,
|
|
||||||
StatusEmoji = hasActiveStatus ? document.StatusEmoji : null,
|
|
||||||
StatusExpiresAt = hasActiveStatus ? document.StatusExpiresAt : null,
|
|
||||||
IsInvisible = document.IsInvisible,
|
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<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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,9 +81,6 @@ public static class ProfilesEndpoints
|
|||||||
request.DisplayName,
|
request.DisplayName,
|
||||||
request.Bio,
|
request.Bio,
|
||||||
request.Birthday,
|
request.Birthday,
|
||||||
request.StatusText,
|
|
||||||
request.StatusEmoji,
|
|
||||||
request.StatusExpiresAt,
|
|
||||||
request.IsInvisible),
|
request.IsInvisible),
|
||||||
ct);
|
ct);
|
||||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
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,9 +15,19 @@ export interface UserPresence extends UserBasic {
|
|||||||
lastSeen: string;
|
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 {
|
export interface User extends UserPresence {
|
||||||
bio: string | null;
|
bio: string | null;
|
||||||
birthday: string | null;
|
birthday: string | null;
|
||||||
|
status?: UserStatus | null;
|
||||||
statusText?: string | null;
|
statusText?: string | null;
|
||||||
statusEmoji?: string | null;
|
statusEmoji?: string | null;
|
||||||
statusExpiresAt?: string | null;
|
statusExpiresAt?: string | null;
|
||||||
@@ -26,6 +36,13 @@ export interface User extends UserPresence {
|
|||||||
hideStoryViews?: boolean;
|
hideStoryViews?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface StatusPreset {
|
||||||
|
id: string;
|
||||||
|
emoji: string;
|
||||||
|
textRu: string;
|
||||||
|
textEn: string;
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Chat types ────────────────────────────────────────────────────────
|
// ─── Chat types ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface ChatMember {
|
export interface ChatMember {
|
||||||
|
|||||||
@@ -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) {
|
if (!result.bio && result.about) {
|
||||||
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) {
|
if (!result.statusExpiresAt && result.status_expires_at) {
|
||||||
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
|
// 5. Privacy flag
|
||||||
if (typeof result.isInvisible === 'undefined' && typeof result.is_invisible !== 'undefined') {
|
if (typeof result.isInvisible === 'undefined' && typeof result.is_invisible !== 'undefined') {
|
||||||
@@ -73,7 +88,13 @@ export function deepNormalize(obj: any): any {
|
|||||||
const result = { ...obj };
|
const result = { ...obj };
|
||||||
|
|
||||||
// Если это объект, похожий на пользователя (есть id и userName/username)
|
// Если это объект, похожий на пользователя (есть 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);
|
const normalized = normalizeUser(result);
|
||||||
// Продолжаем рекурсию по остальным полям (например, если у пользователя есть вложенные объекты)
|
// Продолжаем рекурсию по остальным полям (например, если у пользователя есть вложенные объекты)
|
||||||
for (const key in normalized) {
|
for (const key in normalized) {
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import { UserApi } from '../../../users/infrastructure/userApi';
|
|||||||
|
|
||||||
const userStatusCache = new Map<string, { emoji?: string | null; text?: string | null; isInvisible?: boolean }>();
|
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 {
|
interface ChatListItemProps {
|
||||||
chat: Chat;
|
chat: Chat;
|
||||||
isActive: boolean;
|
isActive: boolean;
|
||||||
@@ -108,7 +114,8 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
UserApi.getUser(otherId)
|
UserApi.getUser(otherId)
|
||||||
.then((profile) => {
|
.then((profile) => {
|
||||||
if (cancelled) return;
|
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);
|
userStatusCache.set(otherId, status);
|
||||||
setOtherUserStatus(status);
|
setOtherUserStatus(status);
|
||||||
})
|
})
|
||||||
@@ -235,8 +242,16 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
<span className="material-symbols-outlined text-on-primary-container text-[24px]">bookmark</span>
|
<span className="material-symbols-outlined text-on-primary-container text-[24px]">bookmark</span>
|
||||||
</div>
|
</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} />
|
<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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -246,14 +261,6 @@ function ChatListItem({ chat, isActive }: ChatListItemProps) {
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="flex items-center gap-1.5 min-w-0">
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
{isPinned && <span className="material-symbols-outlined text-[14px] text-primary rotate-45">push_pin</span>}
|
{isPinned && <span className="material-symbols-outlined text-[14px] text-primary rotate-45">push_pin</span>}
|
||||||
{chat.type === 'personal' && otherUserStatus?.emoji && (
|
|
||||||
<span
|
|
||||||
className="text-sm leading-none"
|
|
||||||
title={otherUserStatus.text || undefined}
|
|
||||||
>
|
|
||||||
{otherUserStatus.emoji}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className={`text-sm font-bold tracking-tight truncate ${isActive ? 'text-primary' : 'text-on-surface'}`}>{chatName}</span>
|
<span className={`text-sm font-bold tracking-tight truncate ${isActive ? 'text-primary' : 'text-on-surface'}`}>{chatName}</span>
|
||||||
</div>
|
</div>
|
||||||
{timeStr && <span className="text-[10px] uppercase font-bold tracking-wider text-on-surface-variant/50 flex-shrink-0 ml-2">{timeStr}</span>}
|
{timeStr && <span className="text-[10px] uppercase font-bold tracking-wider text-on-surface-variant/50 flex-shrink-0 ml-2">{timeStr}</span>}
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
const [scrollReady, setScrollReady] = useState(false);
|
const [scrollReady, setScrollReady] = useState(false);
|
||||||
const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]);
|
const [activeGroupCallParticipants, setActiveGroupCallParticipants] = useState<string[]>([]);
|
||||||
const [otherUserInvisible, setOtherUserInvisible] = useState(false);
|
const [otherUserInvisible, setOtherUserInvisible] = useState(false);
|
||||||
|
const [otherUserStatusHeader, setOtherUserStatusHeader] = useState<{ emoji?: string | null; text?: string | null } | null>(null);
|
||||||
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -188,6 +189,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (chat?.type !== 'personal' || !otherMember?.user.id) {
|
if (chat?.type !== 'personal' || !otherMember?.user.id) {
|
||||||
setOtherUserInvisible(false);
|
setOtherUserInvisible(false);
|
||||||
|
setOtherUserStatusHeader(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,11 +198,15 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
.then((profile) => {
|
.then((profile) => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setOtherUserInvisible(!!profile.isInvisible);
|
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(() => {
|
.catch(() => {
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setOtherUserInvisible(false);
|
setOtherUserInvisible(false);
|
||||||
|
setOtherUserStatusHeader(null);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -869,6 +875,7 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
<span className="material-symbols-outlined text-on-primary-container text-[20px]">bookmark</span>
|
<span className="material-symbols-outlined text-on-primary-container text-[20px]">bookmark</span>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="relative">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={chatAvatar}
|
src={chatAvatar}
|
||||||
name={chatName}
|
name={chatName}
|
||||||
@@ -876,10 +883,25 @@ export default function ChatView({ onStartCall, onStartGroupCall }: { onStartCal
|
|||||||
online={!shouldMaskOtherUserPresence && isOnline ? true : undefined}
|
online={!shouldMaskOtherUserPresence && isOnline ? true : undefined}
|
||||||
className="avatar-knot-container group-hover:border-primary/30"
|
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>
|
||||||
<div className="min-w-0 text-left">
|
<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>
|
<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">
|
<p className="text-[11px] font-bold uppercase tracking-widest text-on-surface-variant/50">
|
||||||
{isFavorites
|
{isFavorites
|
||||||
? t('favoritesDescription')
|
? t('favoritesDescription')
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,9 +16,6 @@ export class UserApi {
|
|||||||
displayName?: string;
|
displayName?: string;
|
||||||
bio?: string;
|
bio?: string;
|
||||||
birthday?: string | null;
|
birthday?: string | null;
|
||||||
statusText?: string | null;
|
|
||||||
statusEmoji?: string | null;
|
|
||||||
statusExpiresAt?: string | null;
|
|
||||||
isInvisible?: boolean;
|
isInvisible?: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { useAuthStore } from '../../../auth/application/authStore';
|
|||||||
import { useChatStore } from '../../../chats/application/chatStore';
|
import { useChatStore } from '../../../chats/application/chatStore';
|
||||||
import { useLang } from '../../../../core/infrastructure/i18n';
|
import { useLang } from '../../../../core/infrastructure/i18n';
|
||||||
import { UserApi } from '../../infrastructure/userApi';
|
import { UserApi } from '../../infrastructure/userApi';
|
||||||
|
import { StatusApi } from '../../infrastructure/statusApi';
|
||||||
import { getMediaUrl, getInitials } from '../../../../core/utils/utils';
|
import { getMediaUrl, getInitials } from '../../../../core/utils/utils';
|
||||||
import DatePicker from '../../../../core/presentation/components/ui/DatePicker';
|
import DatePicker from '../../../../core/presentation/components/ui/DatePicker';
|
||||||
import AvatarCropModal from './AvatarCropModal';
|
import AvatarCropModal from './AvatarCropModal';
|
||||||
@@ -17,18 +18,13 @@ import { Area } from 'react-easy-crop';
|
|||||||
import { getCroppedImg } from '../../../../core/utils/cropImage';
|
import { getCroppedImg } from '../../../../core/utils/cropImage';
|
||||||
import EmojiOnlyPicker from '../../../stories/presentation/components/EmojiOnlyPicker';
|
import EmojiOnlyPicker from '../../../stories/presentation/components/EmojiOnlyPicker';
|
||||||
import ChangePasswordModal from './ChangePasswordModal';
|
import ChangePasswordModal from './ChangePasswordModal';
|
||||||
|
import type { StatusPreset } from '../../../../core/domain/types';
|
||||||
|
|
||||||
const BIO_MAX_LENGTH = 200;
|
const BIO_MAX_LENGTH = 200;
|
||||||
const STATUS_MAX_LENGTH = 50;
|
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() {
|
export default function SettingsPage() {
|
||||||
const { user, updateUser, logout } = useAuthStore();
|
const { user, updateUser, logout, checkAuth } = useAuthStore();
|
||||||
const { applyInvisiblePrivacyMode } = useChatStore();
|
const { applyInvisiblePrivacyMode } = useChatStore();
|
||||||
const { t, lang, setLang } = useLang();
|
const { t, lang, setLang } = useLang();
|
||||||
|
|
||||||
@@ -37,8 +33,8 @@ export default function SettingsPage() {
|
|||||||
displayName: user?.displayName || '',
|
displayName: user?.displayName || '',
|
||||||
bio: user?.bio || '',
|
bio: user?.bio || '',
|
||||||
birthday: user?.birthday || '',
|
birthday: user?.birthday || '',
|
||||||
statusText: user?.statusText || '',
|
statusText: (user?.status?.text ?? user?.statusText) || '',
|
||||||
statusEmoji: user?.statusEmoji || '💬',
|
statusEmoji: (user?.status?.emoji ?? user?.statusEmoji) || '💬',
|
||||||
statusDuration: 'none' as 'none' | '1h' | '1d',
|
statusDuration: 'none' as 'none' | '1h' | '1d',
|
||||||
isInvisible: !!user?.isInvisible
|
isInvisible: !!user?.isInvisible
|
||||||
});
|
});
|
||||||
@@ -46,8 +42,8 @@ export default function SettingsPage() {
|
|||||||
const [showStatusPicker, setShowStatusPicker] = useState(false);
|
const [showStatusPicker, setShowStatusPicker] = useState(false);
|
||||||
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
||||||
const [updatingInvisible, setUpdatingInvisible] = useState(false);
|
const [updatingInvisible, setUpdatingInvisible] = useState(false);
|
||||||
|
const [statusPresets, setStatusPresets] = useState<StatusPreset[]>([]);
|
||||||
|
|
||||||
// Avatar cropping state
|
|
||||||
const [cropModal, setCropModal] = useState<{
|
const [cropModal, setCropModal] = useState<{
|
||||||
open: boolean,
|
open: boolean,
|
||||||
image: string,
|
image: string,
|
||||||
@@ -56,14 +52,20 @@ export default function SettingsPage() {
|
|||||||
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
const [uploadingAvatar, setUploadingAvatar] = useState(false);
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
StatusApi.getPresets()
|
||||||
|
.then(setStatusPresets)
|
||||||
|
.catch(() => setStatusPresets([]));
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user) {
|
if (user) {
|
||||||
setFormData({
|
setFormData({
|
||||||
displayName: user.displayName || '',
|
displayName: user.displayName || '',
|
||||||
bio: user.bio || '',
|
bio: user.bio || '',
|
||||||
birthday: user.birthday || '',
|
birthday: user.birthday || '',
|
||||||
statusText: user.statusText || '',
|
statusText: (user?.status?.text ?? user.statusText) || '',
|
||||||
statusEmoji: user.statusEmoji || '💬',
|
statusEmoji: (user?.status?.emoji ?? user.statusEmoji) || '💬',
|
||||||
statusDuration: 'none',
|
statusDuration: 'none',
|
||||||
isInvisible: !!user.isInvisible
|
isInvisible: !!user.isInvisible
|
||||||
});
|
});
|
||||||
@@ -73,22 +75,33 @@ export default function SettingsPage() {
|
|||||||
const handleSaveProfile = async () => {
|
const handleSaveProfile = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const payload = {
|
await UserApi.updateProfile({
|
||||||
...formData,
|
displayName: formData.displayName,
|
||||||
bio: formData.bio.slice(0, BIO_MAX_LENGTH),
|
bio: formData.bio.slice(0, BIO_MAX_LENGTH),
|
||||||
statusText: formData.statusText.trim() ? formData.statusText.slice(0, STATUS_MAX_LENGTH) : null,
|
birthday: formData.birthday || null,
|
||||||
statusEmoji: formData.statusText.trim() ? formData.statusEmoji : null,
|
isInvisible: formData.isInvisible,
|
||||||
statusExpiresAt:
|
});
|
||||||
|
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'
|
formData.statusText.trim() && formData.statusDuration !== 'none'
|
||||||
? new Date(
|
? new Date(
|
||||||
Date.now() + (formData.statusDuration === '1h' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000)
|
Date.now() +
|
||||||
|
(formData.statusDuration === '1h' ? 60 * 60 * 1000 : 24 * 60 * 60 * 1000)
|
||||||
).toISOString()
|
).toISOString()
|
||||||
: null,
|
: null,
|
||||||
birthday: formData.birthday || null
|
presetKey: null,
|
||||||
};
|
});
|
||||||
const updatedUser = await UserApi.updateProfile(payload);
|
}
|
||||||
updateUser(updatedUser);
|
await checkAuth();
|
||||||
if (payload.isInvisible) {
|
if (formData.isInvisible) {
|
||||||
applyInvisiblePrivacyMode(true);
|
applyInvisiblePrivacyMode(true);
|
||||||
}
|
}
|
||||||
setEditing(false);
|
setEditing(false);
|
||||||
@@ -151,9 +164,6 @@ export default function SettingsPage() {
|
|||||||
displayName: user.displayName || '',
|
displayName: user.displayName || '',
|
||||||
bio: user.bio || '',
|
bio: user.bio || '',
|
||||||
birthday: user.birthday || null,
|
birthday: user.birthday || null,
|
||||||
statusText: user.statusText || null,
|
|
||||||
statusEmoji: user.statusEmoji || null,
|
|
||||||
statusExpiresAt: user.statusExpiresAt || null,
|
|
||||||
isInvisible: nextInvisible,
|
isInvisible: nextInvisible,
|
||||||
});
|
});
|
||||||
updateUser(updatedUser);
|
updateUser(updatedUser);
|
||||||
@@ -170,6 +180,20 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
const initials = getInitials(user?.displayName || user?.username || '??');
|
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 (
|
return (
|
||||||
<div className="flex-1 h-full overflow-y-auto px-12 py-16 custom-scrollbar bg-surface-container-lowest">
|
<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">
|
<div className="max-w-[700px] mx-auto space-y-12">
|
||||||
@@ -343,18 +367,16 @@ export default function SettingsPage() {
|
|||||||
|
|
||||||
{editing ? (
|
{editing ? (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-2">
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-5 gap-2">
|
||||||
{STATUS_PRESETS.map((preset) => (
|
{statusPresets.map((preset) => (
|
||||||
<button
|
<button
|
||||||
key={`${preset.emoji}-${preset.textRu}`}
|
key={preset.id}
|
||||||
onClick={() => setFormData((p) => ({
|
type="button"
|
||||||
...p,
|
onClick={() => applyPresetQuick(preset)}
|
||||||
statusEmoji: preset.emoji,
|
|
||||||
statusText: lang === 'ru' ? preset.textRu : preset.textEn,
|
|
||||||
}))}
|
|
||||||
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"
|
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} {lang === 'ru' ? preset.textRu : preset.textEn}
|
{preset.emoji ? `${preset.emoji} ` : ''}
|
||||||
|
{lang === 'ru' ? preset.textRu : preset.textEn}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@@ -400,7 +422,16 @@ export default function SettingsPage() {
|
|||||||
{formData.statusText.length}/{STATUS_MAX_LENGTH}
|
{formData.statusText.length}/{STATUS_MAX_LENGTH}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFormData((p) => ({ ...p, statusText: '', statusEmoji: '💬', statusDuration: 'none' }))}
|
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"
|
className="text-[10px] font-black uppercase tracking-widest text-red-400/80 hover:text-red-400"
|
||||||
>
|
>
|
||||||
{lang === 'ru' ? 'Очистить статус' : 'Clear status'}
|
{lang === 'ru' ? 'Очистить статус' : 'Clear status'}
|
||||||
@@ -409,7 +440,8 @@ export default function SettingsPage() {
|
|||||||
</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">
|
<div className="inline-flex px-4 py-2 rounded-full bg-white/5 border border-white/10 text-sm font-bold text-white">
|
||||||
{user?.statusEmoji || '💬'} {user?.statusText || (lang === 'ru' ? 'Статус не указан' : 'No status')}
|
{(user?.status?.emoji ?? user?.statusEmoji) || '💬'}{' '}
|
||||||
|
{(user?.status?.text ?? user?.statusText) || (lang === 'ru' ? 'Статус не указан' : 'No status')}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -242,10 +242,12 @@ 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">
|
<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>
|
<span className="text-xs font-black text-primary uppercase tracking-widest">@{user.username || 'user_' + userId.slice(0, 5)}</span>
|
||||||
</div>
|
</div>
|
||||||
{(user.statusEmoji || user.statusText) && (
|
{(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">
|
<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">
|
<span className="text-xs font-black text-white tracking-wide">
|
||||||
{user.statusEmoji || '💬'} {user.statusText || (lang === 'ru' ? 'Статус' : 'Status')}
|
{(user?.status?.emoji ?? user?.statusEmoji) || '💬'}{' '}
|
||||||
|
{(user?.status?.text ?? user?.statusText) || (lang === 'ru' ? 'Статус' : 'Status')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user