diff --git a/client-mobile/.gradle/8.5/checksums/checksums.lock b/client-mobile/.gradle/8.5/checksums/checksums.lock index b120717..fc9ae19 100644 Binary files a/client-mobile/.gradle/8.5/checksums/checksums.lock and b/client-mobile/.gradle/8.5/checksums/checksums.lock differ diff --git a/client-mobile/.gradle/8.5/checksums/md5-checksums.bin b/client-mobile/.gradle/8.5/checksums/md5-checksums.bin index c143861..21de15b 100644 Binary files a/client-mobile/.gradle/8.5/checksums/md5-checksums.bin and b/client-mobile/.gradle/8.5/checksums/md5-checksums.bin differ diff --git a/client-mobile/.gradle/8.5/checksums/sha1-checksums.bin b/client-mobile/.gradle/8.5/checksums/sha1-checksums.bin index 541f4ea..202b7a7 100644 Binary files a/client-mobile/.gradle/8.5/checksums/sha1-checksums.bin and b/client-mobile/.gradle/8.5/checksums/sha1-checksums.bin differ diff --git a/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin b/client-mobile/.gradle/8.5/executionHistory/executionHistory.bin index 59a5b84..7e44f2c 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 0c88c82..6825755 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 7d2502e..86b29e5 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 22eec27..be8759d 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 1b85df7..2e0db4b 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 fc030a7..f2b01f9 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 3b0f8eb..e2d938a 100644 Binary files a/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin and b/client-mobile/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/client-mobile/app/build.gradle.kts b/client-mobile/app/build.gradle.kts index 6282594..7a9cea8 100644 --- a/client-mobile/app/build.gradle.kts +++ b/client-mobile/app/build.gradle.kts @@ -117,6 +117,12 @@ dependencies { 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") diff --git a/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt b/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt index 160f4f2..7695c05 100644 --- a/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt +++ b/client-mobile/app/src/main/kotlin/com/knot/messenger/MainActivity.kt @@ -15,6 +15,16 @@ import core.presentation.theme.ForkMessengerTheme class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // 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( diff --git a/client-mobile/auth/presentation/AuthViewModel.kt b/client-mobile/auth/presentation/AuthViewModel.kt index d5c0367..4f1d45e 100644 --- a/client-mobile/auth/presentation/AuthViewModel.kt +++ b/client-mobile/auth/presentation/AuthViewModel.kt @@ -23,6 +23,12 @@ class AuthViewModel @Inject constructor( private val repository: AuthRepository ) : ViewModel() { + init { + if (repository.isAuthenticated()) { + updatePushToken() + } + } + private val _state = MutableStateFlow(AuthState(isAuthenticated = repository.isAuthenticated())) val state: StateFlow = _state.asStateFlow() diff --git a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt index 8362257..a545287 100644 --- a/client-mobile/chats/data/remote/signalr/ChatHubClient.kt +++ b/client-mobile/chats/data/remote/signalr/ChatHubClient.kt @@ -16,6 +16,12 @@ import javax.inject.Singleton import kotlinx.coroutines.delay +data class ReadMessagesRequest( + val chatId: String, + val lastReadMessageId: String, + val lastReadSequenceId: Int +) + enum class ConnectionStatus { CONNECTED, CONNECTING, DISCONNECTED } @Singleton @@ -38,7 +44,7 @@ class ChatHubClient @Inject constructor() { lastToken = accessToken _status.value = ConnectionStatus.CONNECTING - hubConnection = HubConnectionBuilder.create("${baseUrl}/chatHub") + hubConnection = HubConnectionBuilder.create("${baseUrl}/hubs/chat") .withAccessTokenProvider(Single.just(accessToken)) .build() @@ -87,8 +93,13 @@ class ChatHubClient @Inject constructor() { _events.tryEmit(ChatEvent.NewChat(chat)) }, ChatDto::class.java) - conn.on("reaction_updated", { messageId: String, chatId: String, userId: String, emoji: String -> + conn.on("reaction_added", { messageId: String, chatId: String, userId: String, username: String, emoji: String -> _events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, emoji)) + }, String::class.java, String::class.java, String::class.java, String::class.java, String::class.java) + + conn.on("reaction_removed", { messageId: String, chatId: String, userId: String, emoji: String -> + // Using ReactionUpdated with empty emoji to signal removal or just a specific removal event + _events.tryEmit(ChatEvent.ReactionUpdated(messageId, chatId, userId, "")) }, String::class.java, String::class.java, String::class.java, String::class.java) // WebRTC Signaling Handlers @@ -114,4 +125,10 @@ class ChatHubClient @Inject constructor() { hubConnection?.stop() _status.value = ConnectionStatus.DISCONNECTED } + + fun readMessages(request: ReadMessagesRequest) { + if (hubConnection?.connectionState == HubConnectionState.CONNECTED) { + hubConnection?.invoke("read_messages", request) + } + } } diff --git a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt index 3b1d817..ea57dda 100644 --- a/client-mobile/chats/data/repository/ChatRepositoryImpl.kt +++ b/client-mobile/chats/data/repository/ChatRepositoryImpl.kt @@ -4,6 +4,8 @@ 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 @@ -14,12 +16,16 @@ 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 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 { val currentUserId = tokenManager.getUserId() ?: "" @@ -27,9 +33,24 @@ class ChatRepositoryImpl @Inject constructor( return api.getChats().map { it.toDomain(currentUserId, baseUrl) } } + override fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow> { + val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") + return messageDao.getMessages(chatId).map { entities: List -> + entities.map { it.toDomain(baseUrl, gson) } + } + } + override suspend fun getMessages(chatId: String): List { val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - return api.getMessages(chatId).map { it.toDomain(baseUrl) } + return try { + val messages = api.getMessages(chatId) + // Save to DB + messageDao.insertMessages(messages.map { msg: MessageDto -> msg.toEntity(baseUrl, gson) }) + messages.map { it.toDomain(baseUrl) } + } catch (e: Exception) { + // If network fails, caller should ideally use the Flow from DB + emptyList() + } } override suspend fun sendMessage( @@ -55,8 +76,21 @@ class ChatRepositoryImpl @Inject constructor( api.sendTypingStatus(chatId) } - override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String) { - api.markMessagesAsRead(chatId, lastMessageId) + override suspend fun markMessagesAsRead(chatId: String, lastMessageId: String, lastReadSequenceId: Int) { + val request = chats.data.remote.signalr.ReadMessagesRequest( + chatId = chatId, + lastReadMessageId = lastMessageId, + lastReadSequenceId = lastReadSequenceId + ) + hubClient.readMessages(request) + } + + override suspend fun saveMessage(message: Message) { + messageDao.insertMessages(listOf(message.toEntity(gson))) + } + + override suspend fun deleteLocalMessage(messageId: String) { + messageDao.deleteMessage(messageId) } override suspend fun uploadMedia(file: java.io.File): String { @@ -98,6 +132,24 @@ fun ChatDto.toDomain(currentUserId: String, baseUrl: String): Chat { ) } +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(baseUrl: String): Message { val domainMediaType = when (type) { "gif" -> MediaType.GIF @@ -135,10 +187,70 @@ fun MessageDto.toDomain(baseUrl: String): Message { }, mediaType = domainMediaType, reactions = reactions?.associate { it.emoji to it.count } ?: emptyMap(), + isRead = true, // Network messages are usually considered read when fetched or handled by server replyTo = replyTo?.toDomain(baseUrl) ) } +fun MessageDto.toEntity(baseUrl: 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 = true, + 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>() {}.type + val mediaDtos: List = gson.fromJson(mediaJson, mediaTypeToken) ?: emptyList() + + val reactionsTypeToken = object : com.google.gson.reflect.TypeToken>() {}.type + val reactionDtos: List = gson.fromJson(reactionsJson, reactionsTypeToken) ?: 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, + type = it.type, + 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 diff --git a/client-mobile/chats/di/ChatModule.kt b/client-mobile/chats/di/ChatModule.kt index 5f402bc..438cfff 100644 --- a/client-mobile/chats/di/ChatModule.kt +++ b/client-mobile/chats/di/ChatModule.kt @@ -4,6 +4,7 @@ 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 @@ -25,8 +26,14 @@ object ChatModule { @Provides @Singleton - fun provideChatRepository(api: ChatApi, tokenManager: TokenManager, serverConfig: ServerConfig): ChatRepository { - return ChatRepositoryImpl(api, tokenManager, serverConfig) + 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 diff --git a/client-mobile/chats/domain/repository/ChatRepository.kt b/client-mobile/chats/domain/repository/ChatRepository.kt index 28ab034..1cf71af 100644 --- a/client-mobile/chats/domain/repository/ChatRepository.kt +++ b/client-mobile/chats/domain/repository/ChatRepository.kt @@ -5,6 +5,7 @@ import chats.domain.model.Message interface ChatRepository { suspend fun getChats(): List + fun getMessagesFlow(chatId: String): kotlinx.coroutines.flow.Flow> suspend fun getMessages(chatId: String): List suspend fun sendMessage( chatId: String, @@ -14,7 +15,9 @@ interface ChatRepository { ): Message suspend fun addReaction(messageId: String, emoji: String) suspend fun sendTypingStatus(chatId: String) - suspend fun markMessagesAsRead(chatId: String, lastMessageId: 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 suspend fun searchGifs(query: String, page: Int = 0): List diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt index 3418594..492d9c2 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailScreen.kt @@ -16,7 +16,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.stringResource +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.unit.dp import chats.presentation.components.MediaPicker import chats.presentation.components.MessageBubble @@ -28,7 +31,7 @@ import java.io.File import ru.knot.messager.R import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, androidx.compose.ui.ExperimentalComposeUiApi::class, androidx.compose.foundation.ExperimentalFoundationApi::class) @Composable fun ChatDetailScreen( chatId: String, @@ -45,6 +48,9 @@ fun ChatDetailScreen( var isEmojiPickerVisible by remember { mutableStateOf(false) } var isRecording by remember { mutableStateOf(false) } + val keyboardController = LocalSoftwareKeyboardController.current + val focusManager = LocalFocusManager.current + var autoPlayingMessageId by remember { mutableStateOf(null) } var currentPlaybackSpeed by remember { mutableFloatStateOf(1.0f) } @@ -89,10 +95,27 @@ fun ChatDetailScreen( viewModel.setChatId(chatId) } - // Автопрокрутка к последнему сообщению + // Автопрокрутка к первому непрочитанному или к самому низу + LaunchedEffect(state.initialScrollIndex) { + state.initialScrollIndex?.let { index -> + if (index < state.messages.size) { + listState.scrollToItem(index) + } + } + } + + // Автопрокрутка к новому сообщению, если мы внизу LaunchedEffect(state.messages.size) { if (state.messages.isNotEmpty()) { - listState.animateScrollToItem(state.messages.size - 1) + val isAtBottom = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index == state.messages.size - 2 + if (isAtBottom || state.initialScrollIndex == null) { + listState.animateScrollToItem(state.messages.size - 1) + } + + // Если пришли новые сообщения и мы их видим — помечаем как прочитанные + if (isAtBottom) { + viewModel.markAsRead() + } } } @@ -141,6 +164,7 @@ fun ChatDetailScreen( modifier = Modifier .fillMaxSize() .padding(paddingValues) + .imePadding() ) { // Список сообщений Box( @@ -193,7 +217,15 @@ fun ChatDetailScreen( .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { - IconButton(onClick = { isEmojiPickerVisible = !isEmojiPickerVisible }) { + IconButton( + onClick = { + isEmojiPickerVisible = !isEmojiPickerVisible + if (isEmojiPickerVisible) { + keyboardController?.hide() + focusManager.clearFocus() + } + } + ) { Icon( Icons.Default.EmojiEmotions, contentDescription = stringResource(R.string.emoji), @@ -207,7 +239,13 @@ fun ChatDetailScreen( TextField( value = textInput, onValueChange = { textInput = it }, - modifier = Modifier.weight(1f), + modifier = Modifier + .weight(1f) + .onFocusChanged { + if (it.isFocused) { + isEmojiPickerVisible = false + } + }, placeholder = { Text(stringResource(R.string.message_placeholder)) }, maxLines = 4, colors = TextFieldDefaults.colors( diff --git a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt index 509d003..dcabf3b 100644 --- a/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt +++ b/client-mobile/chats/presentation/chat_detail/ChatDetailViewModel.kt @@ -33,7 +33,8 @@ data class ChatDetailState( val searchedGifs: List = emptyList(), val recentGifs: List = emptyList(), val gifCategories: List = emptyList(), - val isGifsLoading: Boolean = false + val isGifsLoading: Boolean = false, + val initialScrollIndex: Int? = null ) @HiltViewModel @@ -69,13 +70,39 @@ class ChatDetailViewModel @Inject constructor( // Ensure SignalR is connected val token = tokenManager.getToken() if (token != null) { - val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") + val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api") signalrClient.connect(baseUrl, token) } - + loadChatInfo(chatId) - loadMessages(chatId) + observeMessages(chatId) observeSignalREvents() + + // Initial sync from network + refreshMessages(chatId) + } + + private fun observeMessages(chatId: String) { + repository.getMessagesFlow(chatId) + .onEach { messages -> + _state.update { it.copy(messages = messages) } + // Calculate scroll index if not set + if (_state.value.initialScrollIndex == null && messages.isNotEmpty()) { + val firstUnreadIndex = messages.indexOfFirst { !it.isRead && it.senderId != getCurrentUserId() } + val targetIndex = if (firstUnreadIndex != -1) firstUnreadIndex else messages.size - 1 + _state.update { it.copy(initialScrollIndex = targetIndex) } + + // Automark as read + markAsRead() + } + } + .launchIn(viewModelScope) + } + + fun refreshMessages(chatId: String) { + viewModelScope.launch { + repository.getMessages(chatId) + } } private fun loadChatInfo(chatId: String) { @@ -92,17 +119,16 @@ class ChatDetailViewModel @Inject constructor( } } - fun loadMessages(chatId: String) { - viewModelScope.launch { - _state.update { it.copy(isLoading = true) } - try { - // Бэкенд обычно возвращает сообщения от новых к старым. - // Для чата нам нужно наоборот: старые вверху, новые внизу. - val messages = repository.getMessages(chatId).reversed() - _state.update { it.copy(messages = messages, isLoading = false) } - } catch (e: Exception) { - _state.update { it.copy(isLoading = false, error = e.localizedMessage) } + 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) } } @@ -119,13 +145,10 @@ class ChatDetailViewModel @Inject constructor( .onEach { event -> when (event) { is ChatEvent.NewMessage -> { - // Avoid adding duplicates if already loaded val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/") - _state.update { s -> - val domainMsg = event.message.toDomain(baseUrl) - if (s.messages.none { it.id == domainMsg.id }) { - s.copy(messages = s.messages + domainMsg) - } else s + val domainMsg = event.message.toDomain(baseUrl) + viewModelScope.launch { + repository.saveMessage(domainMsg) } } is ChatEvent.ReactionUpdated -> { @@ -142,39 +165,53 @@ class ChatDetailViewModel @Inject constructor( .launchIn(viewModelScope) } - private fun updateMessageReaction(messageId: String, userId: String, emoji: String) { - _state.update { s -> - val updatedMessages = s.messages.map { msg -> - if (msg.id == messageId) { - // Logic to update reactions map. - // Note: Simplified logic, usually we need to know if it was added or removed. - // If we assume reaction_updated is a toggle: - val currentReactions = msg.reactions.toMutableMap() - val count = currentReactions[emoji] ?: 0 - // This is a placeholder logic as the exact behavior depends on server implementation. - // For now, let's just increment/decrement based on some convention or just refresh. - currentReactions[emoji] = count + 1 - msg.copy(reactions = currentReactions) - } else msg + fun markAsRead() { + val chatId = currentChatId ?: return + val messages = _state.value.messages + if (messages.isEmpty()) return + + val lastMessage = messages.last() + viewModelScope.launch { + try { + repository.markMessagesAsRead(chatId, lastMessage.id, lastMessage.sequenceId) + } catch (e: Exception) { + // Ignore } - s.copy(messages = updatedMessages) } } - fun sendMessage(text: String) { val chatId = currentChatId ?: return if (text.isBlank()) return + val tempId = "temp_${System.currentTimeMillis()}" + val userId = getCurrentUserId() + + val tempMessage = Message( + id = tempId, + chatId = chatId, + senderId = userId, + content = text, + createdAt = java.util.Date().toString(), + mediaType = chats.domain.model.MediaType.TEXT, + media = emptyList(), + senderName = "Вы", + senderAvatar = null, + reactions = emptyMap(), + isRead = false, + sequenceId = 0 + ) + + viewModelScope.launch { + repository.saveMessage(tempMessage) + } + viewModelScope.launch { try { 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 - } + repository.deleteLocalMessage(tempId) + repository.saveMessage(sentMessage) } catch (e: Exception) { + repository.deleteLocalMessage(tempId) _state.update { it.copy(error = e.localizedMessage) } } } @@ -253,6 +290,28 @@ class ChatDetailViewModel @Inject constructor( fun sendGif(url: String) { val chatId = currentChatId ?: return + 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( @@ -263,12 +322,8 @@ class ChatDetailViewModel @Inject constructor( ) 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 - } + repository.deleteLocalMessage(tempId) + repository.saveMessage(sentMessage) // Add to recent val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs @@ -287,6 +342,7 @@ class ChatDetailViewModel @Inject constructor( _state.update { it.copy(recentGifs = newList) } } } catch (e: Exception) { + repository.deleteLocalMessage(tempId) _state.update { it.copy(error = e.localizedMessage) } } } diff --git a/client-mobile/chats/presentation/chat_list/ChatListScreen.kt b/client-mobile/chats/presentation/chat_list/ChatListScreen.kt index 4d4eb71..ff42cc1 100644 --- a/client-mobile/chats/presentation/chat_list/ChatListScreen.kt +++ b/client-mobile/chats/presentation/chat_list/ChatListScreen.kt @@ -43,6 +43,10 @@ fun ChatListScreen( val state by viewModel.state.collectAsState() val storyState by storyViewModel.state.collectAsState() + androidx.compose.runtime.LaunchedEffect(Unit) { + viewModel.loadChats() + } + Scaffold( topBar = { TopAppBar( diff --git a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt index 7382d54..633c1a1 100644 --- a/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt +++ b/client-mobile/chats/presentation/chat_list/ChatListViewModel.kt @@ -83,6 +83,18 @@ class ChatListViewModel @Inject constructor( 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 } } @@ -92,12 +104,15 @@ class ChatListViewModel @Inject constructor( 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 == event.message.chatId) { + val isMyMessage = event.message.senderId == currentUserId chat.copy( lastMessage = event.message.toDomain(baseUrl), - unreadCount = chat.unreadCount + 1 + unreadCount = if (isMyMessage) chat.unreadCount else chat.unreadCount + 1 ) } else chat } diff --git a/client-mobile/core/database/data/ChatDatabase.kt b/client-mobile/core/database/data/ChatDatabase.kt new file mode 100644 index 0000000..d4667f0 --- /dev/null +++ b/client-mobile/core/database/data/ChatDatabase.kt @@ -0,0 +1,41 @@ +package core.database.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow + +@Entity(tableName = "messages") +data class MessageEntity( + @PrimaryKey val id: String, + val chatId: String, + val senderId: String, + val senderName: String, + val senderAvatar: String?, + val content: String?, + val sequenceId: Int, + val createdAt: String, + val mediaType: String, + val mediaJson: String, // Simplified for now + val reactionsJson: String, + val isRead: Boolean, + val replyToId: String? = null +) + +@Dao +interface MessageDao { + @Query("SELECT * FROM messages WHERE chatId = :chatId ORDER BY sequenceId ASC") + fun getMessages(chatId: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insertMessages(messages: List) + + @Query("DELETE FROM messages WHERE chatId = :chatId") + suspend fun clearChat(chatId: String) + + @Query("DELETE FROM messages WHERE id = :messageId") + suspend fun deleteMessage(messageId: String) +} + +@Database(entities = [MessageEntity::class], version = 1) +abstract class ChatDatabase : RoomDatabase() { + abstract fun messageDao(): MessageDao +} diff --git a/client-mobile/core/di/DatabaseModule.kt b/client-mobile/core/di/DatabaseModule.kt new file mode 100644 index 0000000..244c485 --- /dev/null +++ b/client-mobile/core/di/DatabaseModule.kt @@ -0,0 +1,32 @@ +package core.di + +import android.content.Context +import androidx.room.Room +import core.database.data.ChatDatabase +import core.database.data.MessageDao +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object DatabaseModule { + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): ChatDatabase { + return Room.databaseBuilder( + context, + ChatDatabase::class.java, + "chat_database" + ).fallbackToDestructiveMigration().build() + } + + @Provides + fun provideMessageDao(database: ChatDatabase): MessageDao { + return database.messageDao() + } +} diff --git a/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt b/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt index 2e8df34..62c4c6d 100644 --- a/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt +++ b/client-mobile/core/notifications/data/ForkFirebaseMessagingService.kt @@ -61,12 +61,23 @@ class ForkFirebaseMessagingService : FirebaseMessagingService() { notificationManager.createNotificationChannel(channel) } + // Create intent to open MainActivity + val intent = Intent(this, com.knot.messenger.MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + putExtra("type", type) + } + val pendingIntent = PendingIntent.getActivity( + this, 0, intent, + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) PendingIntent.FLAG_IMMUTABLE else 0 + ) + val notificationBuilder = NotificationCompat.Builder(this, channelId) - .setSmallIcon(android.R.drawable.ic_dialog_info) // Заменить на наш Indigo-логотип + .setSmallIcon(android.R.drawable.ic_dialog_info) .setContentTitle(title) .setContentText(body) .setAutoCancel(true) .setPriority(NotificationCompat.PRIORITY_HIGH) + .setContentIntent(pendingIntent) notificationManager.notify(System.currentTimeMillis().toInt(), notificationBuilder.build()) }