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
|
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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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.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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user