feat: implement caching for group invite info and enhance message bubble layout for group invites
This commit is contained in:
@@ -16,6 +16,7 @@ import com.rosetta.messenger.network.PacketGroupLeave
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import java.security.SecureRandom
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlin.coroutines.resume
|
||||
@@ -27,6 +28,8 @@ class GroupRepository private constructor(context: Context) {
|
||||
private val messageDao = db.messageDao()
|
||||
private val dialogDao = db.dialogDao()
|
||||
|
||||
private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>()
|
||||
|
||||
companion object {
|
||||
private const val GROUP_PREFIX = "#group:"
|
||||
private const val GROUP_INVITE_PASSWORD = "rosetta_group"
|
||||
@@ -159,6 +162,20 @@ class GroupRepository private constructor(context: Context) {
|
||||
return response.members
|
||||
}
|
||||
|
||||
fun getCachedInviteInfo(groupId: String): GroupInviteInfoResult? {
|
||||
val normalized = normalizeGroupId(groupId)
|
||||
return inviteInfoCache[normalized]
|
||||
}
|
||||
|
||||
fun cacheInviteInfo(groupId: String, status: GroupStatus, membersCount: Int) {
|
||||
val normalized = normalizeGroupId(groupId)
|
||||
inviteInfoCache[normalized] = GroupInviteInfoResult(
|
||||
groupId = normalized,
|
||||
membersCount = membersCount,
|
||||
status = status
|
||||
)
|
||||
}
|
||||
|
||||
suspend fun requestInviteInfo(groupPublicKeyOrId: String): GroupInviteInfoResult? {
|
||||
val groupId = normalizeGroupId(groupPublicKeyOrId)
|
||||
if (groupId.isBlank()) return null
|
||||
@@ -176,11 +193,13 @@ class GroupRepository private constructor(context: Context) {
|
||||
) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
|
||||
?: return null
|
||||
|
||||
return GroupInviteInfoResult(
|
||||
val result = GroupInviteInfoResult(
|
||||
groupId = groupId,
|
||||
membersCount = response.membersCount.coerceAtLeast(0),
|
||||
status = response.groupStatus
|
||||
)
|
||||
inviteInfoCache[groupId] = result
|
||||
return result
|
||||
}
|
||||
|
||||
suspend fun createGroup(
|
||||
|
||||
@@ -112,6 +112,9 @@ import com.rosetta.messenger.database.MessageEntity
|
||||
import com.rosetta.messenger.database.RosettaDatabase
|
||||
import com.rosetta.messenger.network.AttachmentType
|
||||
import com.rosetta.messenger.network.MessageAttachment
|
||||
import com.rosetta.messenger.network.OnlineState
|
||||
import com.rosetta.messenger.network.PacketOnlineState
|
||||
import com.rosetta.messenger.network.PacketOnlineSubscribe
|
||||
import com.rosetta.messenger.network.ProtocolManager
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.repository.AvatarRepository
|
||||
@@ -201,12 +204,14 @@ private data class GroupMediaItem(
|
||||
)
|
||||
|
||||
private val URL_REGEX = Regex("(?i)\\b((?:https?://|www\\.)[^\\s<>()]+)")
|
||||
private val KEY_IMAGE_COLORS = listOf(
|
||||
Color(0xFFD0EBFF),
|
||||
Color(0xFFA5D8FF),
|
||||
Color(0xFF74C0FC),
|
||||
Color(0xFF4DABF7),
|
||||
Color(0xFF339AF0)
|
||||
|
||||
// Identicon colors — same palette for both themes (like Telegram)
|
||||
// White stays white so the pattern is visible on dark backgrounds
|
||||
private val IDENTICON_COLORS = intArrayOf(
|
||||
0xFFFFFFFF.toInt(),
|
||||
0xFFD0E8FF.toInt(),
|
||||
0xFF228BE6.toInt(),
|
||||
0xFF1971C2.toInt()
|
||||
)
|
||||
|
||||
@Composable
|
||||
@@ -292,6 +297,8 @@ fun GroupInfoScreen(
|
||||
|
||||
var members by remember(dialogPublicKey) { mutableStateOf<List<String>>(emptyList()) }
|
||||
val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf<String, SearchUser>() }
|
||||
// Real online status from PacketOnlineState (0x05), NOT from SearchUser.online
|
||||
val memberOnlineStatus = remember(dialogPublicKey) { mutableStateMapOf<String, Boolean>() }
|
||||
val membersCacheKey = remember(currentUserPublicKey, normalizedGroupId) {
|
||||
"${currentUserPublicKey.trim().lowercase()}|${normalizedGroupId.trim().lowercase()}"
|
||||
}
|
||||
@@ -456,9 +463,47 @@ fun GroupInfoScreen(
|
||||
}
|
||||
}
|
||||
|
||||
// 🟢 Subscribe to online status for group members via PacketOnlineSubscribe / PacketOnlineState
|
||||
// Desktop parity: users start as OFFLINE, only become ONLINE when server sends 0x05
|
||||
val onlinePacketHandler = remember<(com.rosetta.messenger.network.Packet) -> Unit>(dialogPublicKey) {
|
||||
{ packet ->
|
||||
val onlinePacket = packet as PacketOnlineState
|
||||
onlinePacket.publicKeysState.forEach { item ->
|
||||
memberOnlineStatus[item.publicKey] = (item.state == OnlineState.ONLINE)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
DisposableEffect(dialogPublicKey) {
|
||||
ProtocolManager.waitPacket(0x05, onlinePacketHandler)
|
||||
onDispose {
|
||||
ProtocolManager.unwaitPacket(0x05, onlinePacketHandler)
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to online status whenever members list changes
|
||||
LaunchedEffect(members, currentUserPrivateKey) {
|
||||
if (members.isEmpty() || currentUserPrivateKey.isBlank()) return@LaunchedEffect
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val privateKeyHash = CryptoManager.generatePrivateKeyHash(currentUserPrivateKey)
|
||||
val keysToSubscribe = members.filter { key ->
|
||||
!key.trim().equals(currentUserPublicKey.trim(), ignoreCase = true)
|
||||
}
|
||||
if (keysToSubscribe.isNotEmpty()) {
|
||||
val packet = PacketOnlineSubscribe().apply {
|
||||
this.privateKey = privateKeyHash
|
||||
keysToSubscribe.forEach { addPublicKey(it) }
|
||||
}
|
||||
ProtocolManager.send(packet)
|
||||
}
|
||||
} catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
val normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
|
||||
|
||||
val onlineCount by remember(members, memberInfoByKey, normalizedCurrentUserKey) {
|
||||
val onlineCount by remember(members, memberOnlineStatus, normalizedCurrentUserKey) {
|
||||
derivedStateOf {
|
||||
if (members.isEmpty()) {
|
||||
0
|
||||
@@ -469,25 +514,27 @@ fun GroupInfoScreen(
|
||||
val isCurrentUser =
|
||||
normalizedCurrentUserKey.isNotBlank() &&
|
||||
key.trim().equals(normalizedCurrentUserKey, ignoreCase = true)
|
||||
!isCurrentUser && (memberInfoByKey[key]?.online ?: 0) > 0
|
||||
!isCurrentUser && (memberOnlineStatus[key] == true)
|
||||
}
|
||||
selfOnline + othersOnline
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val memberItems by remember(members, memberInfoByKey, searchQuery) {
|
||||
val memberItems by remember(members, memberInfoByKey, memberOnlineStatus, searchQuery) {
|
||||
derivedStateOf {
|
||||
val query = searchQuery.trim().lowercase()
|
||||
members.mapIndexed { index, key ->
|
||||
val info = memberInfoByKey[key]
|
||||
val isOnline = memberOnlineStatus[key] == true ||
|
||||
key.trim().equals(currentUserPublicKey.trim(), ignoreCase = true)
|
||||
val fallbackName = shortPublicKey(key)
|
||||
val displayTitle =
|
||||
info?.title?.takeIf { it.isNotBlank() }
|
||||
?: info?.username?.takeIf { it.isNotBlank() }
|
||||
?: fallbackName
|
||||
val subtitle = when {
|
||||
(info?.online ?: 0) > 0 -> "online"
|
||||
isOnline -> "online"
|
||||
info?.username?.isNotBlank() == true -> "@${info.username}"
|
||||
else -> key.take(18)
|
||||
}
|
||||
@@ -496,14 +543,14 @@ fun GroupInfoScreen(
|
||||
title = displayTitle,
|
||||
subtitle = subtitle,
|
||||
verified = info?.verified ?: 0,
|
||||
online = (info?.online ?: 0) > 0,
|
||||
online = isOnline,
|
||||
isAdmin = index == 0,
|
||||
searchUser = SearchUser(
|
||||
publicKey = key,
|
||||
title = info?.title ?: displayTitle,
|
||||
username = info?.username.orEmpty(),
|
||||
verified = info?.verified ?: 0,
|
||||
online = info?.online ?: 0
|
||||
online = if (isOnline) 1 else 0
|
||||
)
|
||||
)
|
||||
}.filter { member ->
|
||||
@@ -1234,8 +1281,6 @@ fun GroupInfoScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
topSurfaceColor = topSurfaceColor,
|
||||
backgroundColor = backgroundColor,
|
||||
secondaryText = secondaryText,
|
||||
accentColor = accentColor,
|
||||
onBack = { showEncryptionPage = false },
|
||||
onCopy = {
|
||||
clipboardManager.setText(AnnotatedString(encryptionKey))
|
||||
@@ -1312,40 +1357,39 @@ fun GroupInfoScreen(
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DesktopStyleKeyImage(
|
||||
private fun TelegramStyleIdenticon(
|
||||
keyRender: String,
|
||||
size: androidx.compose.ui.unit.Dp,
|
||||
radius: androidx.compose.ui.unit.Dp = 0.dp,
|
||||
palette: List<Color> = KEY_IMAGE_COLORS
|
||||
isDarkTheme: Boolean
|
||||
) {
|
||||
val colors = if (palette.isNotEmpty()) palette else KEY_IMAGE_COLORS
|
||||
val composition = remember(keyRender, colors) {
|
||||
buildList(64) {
|
||||
val source = if (keyRender.isBlank()) "rosetta" else keyRender
|
||||
for (i in 0 until 64) {
|
||||
val code = source[i % source.length].code
|
||||
val colorIndex = code % colors.size
|
||||
add(colors[colorIndex])
|
||||
}
|
||||
}
|
||||
val palette = IDENTICON_COLORS
|
||||
|
||||
// Convert key string to byte array, then use 2-bit grouping like Telegram's IdenticonDrawable
|
||||
val keyBytes = remember(keyRender) {
|
||||
val source = keyRender.ifBlank { "rosetta" }
|
||||
source.toByteArray(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(RoundedCornerShape(radius))
|
||||
.background(colors.first())
|
||||
) {
|
||||
val cells = 8
|
||||
val cells = 12
|
||||
val cellSize = this.size.minDimension / cells.toFloat()
|
||||
for (i in 0 until 64) {
|
||||
val row = i / cells
|
||||
val col = i % cells
|
||||
drawRect(
|
||||
color = composition[i],
|
||||
topLeft = Offset(col * cellSize, row * cellSize),
|
||||
size = Size(cellSize, cellSize)
|
||||
)
|
||||
var bitPointer = 0
|
||||
for (iy in 0 until cells) {
|
||||
for (ix in 0 until cells) {
|
||||
val byteIndex = (bitPointer / 8) % keyBytes.size
|
||||
val bitOffset = bitPointer % 8
|
||||
val value = (keyBytes[byteIndex].toInt() shr bitOffset) and 0x3
|
||||
val colorIndex = kotlin.math.abs(value) % 4
|
||||
drawRect(
|
||||
color = Color(palette[colorIndex]),
|
||||
topLeft = Offset(ix * cellSize, iy * cellSize),
|
||||
size = Size(cellSize, cellSize)
|
||||
)
|
||||
bitPointer += 2
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1358,36 +1402,19 @@ private fun GroupEncryptionKeyPage(
|
||||
isDarkTheme: Boolean,
|
||||
topSurfaceColor: Color,
|
||||
backgroundColor: Color,
|
||||
secondaryText: Color,
|
||||
accentColor: Color,
|
||||
onBack: () -> Unit,
|
||||
onCopy: () -> Unit
|
||||
) {
|
||||
val uriHandler = LocalUriHandler.current
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val imageSize = (screenWidth - 80.dp).coerceIn(220.dp, 340.dp)
|
||||
val keyImagePalette = if (isDarkTheme) {
|
||||
listOf(
|
||||
Color(0xFF2B4F78),
|
||||
Color(0xFF2F5F90),
|
||||
Color(0xFF3D74A8),
|
||||
Color(0xFF4E89BE),
|
||||
Color(0xFF64A0D6)
|
||||
)
|
||||
} else {
|
||||
listOf(
|
||||
Color(0xFFD5E8FF),
|
||||
Color(0xFFBBD9FF),
|
||||
Color(0xFFA1CAFF),
|
||||
Color(0xFF87BAFF),
|
||||
Color(0xFF6EA9F4)
|
||||
)
|
||||
}
|
||||
val keyPanelColor = if (isDarkTheme) Color(0xFF202227) else Color(0xFFFFFFFF)
|
||||
val keyCodeColor = if (isDarkTheme) Color(0xFFC7D6EA) else Color(0xFF34495E)
|
||||
val detailsPanelColor = if (isDarkTheme) Color(0xFF1B1D22) else Color(0xFFF7F9FC)
|
||||
val safePeerTitle = peerTitle.ifBlank { "this group" }
|
||||
|
||||
// Rosetta theme colors
|
||||
val identiconBg = if (isDarkTheme) Color(0xFF111111) else Color(0xFFF2F2F7)
|
||||
val bottomBg = if (isDarkTheme) Color(0xFF1A1A1A) else Color.White
|
||||
val codeColor = if (isDarkTheme) Color(0xFF8E8E93) else Color(0xFF6D7883)
|
||||
val descriptionColor = Color(0xFF8E8E93)
|
||||
val linkColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
@@ -1398,6 +1425,7 @@ private fun GroupEncryptionKeyPage(
|
||||
.fillMaxSize()
|
||||
.statusBarsPadding()
|
||||
) {
|
||||
// Top bar
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -1415,92 +1443,92 @@ private fun GroupEncryptionKeyPage(
|
||||
Text(
|
||||
text = "Encryption Key",
|
||||
color = Color.White,
|
||||
fontSize = 22.sp,
|
||||
fontSize = 20.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
TextButton(onClick = onCopy) {
|
||||
Text(
|
||||
text = "Copy",
|
||||
color = Color.White,
|
||||
color = Color.White.copy(alpha = 0.9f),
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Two-half layout like Telegram
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 20.dp, vertical = 16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
.weight(1f)
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.widthIn(max = 420.dp),
|
||||
color = keyPanelColor,
|
||||
shape = RoundedCornerShape(16.dp)
|
||||
// Top half - Identicon image on gray background
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(identiconBg)
|
||||
.padding(24.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier.padding(16.dp),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
DesktopStyleKeyImage(
|
||||
keyRender = encryptionKey,
|
||||
size = imageSize,
|
||||
radius = 0.dp,
|
||||
palette = keyImagePalette
|
||||
)
|
||||
}
|
||||
TelegramStyleIdenticon(
|
||||
keyRender = encryptionKey,
|
||||
size = 280.dp,
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = detailsPanelColor,
|
||||
shape = RoundedCornerShape(14.dp)
|
||||
// Bottom half - Code + Description on white background
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f)
|
||||
.background(bottomBg)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 24.dp, vertical = 20.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
// Hex code display
|
||||
SelectionContainer {
|
||||
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally
|
||||
) {
|
||||
displayLines.forEach { line ->
|
||||
Text(
|
||||
text = line,
|
||||
color = keyCodeColor,
|
||||
fontSize = 13.sp,
|
||||
fontFamily = FontFamily.Monospace
|
||||
color = codeColor,
|
||||
fontSize = 14.sp,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
letterSpacing = 0.5.sp,
|
||||
textAlign = TextAlign.Center
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Description text
|
||||
Text(
|
||||
text = "This image and text were derived from the encryption key for this group with $safePeerTitle.\n\nIf they look the same on $safePeerTitle's device, end-to-end encryption is guaranteed.",
|
||||
color = descriptionColor,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 20.sp
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Text(
|
||||
text = "This image and text were derived from the encryption key for this group with $safePeerTitle.",
|
||||
color = secondaryText,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 21.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(10.dp))
|
||||
Text(
|
||||
text = "If they look the same on $safePeerTitle's device, end-to-end encryption is guaranteed.",
|
||||
color = secondaryText,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = 21.sp
|
||||
)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
TextButton(onClick = { uriHandler.openUri("https://rosetta.im/") }) {
|
||||
Text(
|
||||
text = "Learn more at rosetta.im",
|
||||
color = accentColor,
|
||||
fontSize = 15.sp,
|
||||
textAlign = TextAlign.Center
|
||||
color = linkColor,
|
||||
fontSize = 14.sp,
|
||||
textAlign = TextAlign.Center,
|
||||
modifier = Modifier.clickable { uriHandler.openUri("https://rosetta.im/") }
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
Spacer(modifier = Modifier.height(14.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2001,14 +2029,24 @@ private fun encodeGroupKeyForDisplay(encryptKey: String): List<String> {
|
||||
val normalized = encryptKey.trim()
|
||||
if (normalized.isBlank()) return emptyList()
|
||||
|
||||
// Telegram-style: each char → XOR 27 → 2-char hex, grouped as:
|
||||
// "ab cd ef 12 34 56 78 9a" (4 pairs + double space + 4 pairs per line)
|
||||
val hexPairs = normalized.map { symbol ->
|
||||
(symbol.code xor 27).toString(16).padStart(2, '0')
|
||||
}
|
||||
|
||||
val lines = mutableListOf<String>()
|
||||
normalized.chunked(16).forEach { chunk ->
|
||||
val bytes = mutableListOf<String>()
|
||||
chunk.forEach { symbol ->
|
||||
val encoded = (symbol.code xor 27).toString(16).padStart(2, '0')
|
||||
bytes.add(encoded)
|
||||
for (lineStart in hexPairs.indices step 8) {
|
||||
val lineEnd = minOf(lineStart + 8, hexPairs.size)
|
||||
val linePairs = hexPairs.subList(lineStart, lineEnd)
|
||||
val sb = StringBuilder()
|
||||
for ((i, pair) in linePairs.withIndex()) {
|
||||
if (i > 0) {
|
||||
sb.append(if (i % 4 == 0) " " else " ")
|
||||
}
|
||||
sb.append(pair)
|
||||
}
|
||||
lines.add(bytes.joinToString(" "))
|
||||
lines.add(sb.toString())
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ import com.rosetta.messenger.network.ProtocolState
|
||||
import com.rosetta.messenger.network.SearchUser
|
||||
import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.sync.withPermit
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.json.JSONArray
|
||||
import java.io.File
|
||||
@@ -785,8 +786,11 @@ private fun SearchSkeleton(isDarkTheme: Boolean) {
|
||||
/** Parsed media item for the grid display */
|
||||
private data class MediaItem(
|
||||
val messageId: String,
|
||||
val attachmentId: String,
|
||||
val timestamp: Long,
|
||||
val preview: String, // blurhash or base64
|
||||
val preview: String, // blurhash or "UUID::blurhash"
|
||||
val chachaKey: String, // encrypted key for decryption
|
||||
val senderPublicKey: String,
|
||||
val width: Int,
|
||||
val height: Int
|
||||
)
|
||||
@@ -802,6 +806,30 @@ private fun MediaTabContent(
|
||||
var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
|
||||
var isLoading by remember { mutableStateOf(true) }
|
||||
|
||||
val privateKey = remember {
|
||||
com.rosetta.messenger.data.MessageRepository.getInstance(context).getCurrentPrivateKey().orEmpty()
|
||||
}
|
||||
|
||||
// Fullscreen image viewer state
|
||||
var showImageViewer by remember { mutableStateOf(false) }
|
||||
var imageViewerInitialIndex by remember { mutableStateOf(0) }
|
||||
|
||||
val viewerImages = remember(mediaItems) {
|
||||
mediaItems.map { item ->
|
||||
com.rosetta.messenger.ui.chats.components.ViewableImage(
|
||||
attachmentId = item.attachmentId,
|
||||
preview = item.preview,
|
||||
blob = "",
|
||||
chachaKey = item.chachaKey,
|
||||
senderPublicKey = item.senderPublicKey,
|
||||
senderName = "",
|
||||
timestamp = Date(item.timestamp),
|
||||
width = item.width,
|
||||
height = item.height
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(currentUserPublicKey) {
|
||||
if (currentUserPublicKey.isBlank()) {
|
||||
isLoading = false
|
||||
@@ -820,15 +848,21 @@ private fun MediaTabContent(
|
||||
val obj = arr.getJSONObject(i)
|
||||
val type = obj.optInt("type", -1)
|
||||
if (type == AttachmentType.IMAGE.value) {
|
||||
items.add(
|
||||
MediaItem(
|
||||
messageId = msg.messageId,
|
||||
timestamp = msg.timestamp,
|
||||
preview = obj.optString("preview", ""),
|
||||
width = obj.optInt("width", 100),
|
||||
height = obj.optInt("height", 100)
|
||||
)
|
||||
)
|
||||
val attId = obj.optString("id", "")
|
||||
if (attId.isNotBlank()) {
|
||||
items.add(
|
||||
MediaItem(
|
||||
messageId = msg.messageId,
|
||||
attachmentId = attId,
|
||||
timestamp = msg.timestamp,
|
||||
preview = obj.optString("preview", ""),
|
||||
chachaKey = msg.chachaKey,
|
||||
senderPublicKey = msg.fromPublicKey,
|
||||
width = obj.optInt("width", 100),
|
||||
height = obj.optInt("height", 100)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
@@ -839,70 +873,163 @@ private fun MediaTabContent(
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = PrimaryBlue, modifier = Modifier.size(32.dp))
|
||||
}
|
||||
} else if (mediaItems.isEmpty()) {
|
||||
EmptyTabPlaceholder(
|
||||
iconRes = R.drawable.search_media_filled,
|
||||
title = "No media yet",
|
||||
subtitle = "Photos and videos will appear here",
|
||||
isDarkTheme = isDarkTheme,
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor
|
||||
)
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(mediaItems, key = { "${it.messageId}_${it.preview.hashCode()}" }) { item ->
|
||||
MediaGridItem(item = item, isDarkTheme = isDarkTheme)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
if (isLoading) {
|
||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator(color = PrimaryBlue, modifier = Modifier.size(32.dp))
|
||||
}
|
||||
} else if (mediaItems.isEmpty()) {
|
||||
EmptyTabPlaceholder(
|
||||
iconRes = R.drawable.search_media_filled,
|
||||
title = "No media yet",
|
||||
subtitle = "Photos and videos will appear here",
|
||||
isDarkTheme = isDarkTheme,
|
||||
textColor = textColor,
|
||||
secondaryTextColor = secondaryTextColor
|
||||
)
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Fixed(3),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentPadding = PaddingValues(2.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(mediaItems, key = { it.attachmentId }) { item ->
|
||||
val index = mediaItems.indexOf(item)
|
||||
MediaGridItem(
|
||||
item = item,
|
||||
isDarkTheme = isDarkTheme,
|
||||
currentUserPublicKey = currentUserPublicKey,
|
||||
privateKey = privateKey,
|
||||
onClick = {
|
||||
imageViewerInitialIndex = if (index >= 0) index else 0
|
||||
showImageViewer = true
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fullscreen image viewer overlay
|
||||
if (showImageViewer && viewerImages.isNotEmpty()) {
|
||||
com.rosetta.messenger.ui.chats.components.ImageViewerScreen(
|
||||
images = viewerImages,
|
||||
initialIndex = imageViewerInitialIndex.coerceIn(0, viewerImages.lastIndex),
|
||||
privateKey = privateKey,
|
||||
onDismiss = { showImageViewer = false },
|
||||
isDarkTheme = isDarkTheme
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MediaGridItem(item: MediaItem, isDarkTheme: Boolean) {
|
||||
val bitmap = remember(item.preview) {
|
||||
if (item.preview.isNotBlank() && !item.preview.contains("::")) {
|
||||
try {
|
||||
// Try blurhash decode
|
||||
com.vanniktech.blurhash.BlurHash.decode(
|
||||
item.preview, 32, 32
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
private fun MediaGridItem(
|
||||
item: MediaItem,
|
||||
isDarkTheme: Boolean,
|
||||
currentUserPublicKey: String,
|
||||
privateKey: String,
|
||||
onClick: () -> Unit = {}
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val cacheKey = "img_${item.attachmentId}"
|
||||
|
||||
// Start with cached bitmap if available
|
||||
var imageBitmap by remember(item.attachmentId) {
|
||||
mutableStateOf(com.rosetta.messenger.ui.chats.components.ImageBitmapCache.get(cacheKey))
|
||||
}
|
||||
|
||||
// Decode blurhash for placeholder
|
||||
val blurhashBitmap = remember(item.preview) {
|
||||
val preview = com.rosetta.messenger.ui.chats.components.getPreview(item.preview)
|
||||
if (preview.isNotBlank()) {
|
||||
try { com.vanniktech.blurhash.BlurHash.decode(preview, 32, 32) }
|
||||
catch (_: Exception) { null }
|
||||
} else null
|
||||
}
|
||||
|
||||
// Load the actual image
|
||||
LaunchedEffect(item.attachmentId) {
|
||||
if (imageBitmap != null) return@LaunchedEffect
|
||||
|
||||
// Check cache again (may have loaded between remember and LaunchedEffect)
|
||||
com.rosetta.messenger.ui.chats.components.ImageBitmapCache.get(cacheKey)?.let {
|
||||
imageBitmap = it
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
com.rosetta.messenger.ui.chats.components.ImageLoadSemaphore.semaphore.withPermit {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
// 1. Try loading from local file (AttachmentFileManager)
|
||||
val localBlob = com.rosetta.messenger.utils.AttachmentFileManager.readAttachment(
|
||||
context, item.attachmentId, currentUserPublicKey, privateKey
|
||||
)
|
||||
if (localBlob != null) {
|
||||
val bmp = com.rosetta.messenger.ui.chats.components.base64ToBitmap(localBlob)
|
||||
if (bmp != null) {
|
||||
com.rosetta.messenger.ui.chats.components.ImageBitmapCache.put(cacheKey, bmp)
|
||||
imageBitmap = bmp
|
||||
return@withContext
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Try CDN download if we have a download tag
|
||||
val downloadTag = com.rosetta.messenger.ui.chats.components.getDownloadTag(item.preview)
|
||||
if (downloadTag.isNotEmpty() && item.chachaKey.isNotBlank() && privateKey.isNotBlank()) {
|
||||
val bmp = com.rosetta.messenger.ui.chats.components.downloadAndDecryptImage(
|
||||
attachmentId = item.attachmentId,
|
||||
downloadTag = downloadTag,
|
||||
chachaKey = item.chachaKey,
|
||||
privateKey = privateKey,
|
||||
cacheKey = cacheKey,
|
||||
context = context,
|
||||
senderPublicKey = item.senderPublicKey,
|
||||
recipientPrivateKey = privateKey
|
||||
)
|
||||
if (bmp != null) {
|
||||
imageBitmap = bmp
|
||||
}
|
||||
}
|
||||
} catch (_: Exception) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8))
|
||||
.clip(RoundedCornerShape(2.dp)),
|
||||
.clip(RoundedCornerShape(2.dp))
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
if (bitmap != null) {
|
||||
Image(
|
||||
bitmap = bitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.search_media_filled),
|
||||
contentDescription = null,
|
||||
tint = if (isDarkTheme) Color(0xFF555555) else Color(0xFFCCCCCC),
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
when {
|
||||
imageBitmap != null -> {
|
||||
Image(
|
||||
bitmap = imageBitmap!!.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
blurhashBitmap != null -> {
|
||||
Image(
|
||||
bitmap = blurhashBitmap.asImageBitmap(),
|
||||
contentDescription = null,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
Icon(
|
||||
painter = painterResource(R.drawable.search_media_filled),
|
||||
contentDescription = null,
|
||||
tint = if (isDarkTheme) Color(0xFF555555) else Color(0xFFCCCCCC),
|
||||
modifier = Modifier.size(28.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Block
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Groups
|
||||
import androidx.compose.material.icons.filled.Link
|
||||
import androidx.compose.material.icons.filled.PersonAdd
|
||||
import android.graphics.BitmapFactory
|
||||
@@ -604,7 +605,7 @@ fun MessageBubble(
|
||||
val bubblePadding =
|
||||
when {
|
||||
isSafeSystemMessage -> PaddingValues(0.dp)
|
||||
isStandaloneGroupInvite -> PaddingValues(0.dp)
|
||||
isStandaloneGroupInvite -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
||||
hasOnlyMedia -> PaddingValues(0.dp)
|
||||
hasImageWithCaption -> PaddingValues(0.dp)
|
||||
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
|
||||
@@ -685,7 +686,7 @@ fun MessageBubble(
|
||||
if (isSafeSystemMessage) {
|
||||
Modifier.widthIn(min = 220.dp, max = 320.dp)
|
||||
} else if (isStandaloneGroupInvite) {
|
||||
Modifier.widthIn(min = 220.dp, max = 320.dp)
|
||||
Modifier.widthIn(min = 180.dp, max = 260.dp)
|
||||
} else if (hasImageWithCaption || hasOnlyMedia) {
|
||||
Modifier.width(
|
||||
photoWidth
|
||||
@@ -714,7 +715,7 @@ fun MessageBubble(
|
||||
onLongClick = onLongClick
|
||||
)
|
||||
.then(
|
||||
if (isStandaloneGroupInvite) {
|
||||
if (false) {
|
||||
Modifier
|
||||
} else {
|
||||
Modifier.clip(bubbleShape)
|
||||
@@ -1281,9 +1282,10 @@ private fun GroupInviteInlineCard(
|
||||
val normalizedInvite = remember(inviteText) { inviteText.trim() }
|
||||
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) }
|
||||
|
||||
var status by remember(normalizedInvite) { mutableStateOf<GroupStatus>(GroupStatus.NOT_JOINED) }
|
||||
var membersCount by remember(normalizedInvite) { mutableStateOf(0) }
|
||||
var statusLoading by remember(normalizedInvite) { mutableStateOf(true) }
|
||||
val cachedInfo = remember(normalizedInvite) { parsedInvite?.let { groupRepository.getCachedInviteInfo(it.groupId) } }
|
||||
var status by remember(normalizedInvite) { mutableStateOf(cachedInfo?.status ?: GroupStatus.NOT_JOINED) }
|
||||
var membersCount by remember(normalizedInvite) { mutableStateOf(cachedInfo?.membersCount ?: 0) }
|
||||
var statusLoading by remember(normalizedInvite) { mutableStateOf(cachedInfo == null) }
|
||||
var actionLoading by remember(normalizedInvite) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(normalizedInvite, accountPublicKey) {
|
||||
@@ -1294,7 +1296,9 @@ private fun GroupInviteInlineCard(
|
||||
return@LaunchedEffect
|
||||
}
|
||||
|
||||
statusLoading = true
|
||||
if (cachedInfo == null) {
|
||||
statusLoading = true
|
||||
}
|
||||
|
||||
val localGroupExists =
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -1311,12 +1315,13 @@ private fun GroupInviteInlineCard(
|
||||
}
|
||||
|
||||
membersCount = inviteInfo?.membersCount ?: 0
|
||||
status =
|
||||
when {
|
||||
localGroupExists -> GroupStatus.JOINED
|
||||
inviteInfo != null -> inviteInfo.status
|
||||
else -> GroupStatus.NOT_JOINED
|
||||
}
|
||||
val newStatus = when {
|
||||
localGroupExists -> GroupStatus.JOINED
|
||||
inviteInfo != null -> inviteInfo.status
|
||||
else -> GroupStatus.NOT_JOINED
|
||||
}
|
||||
status = newStatus
|
||||
groupRepository.cacheInviteInfo(parsedInvite.groupId, newStatus, membersCount)
|
||||
|
||||
statusLoading = false
|
||||
}
|
||||
@@ -1347,26 +1352,6 @@ private fun GroupInviteInlineCard(
|
||||
}
|
||||
}
|
||||
|
||||
val cardBackground =
|
||||
if (isOutgoing) {
|
||||
PrimaryBlue
|
||||
} else if (isDarkTheme) {
|
||||
Color(0xFF222326)
|
||||
} else {
|
||||
Color(0xFFF5F7FA)
|
||||
}
|
||||
val cardBorder =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.24f)
|
||||
} else if (isDarkTheme) {
|
||||
Color.White.copy(alpha = 0.1f)
|
||||
} else {
|
||||
Color.Black.copy(alpha = 0.07f)
|
||||
}
|
||||
val titleColor =
|
||||
if (isOutgoing) Color.White
|
||||
else if (isDarkTheme) Color.White
|
||||
else Color(0xFF1A1A1A)
|
||||
val subtitleColor =
|
||||
if (isOutgoing) Color.White.copy(alpha = 0.82f)
|
||||
else if (isDarkTheme) Color(0xFFA9AFBA)
|
||||
@@ -1444,6 +1429,7 @@ private fun GroupInviteInlineCard(
|
||||
|
||||
if (joinResult.success) {
|
||||
status = GroupStatus.JOINED
|
||||
groupRepository.cacheInviteInfo(parsedInvite.groupId, GroupStatus.JOINED, membersCount)
|
||||
openParsedGroup()
|
||||
} else {
|
||||
status = joinResult.status
|
||||
@@ -1458,123 +1444,122 @@ private fun GroupInviteInlineCard(
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = cardBackground,
|
||||
shape = RoundedCornerShape(14.dp),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp)
|
||||
Column {
|
||||
// Icon + Title row
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
// Group icon circle
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (isOutgoing) Color.White.copy(alpha = 0.15f)
|
||||
else accentColor.copy(alpha = 0.12f)
|
||||
),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier.size(34.dp)
|
||||
.clip(CircleShape)
|
||||
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.Link,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(18.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = titleColor,
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 11.sp,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
Surface(
|
||||
modifier =
|
||||
Modifier.clip(RoundedCornerShape(8.dp)).clickable(
|
||||
enabled = actionEnabled,
|
||||
onClick = ::handleAction
|
||||
),
|
||||
color =
|
||||
if (isOutgoing) {
|
||||
Color.White.copy(alpha = 0.2f)
|
||||
} else {
|
||||
accentColor.copy(alpha = 0.14f)
|
||||
},
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
if (actionLoading || statusLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(12.dp),
|
||||
strokeWidth = 1.8.dp,
|
||||
color = accentColor
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = actionIcon,
|
||||
contentDescription = null,
|
||||
tint = accentColor,
|
||||
modifier = Modifier.size(12.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = actionLabel,
|
||||
color = accentColor,
|
||||
fontSize = 11.sp,
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Icon(
|
||||
imageVector = Icons.Default.Groups,
|
||||
contentDescription = null,
|
||||
tint = if (isOutgoing) Color.White else accentColor,
|
||||
modifier = Modifier.size(20.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Spacer(modifier = Modifier.width(10.dp))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = if (isOutgoing) Color.White else if (isDarkTheme) Color.White else Color(0xFF1A1A1A),
|
||||
fontSize = 15.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
Spacer(modifier = Modifier.height(1.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
color = subtitleColor,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
// Action button (full width)
|
||||
Surface(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(
|
||||
enabled = actionEnabled,
|
||||
onClick = ::handleAction
|
||||
),
|
||||
color = if (isOutgoing) Color.White.copy(alpha = 0.18f)
|
||||
else accentColor.copy(alpha = 0.10f),
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
modifier = Modifier.padding(vertical = 7.dp),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = timeFormat.format(timestamp),
|
||||
color = timeColor,
|
||||
fontSize = 11.sp,
|
||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
||||
)
|
||||
if (isOutgoing) {
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
AnimatedMessageStatus(
|
||||
status = messageStatus,
|
||||
timeColor = statusColor,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isOutgoing = true,
|
||||
timestamp = timestamp.time,
|
||||
onRetry = onRetry,
|
||||
onDelete = onDelete
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (actionLoading || statusLoading) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(13.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
color = if (isOutgoing) Color.White else accentColor
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = actionIcon,
|
||||
contentDescription = null,
|
||||
tint = if (isOutgoing) Color.White else accentColor,
|
||||
modifier = Modifier.size(14.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(
|
||||
text = actionLabel,
|
||||
color = if (isOutgoing) Color.White else accentColor,
|
||||
fontSize = 13.sp,
|
||||
fontWeight = FontWeight.SemiBold
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
|
||||
// Time + status row
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Text(
|
||||
text = timeFormat.format(timestamp),
|
||||
color = timeColor,
|
||||
fontSize = 11.sp
|
||||
)
|
||||
if (isOutgoing) {
|
||||
Spacer(modifier = Modifier.width(2.dp))
|
||||
AnimatedMessageStatus(
|
||||
status = messageStatus,
|
||||
timeColor = statusColor,
|
||||
isDarkTheme = isDarkTheme,
|
||||
isOutgoing = true,
|
||||
timestamp = timestamp.time,
|
||||
onRetry = onRetry,
|
||||
onDelete = onDelete
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user