Android chat UX: gif/url media parity, waveform seekbar, unread auto-scroll
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user