Compare commits
26
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f61071ed | ||
|
|
e8c9a55fe9 | ||
|
|
bccbf12a11 | ||
|
|
c8b0384392 | ||
|
|
5245e8b7ae | ||
|
|
b346372555 | ||
|
|
d2e6eb578a | ||
|
|
ae710c3d43 | ||
|
|
600e43eec5 | ||
|
|
7b251686b2 | ||
|
|
00682b2977 | ||
|
|
e05572fc3f | ||
|
|
c264b7df27 | ||
|
|
56f75ae32b | ||
|
|
ca9cf27716 | ||
|
|
7225e3272e | ||
|
|
86ae06beb6 | ||
|
|
454f70f716 | ||
|
|
e700609d30 | ||
|
|
88f39aa51f | ||
|
|
c3dbbaa7b8 | ||
|
|
2eb4f48ca0 | ||
|
|
0c1adaab6c | ||
|
|
a52726d0e6 | ||
|
|
d812a7a40c | ||
|
|
c8b4fed25a |
-10
@@ -87,13 +87,3 @@ 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/
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using FluentAssertions;
|
||||
using Knot.Modules.Profiles.Application.Profiles.UpdateProfile;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Knot.Modules.Profiles.Application.Abstractions;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
namespace Knot.Modules.Profiles.UnitTests;
|
||||
|
||||
@@ -25,14 +25,11 @@ public class UpdateProfileCommandHandlerTests
|
||||
[Fact]
|
||||
public async Task Handle_ShouldReturnError_WhenProfileNotFound()
|
||||
{
|
||||
// Arrange
|
||||
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null);
|
||||
_profileRepository.GetByIdAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((ProfileDocument?)null);
|
||||
var command = new UpdateProfileCommand(Guid.NewGuid(), "FirstName", "Bio", null, null);
|
||||
_profileRepository.GetAsync(command.UserId, Arg.Any<CancellationToken>()).Returns((UserProfileDto?)null);
|
||||
|
||||
// Act
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
result.IsFailure.Should().BeTrue();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ public interface IJwtTokenProvider
|
||||
{
|
||||
string GenerateAccessToken(Guid userId, string username);
|
||||
string GenerateRefreshToken();
|
||||
DateTime GetRefreshTokenExpiry();
|
||||
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||
string Generate(Domain.UserContract user);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,8 @@ public static class AuthErrors
|
||||
public static Error IdentityInvalidCredentials => new("Auth.InvalidCredentials", "Invalid credentials");
|
||||
public static Error IdentityRegistrationDisabled => new("Auth.RegistrationDisabled", "Registration is disabled");
|
||||
public static Error IdentityUsernameNotUnique => new("Auth.UsernameNotUnique", "Username is already taken");
|
||||
public static Error IdentityRegistrationFailed => new("Auth.RegistrationFailed", "Failed to register user");
|
||||
public static Error RefreshTokenExpired => new("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again.");
|
||||
public static Error UserNotFound => new("Auth.UserNotFound", "User not found");
|
||||
public static Error PasswordConfirmationMismatch => new("Auth.PasswordConfirmationMismatch", "Passwords do not match");
|
||||
public static Error PasswordTooShort => new("Auth.PasswordTooShort", "Password must be at least 8 characters");
|
||||
public static Error OldPasswordInvalid => new("Auth.OldPasswordInvalid", "Current password is incorrect");
|
||||
}
|
||||
|
||||
@@ -19,12 +19,4 @@ public class UserContract
|
||||
public bool IsExternal { get; set; }
|
||||
public string? Domain { get; set; }
|
||||
public DateTime? LastSeen { get; set; }
|
||||
public string? RefreshToken { get; set; }
|
||||
public DateTime? RefreshTokenExpiry { get; set; }
|
||||
|
||||
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||
{
|
||||
RefreshToken = refreshToken;
|
||||
RefreshTokenExpiry = expiry;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,4 +9,8 @@ public static class ProfilesErrors
|
||||
public static Error AvatarNotFound => new("Profiles.AvatarNotFound", "Avatar not found");
|
||||
public static Error InvalidAvatarFormat => new("Profiles.InvalidAvatarFormat", "Invalid avatar format");
|
||||
public static Error AvatarUploadFailed => new("Profiles.AvatarUploadFailed", "Avatar upload failed");
|
||||
public static Error BioTooLong => new("Profiles.BioTooLong", "Bio must be 200 characters or less");
|
||||
public static Error StatusTextTooLong => new("Profiles.StatusTextTooLong", "Status text must be 50 characters or less");
|
||||
public static Error StatusEmpty => new("Statuses.Empty", "Status emoji or text is required");
|
||||
public static Error InvalidPreset => new("Statuses.InvalidPreset", "Unknown status preset");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
public sealed class StatusPresetDto
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Emoji { get; set; } = "";
|
||||
public string TextRu { get; set; } = "";
|
||||
public string TextEn { get; set; } = "";
|
||||
}
|
||||
@@ -12,5 +12,13 @@ public class UserProfileDto
|
||||
public DateTime? LastSeen { get; set; }
|
||||
public DateTime? Birthday { get; set; }
|
||||
public bool IsPremium { get; set; }
|
||||
public UserStatusDto? Status { get; set; }
|
||||
|
||||
public Guid? CurrentStatusId { get; set; }
|
||||
|
||||
public string? StatusText { get; set; }
|
||||
public string? StatusEmoji { get; set; }
|
||||
public DateTime? StatusExpiresAt { get; set; }
|
||||
public bool IsInvisible { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
public sealed class UserStatusDto
|
||||
{
|
||||
public Guid Id { get; set; }
|
||||
public string Type { get; set; } = "Custom";
|
||||
public string Emoji { get; set; } = "";
|
||||
public string Text { get; set; } = "";
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Contracts.Profiles.Domain;
|
||||
|
||||
public interface IProfileStatusWriter
|
||||
{
|
||||
Task<Result<UserProfileDto>> SetCustomAsync(Guid userId, string emoji, string text, DateTime? expiresAt, string? presetKey, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<Result<UserProfileDto>> ClearAsync(Guid userId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
namespace Knot.Contracts.Profiles.Domain;
|
||||
|
||||
public interface IUserStatusRepository
|
||||
{
|
||||
Task<UserStatusDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IReadOnlyDictionary<Guid, UserStatusDto>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default);
|
||||
|
||||
Task InsertAsync(UserStatusDto dto, Guid userId, CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteAsync(Guid id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -250,6 +250,7 @@ app.MapStoriesEndpoints();
|
||||
app.MapContactsEndpoints();
|
||||
app.MapSettingsEndpoints();
|
||||
app.MapProfilesEndpoints();
|
||||
app.MapStatusesEndpoints();
|
||||
app.MapFederationEndpoints();
|
||||
app.MapKlipyEndpoints();
|
||||
app.MapChatsEndpoints();
|
||||
|
||||
@@ -13,8 +13,7 @@
|
||||
"Secret": "knot_super_secret_key_1234567890_knot",
|
||||
"Issuer": "Knot",
|
||||
"Audience": "KnotUsers",
|
||||
"ExpiryInMinutes": 1440,
|
||||
"RefreshExpiryInDays": 30
|
||||
"ExpiryInMinutes": 1440
|
||||
},
|
||||
"KNOT_MASTER_ENCRYPTION_KEY": "knot_super_secret_key_1234567890_knot"
|
||||
}
|
||||
@@ -5,8 +5,5 @@ namespace Knot.Modules.Auth.Application.Abstractions;
|
||||
public interface IJwtTokenProvider
|
||||
{
|
||||
string Generate(User user);
|
||||
string Generate(Guid userId, string username, string displayName, string? avatar);
|
||||
string GenerateRefreshToken();
|
||||
DateTime GetRefreshTokenExpiry();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
using BCrypt.Net;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Modules.Auth.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.ChangePassword;
|
||||
|
||||
public sealed record ChangePasswordCommand(
|
||||
Guid UserId,
|
||||
string OldPassword,
|
||||
string NewPassword,
|
||||
string ConfirmPassword) : ICommand;
|
||||
|
||||
internal sealed class ChangePasswordCommandHandler : ICommandHandler<ChangePasswordCommand>
|
||||
{
|
||||
private readonly IAuthDbContext _dbContext;
|
||||
|
||||
public ChangePasswordCommandHandler(IAuthDbContext dbContext)
|
||||
{
|
||||
_dbContext = dbContext;
|
||||
}
|
||||
|
||||
public async Task<Result> Handle(ChangePasswordCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.NewPassword != request.ConfirmPassword)
|
||||
return Result.Failure(AuthErrors.PasswordConfirmationMismatch);
|
||||
|
||||
if (request.NewPassword.Length < 8)
|
||||
return Result.Failure(AuthErrors.PasswordTooShort);
|
||||
|
||||
var user = await _dbContext.Set<User>()
|
||||
.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
|
||||
if (user is null)
|
||||
return Result.Failure(AuthErrors.UserNotFound);
|
||||
|
||||
if (!BCrypt.Net.BCrypt.Verify(request.OldPassword, user.PasswordHash))
|
||||
return Result.Failure(AuthErrors.OldPasswordInvalid);
|
||||
|
||||
user.ChangePassword(BCrypt.Net.BCrypt.HashPassword(request.NewPassword));
|
||||
user.SetRefreshToken(null);
|
||||
|
||||
await _dbContext.SaveChangesAsync(cancellationToken);
|
||||
return Result.Success();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,4 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using System.Security.Claims;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
|
||||
@@ -11,12 +9,10 @@ public sealed record GetMeQuery(Guid UserId) : IQuery<AuthResponseDto>;
|
||||
internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IJwtTokenProvider _tokenProvider;
|
||||
|
||||
public GetMeQueryHandler(IUserRepository userRepository, IJwtTokenProvider tokenProvider)
|
||||
public GetMeQueryHandler(IUserRepository userRepository)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
public async Task<Result<AuthResponseDto>> Handle(GetMeQuery request, CancellationToken cancellationToken)
|
||||
@@ -27,18 +23,9 @@ internal sealed class GetMeQueryHandler : IQueryHandler<GetMeQuery, AuthResponse
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.UserNotFound);
|
||||
}
|
||||
|
||||
// Check if access token needs to be refreshed (less than 1 hour remaining)
|
||||
string? newAccessToken = null;
|
||||
|
||||
// We can't directly check the current token's expiry here, but we can
|
||||
// always issue a new token if the user is authenticated
|
||||
// For now, let's issue a new token on every request (simplified approach)
|
||||
// A better approach would be to parse the incoming token and check expiry
|
||||
newAccessToken = _tokenProvider.Generate(user);
|
||||
|
||||
var response = new AuthResponseDto
|
||||
{
|
||||
AccessToken = newAccessToken,
|
||||
AccessToken = string.Empty,
|
||||
RefreshToken = string.Empty,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
|
||||
@@ -5,6 +5,9 @@ using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.Login;
|
||||
|
||||
/// <summary>
|
||||
/// ������� ��� ����� ������������. ���������� AuthResponseDto.
|
||||
/// </summary>
|
||||
public sealed record LoginUserCommand(string Username, string Password) : ICommand<AuthResponseDto>;
|
||||
|
||||
public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand, AuthResponseDto>
|
||||
@@ -28,21 +31,14 @@ public sealed class LoginUserCommandHandler : ICommandHandler<LoginUserCommand,
|
||||
}
|
||||
|
||||
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||
string refreshToken = _tokenProvider.GenerateRefreshToken();
|
||||
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||
|
||||
// Save refresh token to database
|
||||
user.SetRefreshToken(refreshToken, refreshExpiry);
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
return Result.Success(new AuthResponseDto
|
||||
{
|
||||
AccessToken = token,
|
||||
RefreshToken = refreshToken,
|
||||
RefreshToken = string.Empty,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
DisplayName = user.DisplayName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||
|
||||
public record RefreshTokenCommand(string RefreshToken) : ICommand<AuthResponseDto>;
|
||||
@@ -1,65 +0,0 @@
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Contracts.Auth.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||
|
||||
internal sealed class RefreshTokenCommandHandler : ICommandHandler<RefreshTokenCommand, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly IJwtTokenProvider _tokenProvider;
|
||||
|
||||
public RefreshTokenCommandHandler(
|
||||
IUserRepository userRepository,
|
||||
IJwtTokenProvider tokenProvider)
|
||||
{
|
||||
_userRepository = userRepository;
|
||||
_tokenProvider = tokenProvider;
|
||||
}
|
||||
|
||||
public async Task<Result<AuthResponseDto>> Handle(RefreshTokenCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(request.RefreshToken))
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(
|
||||
new Error("Auth.InvalidRefreshToken", "Refresh token is required"));
|
||||
}
|
||||
|
||||
var user = await _userRepository.GetByRefreshTokenAsync(request.RefreshToken, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(
|
||||
new Error("Auth.InvalidRefreshToken", "Invalid or expired refresh token"));
|
||||
}
|
||||
|
||||
// Check if refresh token has expired
|
||||
if (user.RefreshTokenExpiry.HasValue && user.RefreshTokenExpiry.Value < DateTime.UtcNow)
|
||||
{
|
||||
// Clear expired refresh token
|
||||
user.SetRefreshToken(null, null);
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
return Result.Failure<AuthResponseDto>(
|
||||
new Error("Auth.RefreshTokenExpired", "Refresh token has expired. Please login again."));
|
||||
}
|
||||
|
||||
var newAccessToken = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||
|
||||
var newRefreshToken = _tokenProvider.GenerateRefreshToken();
|
||||
var newRefreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||
user.SetRefreshToken(newRefreshToken, newRefreshExpiry);
|
||||
await _userRepository.UpdateAsync(user, cancellationToken);
|
||||
|
||||
return Result.Success(new AuthResponseDto
|
||||
{
|
||||
AccessToken = newAccessToken,
|
||||
RefreshToken = newRefreshToken,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
DisplayName = user.DisplayName,
|
||||
Avatar = user.Avatar
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using BCrypt.Net;
|
||||
using BCrypt.Net;
|
||||
using Knot.Contracts.Auth.Application.Abstractions;
|
||||
using Knot.Contracts.Auth.Application.Auth.DTOs;
|
||||
using Knot.Contracts.Settings.Application.Abstractions;
|
||||
@@ -8,6 +9,9 @@ using Knot.Shared.Kernel;
|
||||
|
||||
namespace Knot.Modules.Auth.Application.Users.Register;
|
||||
|
||||
/// <summary>
|
||||
/// ������� ��� ����������� ������ ������������.
|
||||
/// </summary>
|
||||
public sealed record RegisterUserCommand(
|
||||
string Username,
|
||||
string Password,
|
||||
@@ -15,6 +19,9 @@ public sealed record RegisterUserCommand(
|
||||
string? Email,
|
||||
string? Bio) : ICommand<AuthResponseDto>;
|
||||
|
||||
/// <summary>
|
||||
/// ���������� ������� �����������.
|
||||
/// </summary>
|
||||
internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserCommand, AuthResponseDto>
|
||||
{
|
||||
private readonly IUserRepository _userRepository;
|
||||
@@ -41,13 +48,16 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationDisabled);
|
||||
}
|
||||
|
||||
// 1. �������� ������������ username
|
||||
if (!await _userRepository.IsUsernameUniqueAsync(request.Username, cancellationToken))
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityUsernameNotUnique);
|
||||
}
|
||||
|
||||
// 2. ����������� ������
|
||||
string passwordHash = BCrypt.Net.BCrypt.HashPassword(request.Password);
|
||||
|
||||
// 3. �������� ��������
|
||||
var user = User.Create(
|
||||
request.Username,
|
||||
passwordHash,
|
||||
@@ -55,33 +65,21 @@ internal sealed class RegisterUserCommandHandler : ICommandHandler<RegisterUserC
|
||||
request.Email,
|
||||
request.Bio);
|
||||
|
||||
var repoImpl = _userRepository as Infrastructure.Persistence.UserRepository;
|
||||
repoImpl?.Add(user);
|
||||
// 4. ���������� - ���������� ����� � Domain User
|
||||
var repoWithDomainUserAdd = _userRepository as Infrastructure.Persistence.UserRepository;
|
||||
repoWithDomainUserAdd?.Add(user);
|
||||
|
||||
await _unitOfWork.SaveChangesAsync(cancellationToken);
|
||||
|
||||
// Get the saved user as contract
|
||||
var userContract = await _userRepository.GetByUsernameAsync(request.Username, cancellationToken);
|
||||
if (userContract == null)
|
||||
{
|
||||
return Result.Failure<AuthResponseDto>(AuthErrors.IdentityRegistrationFailed);
|
||||
}
|
||||
|
||||
string token = _tokenProvider.Generate(userContract.Id, userContract.Username, userContract.DisplayName, userContract.Avatar);
|
||||
string refreshToken = _tokenProvider.GenerateRefreshToken();
|
||||
DateTime refreshExpiry = _tokenProvider.GetRefreshTokenExpiry();
|
||||
|
||||
userContract.SetRefreshToken(refreshToken, refreshExpiry);
|
||||
await _userRepository.UpdateAsync(userContract, cancellationToken);
|
||||
string token = _tokenProvider.Generate(user.Id, user.Username, user.DisplayName, user.Avatar);
|
||||
|
||||
return Result.Success(new AuthResponseDto
|
||||
{
|
||||
AccessToken = token,
|
||||
RefreshToken = refreshToken,
|
||||
UserId = userContract.Id,
|
||||
Username = userContract.Username,
|
||||
DisplayName = userContract.DisplayName
|
||||
RefreshToken = string.Empty,
|
||||
UserId = user.Id,
|
||||
Username = user.Username,
|
||||
DisplayName = user.DisplayName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ public sealed class User : AggregateRoot<Guid>
|
||||
public bool IsBanned { get; private set; }
|
||||
public string? PhoneNumber { get; private set; }
|
||||
public string? RefreshToken { get; private set; }
|
||||
public DateTime? RefreshTokenExpiry { get; private set; }
|
||||
public DateTime? BannedUntil { get; private set; }
|
||||
|
||||
public void Ban() {
|
||||
@@ -49,10 +48,9 @@ public sealed class User : AggregateRoot<Guid>
|
||||
PhoneNumber = phoneNumber;
|
||||
}
|
||||
|
||||
public void SetRefreshToken(string? refreshToken, DateTime? expiry = null)
|
||||
public void SetRefreshToken(string? refreshToken)
|
||||
{
|
||||
RefreshToken = refreshToken;
|
||||
RefreshTokenExpiry = expiry;
|
||||
}
|
||||
|
||||
public void SetBannedUntil(DateTime? bannedUntil)
|
||||
@@ -81,8 +79,6 @@ public sealed class User : AggregateRoot<Guid>
|
||||
BannedUntil = contract.BannedUntil;
|
||||
SetOnline(contract.IsOnline, contract.LastSeen);
|
||||
UserDomain = contract.Domain;
|
||||
RefreshToken = contract.RefreshToken;
|
||||
RefreshTokenExpiry = contract.RefreshTokenExpiry;
|
||||
}
|
||||
|
||||
private User(Guid id, string username, string passwordHash, string displayName, string? email, string? bio = null)
|
||||
@@ -185,9 +181,7 @@ public sealed class User : AggregateRoot<Guid>
|
||||
IsOnline = IsOnline,
|
||||
IsExternal = IsExternal,
|
||||
Domain = _domain,
|
||||
LastSeen = LastSeen,
|
||||
RefreshToken = RefreshToken,
|
||||
RefreshTokenExpiry = RefreshTokenExpiry
|
||||
LastSeen = LastSeen
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,12 +50,6 @@ internal sealed class JwtTokenProvider : IJwtTokenProvider
|
||||
return Convert.ToBase64String(randomBytes);
|
||||
}
|
||||
|
||||
public DateTime GetRefreshTokenExpiry()
|
||||
{
|
||||
var expiryInDays = int.Parse(_configuration["Jwt:RefreshExpiryInDays"] ?? "30");
|
||||
return DateTime.UtcNow.AddDays(expiryInDays);
|
||||
}
|
||||
|
||||
public string Generate(Guid userId, string username, string displayName, string? avatar)
|
||||
{
|
||||
var claims = new Claim[]
|
||||
|
||||
-100
@@ -1,100 +0,0 @@
|
||||
using System;
|
||||
using Knot.Modules.Auth.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Metadata;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Auth.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
[DbContext(typeof(AuthDbContext))]
|
||||
[Migration("20270419220000_AddRefreshTokenExpiry")]
|
||||
partial class AddRefreshTokenExpiry
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.0-rc.1.25451.105")
|
||||
.HasAnnotation("Relational:DefaultSchema", "identity");
|
||||
|
||||
modelBuilder.Entity("Knot.Modules.Auth.Domain.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Avatar")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("BannedUntil")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Bio")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("Birthday")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Domain")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("HideStatus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HideStoryViews")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsBanned")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsExternal")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsOnline")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastSeen")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PasswordHash")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PhoneNumber")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("RefreshToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime?>("RefreshTokenExpiry")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users", "identity");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Knot.Modules.Auth.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddRefreshTokenExpiry : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "RefreshTokenExpiry",
|
||||
schema: "identity",
|
||||
table: "Users",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "RefreshTokenExpiry",
|
||||
schema: "identity",
|
||||
table: "Users");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +1,20 @@
|
||||
using Knot.Modules.Auth.Application.Users.GetMe;
|
||||
using Knot.Modules.Auth.Application.Users.Login;
|
||||
using Knot.Modules.Auth.Application.Users.RefreshToken;
|
||||
using Knot.Modules.Auth.Application.Users.Register;
|
||||
using Knot.Shared.Kernel;
|
||||
using Knot.Modules.Auth.Application.Users.Login;
|
||||
using Knot.Modules.Auth.Application.Users.Register;
|
||||
using Knot.Modules.Auth.Application.Users.GetMe;
|
||||
using Knot.Modules.Auth.Application.Users.ChangePassword;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Knot.Modules.Auth.Presentation.Endpoints;
|
||||
|
||||
public static class AuthEndpoints
|
||||
{
|
||||
public sealed record ChangePasswordRequest(string OldPassword, string NewPassword, string ConfirmPassword);
|
||||
|
||||
public static void MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("api/auth");
|
||||
@@ -29,16 +31,24 @@ public static class AuthEndpoints
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
||||
});
|
||||
|
||||
group.MapPost("refresh", async ([FromBody] RefreshTokenCommand command, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(command, ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.Unauthorized();
|
||||
});
|
||||
|
||||
group.MapGet("me", async (ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new GetMeQuery(userContext.UserId), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound();
|
||||
}).RequireAuthorization();
|
||||
|
||||
group.MapPost("change-password", async ([FromBody] ChangePasswordRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new ChangePasswordCommand(userContext.UserId, request.OldPassword, request.NewPassword, request.ConfirmPassword),
|
||||
ct);
|
||||
|
||||
if (result.IsSuccess) return Results.Ok();
|
||||
|
||||
if (result.Error.Code == "Auth.OldPasswordInvalid")
|
||||
return Results.StatusCode(StatusCodes.Status403Forbidden);
|
||||
|
||||
return Results.BadRequest(new { error = result.Error.Code ?? result.Error.Description });
|
||||
}).RequireAuthorization();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ public record MessageDetailDto(
|
||||
bool? PollIsMultipleChoice = null,
|
||||
bool? PollIsAnonymous = null,
|
||||
bool? PollIsClosed = null,
|
||||
List<Guid>? UserVotedOptionIds = null,
|
||||
bool IsDeletedForUser = false
|
||||
List<Guid>? UserVotedOptionIds = null
|
||||
);
|
||||
|
||||
public record ReplyToMessageDto(
|
||||
|
||||
+13
-8
@@ -42,16 +42,10 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
||||
|
||||
if (request.DeleteForAll)
|
||||
{
|
||||
// Only message sender can delete for everyone
|
||||
if (message.SenderId == request.UserId)
|
||||
{
|
||||
message.Delete();
|
||||
}
|
||||
else
|
||||
{
|
||||
// If not the sender, just delete for current user
|
||||
message.DeleteForUser(request.UserId);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -61,13 +55,24 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||
}
|
||||
|
||||
// Notify all clients in the chat about the deletion
|
||||
if (request.DeleteForAll)
|
||||
{
|
||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
messageIds = request.MessageIds,
|
||||
deleteForAll = request.DeleteForAll
|
||||
deleteForAll = true
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
|
||||
{
|
||||
chatId = request.ChatId,
|
||||
messageIds = request.MessageIds,
|
||||
deleteForAll = false
|
||||
});
|
||||
}
|
||||
|
||||
return global::Knot.Shared.Kernel.Result.Success();
|
||||
}
|
||||
|
||||
+7
-12
@@ -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;
|
||||
|
||||
@@ -115,7 +115,6 @@ 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)
|
||||
{
|
||||
@@ -142,7 +141,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
|
||||
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
|
||||
) : null,
|
||||
(message as TextMessage)?.Quote,
|
||||
message is TextMessage tm ? tm.Quote : null,
|
||||
message.IsEdited,
|
||||
message.IsDeleted,
|
||||
message.CreatedAt,
|
||||
@@ -155,22 +154,19 @@ 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)
|
||||
@@ -183,8 +179,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
||||
(message as PollMessage)?.IsMultipleChoice,
|
||||
(message as PollMessage)?.IsAnonymous,
|
||||
(message as PollMessage)?.IsClosed,
|
||||
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(),
|
||||
message.IsDeletedForUser(request.UserId)
|
||||
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList()
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ using Knot.Modules.Conversations.Application.DTOs;
|
||||
using Knot.Contracts.Messaging.Application.Abstractions;
|
||||
using Knot.Contracts.Messaging.Domain;
|
||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
|
||||
namespace Knot.Modules.Conversations.Infrastructure.SignalR;
|
||||
|
||||
@@ -51,6 +52,7 @@ public sealed class ChatHub : Hub
|
||||
private readonly ILogger<ChatHub> _logger;
|
||||
private readonly IMemoryCache _cache;
|
||||
private readonly IUserDisplayNameProvider _userProvider;
|
||||
private readonly IProfileRepository _profileRepository;
|
||||
|
||||
public ChatHub(
|
||||
ISender sender,
|
||||
@@ -60,7 +62,8 @@ public sealed class ChatHub : Hub
|
||||
IMessageRepository messageRepository,
|
||||
ILogger<ChatHub> logger,
|
||||
IMemoryCache cache,
|
||||
IUserDisplayNameProvider userProvider)
|
||||
IUserDisplayNameProvider userProvider,
|
||||
IProfileRepository profileRepository)
|
||||
{
|
||||
_sender = sender;
|
||||
_userContext = userContext;
|
||||
@@ -70,6 +73,7 @@ public sealed class ChatHub : Hub
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
_userProvider = userProvider;
|
||||
_profileRepository = profileRepository;
|
||||
}
|
||||
|
||||
public override async Task OnConnectedAsync()
|
||||
@@ -95,7 +99,15 @@ public sealed class ChatHub : Hub
|
||||
|
||||
userId, Context.ConnectionId, userChats.Count);
|
||||
|
||||
await Clients.Others.SendAsync("user_online", new { userId });
|
||||
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, Context.ConnectionAborted);
|
||||
if (!isInvisible)
|
||||
{
|
||||
await BroadcastPresenceToVisibleUsersAsync(
|
||||
"user_online",
|
||||
new { userId },
|
||||
_userContext.UserId,
|
||||
Context.ConnectionAborted);
|
||||
}
|
||||
}
|
||||
await base.OnConnectedAsync();
|
||||
}
|
||||
@@ -111,7 +123,22 @@ public sealed class ChatHub : Hub
|
||||
if (set.Count == 0)
|
||||
{
|
||||
_userConnections.TryRemove(userId, out _);
|
||||
await Clients.Others.SendAsync("user_offline", new { userId, lastSeen = DateTime.UtcNow });
|
||||
try
|
||||
{
|
||||
var isInvisible = await IsUserInvisibleAsync(_userContext.UserId, CancellationToken.None);
|
||||
if (!isInvisible)
|
||||
{
|
||||
await BroadcastPresenceToVisibleUsersAsync(
|
||||
"user_offline",
|
||||
new { userId, lastSeen = DateTime.UtcNow },
|
||||
_userContext.UserId,
|
||||
CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Presence broadcast on disconnect failed for {UserId}", userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
_cache.Set("Global_OnlineUsersCount", _userConnections.Count);
|
||||
@@ -120,6 +147,56 @@ public sealed class ChatHub : Hub
|
||||
await base.OnDisconnectedAsync(exception);
|
||||
}
|
||||
|
||||
private async Task<bool> IsUserInvisibleAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var profile = await _profileRepository.GetAsync(userId, ct);
|
||||
return profile?.IsInvisible ?? false;
|
||||
}
|
||||
|
||||
private async Task BroadcastPresenceToVisibleUsersAsync(
|
||||
string eventName,
|
||||
object payload,
|
||||
Guid sourceUserId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var recipients = _userConnections.Keys
|
||||
.Where(id => id != sourceUserId.ToString())
|
||||
.Select(id => Guid.TryParse(id, out var parsed) ? parsed : Guid.Empty)
|
||||
.Where(id => id != Guid.Empty)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
if (recipients.Count == 0)
|
||||
return;
|
||||
|
||||
var recipientProfiles = await _profileRepository.GetAsync(recipients, cancellationToken);
|
||||
var invisibleRecipientIds = recipientProfiles
|
||||
.Where(p => p.IsInvisible)
|
||||
.Select(p => p.UserId)
|
||||
.ToHashSet();
|
||||
|
||||
foreach (var kvp in _userConnections)
|
||||
{
|
||||
if (!Guid.TryParse(kvp.Key, out var recipientId))
|
||||
continue;
|
||||
if (recipientId == sourceUserId)
|
||||
continue;
|
||||
if (invisibleRecipientIds.Contains(recipientId))
|
||||
continue;
|
||||
|
||||
string[] connectionIds;
|
||||
lock (kvp.Value)
|
||||
{
|
||||
connectionIds = kvp.Value.ToArray();
|
||||
}
|
||||
|
||||
foreach (var connectionId in connectionIds)
|
||||
{
|
||||
await Clients.Client(connectionId).SendAsync(eventName, payload);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
// Chat methods
|
||||
// ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
<ProjectReference Include="..\..\Contracts\Messaging\Knot.Contracts.Messaging.csproj" />
|
||||
<ProjectReference Include="..\..\Contracts\Conversations\Knot.Contracts.Conversations.csproj" />
|
||||
<ProjectReference Include="..\..\Contracts\Auth\Knot.Contracts.Auth.csproj" />
|
||||
<ProjectReference Include="..\..\Contracts\Profiles\Knot.Contracts.Profiles.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,5 +2,9 @@ using System;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Profiles.DTOs;
|
||||
|
||||
public record UpdateProfileRequest(string? DisplayName, string? Bio, DateTime? Birthday);
|
||||
public record UpdateProfileRequest(
|
||||
string? DisplayName,
|
||||
string? Bio,
|
||||
DateTime? Birthday,
|
||||
bool? IsInvisible);
|
||||
public record UpdateSettingsRequest(bool? HideStoryViews);
|
||||
|
||||
@@ -11,7 +11,8 @@ public sealed record UpdateProfileCommand(
|
||||
Guid UserId,
|
||||
string? DisplayName,
|
||||
string? Bio,
|
||||
DateTime? Birthday) : ICommand<UserProfileDto>;
|
||||
DateTime? Birthday,
|
||||
bool? IsInvisible) : ICommand<UserProfileDto>;
|
||||
|
||||
internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfileCommand, UserProfileDto>
|
||||
{
|
||||
@@ -28,9 +29,13 @@ internal sealed class UpdateProfileCommandHandler : ICommandHandler<UpdateProfil
|
||||
if (profile is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
if ((request.Bio?.Length ?? 0) > 200)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.BioTooLong);
|
||||
|
||||
profile.DisplayName = request.DisplayName ?? profile.DisplayName;
|
||||
profile.About = request.Bio;
|
||||
profile.Birthday = request.Birthday;
|
||||
profile.IsInvisible = request.IsInvisible ?? profile.IsInvisible;
|
||||
|
||||
var result = await _repository.UpdateAsync(profile, cancellationToken);
|
||||
if (result.IsFailure)
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Statuses;
|
||||
|
||||
public sealed record ClearUserStatusCommand(Guid UserId) : IRequest<Result<UserProfileDto>>;
|
||||
|
||||
public sealed class ClearUserStatusCommandHandler : IRequestHandler<ClearUserStatusCommand, Result<UserProfileDto>>
|
||||
{
|
||||
private readonly IProfileStatusWriter _writer;
|
||||
|
||||
public ClearUserStatusCommandHandler(IProfileStatusWriter writer)
|
||||
{
|
||||
_writer = writer;
|
||||
}
|
||||
|
||||
public Task<Result<UserProfileDto>> Handle(ClearUserStatusCommand request, CancellationToken cancellationToken)
|
||||
=> _writer.ClearAsync(request.UserId, cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Statuses;
|
||||
|
||||
public sealed record GetStatusPresetsQuery : IRequest<IReadOnlyList<StatusPresetDto>>;
|
||||
|
||||
public sealed class GetStatusPresetsQueryHandler : IRequestHandler<GetStatusPresetsQuery, IReadOnlyList<StatusPresetDto>>
|
||||
{
|
||||
public Task<IReadOnlyList<StatusPresetDto>> Handle(GetStatusPresetsQuery request, CancellationToken cancellationToken)
|
||||
=> Task.FromResult(StatusPresetCatalog.All);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Statuses;
|
||||
|
||||
public sealed record SetCustomUserStatusCommand(
|
||||
Guid UserId,
|
||||
string? Emoji,
|
||||
string? Text,
|
||||
DateTime? ExpiresAt,
|
||||
string? PresetKey) : IRequest<Result<UserProfileDto>>;
|
||||
|
||||
public sealed class SetCustomUserStatusCommandHandler : IRequestHandler<SetCustomUserStatusCommand, Result<UserProfileDto>>
|
||||
{
|
||||
private readonly IProfileStatusWriter _writer;
|
||||
|
||||
public SetCustomUserStatusCommandHandler(IProfileStatusWriter writer)
|
||||
{
|
||||
_writer = writer;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> Handle(SetCustomUserStatusCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var key = request.PresetKey?.Trim();
|
||||
if (!string.IsNullOrEmpty(key) && key.Equals("online", StringComparison.OrdinalIgnoreCase))
|
||||
return await _writer.ClearAsync(request.UserId, cancellationToken);
|
||||
|
||||
var emoji = request.Emoji?.Trim() ?? string.Empty;
|
||||
var text = request.Text?.Trim() ?? string.Empty;
|
||||
|
||||
if (!string.IsNullOrEmpty(key))
|
||||
{
|
||||
var preset = StatusPresetCatalog.Find(key);
|
||||
if (preset is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.InvalidPreset);
|
||||
if (string.IsNullOrEmpty(emoji))
|
||||
emoji = preset.Emoji;
|
||||
if (string.IsNullOrEmpty(text))
|
||||
text = preset.TextRu;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(emoji) && string.IsNullOrEmpty(text))
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusEmpty);
|
||||
|
||||
if (text.Length > 50)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.StatusTextTooLong);
|
||||
|
||||
var presetForWriter = string.IsNullOrWhiteSpace(key) ? null : key;
|
||||
return await _writer.SetCustomAsync(request.UserId, emoji, text, request.ExpiresAt, presetForWriter, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
|
||||
namespace Knot.Modules.Profiles.Application.Statuses;
|
||||
|
||||
public static class StatusPresetCatalog
|
||||
{
|
||||
private static readonly StatusPresetDto[] Items =
|
||||
[
|
||||
new() { Id = "online", Emoji = "", TextRu = "Онлайн (стандарт)", TextEn = "Online (standard)" },
|
||||
new() { Id = "away", Emoji = "🕒", TextRu = "В отъезде", TextEn = "Away" },
|
||||
new() { Id = "dnd", Emoji = "⛔", TextRu = "Не беспокоить", TextEn = "Do not disturb" },
|
||||
new() { Id = "sick", Emoji = "🤒", TextRu = "Болен", TextEn = "Sick" },
|
||||
new() { Id = "angry", Emoji = "💢", TextRu = "Злой", TextEn = "Angry" }
|
||||
];
|
||||
|
||||
public static IReadOnlyList<StatusPresetDto> All => Items;
|
||||
|
||||
public static StatusPresetDto? Find(string presetKey)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(presetKey))
|
||||
return null;
|
||||
return Items.FirstOrDefault(x => x.Id.Equals(presetKey.Trim(), StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
}
|
||||
@@ -11,10 +11,11 @@ public static class DependencyInjection
|
||||
public static IServiceCollection AddProfilesModule(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileRepository, ProfileRepository>();
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IUserStatusRepository, UserStatusMongoRepository>();
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfileStatusWriter, ProfileStatusWriter>();
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IProfilesUnitOfWork, ProfilesUnitOfWork>();
|
||||
services.AddScoped<Knot.Contracts.Profiles.Domain.IAvatarStorageService, AvatarStorageService>();
|
||||
|
||||
// MongoDB Registration
|
||||
var mongoConnection = configuration.GetConnectionString("MongoConnection")
|
||||
?? configuration["MONGO_URL"]
|
||||
?? "mongodb://mongo:27017";
|
||||
|
||||
@@ -3,10 +3,6 @@ using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Knot.Modules.Profiles.Domain;
|
||||
|
||||
/// <summary>
|
||||
/// MongoDB-документ профиля пользователя.
|
||||
/// Id совпадает с UserId из модуля Auth (Postgres).
|
||||
/// </summary>
|
||||
public sealed class ProfileDocument
|
||||
{
|
||||
[BsonId]
|
||||
@@ -19,12 +15,23 @@ public sealed class ProfileDocument
|
||||
|
||||
public string? Bio { get; private set; }
|
||||
|
||||
public string? StatusText { get; private set; }
|
||||
|
||||
public string? StatusEmoji { get; private set; }
|
||||
|
||||
public DateTime? StatusExpiresAt { get; private set; }
|
||||
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid? CurrentStatusId { get; private set; }
|
||||
|
||||
public string? AvatarUrl { get; private set; }
|
||||
|
||||
public DateTime? Birthday { get; private set; }
|
||||
|
||||
public bool HideStoryViews { get; private set; }
|
||||
|
||||
public bool IsInvisible { get; private set; }
|
||||
|
||||
public bool IsBanned { get; private set; }
|
||||
|
||||
public bool IsDeleted { get; private set; }
|
||||
@@ -65,6 +72,26 @@ public sealed class ProfileDocument
|
||||
public void UpdateSettings(bool hideStoryViews)
|
||||
=> HideStoryViews = hideStoryViews;
|
||||
|
||||
public void UpdateInvisible(bool isInvisible)
|
||||
=> IsInvisible = isInvisible;
|
||||
|
||||
public void UpdateStatus(string? statusText, string? statusEmoji, DateTime? statusExpiresAt)
|
||||
{
|
||||
StatusText = statusText;
|
||||
StatusEmoji = statusEmoji;
|
||||
StatusExpiresAt = statusExpiresAt;
|
||||
}
|
||||
|
||||
public void SetCurrentStatusId(Guid? statusId)
|
||||
=> CurrentStatusId = statusId;
|
||||
|
||||
public void ClearMoodStatusFields()
|
||||
{
|
||||
StatusText = null;
|
||||
StatusEmoji = null;
|
||||
StatusExpiresAt = null;
|
||||
}
|
||||
|
||||
public void UpdateStatus(bool isBanned, bool isDeleted)
|
||||
{
|
||||
IsBanned = isBanned;
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Bson.Serialization.Attributes;
|
||||
|
||||
namespace Knot.Modules.Profiles.Domain;
|
||||
|
||||
public sealed class UserStatusDocument
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid Id { get; set; }
|
||||
|
||||
[BsonRepresentation(BsonType.String)]
|
||||
public Guid UserId { get; set; }
|
||||
|
||||
public string Type { get; set; } = "Custom";
|
||||
|
||||
public string Emoji { get; set; } = "";
|
||||
|
||||
public string Text { get; set; } = "";
|
||||
|
||||
public DateTime CreatedAt { get; set; }
|
||||
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
|
||||
public BsonDocument Metadata { get; set; } = new();
|
||||
}
|
||||
@@ -10,29 +10,39 @@ namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||
internal class ProfileRepository : IProfileRepository
|
||||
{
|
||||
private readonly IMongoCollection<ProfileDocument> _profiles;
|
||||
private readonly IUserStatusRepository _statuses;
|
||||
|
||||
public ProfileRepository(IMongoDatabase database)
|
||||
public ProfileRepository(IMongoDatabase database, IUserStatusRepository statuses)
|
||||
{
|
||||
_profiles = database.GetCollection<ProfileDocument>("profiles");
|
||||
_statuses = statuses;
|
||||
}
|
||||
|
||||
public async Task<UserProfileDto?> GetAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(ct);
|
||||
return profile?.ToDto();
|
||||
return profile is null ? null : await profile.ToDtoAsync(_statuses, ct);
|
||||
}
|
||||
|
||||
public async Task<UserProfileDto?> GetByUsernameAsync(string username, CancellationToken ct = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Username == username).FirstOrDefaultAsync(ct);
|
||||
return profile?.ToDto();
|
||||
return profile is null ? null : await profile.ToDtoAsync(_statuses, ct);
|
||||
}
|
||||
|
||||
public async Task<List<UserProfileDto>> GetAsync(IEnumerable<Guid> userIds, CancellationToken ct = default)
|
||||
{
|
||||
var ids = userIds.ToList();
|
||||
var profiles = await _profiles.Find(p => ids.Contains(p.Id)).ToListAsync(ct);
|
||||
return profiles.Select(p => p.ToDto()).ToList();
|
||||
var statusIds = profiles
|
||||
.Where(p => p.CurrentStatusId.HasValue)
|
||||
.Select(p => p.CurrentStatusId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var map = statusIds.Count > 0
|
||||
? await _statuses.GetByIdsAsync(statusIds, ct)
|
||||
: new Dictionary<Guid, UserStatusDto>();
|
||||
return profiles.Select(p => ProfileMappings.ToDtoWithStatusMap(p, map)).ToList();
|
||||
}
|
||||
|
||||
public async Task<List<UserProfileDto>> SearchAsync(string query, int limit = 20, CancellationToken ct = default)
|
||||
@@ -57,7 +67,16 @@ internal class ProfileRepository : IProfileRepository
|
||||
var combinedFilter = Builders<ProfileDocument>.Filter.And(baseFilter, searchFilter);
|
||||
docs = await _profiles.Find(combinedFilter).Limit(limit).ToListAsync(ct);
|
||||
}
|
||||
return docs.Select(p => p.ToDto()).ToList();
|
||||
|
||||
var statusIds = docs
|
||||
.Where(p => p.CurrentStatusId.HasValue)
|
||||
.Select(p => p.CurrentStatusId!.Value)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
var map = statusIds.Count > 0
|
||||
? await _statuses.GetByIdsAsync(statusIds, ct)
|
||||
: new Dictionary<Guid, UserStatusDto>();
|
||||
return docs.Select(p => ProfileMappings.ToDtoWithStatusMap(p, map)).ToList();
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> CreateAsync(UserProfileDto dto, CancellationToken ct = default)
|
||||
@@ -65,7 +84,7 @@ internal class ProfileRepository : IProfileRepository
|
||||
var profile = ProfileDocument.Create(dto.UserId, dto.Username ?? string.Empty, dto.DisplayName ?? string.Empty, dto.About);
|
||||
|
||||
await _profiles.InsertOneAsync(profile, null, ct);
|
||||
return Result.Success(profile.ToDto());
|
||||
return Result.Success(await profile.ToDtoAsync(_statuses, ct));
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> UpdateAsync(UserProfileDto dto, CancellationToken ct = default)
|
||||
@@ -80,9 +99,13 @@ internal class ProfileRepository : IProfileRepository
|
||||
dto.Birthday);
|
||||
|
||||
document.UpdateAvatar(dto.Avatar);
|
||||
document.UpdateInvisible(dto.IsInvisible);
|
||||
|
||||
await _profiles.ReplaceOneAsync(p => p.Id == dto.UserId, document, cancellationToken: ct);
|
||||
return Result.Success(document.ToDto());
|
||||
var fresh = await _profiles.Find(p => p.Id == dto.UserId).FirstOrDefaultAsync(ct);
|
||||
if (fresh is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
return Result.Success(await fresh.ToDtoAsync(_statuses, ct));
|
||||
}
|
||||
|
||||
public async Task<Result> UpdateStatusAsync(Guid userId, bool isBanned, bool isDeleted, CancellationToken ct = default)
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Infrastructure.Mappings;
|
||||
using Knot.Shared.Kernel;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||
|
||||
internal sealed class ProfileStatusWriter : IProfileStatusWriter
|
||||
{
|
||||
private readonly IMongoCollection<ProfileDocument> _profiles;
|
||||
private readonly IUserStatusRepository _statuses;
|
||||
|
||||
public ProfileStatusWriter(IMongoDatabase database, IUserStatusRepository statuses)
|
||||
{
|
||||
_profiles = database.GetCollection<ProfileDocument>("profiles");
|
||||
_statuses = statuses;
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> SetCustomAsync(Guid userId, string emoji, string text, DateTime? expiresAt, string? presetKey, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
if (profile.CurrentStatusId is Guid oldId)
|
||||
await _statuses.DeleteAsync(oldId, cancellationToken);
|
||||
|
||||
var newId = Guid.NewGuid();
|
||||
var type = string.IsNullOrWhiteSpace(presetKey) ? "Custom" : "Preset";
|
||||
var dto = new UserStatusDto
|
||||
{
|
||||
Id = newId,
|
||||
Type = type,
|
||||
Emoji = emoji,
|
||||
Text = text,
|
||||
CreatedAt = DateTime.UtcNow,
|
||||
ExpiresAt = expiresAt
|
||||
};
|
||||
await _statuses.InsertAsync(dto, userId, cancellationToken);
|
||||
|
||||
profile.SetCurrentStatusId(newId);
|
||||
profile.ClearMoodStatusFields();
|
||||
await _profiles.ReplaceOneAsync(p => p.Id == userId, profile, cancellationToken: cancellationToken);
|
||||
|
||||
var fresh = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
|
||||
if (fresh is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
return Result.Success(await fresh.ToDtoAsync(_statuses, cancellationToken));
|
||||
}
|
||||
|
||||
public async Task<Result<UserProfileDto>> ClearAsync(Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
|
||||
if (profile is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
if (profile.CurrentStatusId is Guid oldId)
|
||||
await _statuses.DeleteAsync(oldId, cancellationToken);
|
||||
|
||||
profile.SetCurrentStatusId(null);
|
||||
profile.ClearMoodStatusFields();
|
||||
await _profiles.ReplaceOneAsync(p => p.Id == userId, profile, cancellationToken: cancellationToken);
|
||||
|
||||
var fresh = await _profiles.Find(p => p.Id == userId).FirstOrDefaultAsync(cancellationToken);
|
||||
if (fresh is null)
|
||||
return Result.Failure<UserProfileDto>(ProfilesErrors.ProfileNotFound);
|
||||
|
||||
return Result.Success(await fresh.ToDtoAsync(_statuses, cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
using MongoDB.Bson;
|
||||
using MongoDB.Driver;
|
||||
|
||||
namespace Knot.Modules.Profiles.Infrastructure.Database;
|
||||
|
||||
internal sealed class UserStatusMongoRepository : IUserStatusRepository
|
||||
{
|
||||
private readonly IMongoCollection<UserStatusDocument> _collection;
|
||||
|
||||
public UserStatusMongoRepository(IMongoDatabase database)
|
||||
{
|
||||
_collection = database.GetCollection<UserStatusDocument>("user_statuses");
|
||||
}
|
||||
|
||||
public async Task<UserStatusDto?> GetByIdAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = await _collection.Find(x => x.Id == id).FirstOrDefaultAsync(cancellationToken);
|
||||
return doc is null ? null : ToDto(doc);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyDictionary<Guid, UserStatusDto>> GetByIdsAsync(IEnumerable<Guid> ids, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var idList = ids.Distinct().ToList();
|
||||
if (idList.Count == 0)
|
||||
return new Dictionary<Guid, UserStatusDto>();
|
||||
|
||||
var docs = await _collection.Find(x => idList.Contains(x.Id)).ToListAsync(cancellationToken);
|
||||
return docs.ToDictionary(d => d.Id, ToDto);
|
||||
}
|
||||
|
||||
public async Task InsertAsync(UserStatusDto dto, Guid userId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var doc = new UserStatusDocument
|
||||
{
|
||||
Id = dto.Id,
|
||||
UserId = userId,
|
||||
Type = dto.Type,
|
||||
Emoji = dto.Emoji,
|
||||
Text = dto.Text,
|
||||
CreatedAt = dto.CreatedAt,
|
||||
ExpiresAt = dto.ExpiresAt,
|
||||
Metadata = new BsonDocument()
|
||||
};
|
||||
await _collection.InsertOneAsync(doc, cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public Task DeleteAsync(Guid id, CancellationToken cancellationToken = default)
|
||||
=> _collection.DeleteOneAsync(x => x.Id == id, cancellationToken);
|
||||
|
||||
private static UserStatusDto ToDto(UserStatusDocument d) => new()
|
||||
{
|
||||
Id = d.Id,
|
||||
Type = d.Type,
|
||||
Emoji = d.Emoji,
|
||||
Text = d.Text,
|
||||
CreatedAt = d.CreatedAt,
|
||||
ExpiresAt = d.ExpiresAt
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
using Knot.Contracts.Profiles.Application.DTOs;
|
||||
using Knot.Contracts.Profiles.Domain;
|
||||
using Knot.Modules.Profiles.Domain;
|
||||
|
||||
namespace Knot.Modules.Profiles.Infrastructure.Mappings;
|
||||
|
||||
public static class ProfileMappings
|
||||
{
|
||||
public static UserProfileDto ToDto(this ProfileDocument document)
|
||||
public static UserProfileDto ToDtoWithStatusMap(ProfileDocument document, IReadOnlyDictionary<Guid, UserStatusDto> statusMap)
|
||||
{
|
||||
return new UserProfileDto
|
||||
var dto = new UserProfileDto
|
||||
{
|
||||
UserId = document.Id,
|
||||
DisplayName = document.DisplayName,
|
||||
@@ -18,7 +19,59 @@ public static class ProfileMappings
|
||||
LastSeen = null,
|
||||
Birthday = document.Birthday,
|
||||
IsPremium = false,
|
||||
CreatedAt = document.CreatedAt
|
||||
IsInvisible = document.IsInvisible,
|
||||
CreatedAt = document.CreatedAt,
|
||||
Status = null,
|
||||
StatusText = null,
|
||||
StatusEmoji = null,
|
||||
StatusExpiresAt = null,
|
||||
CurrentStatusId = null
|
||||
};
|
||||
|
||||
UserStatusDto? active = null;
|
||||
if (document.CurrentStatusId is Guid sid && statusMap.TryGetValue(sid, out var loaded) && loaded is not null)
|
||||
{
|
||||
var exp = loaded.ExpiresAt;
|
||||
if (!exp.HasValue || exp.Value > DateTime.UtcNow)
|
||||
active = loaded;
|
||||
}
|
||||
|
||||
if (active is not null)
|
||||
{
|
||||
dto.Status = active;
|
||||
dto.StatusText = active.Text;
|
||||
dto.StatusEmoji = active.Emoji;
|
||||
dto.StatusExpiresAt = active.ExpiresAt;
|
||||
dto.CurrentStatusId = active.Id;
|
||||
return dto;
|
||||
}
|
||||
|
||||
var legacyExpired = document.StatusExpiresAt.HasValue && document.StatusExpiresAt.Value <= DateTime.UtcNow;
|
||||
if (!legacyExpired && (!string.IsNullOrEmpty(document.StatusText) || !string.IsNullOrEmpty(document.StatusEmoji)))
|
||||
{
|
||||
dto.StatusText = document.StatusText;
|
||||
dto.StatusEmoji = document.StatusEmoji;
|
||||
dto.StatusExpiresAt = document.StatusExpiresAt;
|
||||
dto.Status = new UserStatusDto
|
||||
{
|
||||
Id = Guid.Empty,
|
||||
Type = "Custom",
|
||||
Emoji = document.StatusEmoji ?? string.Empty,
|
||||
Text = document.StatusText ?? string.Empty,
|
||||
CreatedAt = document.CreatedAt,
|
||||
ExpiresAt = document.StatusExpiresAt
|
||||
};
|
||||
return dto;
|
||||
}
|
||||
|
||||
return dto;
|
||||
}
|
||||
|
||||
public static async Task<UserProfileDto> ToDtoAsync(this ProfileDocument document, IUserStatusRepository statuses, CancellationToken cancellationToken)
|
||||
{
|
||||
if (document.CurrentStatusId is not Guid sid)
|
||||
return ToDtoWithStatusMap(document, new Dictionary<Guid, UserStatusDto>());
|
||||
var map = await statuses.GetByIdsAsync(new[] { sid }, cancellationToken);
|
||||
return ToDtoWithStatusMap(document, map);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ public static class ProfilesEndpoints
|
||||
public static void MapProfilesEndpoints(this WebApplication app)
|
||||
{
|
||||
var group = app.MapGroup("api/profiles").RequireAuthorization();
|
||||
var userGroup = app.MapGroup("api/user").RequireAuthorization();
|
||||
var usersGroup = app.MapGroup("api/users").RequireAuthorization();
|
||||
|
||||
group.MapGet("search", async ([FromQuery] string q, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
{
|
||||
@@ -67,16 +69,37 @@ public static class ProfilesEndpoints
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
|
||||
group.MapPut("profile", async ([FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request, ISender sender, IUserContext userContext, CancellationToken ct) =>
|
||||
async Task<IResult> UpdateProfileHandler(
|
||||
[FromBody] Knot.Modules.Profiles.Application.Profiles.DTOs.UpdateProfileRequest request,
|
||||
ISender sender,
|
||||
IUserContext userContext,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var result = await sender.Send(new UpdateProfileCommand(userContext.UserId, request.DisplayName, request.Bio, request.Birthday), ct);
|
||||
var result = await sender.Send(
|
||||
new UpdateProfileCommand(
|
||||
userContext.UserId,
|
||||
request.DisplayName,
|
||||
request.Bio,
|
||||
request.Birthday,
|
||||
request.IsInvisible),
|
||||
ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
}
|
||||
|
||||
group.MapPut("profile", UpdateProfileHandler);
|
||||
group.MapPatch("profile", UpdateProfileHandler);
|
||||
userGroup.MapPatch("profile", UpdateProfileHandler);
|
||||
|
||||
group.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new GetProfileQuery(id), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
|
||||
usersGroup.MapGet("{id:guid}", async (Guid id, ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new GetProfileQuery(id), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.NotFound(result.Error);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
using Knot.Modules.Profiles.Application.Statuses;
|
||||
using Knot.Shared.Kernel;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Builder;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Routing;
|
||||
|
||||
namespace Knot.Modules.Profiles.Presentation.Endpoints;
|
||||
|
||||
public static class StatusesEndpoints
|
||||
{
|
||||
public static void MapStatusesEndpoints(this WebApplication app)
|
||||
{
|
||||
var g = app.MapGroup("api/statuses").RequireAuthorization();
|
||||
|
||||
g.MapGet("presets", async (ISender sender, CancellationToken ct) =>
|
||||
{
|
||||
var list = await sender.Send(new GetStatusPresetsQuery(), ct);
|
||||
return Results.Ok(list);
|
||||
});
|
||||
|
||||
g.MapPost("custom", async ([FromBody] SetCustomStatusBody body, ISender sender, IUserContext ctx, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(
|
||||
new SetCustomUserStatusCommand(ctx.UserId, body.Emoji, body.Text, body.ExpiresAt, body.PresetKey),
|
||||
ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
|
||||
});
|
||||
|
||||
g.MapDelete("/", async (ISender sender, IUserContext ctx, CancellationToken ct) =>
|
||||
{
|
||||
var result = await sender.Send(new ClearUserStatusCommand(ctx.UserId), ct);
|
||||
return result.IsSuccess ? Results.Ok(result.Value) : Results.BadRequest(result.Error);
|
||||
});
|
||||
}
|
||||
|
||||
public sealed class SetCustomStatusBody
|
||||
{
|
||||
public string? Emoji { get; set; }
|
||||
public string? Text { get; set; }
|
||||
public DateTime? ExpiresAt { get; set; }
|
||||
public string? PresetKey { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
id("org.jetbrains.kotlin.android")
|
||||
id("com.google.dagger.hilt.android")
|
||||
id("com.google.gms.google-services")
|
||||
kotlin("kapt")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "ru.knot.messager"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "ru.knot.messager"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "1.0.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables {
|
||||
useSupportLibrary = true
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||
}
|
||||
}
|
||||
|
||||
// Подключаем все наши папки с кодом как sourceSets
|
||||
sourceSets {
|
||||
getByName("main") {
|
||||
java.srcDirs(
|
||||
"src/main/kotlin",
|
||||
"../auth",
|
||||
"../chats",
|
||||
"../core",
|
||||
"../calls",
|
||||
"../stories",
|
||||
"../contacts",
|
||||
"../profiles",
|
||||
"../settings",
|
||||
"../navigation"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = JavaVersion.VERSION_17
|
||||
targetCompatibility = JavaVersion.VERSION_17
|
||||
}
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
buildFeatures {
|
||||
compose = true
|
||||
}
|
||||
composeOptions {
|
||||
kotlinCompilerExtensionVersion = "1.5.8"
|
||||
}
|
||||
// Включаем оптимизации компилятора Compose
|
||||
kotlinOptions {
|
||||
jvmTarget = "17"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// AndroidX & UI
|
||||
implementation("androidx.core:core-ktx:1.12.0")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||
implementation("androidx.activity:activity-compose:1.8.1")
|
||||
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||
implementation("androidx.compose.ui:ui")
|
||||
implementation("androidx.compose.ui:ui-graphics")
|
||||
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||
implementation("androidx.compose.material3:material3")
|
||||
implementation("com.google.android.material:material:1.11.0")
|
||||
implementation("androidx.navigation:navigation-compose:2.7.5")
|
||||
implementation("androidx.compose.material:material-icons-extended")
|
||||
|
||||
// Hilt
|
||||
implementation("com.google.dagger:hilt-android:2.48")
|
||||
kapt("com.google.dagger:hilt-android-compiler:2.48")
|
||||
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||
|
||||
// Network & SignalR
|
||||
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||
implementation("com.microsoft.signalr:signalr:7.0.0")
|
||||
|
||||
// WebRTC
|
||||
implementation("com.github.webrtc-sdk:android:104.5112.01")
|
||||
|
||||
// Media3 (ExoPlayer)
|
||||
implementation("androidx.media3:media3-exoplayer:1.2.0")
|
||||
implementation("androidx.media3:media3-ui:1.2.0")
|
||||
implementation("androidx.media3:media3-common:1.2.0")
|
||||
|
||||
// Images & GIF
|
||||
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||
implementation("io.coil-kt:coil-gif:2.5.0")
|
||||
implementation("io.coil-kt:coil-svg:2.5.0")
|
||||
implementation("io.coil-kt:coil-video:2.5.0")
|
||||
|
||||
// Security
|
||||
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||
|
||||
// UCrop (Image Cropping)
|
||||
implementation("com.github.yalantis:ucrop:2.2.8")
|
||||
|
||||
// Firebase (Push Notifications)
|
||||
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
|
||||
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||
|
||||
// Room
|
||||
val room_version = "2.6.1"
|
||||
implementation("androidx.room:room-runtime:$room_version")
|
||||
implementation("androidx.room:room-ktx:$room_version")
|
||||
kapt("androidx.room:room-compiler:$room_version")
|
||||
|
||||
// Testing
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.test.ext:junit:1.1.5")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
{
|
||||
"project_info": {
|
||||
"project_number": "483917912506",
|
||||
"project_id": "knot-bad1a",
|
||||
"storage_bucket": "knot-bad1a.firebasestorage.app"
|
||||
},
|
||||
"client": [
|
||||
{
|
||||
"client_info": {
|
||||
"mobilesdk_app_id": "1:483917912506:android:cd39213364869ef9e82583",
|
||||
"android_client_info": {
|
||||
"package_name": "ru.knot.messager"
|
||||
}
|
||||
},
|
||||
"oauth_client": [],
|
||||
"api_key": [
|
||||
{
|
||||
"current_key": "AIzaSyBAL_bZJYaa7rGERLX63LeFXz-__JXRWQY"
|
||||
}
|
||||
],
|
||||
"services": {
|
||||
"appinvite_service": {
|
||||
"other_platform_oauth_client": []
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"configuration_version": "1"
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="ru.knot.messager">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<application
|
||||
android:name="com.knot.messenger.MainApplication"
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.KnotMessenger"
|
||||
android:networkSecurityConfig="@xml/network_security_config"
|
||||
android:usesCleartextTraffic="true">
|
||||
|
||||
<activity
|
||||
android:name="com.knot.messenger.MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop"
|
||||
android:windowSoftInputMode="adjustResize"
|
||||
android:theme="@style/Theme.KnotMessenger">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name="core.notifications.data.KnotFirebaseMessagingService"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -1,58 +0,0 @@
|
||||
package com.knot.messenger
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.ui.Modifier
|
||||
import dagger.hilt.android.AndroidEntryPoint
|
||||
import navigation.AppNavigation
|
||||
import core.presentation.theme.KnotTheme
|
||||
|
||||
@AndroidEntryPoint
|
||||
class MainActivity : ComponentActivity() {
|
||||
@javax.inject.Inject
|
||||
lateinit var navigationManager: core.utils.NavigationManager
|
||||
|
||||
@javax.inject.Inject
|
||||
lateinit var signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
signalrNotificationObserver.start()
|
||||
|
||||
intent.getStringExtra("chatId")?.let { chatId ->
|
||||
navigationManager.navigateToChat(chatId)
|
||||
}
|
||||
|
||||
// Request notifications permission for Android 13+
|
||||
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.TIRAMISU) {
|
||||
androidx.core.app.ActivityCompat.requestPermissions(
|
||||
this,
|
||||
arrayOf(android.Manifest.permission.POST_NOTIFICATIONS),
|
||||
101
|
||||
)
|
||||
}
|
||||
|
||||
setContent {
|
||||
KnotTheme {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
color = MaterialTheme.colorScheme.background
|
||||
) {
|
||||
AppNavigation(navigationManager = navigationManager)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: android.content.Intent?) {
|
||||
super.onNewIntent(intent)
|
||||
intent?.getStringExtra("chatId")?.let { chatId ->
|
||||
navigationManager.navigateToChat(chatId)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
package com.knot.messenger
|
||||
|
||||
import android.app.Application
|
||||
import coil.ImageLoader
|
||||
import coil.ImageLoaderFactory
|
||||
import coil.decode.VideoFrameDecoder
|
||||
import dagger.hilt.android.HiltAndroidApp
|
||||
|
||||
@HiltAndroidApp
|
||||
class MainApplication : Application(), ImageLoaderFactory {
|
||||
override fun newImageLoader(): ImageLoader {
|
||||
return ImageLoader.Builder(this)
|
||||
.components {
|
||||
add(VideoFrameDecoder.Factory())
|
||||
}
|
||||
.build()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<!-- Цветная иконка для notification -->
|
||||
<path
|
||||
android:fillColor="#6C5CE7"
|
||||
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M12,6c-3.31,0 -6,2.69 -6,6s2.69,6 6,6 6,-2.69 6,-6 -2.69,-6 -6,-6zM12,16c-2.21,0 -4,-1.79 -4,-4s1.79,-4 4,-4 4,1.79 4,4 -1.79,4 -4,4z"/>
|
||||
</vector>
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.5 KiB |
@@ -1,63 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">Knot</string>
|
||||
<string name="login">Login</string>
|
||||
<string name="register">Register</string>
|
||||
<string name="username">Username</string>
|
||||
<string name="password">Password</string>
|
||||
<string name="display_name">Display Name</string>
|
||||
<string name="settings">Settings</string>
|
||||
<string name="save">Save</string>
|
||||
<string name="back">Back</string>
|
||||
<string name="server_connection">Server Connection</string>
|
||||
<string name="api_base_url">API Base URL</string>
|
||||
<string name="server_features">Server Features</string>
|
||||
<string name="stories">Stories</string>
|
||||
<string name="polls">Polls</string>
|
||||
<string name="calls">Calls</string>
|
||||
<string name="groups">Groups</string>
|
||||
<string name="enabled">Enabled</string>
|
||||
<string name="disabled">Disabled</string>
|
||||
<string name="limits">Limits</string>
|
||||
<string name="max_file_size">Max File Size</string>
|
||||
<string name="max_group_members">Max Group Members</string>
|
||||
<string name="message">Message</string>
|
||||
<string name="call">Call</string>
|
||||
<string name="block">Block</string>
|
||||
<string name="profile">Profile</string>
|
||||
<string name="confirm_password">Confirm Password</string>
|
||||
<string name="passwords_not_match">Passwords do not match</string>
|
||||
<string name="no_account_register">Don\'t have an account? Register</string>
|
||||
<string name="already_have_account">Already have an account? Login</string>
|
||||
<string name="error_occurred">An error occurred</string>
|
||||
<string name="loading">Loading...</string>
|
||||
<string name="chats_title">Chats</string>
|
||||
<string name="contacts_title">Contacts</string>
|
||||
<string name="stories_title">Stories</string>
|
||||
<string name="create_story">Create Story</string>
|
||||
<string name="send_message_hint">Type a message...</string>
|
||||
<string name="reply_to_user">Reply to %1$s...</string>
|
||||
<string name="story_editor">STORY EDITOR</string>
|
||||
<string name="publish">PUBLISH</string>
|
||||
<string name="start_creation">START CREATION</string>
|
||||
<string name="text_tool">TEXT</string>
|
||||
<string name="crop_tool">CROP</string>
|
||||
<string name="stickers_tool">STICKERS</string>
|
||||
<string name="brush_tool">BRUSH</string>
|
||||
<string name="filters_tool">FILTERS</string>
|
||||
<string name="remove">Remove</string>
|
||||
<string name="no_chats_found">No chats found</string>
|
||||
<string name="typing">typing...</string>
|
||||
<string name="video_call">Video Call</string>
|
||||
<string name="emoji">Emoji</string>
|
||||
<string name="attach">Attach</string>
|
||||
<string name="message_placeholder">Message...</string>
|
||||
<string name="voice_message">Voice Message</string>
|
||||
<string name="send">Send</string>
|
||||
<string name="reply_photo">Photo</string>
|
||||
<string name="reply_video">Video</string>
|
||||
<string name="reply_audio">Audio</string>
|
||||
<string name="reply_file">File</string>
|
||||
<string name="reply_gif">GIF</string>
|
||||
<string name="reply_prefix">Reply to </string>
|
||||
<string name="reply_self">yourself</string>
|
||||
</resources>
|
||||
@@ -1,85 +0,0 @@
|
||||
<resources>
|
||||
<string name="app_name">Knot</string>
|
||||
<string name="login">Войти</string>
|
||||
<string name="register">Регистрация</string>
|
||||
<string name="username">Имя пользователя</string>
|
||||
<string name="password">Пароль</string>
|
||||
<string name="display_name">Отображаемое имя</string>
|
||||
<string name="settings">Настройки</string>
|
||||
<string name="save">Сохранить</string>
|
||||
<string name="back">Назад</string>
|
||||
<string name="server_connection">Подключение к серверу</string>
|
||||
<string name="api_base_url">API Base URL</string>
|
||||
<string name="server_features">Функции сервера</string>
|
||||
<string name="stories">Истории</string>
|
||||
<string name="polls">Опросы</string>
|
||||
<string name="calls">Звонки</string>
|
||||
<string name="groups">Группы</string>
|
||||
<string name="enabled">Включено</string>
|
||||
<string name="disabled">Отключено</string>
|
||||
<string name="limits">Лимиты</string>
|
||||
<string name="max_file_size">Макс. размер файла</string>
|
||||
<string name="max_group_members">Макс. участников в группе</string>
|
||||
<string name="message">Сообщение</string>
|
||||
<string name="call">Позвонить</string>
|
||||
<string name="block">Заблокировать</string>
|
||||
<string name="profile">Профиль</string>
|
||||
<string name="confirm_password">Подтвердите пароль</string>
|
||||
<string name="passwords_not_match">Пароли не совпадают</string>
|
||||
<string name="no_account_register">Нет аккаунта? Зарегистрироваться</string>
|
||||
<string name="already_have_account">Уже есть аккаунт? Войти</string>
|
||||
<string name="error_occurred">Произошла ошибка</string>
|
||||
<string name="loading">Загрузка...</string>
|
||||
<string name="chats_title">Чаты</string>
|
||||
<string name="contacts_title">Контакты</string>
|
||||
<string name="stories_title">Истории</string>
|
||||
<string name="create_story">Создать историю</string>
|
||||
<string name="send_message_hint">Напишите сообщение...</string>
|
||||
<string name="reply_to_user">Ответить %1$s...</string>
|
||||
<string name="story_editor">РЕДАКТОР ИСТОРИЙ</string>
|
||||
<string name="publish">ОПУБЛИКОВАТЬ</string>
|
||||
<string name="start_creation">НАЧАТЬ СОЗДАНИЕ</string>
|
||||
<string name="text_tool">ТЕКСТ</string>
|
||||
<string name="crop_tool">ОБРЕЗКА</string>
|
||||
<string name="stickers_tool">СТИКЕРЫ</string>
|
||||
<string name="brush_tool">КИСТЬ</string>
|
||||
<string name="filters_tool">ФИЛЬТРЫ</string>
|
||||
<string name="remove">Удалить</string>
|
||||
<string name="no_chats_found">Чаты не найдены</string>
|
||||
<string name="typing">печатает...</string>
|
||||
<string name="video_call">Видеозвонок</string>
|
||||
<string name="emoji">Эмодзи</string>
|
||||
<string name="attach">Прикрепить</string>
|
||||
<string name="message_placeholder">Сообщение...</string>
|
||||
<string name="voice_message">Голосовое сообщение</string>
|
||||
<string name="send">Отправить</string>
|
||||
<string name="search_hint">Поиск...</string>
|
||||
<string name="online">В сети</string>
|
||||
<string name="last_seen">Был(а): %1$s</string>
|
||||
<string name="last_seen_recently">недавно</string>
|
||||
<string name="all">Все</string>
|
||||
<string name="online_tab">Онлайн</string>
|
||||
<string name="blocked">Заблокированные</string>
|
||||
<string name="media">Медиа</string>
|
||||
<string name="notifications">Уведомления</string>
|
||||
<string name="mute">Без звука</string>
|
||||
<string name="unmute">Включить звук</string>
|
||||
<string name="log_out">Выйти из аккаунта</string>
|
||||
<string name="bio">О себе</string>
|
||||
<string name="edit_profile">Редактировать профиль</string>
|
||||
<string name="username_label">Имя пользователя</string>
|
||||
<string name="change_photo">Изменить фото</string>
|
||||
<string name="cancel">Отмена</string>
|
||||
<string name="crop">Обрезать</string>
|
||||
<string name="chats">Чаты</string>
|
||||
<string name="contacts_tab">Контакты</string>
|
||||
<string name="profile_tab">Профиль</string>
|
||||
<string name="saving">Сохранение...</string>
|
||||
<string name="reply_photo">Фото</string>
|
||||
<string name="reply_video">Видео</string>
|
||||
<string name="reply_audio">Аудио</string>
|
||||
<string name="reply_file">Файл</string>
|
||||
<string name="reply_gif">GIF</string>
|
||||
<string name="reply_prefix">Ответ </string>
|
||||
<string name="reply_self">самому себе</string>
|
||||
</resources>
|
||||
@@ -1,7 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<style name="Theme.KnotMessenger" parent="Theme.Material3.DayNight.NoActionBar">
|
||||
<item name="android:statusBarColor">#0F0F10</item>
|
||||
<item name="android:windowBackground">#0F0F10</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -1,19 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<network-security-config>
|
||||
<!-- Для отладки: доверяем пользовательским сертификатам -->
|
||||
<debug-overrides>
|
||||
<trust-anchors>
|
||||
<certificates src="user" />
|
||||
<certificates src="system" />
|
||||
</trust-anchors>
|
||||
</debug-overrides>
|
||||
|
||||
<!-- Разрешаем cleartext (HTTP) трафик для локальных IP -->
|
||||
<domain-config cleartextTrafficPermitted="true">
|
||||
<domain includeSubdomains="true">localhost</domain>
|
||||
<domain includeSubdomains="true">127.0.0.1</domain>
|
||||
<domain includeSubdomains="true">10.0.0.0/8</domain>
|
||||
<domain includeSubdomains="true">172.16.0.0/12</domain>
|
||||
<domain includeSubdomains="true">192.168.0.0/16</domain>
|
||||
</domain-config>
|
||||
</network-security-config>
|
||||
@@ -1,29 +0,0 @@
|
||||
package auth.data.remote.api
|
||||
|
||||
import auth.data.remote.dto.AuthRequest
|
||||
import auth.data.remote.dto.AuthResponse
|
||||
import auth.data.remote.dto.RefreshTokenRequest
|
||||
import core.domain.model.ServerConfigModel
|
||||
import retrofit2.http.Body
|
||||
import retrofit2.http.GET
|
||||
import retrofit2.http.Headers
|
||||
import retrofit2.http.POST
|
||||
|
||||
interface AuthApi {
|
||||
@POST("auth/login")
|
||||
suspend fun login(@Body request: AuthRequest): AuthResponse
|
||||
|
||||
@POST("auth/register")
|
||||
suspend fun register(@Body request: AuthRequest): AuthResponse
|
||||
|
||||
@POST("auth/refresh")
|
||||
suspend fun refreshToken(@Body request: RefreshTokenRequest): AuthResponse
|
||||
|
||||
@GET("config")
|
||||
@Headers("Cache-Control: no-cache")
|
||||
suspend fun getConfig(): ServerConfigModel
|
||||
|
||||
@POST("auth/push-token")
|
||||
@Headers("Cache-Control: no-cache")
|
||||
suspend fun updatePushToken(@Body token: String): Unit
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package auth.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class AuthRequest(
|
||||
@SerializedName("userName") val userName: String,
|
||||
@SerializedName("password") val password: String
|
||||
)
|
||||
|
||||
data class AuthResponse(
|
||||
@SerializedName("accessToken") val accessToken: String?,
|
||||
@SerializedName("refreshToken") val refreshToken: String?,
|
||||
@SerializedName("user") val user: UserDto?,
|
||||
@SerializedName("userId") val userId: String?,
|
||||
@SerializedName("username") val username: String?,
|
||||
@SerializedName("displayName") val displayName: String?
|
||||
)
|
||||
|
||||
data class UserDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("userName") val userName: String,
|
||||
@SerializedName("displayName") val displayName: String?,
|
||||
@SerializedName("avatarUrl") val avatarUrl: String?
|
||||
)
|
||||
|
||||
data class RefreshTokenRequest(
|
||||
@SerializedName("refreshToken") val refreshToken: String
|
||||
)
|
||||
@@ -1,131 +0,0 @@
|
||||
package auth.data.repository
|
||||
|
||||
import auth.data.remote.api.AuthApi
|
||||
import auth.data.remote.dto.AuthRequest
|
||||
import auth.data.remote.dto.RefreshTokenRequest
|
||||
import auth.domain.model.AuthResult
|
||||
import auth.domain.repository.AuthRepository
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import javax.inject.Inject
|
||||
|
||||
class AuthRepositoryImpl @Inject constructor(
|
||||
private val api: AuthApi,
|
||||
private val tokenManager: TokenManager,
|
||||
private val serverConfig: ServerConfig
|
||||
) : AuthRepository {
|
||||
|
||||
private val _isAuthenticated = kotlinx.coroutines.flow.MutableStateFlow(tokenManager.getToken() != null)
|
||||
|
||||
override suspend fun login(userName: String, password: String): Result<AuthResult> {
|
||||
return try {
|
||||
val response = api.login(AuthRequest(userName, password))
|
||||
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
|
||||
val userId = response.userId ?: ""
|
||||
|
||||
tokenManager.saveToken(token, userId, response.refreshToken)
|
||||
_isAuthenticated.value = true
|
||||
fetchConfig()
|
||||
Result.success(
|
||||
AuthResult(
|
||||
token = token,
|
||||
refreshToken = response.refreshToken,
|
||||
userId = userId,
|
||||
userName = response.username ?: userName,
|
||||
displayName = response.displayName ?: response.username ?: userName,
|
||||
avatarUrl = null
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun register(userName: String, password: String): Result<AuthResult> {
|
||||
return try {
|
||||
val response = api.register(AuthRequest(userName, password))
|
||||
val token = response.accessToken ?: return Result.failure(Exception("Token is null"))
|
||||
val userId = response.userId ?: ""
|
||||
|
||||
tokenManager.saveToken(token, userId, response.refreshToken)
|
||||
_isAuthenticated.value = true
|
||||
fetchConfig()
|
||||
Result.success(
|
||||
AuthResult(
|
||||
token = token,
|
||||
refreshToken = response.refreshToken,
|
||||
userId = userId,
|
||||
userName = response.username ?: userName,
|
||||
displayName = response.displayName ?: response.username ?: userName,
|
||||
avatarUrl = null
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun logout() {
|
||||
tokenManager.deleteToken()
|
||||
_isAuthenticated.value = false
|
||||
}
|
||||
|
||||
override fun isAuthenticated(): Boolean {
|
||||
return _isAuthenticated.value
|
||||
}
|
||||
|
||||
override fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean> {
|
||||
return _isAuthenticated.asStateFlow()
|
||||
}
|
||||
|
||||
override suspend fun fetchConfig(): Result<Unit> {
|
||||
return try {
|
||||
val config = api.getConfig()
|
||||
serverConfig.saveServerConfig(config)
|
||||
Result.success(Unit)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun updatePushToken(token: String) {
|
||||
try {
|
||||
api.updatePushToken(token)
|
||||
} catch (e: Exception) {
|
||||
// Silent fail
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun refreshToken(): Result<AuthResult> {
|
||||
val currentRefreshToken = tokenManager.getRefreshToken()
|
||||
if (currentRefreshToken == null) {
|
||||
return Result.failure(Exception("Refresh token is null"))
|
||||
}
|
||||
|
||||
return try {
|
||||
val response = api.refreshToken(RefreshTokenRequest(currentRefreshToken))
|
||||
val newAccessToken = response.accessToken ?: return Result.failure(Exception("New access token is null"))
|
||||
val newRefreshToken = response.refreshToken
|
||||
val userId = response.userId ?: ""
|
||||
|
||||
tokenManager.saveToken(newAccessToken, userId, newRefreshToken)
|
||||
_isAuthenticated.value = true
|
||||
|
||||
Result.success(
|
||||
AuthResult(
|
||||
token = newAccessToken,
|
||||
refreshToken = newRefreshToken,
|
||||
userId = userId,
|
||||
userName = response.username ?: "",
|
||||
displayName = response.displayName ?: "",
|
||||
avatarUrl = null
|
||||
)
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
package auth.di
|
||||
|
||||
import auth.data.remote.api.AuthApi
|
||||
import auth.data.repository.AuthRepositoryImpl
|
||||
import auth.domain.repository.AuthRepository
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import retrofit2.Retrofit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object AuthModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthApi(retrofit: Retrofit): AuthApi {
|
||||
return retrofit.create(AuthApi::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideAuthRepository(
|
||||
api: AuthApi,
|
||||
tokenManager: TokenManager,
|
||||
serverConfig: ServerConfig
|
||||
): AuthRepository {
|
||||
return AuthRepositoryImpl(api, tokenManager, serverConfig)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
package auth.domain.model
|
||||
|
||||
data class AuthResult(
|
||||
val token: String,
|
||||
val refreshToken: String?,
|
||||
val userId: String,
|
||||
val userName: String,
|
||||
val displayName: String,
|
||||
val avatarUrl: String?
|
||||
)
|
||||
@@ -1,14 +0,0 @@
|
||||
package auth.domain.repository
|
||||
|
||||
import auth.domain.model.AuthResult
|
||||
|
||||
interface AuthRepository {
|
||||
suspend fun login(userName: String, password: String): Result<AuthResult>
|
||||
suspend fun register(userName: String, password: String): Result<AuthResult>
|
||||
suspend fun logout()
|
||||
suspend fun fetchConfig(): Result<Unit>
|
||||
fun isAuthenticated(): Boolean
|
||||
fun isAuthenticatedFlow(): kotlinx.coroutines.flow.StateFlow<Boolean>
|
||||
suspend fun updatePushToken(token: String)
|
||||
suspend fun refreshToken(): Result<AuthResult>
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package auth.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import auth.domain.repository.AuthRepository
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import com.google.firebase.messaging.FirebaseMessaging
|
||||
import javax.inject.Inject
|
||||
|
||||
data class AuthState(
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val isAuthenticated: Boolean = false
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class AuthViewModel @Inject constructor(
|
||||
private val repository: AuthRepository
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated()))
|
||||
val state: StateFlow<AuthState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
viewModelScope.launch {
|
||||
repository.isAuthenticatedFlow().collect { authenticated ->
|
||||
_state.update { it.copy(isAuthenticated = authenticated) }
|
||||
if (authenticated) {
|
||||
updatePushToken()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun checkAuth() {
|
||||
_state.update { it.copy(isAuthenticated = repository.isAuthenticated()) }
|
||||
}
|
||||
|
||||
fun login(userName: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
repository.login(userName, password)
|
||||
.onSuccess {
|
||||
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
|
||||
updatePushToken()
|
||||
}
|
||||
.onFailure { e ->
|
||||
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun register(userName: String, password: String) {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true, error = null) }
|
||||
repository.register(userName, password)
|
||||
.onSuccess {
|
||||
_state.update { it.copy(isLoading = false, isAuthenticated = true) }
|
||||
updatePushToken()
|
||||
}
|
||||
.onFailure { e ->
|
||||
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshToken() {
|
||||
viewModelScope.launch {
|
||||
repository.refreshToken()
|
||||
.onSuccess {
|
||||
// Token refreshed successfully
|
||||
}
|
||||
.onFailure {
|
||||
// Refresh failed, will trigger logout via AuthInterceptor
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePushToken() {
|
||||
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
val token = task.result
|
||||
viewModelScope.launch {
|
||||
repository.updatePushToken(token)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package auth.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.knot.messager.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun LoginScreen(
|
||||
viewModel: AuthViewModel,
|
||||
onNavigateToRegister: () -> Unit,
|
||||
onNavigateToSettings: () -> Unit,
|
||||
onLoginSuccess: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var userName by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
|
||||
LaunchedEffect(state.isAuthenticated) {
|
||||
if (state.isAuthenticated) {
|
||||
onLoginSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.login)) },
|
||||
actions = {
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(Icons.Default.Settings, contentDescription = stringResource(R.string.settings))
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = userName,
|
||||
onValueChange = { userName = it },
|
||||
label = { Text(stringResource(R.string.username)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
Button(
|
||||
onClick = { viewModel.login(userName, password) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = userName.isNotBlank() && password.isNotBlank()
|
||||
) {
|
||||
Text(stringResource(R.string.login))
|
||||
}
|
||||
TextButton(onClick = onNavigateToRegister) {
|
||||
Text(stringResource(R.string.no_account_register))
|
||||
}
|
||||
}
|
||||
|
||||
if (state.error != null) {
|
||||
Text(
|
||||
text = state.error!!,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package auth.presentation
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.unit.dp
|
||||
import ru.knot.messager.R
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun RegisterScreen(
|
||||
viewModel: AuthViewModel,
|
||||
onNavigateToLogin: () -> Unit,
|
||||
onRegisterSuccess: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
var userName by remember { mutableStateOf("") }
|
||||
var password by remember { mutableStateOf("") }
|
||||
var confirmPassword by remember { mutableStateOf("") }
|
||||
var errorMessage by remember { mutableStateOf<String?>(null) }
|
||||
|
||||
val passwordsNotMatchMsg = stringResource(R.string.passwords_not_match)
|
||||
|
||||
LaunchedEffect(state.isAuthenticated) {
|
||||
if (state.isAuthenticated) {
|
||||
onRegisterSuccess()
|
||||
}
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(title = { Text(stringResource(R.string.register)) })
|
||||
}
|
||||
) { paddingValues ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.Center
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = userName,
|
||||
onValueChange = { userName = it },
|
||||
label = { Text(stringResource(R.string.username)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = password,
|
||||
onValueChange = { password = it },
|
||||
label = { Text(stringResource(R.string.password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedTextField(
|
||||
value = confirmPassword,
|
||||
onValueChange = { confirmPassword = it },
|
||||
label = { Text(stringResource(R.string.confirm_password)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
visualTransformation = PasswordVisualTransformation(),
|
||||
singleLine = true
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator()
|
||||
} else {
|
||||
Button(
|
||||
onClick = {
|
||||
if (password == confirmPassword) {
|
||||
errorMessage = null
|
||||
viewModel.register(userName, password)
|
||||
} else {
|
||||
errorMessage = passwordsNotMatchMsg
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = userName.isNotBlank() && password.isNotBlank() && confirmPassword.isNotBlank()
|
||||
) {
|
||||
Text(stringResource(R.string.register))
|
||||
}
|
||||
TextButton(onClick = onNavigateToLogin) {
|
||||
Text(stringResource(R.string.already_have_account))
|
||||
}
|
||||
}
|
||||
|
||||
val displayError = state.error ?: errorMessage
|
||||
if (displayError != null) {
|
||||
Text(
|
||||
text = displayError,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
// Top-level build file
|
||||
plugins {
|
||||
id("com.android.application") version "8.2.0" apply false
|
||||
id("com.android.library") version "8.2.0" apply false
|
||||
id("org.jetbrains.kotlin.android") version "1.9.22" apply false
|
||||
id("com.google.dagger.hilt.android") version "2.48" apply false
|
||||
id("com.google.gms.google-services") version "4.4.0" apply false
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
package calls.data.remote
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
import android.media.AudioManager
|
||||
import android.os.Build
|
||||
|
||||
class CallAudioManager(private val context: Context) {
|
||||
private val audioManager = context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
|
||||
private var originalMode: Int = AudioManager.MODE_NORMAL
|
||||
private var originalIsSpeakerphoneOn: Boolean = false
|
||||
|
||||
fun startCallMode(isVideoCall: Boolean) {
|
||||
originalMode = audioManager.mode
|
||||
originalIsSpeakerphoneOn = audioManager.isSpeakerphoneOn
|
||||
|
||||
audioManager.mode = AudioManager.MODE_IN_COMMUNICATION
|
||||
setSpeakerphoneOn(isVideoCall)
|
||||
|
||||
// Запрашиваем фокус аудио
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val playbackAttributes = AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_VOICE_COMMUNICATION)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_SPEECH)
|
||||
.build()
|
||||
val focusRequest = AudioFocusRequest.Builder(AudioManager.AUDIOFOCUS_GAIN_TRANSIENT)
|
||||
.setAudioAttributes(playbackAttributes)
|
||||
.build()
|
||||
audioManager.requestAudioFocus(focusRequest)
|
||||
}
|
||||
}
|
||||
|
||||
fun setSpeakerphoneOn(on: Boolean) {
|
||||
audioManager.isSpeakerphoneOn = on
|
||||
}
|
||||
|
||||
fun stopCallMode() {
|
||||
audioManager.mode = originalMode
|
||||
audioManager.isSpeakerphoneOn = originalIsSpeakerphoneOn
|
||||
audioManager.abandonAudioFocus(null)
|
||||
}
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
package calls.data.remote
|
||||
|
||||
import android.content.Context
|
||||
import org.webrtc.*
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class GroupWebRtcManager @Inject constructor(private val context: Context) {
|
||||
private val peerConnections = ConcurrentHashMap<String, PeerConnection>()
|
||||
private val factory: PeerConnectionFactory by lazy { createFactory() }
|
||||
|
||||
private fun createFactory(): PeerConnectionFactory {
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
|
||||
)
|
||||
return PeerConnectionFactory.builder()
|
||||
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
|
||||
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
|
||||
.createPeerConnectionFactory()
|
||||
}
|
||||
|
||||
fun addParticipant(userId: String, observer: PeerConnection.Observer): PeerConnection? {
|
||||
val iceServers = listOf(
|
||||
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
|
||||
)
|
||||
val pc = factory.createPeerConnection(iceServers, observer)
|
||||
if (pc != null) {
|
||||
peerConnections[userId] = pc
|
||||
}
|
||||
return pc
|
||||
}
|
||||
|
||||
fun removeParticipant(userId: String) {
|
||||
peerConnections[userId]?.dispose()
|
||||
peerConnections.remove(userId)
|
||||
}
|
||||
|
||||
fun getPeerConnection(userId: String): PeerConnection? = peerConnections[userId]
|
||||
|
||||
fun closeAll() {
|
||||
peerConnections.values.forEach { it.dispose() }
|
||||
peerConnections.clear()
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
package calls.data.remote
|
||||
|
||||
import android.content.Context
|
||||
import org.webrtc.*
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class WebRtcManager @Inject constructor(private val context: Context) {
|
||||
private var peerConnection: PeerConnection? = null
|
||||
private val factory: PeerConnectionFactory by lazy { createFactory() }
|
||||
|
||||
// Аудио и видео источники
|
||||
private val videoSource by lazy { factory.createVideoSource(false) }
|
||||
private val audioSource by lazy { factory.createAudioSource(MediaConstraints()) }
|
||||
|
||||
private fun createFactory(): PeerConnectionFactory {
|
||||
PeerConnectionFactory.initialize(
|
||||
PeerConnectionFactory.InitializationOptions.builder(context).createInitializationOptions()
|
||||
)
|
||||
return PeerConnectionFactory.builder()
|
||||
.setVideoEncoderFactory(DefaultVideoEncoderFactory(EglBase.create().eglBaseContext, true, true))
|
||||
.setVideoDecoderFactory(DefaultVideoDecoderFactory(EglBase.create().eglBaseContext))
|
||||
.createPeerConnectionFactory()
|
||||
}
|
||||
|
||||
fun initializePeerConnection(observer: PeerConnection.Observer) {
|
||||
val iceServers = listOf(
|
||||
PeerConnection.IceServer.builder("stun:stun.l.google.com:19302").createIceServer()
|
||||
)
|
||||
peerConnection = factory.createPeerConnection(iceServers, observer)
|
||||
}
|
||||
|
||||
fun createOffer(observer: SdpObserver) {
|
||||
peerConnection?.createOffer(observer, MediaConstraints())
|
||||
}
|
||||
|
||||
fun setRemoteDescription(sdp: String, type: SessionDescription.Type, observer: SdpObserver) {
|
||||
peerConnection?.setRemoteDescription(observer, SessionDescription(type, sdp))
|
||||
}
|
||||
|
||||
fun addIceCandidate(candidate: IceCandidate) {
|
||||
peerConnection?.addIceCandidate(candidate)
|
||||
}
|
||||
|
||||
fun close() {
|
||||
peerConnection?.dispose()
|
||||
peerConnection = null
|
||||
}
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package calls.presentation
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import core.presentation.components.AppAvatar
|
||||
import ru.knot.messager.R
|
||||
|
||||
@Composable
|
||||
fun CallScreen(
|
||||
viewModel: CallViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(
|
||||
Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color(0xFF0F0F10),
|
||||
Color(0xFF161618),
|
||||
Color(0xFF6366F1).copy(alpha = 0.2f)
|
||||
)
|
||||
)
|
||||
)
|
||||
) {
|
||||
// Контент звонка (Аватар или Видео)
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(top = 100.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
AppAvatar(
|
||||
url = state.callerAvatar,
|
||||
name = state.callerName,
|
||||
size = 140.dp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
Text(
|
||||
text = state.callerName,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
color = Color.White,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
|
||||
Text(
|
||||
text = when (state.status) {
|
||||
CallStatus.INCOMING -> "Входящий звонок..."
|
||||
CallStatus.OUTGOING -> "Вызов..."
|
||||
CallStatus.CONNECTED -> "00:00" // TODO: Timer
|
||||
CallStatus.ENDED -> "Звонок завершен"
|
||||
else -> ""
|
||||
},
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = Color.White.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
// Кнопки управления (Внизу)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomCenter)
|
||||
.padding(bottom = 60.dp)
|
||||
.fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(32.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (state.status == CallStatus.INCOMING) {
|
||||
// Кнопка отклонить
|
||||
CallActionButton(
|
||||
icon = Icons.Default.CallEnd,
|
||||
backgroundColor = Color.Red,
|
||||
onClick = { viewModel.endCall(); onBack() }
|
||||
)
|
||||
// Кнопка принять
|
||||
CallActionButton(
|
||||
icon = Icons.Default.Call,
|
||||
backgroundColor = Color(0xFF10B981), // Green
|
||||
onClick = { viewModel.acceptCall() }
|
||||
)
|
||||
} else {
|
||||
// Стандартные кнопки во время разговора
|
||||
IconButton(
|
||||
onClick = { /* viewModel.toggleMic() */ },
|
||||
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Icon(Icons.Default.Mic, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
|
||||
CallActionButton(
|
||||
icon = Icons.Default.CallEnd,
|
||||
backgroundColor = Color.Red,
|
||||
onClick = { viewModel.endCall(); onBack() }
|
||||
)
|
||||
|
||||
IconButton(
|
||||
onClick = { /* viewModel.toggleSpeaker() */ },
|
||||
modifier = Modifier.size(56.dp).clip(CircleShape).background(Color.White.copy(alpha = 0.1f))
|
||||
) {
|
||||
Icon(Icons.Default.VolumeUp, contentDescription = null, tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CallActionButton(
|
||||
icon: androidx.compose.ui.graphics.vector.ImageVector,
|
||||
backgroundColor: Color,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = onClick,
|
||||
containerColor = backgroundColor,
|
||||
contentColor = Color.White,
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(64.dp)
|
||||
) {
|
||||
Icon(icon, contentDescription = null, modifier = Modifier.size(32.dp))
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
package calls.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import calls.data.remote.WebRtcManager
|
||||
import chats.data.remote.signalr.ChatEvent
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import org.webrtc.*
|
||||
import javax.inject.Inject
|
||||
|
||||
enum class CallStatus { IDLE, INCOMING, OUTGOING, CONNECTED, ENDED }
|
||||
|
||||
data class CallState(
|
||||
val status: CallStatus = CallStatus.IDLE,
|
||||
val chatId: String? = null,
|
||||
val callerName: String = "",
|
||||
val callerAvatar: String? = null,
|
||||
val isMuted: Boolean = false,
|
||||
val isSpeakerOn: Boolean = false,
|
||||
val localVideoTrack: VideoTrack? = null,
|
||||
val remoteVideoTrack: VideoTrack? = null
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class CallViewModel @Inject constructor(
|
||||
private val webRtcManager: WebRtcManager,
|
||||
private val signalrClient: ChatHubClient
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(CallState())
|
||||
val state: StateFlow<CallState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
observeSignaling()
|
||||
}
|
||||
|
||||
private fun observeSignaling() {
|
||||
signalrClient.events
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is ChatEvent.CallIncoming -> onIncomingCall(event)
|
||||
is ChatEvent.CallAnswered -> onCallAnswered(event)
|
||||
is ChatEvent.IceCandidateReceived -> onIceCandidate(event)
|
||||
is ChatEvent.CallEnded -> onCallEnded()
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun onIncomingCall(event: ChatEvent.CallIncoming) {
|
||||
_state.update { it.copy(
|
||||
status = CallStatus.INCOMING,
|
||||
chatId = event.chatId,
|
||||
callerName = "User ${event.from}" // TODO: Load actual user info
|
||||
) }
|
||||
// Set remote description from offer
|
||||
webRtcManager.setRemoteDescription(event.offer, SessionDescription.Type.OFFER, object : SdpObserver {
|
||||
override fun onCreateSuccess(p0: SessionDescription?) {}
|
||||
override fun onSetSuccess() {}
|
||||
override fun onCreateFailure(p0: String?) {}
|
||||
override fun onSetFailure(p0: String?) {}
|
||||
})
|
||||
}
|
||||
|
||||
private fun onCallAnswered(event: ChatEvent.CallAnswered) {
|
||||
_state.update { it.copy(status = CallStatus.CONNECTED) }
|
||||
webRtcManager.setRemoteDescription(event.answer, SessionDescription.Type.ANSWER, object : SdpObserver {
|
||||
override fun onCreateSuccess(p0: SessionDescription?) {}
|
||||
override fun onSetSuccess() {}
|
||||
override fun onCreateFailure(p0: String?) {}
|
||||
override fun onSetFailure(p0: String?) {}
|
||||
})
|
||||
}
|
||||
|
||||
private fun onIceCandidate(event: ChatEvent.IceCandidateReceived) {
|
||||
// Parse candidate JSON and add to peer connection
|
||||
// webRtcManager.addIceCandidate(...)
|
||||
}
|
||||
|
||||
private fun onCallEnded() {
|
||||
_state.update { it.copy(status = CallStatus.ENDED) }
|
||||
webRtcManager.close()
|
||||
}
|
||||
|
||||
fun acceptCall() {
|
||||
val chatId = _state.value.chatId ?: return
|
||||
// Create answer and send via SignalR
|
||||
}
|
||||
|
||||
fun endCall() {
|
||||
val chatId = _state.value.chatId ?: return
|
||||
// Send call_end via SignalR
|
||||
onCallEnded()
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
package calls.presentation
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.data.remote.signalr.ChatEvent
|
||||
import calls.data.remote.GroupWebRtcManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import org.webrtc.*
|
||||
import javax.inject.Inject
|
||||
|
||||
data class ParticipantState(
|
||||
val userId: String,
|
||||
val videoTrack: VideoTrack? = null,
|
||||
val isAudioMuted: Boolean = false,
|
||||
val isVideoDisabled: Boolean = false
|
||||
)
|
||||
|
||||
data class GroupCallState(
|
||||
val chatId: String? = null,
|
||||
val participants: Map<String, ParticipantState> = emptyMap(),
|
||||
val isMicEnabled: Boolean = true,
|
||||
val isCameraEnabled: Boolean = true
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class GroupCallViewModel @Inject constructor(
|
||||
private val webRtcManager: GroupWebRtcManager,
|
||||
private val signalrClient: ChatHubClient
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(GroupCallState())
|
||||
val state: StateFlow<GroupCallState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
observeSignalREvents()
|
||||
}
|
||||
|
||||
private fun observeSignalREvents() {
|
||||
signalrClient.events.onEach { event ->
|
||||
when (event) {
|
||||
is ChatEvent.GroupCallUserJoined -> handleUserJoined(event.userId)
|
||||
is ChatEvent.GroupCallUserLeft -> handleUserLeft(event.userId)
|
||||
is ChatEvent.GroupCallOffer -> handleOffer(event.from, event.offer)
|
||||
is ChatEvent.GroupCallAnswer -> handleAnswer(event.from, event.answer)
|
||||
// Дополнительные обработчики ICE кандидатов и т.д.
|
||||
else -> Unit
|
||||
}
|
||||
}.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
private fun handleUserJoined(userId: String) {
|
||||
// Создаем PeerConnection для нового участника
|
||||
// Логика идентична портированному CallModal.tsx
|
||||
_state.update { it.copy(participants = it.participants + (userId to ParticipantState(userId))) }
|
||||
}
|
||||
|
||||
private fun handleUserLeft(userId: String) {
|
||||
webRtcManager.removeParticipant(userId)
|
||||
_state.update { it.copy(participants = it.participants - userId) }
|
||||
}
|
||||
|
||||
private fun handleOffer(from: String, sdp: String) {
|
||||
// Установка RemoteDescription и создание Answer
|
||||
}
|
||||
|
||||
private fun handleAnswer(from: String, sdp: String) {
|
||||
// Установка RemoteDescription
|
||||
}
|
||||
|
||||
fun toggleMic() {
|
||||
_state.update { it.copy(isMicEnabled = !it.isMicEnabled) }
|
||||
// Логика управления AudioTrack
|
||||
}
|
||||
|
||||
fun toggleCamera() {
|
||||
_state.update { it.copy(isCameraEnabled = !it.isCameraEnabled) }
|
||||
// Логика управления VideoTrack
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
super.onCleared()
|
||||
webRtcManager.closeAll()
|
||||
}
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
package calls.presentation.components
|
||||
|
||||
import android.view.ViewGroup
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import org.webrtc.EglBase
|
||||
import org.webrtc.SurfaceViewRenderer
|
||||
import org.webrtc.VideoTrack
|
||||
|
||||
@Composable
|
||||
fun VideoGrid(
|
||||
participants: Map<String, VideoTrack?>,
|
||||
localVideoTrack: VideoTrack?
|
||||
) {
|
||||
val eglBaseContext = remember { EglBase.create().eglBaseContext }
|
||||
val allVideoTracks = remember(participants, localVideoTrack) {
|
||||
listOfNotNull(localVideoTrack) + participants.values.filterNotNull()
|
||||
}
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(if (allVideoTracks.size <= 2) 1 else 2),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(8.dp)
|
||||
) {
|
||||
items(allVideoTracks) { track ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.fillMaxWidth()
|
||||
.aspectRatio(if (allVideoTracks.size == 1) 0.6f else 1f)
|
||||
) {
|
||||
VideoRenderer(videoTrack = track, eglBaseContext = eglBaseContext)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun VideoRenderer(
|
||||
videoTrack: VideoTrack,
|
||||
eglBaseContext: EglBase.Context
|
||||
) {
|
||||
AndroidView(
|
||||
factory = { context ->
|
||||
SurfaceViewRenderer(context).apply {
|
||||
init(eglBaseContext, null)
|
||||
layoutParams = ViewGroup.LayoutParams(
|
||||
ViewGroup.LayoutParams.MATCH_PARENT,
|
||||
ViewGroup.LayoutParams.MATCH_PARENT
|
||||
)
|
||||
}
|
||||
},
|
||||
update = { view ->
|
||||
videoTrack.addSink(view)
|
||||
},
|
||||
onRelease = { view ->
|
||||
videoTrack.removeSink(view)
|
||||
view.release()
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
package chats.data.remote.api
|
||||
|
||||
import chats.data.remote.dto.ChatDto
|
||||
import chats.data.remote.dto.MessageDto
|
||||
import retrofit2.http.*
|
||||
|
||||
data class SendMessageRequest(
|
||||
val content: String?,
|
||||
val type: String = "text",
|
||||
val attachments: List<AttachmentRequest>? = null,
|
||||
val replyToId: String? = null,
|
||||
val quote: String? = null,
|
||||
val forwardedFromId: String? = null
|
||||
)
|
||||
|
||||
data class AttachmentRequest(
|
||||
val type: String,
|
||||
val url: String,
|
||||
val fileName: String,
|
||||
val fileSize: Long
|
||||
)
|
||||
|
||||
interface ChatApi {
|
||||
@GET("chats")
|
||||
suspend fun getChats(): List<ChatDto>
|
||||
|
||||
@GET("messages/chat/{chatId}")
|
||||
suspend fun getMessages(
|
||||
@Path("chatId") chatId: String,
|
||||
@Query("cursor") cursor: String? = null,
|
||||
@Query("pivot") pivot: Long? = null,
|
||||
@Query("limit") limit: Int? = 50
|
||||
): List<MessageDto>
|
||||
|
||||
@POST("messages/chat/{chatId}")
|
||||
suspend fun sendMessage(@Path("chatId") chatId: String, @Body request: SendMessageRequest): MessageDto
|
||||
|
||||
@Multipart
|
||||
@POST("messages/upload")
|
||||
suspend fun uploadFile(@Part file: okhttp3.MultipartBody.Part): FileUploadResponse
|
||||
|
||||
// Klipy GIF API
|
||||
@GET("klipy/trending")
|
||||
suspend fun getTrendingGifs(@Query("page") page: Int): KlipyResponse
|
||||
|
||||
@GET("klipy/search")
|
||||
suspend fun searchGifs(@Query("q") query: String, @Query("page") page: Int): KlipyResponse
|
||||
|
||||
@GET("klipy/categories")
|
||||
suspend fun getGifCategories(): GifCategoriesResponse
|
||||
|
||||
@POST("klipy/shared/{id}")
|
||||
suspend fun markGifShared(@Path("id") id: String, @Body query: String)
|
||||
|
||||
@POST("messages/{messageId}/reactions")
|
||||
suspend fun addReaction(@Path("messageId") messageId: String, @Query("emoji") emoji: String)
|
||||
|
||||
@POST("chats/personal")
|
||||
suspend fun createPersonalChat(@Body request: CreatePersonalChatRequest): ChatDto
|
||||
|
||||
@POST("chats/{chatId}/typing")
|
||||
suspend fun sendTypingStatus(@Path("chatId") chatId: String)
|
||||
|
||||
@POST("chats/{chatId}/read")
|
||||
suspend fun markMessagesAsRead(@Path("chatId") chatId: String, @Body lastMessageId: String)
|
||||
|
||||
@DELETE("messages/{messageId}")
|
||||
suspend fun deleteMessage(@Path("messageId") messageId: String, @Query("forEveryone") forEveryone: Boolean): retrofit2.Response<Unit>
|
||||
|
||||
@PUT("messages/{messageId}")
|
||||
suspend fun editMessage(@Path("messageId") messageId: String, @Body request: SendMessageRequest): MessageDto
|
||||
}
|
||||
|
||||
data class CreatePersonalChatRequest(
|
||||
val userId: String
|
||||
)
|
||||
|
||||
data class KlipyResponse(
|
||||
val data: KlipyDataWrapper
|
||||
)
|
||||
|
||||
data class KlipyDataWrapper(
|
||||
val data: List<KlipyGifDto>
|
||||
)
|
||||
|
||||
data class KlipyGifDto(
|
||||
val id: String,
|
||||
val images: GifImagesDto? = null,
|
||||
val files: Map<String, Map<String, GifImageSourceDto>>? = null,
|
||||
val file: Map<String, Map<String, GifImageSourceDto>>? = null,
|
||||
val media_formats: Map<String, GifImageSourceDto>? = null,
|
||||
val title: String? = null
|
||||
)
|
||||
|
||||
data class GifImagesDto(
|
||||
val fixed_height: GifImageSourceDto? = null,
|
||||
val original: GifImageSourceDto? = null,
|
||||
val fixed_height_small: GifImageSourceDto? = null
|
||||
)
|
||||
|
||||
data class GifImageSourceDto(
|
||||
val url: String
|
||||
)
|
||||
|
||||
data class GifCategoryDto(
|
||||
val category: String,
|
||||
val preview_url: String,
|
||||
val query: String
|
||||
)
|
||||
|
||||
data class FileUploadResponse(
|
||||
val url: String,
|
||||
val filename: String,
|
||||
val size: Long
|
||||
)
|
||||
|
||||
data class GifCategoriesResponse(
|
||||
val data: GifCategoriesData
|
||||
)
|
||||
|
||||
data class GifCategoriesData(
|
||||
val categories: List<GifCategoryDto>
|
||||
)
|
||||
@@ -1,57 +0,0 @@
|
||||
package chats.data.remote.dto
|
||||
|
||||
import com.google.gson.annotations.SerializedName
|
||||
|
||||
data class UserBasicDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("username") val username: String? = null,
|
||||
@SerializedName("displayName") val displayName: String? = null,
|
||||
@SerializedName("avatarUrl") val avatarUrl: String? = null
|
||||
)
|
||||
|
||||
data class MessageDto(
|
||||
@SerializedName("id", alternate = ["Id"]) val id: String,
|
||||
@SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
|
||||
@SerializedName("senderId", alternate = ["SenderId"]) val senderId: String? = null,
|
||||
@SerializedName("content", alternate = ["Content"]) val content: String? = null,
|
||||
@SerializedName("type", alternate = ["Type"]) val type: String? = null,
|
||||
@SerializedName("sequenceId", alternate = ["SequenceId"]) val sequenceId: Int? = null,
|
||||
@SerializedName("createdAt", alternate = ["CreatedAt"]) val createdAt: String? = null,
|
||||
@SerializedName("sender", alternate = ["Sender"]) val sender: UserBasicDto? = null,
|
||||
@SerializedName("media", alternate = ["Media"]) val media: List<MediaItemDto> = emptyList(),
|
||||
@SerializedName("reactions", alternate = ["Reactions"]) val reactions: List<ReactionDto>? = emptyList(),
|
||||
@SerializedName("replyTo", alternate = ["ReplyTo"]) val replyTo: MessageDto? = null,
|
||||
@SerializedName("isPinned", alternate = ["IsPinned"]) val isPinned: Boolean? = false,
|
||||
@SerializedName("forwardedFromId", alternate = ["ForwardedFromId"]) val forwardedFromId: String? = null,
|
||||
@SerializedName("forwardedFrom", alternate = ["ForwardedFrom"]) val forwardedFrom: UserBasicDto? = null
|
||||
)
|
||||
|
||||
data class ReactionDto(
|
||||
@SerializedName("emoji") val emoji: String,
|
||||
@SerializedName("count") val count: Int,
|
||||
@SerializedName("isSetByMe") val isSetByMe: Boolean
|
||||
)
|
||||
|
||||
data class MediaItemDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("url") val url: String,
|
||||
@SerializedName("filename") val filename: String? = null,
|
||||
@SerializedName("size") val size: Long? = null,
|
||||
@SerializedName("duration") val duration: Double? = null
|
||||
)
|
||||
|
||||
data class ChatDto(
|
||||
@SerializedName("id") val id: String,
|
||||
@SerializedName("type") val type: String,
|
||||
@SerializedName("name") val name: String? = null,
|
||||
@SerializedName("avatar") val avatar: String? = null,
|
||||
@SerializedName("unreadCount", alternate = ["UnreadCount", "unread_count"]) val unreadCount: Int = 0,
|
||||
@SerializedName("messages") val messages: List<MessageDto> = emptyList(),
|
||||
@SerializedName("members") val members: List<ChatMemberDto> = emptyList()
|
||||
)
|
||||
|
||||
data class ChatMemberDto(
|
||||
@SerializedName("userId") val userId: String,
|
||||
@SerializedName("user") val user: UserBasicDto? = null
|
||||
)
|
||||
@@ -1,33 +0,0 @@
|
||||
package chats.data.remote.signalr
|
||||
|
||||
import chats.data.remote.dto.ChatDto
|
||||
import chats.data.remote.dto.MessageDto
|
||||
|
||||
sealed class ChatEvent {
|
||||
data class NewMessage(val message: MessageDto) : ChatEvent()
|
||||
data class MessageEdited(val messageId: String, val chatId: String, val content: String) : ChatEvent()
|
||||
data class MessageDeleted(val messageId: String, val chatId: String) : ChatEvent()
|
||||
data class MessagesRead(val chatId: String, val userId: String, val lastReadSequenceId: Int) : ChatEvent()
|
||||
data class UserTyping(val chatId: String, val userId: String) : ChatEvent()
|
||||
data class UserStoppedTyping(val chatId: String, val userId: String) : ChatEvent()
|
||||
data class UserOnline(val userId: String) : ChatEvent()
|
||||
data class UserOffline(val userId: String, val lastSeen: String?) : ChatEvent()
|
||||
data class NewChat(val chat: ChatDto) : ChatEvent()
|
||||
data class ReactionUpdated(val messageId: String, val chatId: String, val userId: String, val emoji: String, val isRemoved: Boolean = false) : ChatEvent()
|
||||
data class MessagePinned(val chatId: String, val message: MessageDto) : ChatEvent()
|
||||
data class MessageUnpinned(val chatId: String, val messageId: String) : ChatEvent()
|
||||
|
||||
// Call Events (WebRTC Signaling)
|
||||
data class CallIncoming(val chatId: String, val from: String, val offer: String, val callType: String) : ChatEvent()
|
||||
data class CallAnswered(val chatId: String, val answer: String) : ChatEvent()
|
||||
data class IceCandidateReceived(val chatId: String, val candidate: String) : ChatEvent()
|
||||
data class CallEnded(val chatId: String) : ChatEvent()
|
||||
|
||||
// Group Call Events
|
||||
data class GroupCallIncoming(val chatId: String, val from: String, val callerInfo: Any) : ChatEvent()
|
||||
data class GroupCallParticipants(val chatId: String, val participants: List<String>) : ChatEvent()
|
||||
data class GroupCallUserJoined(val chatId: String, val userId: String) : ChatEvent()
|
||||
data class GroupCallUserLeft(val chatId: String, val userId: String) : ChatEvent()
|
||||
data class GroupCallOffer(val chatId: String, val from: String, val offer: String) : ChatEvent()
|
||||
data class GroupCallAnswer(val chatId: String, val from: String, val answer: String) : ChatEvent()
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
package chats.data.remote.signalr
|
||||
|
||||
import android.util.Log
|
||||
import io.reactivex.rxjava3.core.Single
|
||||
import com.microsoft.signalr.HubConnection
|
||||
import com.microsoft.signalr.HubConnectionBuilder
|
||||
import com.microsoft.signalr.HubConnectionState
|
||||
import chats.data.remote.dto.ChatDto
|
||||
import chats.data.remote.dto.MessageDto
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
import kotlinx.coroutines.delay
|
||||
|
||||
data class ReadMessagesRequest(
|
||||
val chatId: String,
|
||||
val lastReadMessageId: String,
|
||||
val lastReadSequenceId: Int
|
||||
)
|
||||
|
||||
data class MessagesReadEvent(
|
||||
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
|
||||
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
|
||||
@com.google.gson.annotations.SerializedName("lastReadSequenceId", alternate = ["LastReadSequenceId"]) val lastReadSequenceId: Int? = null
|
||||
) {
|
||||
val effectiveChatId: String get() = chatId ?: ""
|
||||
val effectiveUserId: String get() = userId ?: ""
|
||||
val effectiveLastReadSequenceId: Int get() = lastReadSequenceId ?: 0
|
||||
}
|
||||
|
||||
data class ReactionEvent(
|
||||
@com.google.gson.annotations.SerializedName("messageId", alternate = ["MessageId"]) val messageId: String? = null,
|
||||
@com.google.gson.annotations.SerializedName("chatId", alternate = ["ChatId"]) val chatId: String? = null,
|
||||
@com.google.gson.annotations.SerializedName("userId", alternate = ["UserId"]) val userId: String? = null,
|
||||
@com.google.gson.annotations.SerializedName("username", alternate = ["Username", "UserName"]) val username: String? = null,
|
||||
@com.google.gson.annotations.SerializedName("emoji", alternate = ["Emoji"]) val emoji: String? = null
|
||||
)
|
||||
|
||||
enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
|
||||
|
||||
@Singleton
|
||||
class ChatHubClient @Inject constructor() {
|
||||
private var hubConnection: HubConnection? = null
|
||||
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 1024)
|
||||
val events: SharedFlow<ChatEvent> = _events.asSharedFlow()
|
||||
|
||||
private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED)
|
||||
val status: StateFlow<ConnectionStatus> = _status.asStateFlow()
|
||||
|
||||
private val scope = CoroutineScope(Dispatchers.IO)
|
||||
private var lastBaseUrl: String? = null
|
||||
private var lastToken: String? = null
|
||||
|
||||
fun connect(baseUrl: String, accessToken: String) {
|
||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
|
||||
|
||||
lastBaseUrl = baseUrl
|
||||
lastToken = accessToken
|
||||
_status.value = ConnectionStatus.CONNECTING
|
||||
|
||||
hubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat")
|
||||
.withAccessTokenProvider(Single.just(accessToken))
|
||||
.build()
|
||||
|
||||
setupHandlers()
|
||||
|
||||
hubConnection?.onClosed { exception ->
|
||||
Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception)
|
||||
_status.value = ConnectionStatus.DISCONNECTED
|
||||
scope.launch {
|
||||
delay(5000)
|
||||
connect(baseUrl, accessToken)
|
||||
}
|
||||
}
|
||||
|
||||
scope.launch {
|
||||
try {
|
||||
hubConnection?.start()?.blockingAwait()
|
||||
_status.value = ConnectionStatus.CONNECTED
|
||||
Log.d("ChatHubClient", "SignalR Connected")
|
||||
} catch (e: Exception) {
|
||||
Log.e("ChatHubClient", "SignalR Connection Error", e)
|
||||
_status.value = ConnectionStatus.DISCONNECTED
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun setupHandlers() {
|
||||
hubConnection?.let { conn ->
|
||||
conn.on("new_message", { message: MessageDto ->
|
||||
_events.tryEmit(ChatEvent.NewMessage(message))
|
||||
}, MessageDto::class.java)
|
||||
|
||||
conn.on("message_edited", { messageId: String, chatId: String, content: String ->
|
||||
_events.tryEmit(ChatEvent.MessageEdited(messageId, chatId, content))
|
||||
}, String::class.java, String::class.java, String::class.java)
|
||||
|
||||
conn.on("message_deleted", { messageId: String, chatId: String ->
|
||||
_events.tryEmit(ChatEvent.MessageDeleted(messageId, chatId))
|
||||
}, String::class.java, String::class.java)
|
||||
|
||||
conn.on("messages_read", { data: MessagesReadEvent ->
|
||||
_events.tryEmit(ChatEvent.MessagesRead(
|
||||
data.effectiveChatId,
|
||||
data.effectiveUserId,
|
||||
data.effectiveLastReadSequenceId
|
||||
))
|
||||
}, MessagesReadEvent::class.java)
|
||||
|
||||
conn.on("user_typing", { data: ReactionEvent ->
|
||||
_events.tryEmit(ChatEvent.UserTyping(data.chatId ?: "", data.userId ?: ""))
|
||||
}, ReactionEvent::class.java)
|
||||
|
||||
conn.on("user_stopped_typing", { data: ReactionEvent ->
|
||||
_events.tryEmit(ChatEvent.UserStoppedTyping(data.chatId ?: "", data.userId ?: ""))
|
||||
}, ReactionEvent::class.java)
|
||||
|
||||
conn.on("user_online", { userId: String ->
|
||||
_events.tryEmit(ChatEvent.UserOnline(userId))
|
||||
}, String::class.java)
|
||||
|
||||
conn.on("new_chat", { chat: ChatDto ->
|
||||
_events.tryEmit(ChatEvent.NewChat(chat))
|
||||
}, ChatDto::class.java)
|
||||
|
||||
conn.on("reaction_added", { data: ReactionEvent ->
|
||||
_events.tryEmit(ChatEvent.ReactionUpdated(
|
||||
data.messageId ?: "",
|
||||
data.chatId ?: "",
|
||||
data.userId ?: "",
|
||||
data.emoji ?: "",
|
||||
isRemoved = false
|
||||
))
|
||||
}, ReactionEvent::class.java)
|
||||
|
||||
conn.on("reaction_removed", { data: ReactionEvent ->
|
||||
_events.tryEmit(ChatEvent.ReactionUpdated(
|
||||
data.messageId ?: "",
|
||||
data.chatId ?: "",
|
||||
data.userId ?: "",
|
||||
data.emoji ?: "",
|
||||
isRemoved = true
|
||||
))
|
||||
}, ReactionEvent::class.java)
|
||||
|
||||
// WebRTC Signaling Handlers
|
||||
conn.on("call_incoming", { chatId: String, from: String, offer: String, callType: String ->
|
||||
_events.tryEmit(ChatEvent.CallIncoming(chatId, from, offer, callType))
|
||||
}, String::class.java, String::class.java, String::class.java, String::class.java)
|
||||
|
||||
conn.on("call_answered", { chatId: String, answer: String ->
|
||||
_events.tryEmit(ChatEvent.CallAnswered(chatId, answer))
|
||||
}, String::class.java, String::class.java)
|
||||
|
||||
conn.on("ice_candidate", { chatId: String, candidate: String ->
|
||||
_events.tryEmit(ChatEvent.IceCandidateReceived(chatId, candidate))
|
||||
}, String::class.java, String::class.java)
|
||||
|
||||
conn.on("call_ended", { chatId: String ->
|
||||
_events.tryEmit(ChatEvent.CallEnded(chatId))
|
||||
}, String::class.java)
|
||||
conn.on("message_pinned", { data: Map<String, Any> ->
|
||||
// The web version expects a message object, but here we might get a partial DTO or just IDs.
|
||||
// Let's assume we get { chatId, message: MessageDto } based on web
|
||||
// We'll trust the DTO mapping if possible, but SignalR java client is picky with nested objects in Maps.
|
||||
// For simplicity, we might needs a dedicated DTO if it fails.
|
||||
}, Map::class.java)
|
||||
|
||||
conn.on("message_pinned", { chatId: String, message: MessageDto ->
|
||||
_events.tryEmit(ChatEvent.MessagePinned(chatId, message))
|
||||
}, String::class.java, MessageDto::class.java)
|
||||
|
||||
conn.on("message_unpinned", { chatId: String, messageId: String ->
|
||||
_events.tryEmit(ChatEvent.MessageUnpinned(chatId, messageId))
|
||||
}, String::class.java, String::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
hubConnection?.stop()
|
||||
_status.value = ConnectionStatus.DISCONNECTED
|
||||
}
|
||||
|
||||
fun addReaction(messageId: String, chatId: String, emoji: String) {
|
||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||
hubConnection?.invoke("add_reaction", mapOf(
|
||||
"messageId" to messageId,
|
||||
"chatId" to chatId,
|
||||
"emoji" to emoji
|
||||
))?.doOnError { Log.e("ChatHubClient", "add_reaction error", it) }
|
||||
?.subscribe()
|
||||
Log.d("ChatHubClient", "Invoked add_reaction: $emoji on $messageId")
|
||||
} else {
|
||||
Log.w("ChatHubClient", "Cannot add_reaction: Not connected")
|
||||
}
|
||||
}
|
||||
|
||||
fun removeReaction(messageId: String, chatId: String, emoji: String) {
|
||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||
hubConnection?.invoke("remove_reaction", mapOf(
|
||||
"messageId" to messageId,
|
||||
"chatId" to chatId,
|
||||
"emoji" to emoji
|
||||
))?.doOnError { Log.e("ChatHubClient", "remove_reaction error", it) }
|
||||
?.subscribe()
|
||||
Log.d("ChatHubClient", "Invoked remove_reaction: $emoji on $messageId")
|
||||
}
|
||||
}
|
||||
|
||||
fun pinMessage(messageId: String, chatId: String) {
|
||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||
hubConnection?.invoke("pin_message", mapOf(
|
||||
"messageId" to messageId,
|
||||
"chatId" to chatId
|
||||
))?.doOnError { Log.e("ChatHubClient", "pin_message error", it) }
|
||||
?.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
fun unpinMessage(messageId: String, chatId: String) {
|
||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||
hubConnection?.invoke("unpin_message", mapOf(
|
||||
"messageId" to messageId,
|
||||
"chatId" to chatId
|
||||
))?.doOnError { Log.e("ChatHubClient", "unpin_message error", it) }
|
||||
?.subscribe()
|
||||
}
|
||||
}
|
||||
|
||||
fun readMessages(request: ReadMessagesRequest) {
|
||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||
hubConnection?.invoke("read_messages", request)
|
||||
?.doOnError { Log.e("ChatHubClient", "read_messages error", it) }
|
||||
?.subscribe()
|
||||
Log.d("ChatHubClient", "Sent read_messages for chat: ${request.chatId}")
|
||||
}
|
||||
}
|
||||
|
||||
fun joinChat(chatId: String) {
|
||||
scope.launch {
|
||||
// Wait for connection to be established if it's currently connecting
|
||||
var attempts = 0
|
||||
while (hubConnection?.connectionState != HubConnectionState.CONNECTED && attempts < 10) {
|
||||
delay(500)
|
||||
attempts++
|
||||
}
|
||||
|
||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||
hubConnection?.invoke("join_chat", chatId)
|
||||
?.doOnError { Log.e("ChatHubClient", "join_chat error", it) }
|
||||
?.subscribe()
|
||||
Log.d("ChatHubClient", "Joined chat room: $chatId")
|
||||
} else {
|
||||
Log.e("ChatHubClient", "Failed to join chat room $chatId: Not connected")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendTypingIndicator(chatId: String) {
|
||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||
hubConnection?.invoke("typing_start", chatId)
|
||||
?.doOnError { Log.e("ChatHubClient", "typing_start error", it) }
|
||||
?.subscribe()
|
||||
Log.d("ChatHubClient", "Sent typing indicator for chat: $chatId")
|
||||
}
|
||||
}
|
||||
|
||||
fun sendUserStoppedTyping(chatId: String) {
|
||||
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) {
|
||||
hubConnection?.invoke("typing_stop", chatId)
|
||||
?.doOnError { Log.e("ChatHubClient", "typing_stop error", it) }
|
||||
?.subscribe()
|
||||
Log.d("ChatHubClient", "Sent user stopped typing for chat: $chatId")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
package chats.data.remote.signalr
|
||||
|
||||
import android.content.Context
|
||||
import core.notifications.data.ActiveChatTracker
|
||||
import core.notifications.data.NotificationHelper
|
||||
import core.security.TokenManager
|
||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import javax.inject.Inject
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Singleton
|
||||
class SignalRNotificationObserver @Inject constructor(
|
||||
private val signalrClient: ChatHubClient,
|
||||
private val activeChatTracker: ActiveChatTracker,
|
||||
private val tokenManager: TokenManager,
|
||||
private val chatRepository: chats.domain.repository.ChatRepository,
|
||||
@ApplicationContext private val context: Context
|
||||
) {
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
private var isStarted = false
|
||||
private val processedMessageIds = mutableSetOf<String>()
|
||||
|
||||
fun refresh() {
|
||||
scope.launch {
|
||||
try {
|
||||
val chats = chatRepository.getChats()
|
||||
val total = chats.sumOf { it.unreadCount }
|
||||
activeChatTracker.setTotalUnreadCount(total)
|
||||
} catch (e: Exception) {
|
||||
// Ignore load error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun start() {
|
||||
if (isStarted) return
|
||||
isStarted = true
|
||||
|
||||
// Initial count load
|
||||
refresh()
|
||||
|
||||
signalrClient.events
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is ChatEvent.NewMessage -> {
|
||||
val currentUserId = tokenManager.getUserId()
|
||||
val message = event.message
|
||||
|
||||
// Don't show if it's our message or already processed
|
||||
if (message.senderId == currentUserId) return@onEach
|
||||
if (processedMessageIds.contains(message.id)) return@onEach
|
||||
|
||||
// Mark as processed
|
||||
processedMessageIds.add(message.id)
|
||||
if (processedMessageIds.size > 200) {
|
||||
processedMessageIds.remove(processedMessageIds.first())
|
||||
}
|
||||
|
||||
// Increment immediately for UI feedback
|
||||
activeChatTracker.incrementUnreadCount()
|
||||
|
||||
// Refresh total count from source of truth in background
|
||||
refresh()
|
||||
|
||||
// Don't show if this chat is currently open
|
||||
if (activeChatTracker.currentChatId.value == message.chatId) return@onEach
|
||||
|
||||
NotificationHelper.showNotification(
|
||||
context = context,
|
||||
title = message.sender?.displayName ?: "Новое сообщение",
|
||||
body = message.content ?: "Вам прислали вложение",
|
||||
type = "chat",
|
||||
chatId = message.chatId,
|
||||
notificationId = message.id.hashCode(),
|
||||
totalCount = activeChatTracker.totalUnreadCount.value
|
||||
)
|
||||
}
|
||||
is ChatEvent.MessagesRead -> {
|
||||
// If anyone read messages, sync our total count
|
||||
refresh()
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
}
|
||||
@@ -1,151 +0,0 @@
|
||||
package chats.data.repository
|
||||
|
||||
import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.api.SendMessageRequest
|
||||
import chats.data.remote.dto.ChatDto
|
||||
import chats.data.remote.dto.MessageDto
|
||||
import chats.data.remote.dto.MediaItemDto
|
||||
import chats.data.remote.dto.ReactionDto
|
||||
import chats.domain.model.Chat
|
||||
import chats.domain.model.Message
|
||||
import chats.domain.model.MediaType
|
||||
import chats.domain.repository.ChatRepository
|
||||
import core.network.ServerConfig
|
||||
import chats.data.remote.signalr.ReadMessagesRequest
|
||||
import core.security.TokenManager
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import javax.inject.Inject
|
||||
import kotlinx.coroutines.flow.map
|
||||
|
||||
class ChatRepositoryImpl @Inject constructor(
|
||||
private val api: ChatApi,
|
||||
private val tokenManager: TokenManager,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val hubClient: chats.data.remote.signalr.ChatHubClient
|
||||
) : ChatRepository {
|
||||
private val gson = com.google.gson.Gson()
|
||||
|
||||
override suspend fun getChats(): List<Chat> {
|
||||
val currentUserId = tokenManager.getUserId() ?: ""
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
return api.getChats().map { it.toDomain(currentUserId, baseUrl) }
|
||||
}
|
||||
|
||||
override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>> {
|
||||
// Кэш отключён - всегда возвращаем пустой поток
|
||||
return kotlinx.coroutines.flow.flowOf(emptyList())
|
||||
}
|
||||
|
||||
override suspend fun getMessages(chatId: String, cursor: String?, pivot: Long?, limit: Int?): List<Message> {
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
val currentUserId = tokenManager.getUserId() ?: ""
|
||||
return try {
|
||||
android.util.Log.d("ChatRepo", "FETCH: chatId=$chatId, cursor=$cursor, limit=$limit")
|
||||
val messages = api.getMessages(chatId, cursor = cursor, limit = limit)
|
||||
|
||||
if (messages.isNotEmpty()) {
|
||||
android.util.Log.d("ChatRepo", "Received ${messages.size} messages. TopSeq: ${messages.first().sequenceId}, BottomSeq: ${messages.last().sequenceId}")
|
||||
}
|
||||
|
||||
// Мапим в доменные модели. По умолчанию считаем прочитанными,
|
||||
// так как unreadCount нам тут не критичен для истории.
|
||||
messages.map { msg ->
|
||||
msg.toDomain(currentUserId, baseUrl).copy(isRead = true)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatRepo", "Fetch messages failed", e)
|
||||
emptyList()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun sendMessage(
|
||||
chatId: String,
|
||||
content: String?,
|
||||
type: String,
|
||||
attachments: List<chats.data.remote.api.AttachmentRequest>?,
|
||||
replyToId: String?,
|
||||
forwardedFromId: String?
|
||||
): Message {
|
||||
val request = SendMessageRequest(
|
||||
content = content,
|
||||
type = type,
|
||||
attachments = attachments,
|
||||
replyToId = replyToId,
|
||||
forwardedFromId = forwardedFromId
|
||||
)
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
val userId = tokenManager.getUserId() ?: ""
|
||||
return api.sendMessage(chatId, request).toDomain(userId, baseUrl)
|
||||
}
|
||||
|
||||
override suspend fun addReaction(messageId: String, emoji: String) {
|
||||
api.addReaction(messageId, emoji)
|
||||
}
|
||||
|
||||
override suspend fun sendTypingStatus(chatId: String) {
|
||||
api.sendTypingStatus(chatId)
|
||||
}
|
||||
|
||||
override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) {
|
||||
try {
|
||||
android.util.Log.d("ChatRepoImpl", "markMessagesAsRead CALLED FOR $chatId")
|
||||
hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatRepo", "Error marking messages as read", e)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun saveMessage(message: Message) {
|
||||
// Кэш отключён
|
||||
}
|
||||
|
||||
override suspend fun deleteLocalMessage(messageId: String) {
|
||||
// Локальное удаление не поддерживается без кэша
|
||||
}
|
||||
|
||||
override suspend fun uploadMedia(file: java.io.File): String {
|
||||
val mimeType = when (file.extension.lowercase()) {
|
||||
"jpg", "jpeg" -> "image/jpeg"
|
||||
"png" -> "image/png"
|
||||
"webp" -> "image/webp"
|
||||
"mp4" -> "video/mp4"
|
||||
"mp3", "m4a", "wav" -> "audio/mpeg"
|
||||
else -> "application/octet-stream"
|
||||
}
|
||||
val requestFile = file.asRequestBody(mimeType.toMediaTypeOrNull())
|
||||
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
|
||||
return api.uploadFile(body).url
|
||||
}
|
||||
|
||||
override suspend fun getTrendingGifs(page: Int): List<chats.data.remote.api.KlipyGifDto> {
|
||||
return api.getTrendingGifs(page).data.data
|
||||
}
|
||||
|
||||
override suspend fun searchGifs(query: String, page: Int): List<chats.data.remote.api.KlipyGifDto> {
|
||||
return api.searchGifs(query, page).data.data
|
||||
}
|
||||
|
||||
override suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto> {
|
||||
return api.getGifCategories().data.categories
|
||||
}
|
||||
|
||||
override suspend fun createPersonalChat(userId: String): Chat {
|
||||
val currentUserId = tokenManager.getUserId() ?: ""
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
val request = chats.data.remote.api.CreatePersonalChatRequest(userId)
|
||||
return api.createPersonalChat(request).toDomain(currentUserId, baseUrl)
|
||||
}
|
||||
|
||||
override suspend fun deleteMessage(messageId: String, forEveryone: Boolean) {
|
||||
api.deleteMessage(messageId, forEveryone)
|
||||
}
|
||||
|
||||
override suspend fun editMessage(messageId: String, content: String): Message {
|
||||
val request = SendMessageRequest(content = content)
|
||||
val currentUserId = tokenManager.getUserId() ?: ""
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
return api.editMessage(messageId, request).toDomain(currentUserId, baseUrl)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
package chats.data.repository
|
||||
|
||||
import chats.data.remote.dto.*
|
||||
import chats.domain.model.*
|
||||
|
||||
// Mappers
|
||||
fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
|
||||
val chatName = name ?: if (type == "personal") {
|
||||
members.firstOrNull { it.userId != currentUserId }?.user?.displayName ?: "Unknown Chat"
|
||||
} else "Group Chat"
|
||||
|
||||
val chatAvatar = (avatar ?: if (type == "personal") {
|
||||
members.firstOrNull { it.userId != currentUserId }?.user?.avatarUrl
|
||||
} else null)?.ensureAbsoluteUrl(baseUrl)
|
||||
|
||||
return Chat(
|
||||
id = id,
|
||||
type = type,
|
||||
name = chatName,
|
||||
avatar = chatAvatar,
|
||||
unreadCount = unreadCount,
|
||||
lastMessage = messages.firstOrNull()?.toDomain(currentUserId, baseUrl)
|
||||
)
|
||||
}
|
||||
|
||||
fun MessageDto.toDomain(currentUserId: String, baseUrl: String): Message {
|
||||
val domainMediaType = when (type) {
|
||||
"gif" -> MediaType.GIF
|
||||
"image", "photo" -> MediaType.IMAGE
|
||||
"video" -> MediaType.VIDEO
|
||||
"audio", "voice" -> MediaType.AUDIO
|
||||
"file" -> MediaType.FILE
|
||||
else -> when (media.firstOrNull()?.type) {
|
||||
"image", "photo" -> MediaType.IMAGE
|
||||
"video" -> MediaType.VIDEO
|
||||
"audio", "voice" -> MediaType.AUDIO
|
||||
"file" -> MediaType.FILE
|
||||
else -> MediaType.TEXT
|
||||
}
|
||||
}
|
||||
|
||||
return Message(
|
||||
id = id,
|
||||
chatId = chatId ?: "",
|
||||
senderId = senderId ?: "",
|
||||
senderName = sender?.displayName ?: "Unknown",
|
||||
senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
|
||||
content = content,
|
||||
sequenceId = sequenceId ?: 0,
|
||||
createdAt = createdAt ?: "",
|
||||
media = media.map {
|
||||
chats.domain.model.Media(
|
||||
id = it.id ?: java.util.UUID.randomUUID().toString(),
|
||||
type = it.type ?: "unknown",
|
||||
url = (it.url ?: "").ensureAbsoluteUrl(baseUrl),
|
||||
filename = it.filename,
|
||||
size = it.size,
|
||||
duration = it.duration
|
||||
)
|
||||
},
|
||||
mediaType = domainMediaType,
|
||||
reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(),
|
||||
isRead = senderId == currentUserId,
|
||||
isPinned = isPinned ?: false,
|
||||
isForwarded = forwardedFromId != null,
|
||||
forwardedFromName = forwardedFrom?.displayName ?: forwardedFrom?.username,
|
||||
replyTo = replyTo?.toDomain(currentUserId, baseUrl)
|
||||
)
|
||||
}
|
||||
|
||||
fun String.ensureAbsoluteUrl(baseUrl: String): String {
|
||||
return if (this.startsWith("http")) {
|
||||
this
|
||||
} else {
|
||||
val base = baseUrl.removeSuffix("/")
|
||||
val path = if (this.startsWith("/")) this else "/$this"
|
||||
"$base$path"
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package chats.di
|
||||
|
||||
import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.data.repository.ChatRepositoryImpl
|
||||
import chats.domain.repository.ChatRepository
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import dagger.Module
|
||||
import dagger.Provides
|
||||
import dagger.hilt.InstallIn
|
||||
import dagger.hilt.components.SingletonComponent
|
||||
import retrofit2.Retrofit
|
||||
import javax.inject.Singleton
|
||||
|
||||
@Module
|
||||
@InstallIn(SingletonComponent::class)
|
||||
object ChatModule {
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChatApi(retrofit: Retrofit): ChatApi {
|
||||
return retrofit.create(ChatApi::class.java)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChatRepository(
|
||||
api: ChatApi,
|
||||
tokenManager: TokenManager,
|
||||
serverConfig: ServerConfig,
|
||||
hubClient: chats.data.remote.signalr.ChatHubClient
|
||||
): ChatRepository {
|
||||
return ChatRepositoryImpl(api, tokenManager, serverConfig, hubClient)
|
||||
}
|
||||
|
||||
@Provides
|
||||
@Singleton
|
||||
fun provideChatHubClient(): ChatHubClient {
|
||||
return ChatHubClient()
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
package chats.domain.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class Chat(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val name: String,
|
||||
val avatar: String?,
|
||||
val unreadCount: Int,
|
||||
val lastMessage: Message?
|
||||
)
|
||||
@@ -1,37 +0,0 @@
|
||||
package chats.domain.model
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
|
||||
@Immutable
|
||||
data class Message(
|
||||
val id: String,
|
||||
val chatId: String,
|
||||
val senderId: String,
|
||||
val senderName: String,
|
||||
val senderAvatar: String? = null,
|
||||
val content: String?,
|
||||
val sequenceId: Int,
|
||||
val createdAt: String,
|
||||
val media: List<Media> = emptyList(),
|
||||
val mediaType: MediaType = MediaType.TEXT,
|
||||
val reactions: Map<String, Int> = emptyMap(),
|
||||
val isRead: Boolean = false,
|
||||
val isPinned: Boolean = false,
|
||||
val isForwarded: Boolean = false,
|
||||
val forwardedFromName: String? = null,
|
||||
val replyTo: Message? = null
|
||||
)
|
||||
|
||||
@Immutable
|
||||
data class Media(
|
||||
val id: String,
|
||||
val type: String,
|
||||
val url: String,
|
||||
val filename: String? = null,
|
||||
val size: Long? = null,
|
||||
val duration: Double? = null
|
||||
)
|
||||
|
||||
enum class MediaType {
|
||||
TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY, GIF
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package chats.domain.repository
|
||||
|
||||
import chats.domain.model.Chat
|
||||
import chats.domain.model.Message
|
||||
|
||||
interface ChatRepository {
|
||||
suspend fun getChats(): List<Chat>
|
||||
fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow<List<Message>>
|
||||
suspend fun getMessages(chatId: String, cursor: String? = null, pivot: Long? = null, limit: Int? = null): List<Message>
|
||||
suspend fun sendMessage(
|
||||
chatId: String,
|
||||
content: String?,
|
||||
type: String = "text",
|
||||
attachments: List<chats.data.remote.api.AttachmentRequest>? = null,
|
||||
replyToId: String? = null,
|
||||
forwardedFromId: String? = null
|
||||
): Message
|
||||
suspend fun addReaction(messageId: String, emoji: String)
|
||||
suspend fun sendTypingStatus(chatId: String)
|
||||
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int)
|
||||
suspend fun saveMessage(message: Message)
|
||||
suspend fun deleteLocalMessage(messageId: String)
|
||||
suspend fun uploadMedia(file: java.io.File): String
|
||||
suspend fun getTrendingGifs(page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||
suspend fun getGifCategories(): List<chats.data.remote.api.GifCategoryDto>
|
||||
suspend fun createPersonalChat(userId: String): Chat
|
||||
suspend fun deleteMessage(messageId: String, forEveryone: Boolean)
|
||||
suspend fun editMessage(messageId: String, content: String): Message
|
||||
}
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
package chats.domain.usecase
|
||||
|
||||
import chats.data.remote.api.ChatApi
|
||||
import chats.data.remote.api.FileUploadResponse
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.MultipartBody
|
||||
import okhttp3.RequestBody.Companion.asRequestBody
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
|
||||
class UploadMediaUseCase @Inject constructor(
|
||||
private val api: ChatApi
|
||||
) {
|
||||
suspend operator fun invoke(file: File): Result<FileUploadResponse> {
|
||||
return try {
|
||||
val requestFile = file.asRequestBody("image/*".toMediaTypeOrNull())
|
||||
val body = MultipartBody.Part.createFormData("file", file.name, requestFile)
|
||||
val response = api.uploadFile(body)
|
||||
Result.success(response)
|
||||
} catch (e: Exception) {
|
||||
Result.failure(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,803 +0,0 @@
|
||||
package chats.presentation.chat_detail
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import chats.data.remote.signalr.ChatEvent
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.data.repository.toDomain
|
||||
import chats.domain.model.Message
|
||||
import chats.domain.repository.ChatRepository
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import java.io.File
|
||||
import javax.inject.Inject
|
||||
import core.utils.copyUriToFile
|
||||
import core.utils.ImageUtils
|
||||
|
||||
import chats.data.remote.api.KlipyGifDto
|
||||
|
||||
data class ChatDetailState(
|
||||
val messages: List<Message> = emptyList(),
|
||||
val chatName: String? = null,
|
||||
val chatAvatar: String? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val isTyping: Boolean = false,
|
||||
val typingUser: String? = null,
|
||||
val error: String? = null,
|
||||
val canCall: Boolean = true,
|
||||
val maxFileSize: Long = 100 * 1024 * 1024,
|
||||
val trendingGifs: List<KlipyGifDto> = emptyList(),
|
||||
val searchedGifs: List<KlipyGifDto> = emptyList(),
|
||||
val recentGifs: List<KlipyGifDto> = emptyList(),
|
||||
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
|
||||
val isGifsLoading: Boolean = false,
|
||||
val initialScrollIndex: Int? = null,
|
||||
val pendingAttachments: List<File> = emptyList(),
|
||||
val isUploading: Boolean = false,
|
||||
val isCompressionEnabled: Boolean = true,
|
||||
val inputText: String = "",
|
||||
val replyingMessage: Message? = null,
|
||||
val editingMessage: Message? = null,
|
||||
val forwardingMessages: List<Message> = emptyList(),
|
||||
val availableChatsToForward: List<chats.domain.model.Chat> = emptyList(),
|
||||
val selectedMessageIds: Set<String> = emptySet(),
|
||||
val pinnedMessages: List<Message> = emptyList()
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ChatDetailViewModel @Inject constructor(
|
||||
private val repository: ChatRepository,
|
||||
private val signalrClient: ChatHubClient,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val tokenManager: TokenManager,
|
||||
private val activeChatTracker: core.notifications.data.ActiveChatTracker,
|
||||
private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver,
|
||||
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(ChatDetailState())
|
||||
val state: StateFlow<ChatDetailState> = _state.asStateFlow()
|
||||
|
||||
private var currentChatId: String? = null
|
||||
private var typingTimerJob: Job? = null
|
||||
private var signalrEventsJob: Job? = null
|
||||
private var lastTypingSentTime: Long = 0
|
||||
|
||||
private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE)
|
||||
|
||||
init {
|
||||
val config = serverConfig.getServerConfig()
|
||||
val savedCompression = prefs.getBoolean("compression_enabled", true)
|
||||
_state.update { it.copy(
|
||||
canCall = config.features.calls,
|
||||
maxFileSize = config.limits.maxFileSize,
|
||||
isCompressionEnabled = savedCompression
|
||||
) }
|
||||
}
|
||||
|
||||
fun toggleCompression() {
|
||||
_state.update { currentState ->
|
||||
val newValue = !currentState.isCompressionEnabled
|
||||
prefs.edit().putBoolean("compression_enabled", newValue).apply()
|
||||
currentState.copy(isCompressionEnabled = newValue)
|
||||
}
|
||||
}
|
||||
|
||||
fun getCurrentUserId(): String {
|
||||
return tokenManager.getUserId() ?: ""
|
||||
}
|
||||
|
||||
fun setChatId(chatId: String) {
|
||||
if (currentChatId == chatId) return
|
||||
currentChatId = chatId
|
||||
activeChatTracker.setChatId(chatId)
|
||||
|
||||
_state.update { it.copy(
|
||||
messages = emptyList(),
|
||||
isLoading = true,
|
||||
initialScrollIndex = null
|
||||
) }
|
||||
|
||||
// Ensure SignalR is connected and join the chat room
|
||||
val token = tokenManager.getToken()
|
||||
val baseUrl = serverConfig.getBaseUrl()
|
||||
if (token != null && baseUrl.isNotBlank()) {
|
||||
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
|
||||
signalrClient.joinChat(chatId)
|
||||
}
|
||||
|
||||
loadChatInfo(chatId)
|
||||
observeSignalREvents(chatId)
|
||||
|
||||
// Initial sync from network
|
||||
refreshMessages(chatId)
|
||||
}
|
||||
|
||||
private fun updateMessages(messages: List<Message>) {
|
||||
val sortedMessages = messages.sortedByDescending { it.sequenceId }
|
||||
_state.update { it.copy(
|
||||
messages = sortedMessages,
|
||||
isLoading = false,
|
||||
initialScrollIndex = 0 // In reverse layout, 0 is the bottom
|
||||
) }
|
||||
}
|
||||
|
||||
fun onInitialScrollDone() {
|
||||
_state.update { it.copy(initialScrollIndex = -1) }
|
||||
}
|
||||
|
||||
fun refreshMessages(chatId: String) {
|
||||
viewModelScope.launch {
|
||||
val messages = repository.getMessages(chatId)
|
||||
updateMessages(messages)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMoreMessages() {
|
||||
val chatId = currentChatId ?: return
|
||||
if (_state.value.isLoading || _state.value.isLoadingMore) return
|
||||
|
||||
val oldestMsg = _state.value.messages.lastOrNull() ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoadingMore = true) }
|
||||
try {
|
||||
android.util.Log.d("ChatDetailVM", "Loading more history before seqId: ${oldestMsg.sequenceId}")
|
||||
val moreMessages = repository.getMessages(chatId, cursor = oldestMsg.sequenceId.toString())
|
||||
if (moreMessages.isNotEmpty()) {
|
||||
val newSorted = moreMessages.sortedByDescending { it.sequenceId }
|
||||
_state.update { currentState ->
|
||||
// Избегаем дубликатов
|
||||
val existingIds = currentState.messages.map { it.id }.toSet()
|
||||
val uniqueMore = newSorted.filter { it.id !in existingIds }
|
||||
currentState.copy(messages = currentState.messages + uniqueMore)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_state.update { it.copy(isLoadingMore = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadChatInfo(chatId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val chats = repository.getChats()
|
||||
val chat = chats.find { it.id == chatId }
|
||||
chat?.let { c ->
|
||||
_state.update { it.copy(chatName = c.name, chatAvatar = c.avatar) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
// Ignore info load error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateMessageReaction(messageId: String, userId: String, emoji: String) {
|
||||
_state.update { s ->
|
||||
val updatedMessages = s.messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
val currentReactions = msg.reactions.toMutableMap()
|
||||
currentReactions[emoji] = (currentReactions[emoji] ?: 0) + 1
|
||||
msg.copy(reactions = currentReactions)
|
||||
} else msg
|
||||
}
|
||||
s.copy(messages = updatedMessages)
|
||||
}
|
||||
}
|
||||
|
||||
// For handling reaction removed event
|
||||
private fun removeMessageReaction(messageId: String, userId: String, emoji: String) {
|
||||
_state.update { s ->
|
||||
val updatedMessages = s.messages.map { msg ->
|
||||
if (msg.id == messageId) {
|
||||
val currentReactions = msg.reactions.toMutableMap()
|
||||
val count = currentReactions[emoji] ?: 0
|
||||
if (count > 1) {
|
||||
currentReactions[emoji] = count - 1
|
||||
} else {
|
||||
currentReactions.remove(emoji)
|
||||
}
|
||||
msg.copy(reactions = currentReactions)
|
||||
} else msg
|
||||
}
|
||||
s.copy(messages = updatedMessages)
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSignalREvents(chatId: String) {
|
||||
signalrEventsJob?.cancel()
|
||||
signalrEventsJob = signalrClient.events
|
||||
.onEach { android.util.Log.d("ChatDetailVM", "Received SignalR event: $it for chat: $chatId") }
|
||||
.filter { event ->
|
||||
val eventChatId = when(event) {
|
||||
is ChatEvent.NewMessage -> event.message.chatId
|
||||
is ChatEvent.ReactionUpdated -> event.chatId
|
||||
is ChatEvent.UserTyping -> event.chatId
|
||||
is ChatEvent.MessagesRead -> event.chatId
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (eventChatId == null) return@filter false
|
||||
|
||||
val match = eventChatId == chatId || eventChatId.contains(chatId) || chatId.contains(eventChatId)
|
||||
if (match) {
|
||||
android.util.Log.d("ChatDetailVM", "Event MATCHED chat $chatId: $event")
|
||||
}
|
||||
match
|
||||
}
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is ChatEvent.NewMessage -> {
|
||||
android.util.Log.d("ChatDetailVM", "New message added to bottom, NOT marking as read automatically")
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
|
||||
|
||||
_state.update { currentState ->
|
||||
if (currentState.messages.any { it.id == domainMsg.id }) return@update currentState
|
||||
currentState.copy(messages = listOf(domainMsg) + currentState.messages)
|
||||
}
|
||||
}
|
||||
is ChatEvent.ReactionUpdated -> {
|
||||
if (event.isRemoved) {
|
||||
removeMessageReaction(event.messageId, event.userId, event.emoji)
|
||||
} else {
|
||||
updateMessageReaction(event.messageId, event.userId, event.emoji)
|
||||
}
|
||||
}
|
||||
is ChatEvent.MessageDeleted -> {
|
||||
_state.update { currentState ->
|
||||
currentState.copy(messages = currentState.messages.filter { it.id != event.messageId })
|
||||
}
|
||||
}
|
||||
is ChatEvent.MessageEdited -> {
|
||||
_state.update { currentState ->
|
||||
val updated = currentState.messages.map { msg ->
|
||||
if (msg.id == event.messageId) msg.copy(content = event.content) else msg
|
||||
}
|
||||
currentState.copy(messages = updated)
|
||||
}
|
||||
}
|
||||
is ChatEvent.UserTyping -> {
|
||||
_state.update { it.copy(isTyping = true) }
|
||||
typingTimerJob?.cancel()
|
||||
typingTimerJob = viewModelScope.launch {
|
||||
delay(3000)
|
||||
_state.update { it.copy(isTyping = false) }
|
||||
}
|
||||
}
|
||||
is ChatEvent.UserStoppedTyping -> {
|
||||
_state.update { it.copy(isTyping = false) }
|
||||
}
|
||||
is ChatEvent.MessagesRead -> {
|
||||
_state.update { currentState ->
|
||||
val updatedMessages = currentState.messages.map { msg ->
|
||||
if (msg.sequenceId <= event.lastReadSequenceId) {
|
||||
msg.copy(isRead = true)
|
||||
} else msg
|
||||
}
|
||||
currentState.copy(messages = updatedMessages)
|
||||
}
|
||||
}
|
||||
is ChatEvent.MessagePinned -> {
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
_state.update { s ->
|
||||
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
|
||||
s.copy(pinnedMessages = (s.pinnedMessages + domainMsg).distinctBy { it.id })
|
||||
}
|
||||
}
|
||||
is ChatEvent.MessageUnpinned -> {
|
||||
_state.update { s ->
|
||||
s.copy(pinnedMessages = s.pinnedMessages.filter { it.id != event.messageId })
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun clearChatId() {
|
||||
android.util.Log.d("ChatDetailVM", "KILLING ALL SESSION JOBS for $currentChatId")
|
||||
signalrEventsJob?.cancel()
|
||||
signalrEventsJob = null
|
||||
currentChatId = null
|
||||
activeChatTracker.setChatId(null)
|
||||
_state.update { it.copy(messages = emptyList(), isLoading = false) }
|
||||
}
|
||||
|
||||
fun markAsRead() {
|
||||
val chatId = currentChatId ?: return
|
||||
val messages = _state.value.messages
|
||||
if (messages.isEmpty()) return
|
||||
|
||||
// В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника
|
||||
val currentUserId = getCurrentUserId()
|
||||
val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Мгновенно обновляем в памяти для "галочек"
|
||||
_state.update { currentState ->
|
||||
val updatedMessages = currentState.messages.map { msg ->
|
||||
if (msg.senderId != currentUserId && msg.sequenceId <= lastMessageFromOther.sequenceId) {
|
||||
msg.copy(isRead = true)
|
||||
} else msg
|
||||
}
|
||||
currentState.copy(messages = updatedMessages)
|
||||
}
|
||||
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
|
||||
signalrNotificationObserver.refresh()
|
||||
} catch (e: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onInputTextChanged(text: String) {
|
||||
_state.update { it.copy(inputText = text) }
|
||||
|
||||
val chatId = currentChatId ?: return
|
||||
|
||||
// Отправляем индикатор набора текста
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastTypingSentTime > 1000) {
|
||||
signalrClient.sendTypingIndicator(chatId)
|
||||
lastTypingSentTime = now
|
||||
|
||||
// Отправляем "остановился печатать" через 3 секунды
|
||||
typingTimerJob?.cancel()
|
||||
typingTimerJob = viewModelScope.launch {
|
||||
delay(3000)
|
||||
signalrClient.sendUserStoppedTyping(chatId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fun onReply(message: Message) {
|
||||
_state.update { it.copy(replyingMessage = message) }
|
||||
}
|
||||
|
||||
fun forwardMessages(targetChatId: String, messages: List<Message>) {
|
||||
viewModelScope.launch {
|
||||
messages.forEach { msg ->
|
||||
repository.sendMessage(
|
||||
chatId = targetChatId,
|
||||
content = msg.content,
|
||||
type = msg.mediaType.name.lowercase(),
|
||||
forwardedFromId = msg.senderId
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onForward(message: Message) {
|
||||
_state.update { it.copy(forwardingMessages = listOf(message)) }
|
||||
loadChatsForForwarding()
|
||||
}
|
||||
|
||||
fun loadChatsForForwarding() {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val chats = repository.getChats()
|
||||
_state.update { it.copy(availableChatsToForward = chats) }
|
||||
} catch (e: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun cancelForwarding() {
|
||||
_state.update { it.copy(forwardingMessages = emptyList(), availableChatsToForward = emptyList()) }
|
||||
}
|
||||
|
||||
fun onForwardSelectedMessages() {
|
||||
val selectedIds = _state.value.selectedMessageIds
|
||||
val messages = _state.value.messages.filter { selectedIds.contains(it.id) }
|
||||
_state.update { it.copy(forwardingMessages = messages) }
|
||||
loadChatsForForwarding()
|
||||
}
|
||||
|
||||
fun toggleSelection(messageId: String) {
|
||||
_state.update { s ->
|
||||
val newSelection = if (s.selectedMessageIds.contains(messageId)) {
|
||||
s.selectedMessageIds - messageId
|
||||
} else {
|
||||
s.selectedMessageIds + messageId
|
||||
}
|
||||
s.copy(selectedMessageIds = newSelection)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearSelection() {
|
||||
_state.update { it.copy(selectedMessageIds = emptySet()) }
|
||||
}
|
||||
|
||||
fun cancelReply() {
|
||||
_state.update { it.copy(replyingMessage = null) }
|
||||
}
|
||||
|
||||
fun cancelEdit() {
|
||||
_state.update { it.copy(editingMessage = null, inputText = "") }
|
||||
}
|
||||
|
||||
fun deleteMessage(message: Message, forEveryone: Boolean) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.deleteMessage(message.id, forEveryone)
|
||||
// Local update if needed (will also come via SignalR for everyone, but forMe might need local only update)
|
||||
_state.update { currentState ->
|
||||
currentState.copy(messages = currentState.messages.filter { it.id != message.id })
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(error = "Delete failed: ${e.localizedMessage}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun onEdit(message: Message) {
|
||||
_state.update { it.copy(editingMessage = message, inputText = message.content ?: "", replyingMessage = null) }
|
||||
}
|
||||
|
||||
fun pinMessage(messageId: String) {
|
||||
val chatId = currentChatId ?: return
|
||||
signalrClient.pinMessage(messageId, chatId)
|
||||
}
|
||||
|
||||
fun unpinMessage(messageId: String) {
|
||||
val chatId = currentChatId ?: return
|
||||
signalrClient.unpinMessage(messageId, chatId)
|
||||
}
|
||||
|
||||
fun onPin(message: Message) {
|
||||
pinMessage(message.id)
|
||||
}
|
||||
|
||||
fun sendMessage(onFail: (String) -> Unit = {}) {
|
||||
val chatId = currentChatId ?: return
|
||||
val text = _state.value.inputText
|
||||
val pending = _state.value.pendingAttachments
|
||||
val replyToId = _state.value.replyingMessage?.id
|
||||
val editingMsg = _state.value.editingMessage
|
||||
if (text.isBlank() && pending.isEmpty()) return
|
||||
|
||||
// Handle Edit
|
||||
if (editingMsg != null) {
|
||||
_state.update { it.copy(inputText = "", editingMessage = null) }
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val updated = repository.editMessage(editingMsg.id, text)
|
||||
_state.update { currentState ->
|
||||
val updatedList = currentState.messages.map {
|
||||
if (it.id == updated.id) updated else it
|
||||
}
|
||||
currentState.copy(messages = updatedList)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(error = "Edit failed: ${e.localizedMessage}") }
|
||||
onFail(text)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Clear input immediately to avoid double clicks and ensure UI experience
|
||||
_state.update { it.copy(inputText = "", replyingMessage = null) }
|
||||
|
||||
val tempId = "temp_${System.currentTimeMillis()}"
|
||||
val userId = getCurrentUserId()
|
||||
|
||||
// Determine mediaType based on attachments
|
||||
val mediaType = when {
|
||||
pending.isEmpty() -> chats.domain.model.MediaType.TEXT
|
||||
pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> chats.domain.model.MediaType.IMAGE
|
||||
pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> chats.domain.model.MediaType.VIDEO
|
||||
else -> chats.domain.model.MediaType.TEXT
|
||||
}
|
||||
|
||||
val tempMessage = Message(
|
||||
id = tempId,
|
||||
chatId = chatId,
|
||||
senderId = userId,
|
||||
content = if (text.isBlank()) null else text,
|
||||
createdAt = java.util.Date().toString(),
|
||||
mediaType = mediaType,
|
||||
media = emptyList(),
|
||||
senderName = "Вы",
|
||||
senderAvatar = null,
|
||||
reactions = emptyMap(),
|
||||
isRead = false,
|
||||
sequenceId = 0
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
repository.saveMessage(tempMessage)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// Upload attachments if any
|
||||
val attachmentRequests = if (_state.value.pendingAttachments.isNotEmpty()) {
|
||||
_state.update { it.copy(isUploading = true) }
|
||||
val requests = _state.value.pendingAttachments.map { file ->
|
||||
val url = repository.uploadMedia(file)
|
||||
chats.data.remote.api.AttachmentRequest(
|
||||
type = when {
|
||||
file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif") -> "image"
|
||||
file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm") -> "video"
|
||||
file.extension.lowercase() in listOf("mp3", "m4a", "wav", "aac", "ogg") -> "audio"
|
||||
else -> "file"
|
||||
},
|
||||
url = url,
|
||||
fileName = file.name,
|
||||
fileSize = file.length()
|
||||
)
|
||||
}
|
||||
_state.update { it.copy(isUploading = false, pendingAttachments = emptyList()) }
|
||||
requests
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
val sentMessage = repository.sendMessage(
|
||||
chatId = chatId,
|
||||
content = if (text.isBlank()) null else text,
|
||||
type = if (attachmentRequests != null) "media" else "text",
|
||||
attachments = attachmentRequests,
|
||||
replyToId = replyToId
|
||||
)
|
||||
repository.deleteLocalMessage(tempId)
|
||||
repository.saveMessage(sentMessage)
|
||||
// Clear attachments on success
|
||||
_state.update { it.copy(pendingAttachments = emptyList()) }
|
||||
} catch (e: Exception) {
|
||||
repository.deleteLocalMessage(tempId)
|
||||
_state.update { it.copy(error = e.localizedMessage, isUploading = false) }
|
||||
onFail(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addReaction(messageId: String, emoji: String) {
|
||||
val chatId = currentChatId ?: return
|
||||
|
||||
// We rely on SignalR event to update the count to avoid double counting
|
||||
// especially since domain Message doesn't track user IDs for reactions yet.
|
||||
signalrClient.addReaction(messageId, chatId, emoji)
|
||||
}
|
||||
|
||||
fun sendVoiceMessage(file: File) {
|
||||
val chatId = currentChatId ?: return
|
||||
val replyToId = _state.value.replyingMessage?.id
|
||||
_state.update { it.copy(replyingMessage = null) }
|
||||
|
||||
val tempId = "temp_voice_${System.currentTimeMillis()}"
|
||||
val userId = getCurrentUserId()
|
||||
|
||||
val tempMessage = Message(
|
||||
id = tempId,
|
||||
chatId = chatId,
|
||||
senderId = userId,
|
||||
content = null,
|
||||
createdAt = java.util.Date().toString(),
|
||||
mediaType = chats.domain.model.MediaType.AUDIO,
|
||||
media = emptyList(),
|
||||
senderName = "Вы",
|
||||
reactions = emptyMap(),
|
||||
isRead = false,
|
||||
sequenceId = 0
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
repository.saveMessage(tempMessage)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
// 1. Upload the audio file
|
||||
val url = repository.uploadMedia(file)
|
||||
|
||||
// 2. Send the message with the attachment
|
||||
val attachment = chats.data.remote.api.AttachmentRequest(
|
||||
type = "voice",
|
||||
url = url,
|
||||
fileName = file.name,
|
||||
fileSize = file.length()
|
||||
)
|
||||
|
||||
val sentMessage = repository.sendMessage(
|
||||
chatId = chatId,
|
||||
content = null,
|
||||
type = "audio",
|
||||
attachments = listOf(attachment),
|
||||
replyToId = replyToId
|
||||
)
|
||||
|
||||
repository.deleteLocalMessage(tempId)
|
||||
repository.saveMessage(sentMessage)
|
||||
} catch (e: Exception) {
|
||||
repository.deleteLocalMessage(tempId)
|
||||
_state.update { it.copy(error = "Ошибка отправки голосового: ${e.localizedMessage}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun addPendingAttachment(uri: android.net.Uri, context: android.content.Context) {
|
||||
viewModelScope.launch {
|
||||
val mimeType = context.contentResolver.getType(uri) ?: ""
|
||||
val file = if (_state.value.isCompressionEnabled && mimeType.startsWith("image")) {
|
||||
ImageUtils.compressImage(context, uri)
|
||||
} else {
|
||||
copyUriToFile(context, uri)
|
||||
}
|
||||
|
||||
file?.let { f ->
|
||||
_state.update { it.copy(pendingAttachments = it.pendingAttachments + f) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun formatDateHeader(dateString: String): String {
|
||||
return try {
|
||||
// Парсим ISO 8601 (например 2024-04-14T20:56:00Z)
|
||||
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
val date = isoFormat.parse(dateString) ?: return dateString
|
||||
|
||||
// Форматируем в локальное время: "14 апреля"
|
||||
java.text.SimpleDateFormat("d MMMM", java.util.Locale("ru")).format(date)
|
||||
} catch (e: Exception) {
|
||||
dateString
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocalDateString(isoDate: String): String {
|
||||
return try {
|
||||
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
val date = isoFormat.parse(isoDate) ?: return isoDate
|
||||
// Возвращаем просто дату YYYY-MM-DD в локальном часовом поясе для группировки
|
||||
java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(date)
|
||||
} catch (e: Exception) {
|
||||
isoDate.split("T").first()
|
||||
}
|
||||
}
|
||||
|
||||
fun removePendingAttachment(file: File) {
|
||||
_state.update { it.copy(pendingAttachments = it.pendingAttachments - file) }
|
||||
}
|
||||
|
||||
fun uploadMedia(file: File) {
|
||||
if (file.length() > _state.value.maxFileSize) {
|
||||
_state.update { it.copy(error = "File too large") }
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val url = repository.uploadMedia(file)
|
||||
// After upload, we might want to send a message with this media
|
||||
// For now, let's just log it or handle as per app requirements
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(error = e.localizedMessage) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadTrendingGifs() {
|
||||
if (_state.value.trendingGifs.isNotEmpty()) return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isGifsLoading = true) }
|
||||
try {
|
||||
val gifs = repository.getTrendingGifs(0)
|
||||
_state.update { it.copy(trendingGifs = gifs, isGifsLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isGifsLoading = false, error = "GIF load error: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadGifCategories() {
|
||||
if (_state.value.gifCategories.isNotEmpty()) return
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val categories = repository.getGifCategories()
|
||||
_state.update { it.copy(gifCategories = categories) }
|
||||
} catch (e: Exception) {
|
||||
// Ignore failure
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var searchJob: Job? = null
|
||||
fun searchGifs(query: String) {
|
||||
searchJob?.cancel()
|
||||
if (query.isBlank()) {
|
||||
_state.update { it.copy(searchedGifs = emptyList()) }
|
||||
return
|
||||
}
|
||||
searchJob = viewModelScope.launch {
|
||||
delay(500)
|
||||
_state.update { it.copy(isGifsLoading = true) }
|
||||
try {
|
||||
val gifs = repository.searchGifs(query, 0)
|
||||
_state.update { it.copy(searchedGifs = gifs, isGifsLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isGifsLoading = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun sendGif(url: String) {
|
||||
val chatId = currentChatId ?: return
|
||||
val replyToId = _state.value.replyingMessage?.id
|
||||
_state.update { it.copy(replyingMessage = null) }
|
||||
|
||||
val tempId = "temp_gif_${System.currentTimeMillis()}"
|
||||
val userId = getCurrentUserId()
|
||||
|
||||
val tempMessage = Message(
|
||||
id = tempId,
|
||||
chatId = chatId,
|
||||
senderId = userId,
|
||||
content = url,
|
||||
createdAt = java.util.Date().toString(),
|
||||
mediaType = chats.domain.model.MediaType.IMAGE, // We map GIF to IMAGE for rendering
|
||||
media = listOf(chats.domain.model.Media(url = url, type = "image", id = "temp_media")),
|
||||
senderName = "Вы",
|
||||
senderAvatar = null,
|
||||
reactions = emptyMap(),
|
||||
isRead = false,
|
||||
sequenceId = 0
|
||||
)
|
||||
|
||||
viewModelScope.launch {
|
||||
repository.saveMessage(tempMessage)
|
||||
}
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
val attachment = chats.data.remote.api.AttachmentRequest(
|
||||
type = "image",
|
||||
url = url,
|
||||
fileName = "gif.gif",
|
||||
fileSize = 0
|
||||
)
|
||||
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment), replyToId)
|
||||
|
||||
repository.deleteLocalMessage(tempId)
|
||||
repository.saveMessage(sentMessage)
|
||||
|
||||
// Add to recent
|
||||
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
|
||||
val selectedGif = allGifs.find { gif ->
|
||||
val gifUrl = gif.files?.get("hd")?.get("gif")?.url
|
||||
?: gif.files?.get("sd")?.get("gif")?.url
|
||||
?: gif.file?.get("hd")?.get("gif")?.url
|
||||
?: gif.file?.get("sd")?.get("gif")?.url
|
||||
?: gif.media_formats?.get("gif")?.url
|
||||
?: gif.images?.original?.url
|
||||
gifUrl == url
|
||||
}
|
||||
|
||||
selectedGif?.let { gif ->
|
||||
val newList = (listOf(gif) + _state.value.recentGifs).distinctBy { it.id }.take(20)
|
||||
_state.update { it.copy(recentGifs = newList) }
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
repository.deleteLocalMessage(tempId)
|
||||
_state.update { it.copy(error = e.localizedMessage) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package chats.presentation.chat_list
|
||||
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import chats.presentation.components.ChatItem
|
||||
import stories.presentation.StoryViewModel
|
||||
import stories.presentation.components.StoryThumbnail
|
||||
import ru.knot.messager.R
|
||||
|
||||
@Composable
|
||||
fun HorizontalDividerComponent(
|
||||
modifier: Modifier = Modifier,
|
||||
thickness: androidx.compose.ui.unit.Dp = 1.dp,
|
||||
color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.outlineVariant
|
||||
) {
|
||||
androidx.compose.material3.Divider(
|
||||
modifier = modifier,
|
||||
thickness = thickness,
|
||||
color = color
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun ChatListScreen(
|
||||
viewModel: ChatListViewModel,
|
||||
storyViewModel: StoryViewModel,
|
||||
onChatClick: (String) -> Unit,
|
||||
onStoryClick: (Int) -> Unit
|
||||
) {
|
||||
val state by viewModel.state.collectAsState()
|
||||
val storyState by storyViewModel.state.collectAsState()
|
||||
|
||||
androidx.compose.runtime.LaunchedEffect(Unit) {
|
||||
viewModel.loadChats()
|
||||
}
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.chats_title), fontWeight = FontWeight.Bold) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = Color.Transparent,
|
||||
titleContentColor = Color.White
|
||||
)
|
||||
)
|
||||
}
|
||||
) { paddingValues ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(paddingValues)
|
||||
) {
|
||||
if (state.isLoading && state.chats.isEmpty()) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
} else {
|
||||
LazyColumn {
|
||||
// Ряд историй сверху списка чатов
|
||||
item {
|
||||
if (state.isStoriesEnabled && storyState.storyGroups.isNotEmpty()) {
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
contentPadding = PaddingValues(horizontal = 8.dp)
|
||||
) {
|
||||
items(storyState.storyGroups.size) { index ->
|
||||
val group = storyState.storyGroups[index]
|
||||
StoryThumbnail(
|
||||
username = group.username,
|
||||
avatarUrl = group.avatar,
|
||||
hasUnseen = true, // В идеале проверяем по статусам
|
||||
onClick = { onStoryClick(index) }
|
||||
)
|
||||
}
|
||||
}
|
||||
HorizontalDividerComponent(
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if (state.error != null && state.chats.isEmpty()) {
|
||||
item {
|
||||
Text(
|
||||
text = "${stringResource(R.string.error_occurred)}: ${state.error}",
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
modifier = Modifier.padding(16.dp)
|
||||
)
|
||||
}
|
||||
} else if (state.chats.isEmpty() && !state.isLoading) {
|
||||
item {
|
||||
Box(modifier = Modifier.fillParentMaxSize()) {
|
||||
Text(
|
||||
text = stringResource(R.string.no_chats_found),
|
||||
modifier = Modifier.align(Alignment.Center)
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(state.chats) { chat ->
|
||||
ChatItem(chat = chat, onClick = onChatClick)
|
||||
HorizontalDividerComponent(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
thickness = 0.5.dp,
|
||||
color = MaterialTheme.colorScheme.outlineVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package chats.presentation.chat_list
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import chats.domain.model.Chat
|
||||
import chats.domain.repository.ChatRepository
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.data.remote.signalr.ChatEvent
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
import kotlinx.coroutines.flow.*
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
import chats.data.repository.toDomain
|
||||
|
||||
data class ChatListState(
|
||||
val chats: List<Chat> = emptyList(),
|
||||
val isLoading: Boolean = false,
|
||||
val error: String? = null,
|
||||
val isStoriesEnabled: Boolean = true
|
||||
)
|
||||
|
||||
@HiltViewModel
|
||||
class ChatListViewModel @Inject constructor(
|
||||
private val repository: ChatRepository,
|
||||
private val authRepository: auth.domain.repository.AuthRepository,
|
||||
private val signalrClient: ChatHubClient,
|
||||
private val serverConfig: ServerConfig,
|
||||
private val tokenManager: TokenManager
|
||||
) : ViewModel() {
|
||||
|
||||
private val _state = MutableStateFlow(ChatListState())
|
||||
val state: StateFlow<ChatListState> = _state.asStateFlow()
|
||||
|
||||
init {
|
||||
updatePushToken()
|
||||
|
||||
val isStoriesEnabled = try {
|
||||
serverConfig.getServerConfig().features.stories
|
||||
} catch (e: Exception) {
|
||||
true
|
||||
}
|
||||
_state.update { it.copy(isStoriesEnabled = isStoriesEnabled) }
|
||||
|
||||
val token = tokenManager.getToken()
|
||||
val baseUrl = serverConfig.getBaseUrl()
|
||||
|
||||
// Подключаемся к SignalR только если есть токен И введён URL сервера
|
||||
if (token != null && baseUrl.isNotBlank()) {
|
||||
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
|
||||
}
|
||||
|
||||
loadChats()
|
||||
observeSignalREvents()
|
||||
}
|
||||
|
||||
private fun updatePushToken() {
|
||||
com.google.firebase.messaging.FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||
if (task.isSuccessful) {
|
||||
val token = task.result
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
authRepository.updatePushToken(token)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatListVM", "Failed to update push token: ${e.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private fun getCurrentUserId(): String = tokenManager.getUserId() ?: ""
|
||||
|
||||
fun loadChats() {
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoading = true) }
|
||||
try {
|
||||
val chats = repository.getChats()
|
||||
_state.update { it.copy(chats = sortChats(chats), isLoading = false) }
|
||||
} catch (e: Exception) {
|
||||
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sortChats(chats: List<Chat>): List<Chat> {
|
||||
return chats.sortedWith(compareByDescending<Chat> {
|
||||
it.name.equals("Избранное", ignoreCase = true) || it.name.equals("Saved Messages", ignoreCase = true)
|
||||
}.thenByDescending { it.lastMessage?.createdAt })
|
||||
}
|
||||
|
||||
private fun observeSignalREvents() {
|
||||
signalrClient.events
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is ChatEvent.NewMessage -> {
|
||||
updateChatsWithNewMessage(event)
|
||||
}
|
||||
is ChatEvent.NewChat -> {
|
||||
val currentUserId = getCurrentUserId()
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
_state.update { it.copy(chats = listOf(event.chat.toDomain(currentUserId, baseUrl)) + it.chats) }
|
||||
}
|
||||
is ChatEvent.MessagesRead -> {
|
||||
if (event.userId == getCurrentUserId()) {
|
||||
_state.update { currentState ->
|
||||
val updatedChats = currentState.chats.map { chat ->
|
||||
if (chat.id == event.chatId) {
|
||||
chat.copy(unreadCount = 0)
|
||||
} else chat
|
||||
}
|
||||
currentState.copy(chats = sortChats(updatedChats))
|
||||
}
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
|
||||
private fun updateChatsWithNewMessage(event: ChatEvent.NewMessage) {
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
val currentUserId = getCurrentUserId()
|
||||
|
||||
_state.update { currentState ->
|
||||
val updatedChats = currentState.chats.map { chat ->
|
||||
if (chat.id.equals(event.message.chatId, ignoreCase = true)) {
|
||||
val isMyMessage = event.message.senderId == currentUserId
|
||||
val isAlreadySeen = chat.lastMessage?.id == event.message.id
|
||||
|
||||
val lastMsgDomain = event.message.toDomain(currentUserId, baseUrl)
|
||||
val newCount = if (isMyMessage || isAlreadySeen) {
|
||||
chat.unreadCount
|
||||
} else {
|
||||
chat.unreadCount + 1
|
||||
}
|
||||
|
||||
if (!isAlreadySeen) {
|
||||
android.util.Log.d("ChatListVM", "Message ${event.message.id} -> Count ${chat.unreadCount} -> $newCount")
|
||||
}
|
||||
|
||||
chat.copy(
|
||||
lastMessage = lastMsgDomain,
|
||||
unreadCount = newCount
|
||||
)
|
||||
} else chat
|
||||
}
|
||||
|
||||
currentState.copy(chats = sortChats(updatedChats))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,124 +0,0 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import chats.domain.model.Chat
|
||||
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import core.presentation.components.AppAvatar
|
||||
import chats.domain.model.MediaType
|
||||
import androidx.compose.runtime.remember
|
||||
|
||||
@Composable
|
||||
fun ChatItem(
|
||||
chat: Chat,
|
||||
onClick: (String) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable { onClick(chat.id) }
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
AppAvatar(
|
||||
url = chat.avatar,
|
||||
name = chat.name,
|
||||
size = 50.dp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Text(
|
||||
text = chat.name,
|
||||
style = MaterialTheme.typography.bodyLarge.copy(fontWeight = FontWeight.Bold),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
chat.lastMessage?.let {
|
||||
val formattedTime = remember(it.createdAt) {
|
||||
try {
|
||||
it.createdAt.substringAfter('T').take(5)
|
||||
} catch (e: Exception) {
|
||||
""
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = formattedTime,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.Gray
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
val previewText = remember(chat.lastMessage) {
|
||||
val msg = chat.lastMessage
|
||||
if (msg == null) return@remember "No messages yet"
|
||||
|
||||
if (!msg.content.isNullOrBlank()) {
|
||||
msg.content
|
||||
} else if (msg.mediaType == MediaType.AUDIO) {
|
||||
"Голосовое сообщение"
|
||||
} else if (msg.media.isNotEmpty()) {
|
||||
when (msg.mediaType) {
|
||||
MediaType.IMAGE -> "Фото"
|
||||
MediaType.VIDEO -> "Видео"
|
||||
MediaType.FILE -> "Файл"
|
||||
else -> "Медиа"
|
||||
}
|
||||
} else {
|
||||
"Сообщение"
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = previewText,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = Color.Gray,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
|
||||
if (chat.unreadCount > 0) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(start = 8.dp)
|
||||
.background(MaterialTheme.colorScheme.primary, CircleShape)
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = chat.unreadCount.toString(),
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun EmojiPicker(
|
||||
onEmojiSelected: (String) -> Unit
|
||||
) {
|
||||
val emojis = listOf("😀", "😂", "😍", "👍", "🔥", "😭", "🙏", "😎", "🤔", "🎉", "❤️", "✨") // Базовый набор
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(6),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
items(emojis) { emoji ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(48.dp)
|
||||
.clickable { onEmojiSelected(emoji) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = emoji, fontSize = 24.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil.compose.AsyncImage
|
||||
import coil.decode.GifDecoder
|
||||
import coil.decode.ImageDecoderDecoder
|
||||
import coil.request.ImageRequest
|
||||
import chats.data.remote.api.KlipyGifDto
|
||||
|
||||
@Composable
|
||||
fun KlipyPicker(
|
||||
gifs: List<KlipyGifDto>,
|
||||
isLoading: Boolean,
|
||||
onGifSelected: (String) -> Unit,
|
||||
onSearch: (String) -> Unit
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
val context = LocalContext.current
|
||||
val imageLoader = coil.ImageLoader.Builder(context)
|
||||
.components {
|
||||
if (android.os.Build.VERSION.SDK_INT >= 28) {
|
||||
add(ImageDecoderDecoder.Factory())
|
||||
} else {
|
||||
add(GifDecoder.Factory())
|
||||
}
|
||||
}
|
||||
.build()
|
||||
|
||||
Column(modifier = Modifier.fillMaxWidth().height(300.dp)) {
|
||||
OutlinedTextField(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
onSearch(it)
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
placeholder = { Text("Search GIFs...") },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||
singleLine = true
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(4.dp)
|
||||
) {
|
||||
items(gifs) { gif ->
|
||||
val url = gif.images?.fixed_height?.url ?: ""
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(url)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
imageLoader = imageLoader,
|
||||
contentDescription = gif.title,
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.aspectRatio(1.5f)
|
||||
.clickable { onGifSelected(url) },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.border
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import core.utils.LinkMetadata
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
private val linkMetadataCache = mutableMapOf<String, LinkMetadata>()
|
||||
|
||||
@Composable
|
||||
fun LinkPreview(
|
||||
url: String,
|
||||
modifier: Modifier = Modifier,
|
||||
contentColor: Color = Color.White
|
||||
) {
|
||||
var metadata by remember(url) { mutableStateOf<LinkMetadata?>(linkMetadataCache[url]) }
|
||||
var loading by remember(url) { mutableStateOf(metadata == null) }
|
||||
val context = LocalContext.current
|
||||
|
||||
LaunchedEffect(url) {
|
||||
if (metadata != null) {
|
||||
loading = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
loading = true
|
||||
try {
|
||||
val client = okhttp3.OkHttpClient.Builder()
|
||||
.connectTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
|
||||
.build()
|
||||
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url("https://api.microlink.io?url=${java.net.URLEncoder.encode(url, "UTF-8")}")
|
||||
.header("User-Agent", "KnotMessenger/1.0 (Android)")
|
||||
.build()
|
||||
|
||||
withContext(Dispatchers.IO) {
|
||||
client.newCall(request).execute().use { response ->
|
||||
if (response.isSuccessful) {
|
||||
val body = response.body?.string()
|
||||
if (body != null) {
|
||||
val json = com.google.gson.JsonParser.parseString(body).asJsonObject
|
||||
if (json.has("status") && json.get("status").asString == "success") {
|
||||
val data = json.getAsJsonObject("data")
|
||||
|
||||
val title = data.get("title")?.takeIf { !it.isJsonNull }?.asString
|
||||
val description = data.get("description")?.takeIf { !it.isJsonNull }?.asString
|
||||
val imageUrl = data.getAsJsonObject("image")?.get("url")?.takeIf { !it.isJsonNull }?.asString
|
||||
|
||||
if (title != null || description != null || imageUrl != null) {
|
||||
metadata = LinkMetadata(
|
||||
url = url,
|
||||
title = title,
|
||||
description = description,
|
||||
imageUrl = imageUrl
|
||||
)
|
||||
metadata?.let { linkMetadataCache[url] = it }
|
||||
}
|
||||
Unit
|
||||
}
|
||||
Unit
|
||||
}
|
||||
Unit
|
||||
} else {
|
||||
android.util.Log.e("LinkPreview", "API error: ${response.code} ${response.message}")
|
||||
}
|
||||
}
|
||||
Unit
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("LinkPreview", "Failed to fetch metadata for $url", e)
|
||||
} finally {
|
||||
loading = false
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
Text(
|
||||
text = "Загрузка предпросмотра...",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.5f),
|
||||
modifier = modifier.padding(vertical = 4.dp),
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val currentMetadata = metadata ?: LinkMetadata(url = url)
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color.Black.copy(alpha = 0.2f))
|
||||
.clickable {
|
||||
try {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(url))
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
.border(
|
||||
width = if (isSystemInDarkTheme()) 0.5.dp else 0.dp,
|
||||
color = Color.White.copy(alpha = 0.1f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
)
|
||||
) {
|
||||
Row(modifier = Modifier.height(IntrinsicSize.Min)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(3.dp)
|
||||
.background(Color(0xFF3096E5))
|
||||
)
|
||||
|
||||
Column(modifier = Modifier.padding(10.dp)) {
|
||||
val domain = remember(url) {
|
||||
try { java.net.URL(url).host.replace("www.", "") } catch (e: Exception) { "" }
|
||||
}
|
||||
|
||||
Text(
|
||||
text = domain,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color(0xFF3096E5),
|
||||
fontWeight = FontWeight.Bold,
|
||||
modifier = Modifier.padding(bottom = 2.dp)
|
||||
)
|
||||
|
||||
Text(
|
||||
text = currentMetadata.title ?: url,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = contentColor,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
|
||||
currentMetadata.description?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentColor.copy(alpha = 0.7f),
|
||||
maxLines = 3,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
lineHeight = 16.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
currentMetadata.imageUrl?.let {
|
||||
AsyncImage(
|
||||
model = it,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 200.dp)
|
||||
.clip(RoundedCornerShape(bottomStart = 8.dp, bottomEnd = 8.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,416 +0,0 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.animation.*
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import androidx.compose.material.icons.rounded.*
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import coil.compose.AsyncImage
|
||||
import chats.data.remote.api.KlipyGifDto
|
||||
import core.utils.CoilUtils
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
enum class MediaTab { EMOJI, GIF }
|
||||
|
||||
@Composable
|
||||
fun MediaPicker(
|
||||
trendingGifs: List<KlipyGifDto>,
|
||||
searchedGifs: List<KlipyGifDto>,
|
||||
recentGifs: List<KlipyGifDto> = emptyList(),
|
||||
gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
|
||||
isGifsLoading: Boolean,
|
||||
error: String?,
|
||||
onEmojiSelected: (String) -> Unit,
|
||||
onGifSelected: (String) -> Unit,
|
||||
onGifSearch: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
var activeTab by remember { mutableStateOf(MediaTab.EMOJI) }
|
||||
|
||||
// Вычисляем индексы для прыжков (учитываем заголовки)
|
||||
val emojiCategories = remember {
|
||||
listOf(
|
||||
EmojiCategory("Недавние", Icons.Rounded.Schedule, listOf("👍", "❤️", "😂", "😍", "🙏", "🔥", "😭", "😊")),
|
||||
EmojiCategory("Смайлы", Icons.Rounded.EmojiEmotions, listOf("😀", "😃", "😄", "😁", "😆", "😅", "🤣", "😂", "🙂", "🙃", "😉", "😊", "😇")),
|
||||
EmojiCategory("Животные", Icons.Rounded.Pets, listOf("🐶", "🐱", "🐭", "🐹", "🐰", "🦊", "🐻", "🐼", "🐨", "🐯", "🦁", "🐮", "🐷")),
|
||||
EmojiCategory("Еда", Icons.Rounded.Fastfood, listOf("🍎", "🍐", "🍊", "🍋", "🍌", "🍉", "🍇", "🍓", "🫐", "🍈", "🍒", "🍑", "🥭")),
|
||||
EmojiCategory("Спорт", Icons.Rounded.SportsFootball, listOf("⚽", "🏀", "🏈", "⚾", "🎾", "🏐", "🏉", "🎱", "🏓", "🏸", "🥅", "🏒")),
|
||||
EmojiCategory("Транспорт", Icons.Rounded.DirectionsCar, listOf("🚗", "🚕", "🚙", "🚌", "🚎", "🏎️", "🚓", "🚑", "🚒", "🚐", "🚚", "🚛")),
|
||||
EmojiCategory("Объекты", Icons.Rounded.Lightbulb, listOf("💡", "🔦", "🕯️", "🪔", "📔", "📕", "📖", "💚", "💙", "💜", "🤎", "🖤")),
|
||||
EmojiCategory("Музыка", Icons.Rounded.MusicNote, listOf("🎵", "🎶", "🎼", "🎹", "🎸", "🎻", "🎷", "🎺", "🥁", "🎤", "🎧", "📻")),
|
||||
EmojiCategory("Флаги", Icons.Rounded.Flag, listOf("🏁", "🚩", "🎌", "🏴", "🏳️", "🏳️🌈", "🏳️⚧️", "🏴☠️", "🇦🇱", "🇩🇿", "🇦🇸", "🇦🇩"))
|
||||
)
|
||||
}
|
||||
|
||||
val categoryIndices = remember(emojiCategories) {
|
||||
var currentIdx = 0
|
||||
emojiCategories.map { cat ->
|
||||
val startIdx = currentIdx
|
||||
currentIdx += 1 + cat.emojis.size
|
||||
cat.icon to startIdx
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(450.dp)
|
||||
.background(Color(0xFF1C1C1C), RoundedCornerShape(topStart = 16.dp, topEnd = 16.dp))
|
||||
) {
|
||||
// Табы (Эмодзи / GIF)
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
.background(Color(0xFF2C2C2C), RoundedCornerShape(12.dp))
|
||||
.padding(2.dp)
|
||||
) {
|
||||
MediaTabItem(
|
||||
title = "ЭМОДЗИ",
|
||||
isSelected = activeTab == MediaTab.EMOJI,
|
||||
onClick = { activeTab = MediaTab.EMOJI },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
MediaTabItem(
|
||||
title = "GIF",
|
||||
isSelected = activeTab == MediaTab.GIF,
|
||||
onClick = { activeTab = MediaTab.GIF },
|
||||
modifier = Modifier.weight(1f)
|
||||
)
|
||||
}
|
||||
|
||||
Crossfade(targetState = activeTab, label = "media_tab") { tab ->
|
||||
when (tab) {
|
||||
MediaTab.EMOJI -> EmojiContent(
|
||||
emojiCategories = emojiCategories,
|
||||
categoryIndices = categoryIndices,
|
||||
onEmojiSelected = onEmojiSelected
|
||||
)
|
||||
MediaTab.GIF -> GifContent(
|
||||
trendingGifs = trendingGifs,
|
||||
searchedGifs = searchedGifs,
|
||||
recentGifs = recentGifs,
|
||||
gifCategories = gifCategories,
|
||||
isGifsLoading = isGifsLoading,
|
||||
error = error,
|
||||
onGifSelected = onGifSelected,
|
||||
onGifSearch = onGifSearch
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaTabItem(
|
||||
title: String,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(if (isSelected) Color(0xFF3C3C3C) else Color.Transparent)
|
||||
.clickable { onClick() }
|
||||
.padding(vertical = 8.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = title,
|
||||
color = if (isSelected) Color.White else Color.Gray,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.Bold
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiContent(
|
||||
emojiCategories: List<EmojiCategory>,
|
||||
categoryIndices: List<Pair<ImageVector, Int>>,
|
||||
onEmojiSelected: (String) -> Unit
|
||||
) {
|
||||
val scrollState = androidx.compose.foundation.lazy.grid.rememberLazyGridState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Категории
|
||||
LazyRow(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
) {
|
||||
items(categoryIndices) { (icon, index) ->
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = Color.Gray,
|
||||
modifier = Modifier
|
||||
.size(24.dp)
|
||||
.clickable {
|
||||
scope.launch {
|
||||
scrollState.animateScrollToItem(index)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Поиск
|
||||
TextField(
|
||||
value = "",
|
||||
onValueChange = {},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
placeholder = { Text("Поиск", color = Color.Gray) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) },
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color(0xFF2C2C2C),
|
||||
unfocusedContainerColor = Color(0xFF2C2C2C),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(8),
|
||||
state = scrollState,
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
emojiCategories.forEach { category ->
|
||||
// Заголовок категории
|
||||
item(span = { androidx.compose.foundation.lazy.grid.GridItemSpan(8) }) {
|
||||
Text(
|
||||
text = category.name,
|
||||
color = Color.Gray,
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.Medium,
|
||||
modifier = Modifier.padding(8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
items(category.emojis, key = { emoji -> emoji }) { emoji ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clickable { onEmojiSelected(emoji) },
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(text = emoji, fontSize = 28.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class EmojiCategory(val name: String, val icon: ImageVector, val emojis: List<String>)
|
||||
|
||||
@Composable
|
||||
private fun GifContent(
|
||||
trendingGifs: List<KlipyGifDto>,
|
||||
searchedGifs: List<KlipyGifDto>,
|
||||
recentGifs: List<KlipyGifDto>,
|
||||
gifCategories: List<chats.data.remote.api.GifCategoryDto>,
|
||||
isGifsLoading: Boolean,
|
||||
error: String?,
|
||||
onGifSelected: (String) -> Unit,
|
||||
onGifSearch: (String) -> Unit
|
||||
) {
|
||||
var query by remember { mutableStateOf("") }
|
||||
var subTab by remember { mutableStateOf("trending") }
|
||||
val context = LocalContext.current
|
||||
val imageLoader = CoilUtils.getGifImageLoader(context)
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
if (it.isNotEmpty()) subTab = "search"
|
||||
else if (subTab == "search") subTab = "trending"
|
||||
onGifSearch(it)
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 4.dp)
|
||||
.clip(RoundedCornerShape(8.dp)),
|
||||
placeholder = { Text("Поиск GIF...", color = Color.Gray) },
|
||||
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null, tint = Color.Gray) },
|
||||
colors = TextFieldDefaults.colors(
|
||||
focusedContainerColor = Color(0xFF2C2C2C),
|
||||
unfocusedContainerColor = Color(0xFF2C2C2C),
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
|
||||
// Под-вкладки GIF (Recent, Trending, Categories)
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
Icons.Rounded.Schedule,
|
||||
contentDescription = null,
|
||||
tint = if (subTab == "recent") Color(0xFF3B82F6) else Color.Gray,
|
||||
modifier = Modifier.size(20.dp).clickable { subTab = "recent"; query = "" }
|
||||
)
|
||||
Icon(
|
||||
Icons.Rounded.TrendingUp,
|
||||
contentDescription = null,
|
||||
tint = if (subTab == "trending") Color(0xFF3B82F6) else Color.Gray,
|
||||
modifier = Modifier.size(20.dp).clickable { subTab = "trending"; query = "" }
|
||||
)
|
||||
Icon(
|
||||
Icons.Rounded.GridView,
|
||||
contentDescription = null,
|
||||
tint = if (subTab == "categories") Color(0xFF3B82F6) else Color.Gray,
|
||||
modifier = Modifier.size(20.dp).clickable { subTab = "categories"; query = "" }
|
||||
)
|
||||
}
|
||||
|
||||
if (subTab == "categories") {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(2),
|
||||
modifier = Modifier.weight(1f).padding(4.dp),
|
||||
contentPadding = PaddingValues(4.dp)
|
||||
) {
|
||||
items(gifCategories) { category ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(4.dp)
|
||||
.aspectRatio(1.77f)
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(Color(0xFF2C2C2C))
|
||||
.clickable {
|
||||
query = category.query
|
||||
subTab = "search"
|
||||
onGifSearch(category.query)
|
||||
}
|
||||
) {
|
||||
AsyncImage(
|
||||
model = category.preview_url,
|
||||
contentDescription = category.category,
|
||||
modifier = Modifier.fillMaxSize().alpha(0.6f),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = category.category.uppercase(),
|
||||
color = Color.White,
|
||||
fontSize = 10.sp,
|
||||
fontWeight = FontWeight.Black,
|
||||
letterSpacing = 2.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
val displayGifs = when (subTab) {
|
||||
"recent" -> recentGifs
|
||||
"search" -> searchedGifs
|
||||
else -> trendingGifs
|
||||
}
|
||||
|
||||
if (isGifsLoading && displayGifs.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = Color(0xFF3B82F6))
|
||||
}
|
||||
} else if (!isGifsLoading && displayGifs.isEmpty()) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Text(error ?: "Ничего не найдено", color = Color.Gray)
|
||||
if (error != null) {
|
||||
Button(onClick = { onGifSearch(query) }) {
|
||||
Text("Повторить")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.weight(1f).padding(4.dp),
|
||||
contentPadding = PaddingValues(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
items(displayGifs, key = { gif -> gif.id }, contentType = { "gif" }) { gif ->
|
||||
val url = gif.files?.get("hd")?.get("gif")?.url
|
||||
?: gif.files?.get("sd")?.get("gif")?.url
|
||||
?: gif.file?.get("hd")?.get("gif")?.url
|
||||
?: gif.file?.get("sd")?.get("gif")?.url
|
||||
?: gif.media_formats?.get("gif")?.url
|
||||
?: gif.images?.original?.url
|
||||
|
||||
val preview = gif.files?.get("sd")?.get("webp")?.url
|
||||
?: gif.files?.get("sd")?.get("gif")?.url
|
||||
?: gif.file?.get("sd")?.get("webp")?.url
|
||||
?: gif.media_formats?.get("tinygif")?.url
|
||||
?: gif.images?.fixed_height_small?.url
|
||||
?: url
|
||||
|
||||
if (url != null) {
|
||||
AsyncImage(
|
||||
model = preview ?: url,
|
||||
imageLoader = imageLoader,
|
||||
contentDescription = gif.title,
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFF2C2C2C))
|
||||
.clickable { onGifSelected(url) },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.padding(2.dp)
|
||||
.aspectRatio(1f)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(Color(0xFF3C3C3C)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(gif.title ?: "GIF", color = Color.Gray, fontSize = 10.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,843 +0,0 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.layout.onGloballyPositioned
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import ru.knot.messager.R
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import chats.domain.model.Message
|
||||
import chats.domain.model.MediaType
|
||||
import chats.domain.model.Media
|
||||
import coil.compose.AsyncImage
|
||||
import core.presentation.components.AppVideoPlayer
|
||||
import core.presentation.components.AppAudioPlayer
|
||||
import core.presentation.components.AppAvatar
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.*
|
||||
import chats.presentation.components.LinkPreview
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.material3.*
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import core.presentation.components.AppMediaLightbox
|
||||
|
||||
@OptIn(androidx.compose.foundation.ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
message: Message,
|
||||
isCurrentUser: Boolean,
|
||||
onReactionClick: (String) -> Unit = {},
|
||||
autoPlay: Boolean = false,
|
||||
initialPlaybackSpeed: Float = 1.0f,
|
||||
onVoiceFinished: (Float) -> Unit = {},
|
||||
onReply: (Message) -> Unit = {},
|
||||
onReplyClick: (Message) -> Unit = {},
|
||||
onSelect: (String) -> Unit = {},
|
||||
onForward: (Message) -> Unit = {},
|
||||
onPin: (Message) -> Unit = {},
|
||||
onEdit: (Message) -> Unit = {},
|
||||
onDelete: (Message, Boolean) -> Unit = { _, _ -> },
|
||||
isSelected: Boolean = false,
|
||||
isSelectionMode: Boolean = false,
|
||||
onMediaClick: (chats.domain.model.Media) -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
var showReactionPicker by remember { mutableStateOf(false) }
|
||||
var lightboxMediaIndex by remember { mutableStateOf<Int?>(null) }
|
||||
|
||||
val contentColor = Color.White
|
||||
var showContextMenu by remember { mutableStateOf(false) }
|
||||
var showDeleteDialog by remember { mutableStateOf(false) }
|
||||
|
||||
val backgroundColor = when {
|
||||
isSelected -> if (isCurrentUser) Color(0xFF3096E5).copy(alpha = 0.5f) else Color(0xFF212121).copy(alpha = 0.5f)
|
||||
showContextMenu -> Color(0xFF007AFF) // Even brighter highlight
|
||||
isCurrentUser -> Color(0xFF3096E5)
|
||||
else -> Color(0xFF212121)
|
||||
}
|
||||
|
||||
val shape = if (isCurrentUser) {
|
||||
RoundedCornerShape(16.dp, 16.dp, 4.dp, 16.dp)
|
||||
} else {
|
||||
RoundedCornerShape(16.dp, 16.dp, 16.dp, 4.dp)
|
||||
}
|
||||
|
||||
val isVoiceMessage = message.media.any { it.type == "voice" } || (message.mediaType == MediaType.AUDIO && (message.content == null || message.content.isEmpty()))
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.pointerInput(Unit) { // Clicks anywhere in the Row row now work
|
||||
detectTapGestures(
|
||||
onTap = {
|
||||
if (isSelectionMode) {
|
||||
onSelect(message.id)
|
||||
} else {
|
||||
showContextMenu = !showContextMenu
|
||||
}
|
||||
},
|
||||
onLongPress = {
|
||||
if (!isSelectionMode) {
|
||||
onSelect(message.id)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.padding(horizontal = 8.dp, vertical = 2.dp),
|
||||
horizontalArrangement = if (isCurrentUser) Arrangement.End else Arrangement.Start,
|
||||
verticalAlignment = Alignment.Bottom
|
||||
) {
|
||||
var bubbleHeight by remember { mutableIntStateOf(0) }
|
||||
var isNearBottom by remember { mutableStateOf(false) }
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.onGloballyPositioned { coords ->
|
||||
bubbleHeight = coords.size.height
|
||||
val screenHeight = coords.parentLayoutCoordinates?.size?.height ?: 2000
|
||||
val yInWindow = coords.localToWindow(Offset.Zero).y
|
||||
// If bottom of message is in the last 40% of screen, open menu UP
|
||||
isNearBottom = yInWindow > screenHeight * 0.6f
|
||||
},
|
||||
contentAlignment = if (isCurrentUser) Alignment.CenterEnd else Alignment.CenterStart
|
||||
) {
|
||||
// Actual bubble
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.clip(shape)
|
||||
.background(backgroundColor)
|
||||
.padding(8.dp)
|
||||
) {
|
||||
if (showContextMenu) {
|
||||
val density = androidx.compose.ui.platform.LocalDensity.current
|
||||
val menuWidth = 220.dp
|
||||
val menuWidthPx = with(density) { menuWidth.toPx() }
|
||||
|
||||
Popup(
|
||||
alignment = if (isNearBottom) {
|
||||
if (isCurrentUser) Alignment.BottomStart else Alignment.BottomEnd
|
||||
} else {
|
||||
if (isCurrentUser) Alignment.TopStart else Alignment.TopEnd
|
||||
},
|
||||
offset = if (isCurrentUser) {
|
||||
// Message is on RIGHT, show menu to its LEFT
|
||||
IntOffset(-menuWidthPx.toInt() - 20, 0)
|
||||
} else {
|
||||
// Message is on LEFT, show menu to its RIGHT
|
||||
IntOffset(menuWidthPx.toInt() + 20, 0)
|
||||
},
|
||||
onDismissRequest = { showContextMenu = false },
|
||||
properties = androidx.compose.ui.window.PopupProperties(
|
||||
focusable = true,
|
||||
dismissOnBackPress = true,
|
||||
dismissOnClickOutside = true
|
||||
)
|
||||
) {
|
||||
MessageContextMenu(
|
||||
isMyMessage = isCurrentUser,
|
||||
isPinned = message.isPinned,
|
||||
onReactionSelected = {
|
||||
onReactionClick(it)
|
||||
showContextMenu = false
|
||||
},
|
||||
onAction = { action ->
|
||||
showContextMenu = false
|
||||
when(action) {
|
||||
"reply" -> onReply(message)
|
||||
"select" -> onSelect(message.id)
|
||||
"forward" -> onForward(message)
|
||||
"pin" -> onPin(message)
|
||||
"edit" -> onEdit(message)
|
||||
"copy" -> {
|
||||
val clipboard = context.getSystemService(android.content.Context.CLIPBOARD_SERVICE) as android.content.ClipboardManager
|
||||
val clip = android.content.ClipData.newPlainText("message", message.content)
|
||||
clipboard.setPrimaryClip(clip)
|
||||
}
|
||||
"delete" -> {
|
||||
showDeleteDialog = true
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Forwarded Info
|
||||
if (message.isForwarded) {
|
||||
Text(
|
||||
text = "Forwarded from ${message.forwardedFromName ?: "Unknown"}",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.7f),
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic,
|
||||
modifier = Modifier.padding(bottom = 4.dp)
|
||||
)
|
||||
}
|
||||
|
||||
// Reply Info
|
||||
message.replyTo?.let { reply ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(bottom = 6.dp)
|
||||
.clip(RoundedCornerShape(4.dp))
|
||||
.background(contentColor.copy(alpha = 0.1f))
|
||||
.height(IntrinsicSize.Min)
|
||||
.clickable { onReplyClick(reply) }
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(2.dp)
|
||||
.background(if (isCurrentUser) Color.White else Color(0xFF3096E5))
|
||||
)
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
val isReplyImage = reply.mediaType == MediaType.IMAGE || reply.mediaType == MediaType.VIDEO || reply.mediaType == MediaType.GIF
|
||||
|
||||
if (isReplyImage && reply.media.isNotEmpty()) {
|
||||
AsyncImage(
|
||||
model = reply.media.first().url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.width(40.dp)
|
||||
.clip(RoundedCornerShape(0.dp, 4.dp, 4.dp, 0.dp)),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp).weight(1f)) {
|
||||
val accentColor = if (isCurrentUser) Color.White else Color(0xFF3096E5)
|
||||
Text(
|
||||
text = reply.senderName,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = accentColor,
|
||||
maxLines = 1,
|
||||
fontWeight = androidx.compose.ui.text.font.FontWeight.Bold
|
||||
)
|
||||
|
||||
val photoText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_photo)
|
||||
val videoText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_video)
|
||||
val voiceText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.voice_message)
|
||||
val audioText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_audio)
|
||||
val fileText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_file)
|
||||
val gifText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.reply_gif)
|
||||
val mediaText = androidx.compose.ui.res.stringResource(ru.knot.messager.R.string.media)
|
||||
|
||||
val replyText = remember(reply) {
|
||||
if (!reply.content.isNullOrEmpty()) {
|
||||
reply.content
|
||||
} else {
|
||||
when (reply.mediaType) {
|
||||
MediaType.IMAGE -> photoText
|
||||
MediaType.VIDEO -> videoText
|
||||
MediaType.AUDIO -> if (reply.media.any { it.type == "voice" }) voiceText else audioText
|
||||
MediaType.FILE -> fileText
|
||||
MediaType.GIF -> gifText
|
||||
else -> mediaText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = replyText,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentColor.copy(alpha = 0.8f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GIF Content (Klipy)
|
||||
if (message.mediaType == MediaType.GIF) {
|
||||
AsyncImage(
|
||||
model = message.content,
|
||||
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { /* Handle click */ },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Loading state for temp media messages
|
||||
if (message.id.startsWith("temp_") && message.mediaType != MediaType.TEXT) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(contentColor.copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = contentColor.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Media Content (Attachments)
|
||||
if (message.media.isNotEmpty() && message.mediaType != MediaType.GIF) {
|
||||
val mediaCount = message.media.size
|
||||
if (mediaCount == 1) {
|
||||
val mediaItem = message.media[0]
|
||||
when {
|
||||
mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> {
|
||||
AsyncImage(
|
||||
model = mediaItem.url,
|
||||
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onMediaClick(mediaItem) },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
message.mediaType == MediaType.VIDEO || mediaItem.type.startsWith("video") -> {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(240.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
) {
|
||||
AppVideoPlayer(
|
||||
url = mediaItem.url,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
useController = false,
|
||||
isMuted = true,
|
||||
autoPlay = true
|
||||
)
|
||||
// Прозрачный слой поверх видео для клика
|
||||
Box(modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Transparent)
|
||||
.clickable { onMediaClick(mediaItem) }
|
||||
)
|
||||
|
||||
Icon(
|
||||
imageVector = Icons.Default.VolumeOff,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(8.dp).size(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
mediaItem.type.startsWith("audio") || mediaItem.filename?.endsWith(".mp2", true) == true || mediaItem.filename?.endsWith(".mp3", true) == true || message.mediaType == MediaType.AUDIO -> {
|
||||
AppAudioPlayer(
|
||||
url = mediaItem.url,
|
||||
name = mediaItem.filename,
|
||||
size = mediaItem.size,
|
||||
isVoiceMessage = isVoiceMessage,
|
||||
initialPlaybackSpeed = initialPlaybackSpeed,
|
||||
autoPlay = autoPlay,
|
||||
onFinished = onVoiceFinished,
|
||||
contentColor = contentColor
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
FileItem(media = mediaItem, isCurrentUser = isCurrentUser, contentColor = contentColor)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Multi-media grid
|
||||
PhotoGrid(
|
||||
mediaList = message.media,
|
||||
isCurrentUser = isCurrentUser,
|
||||
onMediaClick = { index -> onMediaClick(message.media[index]) },
|
||||
modifier = Modifier.fillMaxWidth().height(300.dp).clip(RoundedCornerShape(12.dp))
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Text Content
|
||||
if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) {
|
||||
Column {
|
||||
val annotatedString = remember(message.content) {
|
||||
val text = message.content ?: ""
|
||||
val links = core.utils.LinkParser.findLinks(text)
|
||||
androidx.compose.ui.text.buildAnnotatedString {
|
||||
append(text)
|
||||
links.forEach { link ->
|
||||
val startIndex = text.indexOf(link)
|
||||
if (startIndex >= 0) {
|
||||
val endIndex = startIndex + link.length
|
||||
addStyle(
|
||||
style = androidx.compose.ui.text.SpanStyle(
|
||||
color = Color(0xFF3096E5),
|
||||
textDecoration = androidx.compose.ui.text.style.TextDecoration.Underline
|
||||
),
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
addStringAnnotation(
|
||||
tag = "URL",
|
||||
annotation = link,
|
||||
start = startIndex,
|
||||
end = endIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val context = androidx.compose.ui.platform.LocalContext.current
|
||||
androidx.compose.foundation.text.ClickableText(
|
||||
text = annotatedString,
|
||||
style = MaterialTheme.typography.bodyMedium.copy(color = contentColor),
|
||||
onClick = { offset ->
|
||||
annotatedString.getStringAnnotations(tag = "URL", start = offset, end = offset)
|
||||
.firstOrNull()?.let { annotation ->
|
||||
try {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(annotation.item))
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
val links = remember(message.content) { core.utils.LinkParser.findLinks(message.content) }
|
||||
if (links.isNotEmpty()) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
LinkPreview(
|
||||
url = links[0],
|
||||
contentColor = contentColor
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Time and Status
|
||||
Row(
|
||||
modifier = Modifier.align(Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (message.reactions.isNotEmpty()) {
|
||||
MessageReactions(
|
||||
reactions = message.reactions,
|
||||
onReactionClick = onReactionClick,
|
||||
modifier = Modifier.padding(end = 4.dp)
|
||||
)
|
||||
}
|
||||
val formattedTime = remember(message.createdAt) {
|
||||
try {
|
||||
val instant = Instant.parse(message.createdAt)
|
||||
val zonedDateTime = instant.atZone(ZoneId.systemDefault())
|
||||
zonedDateTime.format(DateTimeFormatter.ofPattern("HH:mm"))
|
||||
} catch (e: Exception) {
|
||||
message.createdAt.substringAfter('T').take(5)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = formattedTime,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.6f),
|
||||
fontSize = 11.sp
|
||||
)
|
||||
if (message.isPinned) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PushPin,
|
||||
contentDescription = "Pinned",
|
||||
modifier = Modifier.size(12.dp).padding(start = 2.dp).graphicsLayer { rotationZ = 45f },
|
||||
tint = contentColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
if (isCurrentUser) {
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Icon(
|
||||
imageVector = if (message.isRead) Icons.Default.DoneAll else Icons.Default.Done,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(12.dp),
|
||||
tint = contentColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
}
|
||||
} // End of bubble Column
|
||||
|
||||
if (isSelected) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.CheckCircle,
|
||||
contentDescription = null,
|
||||
tint = Color.White,
|
||||
modifier = Modifier
|
||||
.align(Alignment.Center)
|
||||
.size(32.dp)
|
||||
.background(Color(0xFF3096E5), CircleShape)
|
||||
.padding(2.dp)
|
||||
)
|
||||
}
|
||||
} // End of Box
|
||||
} // End of Row
|
||||
|
||||
lightboxMediaIndex?.let { index ->
|
||||
AppMediaLightbox(
|
||||
mediaList = message.media,
|
||||
initialIndex = index,
|
||||
onClose = { lightboxMediaIndex = null }
|
||||
)
|
||||
}
|
||||
|
||||
if (showDeleteDialog) {
|
||||
var deleteForEveryone by remember { mutableStateOf(false) }
|
||||
|
||||
AlertDialog(
|
||||
onDismissRequest = { showDeleteDialog = false },
|
||||
title = { Text("Удалить сообщение?") },
|
||||
text = {
|
||||
Column {
|
||||
Text("Вы уверены, что хотите удалить это сообщение?")
|
||||
if (isCurrentUser) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.padding(top = 8.dp).clickable { deleteForEveryone = !deleteForEveryone }
|
||||
) {
|
||||
Checkbox(
|
||||
checked = deleteForEveryone,
|
||||
onCheckedChange = { deleteForEveryone = it }
|
||||
)
|
||||
Text("Удалить у всех")
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onDelete(message, deleteForEveryone)
|
||||
showDeleteDialog = false
|
||||
},
|
||||
colors = ButtonDefaults.textButtonColors(contentColor = Color(0xFFE53935))
|
||||
) {
|
||||
Text("Удалить")
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = { showDeleteDialog = false }) {
|
||||
Text("Отмена")
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageReactions(
|
||||
reactions: Map<String, Int>,
|
||||
onReactionClick: (String) -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
reactions.forEach { (emoji, count) ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(Color.White.copy(alpha = 0.2f))
|
||||
.clickable { onReactionClick(emoji) }
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(text = emoji, fontSize = 12.sp)
|
||||
if (count > 1) {
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
Text(text = count.toString(), fontSize = 10.sp, color = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun FileItem(media: Media, isCurrentUser: Boolean, contentColor: Color) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(contentColor.copy(alpha = 0.1f))
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(if (isCurrentUser) Color.White.copy(alpha = 0.2f) else Color(0xFF3390EC).copy(alpha = 0.2f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(Icons.Default.Description, contentDescription = null, tint = contentColor)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.weight(1f).padding(horizontal = 8.dp)
|
||||
) {
|
||||
val fileName = media.filename ?: "File"
|
||||
Text(
|
||||
text = fileName,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = contentColor,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
val sizeStr = media.size?.let {
|
||||
if (it > 1024 * 1024) "${it / (1024 * 1024)} MB" else "${it / 1024} KB"
|
||||
} ?: "File"
|
||||
Text(
|
||||
text = sizeStr,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = contentColor.copy(alpha = 0.6f)
|
||||
)
|
||||
}
|
||||
Icon(Icons.Default.Download, contentDescription = null, tint = contentColor, modifier = Modifier.size(20.dp))
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PhotoGrid(mediaList: List<Media>, isCurrentUser: Boolean, onMediaClick: (Int) -> Unit, modifier: Modifier = Modifier) {
|
||||
if (mediaList.isEmpty()) return
|
||||
|
||||
val columns = when {
|
||||
mediaList.size == 1 -> 1
|
||||
mediaList.size <= 4 -> 2
|
||||
else -> 3
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
val rows = (mediaList.size + columns - 1) / columns
|
||||
for (i in 0 until rows) {
|
||||
Row(modifier = Modifier.weight(1f).fillMaxWidth()) {
|
||||
for (j in 0 until columns) {
|
||||
val index = i * columns + j
|
||||
if (index < mediaList.size) {
|
||||
GridItem(
|
||||
media = mediaList[index],
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxHeight()
|
||||
.padding(1.dp)
|
||||
.clickable { onMediaClick(index) }
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun GridItem(media: Media, modifier: Modifier = Modifier) {
|
||||
val context = LocalContext.current
|
||||
val isImage = media.type.startsWith("image")
|
||||
val isVideo = media.type.startsWith("video")
|
||||
|
||||
Box(modifier = modifier.background(Color.White.copy(alpha = 0.1f))) {
|
||||
if (isImage || isVideo) {
|
||||
AsyncImage(
|
||||
model = media.url,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
if (isVideo) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.PlayCircle,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.8f),
|
||||
modifier = Modifier.align(Alignment.Center).size(32.dp)
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(4.dp),
|
||||
verticalArrangement = Arrangement.Center,
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.InsertDriveFile,
|
||||
contentDescription = null,
|
||||
tint = Color.White.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(32.dp)
|
||||
)
|
||||
Text(
|
||||
text = media.filename ?: "File",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = androidx.compose.ui.text.style.TextAlign.Center
|
||||
)
|
||||
Text(
|
||||
text = media.url.substringAfterLast(".").uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.White.copy(alpha = 0.5f),
|
||||
fontSize = 8.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageContextMenu(
|
||||
isMyMessage: Boolean,
|
||||
isPinned: Boolean,
|
||||
onReactionSelected: (String) -> Unit,
|
||||
onAction: (String) -> Unit
|
||||
) {
|
||||
var isVisible by remember { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
isVisible = true
|
||||
}
|
||||
|
||||
androidx.compose.animation.AnimatedVisibility(
|
||||
visible = isVisible,
|
||||
enter = androidx.compose.animation.fadeIn(animationSpec = androidx.compose.animation.core.tween(200)) +
|
||||
androidx.compose.animation.scaleIn(
|
||||
initialScale = 0.5f,
|
||||
animationSpec = androidx.compose.animation.core.spring(
|
||||
dampingRatio = 0.6f,
|
||||
stiffness = 400f
|
||||
),
|
||||
transformOrigin = androidx.compose.ui.graphics.TransformOrigin(
|
||||
if (isVisible) 0.5f else 0.5f,
|
||||
0f
|
||||
)
|
||||
),
|
||||
exit = androidx.compose.animation.fadeOut() + androidx.compose.animation.scaleOut()
|
||||
) {
|
||||
androidx.compose.material3.Surface(
|
||||
modifier = Modifier
|
||||
.width(280.dp) // Even wider for all reactions
|
||||
.clip(RoundedCornerShape(20.dp)),
|
||||
color = Color(0xFF1E1E1E),
|
||||
shadowElevation = 15.dp,
|
||||
border = androidx.compose.foundation.BorderStroke(0.5.dp, Color.White.copy(alpha = 0.15f))
|
||||
) {
|
||||
Column {
|
||||
// Extended Reactions scrollable bar
|
||||
androidx.compose.foundation.lazy.LazyRow(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 4.dp, vertical = 10.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
contentPadding = PaddingValues(horizontal = 12.dp)
|
||||
) {
|
||||
val telegramReactions = listOf(
|
||||
"👍", "❤️", "😂", "🔥", "💯", "👏", "🤩", "🤔", "🤯", "😱", "🤬",
|
||||
"👎", "💩", "🤡", "🥳", "😇", "🌚", "🙏", "👌", "🕊️", "🐳",
|
||||
"🌭", "🦄", "🍌", "💊", "🍓", "🍾", "💋", "🖕", "👀", "👻",
|
||||
"🤝", "🤣", "⚡", "✨", "🎈", "🎉", "🧊", "🆒", "🤨", "😐",
|
||||
"🤷♂️", "💅", "🏆", "👾", "🎯", "🎲", "🍷", "🤮", "🥱", "😴",
|
||||
"🤐", "🥴"
|
||||
)
|
||||
items(telegramReactions) { emoji ->
|
||||
Text(
|
||||
text = emoji,
|
||||
fontSize = 28.sp,
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.clickable { onReactionSelected(emoji) }
|
||||
.padding(4.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Divider(color = Color.White.copy(alpha = 0.1f))
|
||||
|
||||
ContextMenuItem(Icons.Default.Reply, "Ответить", onClick = { onAction("reply") })
|
||||
ContextMenuItem(Icons.Default.CheckCircle, "Выбрать", onClick = { onAction("select") })
|
||||
ContextMenuItem(Icons.Default.Forward, "Переслать", onClick = { onAction("forward") })
|
||||
ContextMenuItem(
|
||||
icon = Icons.Default.PushPin,
|
||||
text = if (isPinned) "Открепить" else "Закрепить",
|
||||
onClick = { onAction("pin") }
|
||||
)
|
||||
ContextMenuItem(Icons.Default.ContentCopy, "Копировать", onClick = { onAction("copy") })
|
||||
if (isMyMessage) {
|
||||
ContextMenuItem(Icons.Default.Edit, "Редактировать", onClick = { onAction("edit") })
|
||||
}
|
||||
|
||||
Divider(color = Color.White.copy(alpha = 0.1f))
|
||||
|
||||
ContextMenuItem(
|
||||
Icons.Default.Delete,
|
||||
"Удалить",
|
||||
textColor = Color(0xFFE53935),
|
||||
onClick = { onAction("delete") }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun ContextMenuItem(
|
||||
icon: ImageVector,
|
||||
text: String,
|
||||
textColor: Color = Color.White,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
tint = textColor.copy(alpha = 0.7f),
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
Spacer(modifier = Modifier.width(16.dp))
|
||||
Text(
|
||||
text = text,
|
||||
color = textColor,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
package chats.presentation.components
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.*
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
@Composable
|
||||
fun ReactionPicker(
|
||||
onReactionSelected: (String) -> Unit
|
||||
) {
|
||||
val reactions = listOf("❤️", "👍", "👎", "🔥", "😂", "😢", "😮")
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.background(MaterialTheme.colorScheme.surface, CircleShape)
|
||||
.padding(8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
reactions.forEach { reaction ->
|
||||
Text(
|
||||
text = reaction,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.clickable { onReactionSelected(reaction) },
|
||||
fontSize = 20.sp
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun MessageReactions(
|
||||
reactions: Map<String, Int>, // Эмодзи -> Количество
|
||||
onReactionClick: (String) -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)
|
||||
) {
|
||||
reactions.forEach { (emoji, count) ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.1f))
|
||||
.clickable { onReactionClick(emoji) }
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
) {
|
||||
Text(text = "$emoji $count", fontSize = 12.sp)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user