Compare commits
28
Commits
bugfix_web
...
8c8ef55b58
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8c8ef55b58 | ||
|
|
70acad56fb | ||
|
|
629fddfca0 | ||
|
|
b68f68a1f2 | ||
|
|
754e9e8ad0 | ||
|
|
ed7521a563 | ||
|
|
bef30c2c86 | ||
|
|
8c00d1376d | ||
|
|
cea3f4d669 | ||
|
|
8409c51842 | ||
|
|
9560a9235f | ||
|
|
f17edfc0da | ||
|
|
c6bebec599 | ||
|
|
5a71e5bbfa | ||
|
|
5b0133d55e | ||
|
|
dcb733ac01 | ||
|
|
487cb1b12b | ||
|
|
58fdf1aca1 | ||
|
|
118f8b8971 | ||
|
|
8165b74e43 | ||
|
|
8ce4bc714f | ||
|
|
2d0bc0d75c | ||
|
|
d7e75797ef | ||
|
|
58bbdae26c | ||
|
|
3310a3c4a4 | ||
|
|
d8b0d86534 | ||
|
|
dc051fa9ae | ||
|
|
1fb1be47dd |
@@ -32,7 +32,8 @@ public record MessageDetailDto(
|
|||||||
bool? PollIsMultipleChoice = null,
|
bool? PollIsMultipleChoice = null,
|
||||||
bool? PollIsAnonymous = null,
|
bool? PollIsAnonymous = null,
|
||||||
bool? PollIsClosed = null,
|
bool? PollIsClosed = null,
|
||||||
List<Guid>? UserVotedOptionIds = null
|
List<Guid>? UserVotedOptionIds = null,
|
||||||
|
bool IsDeletedForUser = false
|
||||||
);
|
);
|
||||||
|
|
||||||
public record ReplyToMessageDto(
|
public record ReplyToMessageDto(
|
||||||
|
|||||||
+12
-17
@@ -42,10 +42,16 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
|||||||
|
|
||||||
if (request.DeleteForAll)
|
if (request.DeleteForAll)
|
||||||
{
|
{
|
||||||
|
// Only message sender can delete for everyone
|
||||||
if (message.SenderId == request.UserId)
|
if (message.SenderId == request.UserId)
|
||||||
{
|
{
|
||||||
message.Delete();
|
message.Delete();
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// If not the sender, just delete for current user
|
||||||
|
message.DeleteForUser(request.UserId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -55,24 +61,13 @@ public sealed class DeleteMessagesCommandHandler : ICommandHandler<DeleteMessage
|
|||||||
await _messageRepository.UpdateAsync(message, cancellationToken);
|
await _messageRepository.UpdateAsync(message, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.DeleteForAll)
|
// Notify all clients in the chat about the deletion
|
||||||
|
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
||||||
{
|
{
|
||||||
await _hubContext.Clients.Group(request.ChatId.ToString()).SendAsync("messages_deleted", new
|
chatId = request.ChatId,
|
||||||
{
|
messageIds = request.MessageIds,
|
||||||
chatId = request.ChatId,
|
deleteForAll = request.DeleteForAll
|
||||||
messageIds = request.MessageIds,
|
});
|
||||||
deleteForAll = true
|
|
||||||
});
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
await _hubContext.Clients.User(request.UserId.ToString()).SendAsync("messages_deleted", new
|
|
||||||
{
|
|
||||||
chatId = request.ChatId,
|
|
||||||
messageIds = request.MessageIds,
|
|
||||||
deleteForAll = false
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return global::Knot.Shared.Kernel.Result.Success();
|
return global::Knot.Shared.Kernel.Result.Success();
|
||||||
}
|
}
|
||||||
|
|||||||
+15
-10
@@ -3,11 +3,11 @@ using System.Collections.Generic;
|
|||||||
using System.Linq;
|
using System.Linq;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
using System.Threading.Tasks;
|
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.Application.Abstractions;
|
||||||
using Knot.Contracts.Messaging.Domain;
|
using Knot.Contracts.Messaging.Domain;
|
||||||
using Knot.Contracts.Conversations.Application.Abstractions;
|
|
||||||
using Knot.Modules.Conversations.Application.DTOs;
|
using Knot.Modules.Conversations.Application.DTOs;
|
||||||
using Knot.Contracts.Conversations.Domain;
|
|
||||||
using Knot.Shared.Kernel;
|
using Knot.Shared.Kernel;
|
||||||
using MediatR;
|
using MediatR;
|
||||||
|
|
||||||
@@ -114,7 +114,8 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
|
|
||||||
senders.TryGetValue(message.SenderId, out var sender);
|
senders.TryGetValue(message.SenderId, out var sender);
|
||||||
reactionsByMessage.TryGetValue(message.Id, out var reactions);
|
reactionsByMessage.TryGetValue(message.Id, out var reactions);
|
||||||
|
|
||||||
|
|
||||||
Message? replyMsg = null;
|
Message? replyMsg = null;
|
||||||
if (message.ReplyToId.HasValue)
|
if (message.ReplyToId.HasValue)
|
||||||
{
|
{
|
||||||
@@ -141,7 +142,7 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
replyMsg is MediaMessage mm ? mm.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() : new List<MediaDto>(),
|
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
|
replySender != null ? new MessageSenderDto(replySender.Id, replySender.Username, replySender.DisplayName, replySender.Avatar) : null
|
||||||
) : null,
|
) : null,
|
||||||
message is TextMessage tm ? tm.Quote : null,
|
(message as TextMessage)?.Quote,
|
||||||
message.IsEdited,
|
message.IsEdited,
|
||||||
message.IsDeleted,
|
message.IsDeleted,
|
||||||
message.CreatedAt,
|
message.CreatedAt,
|
||||||
@@ -154,19 +155,22 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
(message as MediaMessage)?.Media.Select(m => new MediaDto(m.Id, m.Type, m.Url, m.Filename, m.Size, m.Duration)).ToList() ?? new List<MediaDto>(),
|
(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),
|
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
|
new List<ReadByDto>(), // ReadBy not implemented in this detailed view yet
|
||||||
reactions?.Select(r => {
|
reactions?.Select(r =>
|
||||||
|
{
|
||||||
senders.TryGetValue(r.UserId, out var ru);
|
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);
|
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>(),
|
}).ToList() ?? new List<MessageReactionDto>(),
|
||||||
(message as CallMessage)?.CallType,
|
(message as CallMessage)?.CallType,
|
||||||
(message as CallMessage)?.CallStatus,
|
(message as CallMessage)?.CallStatus,
|
||||||
(message as CallMessage)?.Duration,
|
(message as CallMessage)?.Duration,
|
||||||
(message as PollMessage)?.Options.Select(o => {
|
(message as PollMessage)?.Options.Select(o =>
|
||||||
|
{
|
||||||
var pm = (PollMessage)message;
|
var pm = (PollMessage)message;
|
||||||
var voters = pm.IsAnonymous == false
|
var voters = pm.IsAnonymous == false
|
||||||
? pm.Votes
|
? pm.Votes
|
||||||
.Where(v => v.OptionId == o.Id)
|
.Where(v => v.OptionId == o.Id)
|
||||||
.Select(v => {
|
.Select(v =>
|
||||||
|
{
|
||||||
senders.TryGetValue(v.UserId, out var vu);
|
senders.TryGetValue(v.UserId, out var vu);
|
||||||
return vu != null
|
return vu != null
|
||||||
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
|
? new MessageSenderDto(vu.Id, vu.Username, vu.DisplayName, vu.Avatar)
|
||||||
@@ -174,12 +178,13 @@ internal sealed class GetMessagesQueryHandler : IQueryHandler<GetMessagesQuery,
|
|||||||
})
|
})
|
||||||
.ToList()
|
.ToList()
|
||||||
: null;
|
: null;
|
||||||
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
|
return new PollOptionDto(o.Id, o.Text, o.VoteCount, voters, pm.IsAnonymous == false ? pm.Votes.Where(v => v.OptionId == o.Id).Select(v => v.UserId).ToList() : null);
|
||||||
}).ToList(),
|
}).ToList(),
|
||||||
(message as PollMessage)?.IsMultipleChoice,
|
(message as PollMessage)?.IsMultipleChoice,
|
||||||
(message as PollMessage)?.IsAnonymous,
|
(message as PollMessage)?.IsAnonymous,
|
||||||
(message as PollMessage)?.IsClosed,
|
(message as PollMessage)?.IsClosed,
|
||||||
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList()
|
(message as PollMessage)?.Votes.Where(v => v.UserId == request.UserId).Select(v => v.OptionId).ToList(),
|
||||||
|
message.IsDeletedForUser(request.UserId)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
#Tue Apr 14 00:27:15 MSK 2026
|
||||||
|
gradle.version=8.5
|
||||||
Binary file not shown.
@@ -0,0 +1,2 @@
|
|||||||
|
#Tue Apr 14 00:13:58 MSK 2026
|
||||||
|
java.home=C\:\\Program Files\\Android\\Android Studio\\jbr
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
id("com.google.dagger.hilt.android")
|
||||||
|
id("com.google.gms.google-services")
|
||||||
|
kotlin("kapt")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "ru.knot.messager"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "ru.knot.messager"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0.0"
|
||||||
|
|
||||||
|
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||||
|
vectorDrawables {
|
||||||
|
useSupportLibrary = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подключаем все наши папки с кодом как sourceSets
|
||||||
|
sourceSets {
|
||||||
|
getByName("main") {
|
||||||
|
java.srcDirs(
|
||||||
|
"src/main/kotlin",
|
||||||
|
"../auth",
|
||||||
|
"../chats",
|
||||||
|
"../core",
|
||||||
|
"../calls",
|
||||||
|
"../stories",
|
||||||
|
"../contacts",
|
||||||
|
"../profiles",
|
||||||
|
"../settings",
|
||||||
|
"../navigation"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
kotlinOptions {
|
||||||
|
jvmTarget = "17"
|
||||||
|
}
|
||||||
|
buildFeatures {
|
||||||
|
compose = true
|
||||||
|
}
|
||||||
|
composeOptions {
|
||||||
|
kotlinCompilerExtensionVersion = "1.5.8"
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
// AndroidX & UI
|
||||||
|
implementation("androidx.core:core-ktx:1.12.0")
|
||||||
|
implementation("androidx.lifecycle:lifecycle-runtime-ktx:2.6.2")
|
||||||
|
implementation("androidx.activity:activity-compose:1.8.1")
|
||||||
|
implementation(platform("androidx.compose:compose-bom:2023.10.01"))
|
||||||
|
implementation("androidx.compose.ui:ui")
|
||||||
|
implementation("androidx.compose.ui:ui-graphics")
|
||||||
|
implementation("androidx.compose.ui:ui-tooling-preview")
|
||||||
|
implementation("androidx.compose.material3:material3")
|
||||||
|
implementation("com.google.android.material:material:1.11.0")
|
||||||
|
implementation("androidx.navigation:navigation-compose:2.7.5")
|
||||||
|
implementation("androidx.compose.material:material-icons-extended")
|
||||||
|
|
||||||
|
// Hilt
|
||||||
|
implementation("com.google.dagger:hilt-android:2.48")
|
||||||
|
kapt("com.google.dagger:hilt-android-compiler:2.48")
|
||||||
|
implementation("androidx.hilt:hilt-navigation-compose:1.1.0")
|
||||||
|
|
||||||
|
// Network & SignalR
|
||||||
|
implementation("com.squareup.retrofit2:retrofit:2.9.0")
|
||||||
|
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
|
||||||
|
implementation("com.squareup.okhttp3:logging-interceptor:4.12.0")
|
||||||
|
implementation("com.microsoft.signalr:signalr:7.0.0")
|
||||||
|
|
||||||
|
// WebRTC
|
||||||
|
implementation("com.github.webrtc-sdk:android:104.5112.01")
|
||||||
|
|
||||||
|
// Media3 (ExoPlayer)
|
||||||
|
implementation("androidx.media3:media3-exoplayer:1.2.0")
|
||||||
|
implementation("androidx.media3:media3-ui:1.2.0")
|
||||||
|
implementation("androidx.media3:media3-common:1.2.0")
|
||||||
|
|
||||||
|
// Images & GIF
|
||||||
|
implementation("io.coil-kt:coil-compose:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-gif:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-svg:2.5.0")
|
||||||
|
implementation("io.coil-kt:coil-video:2.5.0")
|
||||||
|
|
||||||
|
// Security
|
||||||
|
implementation("androidx.security:security-crypto:1.1.0-alpha06")
|
||||||
|
|
||||||
|
// UCrop (Image Cropping)
|
||||||
|
implementation("com.github.yalantis:ucrop:2.2.8")
|
||||||
|
|
||||||
|
// Firebase (Push Notifications)
|
||||||
|
implementation(platform("com.google.firebase:firebase-bom:32.7.0"))
|
||||||
|
implementation("com.google.firebase:firebase-messaging-ktx")
|
||||||
|
implementation("com.google.firebase:firebase-analytics-ktx")
|
||||||
|
|
||||||
|
// Room
|
||||||
|
val room_version = "2.6.1"
|
||||||
|
implementation("androidx.room:room-runtime:$room_version")
|
||||||
|
implementation("androidx.room:room-ktx:$room_version")
|
||||||
|
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")
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?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.ForkFirebaseMessagingService"
|
||||||
|
android:exported="false">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="com.google.firebase.MESSAGING_EVENT" />
|
||||||
|
</intent-filter>
|
||||||
|
</service>
|
||||||
|
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package com.knot.messenger
|
||||||
|
|
||||||
|
import android.os.Bundle
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.activity.compose.setContent
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import dagger.hilt.android.AndroidEntryPoint
|
||||||
|
import navigation.AppNavigation
|
||||||
|
import core.presentation.theme.ForkMessengerTheme
|
||||||
|
|
||||||
|
@AndroidEntryPoint
|
||||||
|
class MainActivity : ComponentActivity() {
|
||||||
|
@javax.inject.Inject
|
||||||
|
lateinit var navigationManager: core.utils.NavigationManager
|
||||||
|
|
||||||
|
@javax.inject.Inject
|
||||||
|
lateinit var signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
ForkMessengerTheme {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
color = MaterialTheme.colorScheme.background
|
||||||
|
) {
|
||||||
|
AppNavigation(navigationManager = navigationManager)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNewIntent(intent: android.content.Intent?) {
|
||||||
|
super.onNewIntent(intent)
|
||||||
|
intent?.getStringExtra("chatId")?.let { chatId ->
|
||||||
|
navigationManager.navigateToChat(chatId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package com.knot.messenger
|
||||||
|
|
||||||
|
import android.app.Application
|
||||||
|
import coil.ImageLoader
|
||||||
|
import coil.ImageLoaderFactory
|
||||||
|
import coil.decode.VideoFrameDecoder
|
||||||
|
import dagger.hilt.android.HiltAndroidApp
|
||||||
|
|
||||||
|
@HiltAndroidApp
|
||||||
|
class MainApplication : Application(), ImageLoaderFactory {
|
||||||
|
override fun newImageLoader(): ImageLoader {
|
||||||
|
return ImageLoader.Builder(this)
|
||||||
|
.components {
|
||||||
|
add(VideoFrameDecoder.Factory())
|
||||||
|
}
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.1 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.9 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,63 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">ForkMessenger</string>
|
||||||
|
<string name="login">Login</string>
|
||||||
|
<string name="register">Register</string>
|
||||||
|
<string name="username">Username</string>
|
||||||
|
<string name="password">Password</string>
|
||||||
|
<string name="display_name">Display Name</string>
|
||||||
|
<string name="settings">Settings</string>
|
||||||
|
<string name="save">Save</string>
|
||||||
|
<string name="back">Back</string>
|
||||||
|
<string name="server_connection">Server Connection</string>
|
||||||
|
<string name="api_base_url">API Base URL</string>
|
||||||
|
<string name="server_features">Server Features</string>
|
||||||
|
<string name="stories">Stories</string>
|
||||||
|
<string name="polls">Polls</string>
|
||||||
|
<string name="calls">Calls</string>
|
||||||
|
<string name="groups">Groups</string>
|
||||||
|
<string name="enabled">Enabled</string>
|
||||||
|
<string name="disabled">Disabled</string>
|
||||||
|
<string name="limits">Limits</string>
|
||||||
|
<string name="max_file_size">Max File Size</string>
|
||||||
|
<string name="max_group_members">Max Group Members</string>
|
||||||
|
<string name="message">Message</string>
|
||||||
|
<string name="call">Call</string>
|
||||||
|
<string name="block">Block</string>
|
||||||
|
<string name="profile">Profile</string>
|
||||||
|
<string name="confirm_password">Confirm Password</string>
|
||||||
|
<string name="passwords_not_match">Passwords do not match</string>
|
||||||
|
<string name="no_account_register">Don\'t have an account? Register</string>
|
||||||
|
<string name="already_have_account">Already have an account? Login</string>
|
||||||
|
<string name="error_occurred">An error occurred</string>
|
||||||
|
<string name="loading">Loading...</string>
|
||||||
|
<string name="chats_title">Chats</string>
|
||||||
|
<string name="contacts_title">Contacts</string>
|
||||||
|
<string name="stories_title">Stories</string>
|
||||||
|
<string name="create_story">Create Story</string>
|
||||||
|
<string name="send_message_hint">Type a message...</string>
|
||||||
|
<string name="reply_to_user">Reply to %1$s...</string>
|
||||||
|
<string name="story_editor">STORY EDITOR</string>
|
||||||
|
<string name="publish">PUBLISH</string>
|
||||||
|
<string name="start_creation">START CREATION</string>
|
||||||
|
<string name="text_tool">TEXT</string>
|
||||||
|
<string name="crop_tool">CROP</string>
|
||||||
|
<string name="stickers_tool">STICKERS</string>
|
||||||
|
<string name="brush_tool">BRUSH</string>
|
||||||
|
<string name="filters_tool">FILTERS</string>
|
||||||
|
<string name="remove">Remove</string>
|
||||||
|
<string name="no_chats_found">No chats found</string>
|
||||||
|
<string name="typing">typing...</string>
|
||||||
|
<string name="video_call">Video Call</string>
|
||||||
|
<string name="emoji">Emoji</string>
|
||||||
|
<string name="attach">Attach</string>
|
||||||
|
<string name="message_placeholder">Message...</string>
|
||||||
|
<string name="voice_message">Voice Message</string>
|
||||||
|
<string name="send">Send</string>
|
||||||
|
<string name="reply_photo">Photo</string>
|
||||||
|
<string name="reply_video">Video</string>
|
||||||
|
<string name="reply_audio">Audio</string>
|
||||||
|
<string name="reply_file">File</string>
|
||||||
|
<string name="reply_gif">GIF</string>
|
||||||
|
<string name="reply_prefix">Reply to </string>
|
||||||
|
<string name="reply_self">yourself</string>
|
||||||
|
</resources>
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<resources>
|
||||||
|
<string name="app_name">ForkMessenger</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>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<?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>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package auth.data.remote.api
|
||||||
|
|
||||||
|
import auth.data.remote.dto.AuthRequest
|
||||||
|
import auth.data.remote.dto.AuthResponse
|
||||||
|
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")
|
||||||
|
@Headers("Cache-Control: no-cache")
|
||||||
|
suspend fun login(@Body request: AuthRequest): AuthResponse
|
||||||
|
|
||||||
|
@POST("auth/register")
|
||||||
|
@Headers("Cache-Control: no-cache")
|
||||||
|
suspend fun register(@Body request: AuthRequest): 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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("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?
|
||||||
|
)
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
package auth.data.repository
|
||||||
|
|
||||||
|
import auth.data.remote.api.AuthApi
|
||||||
|
import auth.data.remote.dto.AuthRequest
|
||||||
|
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)
|
||||||
|
_isAuthenticated.value = true
|
||||||
|
fetchConfig()
|
||||||
|
Result.success(
|
||||||
|
AuthResult(
|
||||||
|
token = token,
|
||||||
|
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)
|
||||||
|
_isAuthenticated.value = true
|
||||||
|
fetchConfig()
|
||||||
|
Result.success(
|
||||||
|
AuthResult(
|
||||||
|
token = token,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package auth.domain.model
|
||||||
|
|
||||||
|
data class AuthResult(
|
||||||
|
val token: String,
|
||||||
|
val userId: String,
|
||||||
|
val userName: String,
|
||||||
|
val displayName: String,
|
||||||
|
val avatarUrl: String?
|
||||||
|
)
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePushToken() {
|
||||||
|
FirebaseMessaging.getInstance().token.addOnCompleteListener { task ->
|
||||||
|
if (task.isSuccessful) {
|
||||||
|
val token = task.result
|
||||||
|
viewModelScope.launch {
|
||||||
|
repository.updatePushToken(token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
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)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// 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
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,280 @@
|
|||||||
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
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 messageDao: core.database.data.MessageDao,
|
||||||
|
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>> {
|
||||||
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||||
|
return messageDao.getMessages(chatId).map { entities: List<core.database.data.MessageEntity> ->
|
||||||
|
entities.map { it.toDomain(baseUrl, gson) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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. Caller stack: ${android.util.Log.getStackTraceString(Throwable())}")
|
||||||
|
hubClient.readMessages(ReadMessagesRequest(chatId, lastMessageId, lastReadSequenceId))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
android.util.Log.e("ChatRepo", "Error marking messages as read", e)
|
||||||
|
}
|
||||||
|
// Обновляем локальную БД
|
||||||
|
messageDao.markMessagesAsRead(chatId, lastReadSequenceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun saveMessage(message: Message) {
|
||||||
|
android.util.Log.d("ChatRepo", "DB cache disabled, skipping save: ${message.id}")
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun deleteLocalMessage(messageId: String) {
|
||||||
|
messageDao.deleteMessage(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
messageDao.deleteMessage(messageId)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
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 Message.toEntity(gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
||||||
|
return core.database.data.MessageEntity(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = senderId,
|
||||||
|
senderName = senderName,
|
||||||
|
senderAvatar = senderAvatar,
|
||||||
|
content = content,
|
||||||
|
sequenceId = sequenceId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
mediaType = mediaType.name.lowercase(),
|
||||||
|
mediaJson = gson.toJson(media),
|
||||||
|
reactionsJson = gson.toJson(reactions),
|
||||||
|
isRead = isRead,
|
||||||
|
replyToId = replyTo?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 MessageDto.toEntity(baseUrl: String, currentUserId: String, gson: com.google.gson.Gson): core.database.data.MessageEntity {
|
||||||
|
return core.database.data.MessageEntity(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId ?: "",
|
||||||
|
senderId = senderId ?: "",
|
||||||
|
senderName = sender?.displayName ?: "Unknown",
|
||||||
|
senderAvatar = sender?.avatarUrl?.ensureAbsoluteUrl(baseUrl),
|
||||||
|
content = content,
|
||||||
|
sequenceId = sequenceId ?: 0,
|
||||||
|
createdAt = createdAt ?: "",
|
||||||
|
mediaType = type ?: "text",
|
||||||
|
mediaJson = gson.toJson(media),
|
||||||
|
reactionsJson = gson.toJson(reactions),
|
||||||
|
isRead = senderId == currentUserId,
|
||||||
|
replyToId = replyTo?.id
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun core.database.data.MessageEntity.toDomain(baseUrl: String, gson: com.google.gson.Gson): Message {
|
||||||
|
val mediaTypeEnum = when (mediaType) {
|
||||||
|
"gif" -> MediaType.GIF
|
||||||
|
"image", "photo" -> MediaType.IMAGE
|
||||||
|
"video" -> MediaType.VIDEO
|
||||||
|
"audio", "voice" -> MediaType.AUDIO
|
||||||
|
"file" -> MediaType.FILE
|
||||||
|
else -> MediaType.TEXT
|
||||||
|
}
|
||||||
|
|
||||||
|
val mediaTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.MediaItemDto>>() {}.type
|
||||||
|
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = try {
|
||||||
|
gson.fromJson(mediaJson, mediaTypeToken)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
emptyList()
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
val reactionsTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.ReactionDto>>() {}.type
|
||||||
|
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = try {
|
||||||
|
gson.fromJson(reactionsJson, reactionsTypeToken)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
try {
|
||||||
|
val mapType = object : com.google.gson.reflect.TypeToken<Map<String, Int>>() {}.type
|
||||||
|
val map: Map<String, Int> = gson.fromJson(reactionsJson, mapType) ?: emptyMap()
|
||||||
|
map.map { chats.data.remote.dto.ReactionDto(it.key, it.value, false) }
|
||||||
|
} catch (innerE: Exception) {
|
||||||
|
emptyList()
|
||||||
|
}
|
||||||
|
} ?: emptyList()
|
||||||
|
|
||||||
|
return Message(
|
||||||
|
id = id,
|
||||||
|
chatId = chatId,
|
||||||
|
senderId = senderId,
|
||||||
|
senderName = senderName,
|
||||||
|
senderAvatar = senderAvatar,
|
||||||
|
content = content,
|
||||||
|
sequenceId = sequenceId,
|
||||||
|
createdAt = createdAt,
|
||||||
|
media = mediaDtos.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 = mediaTypeEnum,
|
||||||
|
reactions = reactionDtos.associate { it.emoji to it.count },
|
||||||
|
isRead = isRead
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
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.database.data.MessageDao
|
||||||
|
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,
|
||||||
|
messageDao: MessageDao,
|
||||||
|
hubClient: chats.data.remote.signalr.ChatHubClient
|
||||||
|
): ChatRepository {
|
||||||
|
return ChatRepositoryImpl(api, tokenManager, serverConfig, messageDao, hubClient)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideChatHubClient(): ChatHubClient {
|
||||||
|
return ChatHubClient()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package chats.domain.model
|
||||||
|
|
||||||
|
data class Chat(
|
||||||
|
val id: String,
|
||||||
|
val type: String,
|
||||||
|
val name: String,
|
||||||
|
val avatar: String?,
|
||||||
|
val unreadCount: Int,
|
||||||
|
val lastMessage: Message?
|
||||||
|
)
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package chats.domain.model
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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
@@ -0,0 +1,803 @@
|
|||||||
|
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) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,156 @@
|
|||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
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)
|
||||||
|
) {
|
||||||
|
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) { 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)
|
||||||
|
) {
|
||||||
|
items(displayGifs) { 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,843 @@
|
|||||||
|
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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package chats.presentation.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.core.Animatable
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.gestures.Orientation
|
||||||
|
import androidx.compose.foundation.gestures.draggable
|
||||||
|
import androidx.compose.foundation.gestures.rememberDraggableState
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Reply
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun SwipeableMessageItem(
|
||||||
|
onReply: () -> Unit,
|
||||||
|
content: @Composable () -> Unit
|
||||||
|
) {
|
||||||
|
val density = LocalDensity.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val offsetX = remember { Animatable(0f) }
|
||||||
|
val replyThreshold = with(density) { 60.dp.toPx() }
|
||||||
|
val maxDrag = with(density) { 90.dp.toPx() }
|
||||||
|
|
||||||
|
var isTriggered by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.draggable(
|
||||||
|
orientation = Orientation.Horizontal,
|
||||||
|
state = rememberDraggableState { delta ->
|
||||||
|
scope.launch {
|
||||||
|
// We only allow swiping to the left (negative delta)
|
||||||
|
val newOffset = (offsetX.value + delta).coerceIn(-maxDrag, 0f)
|
||||||
|
offsetX.snapTo(newOffset)
|
||||||
|
|
||||||
|
if (newOffset <= -replyThreshold && !isTriggered) {
|
||||||
|
isTriggered = true
|
||||||
|
// Trigger haptic feedback if available?
|
||||||
|
} else if (newOffset > -replyThreshold) {
|
||||||
|
isTriggered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onDragStopped = {
|
||||||
|
if (offsetX.value <= -replyThreshold) {
|
||||||
|
onReply()
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
offsetX.animateTo(0f)
|
||||||
|
isTriggered = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
// Reply Icon (Underneath)
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.CenterEnd)
|
||||||
|
.padding(end = 16.dp)
|
||||||
|
.size(36.dp)
|
||||||
|
.alpha(((-offsetX.value) / replyThreshold).coerceIn(0f, 1f))
|
||||||
|
.clip(CircleShape)
|
||||||
|
.background(if (isTriggered) MaterialTheme.colorScheme.primary else Color.Gray.copy(alpha = 0.2f)),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Reply,
|
||||||
|
contentDescription = "Reply",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(20.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message Content
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.offset { IntOffset(offsetX.value.roundToInt(), 0) }
|
||||||
|
.fillMaxWidth()
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package contacts.data.remote.api
|
||||||
|
|
||||||
|
import retrofit2.http.*
|
||||||
|
|
||||||
|
interface ContactApi {
|
||||||
|
@GET("contacts")
|
||||||
|
suspend fun getContacts(): List<ContactDto>
|
||||||
|
|
||||||
|
@POST("contacts/request")
|
||||||
|
suspend fun addContact(@Body request: AddContactRequest): Any
|
||||||
|
|
||||||
|
@DELETE("contacts/{userId}")
|
||||||
|
suspend fun removeContact(@Path("userId") userId: String)
|
||||||
|
|
||||||
|
@GET("profiles/search")
|
||||||
|
suspend fun searchUsers(@Query("q") query: String): List<ContactDto>
|
||||||
|
|
||||||
|
@GET("contacts/requests")
|
||||||
|
suspend fun getFriendRequests(): List<FriendRequestDto>
|
||||||
|
|
||||||
|
@POST("contacts/{friendshipId}/accept")
|
||||||
|
suspend fun acceptRequest(@Path("friendshipId") friendshipId: String): Any
|
||||||
|
|
||||||
|
@POST("contacts/{friendshipId}/decline")
|
||||||
|
suspend fun declineRequest(@Path("friendshipId") friendshipId: String): Any
|
||||||
|
}
|
||||||
|
|
||||||
|
data class FriendRequestDto(
|
||||||
|
val id: String,
|
||||||
|
val user: ContactDto,
|
||||||
|
val createdAt: String,
|
||||||
|
val isOutgoing: Boolean
|
||||||
|
)
|
||||||
|
|
||||||
|
data class ContactDto(
|
||||||
|
@com.google.gson.annotations.SerializedName("id")
|
||||||
|
val id: String,
|
||||||
|
@com.google.gson.annotations.SerializedName("userName")
|
||||||
|
val userName: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("username")
|
||||||
|
val username: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("displayName")
|
||||||
|
val displayName: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("avatarUrl")
|
||||||
|
val avatarUrl: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("avatar")
|
||||||
|
val avatar: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("isOnline")
|
||||||
|
val isOnline: Boolean,
|
||||||
|
@com.google.gson.annotations.SerializedName("lastSeen")
|
||||||
|
val lastSeen: String?,
|
||||||
|
@com.google.gson.annotations.SerializedName("friendshipId")
|
||||||
|
val friendshipId: String? = null
|
||||||
|
) {
|
||||||
|
val effectiveUsername: String get() = username ?: userName ?: "unknown"
|
||||||
|
val effectiveAvatarUrl: String? get() = avatarUrl ?: avatar
|
||||||
|
}
|
||||||
|
|
||||||
|
data class AddContactRequest(
|
||||||
|
@com.google.gson.annotations.SerializedName("contactId")
|
||||||
|
val contactId: String
|
||||||
|
)
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package contacts.data.repository
|
||||||
|
|
||||||
|
import contacts.data.remote.api.ContactApi
|
||||||
|
import contacts.data.remote.api.ContactDto
|
||||||
|
import contacts.domain.repository.ContactRepository
|
||||||
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
class ContactRepositoryImpl @Inject constructor(
|
||||||
|
private val api: ContactApi
|
||||||
|
) : ContactRepository {
|
||||||
|
|
||||||
|
override suspend fun getContacts(): Result<List<ContactDto>> {
|
||||||
|
return try {
|
||||||
|
Result.success(api.getContacts())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun addContact(userId: String): Result<ContactDto> {
|
||||||
|
return try {
|
||||||
|
val response = api.addContact(contacts.data.remote.api.AddContactRequest(userId))
|
||||||
|
// Бэкенд возвращает статус или пустой ответ для запроса,
|
||||||
|
// так как это "запрос в друзья". Мы возвращаем Result.success с пустым DTO или
|
||||||
|
// по-хорошему надо обновить доменную модель, но для начала вернем заглушку.
|
||||||
|
Result.success(ContactDto(id = userId, userName = null, username = null, displayName = null, avatarUrl = null, avatar = null, isOnline = false, lastSeen = null))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun removeContact(userId: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
api.removeContact(userId)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun searchUsers(query: String): Result<List<ContactDto>> {
|
||||||
|
return try {
|
||||||
|
Result.success(api.searchUsers(query))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>> {
|
||||||
|
return try {
|
||||||
|
Result.success(api.getFriendRequests())
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun acceptRequest(friendshipId: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
api.acceptRequest(friendshipId)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun declineRequest(friendshipId: String): Result<Unit> {
|
||||||
|
return try {
|
||||||
|
api.declineRequest(friendshipId)
|
||||||
|
Result.success(Unit)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package contacts.di
|
||||||
|
|
||||||
|
import contacts.data.remote.api.ContactApi
|
||||||
|
import contacts.data.repository.ContactRepositoryImpl
|
||||||
|
import contacts.domain.repository.ContactRepository
|
||||||
|
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 ContactModule {
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideContactApi(retrofit: Retrofit): ContactApi {
|
||||||
|
return retrofit.create(ContactApi::class.java)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
@Singleton
|
||||||
|
fun provideContactRepository(
|
||||||
|
api: ContactApi
|
||||||
|
): ContactRepository {
|
||||||
|
return ContactRepositoryImpl(api)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package contacts.domain.repository
|
||||||
|
|
||||||
|
import contacts.data.remote.api.ContactDto
|
||||||
|
|
||||||
|
interface ContactRepository {
|
||||||
|
suspend fun getContacts(): Result<List<ContactDto>>
|
||||||
|
suspend fun addContact(userId: String): Result<ContactDto>
|
||||||
|
suspend fun removeContact(userId: String): Result<Unit>
|
||||||
|
suspend fun searchUsers(query: String): Result<List<ContactDto>>
|
||||||
|
suspend fun getFriendRequests(): Result<List<contacts.data.remote.api.FriendRequestDto>>
|
||||||
|
suspend fun acceptRequest(friendshipId: String): Result<Unit>
|
||||||
|
suspend fun declineRequest(friendshipId: String): Result<Unit>
|
||||||
|
}
|
||||||
@@ -0,0 +1,253 @@
|
|||||||
|
package contacts.presentation
|
||||||
|
|
||||||
|
import androidx.compose.foundation.clickable
|
||||||
|
import androidx.compose.foundation.layout.*
|
||||||
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.Search
|
||||||
|
import androidx.compose.material.icons.filled.PersonAdd
|
||||||
|
import androidx.compose.material.icons.filled.Chat
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material.icons.filled.Cancel
|
||||||
|
import androidx.compose.material3.*
|
||||||
|
import androidx.compose.runtime.*
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import contacts.data.remote.api.ContactDto
|
||||||
|
import core.presentation.components.AppAvatar
|
||||||
|
import core.presentation.theme.SoftSquareShape
|
||||||
|
import ru.knot.messager.R
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun ContactListScreen(
|
||||||
|
contacts: List<contacts.data.remote.api.ContactDto>,
|
||||||
|
requests: List<contacts.data.remote.api.FriendRequestDto> = emptyList(),
|
||||||
|
isLoading: Boolean = false,
|
||||||
|
onContactClick: (String) -> Unit,
|
||||||
|
onSearchChange: (String) -> Unit,
|
||||||
|
onAddContact: (String) -> Unit,
|
||||||
|
onStartChat: (String) -> Unit,
|
||||||
|
onAcceptRequest: (String) -> Unit,
|
||||||
|
onDeclineRequest: (String) -> Unit
|
||||||
|
) {
|
||||||
|
var searchQuery by remember { mutableStateOf("") }
|
||||||
|
var selectedTab by remember { mutableIntStateOf(0) }
|
||||||
|
val tabs = listOf(
|
||||||
|
stringResource(R.string.all),
|
||||||
|
stringResource(R.string.online_tab)
|
||||||
|
)
|
||||||
|
|
||||||
|
Scaffold(
|
||||||
|
topBar = {
|
||||||
|
Column {
|
||||||
|
TopAppBar(
|
||||||
|
title = { Text(stringResource(R.string.contacts_title), fontWeight = androidx.compose.ui.text.font.FontWeight.Bold) },
|
||||||
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
|
containerColor = Color.Transparent,
|
||||||
|
titleContentColor = Color.White
|
||||||
|
)
|
||||||
|
)
|
||||||
|
TabRow(selectedTabIndex = selectedTab) {
|
||||||
|
tabs.forEachIndexed { index, title ->
|
||||||
|
Tab(
|
||||||
|
selected = selectedTab == index,
|
||||||
|
onClick = { selectedTab = index },
|
||||||
|
text = { Text(title) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
) { paddingValues ->
|
||||||
|
Column(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(paddingValues)
|
||||||
|
) {
|
||||||
|
// Поисковая строка
|
||||||
|
OutlinedTextField(
|
||||||
|
value = searchQuery,
|
||||||
|
onValueChange = {
|
||||||
|
searchQuery = it
|
||||||
|
onSearchChange(it)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
|
placeholder = { Text(stringResource(R.string.search_hint)) },
|
||||||
|
leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) },
|
||||||
|
shape = SoftSquareShape
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
CircularProgressIndicator()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
val filteredContacts = if (searchQuery.isNotEmpty()) {
|
||||||
|
contacts
|
||||||
|
} else {
|
||||||
|
contacts.filter {
|
||||||
|
if (selectedTab == 1) it.isOnline else true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LazyColumn(modifier = Modifier.fillMaxSize()) {
|
||||||
|
// Contact Requests Section
|
||||||
|
if (searchQuery.isEmpty() && requests.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
text = "Заявки в контакты",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(requests) { request ->
|
||||||
|
ContactRequestItem(
|
||||||
|
request = request,
|
||||||
|
onAccept = { onAcceptRequest(request.id) },
|
||||||
|
onDecline = { onDeclineRequest(request.id) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
Divider(modifier = Modifier.padding(vertical = 8.dp), color = MaterialTheme.colorScheme.outlineVariant)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredContacts.isEmpty() && requests.isEmpty()) {
|
||||||
|
item {
|
||||||
|
Box(modifier = Modifier.fillParentMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
|
Text(
|
||||||
|
text = if (searchQuery.isNotEmpty()) "Пользователи не найдены" else "Список контактов пуст",
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
items(filteredContacts) { contact ->
|
||||||
|
ContactItem(
|
||||||
|
contact = contact,
|
||||||
|
onClick = { onContactClick(contact.id) },
|
||||||
|
onAddClick = { onAddContact(contact.id) },
|
||||||
|
onChatClick = { onStartChat(contact.id) },
|
||||||
|
isSearchMode = searchQuery.isNotEmpty()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactRequestItem(
|
||||||
|
request: contacts.data.remote.api.FriendRequestDto,
|
||||||
|
onAccept: () -> Unit,
|
||||||
|
onDecline: () -> Unit
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(16.dp, 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
AppAvatar(
|
||||||
|
url = request.user.effectiveAvatarUrl,
|
||||||
|
name = request.user.effectiveUsername,
|
||||||
|
size = 48.dp
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = request.user.displayName ?: request.user.effectiveUsername,
|
||||||
|
style = MaterialTheme.typography.titleSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = if (request.isOutgoing) "Исходящий запрос" else "Входящий запрос",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row {
|
||||||
|
if (!request.isOutgoing) {
|
||||||
|
IconButton(onClick = onAccept) {
|
||||||
|
Icon(Icons.Default.CheckCircle, contentDescription = "Accept", tint = Color(0xFF4CAF50))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onDecline) {
|
||||||
|
Icon(Icons.Default.Cancel, contentDescription = "Decline", tint = MaterialTheme.colorScheme.error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ContactItem(
|
||||||
|
contact: contacts.data.remote.api.ContactDto,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
onAddClick: () -> Unit,
|
||||||
|
onChatClick: () -> Unit,
|
||||||
|
isSearchMode: Boolean
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.padding(16.dp, 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
AppAvatar(
|
||||||
|
url = contact.effectiveAvatarUrl,
|
||||||
|
name = contact.effectiveUsername,
|
||||||
|
size = 56.dp
|
||||||
|
)
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.width(16.dp))
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = contact.displayName ?: contact.effectiveUsername,
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface
|
||||||
|
)
|
||||||
|
val statusText = if (contact.isOnline) {
|
||||||
|
stringResource(R.string.online)
|
||||||
|
} else {
|
||||||
|
stringResource(R.string.last_seen, contact.lastSeen ?: stringResource(R.string.last_seen_recently))
|
||||||
|
}
|
||||||
|
Text(
|
||||||
|
text = statusText,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = if (contact.isOnline) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Row {
|
||||||
|
if (isSearchMode) {
|
||||||
|
IconButton(onClick = onAddClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.PersonAdd,
|
||||||
|
contentDescription = "Add Contact",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
IconButton(onClick = onChatClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Chat,
|
||||||
|
contentDescription = "Start Chat",
|
||||||
|
tint = MaterialTheme.colorScheme.primary
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package contacts.presentation
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import androidx.lifecycle.viewModelScope
|
||||||
|
import chats.domain.repository.ChatRepository
|
||||||
|
import contacts.domain.repository.ContactRepository
|
||||||
|
import core.utils.NavigationManager
|
||||||
|
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 javax.inject.Inject
|
||||||
|
|
||||||
|
data class ContactListState(
|
||||||
|
val contacts: List<contacts.data.remote.api.ContactDto> = emptyList(),
|
||||||
|
val requests: List<contacts.data.remote.api.FriendRequestDto> = emptyList(),
|
||||||
|
val isLoading: Boolean = false,
|
||||||
|
val error: String? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
@HiltViewModel
|
||||||
|
class ContactListViewModel @Inject constructor(
|
||||||
|
private val contactRepository: ContactRepository,
|
||||||
|
private val chatRepository: ChatRepository,
|
||||||
|
private val navigationManager: NavigationManager
|
||||||
|
) : ViewModel() {
|
||||||
|
private val _state = MutableStateFlow(ContactListState())
|
||||||
|
val state: StateFlow<ContactListState> = _state.asStateFlow()
|
||||||
|
|
||||||
|
init {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadData() {
|
||||||
|
loadContacts()
|
||||||
|
loadRequests()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadContacts() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
contactRepository.getContacts()
|
||||||
|
.onSuccess { contacts ->
|
||||||
|
_state.update { it.copy(contacts = contacts, isLoading = false) }
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun loadRequests() {
|
||||||
|
viewModelScope.launch {
|
||||||
|
contactRepository.getFriendRequests()
|
||||||
|
.onSuccess { requests ->
|
||||||
|
_state.update { it.copy(requests = requests) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onSearchChange(query: String) {
|
||||||
|
if (query.length < 3) {
|
||||||
|
if (query.isEmpty()) loadData()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch {
|
||||||
|
_state.update { it.copy(isLoading = true, error = null) }
|
||||||
|
contactRepository.searchUsers(query)
|
||||||
|
.onSuccess { users ->
|
||||||
|
_state.update { it.copy(contacts = users, isLoading = false) }
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.update { it.copy(isLoading = false, error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun addContact(userId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
contactRepository.addContact(userId)
|
||||||
|
.onSuccess {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
.onFailure { e ->
|
||||||
|
_state.update { it.copy(error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun acceptRequest(requestId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
contactRepository.acceptRequest(requestId)
|
||||||
|
.onSuccess { loadData() }
|
||||||
|
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun declineRequest(requestId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
contactRepository.declineRequest(requestId)
|
||||||
|
.onSuccess { loadData() }
|
||||||
|
.onFailure { e -> _state.update { it.copy(error = e.message) } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun startChat(userId: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
try {
|
||||||
|
// В вебе мы ищем существующий чат или создаем новый.
|
||||||
|
// В мобилке мы для начала можем просто вызвать createPersonalChat.
|
||||||
|
// Бэкенд обычно возвращает существующий чат, если он уже есть.
|
||||||
|
val chat = chatRepository.createPersonalChat(userId)
|
||||||
|
navigationManager.navigateToChat(chat.id)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_state.update { it.copy(error = e.message) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user