Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f61071ed | ||
|
|
e8c9a55fe9 | ||
|
|
bccbf12a11 | ||
|
|
c8b0384392 | ||
|
|
5245e8b7ae | ||
|
|
b346372555 | ||
|
|
d2e6eb578a | ||
|
|
ae710c3d43 | ||
|
|
600e43eec5 | ||
|
|
7b251686b2 | ||
|
|
00682b2977 | ||
|
|
e05572fc3f | ||
|
|
c264b7df27 | ||
|
|
56f75ae32b | ||
|
|
ca9cf27716 | ||
|
|
7225e3272e | ||
|
|
86ae06beb6 | ||
|
|
454f70f716 | ||
|
|
e700609d30 | ||
|
|
88f39aa51f | ||
|
|
c3dbbaa7b8 | ||
|
|
2eb4f48ca0 | ||
|
|
0c1adaab6c | ||
|
|
a52726d0e6 | ||
|
|
d812a7a40c | ||
|
|
c8b4fed25a |
@@ -1,13 +1,13 @@
|
||||
using FluentAssertions;
|
||||
using Knot.Modules.Profiles.Application.Profiles.UpdateProfile;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Modules.Profiles.Application.Abstractions;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
namespace Knot.Modules.Profiles.UnitTests;
|
||||
|
||||
@@ -25,14 +25,11 @@ public class UpdateProfileCommandHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_ShouldReturnError_WhenProfileNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null);
|
||||
_profileRepository.GetByIdAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((ProfileDocument?)null);
|
||||
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null, null);
|
||||
_profileRepository.GetAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((UserProfileDto?)null);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsFailure.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ public interface IJwtTokenProvider
|
||||
{
|
||||
string GenerateAccessToken(Guid userId, string username);
|
||||
string GenerateRefreshToken();
|
||||
DateTime GetRefreshTokenExpiry();
|
||||
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||
string Generate(Domain.UserContract user);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ public static class AuthErrors
|
||||
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
|
||||
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
|
||||
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
|
||||
public static Error IdentityRegistrationFailed => new("Auth.RegistrationFailed", "Failed to register user");
|
||||
public static Error RefreshTokenExpired => new("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again.");
|
||||
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
|
||||
public static Error PasswordConfirmationMismatch => new("Auth.PasswordConfirmationMismatch", "Passwords do not match");
|
||||
public static Error PasswordTooShort => new("Auth.PasswordTooShort", "Password must be at least 8 characters");
|
||||
public static Error OldPasswordInvalid => new("Auth.OldPasswordInvalid", "Current password is incorrect");
|
||||
}
|
||||
|
||||
@@ -19,12 +19,4 @@ public class UserContract
|
||||
public bool IsExternal { get; set; }
|
||||
public string? Domain { get; set; }
|
||||
public DateTime? LastSeen { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public DateTime? RefreshTokenExpiry { get; set; }
|
||||
|
||||
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||
{
|
||||
RefreshToken = refreshToken;
|
||||
RefreshTokenExpiry = expiry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ public interface IMessageRepository
|
||||
|
||||
Task<List<Message>> GetChatMessagesCursorAsync(Guid chatId, DateTime? cursor, long? sequenceId, int limit, CancellationToken cancellationToken);
|
||||
Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
||||
Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken);
|
||||
Task<Message?> GetLastStoryMessageAsync(Guid chatId, Guid storyId, CancellationToken cancellationToken);
|
||||
|
||||
Task UpdateAsync(Message message, CancellationToken cancellationToken);
|
||||
|
||||
@@ -19,16 +19,13 @@ public abstract class Message : AggregateRoot<Guid>
|
||||
|
||||
public bool IsEdited => HasState(MessageState.IsEdited);
|
||||
public bool IsDeleted => HasState(MessageState.IsDeleted);
|
||||
|
||||
|
||||
protected List<DeletedMessage> _deletedFor = new();
|
||||
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
||||
|
||||
protected List<Guid> _readByUsers = new();
|
||||
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
|
||||
|
||||
protected Message() : base(Guid.Empty) { }
|
||||
|
||||
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||
protected Message(Guid id, Guid chatId, Guid senderId, Guid? replyToId, Guid? forwardedFromId, DateTime createdAt, bool isImported)
|
||||
: base(id)
|
||||
{
|
||||
ChatId = chatId;
|
||||
@@ -45,25 +42,15 @@ public abstract class Message : AggregateRoot<Guid>
|
||||
public bool IsDeletedForUser(Guid userId) => _deletedFor.Exists(d => d.UserId == userId);
|
||||
|
||||
public virtual void Delete() => AddState(MessageState.IsDeleted);
|
||||
public virtual void Edit(string newContent)
|
||||
{
|
||||
Content = newContent;
|
||||
AddState(MessageState.IsEdited);
|
||||
public virtual void Edit(string newContent)
|
||||
{
|
||||
Content = newContent;
|
||||
AddState(MessageState.IsEdited);
|
||||
}
|
||||
|
||||
public void DeleteForUser(Guid userId)
|
||||
{
|
||||
if (!_deletedFor.Exists(x => x.UserId == userId))
|
||||
_deletedFor.Add(new DeletedMessage(Id, userId));
|
||||
public void DeleteForUser(Guid userId)
|
||||
{
|
||||
if (!_deletedFor.Exists(x => x.UserId == userId))
|
||||
_deletedFor.Add(new DeletedMessage(Id, userId));
|
||||
}
|
||||
|
||||
public void MarkAsRead(Guid userId)
|
||||
{
|
||||
if (!_readByUsers.Contains(userId))
|
||||
{
|
||||
_readByUsers.Add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
|
||||
}
|
||||
|
||||
@@ -9,4 +9,8 @@ public static class ProfilesErrors
|
||||
public static Error AvatarNotFound => new("Profiles.AvatarNotFound", "Avatar not found");
|
||||
public static Error InvalidAvatarFormat => new("Profiles.InvalidAvatarFormat", "Invalid avatar format");
|
||||
public static Error AvatarUploadFailed => new("Profiles.AvatarUploadFailed", "Avatar upload failed");
|
||||
public static Error BioTooLong => new("Profiles.BioTooLong", "Bio must be 200 characters or less");
|
||||
public static Error StatusTextTooLong => new("Profiles.StatusTextTooLong", "Status text must be 50 characters or less");
|
||||
public static Error StatusEmpty => new("Statuses.Empty", "Status emoji or text is required");
|
||||
public static Error InvalidPreset => new("Statuses.InvalidPreset", "Unknown status preset");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
public sealed class StatusPresetDto
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Emoji { get; set; } = "";
|
||||
public string TextRu { get; set; } = "";
|
||||
public string TextEn { get; set; } = "";
|
||||
}
|
||||
@@ -12,5 +12,13 @@ public class UserProfileDto
|
||||
public DateTime? LastSeen { get; set; }
|
||||
public DateTime? Birthday { get; set; }
|
||||
public bool IsPremium { get; set; }
|
||||
public UserStatusDto? Status { get; set; }
|
||||
|
||||
public Guid? CurrentStatusId { get; set; }
|
||||
|
||||
public string? StatusText { get; set; }
|
||||
public string? StatusEmoji { get; set; }
|
||||
public DateTime? StatusExpiresAt { get; set; }
|
||||
public bool IsInvisible { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
public sealed class UserStatusDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = "Custom";
|
||||
public string Emoji { get; set; } = "";
|
||||
public string Text { get; set; } = "";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Contracts.Profiles.Domain;
|
||||
|
||||
public interface IProfileStatusWriter
|
||||
{
|
||||
Task<Result<UserProfileDto>> SetCustomAsync(Guid userId, string emoji, string text, DateTime? expiresAt, string? presetKey, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Result<UserProfileDto>> ClearAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
namespace Knot.Contracts.Profiles.Domain;
|
||||
|
||||
public interface IUserStatusRepository
|
||||
{
|
||||
Task<UserStatusDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyDictionary<Guid, UserStatusDto>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default);
|
||||
|
||||
Task InsertAsync(UserStatusDto dto, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -250,6 +250,7 @@ app.MapStoriesEndpoints();
|
||||
app.MapContactsEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapProfilesEndpoints();
|
||||
app.MapStatusesEndpoints();
|
||||
app.MapFederationEndpoints();
|
||||
app.MapKlipyEndpoints();
|
||||
app.MapChatsEndpoints();
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
"Secret": "knot_super_secret_key_1234567890_knot",
|
||||
"Issuer": "Knot",
|
||||
"Audience": "KnotUsers",
|
||||
"ExpiryInMinutes": 1440,
|
||||
"RefreshExpiryInDays": 30
|
||||
"ExpiryInMinutes": 1440
|
||||
},
|
||||
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,5 @@ namespace Knot.Modules.Auth.Application.Abstractions;
|
||||
public interface IJwtTokenProvider
|
||||
{
|
||||
string Generate(User user);
|
||||
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||
string GenerateRefreshToken();
|
||||
DateTime GetRefreshTokenExpiry();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using BCrypt.Net;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Modules.Auth.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.ChangePassword;
|
||||
|
||||
public sealed record ChangePasswordCommand(
|
||||
Guid UserId,
|
||||
string OldPassword,
|
||||
string NewPassword,
|
||||
string ConfirmPassword) : ICommand;
|
||||
|
||||
internal sealed class ChangePasswordCommandHandler : ICommandHandler<ChangePasswordCommand>
|
||||
{
|
||||
private readonly IAuthDbContext _dbContext;
|
||||
|
||||
public ChangePasswordCommandHandler(IAuthDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(ChangePasswordCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.NewPassword != request.ConfirmPassword)
|
||||
return Result.Failure(AuthErrors.PasswordConfirmationMismatch);
|
||||
|
||||
if (request.NewPassword.Length < 8)
|
||||
return Result.Failure(AuthErrors.PasswordTooShort);
|
||||
|
||||
var user = await _dbContext.Set<User>()
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
return Result.Failure(AuthErrors.UserNotFound);
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(request.OldPassword, user.PasswordHash))
|
||||
return Result.Failure(AuthErrors.OldPasswordInvalid);
|
||||
|
||||
user.ChangePassword(BCrypt.Net.BCrypt.HashPassword(request.NewPassword));
|
||||
user.SetRefreshToken(null);
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
@@ -11,12 +9,10 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
|
||||
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IJwtTokenProvider _tokenProvider;
|
||||
|
||||
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
|
||||
public GetMeQueryHandler(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
|
||||
@@ -27,18 +23,9 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
|
||||
}
|
||||
|
||||
// Check if access token needs to be refreshed (less than 1 hour remaining)
|
||||
string? newAccessToken = null;
|
||||
|
||||
// We can't directly check the current token's expiry here, but we can
|
||||
// always issue a new token if the user is authenticated
|
||||
// For now, let's issue a new token on every request (simplified approach)
|
||||
// A better approach would be to parse the incoming token and check expiry
|
||||
newAccessToken = _tokenProvider.Generate(user);
|
||||
|
||||
var response = new AuthResponseDto
|
||||
{
|
||||
AccessToken = newAccessToken,
|
||||
AccessToken = string.Empty,
|
||||
RefreshToken = string.Empty,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
|
||||
@@ -5,6 +5,9 @@ using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.Login;
|
||||
|
||||
/// <summary>
|
||||
/// ������� ��� ����� ������������. ���������� AuthResponseDto.
|
||||
/// </summary>
|
||||
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
|
||||
|
||||
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
|
||||
@@ -28,21 +31,14 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
|
||||
}
|
||||
|
||||
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||
string refreshToken = _tokenProvider.GenerateRefreshToken();
|
||||
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||
|
||||
// Save refresh token to database
|
||||
user.SetRefreshToken(refreshToken, refreshExpiry);
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
return Result.Success(new AuthResponseDto
|
||||
{
|
||||
AccessToken = token,
|
||||
RefreshToken = refreshToken,
|
||||
RefreshToken = string.Empty,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
DisplayName = user.DisplayName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||
|
||||
public record RefreshTokenCommand(string RefreshToken) : ICommand<AuthResponseDto>;
|
||||
@@ -1,65 +0,0 @@
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||
|
||||
internal sealed class RefreshTokenCommandHandler : ICommandHandler<RefreshTokenCommand, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IJwtTokenProvider _tokenProvider;
|
||||
|
||||
public RefreshTokenCommandHandler(
|
||||
IUserRepository userRepository,
|
||||
IJwtTokenProvider tokenProvider)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
public async Task<Result<AuthResponseDto>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(
|
||||
new Error("Auth.InvalidRefreshToken", "Refresh token is required"));
|
||||
}
|
||||
|
||||
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(
|
||||
new Error("Auth.InvalidRefreshToken", "Invalid or expired refresh token"));
|
||||
}
|
||||
|
||||
// Check if refresh token has expired
|
||||
if (user.RefreshTokenExpiry.HasValue && user.RefreshTokenExpiry.Value < DateTime.UtcNow)
|
||||
{
|
||||
// Clear expired refresh token
|
||||
user.SetRefreshToken(null, null);
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
return Result.Failure<AuthResponseDto>(
|
||||
new Error("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again."));
|
||||
}
|
||||
|
||||
var newAccessToken = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||
|
||||
var newRefreshToken = _tokenProvider.GenerateRefreshToken();
|
||||
var newRefreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||
user.SetRefreshToken(newRefreshToken, newRefreshExpiry);
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
return Result.Success(new AuthResponseDto
|
||||
{
|
||||
AccessToken = newAccessToken,
|
||||
RefreshToken = newRefreshToken,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
DisplayName = user.DisplayName,
|
||||
Avatar = user.Avatar
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using BCrypt.Net;
|
||||
using BCrypt.Net;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Contracts.Settings.Application.Abstractions;
|
||||
@@ -8,6 +9,9 @@ using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.Register;
|
||||
|
||||
/// <summary>
|
||||
/// ������� ��� ����������� ������ ������������.
|
||||
/// </summary>
|
||||
public sealed record RegisterUserCommand(
|
||||
string Username,
|
||||
string Password,
|
||||
@@ -15,6 +19,9 @@ public sealed record RegisterUserCommand(
|
||||
string? Email,
|
||||
string? Bio) : ICommand<AuthResponseDto>;
|
||||
|
||||
/// <summary>
|
||||
/// ���������� ������� �����������.
|
||||
/// </summary>
|
||||
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
@@ -41,13 +48,16 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
|
||||
}
|
||||
|
||||
// 1. �������� ������������ username
|
||||
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
|
||||
}
|
||||
|
||||
// 2. ����������� ������
|
||||
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
||||
|
||||
// 3. �������� ��������
|
||||
var user = User.Create(
|
||||
request.Username,
|
||||
passwordHash,
|
||||
@@ -55,33 +65,21 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
||||
request.Email,
|
||||
request.Bio);
|
||||
|
||||
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
|
||||
repoImpl?.Add(user);
|
||||
// 4. ���������� - ���������� ����� � Domain User
|
||||
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
|
||||
repoWithDomainUserAdd?.Add(user);
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Get the saved user as contract
|
||||
var userContract = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
|
||||
if (userContract == null)
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationFailed);
|
||||
}
|
||||
|
||||
string token = _tokenProvider.Generate(userContract.Id, userContract.Username, userContract.DisplayName, userContract.Avatar);
|
||||
string refreshToken = _tokenProvider.GenerateRefreshToken();
|
||||
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||
|
||||
userContract.SetRefreshToken(refreshToken, refreshExpiry);
|
||||
await _userRepository.UpdateAsync(userContract, cancellationToken);
|
||||
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||
|
||||
return Result.Success(new AuthResponseDto
|
||||
{
|
||||
AccessToken = token,
|
||||
RefreshToken = refreshToken,
|
||||
UserId = userContract.Id,
|
||||
Username = userContract.Username,
|
||||
DisplayName = userContract.DisplayName
|
||||
RefreshToken = string.Empty,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
DisplayName = user.DisplayName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ public sealed class User : AggregateRoot<Guid>
|
||||
public bool IsBanned { get; private set; }
|
||||
public string? PhoneNumber { get; private set; }
|
||||
public string? RefreshToken { get; private set; }
|
||||
public DateTime? RefreshTokenExpiry { get; private set; }
|
||||
public DateTime? BannedUntil { get; private set; }
|
||||
|
||||
public void Ban() {
|
||||
@@ -49,10 +48,9 @@ public sealed class User : AggregateRoot<Guid>
|
||||
PhoneNumber = phoneNumber;
|
||||
}
|
||||
|
||||
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||
public void SetRefreshToken(string? refreshToken)
|
||||
{
|
||||
RefreshToken = refreshToken;
|
||||
RefreshTokenExpiry = expiry;
|
||||
}
|
||||
|
||||
public void SetBannedUntil(DateTime? bannedUntil)
|
||||
@@ -81,8 +79,6 @@ public sealed class User : AggregateRoot<Guid>
|
||||
BannedUntil = contract.BannedUntil;
|
||||
SetOnline(contract.IsOnline, contract.LastSeen);
|
||||
UserDomain = contract.Domain;
|
||||
RefreshToken = contract.RefreshToken;
|
||||
RefreshTokenExpiry = contract.RefreshTokenExpiry;
|
||||
}
|
||||
|
||||
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
|
||||
@@ -185,9 +181,7 @@ public sealed class User : AggregateRoot<Guid>
|
||||
IsOnline = IsOnline,
|
||||
IsExternal = IsExternal,
|
||||
Domain = _domain,
|
||||
LastSeen = LastSeen,
|
||||
RefreshToken = RefreshToken,
|
||||
RefreshTokenExpiry = RefreshTokenExpiry
|
||||
LastSeen = LastSeen
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +50,6 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
|
||||
return Convert.ToBase64String(randomBytes);
|
||||
}
|
||||
|
||||
public DateTime GetRefreshTokenExpiry()
|
||||
{
|
||||
var expiryInDays = int.Parse(_configuration["Jwt:RefreshExpiryInDays"] ?? "30");
|
||||
return DateTime.UtcNow.AddDays(expiryInDays);
|
||||
}
|
||||
|
||||
public string Generate(Guid userId, string username, string displayName, string? avatar)
|
||||
{
|
||||
var claims = new Claim[]
|
||||
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
using System;
|
||||
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Auth.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(AuthDbContext))]
|
||||
[Migration("20270419220000_AddRefreshTokenExpiry")]
|
||||
partial class AddRefreshTokenExpiry
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.105")
|
||||
.HasAnnotation("Relational:DefaultSchema", "identity");
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Domain")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("HideStatus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HideStoryViews")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsExternal")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsOnline")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastSeen")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpiry")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users", "identity");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Auth.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRefreshTokenExpiry : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "RefreshTokenExpiry",
|
||||
schema: "identity",
|
||||
table: "Users",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RefreshTokenExpiry",
|
||||
schema: "identity",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
using Knot.Modules.Auth.Application.Users.GetMe;
|
||||
using Knot.Modules.Auth.Application.Users.Login;
|
||||
using Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||
using Knot.Modules.Auth.Application.Users.Register;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Auth.Application.Users.Login;
|
||||
using Knot.Modules.Auth.Application.Users.Register;
|
||||
using Knot.Modules.Auth.Application.Users.GetMe;
|
||||
using Knot.Modules.Auth.Application.Users.ChangePassword;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Knot.Modules.Auth.Presentation.Endpoints;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public sealed record ChangePasswordRequest(string OldPassword, string NewPassword, string ConfirmPassword);
|
||||
|
||||
public static void MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("api/auth");
|
||||
@@ -29,16 +31,24 @@ public static class AuthEndpoints
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
||||
});
|
||||
|
||||
group.MapPost("refresh", async ([FromBody] RefreshTokenCommand command, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(command, ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
||||
});
|
||||
|
||||
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
|
||||
}).RequireAuthorization();
|
||||
|
||||
group.MapPost("change-password", async ([FromBody] ChangePasswordRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ChangePasswordCommand(userContext.UserId, request.OldPassword, request.NewPassword, request.ConfirmPassword),
|
||||
ct);
|
||||
|
||||
if (result.IsSuccess) return Results.Ok();
|
||||
|
||||
if (result.Error.Code == "Auth.OldPasswordInvalid")
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
|
||||
return Results.BadRequest(new { error = result.Error.Code ?? result.Error.Description });
|
||||
}).RequireAuthorization();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ public record MessageDetailDto(
|
||||
bool? PollIsMultipleChoice = null,
|
||||
bool? PollIsAnonymous = null,
|
||||
bool? PollIsClosed = null,
|
||||
List<Guid>? UserVotedOptionIds = null,
|
||||
bool IsDeletedForUser = false
|
||||
List<Guid>? UserVotedOptionIds = null
|
||||
);
|
||||
|
||||
public record ReplyToMessageDto(
|
||||
|
||||
+17
-12
@@ -42,16 +42,10 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
||||
|
||||
if (request.DeleteForAll)
|
||||
{
|
||||
// Only message sender can delete for everyone
|
||||
if (message.SenderId == request.UserId)
|
||||
{
|
||||
message.Delete();
|
||||
}
|
||||
else
|
||||
{
|
||||
// If not the sender, just delete for current user
|
||||
message.DeleteForUser(request.UserId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -61,13 +55,24 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
// Notify all clients in the chat about the deletion
|
||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
||||
if (request.DeleteForAll)
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
messageIds = request.MessageIds,
|
||||
deleteForAll = request.DeleteForAll
|
||||
});
|
||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
messageIds = request.MessageIds,
|
||||
deleteForAll = true
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
messageIds = request.MessageIds,
|
||||
deleteForAll = false
|
||||
});
|
||||
}
|
||||
|
||||
return global::Knot.Shared.Kernel.Result.Success();
|
||||
}
|
||||
|
||||
+13
-23
@@ -3,17 +3,17 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.GetMessages;
|
||||
|
||||
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, long? AfterSequenceId = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
||||
public record GetMessagesQuery(Guid UserId, Guid ChatId, string? Cursor, long? Pivot = null, int? Limit = null) : IQuery<List<MessageDetailDto>>;
|
||||
|
||||
internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery, List<MessageDetailDto>>
|
||||
{
|
||||
@@ -41,12 +41,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
List<Message> messages;
|
||||
int queryLimit = request.Limit ?? ChatConstants.DefaultMessageQueryLimit;
|
||||
|
||||
if (request.AfterSequenceId.HasValue)
|
||||
{
|
||||
// Получаем только сообщения ПОСЛЕ указанного sequenceId (для синхронизации)
|
||||
messages = await _messageRepository.GetChatMessagesAfterAsync(request.ChatId, request.AfterSequenceId.Value, queryLimit, cancellationToken);
|
||||
}
|
||||
else if (request.Pivot.HasValue)
|
||||
if (request.Pivot.HasValue)
|
||||
{
|
||||
messages = await _messageRepository.GetChatMessagesAroundAsync(request.ChatId, request.Pivot.Value, queryLimit, cancellationToken);
|
||||
}
|
||||
@@ -119,8 +114,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
|
||||
senders.TryGetValue(message.SenderId, out var sender);
|
||||
reactionsByMessage.TryGetValue(message.Id, out var reactions);
|
||||
|
||||
|
||||
|
||||
Message? replyMsg = null;
|
||||
if (message.ReplyToId.HasValue)
|
||||
{
|
||||
@@ -147,7 +141,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
|
||||
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
|
||||
) : null,
|
||||
(message as TextMessage)?.Quote,
|
||||
message is TextMessage tm ? tm.Quote : null,
|
||||
message.IsEdited,
|
||||
message.IsDeleted,
|
||||
message.CreatedAt,
|
||||
@@ -159,23 +153,20 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
(message as StoryMessage)?.StoryMediaType,
|
||||
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
|
||||
sender != null ? new MessageSenderDto(sender.Id, sender.Username, sender.DisplayName, sender.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList(),
|
||||
reactions?.Select(r =>
|
||||
{
|
||||
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
|
||||
reactions?.Select(r => {
|
||||
senders.TryGetValue(r.UserId, out var ru);
|
||||
return new MessageReactionDto(r.Id, r.Emoji, r.UserId, ru != null ? new MessageSenderDto(ru.Id, ru.Username, ru.DisplayName, ru.Avatar) : null);
|
||||
}).ToList() ?? new List<MessageReactionDto>(),
|
||||
(message as CallMessage)?.CallType,
|
||||
(message as CallMessage)?.CallStatus,
|
||||
(message as CallMessage)?.Duration,
|
||||
(message as PollMessage)?.Options.Select(o =>
|
||||
{
|
||||
(message as PollMessage)?.Options.Select(o => {
|
||||
var pm = (PollMessage)message;
|
||||
var voters = pm.IsAnonymous == false
|
||||
? pm.Votes
|
||||
.Where(v => v.OptionId == o.Id)
|
||||
.Select(v =>
|
||||
{
|
||||
.Select(v => {
|
||||
senders.TryGetValue(v.UserId, out var vu);
|
||||
return vu != null
|
||||
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
|
||||
@@ -183,13 +174,12 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
})
|
||||
.ToList()
|
||||
: null;
|
||||
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
|
||||
}).ToList(),
|
||||
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
|
||||
}).ToList(),
|
||||
(message as PollMessage)?.IsMultipleChoice,
|
||||
(message as PollMessage)?.IsAnonymous,
|
||||
(message as PollMessage)?.IsClosed,
|
||||
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(),
|
||||
message.IsDeletedForUser(request.UserId)
|
||||
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList()
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
+4
-24
@@ -1,8 +1,7 @@
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Read;
|
||||
|
||||
@@ -12,13 +11,11 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
|
||||
{
|
||||
private readonly IChatRepository _chatRepository;
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IMessageRepository _messageRepository;
|
||||
|
||||
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork, IMessageRepository messageRepository)
|
||||
public ReadMessagesCommandHandler(IChatRepository chatRepository, IChatsUnitOfWork unitOfWork)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_messageRepository = messageRepository;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(ReadMessagesCommand request, CancellationToken cancellationToken)
|
||||
@@ -31,23 +28,6 @@ public sealed class ReadMessagesCommandHandler : ICommandHandler<ReadMessagesCom
|
||||
|
||||
member.UpdateReadCursor(request.LastReadMessageId, request.LastReadSequenceId);
|
||||
|
||||
// Обновляем ReadByUsers для всех сообщений до LastReadSequenceId
|
||||
var messages = await _messageRepository.GetChatMessagesAfterAsync(
|
||||
request.ChatId,
|
||||
0,
|
||||
1000,
|
||||
cancellationToken);
|
||||
|
||||
foreach (var message in messages)
|
||||
{
|
||||
if (message.SequenceId <= request.LastReadSequenceId &&
|
||||
message.SenderId != request.UserId &&
|
||||
!message.IsReadBy(request.UserId))
|
||||
{
|
||||
message.MarkAsRead(request.UserId);
|
||||
}
|
||||
}
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success();
|
||||
|
||||
+3
-4
@@ -3,10 +3,10 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
@@ -41,8 +41,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
||||
var allReactions = await _reactionRepository.GetReactionsForMessagesAsync(messageIds, cancellationToken);
|
||||
var reactionsByMessage = allReactions.GroupBy(r => r.MessageId).ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
var result = messages.Select(message =>
|
||||
{
|
||||
var result = messages.Select(message => {
|
||||
var textMessage = message as TextMessage;
|
||||
var mediaMessage = message as MediaMessage;
|
||||
var storyMessage = message as StoryMessage;
|
||||
@@ -67,7 +66,7 @@ internal sealed class SearchMessagesQueryHandler : IQueryHandler<SearchMessagesQ
|
||||
mediaMessage?.Media.Select(media => new MediaDto(media.Id, media.Type, media.Url, media.Filename, media.Size)).ToList() ?? new List<MediaDto>(),
|
||||
senders.TryGetValue(message.SenderId, out var senderUser) ? new MessageSenderDto(senderUser.Id, senderUser.Username, senderUser.DisplayName, senderUser.Avatar) : new MessageSenderDto(message.SenderId, "unknown", "Unknown", null),
|
||||
reactionsByMessage.TryGetValue(message.Id, out var mr) ? mr.Select(reaction => new SimpleReactionDto(reaction.UserId, reaction.Emoji)).ToList() : new List<SimpleReactionDto>(),
|
||||
message.ReadByUsers.Select(id => new ReadByDto(id)).ToList()
|
||||
new List<ReadByDto>()
|
||||
);
|
||||
}).ToList();
|
||||
|
||||
|
||||
+2
-3
@@ -1,8 +1,8 @@
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Settings.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
||||
@@ -192,7 +192,6 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
var senderMember = chat.Members.First(m => m.UserId == request.SenderId);
|
||||
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
||||
senderMember.UpdateDeliveredCursor(message.Id);
|
||||
message.MarkAsRead(request.SenderId); // Отправитель всегда "прочитал" своё сообщение
|
||||
|
||||
// 5. ���������
|
||||
_messageRepository.Add(message);
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Claims;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
using Knot.Modules.Conversations.Application.Messages.Delete;
|
||||
using Knot.Modules.Conversations.Application.Messages.Edit;
|
||||
using Knot.Modules.Conversations.Application.Messages.Pin;
|
||||
using Knot.Modules.Conversations.Application.Messages.React;
|
||||
using Knot.Modules.Conversations.Application.Messages.Read;
|
||||
using Knot.Modules.Conversations.Application.Messages.Send;
|
||||
using Knot.Modules.Conversations.Application.Messages.Unpin;
|
||||
using Knot.Modules.Conversations.Application.Messages.Vote;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Knot.Modules.Conversations.Application.Messages.Send;
|
||||
using Knot.Modules.Conversations.Application.Messages.Read;
|
||||
using Knot.Modules.Conversations.Application.Messages.Delete;
|
||||
using Knot.Modules.Conversations.Application.Messages.React;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.Messages.Pin;
|
||||
using Knot.Modules.Conversations.Application.Messages.Unpin;
|
||||
using Knot.Modules.Conversations.Application.Messages.Vote;
|
||||
using Knot.Modules.Conversations.Application.Messages.Edit;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
|
||||
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
|
||||
@@ -37,7 +38,7 @@ public sealed class ChatHub : Hub
|
||||
|
||||
public static int OnlineUsersCount => _userConnections.Count;
|
||||
public static bool IsUserOnline(string userId) => _userConnections.ContainsKey(userId);
|
||||
|
||||
|
||||
// userId → CallSession (one user can be in only one call at a time)
|
||||
private static readonly ConcurrentDictionary<string, CallSession> _activeSessionsByUser = new();
|
||||
// chatId → (startTime, callType)
|
||||
@@ -51,16 +52,18 @@ public sealed class ChatHub : Hub
|
||||
private readonly ILogger<ChatHub> _logger;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
private readonly IProfileRepository _profileRepository;
|
||||
|
||||
public ChatHub(
|
||||
ISender sender,
|
||||
IUserContext userContext,
|
||||
IChatRepository chatRepository,
|
||||
IUserRepository userRepository,
|
||||
ISender sender,
|
||||
IUserContext userContext,
|
||||
IChatRepository chatRepository,
|
||||
IUserRepository userRepository,
|
||||
IMessageRepository messageRepository,
|
||||
ILogger<ChatHub> logger,
|
||||
ILogger<ChatHub> logger,
|
||||
IMemoryCache cache,
|
||||
IUserDisplayNameProvider userProvider)
|
||||
IUserDisplayNameProvider userProvider,
|
||||
IProfileRepository profileRepository)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
@@ -70,6 +73,7 @@ public sealed class ChatHub : Hub
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_userProvider = userProvider;
|
||||
_profileRepository = profileRepository;
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
@@ -95,7 +99,15 @@ public sealed class ChatHub : Hub
|
||||
|
||||
userId, Context.ConnectionId, userChats.Count);
|
||||
|
||||
await Clients.Others.SendAsync("user_online", new { userId });
|
||||
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||
if (!isInvisible)
|
||||
{
|
||||
await BroadcastPresenceToVisibleUsersAsync(
|
||||
"user_online",
|
||||
new { userId },
|
||||
_userContext.UserId,
|
||||
Context.ConnectionAborted);
|
||||
}
|
||||
}
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
@@ -111,7 +123,22 @@ public sealed class ChatHub : Hub
|
||||
if (set.Count == 0)
|
||||
{
|
||||
_userConnections.TryRemove(userId, out _);
|
||||
await Clients.Others.SendAsync("user_offline", new { userId, lastSeen = DateTime.UtcNow });
|
||||
try
|
||||
{
|
||||
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, CancellationToken.None);
|
||||
if (!isInvisible)
|
||||
{
|
||||
await BroadcastPresenceToVisibleUsersAsync(
|
||||
"user_offline",
|
||||
new { userId, lastSeen = DateTime.UtcNow },
|
||||
_userContext.UserId,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Presence broadcast on disconnect failed for {UserId}", userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
_cache.Set("Global_OnlineUsersCount", _userConnections.Count);
|
||||
@@ -120,6 +147,56 @@ public sealed class ChatHub : Hub
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
private async Task<bool> IsUserInvisibleAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var profile = await _profileRepository.GetAsync(userId, ct);
|
||||
return profile?.IsInvisible ?? false;
|
||||
}
|
||||
|
||||
private async Task BroadcastPresenceToVisibleUsersAsync(
|
||||
string eventName,
|
||||
object payload,
|
||||
Guid sourceUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var recipients = _userConnections.Keys
|
||||
.Where(id => id != sourceUserId.ToString())
|
||||
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (recipients.Count == 0)
|
||||
return;
|
||||
|
||||
var recipientProfiles = await _profileRepository.GetAsync(recipients, cancellationToken);
|
||||
var invisibleRecipientIds = recipientProfiles
|
||||
.Where(p => p.IsInvisible)
|
||||
.Select(p => p.UserId)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var kvp in _userConnections)
|
||||
{
|
||||
if (!Guid.TryParse(kvp.Key, out var recipientId))
|
||||
continue;
|
||||
if (recipientId == sourceUserId)
|
||||
continue;
|
||||
if (invisibleRecipientIds.Contains(recipientId))
|
||||
continue;
|
||||
|
||||
string[] connectionIds;
|
||||
lock (kvp.Value)
|
||||
{
|
||||
connectionIds = kvp.Value.ToArray();
|
||||
}
|
||||
|
||||
foreach (var connectionId in connectionIds)
|
||||
{
|
||||
await Clients.Client(connectionId).SendAsync(eventName, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Chat methods
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
@@ -158,7 +235,6 @@ public sealed class ChatHub : Hub
|
||||
await _sender.Send(command);
|
||||
}
|
||||
|
||||
// Отправляем событие всем в чате о том, что пользователь прочитал сообщения
|
||||
await Clients.Group(request.ChatId.ToString()).SendAsync("messages_read", new
|
||||
{
|
||||
ChatId = request.ChatId.ToString(),
|
||||
@@ -262,7 +338,7 @@ public sealed class ChatHub : Hub
|
||||
{
|
||||
var senderInfo = await _userProvider.GetUsersInfoAsync(new[] { message.SenderId });
|
||||
var dto = MessageMapper.MapToDto(message, senderInfo, Enumerable.Empty<MessageReaction>(), Enumerable.Empty<Guid>());
|
||||
|
||||
|
||||
await Clients.Group(request.ChatId.ToString()).SendAsync("message_pinned", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
@@ -277,7 +353,7 @@ public sealed class ChatHub : Hub
|
||||
{
|
||||
var command = new UnpinMessageCommand(request.MessageId, request.ChatId, _userContext.UserId);
|
||||
var result = await _sender.Send(command);
|
||||
|
||||
|
||||
await Clients.Group(request.ChatId.ToString()).SendAsync("message_unpinned", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
@@ -329,7 +405,7 @@ public sealed class ChatHub : Hub
|
||||
public async Task FriendAccepted(FriendSignalRequest request)
|
||||
{
|
||||
if (request == null || string.IsNullOrEmpty(request.FriendId)) return;
|
||||
|
||||
|
||||
_logger.LogInformation("Signaling friend_request_accepted to {FriendId} from {UserId}", request.FriendId, _userContext.UserId);
|
||||
await SendToUserAsync(request.FriendId, "friend_request_accepted", new { userId = _userContext.UserId });
|
||||
}
|
||||
@@ -351,8 +427,8 @@ public sealed class ChatHub : Hub
|
||||
{
|
||||
if (string.IsNullOrEmpty(targetUserId))
|
||||
{
|
||||
_logger.LogWarning("SendToUserAsync called with null or empty targetUserId");
|
||||
return;
|
||||
_logger.LogWarning("SendToUserAsync called with null or empty targetUserId");
|
||||
return;
|
||||
}
|
||||
|
||||
if (_userConnections.TryGetValue(targetUserId, out var connectionIds))
|
||||
@@ -399,15 +475,15 @@ public sealed class ChatHub : Hub
|
||||
// Track session for history
|
||||
Guid? chatId = null;
|
||||
if (Guid.TryParse(request.ChatId, out var parsedChatId)) chatId = parsedChatId;
|
||||
|
||||
|
||||
if (!chatId.HasValue)
|
||||
{
|
||||
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||
if (Guid.TryParse(request.TargetUserId, out var targetId))
|
||||
{
|
||||
var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId));
|
||||
if (personalChat != null) chatId = personalChat.Id;
|
||||
}
|
||||
var userChats = await _chatRepository.GetUserChatsAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||
if (Guid.TryParse(request.TargetUserId, out var targetId))
|
||||
{
|
||||
var personalChat = userChats.FirstOrDefault(c => c.Type == ChatType.Personal && c.Members.Any(m => m.UserId == targetId));
|
||||
if (personalChat != null) chatId = personalChat.Id;
|
||||
}
|
||||
}
|
||||
|
||||
var session = new CallSession(chatId, _userContext.UserId, Guid.Parse(request.TargetUserId), request.CallType, DateTime.UtcNow);
|
||||
@@ -462,10 +538,10 @@ public sealed class ChatHub : Hub
|
||||
_activeSessionsByUser.TryRemove(request.TargetUserId, out _);
|
||||
if (session.ChatId.HasValue)
|
||||
{
|
||||
int duration = session.IsAnswered && session.AnswerTime.HasValue
|
||||
? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds
|
||||
int duration = session.IsAnswered && session.AnswerTime.HasValue
|
||||
? (int)(DateTime.UtcNow - session.AnswerTime.Value).TotalSeconds
|
||||
: 0;
|
||||
|
||||
|
||||
string status = session.IsAnswered ? "completed" : (_userContext.UserId == session.FromUserId ? "cancelled" : "missed");
|
||||
await CreateCallMessage(session.ChatId.Value, session.FromUserId, session.CallType, status, duration);
|
||||
}
|
||||
@@ -574,7 +650,7 @@ public sealed class ChatHub : Hub
|
||||
|
||||
var userInfo = new ParticipantInfo(userId, username, displayName, avatar);
|
||||
|
||||
var participants = _groupCallParticipants.GetOrAdd(chatId, _ =>
|
||||
var participants = _groupCallParticipants.GetOrAdd(chatId, _ =>
|
||||
{
|
||||
_activeGroupCalls[chatId] = (DateTime.UtcNow, request.CallType);
|
||||
return new ConcurrentDictionary<string, ParticipantInfo>();
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
|
||||
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -19,17 +19,9 @@ public static class MessagesEndpoints
|
||||
{
|
||||
var group = app.MapGroup("api/messages").RequireAuthorization();
|
||||
|
||||
group.MapGet("chat/{chatId:guid}", async (
|
||||
[FromRoute] Guid chatId,
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] long? afterSequenceId,
|
||||
[FromQuery] long? pivot,
|
||||
[FromQuery] int? limit,
|
||||
ISender sender,
|
||||
IUserContext userContext,
|
||||
CancellationToken ct) =>
|
||||
group.MapGet("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromQuery] string? cursor, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor, pivot, afterSequenceId, limit), ct);
|
||||
var result = await sender.Send(new GetMessagesQuery(userContext.UserId, chatId, cursor), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ public abstract class Message : AggregateRoot<Guid>
|
||||
public Guid SenderId { get; protected set; }
|
||||
public DateTime CreatedAt { get; protected set; }
|
||||
public long SequenceId { get; protected set; }
|
||||
|
||||
|
||||
public void SetSequenceId(long sequenceId)
|
||||
{
|
||||
SequenceId = sequenceId;
|
||||
@@ -26,10 +26,10 @@ public abstract class Message : AggregateRoot<Guid>
|
||||
// ================== Опциональные метаданные (общего назначения) ==================
|
||||
public Guid? ReplyToId { get; protected set; }
|
||||
public Guid? ForwardedFromId { get; protected set; }
|
||||
|
||||
|
||||
// ================== Флаги ==================
|
||||
public MessageState State { get; protected set; }
|
||||
|
||||
|
||||
// ================== Абстрактные / Виртуальные свойства ==================
|
||||
public abstract string Type { get; }
|
||||
public abstract string? Content { get; protected set; }
|
||||
@@ -42,20 +42,16 @@ public abstract class Message : AggregateRoot<Guid>
|
||||
protected List<DeletedMessage> _deletedFor = new();
|
||||
public IReadOnlyCollection<DeletedMessage> DeletedFor => _deletedFor.AsReadOnly();
|
||||
|
||||
// ================== Прочитано ==================
|
||||
protected List<Guid> _readByUsers = new();
|
||||
public IReadOnlyCollection<Guid> ReadByUsers => _readByUsers.AsReadOnly();
|
||||
|
||||
// ================== Инфраструктурный конструктор EF ==================
|
||||
protected Message() : base(Guid.Empty) { }
|
||||
|
||||
protected Message(
|
||||
Guid id,
|
||||
Guid chatId,
|
||||
Guid senderId,
|
||||
Guid? replyToId,
|
||||
Guid? forwardedFromId,
|
||||
DateTime createdAt,
|
||||
Guid id,
|
||||
Guid chatId,
|
||||
Guid senderId,
|
||||
Guid? replyToId,
|
||||
Guid? forwardedFromId,
|
||||
DateTime createdAt,
|
||||
bool isImported) : base(id)
|
||||
{
|
||||
ChatId = chatId;
|
||||
@@ -63,7 +59,7 @@ public abstract class Message : AggregateRoot<Guid>
|
||||
ReplyToId = replyToId;
|
||||
ForwardedFromId = forwardedFromId;
|
||||
CreatedAt = createdAt;
|
||||
|
||||
|
||||
if (isImported) AddState(MessageState.IsImported);
|
||||
}
|
||||
|
||||
@@ -93,16 +89,6 @@ public abstract class Message : AggregateRoot<Guid>
|
||||
_deletedFor.Add(new DeletedMessage(Id, userId));
|
||||
}
|
||||
}
|
||||
|
||||
public void MarkAsRead(Guid userId)
|
||||
{
|
||||
if (!_readByUsers.Contains(userId))
|
||||
{
|
||||
_readByUsers.Add(userId);
|
||||
}
|
||||
}
|
||||
|
||||
public bool IsReadBy(Guid userId) => _readByUsers.Contains(userId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -84,7 +84,7 @@ public sealed class MessageSentDomainEventHandler : INotificationHandler<Message
|
||||
size = m.Size
|
||||
}).ToList() ?? (object)Array.Empty<object>(),
|
||||
sender = senderObj,
|
||||
readBy = message.ReadByUsers.Select(id => new { id }).ToList(),
|
||||
readBy = new List<object>(),
|
||||
storyId = (message as StoryMessage)?.StoryId,
|
||||
storyMediaUrl = (message as StoryMessage)?.StoryMediaUrl,
|
||||
storyMediaType = (message as StoryMessage)?.StoryMediaType,
|
||||
|
||||
@@ -95,28 +95,14 @@ public sealed class MessageRepository : IMessageRepository
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Message>> GetChatMessagesAfterAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = Builders<Message>.Filter;
|
||||
var filter = builder.And(
|
||||
builder.Eq(m => m.ChatId, chatId),
|
||||
builder.Gt(m => m.SequenceId, sequenceId)
|
||||
);
|
||||
|
||||
return await _messages.Find(filter)
|
||||
.SortBy(m => m.SequenceId)
|
||||
.Limit(limit)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<Message>> GetChatMessagesAroundAsync(Guid chatId, long sequenceId, int limit, CancellationToken cancellationToken)
|
||||
{
|
||||
var builder = Builders<Message>.Filter;
|
||||
|
||||
|
||||
// Target message
|
||||
var targetFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Eq(m => m.SequenceId, sequenceId));
|
||||
var targetMsg = await _messages.Find(targetFilter).FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
|
||||
// Older messages
|
||||
var olderFilter = builder.And(builder.Eq(m => m.ChatId, chatId), builder.Lt(m => m.SequenceId, sequenceId));
|
||||
var older = await _messages.Find(olderFilter)
|
||||
|
||||
@@ -2,5 +2,9 @@ using System;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
|
||||
|
||||
public record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
|
||||
public record UpdateProfileRequest(
|
||||
string? DisplayName,
|
||||
string? Bio,
|
||||
DateTime? Birthday,
|
||||
bool? IsInvisible);
|
||||
public record UpdateSettingsRequest(bool? HideStoryViews);
|
||||
|
||||
@@ -11,7 +11,8 @@ public sealed record UpdateProfileCommand(
|
||||
Guid UserId,
|
||||
string? DisplayName,
|
||||
string? Bio,
|
||||
DateTime? Birthday) : ICommand<UserProfileDto>;
|
||||
DateTime? Birthday,
|
||||
bool? IsInvisible) : ICommand<UserProfileDto>;
|
||||
|
||||
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
|
||||
{
|
||||
@@ -28,9 +29,13 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfil
|
||||
if (profile is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
if ((request.Bio?.Length ?? 0) > 200)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.BioTooLong);
|
||||
|
||||
profile.DisplayName = request.DisplayName ?? profile.DisplayName;
|
||||
profile.About = request.Bio;
|
||||
profile.Birthday = request.Birthday;
|
||||
profile.IsInvisible = request.IsInvisible ?? profile.IsInvisible;
|
||||
|
||||
var result = await _repository.UpdateAsync(profile, cancellationToken);
|
||||
if (result.IsFailure)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Statuses;
|
||||
|
||||
public sealed record ClearUserStatusCommand(Guid UserId) : IRequest<Result<UserProfileDto>>;
|
||||
|
||||
public sealed class ClearUserStatusCommandHandler : IRequestHandler<ClearUserStatusCommand, Result<UserProfileDto>>
|
||||
{
|
||||
private readonly IProfileStatusWriter _writer;
|
||||
|
||||
public ClearUserStatusCommandHandler(IProfileStatusWriter writer)
|
||||
{
|
||||
_writer = writer;
|
||||
}
|
||||
|
||||
public Task<Result<UserProfileDto>> Handle(ClearUserStatusCommand request, CancellationToken cancellationToken)
|
||||
=> _writer.ClearAsync(request.UserId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Statuses;
|
||||
|
||||
public sealed record GetStatusPresetsQuery : IRequest<IReadOnlyList<StatusPresetDto>>;
|
||||
|
||||
public sealed class GetStatusPresetsQueryHandler : IRequestHandler<GetStatusPresetsQuery, IReadOnlyList<StatusPresetDto>>
|
||||
{
|
||||
public Task<IReadOnlyList<StatusPresetDto>> Handle(GetStatusPresetsQuery request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(StatusPresetCatalog.All);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Statuses;
|
||||
|
||||
public sealed record SetCustomUserStatusCommand(
|
||||
Guid UserId,
|
||||
string? Emoji,
|
||||
string? Text,
|
||||
DateTime? ExpiresAt,
|
||||
string? PresetKey) : IRequest<Result<UserProfileDto>>;
|
||||
|
||||
public sealed class SetCustomUserStatusCommandHandler : IRequestHandler<SetCustomUserStatusCommand, Result<UserProfileDto>>
|
||||
{
|
||||
private readonly IProfileStatusWriter _writer;
|
||||
|
||||
public SetCustomUserStatusCommandHandler(IProfileStatusWriter writer)
|
||||
{
|
||||
_writer = writer;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> Handle(SetCustomUserStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = request.PresetKey?.Trim();
|
||||
if (!string.IsNullOrEmpty(key) && key.Equals("online", StringComparison.OrdinalIgnoreCase))
|
||||
return await _writer.ClearAsync(request.UserId, cancellationToken);
|
||||
|
||||
var emoji = request.Emoji?.Trim() ?? string.Empty;
|
||||
var text = request.Text?.Trim() ?? string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
var preset = StatusPresetCatalog.Find(key);
|
||||
if (preset is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.InvalidPreset);
|
||||
if (string.IsNullOrEmpty(emoji))
|
||||
emoji = preset.Emoji;
|
||||
if (string.IsNullOrEmpty(text))
|
||||
text = preset.TextRu;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(emoji) && string.IsNullOrEmpty(text))
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusEmpty);
|
||||
|
||||
if (text.Length > 50)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusTextTooLong);
|
||||
|
||||
var presetForWriter = string.IsNullOrWhiteSpace(key) ? null : key;
|
||||
return await _writer.SetCustomAsync(request.UserId, emoji, text, request.ExpiresAt, presetForWriter, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Statuses;
|
||||
|
||||
public static class StatusPresetCatalog
|
||||
{
|
||||
private static readonly StatusPresetDto[] Items =
|
||||
[
|
||||
new() { Id = "online", Emoji = "", TextRu = "Онлайн (стандарт)", TextEn = "Online (standard)" },
|
||||
new() { Id = "away", Emoji = "🕒", TextRu = "В отъезде", TextEn = "Away" },
|
||||
new() { Id = "dnd", Emoji = "⛔", TextRu = "Не беспокоить", TextEn = "Do not disturb" },
|
||||
new() { Id = "sick", Emoji = "🤒", TextRu = "Болен", TextEn = "Sick" },
|
||||
new() { Id = "angry", Emoji = "💢", TextRu = "Злой", TextEn = "Angry" }
|
||||
];
|
||||
|
||||
public static IReadOnlyList<StatusPresetDto> All => Items;
|
||||
|
||||
public static StatusPresetDto? Find(string presetKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(presetKey))
|
||||
return null;
|
||||
return Items.FirstOrDefault(x => x.Id.Equals(presetKey.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,11 @@ public static class DependencyInjection
|
||||
public static IServiceCollection AddProfilesModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileRepository, ProfileRepository>();
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IUserStatusRepository, UserStatusMongoRepository>();
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileStatusWriter, ProfileStatusWriter>();
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfilesUnitOfWork, ProfilesUnitOfWork>();
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IAvatarStorageService, AvatarStorageService>();
|
||||
|
||||
// MongoDB Registration
|
||||
var mongoConnection = configuration.GetConnectionString("MongoConnection")
|
||||
?? configuration["MONGO_URL"]
|
||||
?? "mongodb://mongo:27017";
|
||||
|
||||
@@ -3,10 +3,6 @@ using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Knot.Modules.Profiles.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB-документ профиля пользователя.
|
||||
/// Id совпадает с UserId из модуля Auth (Postgres).
|
||||
/// </summary>
|
||||
public sealed class ProfileDocument
|
||||
{
|
||||
[BsonId]
|
||||
@@ -19,12 +15,23 @@ public sealed class ProfileDocument
|
||||
|
||||
public string? Bio { get; private set; }
|
||||
|
||||
public string? StatusText { get; private set; }
|
||||
|
||||
public string? StatusEmoji { get; private set; }
|
||||
|
||||
public DateTime? StatusExpiresAt { get; private set; }
|
||||
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid? CurrentStatusId { get; private set; }
|
||||
|
||||
public string? AvatarUrl { get; private set; }
|
||||
|
||||
public DateTime? Birthday { get; private set; }
|
||||
|
||||
public bool HideStoryViews { get; private set; }
|
||||
|
||||
public bool IsInvisible { get; private set; }
|
||||
|
||||
public bool IsBanned { get; private set; }
|
||||
|
||||
public bool IsDeleted { get; private set; }
|
||||
@@ -65,6 +72,26 @@ public sealed class ProfileDocument
|
||||
public void UpdateSettings(bool hideStoryViews)
|
||||
=> HideStoryViews = hideStoryViews;
|
||||
|
||||
public void UpdateInvisible(bool isInvisible)
|
||||
=> IsInvisible = isInvisible;
|
||||
|
||||
public void UpdateStatus(string? statusText, string? statusEmoji, DateTime? statusExpiresAt)
|
||||
{
|
||||
StatusText = statusText;
|
||||
StatusEmoji = statusEmoji;
|
||||
StatusExpiresAt = statusExpiresAt;
|
||||
}
|
||||
|
||||
public void SetCurrentStatusId(Guid? statusId)
|
||||
=> CurrentStatusId = statusId;
|
||||
|
||||
public void ClearMoodStatusFields()
|
||||
{
|
||||
StatusText = null;
|
||||
StatusEmoji = null;
|
||||
StatusExpiresAt = null;
|
||||
}
|
||||
|
||||
public void UpdateStatus(bool isBanned, bool isDeleted)
|
||||
{
|
||||
IsBanned = isBanned;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Knot.Modules.Profiles.Domain;
|
||||
|
||||
public sealed class UserStatusDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
public string Type { get; set; } = "Custom";
|
||||
|
||||
public string Emoji { get; set; } = "";
|
||||
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
public BsonDocument Metadata { get; set; } = new();
|
||||
}
|
||||
@@ -10,29 +10,39 @@ namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||
internal class ProfileRepository : IProfileRepository
|
||||
{
|
||||
private readonly IMongoCollection<ProfileDocument> _profiles;
|
||||
private readonly IUserStatusRepository _statuses;
|
||||
|
||||
public ProfileRepository(IMongoDatabase database)
|
||||
public ProfileRepository(IMongoDatabase database, IUserStatusRepository statuses)
|
||||
{
|
||||
_profiles = database.GetCollection<ProfileDocument>("profiles");
|
||||
_statuses = statuses;
|
||||
}
|
||||
|
||||
public async Task<UserProfileDto?> GetAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct);
|
||||
return profile?.ToDto();
|
||||
return profile is null ? null : await profile.ToDtoAsync(_statuses, ct);
|
||||
}
|
||||
|
||||
public async Task<UserProfileDto?> GetByUsernameAsync(string username, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Username == username).FirstOrDefaultAsync(ct);
|
||||
return profile?.ToDto();
|
||||
return profile is null ? null : await profile.ToDtoAsync(_statuses, ct);
|
||||
}
|
||||
|
||||
public async Task<List<UserProfileDto>> GetAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = userIds.ToList();
|
||||
var profiles = await _profiles.Find(p => ids.Contains(p.Id)).ToListAsync(ct);
|
||||
return profiles.Select(p => p.ToDto()).ToList();
|
||||
var statusIds = profiles
|
||||
.Where(p => p.CurrentStatusId.HasValue)
|
||||
.Select(p => p.CurrentStatusId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var map = statusIds.Count > 0
|
||||
? await _statuses.GetByIdsAsync(statusIds, ct)
|
||||
: new Dictionary<Guid, UserStatusDto>();
|
||||
return profiles.Select(p => ProfileMappings.ToDtoWithStatusMap(p, map)).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<UserProfileDto>> SearchAsync(string query, int limit = 20, CancellationToken ct = default)
|
||||
@@ -57,7 +67,16 @@ internal class ProfileRepository : IProfileRepository
|
||||
var combinedFilter = Builders<ProfileDocument>.Filter.And(baseFilter, searchFilter);
|
||||
docs = await _profiles.Find(combinedFilter).Limit(limit).ToListAsync(ct);
|
||||
}
|
||||
return docs.Select(p => p.ToDto()).ToList();
|
||||
|
||||
var statusIds = docs
|
||||
.Where(p => p.CurrentStatusId.HasValue)
|
||||
.Select(p => p.CurrentStatusId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var map = statusIds.Count > 0
|
||||
? await _statuses.GetByIdsAsync(statusIds, ct)
|
||||
: new Dictionary<Guid, UserStatusDto>();
|
||||
return docs.Select(p => ProfileMappings.ToDtoWithStatusMap(p, map)).ToList();
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken ct = default)
|
||||
@@ -65,7 +84,7 @@ internal class ProfileRepository : IProfileRepository
|
||||
var profile = ProfileDocument.Create(dto.UserId, dto.Username ?? string.Empty, dto.DisplayName ?? string.Empty, dto.About);
|
||||
|
||||
await _profiles.InsertOneAsync(profile, null, ct);
|
||||
return Result.Success(profile.ToDto());
|
||||
return Result.Success(await profile.ToDtoAsync(_statuses, ct));
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken ct = default)
|
||||
@@ -80,9 +99,13 @@ internal class ProfileRepository : IProfileRepository
|
||||
dto.Birthday);
|
||||
|
||||
document.UpdateAvatar(dto.Avatar);
|
||||
document.UpdateInvisible(dto.IsInvisible);
|
||||
|
||||
await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct);
|
||||
return Result.Success(document.ToDto());
|
||||
var fresh = await _profiles.Find(p => p.Id == dto.UserId).FirstOrDefaultAsync(ct);
|
||||
if (fresh is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
return Result.Success(await fresh.ToDtoAsync(_statuses, ct));
|
||||
}
|
||||
|
||||
public async Task<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Infrastructure.Mappings;
|
||||
using Knot.Shared.Kernel;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||
|
||||
internal sealed class ProfileStatusWriter : IProfileStatusWriter
|
||||
{
|
||||
private readonly IMongoCollection<ProfileDocument> _profiles;
|
||||
private readonly IUserStatusRepository _statuses;
|
||||
|
||||
public ProfileStatusWriter(IMongoDatabase database, IUserStatusRepository statuses)
|
||||
{
|
||||
_profiles = database.GetCollection<ProfileDocument>("profiles");
|
||||
_statuses = statuses;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> SetCustomAsync(Guid userId, string emoji, string text, DateTime? expiresAt, string? presetKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
if (profile.CurrentStatusId is Guid oldId)
|
||||
await _statuses.DeleteAsync(oldId, cancellationToken);
|
||||
|
||||
var newId = Guid.NewGuid();
|
||||
var type = string.IsNullOrWhiteSpace(presetKey) ? "Custom" : "Preset";
|
||||
var dto = new UserStatusDto
|
||||
{
|
||||
Id = newId,
|
||||
Type = type,
|
||||
Emoji = emoji,
|
||||
Text = text,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
await _statuses.InsertAsync(dto, userId, cancellationToken);
|
||||
|
||||
profile.SetCurrentStatusId(newId);
|
||||
profile.ClearMoodStatusFields();
|
||||
await _profiles.ReplaceOneAsync(p => p.Id == userId, profile, cancellationToken: cancellationToken);
|
||||
|
||||
var fresh = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
|
||||
if (fresh is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
return Result.Success(await fresh.ToDtoAsync(_statuses, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> ClearAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
if (profile.CurrentStatusId is Guid oldId)
|
||||
await _statuses.DeleteAsync(oldId, cancellationToken);
|
||||
|
||||
profile.SetCurrentStatusId(null);
|
||||
profile.ClearMoodStatusFields();
|
||||
await _profiles.ReplaceOneAsync(p => p.Id == userId, profile, cancellationToken: cancellationToken);
|
||||
|
||||
var fresh = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
|
||||
if (fresh is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
return Result.Success(await fresh.ToDtoAsync(_statuses, cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||
|
||||
internal sealed class UserStatusMongoRepository : IUserStatusRepository
|
||||
{
|
||||
private readonly IMongoCollection<UserStatusDocument> _collection;
|
||||
|
||||
public UserStatusMongoRepository(IMongoDatabase database)
|
||||
{
|
||||
_collection = database.GetCollection<UserStatusDocument>("user_statuses");
|
||||
}
|
||||
|
||||
public async Task<UserStatusDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = await _collection.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken);
|
||||
return doc is null ? null : ToDto(doc);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<Guid, UserStatusDto>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var idList = ids.Distinct().ToList();
|
||||
if (idList.Count == 0)
|
||||
return new Dictionary<Guid, UserStatusDto>();
|
||||
|
||||
var docs = await _collection.Find(x => idList.Contains(x.Id)).ToListAsync(cancellationToken);
|
||||
return docs.ToDictionary(d => d.Id, ToDto);
|
||||
}
|
||||
|
||||
public async Task InsertAsync(UserStatusDto dto, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = new UserStatusDocument
|
||||
{
|
||||
Id = dto.Id,
|
||||
UserId = userId,
|
||||
Type = dto.Type,
|
||||
Emoji = dto.Emoji,
|
||||
Text = dto.Text,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
ExpiresAt = dto.ExpiresAt,
|
||||
Metadata = new BsonDocument()
|
||||
};
|
||||
await _collection.InsertOneAsync(doc, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _collection.DeleteOneAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
private static UserStatusDto ToDto(UserStatusDocument d) => new()
|
||||
{
|
||||
Id = d.Id,
|
||||
Type = d.Type,
|
||||
Emoji = d.Emoji,
|
||||
Text = d.Text,
|
||||
CreatedAt = d.CreatedAt,
|
||||
ExpiresAt = d.ExpiresAt
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
|
||||
namespace Knot.Modules.Profiles.Infrastructure.Mappings;
|
||||
|
||||
public static class ProfileMappings
|
||||
{
|
||||
public static UserProfileDto ToDto(this ProfileDocument document)
|
||||
public static UserProfileDto ToDtoWithStatusMap(ProfileDocument document, IReadOnlyDictionary<Guid, UserStatusDto> statusMap)
|
||||
{
|
||||
return new UserProfileDto
|
||||
var dto = new UserProfileDto
|
||||
{
|
||||
UserId = document.Id,
|
||||
DisplayName = document.DisplayName,
|
||||
@@ -18,7 +19,59 @@ public static class ProfileMappings
|
||||
LastSeen = null,
|
||||
Birthday = document.Birthday,
|
||||
IsPremium = false,
|
||||
CreatedAt = document.CreatedAt
|
||||
IsInvisible = document.IsInvisible,
|
||||
CreatedAt = document.CreatedAt,
|
||||
Status = null,
|
||||
StatusText = null,
|
||||
StatusEmoji = null,
|
||||
StatusExpiresAt = null,
|
||||
CurrentStatusId = null
|
||||
};
|
||||
|
||||
UserStatusDto? active = null;
|
||||
if (document.CurrentStatusId is Guid sid && statusMap.TryGetValue(sid, out var loaded) && loaded is not null)
|
||||
{
|
||||
var exp = loaded.ExpiresAt;
|
||||
if (!exp.HasValue || exp.Value > DateTime.UtcNow)
|
||||
active = loaded;
|
||||
}
|
||||
|
||||
if (active is not null)
|
||||
{
|
||||
dto.Status = active;
|
||||
dto.StatusText = active.Text;
|
||||
dto.StatusEmoji = active.Emoji;
|
||||
dto.StatusExpiresAt = active.ExpiresAt;
|
||||
dto.CurrentStatusId = active.Id;
|
||||
return dto;
|
||||
}
|
||||
|
||||
var legacyExpired = document.StatusExpiresAt.HasValue && document.StatusExpiresAt.Value <= DateTime.UtcNow;
|
||||
if (!legacyExpired && (!string.IsNullOrEmpty(document.StatusText) || !string.IsNullOrEmpty(document.StatusEmoji)))
|
||||
{
|
||||
dto.StatusText = document.StatusText;
|
||||
dto.StatusEmoji = document.StatusEmoji;
|
||||
dto.StatusExpiresAt = document.StatusExpiresAt;
|
||||
dto.Status = new UserStatusDto
|
||||
{
|
||||
Id = Guid.Empty,
|
||||
Type = "Custom",
|
||||
Emoji = document.StatusEmoji ?? string.Empty,
|
||||
Text = document.StatusText ?? string.Empty,
|
||||
CreatedAt = document.CreatedAt,
|
||||
ExpiresAt = document.StatusExpiresAt
|
||||
};
|
||||
return dto;
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
public static async Task<UserProfileDto> ToDtoAsync(this ProfileDocument document, IUserStatusRepository statuses, CancellationToken cancellationToken)
|
||||
{
|
||||
if (document.CurrentStatusId is not Guid sid)
|
||||
return ToDtoWithStatusMap(document, new Dictionary<Guid, UserStatusDto>());
|
||||
var map = await statuses.GetByIdsAsync(new[] { sid }, cancellationToken);
|
||||
return ToDtoWithStatusMap(document, map);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ public static class ProfilesEndpoints
|
||||
public static void MapProfilesEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("api/profiles").RequireAuthorization();
|
||||
var userGroup = app.MapGroup("api/user").RequireAuthorization();
|
||||
var usersGroup = app.MapGroup("api/users").RequireAuthorization();
|
||||
|
||||
group.MapGet("search", async ([FromQuery] string q, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
{
|
||||
@@ -67,16 +69,37 @@ public static class ProfilesEndpoints
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
|
||||
group.MapPut("profile", async ([FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
async Task<IResult> UpdateProfileHandler(
|
||||
[FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request,
|
||||
ISender sender,
|
||||
IUserContext userContext,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await sender.Send(new UpdateProfileCommand(userContext.UserId, request.DisplayName, request.Bio, request.Birthday), ct);
|
||||
var result = await sender.Send(
|
||||
new UpdateProfileCommand(
|
||||
userContext.UserId,
|
||||
request.DisplayName,
|
||||
request.Bio,
|
||||
request.Birthday,
|
||||
request.IsInvisible),
|
||||
ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
}
|
||||
|
||||
group.MapPut("profile", UpdateProfileHandler);
|
||||
group.MapPatch("profile", UpdateProfileHandler);
|
||||
userGroup.MapPatch("profile", UpdateProfileHandler);
|
||||
|
||||
group.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new GetProfileQuery(id), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
|
||||
usersGroup.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new GetProfileQuery(id), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Knot.Modules.Profiles.Application.Statuses;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Knot.Modules.Profiles.Presentation.Endpoints;
|
||||
|
||||
public static class StatusesEndpoints
|
||||
{
|
||||
public static void MapStatusesEndpoints(this WebApplication app)
|
||||
{
|
||||
var g = app.MapGroup("api/statuses").RequireAuthorization();
|
||||
|
||||
g.MapGet("presets", async (ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var list = await sender.Send(new GetStatusPresetsQuery(), ct);
|
||||
return Results.Ok(list);
|
||||
});
|
||||
|
||||
g.MapPost("custom", async ([FromBody] SetCustomStatusBody body, ISender sender, IUserContext ctx, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new SetCustomUserStatusCommand(ctx.UserId, body.Emoji, body.Text, body.ExpiresAt, body.PresetKey),
|
||||
ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
|
||||
});
|
||||
|
||||
g.MapDelete("/", async (ISender sender, IUserContext ctx, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new ClearUserStatusCommand(ctx.UserId), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class SetCustomStatusBody
|
||||
{
|
||||
public string? Emoji { get; set; }
|
||||
public string? Text { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? PresetKey { get; set; }
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
#Tue Apr 14 00:27:15 MSK 2026
|
||||
gradle.version=8.5
|
||||
Binary file not shown.
@@ -1,2 +0,0 @@
|
||||
#Tue Apr 14 00:13:58 MSK 2026
|
||||
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
|
||||
@@ -1,141 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("com.google.gms.google-services")
|
||||
kotlin("kapt")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.knot.messager"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ru.knot.messager"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
// Подключаем все наши папки с кодом как sourceSets
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
java.srcDirs(
|
||||
"src/main/kotlin",
|
||||
"../auth",
|
||||
"../chats",
|
||||
"../core",
|
||||
"../calls",
|
||||
"../stories",
|
||||
"../contacts",
|
||||
"../profiles",
|
||||
"../settings",
|
||||
"../navigation"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// AndroidX & UI
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||
implementation("androidx.activity:activity-compose:1.8.1")
|
||||
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.5")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
|
||||
// Hilt
|
||||
implementation("com.google.dagger:hilt-android:2.48")
|
||||
kapt("com.google.dagger:hilt-android-compiler:2.48")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||
|
||||
// Network & SignalR
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
implementation("com.microsoft.signalr:signalr:7.0.0")
|
||||
|
||||
// WebRTC
|
||||
implementation("com.github.webrtc-sdk:android:104.5112.01")
|
||||
|
||||
// Media3 (ExoPlayer)
|
||||
implementation("androidx.media3:media3-exoplayer:1.2.0")
|
||||
implementation("androidx.media3:media3-ui:1.2.0")
|
||||
implementation("androidx.media3:media3-common:1.2.0")
|
||||
|
||||
// Images & GIF
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
implementation("io.coil-kt:coil-gif:2.5.0")
|
||||
implementation("io.coil-kt:coil-svg:2.5.0")
|
||||
implementation("io.coil-kt:coil-video:2.5.0")
|
||||
|
||||
// Security
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// UCrop (Image Cropping)
|
||||
implementation("com.github.yalantis:ucrop:2.2.8")
|
||||
|
||||
// Firebase (Push Notifications)
|
||||
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
|
||||
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||
|
||||
// Room
|
||||
val room_version = "2.6.1"
|
||||
implementation("androidx.room:room-runtime:$room_version")
|
||||
implementation("androidx.room:room-ktx:$room_version")
|
||||
implementation("androidx.room:room-paging:$room_version")
|
||||
kapt("androidx.room:room-compiler:$room_version")
|
||||
|
||||
// Paging 3
|
||||
implementation("androidx.paging:paging-runtime-ktx:3.2.1")
|
||||
implementation("androidx.paging:paging-compose:3.2.1")
|
||||
|
||||
// WorkManager
|
||||
implementation("androidx.work:work-runtime-ktx:2.9.0")
|
||||
implementation("androidx.hilt:hilt-work:1.1.0")
|
||||
kapt("androidx.hilt:hilt-compiler:1.1.0")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "483917912506",
|
||||
"project_id": "knot-bad1a",
|
||||
"storage_bucket": "knot-bad1a.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:483917912506:android:cd39213364869ef9e82583",
|
||||
"android_client_info": {
|
||||
"package_name": "ru.knot.messager"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBAL_bZJYaa7rGERLX63LeFXz-__JXRWQY"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="ru.knot.messager">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name="com.knot.messenger.MainApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.KnotMessenger"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name="com.knot.messenger.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.KnotMessenger">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="core.notifications.data.ForkFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,60 +0,0 @@
|
||||
package com.knot.messenger
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import navigation.AppNavigation
|
||||
import core.presentation.theme.ForkMessengerTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@javax.inject.Inject
|
||||
lateinit var navigationManager: core.utils.NavigationManager
|
||||
|
||||
@javax.inject.Inject
|
||||
lateinit var signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
android.util.Log.d("MainActivity", "onCreate called")
|
||||
signalrNotificationObserver.start()
|
||||
android.util.Log.d("MainActivity", "signalrNotificationObserver.start() called")
|
||||
|
||||
intent.getStringExtra("chatId")?.let { chatId ->
|
||||
navigationManager.navigateToChat(chatId)
|
||||
}
|
||||
|
||||
// Request notifications permission for Android 13+
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
androidx.core.app.ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
|
||||
101
|
||||
)
|
||||
}
|
||||
|
||||
setContent {
|
||||
ForkMessengerTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AppNavigation(navigationManager = navigationManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: android.content.Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
intent?.getStringExtra("chatId")?.let { chatId ->
|
||||
navigationManager.navigateToChat(chatId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +0,0 @@
|
||||
package com.knot.messenger
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.VideoFrameDecoder
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class MainApplication : Application(), ImageLoaderFactory {
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.components {
|
||||
add(VideoFrameDecoder.Factory())
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,64 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">ForkMessenger</string>
|
||||
<string name="login">Login</string>
|
||||
<string name="register">Register</string>
|
||||
<string name="username">Username</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="display_name">Display Name</string>
|
||||
<string name="settings">Settings</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="server_connection">Server Connection</string>
|
||||
<string name="api_base_url">API Base URL</string>
|
||||
<string name="server_features">Server Features</string>
|
||||
<string name="stories">Stories</string>
|
||||
<string name="polls">Polls</string>
|
||||
<string name="calls">Calls</string>
|
||||
<string name="groups">Groups</string>
|
||||
<string name="enabled">Enabled</string>
|
||||
<string name="disabled">Disabled</string>
|
||||
<string name="limits">Limits</string>
|
||||
<string name="max_file_size">Max File Size</string>
|
||||
<string name="max_group_members">Max Group Members</string>
|
||||
<string name="message">Message</string>
|
||||
<string name="call">Call</string>
|
||||
<string name="block">Block</string>
|
||||
<string name="profile">Profile</string>
|
||||
<string name="confirm_password">Confirm Password</string>
|
||||
<string name="passwords_not_match">Passwords do not match</string>
|
||||
<string name="no_account_register">Don\'t have an account? Register</string>
|
||||
<string name="already_have_account">Already have an account? Login</string>
|
||||
<string name="error_occurred">An error occurred</string>
|
||||
<string name="loading">Loading...</string>
|
||||
<string name="chats_title">Chats</string>
|
||||
<string name="contacts_title">Contacts</string>
|
||||
<string name="stories_title">Stories</string>
|
||||
<string name="create_story">Create Story</string>
|
||||
<string name="send_message_hint">Type a message...</string>
|
||||
<string name="reply_to_user">Reply to %1$s...</string>
|
||||
<string name="story_editor">STORY EDITOR</string>
|
||||
<string name="publish">PUBLISH</string>
|
||||
<string name="start_creation">START CREATION</string>
|
||||
<string name="text_tool">TEXT</string>
|
||||
<string name="crop_tool">CROP</string>
|
||||
<string name="stickers_tool">STICKERS</string>
|
||||
<string name="brush_tool">BRUSH</string>
|
||||
<string name="filters_tool">FILTERS</string>
|
||||
<string name="remove">Remove</string>
|
||||
<string name="no_chats_found">No chats found</string>
|
||||
<string name="typing">typing...</string>
|
||||
<string name="video_call">Video Call</string>
|
||||
<string name="emoji">Emoji</string>
|
||||
<string name="attach">Attach</string>
|
||||
<string name="message_placeholder">Message...</string>
|
||||
<string name="voice_message">Voice Message</string>
|
||||
<string name="send">Send</string>
|
||||
<string name="reply_photo">Photo</string>
|
||||
<string name="reply_video">Video</string>
|
||||
<string name="reply_audio">Audio</string>
|
||||
<string name="reply_file">File</string>
|
||||
<string name="reply_gif">GIF</string>
|
||||
<string name="reply_prefix">Reply to </string>
|
||||
<string name="reply_self">yourself</string>
|
||||
<string name="no_messages_yet">No messages yet</string>
|
||||
</resources>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user