Рабочие счетчики и переработка чата
This commit is contained in:
@@ -22,12 +22,18 @@ import kotlin.math.roundToInt
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.zIndex
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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 androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardCapitalization
|
||||
import chats.presentation.components.MediaPicker
|
||||
import chats.presentation.components.MessageBubble
|
||||
import core.presentation.components.AppAvatar
|
||||
@@ -55,6 +61,11 @@ fun ChatDetailScreen(
|
||||
viewModel: ChatDetailViewModel,
|
||||
onBack: () -> Unit
|
||||
) {
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
viewModel.clearChatId()
|
||||
}
|
||||
}
|
||||
val state by viewModel.state.collectAsState()
|
||||
val context = LocalContext.current
|
||||
val listState = rememberLazyListState()
|
||||
@@ -120,27 +131,97 @@ fun ChatDetailScreen(
|
||||
viewModel.setChatId(chatId)
|
||||
}
|
||||
|
||||
// Автопрокрутка к первому непрочитанному или к самому низу
|
||||
LaunchedEffect(state.initialScrollIndex) {
|
||||
state.initialScrollIndex?.let { index ->
|
||||
if (index < state.messages.size) {
|
||||
listState.scrollToItem(index)
|
||||
|
||||
val listItems = remember(state.messages) {
|
||||
val items = mutableListOf<MessageListItem>()
|
||||
if (state.messages.isEmpty()) return@remember items
|
||||
|
||||
// Группируем от новых к старым (Index 0 = bottom)
|
||||
var i = 0
|
||||
while (i < state.messages.size) {
|
||||
val isoDate = state.messages[i].createdAt
|
||||
// Получаем дату в локальном часовом поясе (например "2024-04-15")
|
||||
val localDate = viewModel.getLocalDateString(isoDate)
|
||||
val dayMessages = mutableListOf<chats.domain.model.Message>()
|
||||
|
||||
// Собираем все сообщения за этот локальный день
|
||||
while (i < state.messages.size && viewModel.getLocalDateString(state.messages[i].createdAt) == localDate) {
|
||||
dayMessages.add(state.messages[i])
|
||||
i++
|
||||
}
|
||||
|
||||
// Добавляем сообщения в инвертированном порядке
|
||||
dayMessages.forEach { msg ->
|
||||
items.add(MessageListItem.MessageItem(msg))
|
||||
}
|
||||
|
||||
// Добавляем заголовок (над группой сообщений)
|
||||
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
// Отслеживаем текущую дату для плавающего заголовка
|
||||
val floatingDate by remember {
|
||||
derivedStateOf {
|
||||
val firstIndex = listState.firstVisibleItemIndex
|
||||
// Не показываем плашку на самом первом элементе (там и так дата)
|
||||
// И показываем только если мы прокрутили хотя бы немного
|
||||
if (firstIndex > 0 && firstIndex < listItems.size) {
|
||||
val item = listItems[firstIndex]
|
||||
if (item is MessageListItem.MessageItem) {
|
||||
viewModel.formatDateHeader(item.message.createdAt)
|
||||
} else if (item is MessageListItem.DateHeader) {
|
||||
item.date
|
||||
} else null
|
||||
} else null
|
||||
}
|
||||
}
|
||||
|
||||
// Показывать ли кнопку "вниз"
|
||||
// Она должна появляться если мы не в самом низу
|
||||
val showScrollToBottom by remember {
|
||||
derivedStateOf {
|
||||
val layoutInfo = listState.layoutInfo
|
||||
if (layoutInfo.totalItemsCount == 0) return@derivedStateOf false
|
||||
|
||||
val lastVisibleIndex = layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0
|
||||
// Показываем, если не в самом низу (с запасом 1-2 элемента)
|
||||
lastVisibleIndex < listItems.size - 2
|
||||
}
|
||||
}
|
||||
|
||||
// Автоматическая прокрутка и прочтение при инициализации и новых сообщениях
|
||||
LaunchedEffect(listItems.size) {
|
||||
if (listItems.isNotEmpty()) {
|
||||
if (state.initialScrollIndex != null && state.initialScrollIndex != -1) {
|
||||
// Первый вход в чат - мы и так внизу из-за reverseLayout=true, но можем форсированно прокрутить к 0
|
||||
listState.scrollToItem(0)
|
||||
viewModel.onInitialScrollDone()
|
||||
viewModel.markAsRead()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Автопрокрутка к новому сообщению, если мы внизу
|
||||
LaunchedEffect(state.messages.size) {
|
||||
if (state.messages.isNotEmpty()) {
|
||||
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()
|
||||
// Подгрузка истории при прокрутке вверх
|
||||
LaunchedEffect(listState) {
|
||||
snapshotFlow { listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index }
|
||||
.collect { lastVisibleIndex ->
|
||||
if (lastVisibleIndex != null && lastVisibleIndex >= listItems.size - 10 && listItems.size >= 15) {
|
||||
android.util.Log.d("ChatDetailScreen", "TRIGGER LOAD MORE: index $lastVisibleIndex, size ${listItems.size}")
|
||||
viewModel.loadMoreMessages()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val unreadCount = remember(state.messages) {
|
||||
state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() }
|
||||
}
|
||||
|
||||
// Авто-прочитка при нахождении внизу списка (в reverseLayout это индекс 0)
|
||||
LaunchedEffect(listState.isScrollInProgress, listState.firstVisibleItemIndex) {
|
||||
if (!listState.isScrollInProgress && listState.firstVisibleItemIndex <= 1) {
|
||||
viewModel.markAsRead()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -200,6 +281,7 @@ fun ChatDetailScreen(
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
reverseLayout = true,
|
||||
contentPadding = PaddingValues(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
@@ -207,34 +289,76 @@ fun ChatDetailScreen(
|
||||
msg.media.map { it to msg.id }
|
||||
}.reversed()
|
||||
|
||||
items(state.messages, key = { it.id }) { message ->
|
||||
MessageBubble(
|
||||
message = message,
|
||||
isCurrentUser = message.senderId == viewModel.getCurrentUserId(),
|
||||
autoPlay = message.id == autoPlayingMessageId,
|
||||
initialPlaybackSpeed = if (message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f,
|
||||
onVoiceFinished = { speed -> playNextVoiceMessage(message.id, speed) },
|
||||
onReactionClick = { emoji -> viewModel.addReaction(message.id, emoji) },
|
||||
onMediaClick = { clickedMedia ->
|
||||
val isMedia = clickedMedia.type.startsWith("image") ||
|
||||
clickedMedia.type.startsWith("video") ||
|
||||
clickedMedia.type.contains("gif")
|
||||
|
||||
if (isMedia) {
|
||||
val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url }
|
||||
selectedMediaList = allChatMedia.map { it.first }
|
||||
initialMediaIndex = if (initialIndex != -1) initialIndex else 0
|
||||
} else {
|
||||
// Прямое открытие документов во внешних приложениях
|
||||
try {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url))
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) {
|
||||
// Ошибка (нет софта)
|
||||
items(listItems, key = {
|
||||
when(it) {
|
||||
is MessageListItem.MessageItem -> it.message.id
|
||||
is MessageListItem.DateHeader -> "date_${it.date}"
|
||||
MessageListItem.UnreadSeparator -> "unread_separator"
|
||||
}
|
||||
}) { item ->
|
||||
when(item) {
|
||||
is MessageListItem.MessageItem -> {
|
||||
MessageBubble(
|
||||
message = item.message,
|
||||
isCurrentUser = item.message.senderId == viewModel.getCurrentUserId(),
|
||||
autoPlay = item.message.id == autoPlayingMessageId,
|
||||
initialPlaybackSpeed = if (item.message.id == autoPlayingMessageId) currentPlaybackSpeed else 1.0f,
|
||||
onVoiceFinished = { speed -> playNextVoiceMessage(item.message.id, speed) },
|
||||
onReactionClick = { emoji -> viewModel.addReaction(item.message.id, emoji) },
|
||||
onMediaClick = { clickedMedia ->
|
||||
val isMedia = clickedMedia.type.startsWith("image") ||
|
||||
clickedMedia.type.startsWith("video") ||
|
||||
clickedMedia.type.contains("gif")
|
||||
|
||||
if (isMedia) {
|
||||
val initialIndex = allChatMedia.indexOfFirst { it.first.url == clickedMedia.url }
|
||||
selectedMediaList = allChatMedia.map { it.first }
|
||||
initialMediaIndex = if (initialIndex != -1) initialIndex else 0
|
||||
} else {
|
||||
try {
|
||||
val intent = android.content.Intent(android.content.Intent.ACTION_VIEW, android.net.Uri.parse(clickedMedia.url))
|
||||
context.startActivity(intent)
|
||||
} catch (e: Exception) { }
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
is MessageListItem.DateHeader -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Surface(
|
||||
color = Color.Black.copy(alpha = 0.2f),
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Text(
|
||||
text = item.date,
|
||||
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
MessageListItem.UnreadSeparator -> {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.weight(1f).height(0.5.dp).background(Color.Gray.copy(alpha = 0.3f)))
|
||||
Text(
|
||||
text = "НЕПРОЧИТАННЫЕ СООБЩЕНИЯ",
|
||||
modifier = Modifier.padding(horizontal = 12.dp),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = Color.Gray.copy(alpha = 0.6f)
|
||||
)
|
||||
Box(Modifier.weight(1f).height(0.5.dp).background(Color.Gray.copy(alpha = 0.3f)))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -242,6 +366,72 @@ fun ChatDetailScreen(
|
||||
if (state.isLoading) {
|
||||
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
|
||||
}
|
||||
|
||||
// Плавающий заголовок даты (на самом верху списка под топбаром)
|
||||
floatingDate?.let { date ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp)
|
||||
.align(Alignment.TopCenter)
|
||||
.zIndex(1f), // Гарантируем видимость поверх всего
|
||||
contentAlignment = Alignment.TopCenter
|
||||
) {
|
||||
Surface(
|
||||
color = Color.DarkGray.copy(alpha = 0.9f),
|
||||
shape = CircleShape,
|
||||
shadowElevation = 4.dp
|
||||
) {
|
||||
Text(
|
||||
text = date,
|
||||
modifier = Modifier.padding(horizontal = 14.dp, vertical = 6.dp),
|
||||
style = MaterialTheme.typography.labelMedium.copy(fontWeight = FontWeight.Bold),
|
||||
color = Color.White
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Кнопка прокрутки вниз с бейджем
|
||||
if (showScrollToBottom) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.padding(bottom = 16.dp, end = 16.dp)
|
||||
) {
|
||||
FloatingActionButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
listState.animateScrollToItem(listItems.size - 1)
|
||||
}
|
||||
},
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
contentColor = MaterialTheme.colorScheme.primary,
|
||||
shape = CircleShape,
|
||||
modifier = Modifier.size(44.dp),
|
||||
elevation = FloatingActionButtonDefaults.elevation(2.dp)
|
||||
) {
|
||||
Icon(Icons.Default.KeyboardArrowDown, contentDescription = null)
|
||||
}
|
||||
|
||||
if (unreadCount > 0) {
|
||||
Surface(
|
||||
color = Color(0xFFF44336), // Красный как в вебе
|
||||
shape = CircleShape,
|
||||
modifier = Modifier
|
||||
.align(Alignment.TopEnd)
|
||||
.offset(x = 4.dp, y = (-4).dp)
|
||||
) {
|
||||
Text(
|
||||
text = if (unreadCount > 99) "99+" else unreadCount.toString(),
|
||||
color = Color.White,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
modifier = Modifier.padding(horizontal = 6.dp, vertical = 2.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Панель ввода
|
||||
@@ -421,6 +611,21 @@ fun ChatDetailScreen(
|
||||
}
|
||||
},
|
||||
textStyle = MaterialTheme.typography.bodyLarge.copy(color = MaterialTheme.colorScheme.onSurface),
|
||||
keyboardOptions = KeyboardOptions(
|
||||
imeAction = ImeAction.Send,
|
||||
capitalization = KeyboardCapitalization.Sentences
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onSend = {
|
||||
if (textInput.isNotBlank() || state.pendingAttachments.isNotEmpty()) {
|
||||
viewModel.sendMessage(textInput, onFail = { failedText ->
|
||||
textInput = failedText
|
||||
})
|
||||
textInput = ""
|
||||
isEmojiPickerVisible = false
|
||||
}
|
||||
}
|
||||
),
|
||||
decorationBox = { innerTextField ->
|
||||
if (textInput.isEmpty()) {
|
||||
Text(
|
||||
@@ -443,8 +648,11 @@ fun ChatDetailScreen(
|
||||
Surface(
|
||||
onClick = {
|
||||
if (!showMic) {
|
||||
viewModel.sendMessage(textInput)
|
||||
viewModel.sendMessage(textInput, onFail = { failedText ->
|
||||
textInput = failedText
|
||||
})
|
||||
textInput = ""
|
||||
isEmojiPickerVisible = false
|
||||
}
|
||||
},
|
||||
shape = RoundedCornerShape(16.dp),
|
||||
@@ -566,3 +774,9 @@ fun ChatDetailScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
sealed class MessageListItem {
|
||||
data class MessageItem(val message: chats.domain.model.Message) : MessageListItem()
|
||||
data class DateHeader(val date: String) : MessageListItem()
|
||||
object UnreadSeparator : MessageListItem()
|
||||
}
|
||||
|
||||
@@ -4,9 +4,9 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import chats.data.remote.signalr.ChatEvent
|
||||
import chats.data.remote.signalr.ChatHubClient
|
||||
import chats.data.repository.toDomain
|
||||
import chats.domain.model.Message
|
||||
import chats.domain.repository.ChatRepository
|
||||
import chats.data.repository.toDomain
|
||||
import core.network.ServerConfig
|
||||
import core.security.TokenManager
|
||||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||||
@@ -26,6 +26,7 @@ data class ChatDetailState(
|
||||
val chatName: String? = null,
|
||||
val chatAvatar: String? = null,
|
||||
val isLoading: Boolean = false,
|
||||
val isLoadingMore: Boolean = false,
|
||||
val isTyping: Boolean = false,
|
||||
val typingUser: String? = null,
|
||||
val error: String? = null,
|
||||
@@ -56,6 +57,7 @@ class ChatDetailViewModel @Inject constructor(
|
||||
|
||||
private var currentChatId: String? = null
|
||||
private var typingTimerJob: Job? = null
|
||||
private var signalrEventsJob: Job? = null
|
||||
private var lastTypingSentTime: Long = 0
|
||||
|
||||
private val prefs = context.getSharedPreferences("chat_settings", android.content.Context.MODE_PRIVATE)
|
||||
@@ -83,43 +85,73 @@ class ChatDetailViewModel @Inject constructor(
|
||||
}
|
||||
|
||||
fun setChatId(chatId: String) {
|
||||
if (currentChatId == chatId) return
|
||||
currentChatId = chatId
|
||||
|
||||
// Ensure SignalR is connected
|
||||
_state.update { it.copy(
|
||||
messages = emptyList(),
|
||||
isLoading = true,
|
||||
initialScrollIndex = null
|
||||
) }
|
||||
|
||||
// Ensure SignalR is connected and join the chat room
|
||||
val token = tokenManager.getToken()
|
||||
if (token != null) {
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api")
|
||||
val baseUrl = serverConfig.getBaseUrl()
|
||||
signalrClient.connect(baseUrl, token)
|
||||
signalrClient.joinChat(chatId)
|
||||
}
|
||||
|
||||
loadChatInfo(chatId)
|
||||
observeMessages(chatId)
|
||||
observeSignalREvents()
|
||||
observeSignalREvents(chatId)
|
||||
|
||||
// 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)
|
||||
private fun updateMessages(messages: List<Message>) {
|
||||
val sortedMessages = messages.sortedByDescending { it.sequenceId }
|
||||
_state.update { it.copy(
|
||||
messages = sortedMessages,
|
||||
isLoading = false,
|
||||
initialScrollIndex = 0 // In reverse layout, 0 is the bottom
|
||||
) }
|
||||
}
|
||||
|
||||
fun onInitialScrollDone() {
|
||||
_state.update { it.copy(initialScrollIndex = -1) }
|
||||
}
|
||||
|
||||
fun refreshMessages(chatId: String) {
|
||||
viewModelScope.launch {
|
||||
repository.getMessages(chatId)
|
||||
val messages = repository.getMessages(chatId)
|
||||
updateMessages(messages)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadMoreMessages() {
|
||||
val chatId = currentChatId ?: return
|
||||
if (_state.value.isLoading || _state.value.isLoadingMore) return
|
||||
|
||||
val oldestMsg = _state.value.messages.lastOrNull() ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
_state.update { it.copy(isLoadingMore = true) }
|
||||
try {
|
||||
android.util.Log.d("ChatDetailVM", "Loading more history before seqId: ${oldestMsg.sequenceId}")
|
||||
val moreMessages = repository.getMessages(chatId, cursor = oldestMsg.sequenceId.toString())
|
||||
if (moreMessages.isNotEmpty()) {
|
||||
val newSorted = moreMessages.sortedByDescending { it.sequenceId }
|
||||
_state.update { currentState ->
|
||||
// Избегаем дубликатов
|
||||
val existingIds = currentState.messages.map { it.id }.toSet()
|
||||
val uniqueMore = newSorted.filter { it.id !in existingIds }
|
||||
currentState.copy(messages = currentState.messages + uniqueMore)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
_state.update { it.copy(isLoadingMore = false) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -150,23 +182,37 @@ class ChatDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeSignalREvents() {
|
||||
signalrClient.events
|
||||
private fun observeSignalREvents(chatId: String) {
|
||||
signalrEventsJob?.cancel()
|
||||
signalrEventsJob = signalrClient.events
|
||||
.onEach { android.util.Log.d("ChatDetailVM", "Received SignalR event: $it for chat: $chatId") }
|
||||
.filter { event ->
|
||||
when(event) {
|
||||
is ChatEvent.NewMessage -> event.message.chatId == currentChatId
|
||||
is ChatEvent.ReactionUpdated -> event.chatId == currentChatId
|
||||
is ChatEvent.UserTyping -> event.chatId == currentChatId
|
||||
else -> false
|
||||
val eventChatId = when(event) {
|
||||
is ChatEvent.NewMessage -> event.message.chatId
|
||||
is ChatEvent.ReactionUpdated -> event.chatId
|
||||
is ChatEvent.UserTyping -> event.chatId
|
||||
is ChatEvent.MessagesRead -> event.chatId
|
||||
else -> null
|
||||
}
|
||||
|
||||
if (eventChatId == null) return@filter false
|
||||
|
||||
val match = eventChatId == chatId || eventChatId.contains(chatId) || chatId.contains(eventChatId)
|
||||
if (match) {
|
||||
android.util.Log.d("ChatDetailVM", "Event MATCHED chat $chatId: $event")
|
||||
}
|
||||
match
|
||||
}
|
||||
.onEach { event ->
|
||||
when (event) {
|
||||
is ChatEvent.NewMessage -> {
|
||||
android.util.Log.d("ChatDetailVM", "New message added to bottom, NOT marking as read automatically")
|
||||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||||
val domainMsg = event.message.toDomain(baseUrl)
|
||||
viewModelScope.launch {
|
||||
repository.saveMessage(domainMsg)
|
||||
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
|
||||
|
||||
_state.update { currentState ->
|
||||
if (currentState.messages.any { it.id == domainMsg.id }) return@update currentState
|
||||
currentState.copy(messages = listOf(domainMsg) + currentState.messages)
|
||||
}
|
||||
}
|
||||
is ChatEvent.ReactionUpdated -> {
|
||||
@@ -174,8 +220,21 @@ class ChatDetailViewModel @Inject constructor(
|
||||
}
|
||||
is ChatEvent.UserTyping -> {
|
||||
_state.update { it.copy(isTyping = true) }
|
||||
// Reset typing status after some delay would be better,
|
||||
// but usually server sends stopped_typing event.
|
||||
typingTimerJob?.cancel()
|
||||
typingTimerJob = viewModelScope.launch {
|
||||
delay(3000)
|
||||
_state.update { it.copy(isTyping = false) }
|
||||
}
|
||||
}
|
||||
is ChatEvent.MessagesRead -> {
|
||||
_state.update { currentState ->
|
||||
val updatedMessages = currentState.messages.map { msg ->
|
||||
if (msg.sequenceId <= event.lastReadSequenceId) {
|
||||
msg.copy(isRead = true)
|
||||
} else msg
|
||||
}
|
||||
currentState.copy(messages = updatedMessages)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
@@ -183,21 +242,42 @@ class ChatDetailViewModel @Inject constructor(
|
||||
.launchIn(viewModelScope)
|
||||
}
|
||||
|
||||
fun clearChatId() {
|
||||
android.util.Log.d("ChatDetailVM", "KILLING ALL SESSION JOBS for $currentChatId")
|
||||
signalrEventsJob?.cancel()
|
||||
signalrEventsJob = null
|
||||
currentChatId = null
|
||||
_state.update { it.copy(messages = emptyList(), isLoading = false) }
|
||||
}
|
||||
|
||||
fun markAsRead() {
|
||||
val chatId = currentChatId ?: return
|
||||
val messages = _state.value.messages
|
||||
if (messages.isEmpty()) return
|
||||
|
||||
val lastMessage = messages.last()
|
||||
// В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника
|
||||
val currentUserId = getCurrentUserId()
|
||||
val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return
|
||||
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.markMessagesAsRead(chatId, lastMessage.id, lastMessage.sequenceId)
|
||||
// Мгновенно обновляем в памяти для "галочек"
|
||||
_state.update { currentState ->
|
||||
val updatedMessages = currentState.messages.map { msg ->
|
||||
if (msg.senderId != currentUserId && msg.sequenceId <= lastMessageFromOther.sequenceId) {
|
||||
msg.copy(isRead = true)
|
||||
} else msg
|
||||
}
|
||||
currentState.copy(messages = updatedMessages)
|
||||
}
|
||||
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
|
||||
} catch (e: Exception) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
fun sendMessage(text: String) {
|
||||
|
||||
fun sendMessage(text: String, onFail: (String) -> Unit = {}) {
|
||||
val chatId = currentChatId ?: return
|
||||
val pending = _state.value.pendingAttachments
|
||||
if (text.isBlank() && pending.isEmpty()) return
|
||||
@@ -259,15 +339,18 @@ class ChatDetailViewModel @Inject constructor(
|
||||
|
||||
val sentMessage = repository.sendMessage(
|
||||
chatId = chatId,
|
||||
content = text,
|
||||
content = if (text.isBlank()) null else text,
|
||||
type = if (attachmentRequests != null) "media" else "text",
|
||||
attachments = attachmentRequests
|
||||
)
|
||||
repository.deleteLocalMessage(tempId)
|
||||
repository.saveMessage(sentMessage)
|
||||
// Clear attachments on success
|
||||
_state.update { it.copy(pendingAttachments = emptyList()) }
|
||||
} catch (e: Exception) {
|
||||
repository.deleteLocalMessage(tempId)
|
||||
_state.update { it.copy(error = e.localizedMessage, isUploading = false) }
|
||||
onFail(text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -349,6 +432,34 @@ class ChatDetailViewModel @Inject constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun formatDateHeader(dateString: String): String {
|
||||
return try {
|
||||
// Парсим ISO 8601 (например 2024-04-14T20:56:00Z)
|
||||
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
val date = isoFormat.parse(dateString) ?: return dateString
|
||||
|
||||
// Форматируем в локальное время: "14 апреля"
|
||||
java.text.SimpleDateFormat("d MMMM", java.util.Locale("ru")).format(date)
|
||||
} catch (e: Exception) {
|
||||
dateString
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocalDateString(isoDate: String): String {
|
||||
return try {
|
||||
val isoFormat = java.text.SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", java.util.Locale.US).apply {
|
||||
timeZone = java.util.TimeZone.getTimeZone("UTC")
|
||||
}
|
||||
val date = isoFormat.parse(isoDate) ?: return isoDate
|
||||
// Возвращаем просто дату YYYY-MM-DD в локальном часовом поясе для группировки
|
||||
java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(date)
|
||||
} catch (e: Exception) {
|
||||
isoDate.split("T").first()
|
||||
}
|
||||
}
|
||||
|
||||
fun removePendingAttachment(file: File) {
|
||||
_state.update { it.copy(pendingAttachments = it.pendingAttachments - file) }
|
||||
}
|
||||
|
||||
@@ -108,11 +108,24 @@ class ChatListViewModel @Inject constructor(
|
||||
|
||||
_state.update { currentState ->
|
||||
val updatedChats = currentState.chats.map { chat ->
|
||||
if (chat.id == event.message.chatId) {
|
||||
if (chat.id.equals(event.message.chatId, ignoreCase = true)) {
|
||||
val isMyMessage = event.message.senderId == currentUserId
|
||||
val isAlreadySeen = chat.lastMessage?.id == event.message.id
|
||||
|
||||
val lastMsgDomain = event.message.toDomain(currentUserId, baseUrl)
|
||||
val newCount = if (isMyMessage || isAlreadySeen) {
|
||||
chat.unreadCount
|
||||
} else {
|
||||
chat.unreadCount + 1
|
||||
}
|
||||
|
||||
if (!isAlreadySeen) {
|
||||
android.util.Log.d("ChatListVM", "Message ${event.message.id} -> Count ${chat.unreadCount} -> $newCount")
|
||||
}
|
||||
|
||||
chat.copy(
|
||||
lastMessage = event.message.toDomain(baseUrl),
|
||||
unreadCount = if (isMyMessage) chat.unreadCount else chat.unreadCount + 1
|
||||
lastMessage = lastMsgDomain,
|
||||
unreadCount = newCount
|
||||
)
|
||||
} else chat
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.runtime.*
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -157,6 +158,25 @@ fun MessageBubble(
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Loading state for temp media messages
|
||||
if (message.id.startsWith("temp_") && message.mediaType != MediaType.TEXT) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(200.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(contentColor.copy(alpha = 0.1f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(24.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = contentColor.copy(alpha = 0.5f)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
// Media Content (Attachments)
|
||||
if (message.media.isNotEmpty() && message.mediaType != MediaType.GIF) {
|
||||
val mediaCount = message.media.size
|
||||
@@ -370,8 +390,8 @@ fun PhotoGrid(mediaList: List<Media>, isCurrentUser: Boolean, onMediaClick: (Int
|
||||
|
||||
val columns = when {
|
||||
mediaList.size == 1 -> 1
|
||||
mediaList.size % 3 == 0 -> 3
|
||||
else -> 2
|
||||
mediaList.size <= 4 -> 2
|
||||
else -> 3
|
||||
}
|
||||
|
||||
Column(modifier = modifier) {
|
||||
|
||||
Reference in New Issue
Block a user