Отправка гиф

This commit is contained in:
Халимов Рустам
2026-04-14 13:55:01 +03:00
parent 8ce4bc714f
commit 8165b74e43
16 changed files with 117 additions and 15 deletions
Binary file not shown.
Binary file not shown.
@@ -75,4 +75,12 @@ class AuthRepositoryImpl @Inject constructor(
Result.failure(e) Result.failure(e)
} }
} }
override suspend fun updatePushToken(token: String) {
try {
api.updatePushToken(token)
} catch (e: Exception) {
// Silent fail
}
}
} }
@@ -8,4 +8,5 @@ interface AuthRepository {
suspend fun logout() suspend fun logout()
suspend fun fetchConfig(): Result<Unit> suspend fun fetchConfig(): Result<Unit>
fun isAuthenticated(): Boolean fun isAuthenticated(): Boolean
suspend fun updatePushToken(token: String)
} }
@@ -9,6 +9,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import com.google.firebase.messaging.FirebaseMessaging
import javax.inject.Inject import javax.inject.Inject
data class AuthState( data class AuthState(
@@ -35,6 +36,7 @@ class AuthViewModel @Inject constructor(
repository.login(userName, password) repository.login(userName, password)
.onSuccess { .onSuccess {
_state.update { it.copy(isLoading = false, isAuthenticated = true) } _state.update { it.copy(isLoading = false, isAuthenticated = true) }
updatePushToken()
} }
.onFailure { e -> .onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) } _state.update { it.copy(isLoading = false, error = e.message) }
@@ -48,10 +50,22 @@ class AuthViewModel @Inject constructor(
repository.register(userName, password) repository.register(userName, password)
.onSuccess { .onSuccess {
_state.update { it.copy(isLoading = false, isAuthenticated = true) } _state.update { it.copy(isLoading = false, isAuthenticated = true) }
updatePushToken()
} }
.onFailure { e -> .onFailure { e ->
_state.update { it.copy(isLoading = false, error = e.message) } _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)
}
}
}
}
} }
@@ -7,25 +7,37 @@ import com.microsoft.signalr.HubConnectionBuilder
import com.microsoft.signalr.HubConnectionState import com.microsoft.signalr.HubConnectionState
import chats.data.remote.dto.ChatDto import chats.data.remote.dto.ChatDto
import chats.data.remote.dto.MessageDto import chats.data.remote.dto.MessageDto
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.*
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlinx.coroutines.delay
enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED }
@Singleton @Singleton
class ChatHubClient @Inject constructor() { class ChatHubClient @Inject constructor() {
private var hubConnection: HubConnection? = null private var hubConnection: HubConnection? = null
private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 64) private val _events = MutableSharedFlow<ChatEvent>(extraBufferCapacity = 64)
val events: SharedFlow<ChatEvent> = _events.asSharedFlow() 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 val scope = CoroutineScope(Dispatchers.IO)
private var lastBaseUrl: String? = null
private var lastToken: String? = null
fun connect(baseUrl: String, accessToken: String) { fun connect(baseUrl: String, accessToken: String) {
if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return if (hubConnection?.connectionState == HubConnectionState.CONNECTED) return
lastBaseUrl = baseUrl
lastToken = accessToken
_status.value = ConnectionStatus.CONNECTING
hubConnection = HubConnectionBuilder.create("${baseUrl}/chatHub") hubConnection = HubConnectionBuilder.create("${baseUrl}/chatHub")
.withAccessTokenProvider(Single.just(accessToken)) .withAccessTokenProvider(Single.just(accessToken))
.build() .build()
@@ -33,16 +45,22 @@ class ChatHubClient @Inject constructor() {
setupHandlers() setupHandlers()
hubConnection?.onClosed { exception -> hubConnection?.onClosed { exception ->
Log.e("ChatHubClient", "Connection closed", exception) Log.e("ChatHubClient", "Connection closed. Reconnecting...", exception)
// Optional: Reconnect logic _status.value = ConnectionStatus.DISCONNECTED
scope.launch {
delay(5000)
connect(baseUrl, accessToken)
}
} }
scope.launch { scope.launch {
try { try {
hubConnection?.start()?.blockingAwait() hubConnection?.start()?.blockingAwait()
_status.value = ConnectionStatus.CONNECTED
Log.d("ChatHubClient", "SignalR Connected") Log.d("ChatHubClient", "SignalR Connected")
} catch (e: Exception) { } catch (e: Exception) {
Log.e("ChatHubClient", "SignalR Connection Error", e) Log.e("ChatHubClient", "SignalR Connection Error", e)
_status.value = ConnectionStatus.DISCONNECTED
} }
} }
} }
@@ -94,5 +112,6 @@ class ChatHubClient @Inject constructor() {
fun disconnect() { fun disconnect() {
hubConnection?.stop() hubConnection?.stop()
_status.value = ConnectionStatus.DISCONNECTED
} }
} }
@@ -32,8 +32,17 @@ class ChatRepositoryImpl @Inject constructor(
return api.getMessages(chatId).map { it.toDomain(baseUrl) } return api.getMessages(chatId).map { it.toDomain(baseUrl) }
} }
override suspend fun sendMessage(chatId: String, content: String): Message { override suspend fun sendMessage(
val request = SendMessageRequest(content = content, type = "text") chatId: String,
content: String?,
type: String,
attachments: List<chats.data.remote.api.AttachmentRequest>?
): Message {
val request = SendMessageRequest(
content = content,
type = type,
attachments = attachments
)
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
return api.sendMessage(chatId, request).toDomain(baseUrl) return api.sendMessage(chatId, request).toDomain(baseUrl)
} }
@@ -91,6 +100,7 @@ fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat {
fun MessageDto.toDomain(baseUrl: String): Message { fun MessageDto.toDomain(baseUrl: String): Message {
val domainMediaType = when (type) { val domainMediaType = when (type) {
"gif" -> MediaType.GIF
"image", "photo" -> MediaType.IMAGE "image", "photo" -> MediaType.IMAGE
"video" -> MediaType.VIDEO "video" -> MediaType.VIDEO
"audio", "voice" -> MediaType.AUDIO "audio", "voice" -> MediaType.AUDIO
+1 -1
View File
@@ -26,5 +26,5 @@ data class Media(
) )
enum class MediaType { enum class MediaType {
TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY, GIF
} }
@@ -6,7 +6,12 @@ import chats.domain.model.Message
interface ChatRepository { interface ChatRepository {
suspend fun getChats(): List<Chat> suspend fun getChats(): List<Chat>
suspend fun getMessages(chatId: String): List<Message> suspend fun getMessages(chatId: String): List<Message>
suspend fun sendMessage(chatId: String, content: String): Message suspend fun sendMessage(
chatId: String,
content: String?,
type: String = "text",
attachments: List<chats.data.remote.api.AttachmentRequest>? = null
): Message
suspend fun addReaction(messageId: String, emoji: String) suspend fun addReaction(messageId: String, emoji: String)
suspend fun sendTypingStatus(chatId: String) suspend fun sendTypingStatus(chatId: String)
suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) suspend fun markMessagesAsRead(chatId: String, lastMessageId: String)
@@ -65,6 +65,14 @@ class ChatDetailViewModel @Inject constructor(
fun setChatId(chatId: String) { fun setChatId(chatId: String) {
currentChatId = chatId currentChatId = chatId
// Ensure SignalR is connected
val token = tokenManager.getToken()
if (token != null) {
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
signalrClient.connect(baseUrl, token)
}
loadChatInfo(chatId) loadChatInfo(chatId)
loadMessages(chatId) loadMessages(chatId)
observeSignalREvents() observeSignalREvents()
@@ -155,9 +163,17 @@ class ChatDetailViewModel @Inject constructor(
fun sendMessage(text: String) { fun sendMessage(text: String) {
val chatId = currentChatId ?: return val chatId = currentChatId ?: return
if (text.isBlank()) return
viewModelScope.launch { viewModelScope.launch {
try { try {
repository.sendMessage(chatId, text) val sentMessage = repository.sendMessage(chatId, text)
// Optimistic update if not already there
_state.update { s ->
if (s.messages.none { it.id == sentMessage.id }) {
s.copy(messages = s.messages + sentMessage)
} else s
}
} catch (e: Exception) { } catch (e: Exception) {
_state.update { it.copy(error = e.localizedMessage) } _state.update { it.copy(error = e.localizedMessage) }
} }
@@ -239,7 +255,20 @@ class ChatDetailViewModel @Inject constructor(
val chatId = currentChatId ?: return val chatId = currentChatId ?: return
viewModelScope.launch { viewModelScope.launch {
try { try {
repository.sendMessage(chatId, url) 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))
// Optimistic update
_state.update { s ->
if (s.messages.none { it.id == sentMessage.id }) {
s.copy(messages = s.messages + sentMessage)
} else s
}
// Add to recent // Add to recent
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
@@ -142,13 +142,29 @@ fun MessageBubble(
} }
} }
// Media Content // GIF Content (Klipy)
if (message.media.isNotEmpty()) { 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))
}
// Media Content (Attachments)
if (message.media.isNotEmpty() && message.mediaType != MediaType.GIF) {
val mediaCount = message.media.size val mediaCount = message.media.size
if (mediaCount == 1) { if (mediaCount == 1) {
val mediaItem = message.media[0] val mediaItem = message.media[0]
when { when {
mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE || mediaItem.type.startsWith("image") -> { mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> {
AsyncImage( AsyncImage(
model = mediaItem.url, model = mediaItem.url,
imageLoader = core.utils.CoilUtils.getGifImageLoader(context), imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
@@ -218,7 +234,7 @@ fun MessageBubble(
} }
// Text Content // Text Content
if (!message.content.isNullOrBlank() && !isVoiceMessage) { if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) {
Text( Text(
text = message.content, text = message.content,
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,