diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index 42f6e75..59a5b84 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock index 8f60357..0c88c82 100644 Binary files a/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock and b/client-mobile/.gradle/8.5/executionHistory/executionHistory.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin index d2927bf..7d2502e 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.bin differ diff --git a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock index 4963c9b..22eec27 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock and b/client-mobile/.gradle/8.5/fileHashes/fileHashes.lock differ diff --git a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin index 7b720d6..1b85df7 100644 Binary files a/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin and b/client-mobile/.gradle/8.5/fileHashes/resourceHashesCache.bin differ diff --git a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 8060032..fc030a7 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/client-mobile/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin index 525a487..3b0f8eb 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/auth/data/repository/AuthRepositoryImpl.kt b/client-mobile/auth/data/repository/AuthRepositoryImpl.kt index 5594560..3ab91d2 100644 --- a/client-mobile/auth/data/repository/AuthRepositoryImpl.kt +++ b/client-mobile/auth/data/repository/AuthRepositoryImpl.kt @@ -75,4 +75,12 @@ class AuthRepositoryImpl @Inject constructor( Result.failure(e) } } + + override suspend fun updatePushToken(token: String) { + try { + api.updatePushToken(token) + } catch (e: Exception) { + // Silent fail + } + } } diff --git a/client-mobile/auth/domain/repository/AuthRepository.kt b/client-mobile/auth/domain/repository/AuthRepository.kt index 7b5b9b8..381793f 100644 --- a/client-mobile/auth/domain/repository/AuthRepository.kt +++ b/client-mobile/auth/domain/repository/AuthRepository.kt @@ -8,4 +8,5 @@ interface AuthRepository { suspend fun logout() suspend fun fetchConfig(): Result fun isAuthenticated(): Boolean + suspend fun updatePushToken(token: String) } diff --git a/client-mobile/auth/presentation/AuthViewModel.kt b/client-mobile/auth/presentation/AuthViewModel.kt index 87949ee..d5c0367 100644 --- a/client-mobile/auth/presentation/AuthViewModel.kt +++ b/client-mobile/auth/presentation/AuthViewModel.kt @@ -9,6 +9,7 @@ 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( @@ -35,6 +36,7 @@ class AuthViewModel @Inject constructor( 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) } @@ -48,10 +50,22 @@ class AuthViewModel @Inject constructor( 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) + } + } + } + } } diff --git a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt index e2fe934..8362257 100644 --- a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt +++ b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt @@ -7,24 +7,36 @@ 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.MutableSharedFlow -import kotlinx.coroutines.flow.SharedFlow -import kotlinx.coroutines.flow.asSharedFlow +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 + +enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED } + @Singleton class ChatHubClient @Inject constructor() { private var hubConnection: HubConnection? = null private val _events = MutableSharedFlow(extraBufferCapacity = 64) val events: SharedFlow = _events.asSharedFlow() + + private val _status = MutableStateFlow(ConnectionStatus.DISCONNECTED) + val status: StateFlow = _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}/chatHub") .withAccessTokenProvider(Single.just(accessToken)) @@ -33,16 +45,22 @@ class ChatHubClient @Inject constructor() { setupHandlers() hubConnection?.onClosed { exception -> - Log.e("ChatHubClient", "Connection closed", exception) - // Optional: Reconnect logic + 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 } } } @@ -94,5 +112,6 @@ class ChatHubClient @Inject constructor() { fun disconnect() { hubConnection?.stop() + _status.value = ConnectionStatus.DISCONNECTED } } diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index 8962e36..3b1d817 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -32,8 +32,17 @@ class ChatRepositoryImpl @Inject constructor( return api.getMessages(chatId).map { it.toDomain(baseUrl) } } - override suspend fun sendMessage(chatId: String, content: String): Message { - val request = SendMessageRequest(content = content, type = "text") + override suspend fun sendMessage( + chatId: String, + content: String?, + type: String, + attachments: List? + ): Message { + val request = SendMessageRequest( + content = content, + type = type, + attachments = attachments + ) val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") 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 { val domainMediaType = when (type) { + "gif" -> MediaType.GIF "image", "photo" -> MediaType.IMAGE "video" -> MediaType.VIDEO "audio", "voice" -> MediaType.AUDIO diff --git a/client-mobile/chats/domain/model/Message.kt b/client-mobile/chats/domain/model/Message.kt index 12ce75a..e77e1e0 100644 --- a/client-mobile/chats/domain/model/Message.kt +++ b/client-mobile/chats/domain/model/Message.kt @@ -26,5 +26,5 @@ data class Media( ) enum class MediaType { - TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY + TEXT, IMAGE, VIDEO, AUDIO, FILE, STORY_REPLY, GIF } diff --git a/client-mobile/chats/domain/repository/ChatRepository.kt b/client-mobile/chats/domain/repository/ChatRepository.kt index 312b6f2..28ab034 100644 --- a/client-mobile/chats/domain/repository/ChatRepository.kt +++ b/client-mobile/chats/domain/repository/ChatRepository.kt @@ -6,7 +6,12 @@ import chats.domain.model.Message interface ChatRepository { suspend fun getChats(): List suspend fun getMessages(chatId: String): List - suspend fun sendMessage(chatId: String, content: String): Message + suspend fun sendMessage( + chatId: String, + content: String?, + type: String = "text", + attachments: List? = null + ): Message suspend fun addReaction(messageId: String, emoji: String) suspend fun sendTypingStatus(chatId: String) suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index f8abd35..509d003 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -65,6 +65,14 @@ class ChatDetailViewModel @Inject constructor( fun setChatId(chatId: String) { 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) loadMessages(chatId) observeSignalREvents() @@ -155,9 +163,17 @@ class ChatDetailViewModel @Inject constructor( fun sendMessage(text: String) { val chatId = currentChatId ?: return + if (text.isBlank()) return + viewModelScope.launch { 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) { _state.update { it.copy(error = e.localizedMessage) } } @@ -239,8 +255,21 @@ class ChatDetailViewModel @Inject constructor( val chatId = currentChatId ?: return viewModelScope.launch { 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 val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs val selectedGif = allGifs.find { gif -> diff --git a/client-mobile/chats/presentation/components/MessageBubble.kt b/client-mobile/chats/presentation/components/MessageBubble.kt index a2a0ea5..7ec9126 100644 --- a/client-mobile/chats/presentation/components/MessageBubble.kt +++ b/client-mobile/chats/presentation/components/MessageBubble.kt @@ -142,13 +142,29 @@ fun MessageBubble( } } - // Media Content - if (message.media.isNotEmpty()) { + // 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)) + } + + // 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 || mediaItem.type.startsWith("image") -> { + mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> { AsyncImage( model = mediaItem.url, imageLoader = core.utils.CoilUtils.getGifImageLoader(context), @@ -218,7 +234,7 @@ fun MessageBubble( } // Text Content - if (!message.content.isNullOrBlank() && !isVoiceMessage) { + if (!message.content.isNullOrBlank() && !isVoiceMessage && message.mediaType != MediaType.GIF) { Text( text = message.content, style = MaterialTheme.typography.bodyMedium,