Android chat UX: gif/url media parity, waveform seekbar, unread auto-scroll
Some checks failed
Android CI / android (push) Failing after 6m42s
Android Release / release (push) Failing after 6m12s
CI / test (push) Failing after 2m53s

This commit is contained in:
2026-03-10 22:04:08 +03:00
parent 3c9b97e102
commit a4fd60919e
8 changed files with 722 additions and 237 deletions

View File

@@ -1,8 +1,11 @@
package ru.daemonlord.messenger package ru.daemonlord.messenger
import android.app.Application import android.app.Application
import android.os.Build
import coil.ImageLoader import coil.ImageLoader
import coil.ImageLoaderFactory import coil.ImageLoaderFactory
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import coil.disk.DiskCache import coil.disk.DiskCache
import coil.memory.MemoryCache import coil.memory.MemoryCache
import com.google.firebase.crashlytics.FirebaseCrashlytics import com.google.firebase.crashlytics.FirebaseCrashlytics
@@ -35,6 +38,13 @@ class MessengerApplication : Application(), ImageLoaderFactory {
diskCacheDir.mkdirs() diskCacheDir.mkdirs()
} }
return ImageLoader.Builder(this) return ImageLoader.Builder(this)
.components {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
}
.memoryCache { .memoryCache {
MemoryCache.Builder(this) MemoryCache.Builder(this)
.maxSizePercent(0.25) .maxSizePercent(0.25)

View File

@@ -395,6 +395,70 @@ class NetworkMessageRepository @Inject constructor(
} }
} }
override suspend fun sendImageUrlMessage(
chatId: Long,
imageUrl: String,
replyToMessageId: Long?,
): AppResult<Unit> = 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<Unit> = withContext(ioDispatcher) { override suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit> = withContext(ioDispatcher) {
try { try {
messageApiService.updateMessageStatus( messageApiService.updateMessageStatus(

View File

@@ -20,6 +20,11 @@ interface MessageRepository {
caption: String? = null, caption: String? = null,
replyToMessageId: Long? = null, replyToMessageId: Long? = null,
): AppResult<Unit> ): AppResult<Unit>
suspend fun sendImageUrlMessage(
chatId: Long,
imageUrl: String,
replyToMessageId: Long? = null,
): AppResult<Unit>
suspend fun editMessage(messageId: Long, newText: String): AppResult<Unit> suspend fun editMessage(messageId: Long, newText: String): AppResult<Unit>
suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit> suspend fun deleteMessage(messageId: Long, forAll: Boolean): AppResult<Unit>
suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit> suspend fun markMessageDelivered(chatId: Long, messageId: Long): AppResult<Unit>

View File

@@ -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<Unit> {
return messageRepository.sendImageUrlMessage(
chatId = chatId,
imageUrl = imageUrl,
replyToMessageId = replyToMessageId,
)
}
}

View File

@@ -36,6 +36,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.layout.aspectRatio import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
@@ -67,6 +68,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue 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.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope 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.text.input.TextFieldValue
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.layout.onSizeChanged
import androidx.hilt.navigation.compose.hiltViewModel import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -230,6 +242,7 @@ fun ChatRoute(
bytes = bytes, bytes = bytes,
) )
}, },
onSendPresetMediaUrl = viewModel::onSendPresetMediaUrl,
onVoiceRecordStart = { onVoiceRecordStart = {
if (!hasAudioPermission) { if (!hasAudioPermission) {
startRecordingAfterPermissionGrant = true startRecordingAfterPermissionGrant = true
@@ -290,6 +303,7 @@ fun ChatScreen(
onLoadMore: () -> Unit, onLoadMore: () -> Unit,
onPickMedia: () -> Unit, onPickMedia: () -> Unit,
onSendRemoteMedia: (String, String, ByteArray) -> Unit, onSendRemoteMedia: (String, String, ByteArray) -> Unit,
onSendPresetMediaUrl: (String) -> Unit,
onVoiceRecordStart: () -> Unit, onVoiceRecordStart: () -> Unit,
onVoiceRecordTick: () -> Unit, onVoiceRecordTick: () -> Unit,
onVoiceRecordLock: () -> Unit, onVoiceRecordLock: () -> Unit,
@@ -304,14 +318,34 @@ fun ChatScreen(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val allImageUrls = remember(state.messages) { val allImageUrls = remember(state.messages) {
state.messages state.messages
.flatMap { message -> message.attachments } .flatMap { message ->
.map { it.fileUrl to it.fileType.lowercase() } val urls = message.attachments
.filter { (_, type) -> type.startsWith("image/") } .map { it.fileUrl to it.fileType.lowercase() }
.map { (url, _) -> url } .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() .distinct()
} }
val isChannelChat = state.chatType.equals("channel", ignoreCase = true) val isChannelChat = state.chatType.equals("channel", ignoreCase = true)
val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) } val timelineItems = remember(state.messages) { buildChatTimelineItems(state.messages) }
var didInitialAutoScroll by remember(state.chatId) { mutableStateOf(false) }
var viewerImageIndex by remember { mutableStateOf<Int?>(null) } var viewerImageIndex by remember { mutableStateOf<Int?>(null) }
var viewerVideoUrl by remember { mutableStateOf<String?>(null) } var viewerVideoUrl by remember { mutableStateOf<String?>(null) }
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) } var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(null) }
@@ -341,6 +375,24 @@ fun ChatScreen(
val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp val adaptiveHorizontalPadding = if (isTabletLayout) 72.dp else 0.dp
val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) } val chatInfoEntries = remember(state.messages) { buildChatInfoEntries(state.messages) }
val giphyApiKey = remember { BuildConfig.GIPHY_API_KEY.trim() } 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) { LaunchedEffect(state.isRecordingVoice) {
if (!state.isRecordingVoice) return@LaunchedEffect if (!state.isRecordingVoice) return@LaunchedEffect
@@ -358,6 +410,20 @@ fun ChatScreen(
listState.animateScrollToItem(index = index) 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) { LaunchedEffect(state.inputText) {
if (state.inputText != composerValue.text) { if (state.inputText != composerValue.text) {
val clampedCursor = composerValue.selection.end.coerceAtMost(state.inputText.length) val clampedCursor = composerValue.selection.end.coerceAtMost(state.inputText.length)
@@ -724,92 +790,121 @@ fun ChatScreen(
} }
else -> { else -> {
LazyColumn( Box(
state = listState,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .fillMaxWidth(),
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
items( LazyColumn(
items = timelineItems, state = listState,
key = { item -> modifier = Modifier
when (item) { .fillMaxSize()
is ChatTimelineItem.DayHeader -> "day:${item.headerId}" .padding(horizontal = 12.dp, vertical = 8.dp),
is ChatTimelineItem.MessageEntry -> "msg:${item.message.id}" 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
} }
}, val message = (item as ChatTimelineItem.MessageEntry).message
) { item -> val isSelected = state.actionState.selectedMessageIds.contains(message.id)
if (item is ChatTimelineItem.DayHeader) { MessageBubble(
DaySeparatorChip(label = item.label) message = message,
return@items isChannelChat = isChannelChat,
} isSelected = isSelected,
val message = (item as ChatTimelineItem.MessageEntry).message isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI,
val isSelected = state.actionState.selectedMessageIds.contains(message.id) isInlineHighlighted = state.highlightedMessageId == message.id,
MessageBubble( reactions = state.reactionByMessageId[message.id].orEmpty(),
message = message, onAttachmentImageClick = { imageUrl ->
isChannelChat = isChannelChat, val idx = allImageUrls.indexOf(imageUrl)
isSelected = isSelected, viewerImageIndex = if (idx >= 0) idx else null
isMultiSelecting = state.actionState.mode == MessageSelectionMode.MULTI, },
isInlineHighlighted = state.highlightedMessageId == message.id, onAttachmentVideoClick = { videoUrl ->
reactions = state.reactionByMessageId[message.id].orEmpty(), viewerVideoUrl = videoUrl
onAttachmentImageClick = { imageUrl -> },
val idx = allImageUrls.indexOf(imageUrl) onAudioPlaybackChanged = { playback ->
viewerImageIndex = if (idx >= 0) idx else null if (playback.isPlaying) {
}, val previous = topAudioStrip?.sourceId
onAttachmentVideoClick = { videoUrl -> if (!previous.isNullOrBlank() && previous != playback.sourceId) {
viewerVideoUrl = videoUrl forceStopAudioSourceId = previous
}, }
onAudioPlaybackChanged = { playback -> topAudioStrip = playback
if (playback.isPlaying) { if (forceStopAudioSourceId == playback.sourceId) {
val previous = topAudioStrip?.sourceId forceStopAudioSourceId = null
if (!previous.isNullOrBlank() && previous != playback.sourceId) { }
forceStopAudioSourceId = previous } 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 forceStopAudioSourceId = null
} }
} else if (topAudioStrip?.sourceId == playback.sourceId) { },
topAudioStrip = null onForceToggleAudioSourceHandled = { sourceId ->
} if (forceToggleAudioSourceId == sourceId) {
}, forceToggleAudioSourceId = null
forceStopAudioSourceId = forceStopAudioSourceId, }
forceToggleAudioSourceId = forceToggleAudioSourceId, },
forceCycleSpeedAudioSourceId = forceCycleSpeedAudioSourceId, onForceCycleSpeedAudioSourceHandled = { sourceId ->
onForceStopAudioSourceHandled = { sourceId -> if (forceCycleSpeedAudioSourceId == sourceId) {
if (forceStopAudioSourceId == sourceId) { forceCycleSpeedAudioSourceId = null
forceStopAudioSourceId = null }
} },
}, onClick = {
onForceToggleAudioSourceHandled = { sourceId -> if (state.actionState.mode == MessageSelectionMode.MULTI) {
if (forceToggleAudioSourceId == sourceId) { onToggleMessageMultiSelection(message)
forceToggleAudioSourceId = null } else {
} onSelectMessage(message)
}, actionMenuMessage = message
onForceCycleSpeedAudioSourceHandled = { sourceId -> }
if (forceCycleSpeedAudioSourceId == sourceId) { },
forceCycleSpeedAudioSourceId = null onLongPress = {
} if (state.actionState.mode == MessageSelectionMode.MULTI) {
}, onToggleMessageMultiSelection(message)
onClick = { } else {
if (state.actionState.mode == MessageSelectionMode.MULTI) { actionMenuMessage = null
onToggleMessageMultiSelection(message) onEnterMultiSelect(message)
} else { }
onSelectMessage(message) },
actionMenuMessage = message )
} }
}, }
onLongPress = { if (shouldShowScrollToBottomButton && timelineItems.isNotEmpty()) {
if (state.actionState.mode == MessageSelectionMode.MULTI) { Surface(
onToggleMessageMultiSelection(message) shape = CircleShape,
} else { color = MaterialTheme.colorScheme.primary,
actionMenuMessage = null modifier = Modifier
onEnterMultiSelect(message) .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( ChatInfoTabContent(
tab = chatInfoTab, tab = chatInfoTab,
entries = chatInfoEntries, 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 -> onEntryClick = { entry ->
when (entry.type) { when (entry.type) {
ChatInfoEntryType.Media -> { ChatInfoEntryType.Media -> {
@@ -1729,16 +1863,17 @@ fun ChatScreen(
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.clickable(enabled = !isPickerSending) { .clickable(enabled = !isPickerSending) {
scope.launch { scope.launch {
isPickerSending = true isPickerSending = true
val payload = downloadRemoteMedia(remote.url, remote.fileNamePrefix) if (emojiPickerTab == ComposerPickerTab.Gif || emojiPickerTab == ComposerPickerTab.Sticker) {
if (payload != null) { onSendPresetMediaUrl(remote.url)
val stickerAdjustedName = if (emojiPickerTab == ComposerPickerTab.Sticker) {
"sticker_${payload.fileName}"
} else {
payload.fileName
}
onSendRemoteMedia(stickerAdjustedName, payload.mimeType, payload.bytes)
showEmojiPicker = false 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 isPickerSending = false
} }
@@ -1940,7 +2075,16 @@ private fun MessageBubble(
} }
val mainText = message.text?.takeIf { it.isNotBlank() } 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( FormattedMessageText(
text = mainText ?: "[${message.type}]", text = mainText ?: "[${message.type}]",
style = MaterialTheme.typography.bodyLarge, 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()) { if (reactions.isNotEmpty()) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
reactions.forEach { reaction -> reactions.forEach { reaction ->
@@ -1973,16 +2157,32 @@ private fun MessageBubble(
if (imageAttachments.isNotEmpty()) { if (imageAttachments.isNotEmpty()) {
if (imageAttachments.size == 1) { if (imageAttachments.size == 1) {
val single = imageAttachments.first() val single = imageAttachments.first()
AsyncImage( val badgeLabel = mediaBadgeLabel(fileType = single.fileType, url = single.fileUrl)
model = single.fileUrl, val openable = badgeLabel == null
contentDescription = "Image", Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.height(188.dp) .height(188.dp)
.clip(RoundedCornerShape(12.dp)) .clip(RoundedCornerShape(12.dp))
.clickable { onAttachmentImageClick(single.fileUrl) }, .let { base ->
contentScale = ContentScale.Crop, 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 { } else {
imageAttachments.chunked(2).forEach { rowItems -> imageAttachments.chunked(2).forEach { rowItems ->
Row( Row(
@@ -1990,16 +2190,32 @@ private fun MessageBubble(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp),
) { ) {
rowItems.forEach { image -> rowItems.forEach { image ->
AsyncImage( val badgeLabel = mediaBadgeLabel(fileType = image.fileType, url = image.fileUrl)
model = image.fileUrl, val openable = badgeLabel == null
contentDescription = "Image", Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.height(112.dp) .height(112.dp)
.clip(RoundedCornerShape(10.dp)) .clip(RoundedCornerShape(10.dp))
.clickable { onAttachmentImageClick(image.fileUrl) }, .let { base ->
contentScale = ContentScale.Crop, 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)) if (rowItems.size == 1) Spacer(modifier = Modifier.weight(1f))
} }
@@ -2449,13 +2665,15 @@ private suspend fun fetchGiphySearchItems(
data.mapNotNull { node -> data.mapNotNull { node ->
val obj = node.jsonObject val obj = node.jsonObject
val id = obj["id"]?.jsonPrimitive?.content.orEmpty() val id = obj["id"]?.jsonPrimitive?.content.orEmpty()
val url = obj["images"] val images = obj["images"]?.jsonObject
?.jsonObject val url = listOfNotNull(
?.get("fixed_width") images?.get("downsized")?.jsonObject?.get("url")?.jsonPrimitive?.content,
?.jsonObject images?.get("original")?.jsonObject?.get("url")?.jsonPrimitive?.content,
?.get("url") images?.get("fixed_width")?.jsonObject?.get("url")?.jsonPrimitive?.content,
?.jsonPrimitive )
?.content .firstOrNull { candidate ->
candidate.contains(".gif", ignoreCase = true)
}
.orEmpty() .orEmpty()
if (id.isBlank() || url.isBlank()) return@mapNotNull null if (id.isBlank() || url.isBlank()) return@mapNotNull null
RemotePickerItem( RemotePickerItem(
@@ -2654,126 +2872,208 @@ private fun AudioAttachmentPlayer(
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(10.dp)) .background(MaterialTheme.colorScheme.surface.copy(alpha = 0.45f), RoundedCornerShape(12.dp))
.padding(8.dp), .padding(horizontal = 8.dp, vertical = 7.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
) { ) {
Button( Surface(
onClick = { shape = CircleShape,
if (!isPrepared) return@Button color = MaterialTheme.colorScheme.primary.copy(alpha = if (isPrepared) 1f else 0.45f),
if (isPlaying) { modifier = Modifier
mediaPlayer.pause() .size(34.dp)
isPlaying = false .clip(CircleShape)
AppAudioFocusCoordinator.release("player:$url") .clickable(enabled = isPrepared) {
emitTopStrip(false) if (!isPrepared) return@clickable
} else { if (isPlaying) {
if (durationMs > 0 && positionMs >= durationMs - 200) { mediaPlayer.pause()
runCatching { mediaPlayer.seekTo(0) } isPlaying = false
positionMs = 0 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") Box(contentAlignment = Alignment.Center) {
} Icon(
Text( imageVector = if (isPlaying) Icons.Filled.Pause else Icons.Filled.PlayArrow,
text = if (isVoice) "Voice" else "Audio", contentDescription = if (isPlaying) "Pause" else "Play",
style = MaterialTheme.typography.labelSmall, tint = MaterialTheme.colorScheme.onPrimary,
) modifier = Modifier.size(18.dp),
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
} }
isSeeking = false }
}, val progress = if (durationMs <= 0) 0f else (positionMs.toFloat() / durationMs.toFloat()).coerceIn(0f, 1f)
enabled = isPrepared && durationMs > 0, WaveformSeekBar(
modifier = Modifier.fillMaxWidth(), waveform = waveform,
) progress = if (isSeeking) seekFraction else progress,
Row( isPlaying = isPlaying,
modifier = Modifier.fillMaxWidth(), seed = url,
horizontalArrangement = Arrangement.SpaceBetween, enabled = isPrepared && durationMs > 0,
) { modifier = Modifier
Text(text = formatDuration(positionMs), style = MaterialTheme.typography.labelSmall) .weight(1f)
Text( .height(26.dp),
text = if (durationMs > 0) formatDuration(durationMs) else "--:--", onSeek = { fraction ->
style = MaterialTheme.typography.labelSmall, 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 @Composable
private fun VoiceWaveform( private fun WaveformSeekBar(
waveform: List<Int>?, waveform: List<Int>?,
progress: Float, 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 -> val source = waveform?.takeIf { it.size >= 8 } ?: buildFallbackWaveform(seed = seed, bars = 56)
((index % 7) + 1) * 6 val playedColor = MaterialTheme.colorScheme.primary
} val idleColor = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.28f)
val playedBars = (source.size * progress).roundToInt().coerceIn(0, source.size) val transition = rememberInfiniteTransition(label = "waveformPulse")
Row(horizontalArrangement = Arrangement.spacedBy(2.dp), modifier = Modifier.fillMaxWidth()) { val pulse by transition.animateFloat(
source.forEachIndexed { index, value -> initialValue = 0.65f,
val normalized = (value.coerceAtLeast(4).coerceAtMost(100)).toFloat() targetValue = 1f,
val barHeight = (12f + normalized / 4f).dp animationSpec = infiniteRepeatable(
Box( animation = tween(durationMillis = 650, easing = LinearEasing),
modifier = Modifier repeatMode = RepeatMode.Reverse,
.weight(1f) ),
.height(barHeight) label = "waveformPulseValue",
.clip(RoundedCornerShape(4.dp)) )
.background( var widthPx by remember { mutableStateOf(0f) }
if (index < playedBars) { val normalizedProgress = progress.coerceIn(0f, 1f)
MaterialTheme.colorScheme.primary BoxWithConstraints(
} else { modifier = modifier
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.25f) .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<Int> {
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 @Composable
private fun VoiceHoldToRecordButton( private fun VoiceHoldToRecordButton(
enabled: Boolean, enabled: Boolean,
@@ -2916,6 +3216,15 @@ private data class ChatInfoEntry(
private fun ChatInfoTabContent( private fun ChatInfoTabContent(
tab: ChatInfoTab, tab: ChatInfoTab,
entries: List<ChatInfoEntry>, entries: List<ChatInfoEntry>,
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, onEntryClick: (ChatInfoEntry) -> Unit,
) { ) {
val filtered = remember(tab, entries) { val filtered = remember(tab, entries) {
@@ -3000,8 +3309,6 @@ private fun ChatInfoTabContent(
return return
} }
if (tab == ChatInfoTab.Voice) { if (tab == ChatInfoTab.Voice) {
var voiceTabActiveSourceId by remember(entries) { mutableStateOf<String?>(null) }
var voiceTabForceStopSourceId by remember(entries) { mutableStateOf<String?>(null) }
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -3058,30 +3365,13 @@ private fun ChatInfoTabContent(
playbackTitle = entry.title, playbackTitle = entry.title,
playbackSubtitle = entry.subtitle, playbackSubtitle = entry.subtitle,
messageId = entry.sourceMessageId ?: entry.title.hashCode().toLong(), messageId = entry.sourceMessageId ?: entry.title.hashCode().toLong(),
onPlaybackChanged = { playback -> onPlaybackChanged = onAudioPlaybackChanged,
if (playback.isPlaying) { forceStopAudioSourceId = forceStopAudioSourceId,
val previous = voiceTabActiveSourceId forceToggleAudioSourceId = forceToggleAudioSourceId,
if (previous != null && previous != playback.sourceId) { forceCycleSpeedAudioSourceId = forceCycleSpeedAudioSourceId,
voiceTabForceStopSourceId = previous onForceStopAudioSourceHandled = onForceStopAudioSourceHandled,
} onForceToggleAudioSourceHandled = onForceToggleAudioSourceHandled,
voiceTabActiveSourceId = playback.sourceId onForceCycleSpeedAudioSourceHandled = onForceCycleSpeedAudioSourceHandled,
} 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 = {},
) )
} }
} }
@@ -3172,6 +3462,8 @@ private fun buildChatInfoEntries(messages: List<MessageItem>): List<ChatInfoEntr
val time = formatMessageTime(message.createdAt) val time = formatMessageTime(message.createdAt)
message.attachments.forEach { attachment -> message.attachments.forEach { attachment ->
val normalized = attachment.fileType.lowercase(Locale.getDefault()) val normalized = attachment.fileType.lowercase(Locale.getDefault())
val skipFromInfo = normalized.contains("gif") || normalized.contains("webp")
if (skipFromInfo) return@forEach
when { when {
normalized.startsWith("image/") || normalized.startsWith("video/") -> { normalized.startsWith("image/") || normalized.startsWith("video/") -> {
entries += ChatInfoEntry( entries += ChatInfoEntry(
@@ -3207,6 +3499,7 @@ private fun buildChatInfoEntries(messages: List<MessageItem>): List<ChatInfoEntr
} }
} }
urlRegex.findAll(message.text.orEmpty()).forEach { match -> urlRegex.findAll(message.text.orEmpty()).forEach { match ->
if (isGifLikeUrl(match.value) || isStickerLikeUrl(match.value)) return@forEach
entries += ChatInfoEntry( entries += ChatInfoEntry(
type = ChatInfoEntryType.Link, type = ChatInfoEntryType.Link,
title = match.value, title = match.value,
@@ -3219,6 +3512,47 @@ private fun buildChatInfoEntries(messages: List<MessageItem>): List<ChatInfoEntr
return entries return entries
} }
private fun isGifLikeUrl(url: String): Boolean {
if (url.isBlank()) return false
val normalized = url.lowercase(Locale.getDefault())
return normalized.contains(".gif") || normalized.contains("giphy.com")
}
private fun isStickerLikeUrl(url: String): Boolean {
if (url.isBlank()) return false
val normalized = url.lowercase(Locale.getDefault())
return normalized.contains("twemoji") || normalized.endsWith(".webp")
}
private fun mediaBadgeLabel(fileType: String, url: String): String? {
val normalizedType = fileType.lowercase(Locale.getDefault())
return when {
normalizedType.contains("gif") || isGifLikeUrl(url) -> "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( private data class TopAudioStrip(
val messageId: Long, val messageId: Long,
val sourceId: String, val sourceId: String,

View File

@@ -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.MarkMessageDeliveredUseCase
import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase import ru.daemonlord.messenger.domain.message.usecase.MarkMessageReadUseCase
import ru.daemonlord.messenger.domain.message.usecase.ObserveMessagesUseCase 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.SendMediaMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase import ru.daemonlord.messenger.domain.message.usecase.SendTextMessageUseCase
import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase import ru.daemonlord.messenger.domain.message.usecase.SyncRecentMessagesUseCase
@@ -46,6 +47,7 @@ class ChatViewModel @Inject constructor(
private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase, private val syncRecentMessagesUseCase: SyncRecentMessagesUseCase,
private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase, private val loadMoreMessagesUseCase: LoadMoreMessagesUseCase,
private val sendTextMessageUseCase: SendTextMessageUseCase, private val sendTextMessageUseCase: SendTextMessageUseCase,
private val sendImageUrlMessageUseCase: SendImageUrlMessageUseCase,
private val sendMediaMessageUseCase: SendMediaMessageUseCase, private val sendMediaMessageUseCase: SendMediaMessageUseCase,
private val editMessageUseCase: EditMessageUseCase, private val editMessageUseCase: EditMessageUseCase,
private val deleteMessageUseCase: DeleteMessageUseCase, 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() { fun loadMore() {
val oldest = uiState.value.messages.firstOrNull() ?: return val oldest = uiState.value.messages.firstOrNull() ?: return
viewModelScope.launch { viewModelScope.launch {
@@ -624,6 +657,7 @@ class ChatViewModel @Inject constructor(
chatTitle = chatTitle, chatTitle = chatTitle,
chatSubtitle = chatSubtitle, chatSubtitle = chatSubtitle,
chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl, chatAvatarUrl = chat.avatarUrl ?: chat.counterpartAvatarUrl,
chatUnreadCount = chat.unreadCount.coerceAtLeast(0),
canSendMessages = canSend, canSendMessages = canSend,
sendRestrictionText = restriction, sendRestrictionText = restriction,
pinnedMessageId = chat.pinnedMessageId, pinnedMessageId = chat.pinnedMessageId,

View File

@@ -14,6 +14,7 @@ data class MessageUiState(
val chatSubtitle: String = "", val chatSubtitle: String = "",
val chatAvatarUrl: String? = null, val chatAvatarUrl: String? = null,
val chatType: String = "", val chatType: String = "",
val chatUnreadCount: Int = 0,
val messages: List<MessageItem> = emptyList(), val messages: List<MessageItem> = emptyList(),
val pinnedMessageId: Long? = null, val pinnedMessageId: Long? = null,
val pinnedMessage: MessageItem? = null, val pinnedMessage: MessageItem? = null,

View File

@@ -1505,6 +1505,10 @@ private fun CenterState(
private fun ChatItem.previewText(): String { private fun ChatItem.previewText(): String {
val raw = lastMessageText.orEmpty().trim() 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) { val prefix = when (lastMessageType) {
"image" -> "🖼" "image" -> "🖼"
"video" -> "🎥" "video" -> "🎥"
@@ -1537,3 +1541,15 @@ private fun ChatItem.previewText(): String {
else -> "Media" 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")
}