Прочтение

This commit is contained in:
Халимов Рустам
2026-04-14 15:09:44 +03:00
parent 8165b74e43
commit 118f8b8971
24 changed files with 422 additions and 64 deletions
@@ -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
}