diff --git a/android/app/src/main/java/ru/daemonlord/messenger/MessengerApplication.kt b/android/app/src/main/java/ru/daemonlord/messenger/MessengerApplication.kt index 19c2238..4adeb2f 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/MessengerApplication.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/MessengerApplication.kt @@ -1,8 +1,11 @@ package ru.daemonlord.messenger import android.app.Application +import android.os.Build import coil.ImageLoader import coil.ImageLoaderFactory +import coil.decode.GifDecoder +import coil.decode.ImageDecoderDecoder import coil.disk.DiskCache import coil.memory.MemoryCache import com.google.firebase.crashlytics.FirebaseCrashlytics @@ -35,6 +38,13 @@ class MessengerApplication : Application(), ImageLoaderFactory { diskCacheDir.mkdirs() } return ImageLoader.Builder(this) + .components { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + add(ImageDecoderDecoder.Factory()) + } else { + add(GifDecoder.Factory()) + } + } .memoryCache { MemoryCache.Builder(this) .maxSizePercent(0.25) diff --git a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt index 976dd1f..12e2f25 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/data/message/repository/NetworkMessageRepository.kt @@ -395,6 +395,70 @@ class NetworkMessageRepository @Inject constructor( } } + override suspend fun sendImageUrlMessage( + chatId: Long, + imageUrl: String, + replyToMessageId: Long?, + ): AppResult = withContext(ioDispatcher) { + val normalizedUrl = imageUrl.trim() + if (normalizedUrl.isBlank()) { + return@withContext AppResult.Error(AppError.Server("Image URL is empty")) + } + val tempId = -System.currentTimeMillis() + val now = java.time.Instant.now().toString() + val tempMessage = MessageEntity( + id = tempId, + chatId = chatId, + senderId = currentUserId ?: 0L, + senderDisplayName = null, + senderUsername = null, + senderAvatarUrl = null, + replyToMessageId = replyToMessageId, + replyPreviewText = null, + replyPreviewSenderName = null, + forwardedFromMessageId = null, + forwardedFromDisplayName = null, + type = "image", + text = normalizedUrl, + status = "pending", + attachmentWaveformJson = null, + createdAt = now, + updatedAt = null, + ) + messageDao.upsertMessages(listOf(tempMessage)) + chatDao.updateLastMessage( + chatId = chatId, + lastMessageText = normalizedUrl, + lastMessageType = "image", + lastMessageCreatedAt = now, + updatedSortAt = now, + ) + try { + val sent = messageApiService.sendMessage( + request = MessageCreateRequestDto( + chatId = chatId, + type = "image", + text = normalizedUrl, + clientMessageId = UUID.randomUUID().toString(), + replyToMessageId = replyToMessageId, + ) + ) + messageDao.deleteMessage(tempId) + messageDao.upsertMessages(listOf(sent.toEntity())) + chatDao.updateLastMessage( + chatId = chatId, + lastMessageText = sent.text, + lastMessageType = sent.type, + lastMessageCreatedAt = sent.createdAt, + updatedSortAt = sent.createdAt, + ) + AppResult.Success(Unit) + } catch (error: Throwable) { + messageDao.deleteMessage(tempId) + AppResult.Error(error.toAppError()) + } + } + override suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult = withContext(ioDispatcher) { try { messageApiService.updateMessageStatus( diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt index dc48c75..6bb2030 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/repository/MessageRepository.kt @@ -20,6 +20,11 @@ interface MessageRepository { caption: String? = null, replyToMessageId: Long? = null, ): AppResult + suspend fun sendImageUrlMessage( + chatId: Long, + imageUrl: String, + replyToMessageId: Long? = null, + ): AppResult suspend fun editMessage(messageId: Long, newText: String): AppResult suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult diff --git a/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendImageUrlMessageUseCase.kt b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendImageUrlMessageUseCase.kt new file mode 100644 index 0000000..d1e3f7e --- /dev/null +++ b/android/app/src/main/java/ru/daemonlord/messenger/domain/message/usecase/SendImageUrlMessageUseCase.kt @@ -0,0 +1,21 @@ +package ru.daemonlord.messenger.domain.message.usecase + +import ru.daemonlord.messenger.domain.common.AppResult +import ru.daemonlord.messenger.domain.message.repository.MessageRepository +import javax.inject.Inject + +class SendImageUrlMessageUseCase @Inject constructor( + private val messageRepository: MessageRepository, +) { + suspend operator fun invoke( + chatId: Long, + imageUrl: String, + replyToMessageId: Long? = null, + ): AppResult { + return messageRepository.sendImageUrlMessage( + chatId = chatId, + imageUrl = imageUrl, + replyToMessageId = replyToMessageId, + ) + } +} diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt index 9a5ea07..2c5bfb4 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatScreen.kt @@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -67,6 +68,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.derivedStateOf +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -81,16 +89,20 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.draw.clip +import androidx.compose.ui.geometry.Offset import androidx.compose.ui.unit.dp import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.viewinterop.AndroidView +import androidx.compose.foundation.Canvas import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.safeDrawing import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.layout.onSizeChanged import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.core.content.ContextCompat @@ -230,6 +242,7 @@ fun ChatRoute( bytes = bytes, ) }, + onSendPresetMediaUrl = viewModel::onSendPresetMediaUrl, onVoiceRecordStart = { if (!hasAudioPermission) { startRecordingAfterPermissionGrant = true @@ -290,6 +303,7 @@ fun ChatScreen( onLoadMore: () -> Unit, onPickMedia: () -> Unit, onSendRemoteMedia: (String, String, ByteArray) -> Unit, + onSendPresetMediaUrl: (String) -> Unit, onVoiceRecordStart: () -> Unit, onVoiceRecordTick: () -> Unit, onVoiceRecordLock: () -> Unit, @@ -304,14 +318,34 @@ fun ChatScreen( val scope = rememberCoroutineScope() val allImageUrls = remember(state.messages) { state.messages - .flatMap { message -> message.attachments } - .map { it.fileUrl to it.fileType.lowercase() } - .filter { (_, type) -> type.startsWith("image/") } - .map { (url, _) -> url } + .flatMap { message -> + val urls = message.attachments + .map { it.fileUrl to it.fileType.lowercase() } + .filter { (_, type) -> + type.startsWith("image/") && + !type.contains("gif") && + !type.contains("webp") + } + .map { (url, _) -> url } + .toMutableList() + val legacyUrl = message.text?.trim() + if ( + urls.isEmpty() && + message.type.equals("image", ignoreCase = true) && + !legacyUrl.isNullOrBlank() && + legacyUrl.startsWith("http", ignoreCase = true) && + !isGifLikeUrl(legacyUrl) && + !isStickerLikeUrl(legacyUrl) + ) { + urls += legacyUrl + } + urls + } .distinct() } val isChannelChat = state.chatType.equals("channel", ignoreCase = true) val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) } + var didInitialAutoScroll by remember(state.chatId) { mutableStateOf(false) } var viewerImageIndex by remember { mutableStateOf(null) } var viewerVideoUrl by remember { mutableStateOf(null) } var dismissedPinnedMessageId by remember { mutableStateOf(null) } @@ -341,6 +375,24 @@ fun ChatScreen( val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) } val giphyApiKey = remember { BuildConfig.GIPHY_API_KEY.trim() } + val firstUnreadIncomingMessageId = remember(state.messages, state.chatUnreadCount) { + val unread = state.chatUnreadCount.coerceAtLeast(0) + if (unread <= 0) { + null + } else { + val incoming = state.messages.filter { !it.isOutgoing } + val firstUnreadIncomingIndex = (incoming.size - unread).coerceAtLeast(0) + incoming.getOrNull(firstUnreadIncomingIndex)?.id + } + } + val shouldShowScrollToBottomButton by remember(listState, timelineItems) { + derivedStateOf { + val lastIndex = timelineItems.lastIndex + if (lastIndex < 0) return@derivedStateOf false + val lastVisibleIndex = listState.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: 0 + lastVisibleIndex < lastIndex - 1 + } + } LaunchedEffect(state.isRecordingVoice) { if (!state.isRecordingVoice) return@LaunchedEffect @@ -358,6 +410,20 @@ fun ChatScreen( listState.animateScrollToItem(index = index) } } + LaunchedEffect(timelineItems, state.isLoading, firstUnreadIncomingMessageId, didInitialAutoScroll) { + if (didInitialAutoScroll) return@LaunchedEffect + if (state.isLoading) return@LaunchedEffect + if (timelineItems.isEmpty()) return@LaunchedEffect + val targetIndex = firstUnreadIncomingMessageId + ?.let { unreadId -> + timelineItems.indexOfFirst { item -> + item is ChatTimelineItem.MessageEntry && item.message.id == unreadId + }.takeIf { it >= 0 } + } + ?: timelineItems.lastIndex + listState.scrollToItem(targetIndex) + didInitialAutoScroll = true + } LaunchedEffect(state.inputText) { if (state.inputText != composerValue.text) { val clampedCursor = composerValue.selection.end.coerceAtMost(state.inputText.length) @@ -724,92 +790,121 @@ fun ChatScreen( } else -> { - LazyColumn( - state = listState, + Box( modifier = Modifier .weight(1f) - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + .fillMaxWidth(), ) { - items( - items = timelineItems, - key = { item -> - when (item) { - is ChatTimelineItem.DayHeader -> "day:${item.headerId}" - is ChatTimelineItem.MessageEntry -> "msg:${item.message.id}" + LazyColumn( + state = listState, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 12.dp, vertical = 8.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + items( + items = timelineItems, + key = { item -> + when (item) { + is ChatTimelineItem.DayHeader -> "day:${item.headerId}" + is ChatTimelineItem.MessageEntry -> "msg:${item.message.id}" + } + }, + ) { item -> + if (item is ChatTimelineItem.DayHeader) { + DaySeparatorChip(label = item.label) + return@items } - }, - ) { item -> - if (item is ChatTimelineItem.DayHeader) { - DaySeparatorChip(label = item.label) - return@items - } - val message = (item as ChatTimelineItem.MessageEntry).message - val isSelected = state.actionState.selectedMessageIds.contains(message.id) - MessageBubble( - message = message, - isChannelChat = isChannelChat, - isSelected = isSelected, - isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI, - isInlineHighlighted = state.highlightedMessageId == message.id, - reactions = state.reactionByMessageId[message.id].orEmpty(), - onAttachmentImageClick = { imageUrl -> - val idx = allImageUrls.indexOf(imageUrl) - viewerImageIndex = if (idx >= 0) idx else null - }, - onAttachmentVideoClick = { videoUrl -> - viewerVideoUrl = videoUrl - }, - onAudioPlaybackChanged = { playback -> - if (playback.isPlaying) { - val previous = topAudioStrip?.sourceId - if (!previous.isNullOrBlank() && previous != playback.sourceId) { - forceStopAudioSourceId = previous + val message = (item as ChatTimelineItem.MessageEntry).message + val isSelected = state.actionState.selectedMessageIds.contains(message.id) + MessageBubble( + message = message, + isChannelChat = isChannelChat, + isSelected = isSelected, + isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI, + isInlineHighlighted = state.highlightedMessageId == message.id, + reactions = state.reactionByMessageId[message.id].orEmpty(), + onAttachmentImageClick = { imageUrl -> + val idx = allImageUrls.indexOf(imageUrl) + viewerImageIndex = if (idx >= 0) idx else null + }, + onAttachmentVideoClick = { videoUrl -> + viewerVideoUrl = videoUrl + }, + onAudioPlaybackChanged = { playback -> + if (playback.isPlaying) { + val previous = topAudioStrip?.sourceId + if (!previous.isNullOrBlank() && previous != playback.sourceId) { + forceStopAudioSourceId = previous + } + topAudioStrip = playback + if (forceStopAudioSourceId == playback.sourceId) { + forceStopAudioSourceId = null + } + } else if (topAudioStrip?.sourceId == playback.sourceId) { + topAudioStrip = null } - topAudioStrip = playback - if (forceStopAudioSourceId == playback.sourceId) { + }, + forceStopAudioSourceId = forceStopAudioSourceId, + forceToggleAudioSourceId = forceToggleAudioSourceId, + forceCycleSpeedAudioSourceId = forceCycleSpeedAudioSourceId, + onForceStopAudioSourceHandled = { sourceId -> + if (forceStopAudioSourceId == sourceId) { forceStopAudioSourceId = null } - } else if (topAudioStrip?.sourceId == playback.sourceId) { - topAudioStrip = null - } - }, - forceStopAudioSourceId = forceStopAudioSourceId, - forceToggleAudioSourceId = forceToggleAudioSourceId, - forceCycleSpeedAudioSourceId = forceCycleSpeedAudioSourceId, - onForceStopAudioSourceHandled = { sourceId -> - if (forceStopAudioSourceId == sourceId) { - forceStopAudioSourceId = null - } - }, - onForceToggleAudioSourceHandled = { sourceId -> - if (forceToggleAudioSourceId == sourceId) { - forceToggleAudioSourceId = null - } - }, - onForceCycleSpeedAudioSourceHandled = { sourceId -> - if (forceCycleSpeedAudioSourceId == sourceId) { - forceCycleSpeedAudioSourceId = null - } - }, - onClick = { - if (state.actionState.mode == MessageSelectionMode.MULTI) { - onToggleMessageMultiSelection(message) - } else { - onSelectMessage(message) - actionMenuMessage = message - } - }, - onLongPress = { - if (state.actionState.mode == MessageSelectionMode.MULTI) { - onToggleMessageMultiSelection(message) - } else { - actionMenuMessage = null - onEnterMultiSelect(message) - } - }, - ) + }, + onForceToggleAudioSourceHandled = { sourceId -> + if (forceToggleAudioSourceId == sourceId) { + forceToggleAudioSourceId = null + } + }, + onForceCycleSpeedAudioSourceHandled = { sourceId -> + if (forceCycleSpeedAudioSourceId == sourceId) { + forceCycleSpeedAudioSourceId = null + } + }, + onClick = { + if (state.actionState.mode == MessageSelectionMode.MULTI) { + onToggleMessageMultiSelection(message) + } else { + onSelectMessage(message) + actionMenuMessage = message + } + }, + onLongPress = { + if (state.actionState.mode == MessageSelectionMode.MULTI) { + onToggleMessageMultiSelection(message) + } else { + actionMenuMessage = null + onEnterMultiSelect(message) + } + }, + ) + } + } + if (shouldShowScrollToBottomButton && timelineItems.isNotEmpty()) { + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 20.dp, bottom = 18.dp) + .size(42.dp) + .clip(CircleShape) + .clickable { + scope.launch { + listState.animateScrollToItem(timelineItems.lastIndex) + } + }, + ) { + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = Icons.Filled.ArrowDownward, + contentDescription = "Scroll to bottom", + tint = MaterialTheme.colorScheme.onPrimary, + ) + } + } } } } @@ -1095,6 +1190,45 @@ fun ChatScreen( ChatInfoTabContent( tab = chatInfoTab, entries = chatInfoEntries, + onAttachmentImageClick = { imageUrl -> + val idx = allImageUrls.indexOf(imageUrl) + viewerImageIndex = if (idx >= 0) idx else null + }, + onAttachmentVideoClick = { videoUrl -> + viewerVideoUrl = videoUrl + }, + onAudioPlaybackChanged = { playback -> + if (playback.isPlaying) { + val previous = topAudioStrip?.sourceId + if (!previous.isNullOrBlank() && previous != playback.sourceId) { + forceStopAudioSourceId = previous + } + topAudioStrip = playback + if (forceStopAudioSourceId == playback.sourceId) { + forceStopAudioSourceId = null + } + } else if (topAudioStrip?.sourceId == playback.sourceId) { + topAudioStrip = null + } + }, + forceStopAudioSourceId = forceStopAudioSourceId, + forceToggleAudioSourceId = forceToggleAudioSourceId, + forceCycleSpeedAudioSourceId = forceCycleSpeedAudioSourceId, + onForceStopAudioSourceHandled = { sourceId -> + if (forceStopAudioSourceId == sourceId) { + forceStopAudioSourceId = null + } + }, + onForceToggleAudioSourceHandled = { sourceId -> + if (forceToggleAudioSourceId == sourceId) { + forceToggleAudioSourceId = null + } + }, + onForceCycleSpeedAudioSourceHandled = { sourceId -> + if (forceCycleSpeedAudioSourceId == sourceId) { + forceCycleSpeedAudioSourceId = null + } + }, onEntryClick = { entry -> when (entry.type) { ChatInfoEntryType.Media -> { @@ -1729,16 +1863,17 @@ fun ChatScreen( .clip(RoundedCornerShape(10.dp)) .clickable(enabled = !isPickerSending) { scope.launch { - isPickerSending = true - val payload = downloadRemoteMedia(remote.url, remote.fileNamePrefix) - if (payload != null) { - val stickerAdjustedName = if (emojiPickerTab == ComposerPickerTab.Sticker) { - "sticker_${payload.fileName}" - } else { - payload.fileName - } - onSendRemoteMedia(stickerAdjustedName, payload.mimeType, payload.bytes) + isPickerSending = true + if (emojiPickerTab == ComposerPickerTab.Gif || emojiPickerTab == ComposerPickerTab.Sticker) { + onSendPresetMediaUrl(remote.url) showEmojiPicker = false + } else { + val payload = downloadRemoteMedia(remote.url, remote.fileNamePrefix) + if (payload != null) { + val stickerAdjustedName = "sticker_${payload.fileName}" + onSendRemoteMedia(stickerAdjustedName, payload.mimeType, payload.bytes) + showEmojiPicker = false + } } isPickerSending = false } @@ -1940,7 +2075,16 @@ private fun MessageBubble( } val mainText = message.text?.takeIf { it.isNotBlank() } - if (mainText != null || message.attachments.isEmpty()) { + val textUrl = mainText?.trim()?.takeIf { it.startsWith("http", ignoreCase = true) } + val isLegacyImageUrlMessage = message.attachments.isEmpty() && + message.type.equals("image", ignoreCase = true) && + !textUrl.isNullOrBlank() + val isGifOrStickerLegacyImage = !textUrl.isNullOrBlank() && + (isGifLikeUrl(textUrl) || isStickerLikeUrl(textUrl)) + val isLegacyVideoUrlMessage = message.attachments.isEmpty() && + (message.type.equals("video", ignoreCase = true) || message.type.equals("circle_video", ignoreCase = true)) && + !textUrl.isNullOrBlank() + if ((mainText != null || message.attachments.isEmpty()) && !isLegacyImageUrlMessage && !isLegacyVideoUrlMessage) { FormattedMessageText( text = mainText ?: "[${message.type}]", style = MaterialTheme.typography.bodyLarge, @@ -1948,6 +2092,46 @@ private fun MessageBubble( ) } + if (isLegacyImageUrlMessage && textUrl != null) { + val badgeLabel = mediaBadgeLabel(fileType = "image/url", url = textUrl) + Box( + modifier = Modifier + .fillMaxWidth() + .height(188.dp) + .clip(RoundedCornerShape(12.dp)) + .let { base -> + if (isGifOrStickerLegacyImage) { + base + } else { + base.clickable { onAttachmentImageClick(textUrl) } + } + }, + ) { + AsyncImage( + model = textUrl, + contentDescription = "Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + if (!badgeLabel.isNullOrBlank()) { + MediaTypeBadge( + label = badgeLabel, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp), + ) + } + } + } + + if (isLegacyVideoUrlMessage && textUrl != null) { + VideoAttachmentCard( + url = textUrl, + fileType = message.type, + onOpenViewer = { onAttachmentVideoClick(textUrl) }, + ) + } + if (reactions.isNotEmpty()) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { reactions.forEach { reaction -> @@ -1973,16 +2157,32 @@ private fun MessageBubble( if (imageAttachments.isNotEmpty()) { if (imageAttachments.size == 1) { val single = imageAttachments.first() - AsyncImage( - model = single.fileUrl, - contentDescription = "Image", + val badgeLabel = mediaBadgeLabel(fileType = single.fileType, url = single.fileUrl) + val openable = badgeLabel == null + Box( modifier = Modifier .fillMaxWidth() .height(188.dp) .clip(RoundedCornerShape(12.dp)) - .clickable { onAttachmentImageClick(single.fileUrl) }, - contentScale = ContentScale.Crop, - ) + .let { base -> + if (openable) base.clickable { onAttachmentImageClick(single.fileUrl) } else base + }, + ) { + AsyncImage( + model = single.fileUrl, + contentDescription = "Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + if (!badgeLabel.isNullOrBlank()) { + MediaTypeBadge( + label = badgeLabel, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(8.dp), + ) + } + } } else { imageAttachments.chunked(2).forEach { rowItems -> Row( @@ -1990,16 +2190,32 @@ private fun MessageBubble( horizontalArrangement = Arrangement.spacedBy(4.dp), ) { rowItems.forEach { image -> - AsyncImage( - model = image.fileUrl, - contentDescription = "Image", + val badgeLabel = mediaBadgeLabel(fileType = image.fileType, url = image.fileUrl) + val openable = badgeLabel == null + Box( modifier = Modifier .weight(1f) .height(112.dp) .clip(RoundedCornerShape(10.dp)) - .clickable { onAttachmentImageClick(image.fileUrl) }, - contentScale = ContentScale.Crop, - ) + .let { base -> + if (openable) base.clickable { onAttachmentImageClick(image.fileUrl) } else base + }, + ) { + AsyncImage( + model = image.fileUrl, + contentDescription = "Image", + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Crop, + ) + if (!badgeLabel.isNullOrBlank()) { + MediaTypeBadge( + label = badgeLabel, + modifier = Modifier + .align(Alignment.BottomStart) + .padding(6.dp), + ) + } + } } if (rowItems.size == 1) Spacer(modifier = Modifier.weight(1f)) } @@ -2449,13 +2665,15 @@ private suspend fun fetchGiphySearchItems( data.mapNotNull { node -> val obj = node.jsonObject val id = obj["id"]?.jsonPrimitive?.content.orEmpty() - val url = obj["images"] - ?.jsonObject - ?.get("fixed_width") - ?.jsonObject - ?.get("url") - ?.jsonPrimitive - ?.content + val images = obj["images"]?.jsonObject + val url = listOfNotNull( + images?.get("downsized")?.jsonObject?.get("url")?.jsonPrimitive?.content, + images?.get("original")?.jsonObject?.get("url")?.jsonPrimitive?.content, + images?.get("fixed_width")?.jsonObject?.get("url")?.jsonPrimitive?.content, + ) + .firstOrNull { candidate -> + candidate.contains(".gif", ignoreCase = true) + } .orEmpty() if (id.isBlank() || url.isBlank()) return@mapNotNull null RemotePickerItem( @@ -2654,126 +2872,208 @@ private fun AudioAttachmentPlayer( Column( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) - .padding(8.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(12.dp)) + .padding(horizontal = 8.dp, vertical = 7.dp), ) { Row( verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), ) { - Button( - onClick = { - if (!isPrepared) return@Button - if (isPlaying) { - mediaPlayer.pause() - isPlaying = false - AppAudioFocusCoordinator.release("player:$url") - emitTopStrip(false) - } else { - if (durationMs > 0 && positionMs >= durationMs - 200) { - runCatching { mediaPlayer.seekTo(0) } - positionMs = 0 + Surface( + shape = CircleShape, + color = MaterialTheme.colorScheme.primary.copy(alpha = if (isPrepared) 1f else 0.45f), + modifier = Modifier + .size(34.dp) + .clip(CircleShape) + .clickable(enabled = isPrepared) { + if (!isPrepared) return@clickable + if (isPlaying) { + mediaPlayer.pause() + isPlaying = false + AppAudioFocusCoordinator.release("player:$url") + emitTopStrip(false) + } else { + if (durationMs > 0 && positionMs >= durationMs - 200) { + runCatching { mediaPlayer.seekTo(0) } + positionMs = 0 + } + runCatching { + mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex]) + } + AppAudioFocusCoordinator.request("player:$url") + mediaPlayer.start() + isPlaying = true + emitTopStrip(true) } - runCatching { - mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex]) - } - AppAudioFocusCoordinator.request("player:$url") - mediaPlayer.start() - isPlaying = true - emitTopStrip(true) - } - }, - enabled = isPrepared, + }, ) { - Text(if (isPlaying) "Pause" else "Play") - } - Text( - text = if (isVoice) "Voice" else "Audio", - style = MaterialTheme.typography.labelSmall, - ) - Button( - onClick = { - speedIndex = (speedIndex + 1) % speedOptions.size - if (isPrepared) { - runCatching { - mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex]) - } - } - emitTopStrip(isPlaying) - }, - enabled = isPrepared, - ) { - Text(formatAudioSpeed(speedOptions[speedIndex])) - } - } - if (isVoice) { - VoiceWaveform( - waveform = waveform, - progress = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f), - ) - } - val progress = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) - Slider( - value = if (isSeeking) seekFraction else progress, - onValueChange = { fraction -> - isSeeking = true - seekFraction = fraction.coerceIn(0f, 1f) - }, - onValueChangeFinished = { - if (durationMs > 0) { - val targetMs = (durationMs * seekFraction).roundToInt() - runCatching { mediaPlayer.seekTo(targetMs) } - positionMs = targetMs + Box(contentAlignment = Alignment.Center) { + Icon( + imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow, + contentDescription = if (isPlaying) "Pause" else "Play", + tint = MaterialTheme.colorScheme.onPrimary, + modifier = Modifier.size(18.dp), + ) } - isSeeking = false - }, - enabled = isPrepared && durationMs > 0, - modifier = Modifier.fillMaxWidth(), - ) - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text(text = formatDuration(positionMs), style = MaterialTheme.typography.labelSmall) - Text( - text = if (durationMs > 0) formatDuration(durationMs) else "--:--", - style = MaterialTheme.typography.labelSmall, + } + val progress = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f) + WaveformSeekBar( + waveform = waveform, + progress = if (isSeeking) seekFraction else progress, + isPlaying = isPlaying, + seed = url, + enabled = isPrepared && durationMs > 0, + modifier = Modifier + .weight(1f) + .height(26.dp), + onSeek = { fraction -> + isSeeking = true + seekFraction = fraction.coerceIn(0f, 1f) + }, + onSeekFinished = { + if (durationMs > 0) { + val targetMs = (durationMs * seekFraction).roundToInt() + runCatching { mediaPlayer.seekTo(targetMs) } + positionMs = targetMs + } + isSeeking = false + }, ) + Text( + text = if (durationMs > 0) { + val displayPosition = if (isSeeking) (durationMs * seekFraction).roundToInt() else positionMs + formatDuration((durationMs - displayPosition).coerceAtLeast(0)) + } else { + "0:00" + }, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurface, + fontWeight = FontWeight.SemiBold, + ) + Surface( + shape = RoundedCornerShape(999.dp), + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.65f), + modifier = Modifier + .clip(RoundedCornerShape(999.dp)) + .clickable(enabled = isPrepared) { + speedIndex = (speedIndex + 1) % speedOptions.size + if (isPrepared) { + runCatching { + mediaPlayer.playbackParams = mediaPlayer.playbackParams.setSpeed(speedOptions[speedIndex]) + } + } + emitTopStrip(isPlaying) + }, + ) { + Text( + text = formatAudioSpeed(speedOptions[speedIndex]), + modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } @Composable -private fun VoiceWaveform( +private fun WaveformSeekBar( waveform: List?, progress: Float, + isPlaying: Boolean, + seed: String, + enabled: Boolean, + modifier: Modifier = Modifier, + onSeek: (Float) -> Unit, + onSeekFinished: () -> Unit, ) { - val source = waveform?.takeIf { it.isNotEmpty() } ?: List(28) { index -> - ((index % 7) + 1) * 6 - } - val playedBars = (source.size * progress).roundToInt().coerceIn(0, source.size) - Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.fillMaxWidth()) { - source.forEachIndexed { index, value -> - val normalized = (value.coerceAtLeast(4).coerceAtMost(100)).toFloat() - val barHeight = (12f + normalized / 4f).dp - Box( - modifier = Modifier - .weight(1f) - .height(barHeight) - .clip(RoundedCornerShape(4.dp)) - .background( - if (index < playedBars) { - MaterialTheme.colorScheme.primary - } else { - MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f) + val source = waveform?.takeIf { it.size >= 8 } ?: buildFallbackWaveform(seed = seed, bars = 56) + val playedColor = MaterialTheme.colorScheme.primary + val idleColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.28f) + val transition = rememberInfiniteTransition(label = "waveformPulse") + val pulse by transition.animateFloat( + initialValue = 0.65f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 650, easing = LinearEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "waveformPulseValue", + ) + var widthPx by remember { mutableStateOf(0f) } + val normalizedProgress = progress.coerceIn(0f, 1f) + BoxWithConstraints( + modifier = modifier + .onSizeChanged { widthPx = it.width.toFloat() } + .clip(RoundedCornerShape(8.dp)) + .pointerInput(enabled, widthPx) { + if (!enabled) return@pointerInput + awaitEachGesture { + val down = awaitFirstDown(requireUnconsumed = false) + down.consume() + val startFraction = if (widthPx <= 0f) 0f else (down.position.x / widthPx).coerceIn(0f, 1f) + onSeek(startFraction) + while (true) { + val event = awaitPointerEvent() + val change = event.changes.first() + val fraction = if (widthPx <= 0f) 0f else (change.position.x / widthPx).coerceIn(0f, 1f) + onSeek(fraction) + change.consume() + if (!change.pressed) { + onSeekFinished() + break } - ), - ) + } + } + }, + ) { + Canvas(modifier = Modifier.fillMaxSize()) { + val count = source.size.coerceAtLeast(1) + val gap = 2.dp.toPx() + val totalGap = gap * (count - 1) + val barWidth = ((size.width - totalGap) / count).coerceAtLeast(1f) + val progressBars = (normalizedProgress * count).roundToInt().coerceIn(0, count) + val activeHead = (progressBars - 1).coerceAtLeast(0) + source.forEachIndexed { index, value -> + val normalized = value.coerceIn(4, 100) / 100f + val minHeight = size.height * 0.26f + val maxHeight = size.height * 0.95f + val barHeight = minHeight + (maxHeight - minHeight) * normalized + val left = index * (barWidth + gap) + val top = (size.height - barHeight) / 2f + val isPlayed = index < progressBars + val baseColor = if (isPlayed) playedColor else idleColor + val color = if (isPlaying && isPlayed && index == activeHead) { + baseColor.copy(alpha = pulse) + } else { + baseColor + } + drawLine( + color = color, + start = Offset(left + barWidth / 2f, top), + end = Offset(left + barWidth / 2f, top + barHeight), + strokeWidth = barWidth, + cap = StrokeCap.Round, + ) + } } } } +private fun buildFallbackWaveform(seed: String, bars: Int = 56): List { + var hash = 0 + seed.forEach { ch -> + hash = (hash * 31 + ch.code) + } + var value = if (hash == 0) 1 else hash + return List(bars.coerceAtLeast(12)) { + value = value xor (value shl 13) + value = value xor (value ushr 17) + value = value xor (value shl 5) + kotlin.math.abs(value % 20) + 6 + } +} + @Composable private fun VoiceHoldToRecordButton( enabled: Boolean, @@ -2916,6 +3216,15 @@ private data class ChatInfoEntry( private fun ChatInfoTabContent( tab: ChatInfoTab, entries: List, + onAttachmentImageClick: (String) -> Unit, + onAttachmentVideoClick: (String) -> Unit, + onAudioPlaybackChanged: (TopAudioStrip) -> Unit, + forceStopAudioSourceId: String?, + forceToggleAudioSourceId: String?, + forceCycleSpeedAudioSourceId: String?, + onForceStopAudioSourceHandled: (String) -> Unit, + onForceToggleAudioSourceHandled: (String) -> Unit, + onForceCycleSpeedAudioSourceHandled: (String) -> Unit, onEntryClick: (ChatInfoEntry) -> Unit, ) { val filtered = remember(tab, entries) { @@ -3000,8 +3309,6 @@ private fun ChatInfoTabContent( return } if (tab == ChatInfoTab.Voice) { - var voiceTabActiveSourceId by remember(entries) { mutableStateOf(null) } - var voiceTabForceStopSourceId by remember(entries) { mutableStateOf(null) } LazyColumn( modifier = Modifier .fillMaxWidth() @@ -3058,30 +3365,13 @@ private fun ChatInfoTabContent( playbackTitle = entry.title, playbackSubtitle = entry.subtitle, messageId = entry.sourceMessageId ?: entry.title.hashCode().toLong(), - onPlaybackChanged = { playback -> - if (playback.isPlaying) { - val previous = voiceTabActiveSourceId - if (previous != null && previous != playback.sourceId) { - voiceTabForceStopSourceId = previous - } - voiceTabActiveSourceId = playback.sourceId - } else if (voiceTabActiveSourceId == playback.sourceId) { - voiceTabActiveSourceId = null - } - }, - forceStopAudioSourceId = voiceTabForceStopSourceId, - forceToggleAudioSourceId = null, - forceCycleSpeedAudioSourceId = null, - onForceStopAudioSourceHandled = { sourceId -> - if (voiceTabForceStopSourceId == sourceId) { - voiceTabForceStopSourceId = null - } - if (voiceTabActiveSourceId == sourceId) { - voiceTabActiveSourceId = null - } - }, - onForceToggleAudioSourceHandled = {}, - onForceCycleSpeedAudioSourceHandled = {}, + onPlaybackChanged = onAudioPlaybackChanged, + forceStopAudioSourceId = forceStopAudioSourceId, + forceToggleAudioSourceId = forceToggleAudioSourceId, + forceCycleSpeedAudioSourceId = forceCycleSpeedAudioSourceId, + onForceStopAudioSourceHandled = onForceStopAudioSourceHandled, + onForceToggleAudioSourceHandled = onForceToggleAudioSourceHandled, + onForceCycleSpeedAudioSourceHandled = onForceCycleSpeedAudioSourceHandled, ) } } @@ -3172,6 +3462,8 @@ private fun buildChatInfoEntries(messages: List): List val normalized = attachment.fileType.lowercase(Locale.getDefault()) + val skipFromInfo = normalized.contains("gif") || normalized.contains("webp") + if (skipFromInfo) return@forEach when { normalized.startsWith("image/") || normalized.startsWith("video/") -> { entries += ChatInfoEntry( @@ -3207,6 +3499,7 @@ private fun buildChatInfoEntries(messages: List): List + if (isGifLikeUrl(match.value) || isStickerLikeUrl(match.value)) return@forEach entries += ChatInfoEntry( type = ChatInfoEntryType.Link, title = match.value, @@ -3219,6 +3512,47 @@ private fun buildChatInfoEntries(messages: List): List "GIF" + normalizedType.contains("webp") || isStickerLikeUrl(url) -> "Sticker" + else -> null + } +} + +@Composable +private fun MediaTypeBadge( + label: String, + modifier: Modifier = Modifier, +) { + Surface( + shape = RoundedCornerShape(8.dp), + color = Color.Black.copy(alpha = 0.62f), + modifier = modifier, + ) { + Text( + text = label, + style = MaterialTheme.typography.labelSmall, + color = Color.White, + modifier = Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + fontWeight = FontWeight.SemiBold, + ) + } +} + private data class TopAudioStrip( val messageId: Long, val sourceId: String, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt index 4fadca0..6d894ef 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/ChatViewModel.kt @@ -29,6 +29,7 @@ import ru.daemonlord.messenger.domain.message.usecase.LoadMoreMessagesUseCase import ru.daemonlord.messenger.domain.message.usecase.MarkMessageDeliveredUseCase import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase +import ru.daemonlord.messenger.domain.message.usecase.SendImageUrlMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.SendMediaMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase @@ -46,6 +47,7 @@ class ChatViewModel @Inject constructor( private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase, private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase, private val sendTextMessageUseCase: SendTextMessageUseCase, + private val sendImageUrlMessageUseCase: SendImageUrlMessageUseCase, private val sendMediaMessageUseCase: SendMediaMessageUseCase, private val editMessageUseCase: EditMessageUseCase, private val deleteMessageUseCase: DeleteMessageUseCase, @@ -541,6 +543,37 @@ class ChatViewModel @Inject constructor( } } + fun onSendPresetMediaUrl(url: String) { + val normalizedUrl = url.trim() + if (normalizedUrl.isBlank()) return + viewModelScope.launch { + _uiState.update { it.copy(isUploadingMedia = true, errorMessage = null) } + when ( + val result = sendImageUrlMessageUseCase( + chatId = chatId, + imageUrl = normalizedUrl, + replyToMessageId = uiState.value.replyToMessage?.id, + ) + ) { + is AppResult.Success -> _uiState.update { + it.copy( + isUploadingMedia = false, + inputText = "", + replyToMessage = null, + editingMessage = null, + ) + } + + is AppResult.Error -> _uiState.update { + it.copy( + isUploadingMedia = false, + errorMessage = result.reason.toUiMessage(), + ) + } + } + } + } + fun loadMore() { val oldest = uiState.value.messages.firstOrNull() ?: return viewModelScope.launch { @@ -624,6 +657,7 @@ class ChatViewModel @Inject constructor( chatTitle = chatTitle, chatSubtitle = chatSubtitle, chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl, + chatUnreadCount = chat.unreadCount.coerceAtLeast(0), canSendMessages = canSend, sendRestrictionText = restriction, pinnedMessageId = chat.pinnedMessageId, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt index 6edd81e..39daa52 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chat/MessageUiState.kt @@ -14,6 +14,7 @@ data class MessageUiState( val chatSubtitle: String = "", val chatAvatarUrl: String? = null, val chatType: String = "", + val chatUnreadCount: Int = 0, val messages: List = emptyList(), val pinnedMessageId: Long? = null, val pinnedMessage: MessageItem? = null, diff --git a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt index 91adc2e..f6e3081 100644 --- a/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt +++ b/android/app/src/main/java/ru/daemonlord/messenger/ui/chats/ChatListScreen.kt @@ -1505,6 +1505,10 @@ private fun CenterState( private fun ChatItem.previewText(): String { val raw = lastMessageText.orEmpty().trim() + val isGifImage = lastMessageType == "image" && isGifLikeUrl(raw) + val isStickerImage = lastMessageType == "image" && isStickerLikeUrl(raw) + if (isGifImage) return "🖼 GIF" + if (isStickerImage) return "🖼 Sticker" val prefix = when (lastMessageType) { "image" -> "🖼" "video" -> "🎥" @@ -1537,3 +1541,15 @@ private fun ChatItem.previewText(): String { else -> "Media" } } + +private fun isGifLikeUrl(url: String): Boolean { + if (url.isBlank()) return false + val normalized = url.lowercase() + return normalized.contains(".gif") || normalized.contains("giphy.com") +} + +private fun isStickerLikeUrl(url: String): Boolean { + if (url.isBlank()) return false + val normalized = url.lowercase() + return normalized.contains("twemoji") || normalized.endsWith(".webp") +}