Прочтение
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Chat> {
|
||||
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<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): List<Message> {
|
||||
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<List<chats.data.remote.dto.MediaItemDto>>() {}.type
|
||||
val mediaDtos: List<chats.data.remote.dto.MediaItemDto> = gson.fromJson(mediaJson, mediaTypeToken) ?: emptyList()
|
||||
|
||||
val reactionsTypeToken = object : com.google.gson.reflect.TypeToken<List<chats.data.remote.dto.ReactionDto>>() {}.type
|
||||
val reactionDtos: List<chats.data.remote.dto.ReactionDto> = 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ 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): List<Message>
|
||||
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<chats.data.remote.api.KlipyGifDto>
|
||||
suspend fun searchGifs(query: String, page: Int = 0): List<chats.data.remote.api.KlipyGifDto>
|
||||
|
||||
@@ -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<String?>(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(
|
||||
|
||||
@@ -33,7 +33,8 @@ data class ChatDetailState(
|
||||
val searchedGifs: List<KlipyGifDto> = emptyList(),
|
||||
val recentGifs: List<KlipyGifDto> = emptyList(),
|
||||
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = 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) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user