478 lines
18 KiB
Kotlin
478 lines
18 KiB
Kotlin
package chats.presentation.chat_detail
|
|
|
|
import androidx.lifecycle.ViewModel
|
|
import androidx.lifecycle.viewModelScope
|
|
import chats.data.remote.signalr.ChatEvent
|
|
import chats.data.remote.signalr.ChatHubClient
|
|
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
|
|
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
|
|
|
|
data class ChatDetailState(
|
|
val messages: List<Message> = emptyList(),
|
|
val chatName: String? = null,
|
|
val chatAvatar: String? = null,
|
|
val isLoading: 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
|
|
)
|
|
|
|
@HiltViewModel
|
|
class ChatDetailViewModel @Inject constructor(
|
|
private val repository: ChatRepository,
|
|
private val signalrClient: ChatHubClient,
|
|
private val serverConfig: ServerConfig,
|
|
private val tokenManager: TokenManager,
|
|
@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 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() ?: ""
|
|
}
|
|
|
|
fun setChatId(chatId: String) {
|
|
currentChatId = chatId
|
|
|
|
// Ensure SignalR is connected
|
|
val token = tokenManager.getToken()
|
|
if (token != null) {
|
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api")
|
|
signalrClient.connect(baseUrl, token)
|
|
}
|
|
|
|
loadChatInfo(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) {
|
|
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)
|
|
}
|
|
}
|
|
|
|
private fun observeSignalREvents() {
|
|
signalrClient.events
|
|
.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
|
|
}
|
|
}
|
|
.onEach { event ->
|
|
when (event) {
|
|
is ChatEvent.NewMessage -> {
|
|
val baseUrl = serverConfig.getBaseUrl().removeSuffix("/api/")
|
|
val domainMsg = event.message.toDomain(baseUrl)
|
|
viewModelScope.launch {
|
|
repository.saveMessage(domainMsg)
|
|
}
|
|
}
|
|
is ChatEvent.ReactionUpdated -> {
|
|
updateMessageReaction(event.messageId, event.userId, event.emoji)
|
|
}
|
|
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.
|
|
}
|
|
else -> Unit
|
|
}
|
|
}
|
|
.launchIn(viewModelScope)
|
|
}
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
fun sendMessage(text: String) {
|
|
val chatId = currentChatId ?: return
|
|
val pending = _state.value.pendingAttachments
|
|
if (text.isBlank() && pending.isEmpty()) return
|
|
|
|
val tempId = "temp_${System.currentTimeMillis()}"
|
|
val userId = getCurrentUserId()
|
|
|
|
// Determine mediaType based on attachments
|
|
val mediaType = when {
|
|
pending.isEmpty() -> chats.domain.model.MediaType.TEXT
|
|
pending.any { it.extension.lowercase() in listOf("jpg", "jpeg", "png", "webp", "gif") } -> chats.domain.model.MediaType.IMAGE
|
|
pending.any { it.extension.lowercase() in listOf("mp4", "mov", "webm") } -> chats.domain.model.MediaType.VIDEO
|
|
else -> chats.domain.model.MediaType.TEXT
|
|
}
|
|
|
|
val tempMessage = Message(
|
|
id = tempId,
|
|
chatId = chatId,
|
|
senderId = userId,
|
|
content = if (text.isBlank()) null else text,
|
|
createdAt = java.util.Date().toString(),
|
|
mediaType = mediaType,
|
|
media = emptyList(),
|
|
senderName = "Вы",
|
|
senderAvatar = null,
|
|
reactions = emptyMap(),
|
|
isRead = false,
|
|
sequenceId = 0
|
|
)
|
|
|
|
viewModelScope.launch {
|
|
repository.saveMessage(tempMessage)
|
|
}
|
|
|
|
viewModelScope.launch {
|
|
try {
|
|
// Upload attachments if any
|
|
val attachmentRequests = if (_state.value.pendingAttachments.isNotEmpty()) {
|
|
_state.update { it.copy(isUploading = true) }
|
|
val requests = _state.value.pendingAttachments.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, pendingAttachments = emptyList()) }
|
|
requests
|
|
} else {
|
|
null
|
|
}
|
|
|
|
val sentMessage = repository.sendMessage(
|
|
chatId = chatId,
|
|
content = text,
|
|
type = if (attachmentRequests != null) "media" else "text",
|
|
attachments = attachmentRequests
|
|
)
|
|
repository.deleteLocalMessage(tempId)
|
|
repository.saveMessage(sentMessage)
|
|
} catch (e: Exception) {
|
|
repository.deleteLocalMessage(tempId)
|
|
_state.update { it.copy(error = e.localizedMessage, isUploading = false) }
|
|
}
|
|
}
|
|
}
|
|
|
|
fun addReaction(messageId: String, emoji: String) {
|
|
viewModelScope.launch {
|
|
try {
|
|
repository.addReaction(messageId, emoji)
|
|
} catch (e: Exception) {
|
|
// Ignore
|
|
}
|
|
}
|
|
}
|
|
|
|
fun sendVoiceMessage(file: File) {
|
|
val chatId = currentChatId ?: return
|
|
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 = "Вы",
|
|
reactions = emptyMap(),
|
|
isRead = false,
|
|
sequenceId = 0
|
|
)
|
|
|
|
viewModelScope.launch {
|
|
repository.saveMessage(tempMessage)
|
|
}
|
|
|
|
viewModelScope.launch {
|
|
try {
|
|
// 1. Upload the audio file
|
|
val url = repository.uploadMedia(file)
|
|
|
|
// 2. Send the message with the attachment
|
|
val attachment = chats.data.remote.api.AttachmentRequest(
|
|
type = "voice",
|
|
url = url,
|
|
fileName = file.name,
|
|
fileSize = file.length()
|
|
)
|
|
|
|
val sentMessage = repository.sendMessage(
|
|
chatId = chatId,
|
|
content = null,
|
|
type = "audio",
|
|
attachments = listOf(attachment)
|
|
)
|
|
|
|
repository.deleteLocalMessage(tempId)
|
|
repository.saveMessage(sentMessage)
|
|
} catch (e: Exception) {
|
|
repository.deleteLocalMessage(tempId)
|
|
_state.update { it.copy(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 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 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))
|
|
|
|
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) }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|