838 lines
34 KiB
Kotlin
838 lines
34 KiB
Kotlin
package chats.presentation.chat_detail
|
||
|
||
import androidx.lifecycle.ViewModel
|
||
import androidx.lifecycle.viewModelScope
|
||
import android.util.Log
|
||
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 core.network.ServerConfig
|
||
import core.security.TokenManager
|
||
import dagger.hilt.android.lifecycle.HiltViewModel
|
||
import kotlinx.coroutines.Job
|
||
import kotlinx.coroutines.delay
|
||
import kotlinx.coroutines.flow.*
|
||
import kotlinx.coroutines.launch
|
||
import java.io.File
|
||
import javax.inject.Inject
|
||
import core.utils.copyUriToFile
|
||
import core.utils.ImageUtils
|
||
|
||
import chats.data.remote.api.KlipyGifDto
|
||
import chats.domain.model.MessageStatus
|
||
|
||
data class ChatDetailState(
|
||
val messages: List<Message> = emptyList(),
|
||
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,
|
||
val canCall: Boolean = true,
|
||
val maxFileSize: Long = 100 * 1024 * 1024,
|
||
val trendingGifs: List<KlipyGifDto> = emptyList(),
|
||
val searchedGifs: List<KlipyGifDto> = emptyList(),
|
||
val recentGifs: List<KlipyGifDto> = emptyList(),
|
||
val gifCategories: List<chats.data.remote.api.GifCategoryDto> = emptyList(),
|
||
val isGifsLoading: Boolean = false,
|
||
val initialScrollIndex: Int? = null,
|
||
val pendingAttachments: List<File> = emptyList(),
|
||
val isUploading: Boolean = false,
|
||
val isCompressionEnabled: Boolean = true,
|
||
val inputText: String = "",
|
||
val replyingMessage: Message? = null,
|
||
val editingMessage: Message? = null,
|
||
val forwardingMessages: List<Message> = emptyList(),
|
||
val availableChatsToForward: List<chats.domain.model.Chat> = emptyList(),
|
||
val selectedMessageIds: Set<String> = emptySet(),
|
||
val pinnedMessages: List<Message> = emptyList(),
|
||
val isOffline: Boolean = false
|
||
)
|
||
|
||
@HiltViewModel
|
||
class ChatDetailViewModel @Inject constructor(
|
||
private val repository: ChatRepository,
|
||
private val signalrClient: ChatHubClient,
|
||
private val serverConfig: ServerConfig,
|
||
private val tokenManager: TokenManager,
|
||
private val activeChatTracker: core.notifications.data.ActiveChatTracker,
|
||
private val signalrNotificationObserver: chats.data.remote.signalr.SignalRNotificationObserver,
|
||
@dagger.hilt.android.qualifiers.ApplicationContext private val context: android.content.Context
|
||
) : ViewModel() {
|
||
|
||
private val _state = MutableStateFlow(ChatDetailState())
|
||
val state: StateFlow<ChatDetailState> = _state.asStateFlow()
|
||
|
||
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)
|
||
|
||
init {
|
||
val config = serverConfig.getServerConfig()
|
||
val savedCompression = prefs.getBoolean("compression_enabled", true)
|
||
_state.update { it.copy(
|
||
canCall = config.features.calls,
|
||
maxFileSize = config.limits.maxFileSize,
|
||
isCompressionEnabled = savedCompression
|
||
) }
|
||
}
|
||
|
||
fun toggleCompression() {
|
||
_state.update { currentState ->
|
||
val newValue = !currentState.isCompressionEnabled
|
||
prefs.edit().putBoolean("compression_enabled", newValue).apply()
|
||
currentState.copy(isCompressionEnabled = newValue)
|
||
}
|
||
}
|
||
|
||
fun getCurrentUserId(): String {
|
||
return tokenManager.getUserId() ?: ""
|
||
}
|
||
|
||
private var messagesFlowJob: Job? = null
|
||
|
||
fun setChatId(chatId: String) {
|
||
if (currentChatId == chatId) return
|
||
currentChatId = chatId
|
||
activeChatTracker.setChatId(chatId)
|
||
|
||
_state.update { it.copy(
|
||
messages = emptyList(),
|
||
isLoading = true,
|
||
initialScrollIndex = null
|
||
) }
|
||
|
||
// Ensure SignalR is connected and join the chat room
|
||
val token = tokenManager.getToken()
|
||
val baseUrl = serverConfig.getBaseUrl()
|
||
if (token != null && baseUrl.isNotBlank()) {
|
||
signalrClient.connect(baseUrl.removeSuffix("/api/"), token)
|
||
signalrClient.joinChat(chatId)
|
||
}
|
||
|
||
loadChatInfo(chatId)
|
||
observeSignalREvents(chatId)
|
||
|
||
// Подписываемся на Flow из Room (Single Source of Truth)
|
||
observeMessagesFromLocal(chatId)
|
||
|
||
// Запускаем синхронизацию с сервером
|
||
viewModelScope.launch {
|
||
repository.syncMessagesForChat(chatId)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Подписка на локальные сообщения из Room (Single Source of Truth)
|
||
*/
|
||
private fun observeMessagesFromLocal(chatId: String) {
|
||
messagesFlowJob?.cancel()
|
||
messagesFlowJob = viewModelScope.launch {
|
||
repository.getMessagesFlow(chatId)
|
||
.catch { e ->
|
||
Log.e("ChatDetailVM", "Error observing messages", e)
|
||
_state.update { it.copy(isLoading = false, error = e.message) }
|
||
}
|
||
.collect { messages ->
|
||
val sortedMessages = messages.sortedByDescending { it.sequenceId }
|
||
_state.update { currentState ->
|
||
currentState.copy(
|
||
messages = sortedMessages,
|
||
isLoading = false,
|
||
initialScrollIndex = if (currentState.initialScrollIndex == null) 0 else currentState.initialScrollIndex
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fun onInitialScrollDone() {
|
||
_state.update { it.copy(initialScrollIndex = -1) }
|
||
}
|
||
|
||
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) }
|
||
}
|
||
}
|
||
}
|
||
|
||
private fun loadChatInfo(chatId: String) {
|
||
viewModelScope.launch {
|
||
try {
|
||
val chats = repository.getChats()
|
||
val chat = chats.find { it.id == chatId }
|
||
chat?.let { c ->
|
||
_state.update { it.copy(chatName = c.name, chatAvatar = c.avatar) }
|
||
}
|
||
} catch (e: Exception) {
|
||
// Ignore info load error
|
||
}
|
||
}
|
||
}
|
||
|
||
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)
|
||
}
|
||
}
|
||
|
||
// For handling reaction removed event
|
||
private fun removeMessageReaction(messageId: String, userId: String, emoji: String) {
|
||
_state.update { s ->
|
||
val updatedMessages = s.messages.map { msg ->
|
||
if (msg.id == messageId) {
|
||
val currentReactions = msg.reactions.toMutableMap()
|
||
val count = currentReactions[emoji] ?: 0
|
||
if (count > 1) {
|
||
currentReactions[emoji] = count - 1
|
||
} else {
|
||
currentReactions.remove(emoji)
|
||
}
|
||
msg.copy(reactions = currentReactions)
|
||
} else msg
|
||
}
|
||
s.copy(messages = updatedMessages)
|
||
}
|
||
}
|
||
|
||
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 ->
|
||
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(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 -> {
|
||
if (event.isRemoved) {
|
||
removeMessageReaction(event.messageId, event.userId, event.emoji)
|
||
} else {
|
||
updateMessageReaction(event.messageId, event.userId, event.emoji)
|
||
}
|
||
}
|
||
is ChatEvent.MessageDeleted -> {
|
||
_state.update { currentState ->
|
||
currentState.copy(messages = currentState.messages.filter { it.id != event.messageId })
|
||
}
|
||
}
|
||
is ChatEvent.MessageEdited -> {
|
||
_state.update { currentState ->
|
||
val updated = currentState.messages.map { msg ->
|
||
if (msg.id == event.messageId) msg.copy(content = event.content) else msg
|
||
}
|
||
currentState.copy(messages = updated)
|
||
}
|
||
}
|
||
is ChatEvent.UserTyping -> {
|
||
_state.update { it.copy(isTyping = true) }
|
||
typingTimerJob?.cancel()
|
||
typingTimerJob = viewModelScope.launch {
|
||
delay(3000)
|
||
_state.update { it.copy(isTyping = false) }
|
||
}
|
||
}
|
||
is ChatEvent.UserStoppedTyping -> {
|
||
_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)
|
||
}
|
||
}
|
||
is ChatEvent.MessagePinned -> {
|
||
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
||
_state.update { s ->
|
||
val domainMsg = event.message.toDomain(getCurrentUserId(), baseUrl)
|
||
s.copy(pinnedMessages = (s.pinnedMessages + domainMsg).distinctBy { it.id })
|
||
}
|
||
}
|
||
is ChatEvent.MessageUnpinned -> {
|
||
_state.update { s ->
|
||
s.copy(pinnedMessages = s.pinnedMessages.filter { it.id != event.messageId })
|
||
}
|
||
}
|
||
else -> Unit
|
||
}
|
||
}
|
||
.launchIn(viewModelScope)
|
||
}
|
||
|
||
fun clearChatId() {
|
||
android.util.Log.d("ChatDetailVM", "KILLING ALL SESSION JOBS for $currentChatId")
|
||
signalrEventsJob?.cancel()
|
||
signalrEventsJob = null
|
||
currentChatId = null
|
||
activeChatTracker.setChatId(null)
|
||
_state.update { it.copy(messages = emptyList(), isLoading = false) }
|
||
}
|
||
|
||
fun markAsRead() {
|
||
val chatId = currentChatId ?: return
|
||
val messages = _state.value.messages
|
||
if (messages.isEmpty()) return
|
||
|
||
// В нашем reverseLayout (newest first) первое сообщение - самое новое от собеседника
|
||
val currentUserId = getCurrentUserId()
|
||
val lastMessageFromOther = messages.firstOrNull { it.senderId != currentUserId } ?: return
|
||
|
||
viewModelScope.launch {
|
||
try {
|
||
// Мгновенно обновляем в памяти для "галочек"
|
||
_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)
|
||
signalrNotificationObserver.refresh()
|
||
} catch (e: Exception) {
|
||
// Ignore
|
||
}
|
||
}
|
||
}
|
||
|
||
fun onInputTextChanged(text: String) {
|
||
_state.update { it.copy(inputText = text) }
|
||
|
||
val chatId = currentChatId ?: return
|
||
|
||
// Отправляем индикатор набора текста
|
||
val now = System.currentTimeMillis()
|
||
if (now - lastTypingSentTime > 1000) {
|
||
signalrClient.sendTypingIndicator(chatId)
|
||
lastTypingSentTime = now
|
||
|
||
// Отправляем "остановился печатать" через 3 секунды
|
||
typingTimerJob?.cancel()
|
||
typingTimerJob = viewModelScope.launch {
|
||
delay(3000)
|
||
signalrClient.sendUserStoppedTyping(chatId)
|
||
}
|
||
}
|
||
}
|
||
|
||
|
||
fun onReply(message: Message) {
|
||
_state.update { it.copy(replyingMessage = message) }
|
||
}
|
||
|
||
fun forwardMessages(targetChatId: String, messages: List<Message>) {
|
||
viewModelScope.launch {
|
||
messages.forEach { msg ->
|
||
repository.sendMessage(
|
||
chatId = targetChatId,
|
||
content = msg.content,
|
||
type = msg.mediaType.name.lowercase(),
|
||
forwardedFromId = msg.senderId
|
||
)
|
||
}
|
||
}
|
||
}
|
||
|
||
fun onForward(message: Message) {
|
||
_state.update { it.copy(forwardingMessages = listOf(message)) }
|
||
loadChatsForForwarding()
|
||
}
|
||
|
||
fun loadChatsForForwarding() {
|
||
viewModelScope.launch {
|
||
try {
|
||
val chats = repository.getChats()
|
||
_state.update { it.copy(availableChatsToForward = chats) }
|
||
} catch (e: Exception) {
|
||
// Ignore
|
||
}
|
||
}
|
||
}
|
||
|
||
fun cancelForwarding() {
|
||
_state.update { it.copy(forwardingMessages = emptyList(), availableChatsToForward = emptyList()) }
|
||
}
|
||
|
||
fun onForwardSelectedMessages() {
|
||
val selectedIds = _state.value.selectedMessageIds
|
||
val messages = _state.value.messages.filter { selectedIds.contains(it.id) }
|
||
_state.update { it.copy(forwardingMessages = messages) }
|
||
loadChatsForForwarding()
|
||
}
|
||
|
||
fun toggleSelection(messageId: String) {
|
||
_state.update { s ->
|
||
val newSelection = if (s.selectedMessageIds.contains(messageId)) {
|
||
s.selectedMessageIds - messageId
|
||
} else {
|
||
s.selectedMessageIds + messageId
|
||
}
|
||
s.copy(selectedMessageIds = newSelection)
|
||
}
|
||
}
|
||
|
||
fun clearSelection() {
|
||
_state.update { it.copy(selectedMessageIds = emptySet()) }
|
||
}
|
||
|
||
fun cancelReply() {
|
||
_state.update { it.copy(replyingMessage = null) }
|
||
}
|
||
|
||
fun cancelEdit() {
|
||
_state.update { it.copy(editingMessage = null, inputText = "") }
|
||
}
|
||
|
||
fun deleteMessage(message: Message, forEveryone: Boolean) {
|
||
viewModelScope.launch {
|
||
try {
|
||
repository.deleteMessage(message.id, forEveryone)
|
||
// Local update if needed (will also come via SignalR for everyone, but forMe might need local only update)
|
||
_state.update { currentState ->
|
||
currentState.copy(messages = currentState.messages.filter { it.id != message.id })
|
||
}
|
||
} catch (e: Exception) {
|
||
_state.update { it.copy(error = "Delete failed: ${e.localizedMessage}") }
|
||
}
|
||
}
|
||
}
|
||
|
||
fun onEdit(message: Message) {
|
||
_state.update { it.copy(editingMessage = message, inputText = message.content ?: "", replyingMessage = null) }
|
||
}
|
||
|
||
fun pinMessage(messageId: String) {
|
||
val chatId = currentChatId ?: return
|
||
signalrClient.pinMessage(messageId, chatId)
|
||
}
|
||
|
||
fun unpinMessage(messageId: String) {
|
||
val chatId = currentChatId ?: return
|
||
signalrClient.unpinMessage(messageId, chatId)
|
||
}
|
||
|
||
fun onPin(message: Message) {
|
||
pinMessage(message.id)
|
||
}
|
||
|
||
fun sendMessage(onFail: (String) -> Unit = {}) {
|
||
val chatId = currentChatId ?: return
|
||
val text = _state.value.inputText
|
||
val pending = _state.value.pendingAttachments
|
||
val replyToId = _state.value.replyingMessage?.id
|
||
val editingMsg = _state.value.editingMessage
|
||
if (text.isBlank() && pending.isEmpty()) return
|
||
|
||
// Handle Edit
|
||
if (editingMsg != null) {
|
||
_state.update { it.copy(inputText = "", editingMessage = null) }
|
||
viewModelScope.launch {
|
||
try {
|
||
val updated = repository.editMessage(editingMsg.id, text)
|
||
_state.update { currentState ->
|
||
val updatedList = currentState.messages.map {
|
||
if (it.id == updated.id) updated else it
|
||
}
|
||
currentState.copy(messages = updatedList)
|
||
}
|
||
} catch (e: Exception) {
|
||
_state.update { it.copy(error = "Edit failed: ${e.localizedMessage}") }
|
||
onFail(text)
|
||
}
|
||
}
|
||
return
|
||
}
|
||
|
||
// Clear input immediately для отзывчивого UI
|
||
_state.update { it.copy(inputText = "", replyingMessage = null, pendingAttachments = emptyList()) }
|
||
|
||
// Определяем тип медиа
|
||
val mediaType = when {
|
||
pending.isEmpty() -> "text"
|
||
pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> "image"
|
||
pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> "video"
|
||
pending.any { it.extension.lowercase() in listOf("mp3", "m4a", "wav") } -> "audio"
|
||
else -> "text"
|
||
}
|
||
|
||
viewModelScope.launch {
|
||
try {
|
||
// Upload attachments если есть
|
||
val attachmentRequests = if (pending.isNotEmpty()) {
|
||
_state.update { it.copy(isUploading = true) }
|
||
val requests = pending.map { file ->
|
||
val url = repository.uploadMedia(file)
|
||
chats.data.remote.api.AttachmentRequest(
|
||
type = when {
|
||
file.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif", "heic", "heif") -> "image"
|
||
file.extension.lowercase() in listOf("mp4", "mov", "3gp", "mkv", "webm") -> "video"
|
||
file.extension.lowercase() in listOf("mp3", "m4a", "wav", "aac", "ogg") -> "audio"
|
||
else -> "file"
|
||
},
|
||
url = url,
|
||
fileName = file.name,
|
||
fileSize = file.length()
|
||
)
|
||
}
|
||
_state.update { it.copy(isUploading = false) }
|
||
requests
|
||
} else {
|
||
null
|
||
}
|
||
|
||
// Отправляем сообщение - теперь оно создается локально и ставится в очередь
|
||
repository.sendMessage(
|
||
chatId = chatId,
|
||
content = if (text.isBlank()) null else text,
|
||
type = mediaType,
|
||
attachments = attachmentRequests,
|
||
replyToId = replyToId
|
||
)
|
||
// Сообщение автоматически появится в UI через Flow из Room
|
||
} catch (e: Exception) {
|
||
_state.update { it.copy(error = e.localizedMessage, isUploading = false) }
|
||
onFail(text)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Повторная отправка неудачного сообщения
|
||
*/
|
||
fun retryMessage(messageId: String) {
|
||
viewModelScope.launch {
|
||
repository.retryFailedMessage(messageId)
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Удаление локального сообщения (отмена отправки)
|
||
*/
|
||
fun cancelMessage(messageId: String) {
|
||
viewModelScope.launch {
|
||
repository.deleteLocalMessage(messageId)
|
||
}
|
||
}
|
||
|
||
fun addReaction(messageId: String, emoji: String) {
|
||
val chatId = currentChatId ?: return
|
||
|
||
// We rely on SignalR event to update the count to avoid double counting
|
||
// especially since domain Message doesn't track user IDs for reactions yet.
|
||
signalrClient.addReaction(messageId, chatId, emoji)
|
||
}
|
||
|
||
fun sendVoiceMessage(file: File) {
|
||
android.util.Log.d("ChatDetailVM", "sendVoiceMessage called with file: ${file.absolutePath}, size: ${file.length()}")
|
||
val chatId = currentChatId ?: run {
|
||
android.util.Log.e("ChatDetailVM", "sendVoiceMessage failed: currentChatId is null")
|
||
return
|
||
}
|
||
val replyToId = _state.value.replyingMessage?.id
|
||
_state.update { it.copy(replyingMessage = null) }
|
||
|
||
val tempId = "temp_voice_${System.currentTimeMillis()}"
|
||
val userId = getCurrentUserId()
|
||
|
||
val tempMessage = Message(
|
||
id = tempId,
|
||
chatId = chatId,
|
||
senderId = userId,
|
||
content = null,
|
||
createdAt = java.util.Date().toString(),
|
||
mediaType = chats.domain.model.MediaType.AUDIO,
|
||
media = emptyList(),
|
||
senderName = "Вы",
|
||
senderAvatar = null,
|
||
reactions = emptyMap(),
|
||
isRead = false,
|
||
sequenceId = 0
|
||
)
|
||
|
||
// Добавляем временное сообщение в стейт для индикации отправки
|
||
_state.update { it.copy(messages = listOf(tempMessage) + it.messages) }
|
||
|
||
viewModelScope.launch {
|
||
try {
|
||
android.util.Log.d("ChatDetailVM", "Starting voice upload...")
|
||
// 1. Upload the audio file
|
||
val url = repository.uploadMedia(file)
|
||
android.util.Log.d("ChatDetailVM", "Voice upload successful, url: $url")
|
||
|
||
// 2. Send the message with the attachment
|
||
val attachment = chats.data.remote.api.AttachmentRequest(
|
||
type = "voice",
|
||
url = url,
|
||
fileName = file.name,
|
||
fileSize = file.length()
|
||
)
|
||
|
||
android.util.Log.d("ChatDetailVM", "Sending message with voice attachment...")
|
||
val sentMessage = repository.sendMessage(
|
||
chatId = chatId,
|
||
content = null,
|
||
type = "media",
|
||
attachments = listOf(attachment),
|
||
replyToId = replyToId
|
||
)
|
||
android.util.Log.d("ChatDetailVM", "Voice message sent successfully: ${sentMessage.id}")
|
||
|
||
// Заменяем временное сообщение на настоящее
|
||
_state.update { currentState ->
|
||
val filtered = currentState.messages.filter { it.id != tempId }
|
||
// Избегаем дубликатов, если SignalR уже добавил сообщение
|
||
if (filtered.any { it.id == sentMessage.id }) {
|
||
currentState.copy(messages = filtered)
|
||
} else {
|
||
currentState.copy(messages = listOf(sentMessage) + filtered)
|
||
}
|
||
}
|
||
} catch (e: Exception) {
|
||
android.util.Log.e("ChatDetailVM", "Error sending voice message", e)
|
||
// Удаляем временное сообщение и показываем ошибку
|
||
_state.update { currentState ->
|
||
currentState.copy(
|
||
messages = currentState.messages.filter { it.id != tempId },
|
||
error = "Ошибка отправки голосового: ${e.localizedMessage}"
|
||
)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
fun addPendingAttachment(uri: android.net.Uri, context: android.content.Context) {
|
||
viewModelScope.launch {
|
||
val mimeType = context.contentResolver.getType(uri) ?: ""
|
||
val file = if (_state.value.isCompressionEnabled && mimeType.startsWith("image")) {
|
||
ImageUtils.compressImage(context, uri)
|
||
} else {
|
||
copyUriToFile(context, uri)
|
||
}
|
||
|
||
file?.let { f ->
|
||
_state.update { it.copy(pendingAttachments = it.pendingAttachments + f) }
|
||
}
|
||
}
|
||
}
|
||
|
||
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) }
|
||
}
|
||
|
||
fun uploadMedia(file: File) {
|
||
if (file.length() > _state.value.maxFileSize) {
|
||
_state.update { it.copy(error = "File too large") }
|
||
return
|
||
}
|
||
viewModelScope.launch {
|
||
try {
|
||
val url = repository.uploadMedia(file)
|
||
// After upload, we might want to send a message with this media
|
||
// For now, let's just log it or handle as per app requirements
|
||
} catch (e: Exception) {
|
||
_state.update { it.copy(error = e.localizedMessage) }
|
||
}
|
||
}
|
||
}
|
||
|
||
fun loadTrendingGifs() {
|
||
if (_state.value.trendingGifs.isNotEmpty()) return
|
||
|
||
viewModelScope.launch {
|
||
_state.update { it.copy(isGifsLoading = true) }
|
||
try {
|
||
val gifs = repository.getTrendingGifs(0)
|
||
_state.update { it.copy(trendingGifs = gifs, isGifsLoading = false) }
|
||
} catch (e: Exception) {
|
||
_state.update { it.copy(isGifsLoading = false, error = "GIF load error: ${e.message}") }
|
||
}
|
||
}
|
||
}
|
||
|
||
fun loadGifCategories() {
|
||
if (_state.value.gifCategories.isNotEmpty()) return
|
||
viewModelScope.launch {
|
||
try {
|
||
val categories = repository.getGifCategories()
|
||
_state.update { it.copy(gifCategories = categories) }
|
||
} catch (e: Exception) {
|
||
// Ignore failure
|
||
}
|
||
}
|
||
}
|
||
|
||
private var searchJob: Job? = null
|
||
fun searchGifs(query: String) {
|
||
searchJob?.cancel()
|
||
if (query.isBlank()) {
|
||
_state.update { it.copy(searchedGifs = emptyList()) }
|
||
return
|
||
}
|
||
searchJob = viewModelScope.launch {
|
||
delay(500)
|
||
_state.update { it.copy(isGifsLoading = true) }
|
||
try {
|
||
val gifs = repository.searchGifs(query, 0)
|
||
_state.update { it.copy(searchedGifs = gifs, isGifsLoading = false) }
|
||
} catch (e: Exception) {
|
||
_state.update { it.copy(isGifsLoading = false) }
|
||
}
|
||
}
|
||
}
|
||
|
||
fun sendGif(url: String) {
|
||
val chatId = currentChatId ?: return
|
||
val replyToId = _state.value.replyingMessage?.id
|
||
_state.update { it.copy(replyingMessage = null) }
|
||
|
||
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(
|
||
type = "image",
|
||
url = url,
|
||
fileName = "gif.gif",
|
||
fileSize = 0
|
||
)
|
||
val sentMessage = repository.sendMessage(chatId, null, "image", listOf(attachment), replyToId)
|
||
|
||
repository.deleteLocalMessage(tempId)
|
||
repository.saveMessage(sentMessage)
|
||
|
||
// Add to recent
|
||
val allGifs = _state.value.trendingGifs + _state.value.searchedGifs + _state.value.recentGifs
|
||
val selectedGif = allGifs.find { gif ->
|
||
val gifUrl = gif.files?.get("hd")?.get("gif")?.url
|
||
?: gif.files?.get("sd")?.get("gif")?.url
|
||
?: gif.file?.get("hd")?.get("gif")?.url
|
||
?: gif.file?.get("sd")?.get("gif")?.url
|
||
?: gif.media_formats?.get("gif")?.url
|
||
?: gif.images?.original?.url
|
||
gifUrl == url
|
||
}
|
||
|
||
selectedGif?.let { gif ->
|
||
val newList = (listOf(gif) + _state.value.recentGifs).distinctBy { it.id }.take(20)
|
||
_state.update { it.copy(recentGifs = newList) }
|
||
}
|
||
} catch (e: Exception) {
|
||
repository.deleteLocalMessage(tempId)
|
||
_state.update { it.copy(error = e.localizedMessage) }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|