Прочтение
This commit is contained in:
@@ -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