feat: implement caching for group invite info and enhance message bubble layout for group invites

This commit is contained in:
2026-03-03 03:06:11 +05:00
parent 36fb8609d5
commit c53cb87595
4 changed files with 490 additions and 321 deletions

View File

@@ -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(

View File

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

View File

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

View File

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