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
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)

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) {
try {
messageApiService.updateMessageStatus(

View File

@@ -20,6 +20,11 @@ interface MessageRepository {
caption: String? = null,
replyToMessageId: Long? = null,
): 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 deleteMessage(messageId: Long, forAll: Boolean): 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.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<Int?>(null) }
var viewerVideoUrl by remember { mutableStateOf<String?>(null) }
var dismissedPinnedMessageId by remember { mutableStateOf<Long?>(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<Int>?,
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<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
private fun VoiceHoldToRecordButton(
enabled: Boolean,
@@ -2916,6 +3216,15 @@ private data class ChatInfoEntry(
private fun ChatInfoTabContent(
tab: ChatInfoTab,
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,
) {
val filtered = remember(tab, entries) {
@@ -3000,8 +3309,6 @@ private fun ChatInfoTabContent(
return
}
if (tab == ChatInfoTab.Voice) {
var voiceTabActiveSourceId by remember(entries) { mutableStateOf<String?>(null) }
var voiceTabForceStopSourceId by remember(entries) { mutableStateOf<String?>(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<MessageItem>): List<ChatInfoEntr
val time = formatMessageTime(message.createdAt)
message.attachments.forEach { attachment ->
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<MessageItem>): List<ChatInfoEntr
}
}
urlRegex.findAll(message.text.orEmpty()).forEach { match ->
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<MessageItem>): List<ChatInfoEntr
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(
val messageId: Long,
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.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,

View File

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

View File

@@ -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")
}