Правки
This commit is contained in:
@@ -149,31 +149,44 @@ fun ChatDetailScreen(
|
||||
viewModel.setChatId(chatId)
|
||||
}
|
||||
|
||||
|
||||
// Безопасная группировка сообщений с обработкой ошибок
|
||||
val listItems = remember(state.messages) {
|
||||
val items = mutableListOf<MessageListItem>()
|
||||
if (state.messages.isEmpty()) return@remember items
|
||||
|
||||
// Группируем от старых к новым (для normal layout)
|
||||
val reversedMessages = state.messages.reversed()
|
||||
var i = 0
|
||||
while (i < reversedMessages.size) {
|
||||
val isoDate = reversedMessages[i].createdAt
|
||||
val localDate = viewModel.getLocalDateString(isoDate)
|
||||
val dayMessages = mutableListOf<chats.domain.model.Message>()
|
||||
|
||||
while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) {
|
||||
dayMessages.add(reversedMessages[i])
|
||||
i++
|
||||
}
|
||||
|
||||
// Добавляем заголовок даты
|
||||
items.add(MessageListItem.DateHeader(viewModel.formatDateHeader(isoDate)))
|
||||
|
||||
// Добавляем сообщения
|
||||
dayMessages.forEach { msg ->
|
||||
items.add(MessageListItem.MessageItem(msg))
|
||||
try {
|
||||
// Удаляем дубликаты по ID сообщения
|
||||
val uniqueMessages = state.messages.distinctBy { it.id }
|
||||
if (uniqueMessages.isEmpty()) return@remember items
|
||||
|
||||
// Группируем от старых к новым (для normal layout)
|
||||
val reversedMessages = uniqueMessages.reversed()
|
||||
var i = 0
|
||||
while (i < reversedMessages.size) {
|
||||
val isoDate = reversedMessages[i].createdAt
|
||||
val localDate = viewModel.getLocalDateString(isoDate)
|
||||
val dayMessages = mutableListOf<chats.domain.model.Message>()
|
||||
|
||||
while (i < reversedMessages.size && viewModel.getLocalDateString(reversedMessages[i].createdAt) == localDate) {
|
||||
dayMessages.add(reversedMessages[i])
|
||||
i++
|
||||
}
|
||||
|
||||
// Добавляем заголовок даты
|
||||
val safeDateHeader = try {
|
||||
viewModel.formatDateHeader(isoDate)
|
||||
} catch (e: Exception) {
|
||||
isoDate.take(10)
|
||||
}
|
||||
items.add(MessageListItem.DateHeader(safeDateHeader))
|
||||
|
||||
// Добавляем сообщения
|
||||
dayMessages.forEach { msg ->
|
||||
items.add(MessageListItem.MessageItem(msg))
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatDetailScreen", "Error building listItems", e)
|
||||
}
|
||||
items
|
||||
}
|
||||
@@ -255,28 +268,38 @@ fun ChatDetailScreen(
|
||||
}
|
||||
|
||||
// Авто-скролл к новым сообщениям - только если мы уже внизу
|
||||
val lastMessageId = state.messages.lastOrNull()?.id
|
||||
// Берём firstOrNull, т.к. messages отсортированы по sequenceId убыванию (новые в начале)
|
||||
val lastMessageId = state.messages.firstOrNull()?.id
|
||||
LaunchedEffect(lastMessageId) {
|
||||
if (lastMessageId != null && listItems.isNotEmpty()) {
|
||||
val isAtBottom = listState.layoutInfo.run {
|
||||
val visibleItems = visibleItemsInfo
|
||||
val totalCount = totalItemsCount
|
||||
if (totalCount == 0 || visibleItems.isEmpty()) return@run true
|
||||
visibleItems.last().index >= totalCount - 2
|
||||
}
|
||||
|
||||
if (isAtBottom) {
|
||||
listState.animateScrollToItem(listItems.size - 1)
|
||||
// Даём время LazyColumn отрисовать новое сообщение и обновить layout
|
||||
delay(200)
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
val totalCount = layoutInfo.totalItemsCount
|
||||
if (totalCount > 0 && visibleItems.isNotEmpty()) {
|
||||
val lastVisibleIndex = visibleItems.last().index
|
||||
val atBottom = lastVisibleIndex >= totalCount - 2
|
||||
android.util.Log.d("ChatDetailScreen", "Auto-scroll check: lastVisible=$lastVisibleIndex, total=$totalCount, atBottom=$atBottom, msgId=$lastMessageId")
|
||||
if (atBottom) {
|
||||
listState.animateScrollToItem(listItems.size - 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Автоматическая прокрутка при инициализации
|
||||
// Автоматическая прокрутка при инициализации - только один раз
|
||||
var initialScrollDone by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(listItems.size) {
|
||||
if (listItems.isNotEmpty()) {
|
||||
listState.scrollToItem(listItems.size - 1)
|
||||
viewModel.onInitialScrollDone()
|
||||
viewModel.markAsRead()
|
||||
if (!initialScrollDone && listItems.isNotEmpty() && listItems.size > 1) {
|
||||
try {
|
||||
listState.scrollToItem(listItems.size - 1)
|
||||
initialScrollDone = true
|
||||
viewModel.onInitialScrollDone()
|
||||
viewModel.markAsRead()
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatDetailScreen", "Error in auto-scroll", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +323,20 @@ fun ChatDetailScreen(
|
||||
state.messages.count { !it.isRead && it.senderId != viewModel.getCurrentUserId() }
|
||||
}
|
||||
|
||||
// Авто-прочитка при открытии чата - если мы внизу
|
||||
LaunchedEffect(Unit) {
|
||||
delay(500)
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val visibleItems = layoutInfo.visibleItemsInfo
|
||||
val totalCount = layoutInfo.totalItemsCount
|
||||
if (totalCount > 0 && visibleItems.isNotEmpty()) {
|
||||
val lastVisibleIndex = visibleItems.last().index
|
||||
if (lastVisibleIndex >= totalCount - 2) {
|
||||
viewModel.markAsRead()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Авто-прочитка при нахождении внизу списка
|
||||
LaunchedEffect(listState.isScrollInProgress) {
|
||||
if (!listState.isScrollInProgress) {
|
||||
|
||||
@@ -123,7 +123,16 @@ class ChatDetailViewModel @Inject constructor(
|
||||
// Подписываемся на Flow из Room (Single Source of Truth)
|
||||
observeMessagesFromLocal(chatId)
|
||||
|
||||
// Запускаем синхронизацию с сервером
|
||||
// Немедленно грузим историю с сервера, чтобы не ждать WorkManager
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
repository.getMessages(chatId, limit = 50)
|
||||
} catch (e: Exception) {
|
||||
android.util.Log.e("ChatDetailVM", "Initial messages load failed", e)
|
||||
}
|
||||
}
|
||||
|
||||
// Дополнительно ставим фоновую синхронизацию через WorkManager
|
||||
viewModelScope.launch {
|
||||
repository.syncMessagesForChat(chatId)
|
||||
}
|
||||
@@ -351,6 +360,7 @@ class ChatDetailViewModel @Inject constructor(
|
||||
currentState.copy(messages = updatedMessages)
|
||||
}
|
||||
repository.markMessagesAsRead(chatId, lastMessageFromOther.id, lastMessageFromOther.sequenceId)
|
||||
repository.resetUnreadCount(chatId)
|
||||
signalrNotificationObserver.refresh()
|
||||
} catch (e: Exception) {
|
||||
// Ignore
|
||||
@@ -641,7 +651,8 @@ class ChatDetailViewModel @Inject constructor(
|
||||
|
||||
fun formatDateHeader(dateString: String): String {
|
||||
return try {
|
||||
// Парсим ISO 8601 (например 2024-04-14T20:56:00Z)
|
||||
if (dateString.isBlank()) return "Unknown"
|
||||
// Парсим ISO 8001 (например 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")
|
||||
}
|
||||
@@ -650,20 +661,23 @@ class ChatDetailViewModel @Inject constructor(
|
||||
// Форматируем в локальное время: "14 апреля"
|
||||
java.text.SimpleDateFormat("d MMMM", java.util.Locale("ru")).format(date)
|
||||
} catch (e: Exception) {
|
||||
dateString
|
||||
android.util.Log.e("ChatDetailVM", "formatDateHeader error: ${e.message}", e)
|
||||
dateString.take(10)
|
||||
}
|
||||
}
|
||||
|
||||
fun getLocalDateString(isoDate: String): String {
|
||||
return try {
|
||||
if (isoDate.isBlank()) return "1970-01-01"
|
||||
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
|
||||
val date = isoFormat.parse(isoDate) ?: return isoDate.take(10)
|
||||
// Возвращаем просто дату YYYY-MM-DD в локальном часовом поясе для группировки
|
||||
java.text.SimpleDateFormat("yyyy-MM-dd", java.util.Locale.getDefault()).format(date)
|
||||
} catch (e: Exception) {
|
||||
isoDate.split("T").first()
|
||||
android.util.Log.e("ChatDetailVM", "getLocalDateString error: ${e.message}", e)
|
||||
isoDate.split("T").firstOrNull() ?: "1970-01-01"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import chats.domain.model.MediaType
|
||||
import chats.domain.model.Media
|
||||
import chats.domain.model.MessageStatus
|
||||
import coil.compose.AsyncImage
|
||||
import coil.request.ImageRequest
|
||||
import core.presentation.components.AppVideoPlayer
|
||||
import core.presentation.components.AppAudioPlayer
|
||||
import core.presentation.components.AppAvatar
|
||||
@@ -225,7 +226,10 @@ fun MessageBubble(
|
||||
|
||||
if (isReplyImage && reply.media.isNotEmpty()) {
|
||||
AsyncImage(
|
||||
model = reply.media.first().url,
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(reply.media.first().url)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
@@ -281,17 +285,24 @@ fun MessageBubble(
|
||||
|
||||
// GIF Content (Klipy)
|
||||
if (message.mediaType == MediaType.GIF) {
|
||||
AsyncImage(
|
||||
model = message.content,
|
||||
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
|
||||
contentDescription = null,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { /* Handle click */ },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
.clickable { /* Handle click */ }
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(message.content)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
}
|
||||
|
||||
@@ -321,17 +332,25 @@ fun MessageBubble(
|
||||
val mediaItem = message.media[0]
|
||||
when {
|
||||
mediaItem.type.startsWith("image") || message.mediaType == MediaType.IMAGE -> {
|
||||
AsyncImage(
|
||||
model = mediaItem.url,
|
||||
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
|
||||
contentDescription = null,
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = 300.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { onMediaClick(mediaItem) },
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
.clickable { onMediaClick(mediaItem) }
|
||||
) {
|
||||
AsyncImage(
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(mediaItem.url)
|
||||
.crossfade(true)
|
||||
.memoryCacheKey(mediaItem.url)
|
||||
.build(),
|
||||
imageLoader = core.utils.CoilUtils.getGifImageLoader(context),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
}
|
||||
message.mediaType == MediaType.VIDEO || mediaItem.type.startsWith("video") -> {
|
||||
Box(
|
||||
@@ -708,7 +727,10 @@ fun GridItem(media: Media, modifier: Modifier = Modifier) {
|
||||
Box(modifier = modifier.background(Color.White.copy(alpha = 0.1f))) {
|
||||
if (isImage || isVideo) {
|
||||
AsyncImage(
|
||||
model = media.url,
|
||||
model = ImageRequest.Builder(context)
|
||||
.data(media.url)
|
||||
.crossfade(true)
|
||||
.build(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
|
||||
Reference in New Issue
Block a user