Compare commits
47 Commits
bugfix_web
...
android_v2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a426b63b7d | ||
|
|
4d4bc8edd1 | ||
|
|
40589dbb75 | ||
|
|
0b013def2e | ||
|
|
ccffc5a53c | ||
|
|
e8161d23d4 | ||
|
|
89e325556c | ||
|
|
df4feeeee5 | ||
|
|
00ed4b5959 | ||
|
|
a0b50b57b0 | ||
|
|
d4c41b333a | ||
|
|
9daa786cfb | ||
|
|
2d2e4b685e | ||
|
|
46c22300e9 | ||
|
|
d9a20e40b0 | ||
|
|
945134f029 | ||
|
|
f6400ce3ac | ||
|
|
d9462069e2 | ||
|
|
9e715fe3ab | ||
|
|
8c8ef55b58 | ||
|
|
70acad56fb | ||
|
|
629fddfca0 | ||
|
|
b68f68a1f2 | ||
|
|
754e9e8ad0 | ||
|
|
ed7521a563 | ||
|
|
bef30c2c86 | ||
|
|
8c00d1376d | ||
|
|
cea3f4d669 | ||
|
|
8409c51842 | ||
|
|
9560a9235f | ||
|
|
f17edfc0da | ||
|
|
c6bebec599 | ||
|
|
5a71e5bbfa | ||
|
|
5b0133d55e | ||
|
|
dcb733ac01 | ||
|
|
487cb1b12b | ||
|
|
58fdf1aca1 | ||
|
|
118f8b8971 | ||
|
|
8165b74e43 | ||
|
|
8ce4bc714f | ||
|
|
2d0bc0d75c | ||
|
|
d7e75797ef | ||
|
|
58bbdae26c | ||
|
|
3310a3c4a4 | ||
|
|
d8b0d86534 | ||
|
|
dc051fa9ae | ||
|
|
1fb1be47dd |
10
.gitignore
vendored
10
.gitignore
vendored
@@ -87,3 +87,13 @@ postgres_data/
|
||||
tmp/
|
||||
|
||||
*.txt
|
||||
|
||||
# Android / Kotlin Mobile
|
||||
client-mobile/.gradle/
|
||||
client-mobile/.idea/
|
||||
client-mobile/.run/
|
||||
client-mobile/build/
|
||||
client-mobile/.cxx/
|
||||
client-mobile/local.properties
|
||||
client-mobile/*.iml
|
||||
client-mobile/.kotlin/
|
||||
|
||||
@@ -7,9 +7,8 @@ using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.Chats.Create;
|
||||
|
||||
namespace Knot.Modules.Conversations.UnitTests.Chats;
|
||||
|
||||
@@ -7,9 +7,10 @@ using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Chats.GetChats;
|
||||
using Knot.Modules.Conversations.Application.DTOs;
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
using FluentAssertions;
|
||||
using NSubstitute;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.Chats.GetOrCreateFavorites;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Messaging.Domain;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Xunit;
|
||||
|
||||
|
||||
@@ -23,10 +23,10 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\..\src\Modules\Chats\Knot.Modules.Conversations.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Modules\Messaging\Knot.Modules.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Modules\Conversations\Knot.Modules.Conversations.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
|
||||
<ProjectReference Include="..\..\..\src\Modules\Settings\Knot.Modules.Settings.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -5,15 +5,17 @@ using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using MediatR;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Conversations.Domain;
|
||||
using Knot.Modules.Messaging.Domain;
|
||||
using Knot.Modules.Conversations.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Application.Messages.Send;
|
||||
using Knot.Modules.Settings.Application.Settings.Abstractions;
|
||||
using Knot.Modules.Settings.Application.Settings.DTOs;
|
||||
using Knot.Contracts.Settings.Application.Abstractions;
|
||||
using Knot.Contracts.Settings.Application.DTOs;
|
||||
|
||||
namespace Knot.Modules.Conversations.UnitTests.Messages;
|
||||
|
||||
@@ -24,6 +26,8 @@ public class SendMessageCommandHandlerTests
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly IMediator _mediator;
|
||||
private readonly IMessagesSettings _messagesSettings;
|
||||
private readonly IIdempotencyStore _idempotencyStore;
|
||||
private readonly ILogger<SendMessageCommandHandler> _logger;
|
||||
private readonly SendMessageCommandHandler _handler;
|
||||
|
||||
public SendMessageCommandHandlerTests()
|
||||
@@ -33,11 +37,13 @@ public class SendMessageCommandHandlerTests
|
||||
_unitOfWork = Substitute.For<IChatsUnitOfWork>();
|
||||
_mediator = Substitute.For<IMediator>();
|
||||
_messagesSettings = Substitute.For<IMessagesSettings>();
|
||||
_idempotencyStore = Substitute.For<IIdempotencyStore>();
|
||||
_logger = Substitute.For<ILogger<SendMessageCommandHandler>>();
|
||||
|
||||
var config = new Knot.Modules.Settings.Application.Settings.DTOs.MessagesConfig();
|
||||
var config = new MessagesConfig();
|
||||
_messagesSettings.Current.Returns(config);
|
||||
|
||||
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings);
|
||||
_handler = new SendMessageCommandHandler(_chatRepository, _messageRepository, _unitOfWork, _mediator, _messagesSettings, _idempotencyStore, _logger);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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.Contracts.Profiles.Domain;
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Modules.Profiles.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Profiles.UnitTests;
|
||||
|
||||
@@ -25,11 +25,14 @@ public class UpdateProfileCommandHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_ShouldReturnError_WhenProfileNotFound()
|
||||
{
|
||||
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null, null);
|
||||
_profileRepository.GetAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((UserProfileDto?)null);
|
||||
// Arrange
|
||||
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null);
|
||||
_profileRepository.GetByIdAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((ProfileDocument?)null);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsFailure.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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,8 +7,7 @@ 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,4 +19,12 @@ 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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace Knot.Contracts.Conversations.Application.Abstractions;
|
||||
|
||||
/// <summary>
|
||||
/// Хранилище для обеспечения идемпотентности операций.
|
||||
/// Если ключ уже существует — возвращает сохранённый результат без повторного выполнения.
|
||||
/// </summary>
|
||||
public interface IIdempotencyStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Возвращает сохранённый результат по ключу или выполняет factory, сохраняет и возвращает результат.
|
||||
/// </summary>
|
||||
Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
TimeSpan? expiration = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -9,8 +9,4 @@ 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");
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
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,13 +12,5 @@ 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; }
|
||||
}
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -32,11 +32,11 @@ using Knot.Modules.TelegramImport.Presentation.Endpoints;
|
||||
using Knot.Modules.WebRtc;
|
||||
using Knot.Modules.WebRtc.Presentation.Endpoints;
|
||||
using Knot.Shared.Infrastructure;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Authentication.JwtBearer;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.IdentityModel.Tokens;
|
||||
using MediatR;
|
||||
|
||||
|
||||
|
||||
@@ -250,7 +250,6 @@ app.MapStoriesEndpoints();
|
||||
app.MapContactsEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapProfilesEndpoints();
|
||||
app.MapStatusesEndpoints();
|
||||
app.MapFederationEndpoints();
|
||||
app.MapKlipyEndpoints();
|
||||
app.MapChatsEndpoints();
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
"Secret": "knot_super_secret_key_1234567890_knot",
|
||||
"Issuer": "Knot",
|
||||
"Audience": "KnotUsers",
|
||||
"ExpiryInMinutes": 1440
|
||||
"ExpiryInMinutes": 1440,
|
||||
"RefreshExpiryInDays": 30
|
||||
},
|
||||
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,8 @@ 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();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
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,4 +1,6 @@
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
@@ -9,10 +11,12 @@ 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)
|
||||
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
|
||||
@@ -23,9 +27,18 @@ 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 = string.Empty,
|
||||
AccessToken = newAccessToken,
|
||||
RefreshToken = string.Empty,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
|
||||
@@ -5,9 +5,6 @@ using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.Login;
|
||||
|
||||
/// <summary>
|
||||
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> AuthResponseDto.
|
||||
/// </summary>
|
||||
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
|
||||
|
||||
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
|
||||
@@ -31,14 +28,21 @@ 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 = string.Empty,
|
||||
RefreshToken = refreshToken,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
DisplayName = user.DisplayName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
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>;
|
||||
@@ -0,0 +1,65 @@
|
||||
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,5 +1,4 @@
|
||||
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;
|
||||
@@ -9,9 +8,6 @@ using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.Register;
|
||||
|
||||
/// <summary>
|
||||
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
|
||||
/// </summary>
|
||||
public sealed record RegisterUserCommand(
|
||||
string Username,
|
||||
string Password,
|
||||
@@ -19,9 +15,6 @@ public sealed record RegisterUserCommand(
|
||||
string? Email,
|
||||
string? Bio) : ICommand<AuthResponseDto>;
|
||||
|
||||
/// <summary>
|
||||
/// <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>.
|
||||
/// </summary>
|
||||
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
@@ -48,16 +41,13 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
|
||||
}
|
||||
|
||||
// 1. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> username
|
||||
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
|
||||
}
|
||||
|
||||
// 2. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
||||
|
||||
// 3. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
var user = User.Create(
|
||||
request.Username,
|
||||
passwordHash,
|
||||
@@ -65,21 +55,33 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
||||
request.Email,
|
||||
request.Bio);
|
||||
|
||||
// 4. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> - <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD> <20> Domain User
|
||||
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
|
||||
repoWithDomainUserAdd?.Add(user);
|
||||
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
|
||||
repoImpl?.Add(user);
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||
// 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);
|
||||
|
||||
return Result.Success(new AuthResponseDto
|
||||
{
|
||||
AccessToken = token,
|
||||
RefreshToken = string.Empty,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
DisplayName = user.DisplayName
|
||||
RefreshToken = refreshToken,
|
||||
UserId = userContract.Id,
|
||||
Username = userContract.Username,
|
||||
DisplayName = userContract.DisplayName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,6 +26,7 @@ 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() {
|
||||
@@ -48,9 +49,10 @@ public sealed class User : AggregateRoot<Guid>
|
||||
PhoneNumber = phoneNumber;
|
||||
}
|
||||
|
||||
public void SetRefreshToken(string? refreshToken)
|
||||
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||
{
|
||||
RefreshToken = refreshToken;
|
||||
RefreshTokenExpiry = expiry;
|
||||
}
|
||||
|
||||
public void SetBannedUntil(DateTime? bannedUntil)
|
||||
@@ -79,6 +81,8 @@ 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)
|
||||
@@ -181,7 +185,9 @@ public sealed class User : AggregateRoot<Guid>
|
||||
IsOnline = IsOnline,
|
||||
IsExternal = IsExternal,
|
||||
Domain = _domain,
|
||||
LastSeen = LastSeen
|
||||
LastSeen = LastSeen,
|
||||
RefreshToken = RefreshToken,
|
||||
RefreshTokenExpiry = RefreshTokenExpiry
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,6 +50,12 @@ 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
backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.Designer.cs
generated
Normal file
100
backend/src/Modules/Auth/Migrations/20270419220000_AddRefreshTokenExpiry.Designer.cs
generated
Normal file
@@ -0,0 +1,100 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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,20 +1,18 @@
|
||||
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 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 MediatR;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
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");
|
||||
@@ -31,24 +29,16 @@ 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,7 +32,8 @@ public record MessageDetailDto(
|
||||
bool? PollIsMultipleChoice = null,
|
||||
bool? PollIsAnonymous = null,
|
||||
bool? PollIsClosed = null,
|
||||
List<Guid>? UserVotedOptionIds = null
|
||||
List<Guid>? UserVotedOptionIds = null,
|
||||
bool IsDeletedForUser = false
|
||||
);
|
||||
|
||||
public record ReplyToMessageDto(
|
||||
|
||||
@@ -42,10 +42,16 @@ 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
|
||||
{
|
||||
@@ -55,24 +61,13 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
if (request.DeleteForAll)
|
||||
// Notify all clients in the chat about the deletion
|
||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
||||
{
|
||||
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
|
||||
});
|
||||
}
|
||||
chatId = request.ChatId,
|
||||
messageIds = request.MessageIds,
|
||||
deleteForAll = request.DeleteForAll
|
||||
});
|
||||
|
||||
return global::Knot.Shared.Kernel.Result.Success();
|
||||
}
|
||||
|
||||
@@ -3,11 +3,11 @@ 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;
|
||||
|
||||
@@ -114,7 +114,8 @@ 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)
|
||||
{
|
||||
@@ -141,7 +142,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 is TextMessage tm ? tm.Quote : null,
|
||||
(message as TextMessage)?.Quote,
|
||||
message.IsEdited,
|
||||
message.IsDeleted,
|
||||
message.CreatedAt,
|
||||
@@ -154,19 +155,22 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
(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),
|
||||
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
|
||||
reactions?.Select(r => {
|
||||
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)
|
||||
@@ -174,12 +178,13 @@ 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 as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(),
|
||||
message.IsDeletedForUser(request.UserId)
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
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;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Knot.Modules.Conversations.Application.Messages.Send;
|
||||
|
||||
@@ -30,7 +31,8 @@ public sealed record SendMessageCommand(
|
||||
DateTime? PollExpiresAt = null,
|
||||
string? CallType = null,
|
||||
string? CallStatus = null,
|
||||
int? Duration = null) : ICommand<Guid>;
|
||||
int? Duration = null,
|
||||
string? IdempotencyKey = null) : ICommand<Guid>;
|
||||
|
||||
public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageCommand, Guid>
|
||||
{
|
||||
@@ -39,64 +41,74 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
private readonly IChatsUnitOfWork _unitOfWork;
|
||||
private readonly MediatR.IMediator _mediator;
|
||||
private readonly IMessagesSettings _messagesSettings;
|
||||
private readonly IIdempotencyStore _idempotencyStore;
|
||||
private readonly ILogger<SendMessageCommandHandler> _logger;
|
||||
|
||||
public SendMessageCommandHandler(
|
||||
IChatRepository chatRepository,
|
||||
IMessageRepository messageRepository,
|
||||
IChatsUnitOfWork unitOfWork,
|
||||
MediatR.IMediator mediator,
|
||||
IMessagesSettings messagesSettings)
|
||||
IMessagesSettings messagesSettings,
|
||||
IIdempotencyStore idempotencyStore,
|
||||
ILogger<SendMessageCommandHandler> logger)
|
||||
{
|
||||
_chatRepository = chatRepository;
|
||||
_messageRepository = messageRepository;
|
||||
_unitOfWork = unitOfWork;
|
||||
_mediator = mediator;
|
||||
_messagesSettings = messagesSettings;
|
||||
_idempotencyStore = idempotencyStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<Result<Guid>> Handle(SendMessageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD>
|
||||
if (!string.IsNullOrWhiteSpace(request.IdempotencyKey))
|
||||
{
|
||||
var key = $"send_msg:{request.ChatId}:{request.IdempotencyKey}";
|
||||
return await _idempotencyStore.GetOrCreateAsync(
|
||||
key,
|
||||
factory: ct => ExecuteAsync(request, ct),
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
return await ExecuteAsync(request, cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<Result<Guid>> ExecuteAsync(SendMessageCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
// 1. Проверка существования чата
|
||||
var chat = await _chatRepository.GetByIdAsync(request.ChatId, cancellationToken);
|
||||
if (chat is null)
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatsNotFound);
|
||||
}
|
||||
|
||||
// 2. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>, <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
// 2. Проверка, является ли отправитель участником чата
|
||||
if (!chat.Members.Any(m => m.UserId == request.SenderId))
|
||||
{
|
||||
return Result.Failure<Guid>(ChatErrors.ChatsForbidden);
|
||||
}
|
||||
|
||||
// 3. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
// 3. Создание сообщения
|
||||
Message message;
|
||||
if (request.Type == "story_reply" || request.Type == "story_reaction")
|
||||
{
|
||||
if (!_messagesSettings.Current.AllowMedia) return Result.Failure<Guid>(ChatErrors.MediaDisabled);
|
||||
|
||||
|
||||
var parsedStoryMediaType = Enum.TryParse<MediaType>(request.StoryMediaType, true, out var sTypeEnum) ? sTypeEnum : MediaType.Image;
|
||||
message = new StoryMessage(
|
||||
Guid.NewGuid(),
|
||||
|
||||
request.ChatId,
|
||||
|
||||
request.SenderId,
|
||||
|
||||
request.StoryId ?? Guid.Empty,
|
||||
|
||||
request.StoryMediaUrl ?? string.Empty,
|
||||
request.StoryMediaType,
|
||||
|
||||
request.Content,
|
||||
|
||||
request.ReplyToId,
|
||||
|
||||
request.ForwardedFromId,
|
||||
|
||||
DateTime.UtcNow,
|
||||
|
||||
false);
|
||||
}
|
||||
else if (request.Attachments != null && request.Attachments.Any())
|
||||
@@ -106,26 +118,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
var firstAtt = request.Attachments.First();
|
||||
var parsedType = Enum.TryParse<MediaType>(firstAtt.Type, true, out var mTypeEnum) ? mTypeEnum : MediaType.File;
|
||||
|
||||
|
||||
message = new MediaMessage(
|
||||
Guid.NewGuid(),
|
||||
|
||||
request.ChatId,
|
||||
|
||||
request.SenderId,
|
||||
parsedType,
|
||||
|
||||
request.Content,
|
||||
|
||||
request.ReplyToId,
|
||||
|
||||
request.ForwardedFromId,
|
||||
|
||||
DateTime.UtcNow,
|
||||
|
||||
false);
|
||||
|
||||
|
||||
foreach (var att in request.Attachments)
|
||||
{
|
||||
var pType = Enum.TryParse<MediaType>(att.Type, true, out var tEnum) ? tEnum : MediaType.File;
|
||||
@@ -167,25 +170,17 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
{
|
||||
message = new TextMessage(
|
||||
Guid.NewGuid(),
|
||||
|
||||
request.ChatId,
|
||||
|
||||
request.SenderId,
|
||||
|
||||
request.Content ?? string.Empty,
|
||||
|
||||
request.ReplyToId,
|
||||
|
||||
request.Quote,
|
||||
|
||||
request.ForwardedFromId,
|
||||
|
||||
DateTime.UtcNow,
|
||||
|
||||
false);
|
||||
}
|
||||
|
||||
// 4. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> <20><><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD> High-Water Mark
|
||||
// 4. Обновление High-Water Mark
|
||||
chat.IncrementSequenceId();
|
||||
message.SetSequenceId(chat.LastMessageSequenceId);
|
||||
|
||||
@@ -193,19 +188,15 @@ public sealed class SendMessageCommandHandler : ICommandHandler<SendMessageComma
|
||||
senderMember.UpdateReadCursor(message.Id, message.SequenceId);
|
||||
senderMember.UpdateDeliveredCursor(message.Id);
|
||||
|
||||
// 5. <EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD><EFBFBD>
|
||||
// 5. Сохранение
|
||||
_messageRepository.Add(message);
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
await _mediator.Publish(new MessageSentDomainEvent(
|
||||
message.Id,
|
||||
|
||||
message.ChatId,
|
||||
|
||||
message.SenderId,
|
||||
|
||||
message.Content),
|
||||
|
||||
cancellationToken);
|
||||
|
||||
return Result.Success(message.Id);
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Contracts.Conversations.Domain;
|
||||
using Knot.Contracts.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Modules.Conversations.Infrastructure.Idempotency;
|
||||
using Knot.Modules.Conversations.Infrastructure.Persistence;
|
||||
using Knot.Modules.Conversations.Infrastructure.Persistence.Mongo;
|
||||
using Knot.Modules.Conversations.Infrastructure.Services;
|
||||
@@ -8,7 +10,6 @@ using Knot.Shared.Kernel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
|
||||
namespace Knot.Modules.Conversations;
|
||||
|
||||
@@ -42,7 +43,10 @@ public static class DependencyInjection
|
||||
services.AddScoped<Knot.Contracts.Conversations.Application.Abstractions.IUserStatusService, UserStatusService>();
|
||||
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserStatusService, UserStatusService>();
|
||||
services.AddScoped<Knot.Contracts.Conversations.Abstractions.IUserDeleterService, UserDeleterService>();
|
||||
|
||||
|
||||
services.AddMemoryCache();
|
||||
services.AddSingleton<Knot.Contracts.Conversations.Application.Abstractions.IIdempotencyStore, MemoryCacheIdempotencyStore>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace Knot.Modules.Conversations.Infrastructure.Idempotency;
|
||||
|
||||
/// <summary>
|
||||
/// Реализация хранилища идемпотентности на основе IMemoryCache.
|
||||
/// Использует семафор для предотвращения race condition при одновременных запросах с одинаковым ключом.
|
||||
/// </summary>
|
||||
public sealed class MemoryCacheIdempotencyStore : IIdempotencyStore
|
||||
{
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly SemaphoreSlim _semaphore = new(1, 1);
|
||||
|
||||
public MemoryCacheIdempotencyStore(IMemoryCache cache)
|
||||
{
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
public async Task<T> GetOrCreateAsync<T>(
|
||||
string key,
|
||||
Func<CancellationToken, Task<T>> factory,
|
||||
TimeSpan? expiration = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out T? cachedValue) && cachedValue is not null)
|
||||
{
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
await _semaphore.WaitAsync(cancellationToken);
|
||||
try
|
||||
{
|
||||
// Double-check после получения блокировки
|
||||
if (_cache.TryGetValue(key, out cachedValue) && cachedValue is not null)
|
||||
{
|
||||
return cachedValue;
|
||||
}
|
||||
|
||||
var value = await factory(cancellationToken);
|
||||
|
||||
var options = new MemoryCacheEntryOptions()
|
||||
.SetAbsoluteExpiration(expiration ?? TimeSpan.FromHours(24))
|
||||
.SetPriority(CacheItemPriority.Normal);
|
||||
|
||||
_cache.Set(key, value, options);
|
||||
return value;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_semaphore.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,6 @@ 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;
|
||||
|
||||
@@ -52,7 +51,6 @@ 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,
|
||||
@@ -62,8 +60,7 @@ public sealed class ChatHub : Hub
|
||||
IMessageRepository messageRepository,
|
||||
ILogger<ChatHub> logger,
|
||||
IMemoryCache cache,
|
||||
IUserDisplayNameProvider userProvider,
|
||||
IProfileRepository profileRepository)
|
||||
IUserDisplayNameProvider userProvider)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
@@ -73,7 +70,6 @@ public sealed class ChatHub : Hub
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_userProvider = userProvider;
|
||||
_profileRepository = profileRepository;
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
@@ -99,15 +95,7 @@ public sealed class ChatHub : Hub
|
||||
|
||||
userId, Context.ConnectionId, userChats.Count);
|
||||
|
||||
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||
if (!isInvisible)
|
||||
{
|
||||
await BroadcastPresenceToVisibleUsersAsync(
|
||||
"user_online",
|
||||
new { userId },
|
||||
_userContext.UserId,
|
||||
Context.ConnectionAborted);
|
||||
}
|
||||
await Clients.Others.SendAsync("user_online", new { userId });
|
||||
}
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
@@ -123,22 +111,7 @@ public sealed class ChatHub : Hub
|
||||
if (set.Count == 0)
|
||||
{
|
||||
_userConnections.TryRemove(userId, out _);
|
||||
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);
|
||||
}
|
||||
await Clients.Others.SendAsync("user_offline", new { userId, lastSeen = DateTime.UtcNow });
|
||||
}
|
||||
}
|
||||
_cache.Set("Global_OnlineUsersCount", _userConnections.Count);
|
||||
@@ -147,56 +120,6 @@ public sealed class ChatHub : Hub
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
private async Task<bool> IsUserInvisibleAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var profile = await _profileRepository.GetAsync(userId, ct);
|
||||
return profile?.IsInvisible ?? false;
|
||||
}
|
||||
|
||||
private async Task BroadcastPresenceToVisibleUsersAsync(
|
||||
string eventName,
|
||||
object payload,
|
||||
Guid sourceUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var recipients = _userConnections.Keys
|
||||
.Where(id => id != sourceUserId.ToString())
|
||||
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (recipients.Count == 0)
|
||||
return;
|
||||
|
||||
var recipientProfiles = await _profileRepository.GetAsync(recipients, cancellationToken);
|
||||
var invisibleRecipientIds = recipientProfiles
|
||||
.Where(p => p.IsInvisible)
|
||||
.Select(p => p.UserId)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var kvp in _userConnections)
|
||||
{
|
||||
if (!Guid.TryParse(kvp.Key, out var recipientId))
|
||||
continue;
|
||||
if (recipientId == sourceUserId)
|
||||
continue;
|
||||
if (invisibleRecipientIds.Contains(recipientId))
|
||||
continue;
|
||||
|
||||
string[] connectionIds;
|
||||
lock (kvp.Value)
|
||||
{
|
||||
connectionIds = kvp.Value.ToArray();
|
||||
}
|
||||
|
||||
foreach (var connectionId in connectionIds)
|
||||
{
|
||||
await Clients.Client(connectionId).SendAsync(eventName, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Chat methods
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
<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>
|
||||
@@ -38,6 +37,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
|
||||
<InternalsVisibleTo Include="Knot.Modules.Conversations.UnitTests" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -55,11 +55,14 @@ public static class MessagesEndpoints
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
||||
});
|
||||
|
||||
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
group.MapPost("chat/{chatId:guid}", async ([FromRoute] Guid chatId, [FromBody] SendMessageRequest request, ISender sender, IUserContext userContext, HttpRequest httpRequest, CancellationToken ct) =>
|
||||
{
|
||||
var attachments = request.Attachments?.Select(a =>
|
||||
new AttachmentRequest(a.Type, a.Url, a.FileName, a.FileSize)).ToList();
|
||||
|
||||
// Получаем idempotency ключ из заголовка
|
||||
httpRequest.Headers.TryGetValue("X-Idempotency-Key", out var idempotencyKey);
|
||||
|
||||
var command = new SendMessageCommand(
|
||||
chatId,
|
||||
userContext.UserId,
|
||||
@@ -68,7 +71,8 @@ public static class MessagesEndpoints
|
||||
attachments,
|
||||
request.ReplyToId,
|
||||
request.Quote,
|
||||
request.ForwardedFromId);
|
||||
request.ForwardedFromId,
|
||||
IdempotencyKey: idempotencyKey.ToString());
|
||||
|
||||
var result = await sender.Send(command, ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error.Description);
|
||||
|
||||
@@ -2,9 +2,5 @@ using System;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
|
||||
|
||||
public record UpdateProfileRequest(
|
||||
string? DisplayName,
|
||||
string? Bio,
|
||||
DateTime? Birthday,
|
||||
bool? IsInvisible);
|
||||
public record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
|
||||
public record UpdateSettingsRequest(bool? HideStoryViews);
|
||||
|
||||
@@ -11,8 +11,7 @@ public sealed record UpdateProfileCommand(
|
||||
Guid UserId,
|
||||
string? DisplayName,
|
||||
string? Bio,
|
||||
DateTime? Birthday,
|
||||
bool? IsInvisible) : ICommand<UserProfileDto>;
|
||||
DateTime? Birthday) : ICommand<UserProfileDto>;
|
||||
|
||||
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
|
||||
{
|
||||
@@ -29,13 +28,9 @@ 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)
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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,11 +11,10 @@ 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,6 +3,10 @@ using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Knot.Modules.Profiles.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB-документ профиля пользователя.
|
||||
/// Id совпадает с UserId из модуля Auth (Postgres).
|
||||
/// </summary>
|
||||
public sealed class ProfileDocument
|
||||
{
|
||||
[BsonId]
|
||||
@@ -15,23 +19,12 @@ 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; }
|
||||
@@ -72,26 +65,6 @@ 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;
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
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,39 +10,29 @@ namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||
internal class ProfileRepository : IProfileRepository
|
||||
{
|
||||
private readonly IMongoCollection<ProfileDocument> _profiles;
|
||||
private readonly IUserStatusRepository _statuses;
|
||||
|
||||
public ProfileRepository(IMongoDatabase database, IUserStatusRepository statuses)
|
||||
public ProfileRepository(IMongoDatabase database)
|
||||
{
|
||||
_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 is null ? null : await profile.ToDtoAsync(_statuses, ct);
|
||||
return profile?.ToDto();
|
||||
}
|
||||
|
||||
public async Task<UserProfileDto?> GetByUsernameAsync(string username, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Username == username).FirstOrDefaultAsync(ct);
|
||||
return profile is null ? null : await profile.ToDtoAsync(_statuses, ct);
|
||||
return profile?.ToDto();
|
||||
}
|
||||
|
||||
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);
|
||||
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();
|
||||
return profiles.Select(p => p.ToDto()).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<UserProfileDto>> SearchAsync(string query, int limit = 20, CancellationToken ct = default)
|
||||
@@ -67,16 +57,7 @@ internal class ProfileRepository : IProfileRepository
|
||||
var combinedFilter = Builders<ProfileDocument>.Filter.And(baseFilter, searchFilter);
|
||||
docs = await _profiles.Find(combinedFilter).Limit(limit).ToListAsync(ct);
|
||||
}
|
||||
|
||||
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();
|
||||
return docs.Select(p => p.ToDto()).ToList();
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken ct = default)
|
||||
@@ -84,7 +65,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(await profile.ToDtoAsync(_statuses, ct));
|
||||
return Result.Success(profile.ToDto());
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken ct = default)
|
||||
@@ -99,13 +80,9 @@ 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);
|
||||
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));
|
||||
return Result.Success(document.ToDto());
|
||||
}
|
||||
|
||||
public async Task<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default)
|
||||
|
||||
@@ -1,73 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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,14 +1,13 @@
|
||||
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 ToDtoWithStatusMap(ProfileDocument document, IReadOnlyDictionary<Guid, UserStatusDto> statusMap)
|
||||
public static UserProfileDto ToDto(this ProfileDocument document)
|
||||
{
|
||||
var dto = new UserProfileDto
|
||||
return new UserProfileDto
|
||||
{
|
||||
UserId = document.Id,
|
||||
DisplayName = document.DisplayName,
|
||||
@@ -19,59 +18,7 @@ public static class ProfileMappings
|
||||
LastSeen = null,
|
||||
Birthday = document.Birthday,
|
||||
IsPremium = false,
|
||||
IsInvisible = document.IsInvisible,
|
||||
CreatedAt = document.CreatedAt,
|
||||
Status = null,
|
||||
StatusText = null,
|
||||
StatusEmoji = null,
|
||||
StatusExpiresAt = null,
|
||||
CurrentStatusId = null
|
||||
CreatedAt = document.CreatedAt
|
||||
};
|
||||
|
||||
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,8 +17,6 @@ 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) =>
|
||||
{
|
||||
@@ -69,37 +67,16 @@ public static class ProfilesEndpoints
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
|
||||
async Task<IResult> UpdateProfileHandler(
|
||||
[FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request,
|
||||
ISender sender,
|
||||
IUserContext userContext,
|
||||
CancellationToken ct)
|
||||
group.MapPut("profile", async ([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,
|
||||
request.IsInvisible),
|
||||
ct);
|
||||
var result = await sender.Send(new UpdateProfileCommand(userContext.UserId, request.DisplayName, request.Bio, request.Birthday), 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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; }
|
||||
}
|
||||
}
|
||||
44
client-mobile/.gitignore
vendored
Normal file
44
client-mobile/.gitignore
vendored
Normal file
@@ -0,0 +1,44 @@
|
||||
# Miscellaneous
|
||||
*.class
|
||||
*.log
|
||||
*.pyc
|
||||
*.swp
|
||||
.DS_Store
|
||||
.atom/
|
||||
.buildlog/
|
||||
.history
|
||||
.svn/
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
*.iml
|
||||
*.ipr
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
**/doc/api/
|
||||
**/ios/Flutter/.last_build_id
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
.pub-cache/
|
||||
.pub/
|
||||
/build/
|
||||
|
||||
# Symbolication related
|
||||
app.*.symbols
|
||||
|
||||
# Obfuscation related
|
||||
app.*.map.json
|
||||
|
||||
# Android Studio will place build artifacts here
|
||||
/android/app/debug
|
||||
/android/app/profile
|
||||
/android/app/release
|
||||
20
client-mobile/.metadata
Normal file
20
client-mobile/.metadata
Normal file
@@ -0,0 +1,20 @@
|
||||
# This file tracks properties of this Flutter project.
|
||||
# Used by Flutter tool to assess capabilities and perform upgrades etc.
|
||||
#
|
||||
# This file should be version controlled and should not be manually edited.
|
||||
|
||||
version:
|
||||
revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||
channel: stable
|
||||
|
||||
project_type: app
|
||||
|
||||
# Tracks metadata for the flutter migrate command
|
||||
migration:
|
||||
platforms:
|
||||
- platform: root
|
||||
create_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||
base_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||
- platform: android
|
||||
create_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||
base_revision: 5f120583730cedfc49a0e0872e3e1ac7a0a3c8eb
|
||||
125
client-mobile/GETTING_STARTED.md
Normal file
125
client-mobile/GETTING_STARTED.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Инструкция по запуску проекта
|
||||
|
||||
## Предварительные требования
|
||||
|
||||
1. **Flutter SDK** (версия 3.0.0 или выше)
|
||||
- Установите Flutter: https://docs.flutter.dev/get-started/install
|
||||
- Проверьте установку: `flutter doctor`
|
||||
|
||||
2. **Android Studio** или **VS Code** с Flutter плагином
|
||||
|
||||
3. **Android SDK** (для сборки под Android)
|
||||
- MinSDK: 21
|
||||
- TargetSDK: 34
|
||||
- CompileSDK: 34
|
||||
|
||||
## Установка зависимостей
|
||||
|
||||
```bash
|
||||
cd client-mobile
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
## Генерация кода
|
||||
|
||||
Проект использует code generation для:
|
||||
- Freezed (immutable модели)
|
||||
- AutoRoute (навигация)
|
||||
- Isar (база данных)
|
||||
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
Для автоматической генерации при изменениях:
|
||||
```bash
|
||||
dart run build_runner watch --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
## Запуск приложения
|
||||
|
||||
### Android
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
### Сборка релиза
|
||||
```bash
|
||||
flutter build apk --release
|
||||
```
|
||||
|
||||
## Структура проекта
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/ # Общие компоненты
|
||||
│ ├── constants/ # Константы приложения
|
||||
│ ├── errors/ # Обработка ошибок
|
||||
│ ├── network/ # Сетевой клиент (Dio)
|
||||
│ └── theme/ # Темы оформления
|
||||
├── features/ # Функциональные модули
|
||||
│ ├── auth/ # Аутентификация
|
||||
│ │ ├── data/ # Data layer
|
||||
│ │ ├── domain/ # Domain layer
|
||||
│ │ └── presentation/ # UI layer (BLoC, страницы)
|
||||
│ ├── chat/ # Чаты
|
||||
│ ├── profile/ # Профиль
|
||||
│ └── settings/ # Настройки
|
||||
└── internal/ # Внутренняя конфигурация
|
||||
├── di/ # Dependency Injection (GetIt)
|
||||
└── router/ # Навигация
|
||||
```
|
||||
|
||||
## Архитектура
|
||||
|
||||
Проект следует принципам **Clean Architecture**:
|
||||
|
||||
- **Domain Layer**: Бизнес-логика, entities, use cases, repository interfaces
|
||||
- **Data Layer**: Реализации репозиториев, datasources, модели
|
||||
- **Presentation Layer**: UI, BLoC, страницы, виджеты
|
||||
|
||||
## State Management
|
||||
|
||||
Используется **flutter_bloc** для управления состоянием:
|
||||
- Каждый feature имеет свой BLoC
|
||||
- События и состояния генерируются через Freezed
|
||||
- DI через GetIt
|
||||
|
||||
## Навигация
|
||||
|
||||
Используется **auto_route** для декларативной навигации.
|
||||
|
||||
## База данных
|
||||
|
||||
Используется **Isar** - быстрая NoSQL база данных для Flutter.
|
||||
|
||||
## Сетевые запросы
|
||||
|
||||
- **Dio** для REST API запросов
|
||||
- **SignalR** для real-time обновлений
|
||||
|
||||
## Тестирование
|
||||
|
||||
```bash
|
||||
flutter test
|
||||
```
|
||||
|
||||
## Полезные команды
|
||||
|
||||
```bash
|
||||
# Анализ кода
|
||||
flutter analyze
|
||||
|
||||
# Форматирование
|
||||
dart format .
|
||||
|
||||
# Очистка
|
||||
flutter clean
|
||||
|
||||
# Проверка зависимостей
|
||||
flutter pub outdated
|
||||
```
|
||||
|
||||
## Контакты
|
||||
|
||||
Для вопросов и предложений обращайтесь к команде разработки.
|
||||
51
client-mobile/README.md
Normal file
51
client-mobile/README.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Messenger App
|
||||
|
||||
A Telegram-like mobile messenger application built with Flutter using Clean Architecture.
|
||||
|
||||
## Architecture
|
||||
|
||||
This project follows Clean Architecture principles with the following structure:
|
||||
|
||||
```
|
||||
lib/
|
||||
├── core/ # Core utilities, network, errors, theme
|
||||
├── features/ # Feature modules (auth, chat, profile, settings)
|
||||
│ └── [feature]/
|
||||
│ ├── data/ # Data layer (repositories, datasources)
|
||||
│ ├── domain/ # Domain layer (entities, usecases, repository interfaces)
|
||||
│ └── presentation/ # UI layer (bloc, pages, widgets)
|
||||
└── internal/ # App configuration, DI, routing
|
||||
```
|
||||
|
||||
## Dependencies
|
||||
|
||||
- **State Management**: flutter_bloc, bloc
|
||||
- **Architecture & DI**: get_it, injectable, freezed_annotation
|
||||
- **Network & Real-time**: dio, signalr_netcore
|
||||
- **Navigation**: auto_route
|
||||
- **Database**: isar, isar_flutter_libs
|
||||
|
||||
## Setup
|
||||
|
||||
1. Install Flutter dependencies:
|
||||
```bash
|
||||
flutter pub get
|
||||
```
|
||||
|
||||
2. Generate code:
|
||||
```bash
|
||||
dart run build_runner build --delete-conflicting-outputs
|
||||
```
|
||||
|
||||
3. Run the app:
|
||||
```bash
|
||||
flutter run
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Authentication (Login/Register)
|
||||
- Real-time Chat
|
||||
- Contacts
|
||||
- Profile Management
|
||||
- Settings
|
||||
8
client-mobile/analysis_options.yaml
Normal file
8
client-mobile/analysis_options.yaml
Normal file
@@ -0,0 +1,8 @@
|
||||
include: package:flutter_lints/flutter.yaml
|
||||
|
||||
linter:
|
||||
rules:
|
||||
prefer_const_constructors: true
|
||||
prefer_const_declarations: true
|
||||
avoid_print: false
|
||||
prefer_single_quotes: true
|
||||
7
client-mobile/android/.gitignore
vendored
Normal file
7
client-mobile/android/.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
gradle-wrapper.jar
|
||||
/.gradle
|
||||
/captures/
|
||||
/gradlew
|
||||
/gradlew.bat
|
||||
/local.properties
|
||||
GeneratedPluginRegistrant.java
|
||||
69
client-mobile/android/app/build.gradle
Normal file
69
client-mobile/android/app/build.gradle
Normal file
@@ -0,0 +1,69 @@
|
||||
plugins {
|
||||
id "com.android.application"
|
||||
id "kotlin-android"
|
||||
id "dev.flutter.flutter-gradle-plugin"
|
||||
}
|
||||
|
||||
def localProperties = new Properties()
|
||||
def localPropertiesFile = rootProject.file('local.properties')
|
||||
if (localPropertiesFile.exists()) {
|
||||
localPropertiesFile.withReader('UTF-8') { reader ->
|
||||
localProperties.load(reader)
|
||||
}
|
||||
}
|
||||
|
||||
def flutterVersionCode = localProperties.getProperty('flutter.versionCode')
|
||||
if (flutterVersionCode == null) {
|
||||
flutterVersionCode = '1'
|
||||
}
|
||||
|
||||
def flutterVersionName = localProperties.getProperty('flutter.versionName')
|
||||
if (flutterVersionName == null) {
|
||||
flutterVersionName = '1.0'
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "com.example.messenger_app"
|
||||
compileSdkVersion 36
|
||||
ndkVersion flutter.ndkVersion
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility JavaVersion.VERSION_17
|
||||
targetCompatibility JavaVersion.VERSION_17
|
||||
}
|
||||
|
||||
kotlinOptions {
|
||||
jvmTarget = '17'
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
main.java.srcDirs += 'src/main/kotlin'
|
||||
}
|
||||
|
||||
defaultConfig {
|
||||
applicationId "com.example.messenger_app"
|
||||
minSdkVersion flutter.minSdkVersion
|
||||
targetSdkVersion 35
|
||||
versionCode flutterVersionCode.toInteger()
|
||||
versionName flutterVersionName
|
||||
multiDexEnabled true
|
||||
}
|
||||
|
||||
packagingOptions {
|
||||
resources {
|
||||
excludes += '/META-INF/{AL2.0,LGPL2.1}'
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
signingConfig signingConfigs.debug
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
dependencies {}
|
||||
28
client-mobile/android/app/src/main/AndroidManifest.xml
Normal file
28
client-mobile/android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,28 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:label="Messenger"
|
||||
android:name="${applicationName}"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:theme="@style/LaunchTheme"
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
android:windowSoftInputMode="adjustResize">
|
||||
<meta-data
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
/>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<meta-data
|
||||
android:name="flutterEmbedding"
|
||||
android:value="2" />
|
||||
</application>
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
</manifest>
|
||||
@@ -0,0 +1,5 @@
|
||||
package com.example.messenger_app
|
||||
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
|
||||
class MainActivity: FlutterActivity()
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
</layer-list>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2481CC" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2481CC" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2481CC" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2481CC" />
|
||||
</shape>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#2481CC" />
|
||||
</shape>
|
||||
9
client-mobile/android/app/src/main/res/values/styles.xml
Normal file
9
client-mobile/android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
</style>
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
</style>
|
||||
</resources>
|
||||
25
client-mobile/android/build.gradle
Normal file
25
client-mobile/android/build.gradle
Normal file
@@ -0,0 +1,25 @@
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
}
|
||||
|
||||
rootProject.buildDir = '../build'
|
||||
|
||||
subprojects {
|
||||
project.buildDir = "${rootProject.buildDir}/${project.name}"
|
||||
afterEvaluate { project ->
|
||||
if (project.hasProperty("android")) {
|
||||
project.android {
|
||||
if (namespace == null) {
|
||||
namespace project.group
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register("clean", Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
3
client-mobile/android/gradle.properties
Normal file
3
client-mobile/android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx4G -XX:MaxMetaspaceSize=2G -XX:+HeapDumpOnOutOfMemoryError
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
5
client-mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
5
client-mobile/android/gradle/wrapper/gradle-wrapper.properties
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
|
||||
25
client-mobile/android/settings.gradle
Normal file
25
client-mobile/android/settings.gradle
Normal file
@@ -0,0 +1,25 @@
|
||||
pluginManagement {
|
||||
def flutterSdkPath = {
|
||||
def properties = new Properties()
|
||||
file("local.properties").withInputStream { properties.load(it) }
|
||||
def flutterSdkPath = properties.getProperty("flutter.sdk")
|
||||
assert flutterSdkPath != null, "flutter.sdk not set in local.properties"
|
||||
return flutterSdkPath
|
||||
}()
|
||||
|
||||
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
|
||||
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
gradlePluginPortal()
|
||||
}
|
||||
}
|
||||
|
||||
plugins {
|
||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||
id "com.android.application" version "8.6.0" apply false
|
||||
id "org.jetbrains.kotlin.android" version "2.1.0" apply false
|
||||
}
|
||||
|
||||
include ":app"
|
||||
14
client-mobile/ios/Flutter/Generated.xcconfig
Normal file
14
client-mobile/ios/Flutter/Generated.xcconfig
Normal file
@@ -0,0 +1,14 @@
|
||||
// This is a generated file; do not edit or check into version control.
|
||||
FLUTTER_ROOT=C:\src\flutter
|
||||
FLUTTER_APPLICATION_PATH=E:\GIT\forkmessager\client-mobile
|
||||
COCOAPODS_PARALLEL_CODE_SIGN=true
|
||||
FLUTTER_TARGET=lib\main.dart
|
||||
FLUTTER_BUILD_DIR=build
|
||||
FLUTTER_BUILD_NAME=1.0.0
|
||||
FLUTTER_BUILD_NUMBER=1
|
||||
EXCLUDED_ARCHS[sdk=iphonesimulator*]=i386
|
||||
EXCLUDED_ARCHS[sdk=iphoneos*]=armv7
|
||||
DART_OBFUSCATION=false
|
||||
TRACK_WIDGET_CREATION=true
|
||||
TREE_SHAKE_ICONS=false
|
||||
PACKAGE_CONFIG=.dart_tool/package_config.json
|
||||
32
client-mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py
Normal file
32
client-mobile/ios/Flutter/ephemeral/flutter_lldb_helper.py
Normal file
@@ -0,0 +1,32 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
import lldb
|
||||
|
||||
def handle_new_rx_page(frame: lldb.SBFrame, bp_loc, extra_args, intern_dict):
|
||||
"""Intercept NOTIFY_DEBUGGER_ABOUT_RX_PAGES and touch the pages."""
|
||||
base = frame.register["x0"].GetValueAsAddress()
|
||||
page_len = frame.register["x1"].GetValueAsUnsigned()
|
||||
|
||||
# Note: NOTIFY_DEBUGGER_ABOUT_RX_PAGES will check contents of the
|
||||
# first page to see if handled it correctly. This makes diagnosing
|
||||
# misconfiguration (e.g. missing breakpoint) easier.
|
||||
data = bytearray(page_len)
|
||||
data[0:8] = b'IHELPED!'
|
||||
|
||||
error = lldb.SBError()
|
||||
frame.GetThread().GetProcess().WriteMemory(base, data, error)
|
||||
if not error.Success():
|
||||
print(f'Failed to write into {base}[+{page_len}]', error)
|
||||
return
|
||||
|
||||
def __lldb_init_module(debugger: lldb.SBDebugger, _):
|
||||
target = debugger.GetDummyTarget()
|
||||
# Caveat: must use BreakpointCreateByRegEx here and not
|
||||
# BreakpointCreateByName. For some reasons callback function does not
|
||||
# get carried over from dummy target for the later.
|
||||
bp = target.BreakpointCreateByRegex("^NOTIFY_DEBUGGER_ABOUT_RX_PAGES$")
|
||||
bp.SetScriptCallbackFunction('{}.handle_new_rx_page'.format(__name__))
|
||||
bp.SetAutoContinue(True)
|
||||
print("-- LLDB integration loaded --")
|
||||
5
client-mobile/ios/Flutter/ephemeral/flutter_lldbinit
Normal file
5
client-mobile/ios/Flutter/ephemeral/flutter_lldbinit
Normal file
@@ -0,0 +1,5 @@
|
||||
#
|
||||
# Generated file, do not edit.
|
||||
#
|
||||
|
||||
command script import --relative-to-command-file flutter_lldb_helper.py
|
||||
13
client-mobile/ios/Flutter/flutter_export_environment.sh
Normal file
13
client-mobile/ios/Flutter/flutter_export_environment.sh
Normal file
@@ -0,0 +1,13 @@
|
||||
#!/bin/sh
|
||||
# This is a generated file; do not edit or check into version control.
|
||||
export "FLUTTER_ROOT=C:\src\flutter"
|
||||
export "FLUTTER_APPLICATION_PATH=E:\GIT\forkmessager\client-mobile"
|
||||
export "COCOAPODS_PARALLEL_CODE_SIGN=true"
|
||||
export "FLUTTER_TARGET=lib\main.dart"
|
||||
export "FLUTTER_BUILD_DIR=build"
|
||||
export "FLUTTER_BUILD_NAME=1.0.0"
|
||||
export "FLUTTER_BUILD_NUMBER=1"
|
||||
export "DART_OBFUSCATION=false"
|
||||
export "TRACK_WIDGET_CREATION=true"
|
||||
export "TREE_SHAKE_ICONS=false"
|
||||
export "PACKAGE_CONFIG=.dart_tool/package_config.json"
|
||||
13
client-mobile/ios/Runner/AppDelegate.swift
Normal file
13
client-mobile/ios/Runner/AppDelegate.swift
Normal file
@@ -0,0 +1,13 @@
|
||||
import UIKit
|
||||
import Flutter
|
||||
|
||||
@UIApplicationMain
|
||||
@objc class AppDelegate: FlutterAppDelegate {
|
||||
override func application(
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
GeneratedPluginRegistrant.register(with: self)
|
||||
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||
}
|
||||
}
|
||||
19
client-mobile/ios/Runner/GeneratedPluginRegistrant.h
Normal file
19
client-mobile/ios/Runner/GeneratedPluginRegistrant.h
Normal file
@@ -0,0 +1,19 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#ifndef GeneratedPluginRegistrant_h
|
||||
#define GeneratedPluginRegistrant_h
|
||||
|
||||
#import <Flutter/Flutter.h>
|
||||
|
||||
NS_ASSUME_NONNULL_BEGIN
|
||||
|
||||
@interface GeneratedPluginRegistrant : NSObject
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry;
|
||||
@end
|
||||
|
||||
NS_ASSUME_NONNULL_END
|
||||
#endif /* GeneratedPluginRegistrant_h */
|
||||
28
client-mobile/ios/Runner/GeneratedPluginRegistrant.m
Normal file
28
client-mobile/ios/Runner/GeneratedPluginRegistrant.m
Normal file
@@ -0,0 +1,28 @@
|
||||
//
|
||||
// Generated file. Do not edit.
|
||||
//
|
||||
|
||||
// clang-format off
|
||||
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
|
||||
#if __has_include(<isar_flutter_libs/IsarFlutterLibsPlugin.h>)
|
||||
#import <isar_flutter_libs/IsarFlutterLibsPlugin.h>
|
||||
#else
|
||||
@import isar_flutter_libs;
|
||||
#endif
|
||||
|
||||
#if __has_include(<shared_preferences_foundation/SharedPreferencesPlugin.h>)
|
||||
#import <shared_preferences_foundation/SharedPreferencesPlugin.h>
|
||||
#else
|
||||
@import shared_preferences_foundation;
|
||||
#endif
|
||||
|
||||
@implementation GeneratedPluginRegistrant
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||
[IsarFlutterLibsPlugin registerWithRegistrar:[registry registrarForPlugin:@"IsarFlutterLibsPlugin"]];
|
||||
[SharedPreferencesPlugin registerWithRegistrar:[registry registrarForPlugin:@"SharedPreferencesPlugin"]];
|
||||
}
|
||||
|
||||
@end
|
||||
6
client-mobile/l10n.yaml
Normal file
6
client-mobile/l10n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
arb-dir: l10n
|
||||
template-arb-file: app_ru.arb
|
||||
output-localization-file: app_localizations.dart
|
||||
output-class: AppLocalizations
|
||||
output-dir: lib/l10n
|
||||
synthetic-package: false
|
||||
93
client-mobile/l10n/app_en.arb
Normal file
93
client-mobile/l10n/app_en.arb
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
"appTitle": "Messenger",
|
||||
"chats": "Chats",
|
||||
"favorites": "Favorites",
|
||||
"contacts": "Contacts",
|
||||
"settings": "Settings",
|
||||
"login": "Login",
|
||||
"username": "Login",
|
||||
"password": "Password",
|
||||
"name": "Name",
|
||||
"loginButton": "Sign In",
|
||||
"register": "Register",
|
||||
"noAccount": "No account? Sign up",
|
||||
"logout": "Sign Out",
|
||||
"serverSettings": "Server Settings",
|
||||
"apiUrl": "API URL",
|
||||
"apiUrlHint": "https://api.example.com",
|
||||
"save": "Save",
|
||||
"serverConfig": "Server Configuration",
|
||||
"system": "System",
|
||||
"stories": "Stories",
|
||||
"chatsModule": "Chats",
|
||||
"messages": "Messages",
|
||||
"calls": "Calls",
|
||||
"klipy": "Klipy",
|
||||
"import": "Import",
|
||||
"federation": "Federation",
|
||||
"domainUrl": "Domain",
|
||||
"registrationStatus": "Registration",
|
||||
"enabled": "Enabled",
|
||||
"disabled": "Disabled",
|
||||
"language": "Language",
|
||||
"russian": "Русский",
|
||||
"english": "English",
|
||||
"notifications": "Notifications",
|
||||
"privacy": "Privacy",
|
||||
"soundsAndVibration": "Sounds & Vibration",
|
||||
"dataAndStorage": "Data & Storage",
|
||||
"aboutApp": "About App",
|
||||
"help": "Help",
|
||||
"version": "Version",
|
||||
"loading": "Loading...",
|
||||
"error": "Error",
|
||||
"retry": "Retry",
|
||||
"noChats": "No chats yet",
|
||||
"inDevelopment": "In development",
|
||||
"user": "User",
|
||||
"edit": "Edit",
|
||||
"serverConnectionError": "Server connection error",
|
||||
"connected": "Connected",
|
||||
"unknownError": "Unknown error",
|
||||
"networkError": "Network error",
|
||||
"serverError": "Server error",
|
||||
"parsingError": "Data parsing error",
|
||||
"voice": "Voice Calls",
|
||||
"video": "Video Calls",
|
||||
"lifetime": "Lifetime",
|
||||
"groups": "Groups",
|
||||
"maxParticipants": "Max participants",
|
||||
"hours": "h",
|
||||
"dailyLimit": "Daily Limit",
|
||||
"historyLimit": "History Limit",
|
||||
"maxFileSize": "Max File Size",
|
||||
"noLimit": "No Limit",
|
||||
"allowForwarding": "Forwarding",
|
||||
"allowReactions": "Reactions",
|
||||
"allowReplies": "Replies",
|
||||
"allowQuoting": "Quoting",
|
||||
"allowMessageDeletion": "Deletion",
|
||||
"forbidCopying": "Forbid Copying",
|
||||
"allowLinks": "Links",
|
||||
"allowPolls": "Polls",
|
||||
"allowPinning": "Pins",
|
||||
"allowMedia": "Media",
|
||||
"description": "Description",
|
||||
"server": "Server",
|
||||
"notSpecified": "not specified",
|
||||
"settingsSaved": "Settings saved",
|
||||
"enterUsername": "Enter login",
|
||||
"invalidUsername": "Login must be at least 3 characters and without Cyrillic",
|
||||
"loginFailed": "Login failed. Check your login and password",
|
||||
"registrationFailed": "Registration failed",
|
||||
"online": "online",
|
||||
"lastSeen": "last seen {date} at {time}",
|
||||
"lastSeenRelative": "last seen {hours}h {minutes}m ago",
|
||||
"lastSeenRelativeMinutes": "last seen {minutes}m ago",
|
||||
"lastSeenJustNow": "last seen just now",
|
||||
"lastSeenYesterday": "last seen yesterday at {time}",
|
||||
"lastSeenDayBeforeYesterday": "last seen day before yesterday at {time}",
|
||||
"noMessages": "No messages yet",
|
||||
"messageHint": "Message"
|
||||
}
|
||||
93
client-mobile/l10n/app_ru.arb
Normal file
93
client-mobile/l10n/app_ru.arb
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"@@locale": "ru",
|
||||
"appTitle": "Мессенджер",
|
||||
"chats": "Чаты",
|
||||
"favorites": "Избранное",
|
||||
"contacts": "Контакты",
|
||||
"settings": "Настройки",
|
||||
"login": "Вход",
|
||||
"username": "Логин",
|
||||
"password": "Пароль",
|
||||
"name": "Имя",
|
||||
"loginButton": "Войти",
|
||||
"register": "Зарегистрироваться",
|
||||
"noAccount": "Нет аккаунта? Зарегистрироваться",
|
||||
"logout": "Выйти из аккаунта",
|
||||
"serverSettings": "Настройки сервера",
|
||||
"apiUrl": "Адрес API",
|
||||
"apiUrlHint": "https://api.example.com",
|
||||
"save": "Сохранить",
|
||||
"serverConfig": "Конфигурация сервера",
|
||||
"system": "Система",
|
||||
"stories": "Истории",
|
||||
"chatsModule": "Чаты",
|
||||
"messages": "Сообщения",
|
||||
"calls": "Звонки",
|
||||
"klipy": "Клипы",
|
||||
"import": "Импорт",
|
||||
"federation": "Федерация",
|
||||
"domainUrl": "Домен",
|
||||
"registrationStatus": "Регистрация",
|
||||
"enabled": "Включено",
|
||||
"disabled": "Отключено",
|
||||
"language": "Язык",
|
||||
"russian": "Русский",
|
||||
"english": "English",
|
||||
"notifications": "Уведомления",
|
||||
"privacy": "Конфиденциальность",
|
||||
"soundsAndVibration": "Звуки и вибрация",
|
||||
"dataAndStorage": "Данные и память",
|
||||
"aboutApp": "О приложении",
|
||||
"help": "Помощь",
|
||||
"version": "Версия",
|
||||
"loading": "Загрузка...",
|
||||
"error": "Ошибка",
|
||||
"retry": "Повторить",
|
||||
"noChats": "У вас пока нет чатов",
|
||||
"inDevelopment": "Функция в разработке",
|
||||
"user": "Пользователь",
|
||||
"edit": "Редактировать",
|
||||
"serverConnectionError": "Ошибка подключения к серверу",
|
||||
"connected": "Подключено",
|
||||
"unknownError": "Неизвестная ошибка",
|
||||
"networkError": "Ошибка сети",
|
||||
"serverError": "Ошибка сервера",
|
||||
"parsingError": "Ошибка обработки данных",
|
||||
"voice": "Голосовые вызовы",
|
||||
"video": "Видеовызовы",
|
||||
"lifetime": "Время жизни",
|
||||
"groups": "Группы",
|
||||
"maxParticipants": "Макс. участников",
|
||||
"hours": "ч.",
|
||||
"dailyLimit": "Дневной лимит",
|
||||
"historyLimit": "Лимит истории",
|
||||
"maxFileSize": "Макс. файл",
|
||||
"noLimit": "Без лимита",
|
||||
"allowForwarding": "Пересылка",
|
||||
"allowReactions": "Реакции",
|
||||
"allowReplies": "Ответы",
|
||||
"allowQuoting": "Цитирование",
|
||||
"allowMessageDeletion": "Удаление",
|
||||
"forbidCopying": "Запрет копирования",
|
||||
"allowLinks": "Ссылки",
|
||||
"allowPolls": "Опросы",
|
||||
"allowPinning": "Закрепы",
|
||||
"allowMedia": "Медиа",
|
||||
"description": "Описание",
|
||||
"server": "Сервер",
|
||||
"notSpecified": "не указан",
|
||||
"settingsSaved": "Настройки сохранены",
|
||||
"enterUsername": "Введите логин",
|
||||
"invalidUsername": "Логин должен быть от 3 символов и без кириллицы",
|
||||
"loginFailed": "Ошибка входа. Проверьте логин и пароль",
|
||||
"registrationFailed": "Ошибка регистрации",
|
||||
"online": "в сети",
|
||||
"lastSeen": "был(а) в сети {date} в {time}",
|
||||
"lastSeenRelative": "был(а) {hours} ч. {minutes} мин. назад",
|
||||
"lastSeenRelativeMinutes": "был(а) {minutes} мин. назад",
|
||||
"lastSeenJustNow": "был(а) только что",
|
||||
"lastSeenYesterday": "был(а) вчера в {time}",
|
||||
"lastSeenDayBeforeYesterday": "был(а) позавчера в {time}",
|
||||
"noMessages": "Сообщений пока нет",
|
||||
"messageHint": "Сообщение"
|
||||
}
|
||||
85
client-mobile/lib/app.dart
Normal file
85
client-mobile/lib/app.dart
Normal file
@@ -0,0 +1,85 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'features/auth/presentation/bloc/auth_bloc.dart';
|
||||
import 'features/auth/presentation/bloc/auth_event.dart';
|
||||
import 'features/chat/presentation/bloc/chat_bloc.dart';
|
||||
import 'features/chat/presentation/bloc/chat_event.dart';
|
||||
import 'internal/di/injection_container.dart' as di;
|
||||
import 'features/settings/presentation/bloc/settings_bloc.dart';
|
||||
import 'features/settings/presentation/bloc/settings_event.dart';
|
||||
import 'features/settings/presentation/bloc/settings_state.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'internal/router/main_screen.dart';
|
||||
import 'features/auth/presentation/pages/login_page.dart';
|
||||
import 'features/auth/presentation/bloc/auth_state.dart';
|
||||
|
||||
class MessengerApp extends StatelessWidget {
|
||||
const MessengerApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return MultiBlocProvider(
|
||||
providers: [
|
||||
BlocProvider<AuthBloc>(
|
||||
create: (_) => di.sl<AuthBloc>()..add(const AuthEvent.authChecked()),
|
||||
),
|
||||
BlocProvider<ChatBloc>(
|
||||
create: (_) => di.sl<ChatBloc>()..add(const ChatEvent.started()),
|
||||
),
|
||||
BlocProvider<SettingsBloc>(
|
||||
create: (_) => di.sl<SettingsBloc>()..add(const SettingsEvent.started()),
|
||||
),
|
||||
],
|
||||
child: BlocBuilder<SettingsBloc, SettingsState>(
|
||||
builder: (context, state) {
|
||||
final locale = state.maybeWhen(
|
||||
loaded: (_, __, languageCode, ___, ____) => Locale(languageCode),
|
||||
orElse: () => const Locale('ru'),
|
||||
);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'Messenger',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: ThemeMode.system,
|
||||
locale: locale,
|
||||
supportedLocales: const [
|
||||
Locale('ru'),
|
||||
Locale('en'),
|
||||
],
|
||||
localizationsDelegates: const [
|
||||
AppLocalizations.delegate,
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
home: BlocListener<AuthBloc, AuthState>(
|
||||
listener: (context, authState) {
|
||||
authState.maybeWhen(
|
||||
authenticated: (userId) {
|
||||
context.read<ChatBloc>().add(ChatEvent.started(userId: userId));
|
||||
},
|
||||
unauthenticated: () {
|
||||
context.read<ChatBloc>().add(const ChatEvent.cacheCleared());
|
||||
},
|
||||
orElse: () {},
|
||||
);
|
||||
},
|
||||
child: BlocBuilder<AuthBloc, AuthState>(
|
||||
builder: (context, authState) {
|
||||
return authState.maybeWhen(
|
||||
authenticated: (_) => const MainScreen(),
|
||||
orElse: () => const LoginPage(),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
client-mobile/lib/core/constants/app_constants.dart
Normal file
25
client-mobile/lib/core/constants/app_constants.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
class AppConstants {
|
||||
AppConstants._();
|
||||
|
||||
// API
|
||||
static const String apiBaseUrl = 'https://api.messenger.app';
|
||||
static const int apiTimeout = 30;
|
||||
|
||||
// Storage
|
||||
static const String sharedPrefsName = 'messenger_prefs';
|
||||
static const String tokenKey = 'auth_token';
|
||||
static const String refreshTokenKey = 'refresh_token';
|
||||
static const String userIdKey = 'user_id';
|
||||
|
||||
// Pagination
|
||||
static const int pageSize = 20;
|
||||
static const int defaultPageSize = 50;
|
||||
|
||||
// Chat
|
||||
static const int maxMessageLength = 4096;
|
||||
static const int typingIndicatorTimeout = 3000;
|
||||
|
||||
// Media
|
||||
static const int maxImageSize = 10 * 1024 * 1024; // 10MB
|
||||
static const int maxVideoSize = 100 * 1024 * 1024; // 100MB
|
||||
}
|
||||
9
client-mobile/lib/core/constants/storage_keys.dart
Normal file
9
client-mobile/lib/core/constants/storage_keys.dart
Normal file
@@ -0,0 +1,9 @@
|
||||
class StorageKeys {
|
||||
StorageKeys._();
|
||||
|
||||
static const String apiUrl = 'api_url';
|
||||
static const String languageCode = 'language_code';
|
||||
static const String serverConfig = 'server_config';
|
||||
static const String authToken = 'auth_token';
|
||||
static const String refreshToken = 'refresh_token';
|
||||
}
|
||||
22
client-mobile/lib/core/database/database_service.dart
Normal file
22
client-mobile/lib/core/database/database_service.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../../features/chat/data/models/chat_model.dart';
|
||||
import '../../features/chat/data/models/message_model.dart';
|
||||
|
||||
class DatabaseService {
|
||||
late Isar _isar;
|
||||
|
||||
Isar get isar => _isar;
|
||||
|
||||
Future<void> init() async {
|
||||
final dir = await getApplicationDocumentsDirectory();
|
||||
_isar = await Isar.open(
|
||||
[ChatModelSchema, MessageModelSchema],
|
||||
directory: dir.path,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> clearAll() async {
|
||||
await _isar.writeTxn(() => _isar.clear());
|
||||
}
|
||||
}
|
||||
16
client-mobile/lib/core/errors/errors.dart
Normal file
16
client-mobile/lib/core/errors/errors.dart
Normal file
@@ -0,0 +1,16 @@
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
part 'errors.freezed.dart';
|
||||
|
||||
@freezed
|
||||
class AppError with _$AppError {
|
||||
const factory AppError.unknown({String? message}) = UnknownError;
|
||||
const factory AppError.network({String? message}) = NetworkError;
|
||||
const factory AppError.unauthorized({String? message}) = UnauthorizedError;
|
||||
const factory AppError.forbidden({String? message}) = ForbiddenError;
|
||||
const factory AppError.notFound({String? message}) = NotFoundError;
|
||||
const factory AppError.server({int? statusCode, String? message}) = ServerError;
|
||||
const factory AppError.database({String? message}) = DatabaseError;
|
||||
const factory AppError.validation({String? message, Map<String, String>? fieldErrors}) = ValidationError;
|
||||
const factory AppError.parsing({String? message}) = ParsingError;
|
||||
}
|
||||
1814
client-mobile/lib/core/errors/errors.freezed.dart
Normal file
1814
client-mobile/lib/core/errors/errors.freezed.dart
Normal file
File diff suppressed because it is too large
Load Diff
47
client-mobile/lib/core/errors/result.dart
Normal file
47
client-mobile/lib/core/errors/result.dart
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'errors.dart';
|
||||
|
||||
/// A generic class for handling success/failure results
|
||||
class Result<T> {
|
||||
final T? data;
|
||||
final AppError? error;
|
||||
|
||||
const Result.success(this.data) : error = null;
|
||||
const Result.failure(this.error) : data = null;
|
||||
|
||||
bool get isSuccess => error == null;
|
||||
bool get isFailure => error != null;
|
||||
|
||||
R when<R>({
|
||||
required R Function(T data) onSuccess,
|
||||
required R Function(AppError error) onFailure,
|
||||
}) {
|
||||
if (isSuccess) {
|
||||
return onSuccess(data as T);
|
||||
}
|
||||
return onFailure(error!);
|
||||
}
|
||||
|
||||
R fold<R>(
|
||||
R Function(AppError error) onFailure,
|
||||
R Function(T data) onSuccess,
|
||||
) {
|
||||
if (isSuccess) {
|
||||
return onSuccess(data as T);
|
||||
}
|
||||
return onFailure(error!);
|
||||
}
|
||||
|
||||
Result<R> map<R>(R Function(T data) transform) {
|
||||
if (isSuccess) {
|
||||
return Result.success(transform(data as T));
|
||||
}
|
||||
return Result.failure(error!);
|
||||
}
|
||||
|
||||
T getOrThrow() {
|
||||
if (isFailure) {
|
||||
throw error!;
|
||||
}
|
||||
return data as T;
|
||||
}
|
||||
}
|
||||
135
client-mobile/lib/core/network/api_client.dart
Normal file
135
client-mobile/lib/core/network/api_client.dart
Normal file
@@ -0,0 +1,135 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../errors/errors.dart';
|
||||
import '../errors/result.dart';
|
||||
|
||||
class ApiClient {
|
||||
final Dio _dio;
|
||||
|
||||
ApiClient(this._dio);
|
||||
|
||||
Future<Result<T>> get<T>(
|
||||
String path, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.get<T>(
|
||||
path,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
);
|
||||
return Result.success(response.data as T);
|
||||
} on DioException catch (e) {
|
||||
return Result.failure(_handleDioError(e));
|
||||
} catch (e) {
|
||||
return Result.failure(const AppError.unknown(message: 'Unknown error occurred'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<T>> post<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.post<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
);
|
||||
return Result.success(response.data as T);
|
||||
} on DioException catch (e) {
|
||||
return Result.failure(_handleDioError(e));
|
||||
} catch (e) {
|
||||
return Result.failure(const AppError.unknown(message: 'Unknown error occurred'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<T>> put<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.put<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
);
|
||||
return Result.success(response.data as T);
|
||||
} on DioException catch (e) {
|
||||
return Result.failure(_handleDioError(e));
|
||||
} catch (e) {
|
||||
return Result.failure(const AppError.unknown(message: 'Unknown error occurred'));
|
||||
}
|
||||
}
|
||||
|
||||
Future<Result<T>> delete<T>(
|
||||
String path, {
|
||||
dynamic data,
|
||||
Map<String, dynamic>? queryParameters,
|
||||
Options? options,
|
||||
}) async {
|
||||
try {
|
||||
final response = await _dio.delete<T>(
|
||||
path,
|
||||
data: data,
|
||||
queryParameters: queryParameters,
|
||||
options: options,
|
||||
);
|
||||
return Result.success(response.data as T);
|
||||
} on DioException catch (e) {
|
||||
return Result.failure(_handleDioError(e));
|
||||
} catch (e) {
|
||||
return Result.failure(const AppError.unknown(message: 'Unknown error occurred'));
|
||||
}
|
||||
}
|
||||
|
||||
AppError _handleDioError(DioException error) {
|
||||
switch (error.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.sendTimeout:
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return const AppError.network(message: 'Connection timeout');
|
||||
case DioExceptionType.connectionError:
|
||||
return const AppError.network(message: 'No internet connection');
|
||||
case DioExceptionType.badResponse:
|
||||
final statusCode = error.response?.statusCode;
|
||||
switch (statusCode) {
|
||||
case 401:
|
||||
return const AppError.unauthorized();
|
||||
case 403:
|
||||
return const AppError.forbidden();
|
||||
case 404:
|
||||
return const AppError.notFound();
|
||||
case 422:
|
||||
return AppError.validation(fieldErrors: _extractValidationErrors(error.response?.data));
|
||||
case int s when s >= 500:
|
||||
return AppError.server(statusCode: statusCode, message: 'Server error');
|
||||
default:
|
||||
return AppError.server(statusCode: statusCode, message: 'Request failed');
|
||||
}
|
||||
case DioExceptionType.cancel:
|
||||
return const AppError.unknown(message: 'Request cancelled');
|
||||
default:
|
||||
return const AppError.unknown();
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, String>? _extractValidationErrors(dynamic data) {
|
||||
if (data is Map<String, dynamic> && data.containsKey('errors')) {
|
||||
final errors = data['errors'] as Map<String, dynamic>;
|
||||
return errors.map((key, value) {
|
||||
if (value is List && value.isNotEmpty) {
|
||||
return MapEntry(key, value.first.toString());
|
||||
}
|
||||
return MapEntry(key, 'Invalid');
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
190
client-mobile/lib/core/network/signalr_service.dart
Normal file
190
client-mobile/lib/core/network/signalr_service.dart
Normal file
@@ -0,0 +1,190 @@
|
||||
import 'dart:async';
|
||||
import 'package:signalr_netcore/signalr_client.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../features/chat/domain/entities/message.dart';
|
||||
|
||||
class SignalRService {
|
||||
final SharedPreferences prefs;
|
||||
HubConnection? _hubConnection;
|
||||
final _messageController = StreamController<Message>.broadcast();
|
||||
final _typingController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _statusController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
final _readReceiptController = StreamController<Map<String, dynamic>>.broadcast();
|
||||
|
||||
Stream<Message> get messages => _messageController.stream;
|
||||
Stream<Map<String, dynamic>> get typingUpdates => _typingController.stream;
|
||||
Stream<Map<String, dynamic>> get statusUpdates => _statusController.stream;
|
||||
Stream<Map<String, dynamic>> get readReceipts => _readReceiptController.stream;
|
||||
|
||||
SignalRService(this.prefs);
|
||||
|
||||
Future<void> init() async {
|
||||
if (_hubConnection?.state == HubConnectionState.Connected ||
|
||||
_hubConnection?.state == HubConnectionState.Connecting) {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] Connection already active or connecting. Skipping init.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (_hubConnection != null) {
|
||||
await stop();
|
||||
}
|
||||
final apiUrl = prefs.getString('api_url') ?? 'https://api.messenger.app';
|
||||
final token = prefs.getString('access_token');
|
||||
|
||||
if (token == null) return;
|
||||
|
||||
final hubUrl = '${apiUrl.replaceAll(RegExp(r'/+$'), '')}/hubs/chat';
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] Initializing connection to: $hubUrl');
|
||||
|
||||
_hubConnection = HubConnectionBuilder()
|
||||
.withUrl(hubUrl, options: HttpConnectionOptions(
|
||||
accessTokenFactory: () async => token,
|
||||
))
|
||||
.withAutomaticReconnect()
|
||||
.build();
|
||||
|
||||
_hubConnection?.onclose(({error}) {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] Connection closed. Error: $error');
|
||||
});
|
||||
|
||||
_hubConnection?.onreconnecting(({error}) {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] Reconnecting... Error: $error');
|
||||
});
|
||||
|
||||
_hubConnection?.onreconnected(({connectionId}) {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] Reconnected! ID: $connectionId');
|
||||
});
|
||||
|
||||
// Event names verified from backend source code (MessageNotifier.cs)
|
||||
_hubConnection?.on('new_message', _handleReceiveMessage);
|
||||
_hubConnection?.on('new_message_received', _handleReceiveMessage); // Optional fallback
|
||||
|
||||
_hubConnection?.on('user_typing', _handleUserTyping);
|
||||
_hubConnection?.on('user_stopped_typing', _handleUserStoppedTyping);
|
||||
_hubConnection?.on('user_online', (args) {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] user_online: $args');
|
||||
if (args != null && args.isNotEmpty) {
|
||||
final data = args[0] as Map<String, dynamic>;
|
||||
_statusController.add({'userId': data['userId'] ?? data['UserId'], 'isOnline': true});
|
||||
}
|
||||
});
|
||||
_hubConnection?.on('user_offline', (args) {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] user_offline: $args');
|
||||
if (args != null && args.isNotEmpty) {
|
||||
final data = args[0] as Map<String, dynamic>;
|
||||
_statusController.add({
|
||||
'userId': data['userId'] ?? data['UserId'],
|
||||
'isOnline': false,
|
||||
'lastSeen': data['lastSeen'] ?? data['LastSeen']
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
_hubConnection?.on('messages_read', (args) {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] messages_read: $args');
|
||||
if (args != null && args.isNotEmpty) {
|
||||
final data = args[0] as Map<String, dynamic>;
|
||||
_readReceiptController.add({
|
||||
'chatId': data['chatId'] ?? data['ChatId'],
|
||||
'userId': data['userId'] ?? data['UserId'],
|
||||
'lastReadMessageId': data['lastReadMessageId'] ?? data['LastReadMessageId'],
|
||||
'lastReadSequenceId': data['lastReadSequenceId'] ?? data['LastReadSequenceId'],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] Starting connection...');
|
||||
await _hubConnection?.start();
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] Connection started successfully. State: ${_hubConnection?.state}');
|
||||
} catch (e) {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] Connection failed: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> markMessagesAsRead(String chatId, String lastReadMessageId, int lastReadSequenceId) async {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] markMessagesAsRead: chatId=$chatId, msgId=$lastReadMessageId, seqId=$lastReadSequenceId');
|
||||
await _hubConnection?.invoke('read_messages', args: [
|
||||
{
|
||||
'ChatId': chatId,
|
||||
'LastReadMessageId': lastReadMessageId,
|
||||
'LastReadSequenceId': lastReadSequenceId,
|
||||
}
|
||||
]);
|
||||
}
|
||||
|
||||
void _handleUserTyping(List<dynamic>? arguments) {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] _handleUserTyping: $arguments');
|
||||
if (arguments != null && arguments.isNotEmpty) {
|
||||
final data = arguments[0] as Map<String, dynamic>;
|
||||
_typingController.add({'chatId': data['ChatId'] ?? data['chatId'], 'userId': data['UserId'] ?? data['userId'], 'isTyping': true});
|
||||
}
|
||||
}
|
||||
|
||||
void _handleUserStoppedTyping(List<dynamic>? arguments) {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] _handleUserStoppedTyping: $arguments');
|
||||
if (arguments != null && arguments.isNotEmpty) {
|
||||
final data = arguments[0] as Map<String, dynamic>;
|
||||
_typingController.add({'chatId': data['ChatId'] ?? data['chatId'], 'userId': data['UserId'] ?? data['userId'], 'isTyping': false});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> sendTypingStatus(String chatId, bool isTyping) async {
|
||||
if (isTyping) {
|
||||
await _hubConnection?.invoke('typing_start', args: [chatId]);
|
||||
} else {
|
||||
await _hubConnection?.invoke('typing_stop', args: [chatId]);
|
||||
}
|
||||
}
|
||||
|
||||
void _handleReceiveMessage(List<dynamic>? arguments) {
|
||||
// ignore: avoid_print
|
||||
print('[SignalR] _handleReceiveMessage: $arguments');
|
||||
if (arguments != null && arguments.isNotEmpty) {
|
||||
final data = arguments[0] as Map<String, dynamic>;
|
||||
final message = Message(
|
||||
id: (data['id'] ?? data['Id'] ?? '').toString(),
|
||||
chatId: (data['chatId'] ?? data['ChatId'] ?? '').toString(),
|
||||
senderId: (data['senderId'] ?? data['SenderId'] ?? data['userId'] ?? data['UserId'] ?? '').toString(),
|
||||
content: data['content'] ?? data['Content'] ?? '',
|
||||
messageType: (data['type'] ?? data['Type'] ?? 'text').toString().toLowerCase(),
|
||||
sequenceId: int.tryParse((data['sequenceId'] ?? data['SequenceId'] ?? '0').toString()) ?? 0,
|
||||
createdAt: _parseDateTime(data['createdAt'] ?? data['CreatedAt']),
|
||||
updatedAt: _parseDateTime(data['updatedAt'] ?? data['UpdatedAt']),
|
||||
status: (data['isRead'] ?? data['IsRead'] ?? false) ? MessageStatus.read : MessageStatus.delivered,
|
||||
media: (data['media'] ?? data['Media']) is Map<String, dynamic>
|
||||
? (data['media'] ?? data['Media']) as Map<String, dynamic>
|
||||
: null,
|
||||
);
|
||||
_messageController.add(message);
|
||||
}
|
||||
}
|
||||
|
||||
DateTime? _parseDateTime(String? dateStr) {
|
||||
if (dateStr == null) return null;
|
||||
String normalized = dateStr;
|
||||
if (!normalized.endsWith('Z') && !normalized.contains('+')) {
|
||||
normalized += 'Z';
|
||||
}
|
||||
return DateTime.parse(normalized).toLocal();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
await _hubConnection?.stop();
|
||||
_hubConnection = null;
|
||||
}
|
||||
}
|
||||
134
client-mobile/lib/core/theme/app_theme.dart
Normal file
134
client-mobile/lib/core/theme/app_theme.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static const _primaryColor = Color(0xFF2481CC);
|
||||
static const _primaryContainerColor = Color(0xFFD3E4FD);
|
||||
static const _secondaryColor = Color(0xFF5F6AC4);
|
||||
static const _tertiaryColor = Color(0xFFFFD700);
|
||||
|
||||
static const _lightSurfaceColor = Color(0xFFFFFBFE);
|
||||
static const _darkSurfaceColor = Color(0xFF1C1B1F);
|
||||
|
||||
static ThemeData get lightTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.light,
|
||||
colorScheme: const ColorScheme.light(
|
||||
primary: _primaryColor,
|
||||
primaryContainer: _primaryContainerColor,
|
||||
secondary: _secondaryColor,
|
||||
tertiary: _tertiaryColor,
|
||||
surface: _lightSurfaceColor,
|
||||
onPrimary: Colors.white,
|
||||
onSecondary: Colors.white,
|
||||
onSurface: Colors.black87,
|
||||
error: Color(0xFFBA1A1A),
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
backgroundColor: _primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: Colors.white,
|
||||
selectedItemColor: _primaryColor,
|
||||
unselectedItemColor: Colors.grey,
|
||||
elevation: 8,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.grey[100],
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static ThemeData get darkTheme {
|
||||
return ThemeData(
|
||||
useMaterial3: true,
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: const ColorScheme.dark(
|
||||
primary: _primaryColor,
|
||||
primaryContainer: _primaryContainerColor,
|
||||
secondary: _secondaryColor,
|
||||
tertiary: _tertiaryColor,
|
||||
surface: _darkSurfaceColor,
|
||||
onPrimary: Colors.white,
|
||||
onSecondary: Colors.white,
|
||||
onSurface: const Color(0xFFE6E1E5),
|
||||
error: Color(0xFFFFB4AB),
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: true,
|
||||
elevation: 0,
|
||||
backgroundColor: Color(0xFF1F1F1F),
|
||||
foregroundColor: Colors.white,
|
||||
systemOverlayStyle: SystemUiOverlayStyle.light,
|
||||
),
|
||||
bottomNavigationBarTheme: const BottomNavigationBarThemeData(
|
||||
type: BottomNavigationBarType.fixed,
|
||||
backgroundColor: Color(0xFF1F1F1F),
|
||||
selectedItemColor: _primaryColor,
|
||||
unselectedItemColor: Colors.grey,
|
||||
elevation: 8,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFF2D2D2D),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
abstract class AuthLocalDataSource {
|
||||
Future<String?> getToken();
|
||||
String? getTokenSync();
|
||||
Future<void> saveToken(String token);
|
||||
Future<String?> getRefreshToken();
|
||||
Future<void> saveRefreshToken(String token);
|
||||
Future<void> removeToken();
|
||||
Future<String?> getUserId();
|
||||
Future<void> saveUserId(String userId);
|
||||
Future<void> removeUserId();
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'auth_local_datasource.dart';
|
||||
|
||||
class AuthLocalDataSourceImpl implements AuthLocalDataSource {
|
||||
final SharedPreferences sharedPreferences;
|
||||
|
||||
static const String _accessTokenKey = 'access_token';
|
||||
static const String _refreshTokenKey = 'refresh_token';
|
||||
static const String _userIdKey = 'user_id';
|
||||
|
||||
AuthLocalDataSourceImpl(this.sharedPreferences);
|
||||
|
||||
@override
|
||||
Future<String?> getToken() async {
|
||||
return sharedPreferences.getString(_accessTokenKey);
|
||||
}
|
||||
|
||||
@override
|
||||
String? getTokenSync() {
|
||||
return sharedPreferences.getString(_accessTokenKey);
|
||||
}
|
||||
|
||||
Future<String?> getRefreshToken() async {
|
||||
return sharedPreferences.getString(_refreshTokenKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveToken(String token) async {
|
||||
// ignore: avoid_print
|
||||
print('[DEBUG] Saving new access token (starts with: ${token.substring(0, 10)}...)');
|
||||
await sharedPreferences.setString(_accessTokenKey, token);
|
||||
}
|
||||
|
||||
Future<void> saveRefreshToken(String token) async {
|
||||
await sharedPreferences.setString(_refreshTokenKey, token);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeToken() async {
|
||||
await sharedPreferences.remove(_accessTokenKey);
|
||||
await sharedPreferences.remove(_refreshTokenKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<String?> getUserId() async {
|
||||
return sharedPreferences.getString(_userIdKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveUserId(String userId) async {
|
||||
await sharedPreferences.setString(_userIdKey, userId);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeUserId() async {
|
||||
await sharedPreferences.remove(_userIdKey);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import '../../../../core/errors/result.dart';
|
||||
import '../../domain/entities/user.dart';
|
||||
import '../models/auth_response.dart';
|
||||
|
||||
abstract class AuthRemoteDataSource {
|
||||
Future<Result<AuthResponse>> login(String username, String password);
|
||||
Future<Result<AuthResponse>> register(String username, String password, String name);
|
||||
Future<Result<void>> logout();
|
||||
Future<Result<User>> getCurrentUser();
|
||||
Future<Result<void>> refreshToken();
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import '../../../../core/errors/errors.dart';
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../core/errors/result.dart';
|
||||
import '../../domain/entities/user.dart';
|
||||
import '../models/auth_response.dart';
|
||||
import 'auth_remote_datasource.dart';
|
||||
|
||||
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||
final ApiClient apiClient;
|
||||
|
||||
AuthRemoteDataSourceImpl(this.apiClient);
|
||||
|
||||
@override
|
||||
Future<Result<AuthResponse>> login(String username, String password) async {
|
||||
final result = await apiClient.post<Map<String, dynamic>>(
|
||||
'/api/auth/login',
|
||||
data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
},
|
||||
);
|
||||
|
||||
return result.fold(
|
||||
(error) => Result.failure(error),
|
||||
(data) {
|
||||
try {
|
||||
return Result.success(AuthResponse.fromJson(data));
|
||||
} catch (e) {
|
||||
return Result.failure(AppError.parsing(message: 'Invalid server response format: $e'));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<AuthResponse>> register(String username, String password, String name) async {
|
||||
final result = await apiClient.post<Map<String, dynamic>>(
|
||||
'/api/auth/register',
|
||||
data: {
|
||||
'username': username,
|
||||
'password': password,
|
||||
'displayName': name,
|
||||
},
|
||||
);
|
||||
|
||||
return result.fold(
|
||||
(error) => Result.failure(error),
|
||||
(data) {
|
||||
try {
|
||||
return Result.success(AuthResponse.fromJson(data));
|
||||
} catch (e) {
|
||||
return Result.failure(AppError.parsing(message: 'Invalid server response format: $e'));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> logout() async {
|
||||
return await apiClient.post('/api/auth/logout');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<User>> getCurrentUser() async {
|
||||
final result = await apiClient.get<Map<String, dynamic>>('/api/auth/me');
|
||||
return result.fold(
|
||||
(error) => Result.failure(error),
|
||||
(data) {
|
||||
try {
|
||||
return Result.success(User(
|
||||
id: data['userId'] as String,
|
||||
email: data['username'] as String,
|
||||
name: data['displayName'] as String,
|
||||
avatarUrl: data['avatar'] as String?,
|
||||
));
|
||||
} catch (e) {
|
||||
return Result.failure(AppError.parsing(message: 'Invalid user data format: $e'));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Result<void>> refreshToken() async {
|
||||
return await apiClient.post('/api/auth/refresh');
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user