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 com.rosetta.messenger.network.ProtocolManager
import java.security.SecureRandom import java.security.SecureRandom
import java.util.UUID import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import kotlin.coroutines.resume import kotlin.coroutines.resume
@@ -27,6 +28,8 @@ class GroupRepository private constructor(context: Context) {
private val messageDao = db.messageDao() private val messageDao = db.messageDao()
private val dialogDao = db.dialogDao() private val dialogDao = db.dialogDao()
private val inviteInfoCache = ConcurrentHashMap<String, GroupInviteInfoResult>()
companion object { companion object {
private const val GROUP_PREFIX = "#group:" private const val GROUP_PREFIX = "#group:"
private const val GROUP_INVITE_PASSWORD = "rosetta_group" private const val GROUP_INVITE_PASSWORD = "rosetta_group"
@@ -159,6 +162,20 @@ class GroupRepository private constructor(context: Context) {
return response.members 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? { suspend fun requestInviteInfo(groupPublicKeyOrId: String): GroupInviteInfoResult? {
val groupId = normalizeGroupId(groupPublicKeyOrId) val groupId = normalizeGroupId(groupPublicKeyOrId)
if (groupId.isBlank()) return null if (groupId.isBlank()) return null
@@ -176,11 +193,13 @@ class GroupRepository private constructor(context: Context) {
) { incoming -> normalizeGroupId(incoming.groupId) == groupId } ) { incoming -> normalizeGroupId(incoming.groupId) == groupId }
?: return null ?: return null
return GroupInviteInfoResult( val result = GroupInviteInfoResult(
groupId = groupId, groupId = groupId,
membersCount = response.membersCount.coerceAtLeast(0), membersCount = response.membersCount.coerceAtLeast(0),
status = response.groupStatus status = response.groupStatus
) )
inviteInfoCache[groupId] = result
return result
} }
suspend fun createGroup( 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.database.RosettaDatabase
import com.rosetta.messenger.network.AttachmentType import com.rosetta.messenger.network.AttachmentType
import com.rosetta.messenger.network.MessageAttachment 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.ProtocolManager
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository 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 URL_REGEX = Regex("(?i)\\b((?:https?://|www\\.)[^\\s<>()]+)")
private val KEY_IMAGE_COLORS = listOf(
Color(0xFFD0EBFF), // Identicon colors — same palette for both themes (like Telegram)
Color(0xFFA5D8FF), // White stays white so the pattern is visible on dark backgrounds
Color(0xFF74C0FC), private val IDENTICON_COLORS = intArrayOf(
Color(0xFF4DABF7), 0xFFFFFFFF.toInt(),
Color(0xFF339AF0) 0xFFD0E8FF.toInt(),
0xFF228BE6.toInt(),
0xFF1971C2.toInt()
) )
@Composable @Composable
@@ -292,6 +297,8 @@ fun GroupInfoScreen(
var members by remember(dialogPublicKey) { mutableStateOf<List<String>>(emptyList()) } var members by remember(dialogPublicKey) { mutableStateOf<List<String>>(emptyList()) }
val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf<String, SearchUser>() } 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) { val membersCacheKey = remember(currentUserPublicKey, normalizedGroupId) {
"${currentUserPublicKey.trim().lowercase()}|${normalizedGroupId.trim().lowercase()}" "${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 normalizedCurrentUserKey = remember(currentUserPublicKey) { currentUserPublicKey.trim() }
val onlineCount by remember(members, memberInfoByKey, normalizedCurrentUserKey) { val onlineCount by remember(members, memberOnlineStatus, normalizedCurrentUserKey) {
derivedStateOf { derivedStateOf {
if (members.isEmpty()) { if (members.isEmpty()) {
0 0
@@ -469,25 +514,27 @@ fun GroupInfoScreen(
val isCurrentUser = val isCurrentUser =
normalizedCurrentUserKey.isNotBlank() && normalizedCurrentUserKey.isNotBlank() &&
key.trim().equals(normalizedCurrentUserKey, ignoreCase = true) key.trim().equals(normalizedCurrentUserKey, ignoreCase = true)
!isCurrentUser && (memberInfoByKey[key]?.online ?: 0) > 0 !isCurrentUser && (memberOnlineStatus[key] == true)
} }
selfOnline + othersOnline selfOnline + othersOnline
} }
} }
} }
val memberItems by remember(members, memberInfoByKey, searchQuery) { val memberItems by remember(members, memberInfoByKey, memberOnlineStatus, searchQuery) {
derivedStateOf { derivedStateOf {
val query = searchQuery.trim().lowercase() val query = searchQuery.trim().lowercase()
members.mapIndexed { index, key -> members.mapIndexed { index, key ->
val info = memberInfoByKey[key] val info = memberInfoByKey[key]
val isOnline = memberOnlineStatus[key] == true ||
key.trim().equals(currentUserPublicKey.trim(), ignoreCase = true)
val fallbackName = shortPublicKey(key) val fallbackName = shortPublicKey(key)
val displayTitle = val displayTitle =
info?.title?.takeIf { it.isNotBlank() } info?.title?.takeIf { it.isNotBlank() }
?: info?.username?.takeIf { it.isNotBlank() } ?: info?.username?.takeIf { it.isNotBlank() }
?: fallbackName ?: fallbackName
val subtitle = when { val subtitle = when {
(info?.online ?: 0) > 0 -> "online" isOnline -> "online"
info?.username?.isNotBlank() == true -> "@${info.username}" info?.username?.isNotBlank() == true -> "@${info.username}"
else -> key.take(18) else -> key.take(18)
} }
@@ -496,14 +543,14 @@ fun GroupInfoScreen(
title = displayTitle, title = displayTitle,
subtitle = subtitle, subtitle = subtitle,
verified = info?.verified ?: 0, verified = info?.verified ?: 0,
online = (info?.online ?: 0) > 0, online = isOnline,
isAdmin = index == 0, isAdmin = index == 0,
searchUser = SearchUser( searchUser = SearchUser(
publicKey = key, publicKey = key,
title = info?.title ?: displayTitle, title = info?.title ?: displayTitle,
username = info?.username.orEmpty(), username = info?.username.orEmpty(),
verified = info?.verified ?: 0, verified = info?.verified ?: 0,
online = info?.online ?: 0 online = if (isOnline) 1 else 0
) )
) )
}.filter { member -> }.filter { member ->
@@ -1234,8 +1281,6 @@ fun GroupInfoScreen(
isDarkTheme = isDarkTheme, isDarkTheme = isDarkTheme,
topSurfaceColor = topSurfaceColor, topSurfaceColor = topSurfaceColor,
backgroundColor = backgroundColor, backgroundColor = backgroundColor,
secondaryText = secondaryText,
accentColor = accentColor,
onBack = { showEncryptionPage = false }, onBack = { showEncryptionPage = false },
onCopy = { onCopy = {
clipboardManager.setText(AnnotatedString(encryptionKey)) clipboardManager.setText(AnnotatedString(encryptionKey))
@@ -1312,40 +1357,39 @@ fun GroupInfoScreen(
} }
@Composable @Composable
private fun DesktopStyleKeyImage( private fun TelegramStyleIdenticon(
keyRender: String, keyRender: String,
size: androidx.compose.ui.unit.Dp, size: androidx.compose.ui.unit.Dp,
radius: androidx.compose.ui.unit.Dp = 0.dp, isDarkTheme: Boolean
palette: List<Color> = KEY_IMAGE_COLORS
) { ) {
val colors = if (palette.isNotEmpty()) palette else KEY_IMAGE_COLORS val palette = IDENTICON_COLORS
val composition = remember(keyRender, colors) {
buildList(64) { // Convert key string to byte array, then use 2-bit grouping like Telegram's IdenticonDrawable
val source = if (keyRender.isBlank()) "rosetta" else keyRender val keyBytes = remember(keyRender) {
for (i in 0 until 64) { val source = keyRender.ifBlank { "rosetta" }
val code = source[i % source.length].code source.toByteArray(Charsets.UTF_8)
val colorIndex = code % colors.size
add(colors[colorIndex])
}
}
} }
Canvas( Canvas(
modifier = Modifier modifier = Modifier
.size(size) .size(size)
.clip(RoundedCornerShape(radius))
.background(colors.first())
) { ) {
val cells = 8 val cells = 12
val cellSize = this.size.minDimension / cells.toFloat() val cellSize = this.size.minDimension / cells.toFloat()
for (i in 0 until 64) { var bitPointer = 0
val row = i / cells for (iy in 0 until cells) {
val col = i % 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( drawRect(
color = composition[i], color = Color(palette[colorIndex]),
topLeft = Offset(col * cellSize, row * cellSize), topLeft = Offset(ix * cellSize, iy * cellSize),
size = Size(cellSize, cellSize) size = Size(cellSize, cellSize)
) )
bitPointer += 2
}
} }
} }
} }
@@ -1358,36 +1402,19 @@ private fun GroupEncryptionKeyPage(
isDarkTheme: Boolean, isDarkTheme: Boolean,
topSurfaceColor: Color, topSurfaceColor: Color,
backgroundColor: Color, backgroundColor: Color,
secondaryText: Color,
accentColor: Color,
onBack: () -> Unit, onBack: () -> Unit,
onCopy: () -> Unit onCopy: () -> Unit
) { ) {
val uriHandler = LocalUriHandler.current 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" } 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( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@@ -1398,6 +1425,7 @@ private fun GroupEncryptionKeyPage(
.fillMaxSize() .fillMaxSize()
.statusBarsPadding() .statusBarsPadding()
) { ) {
// Top bar
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -1415,92 +1443,92 @@ private fun GroupEncryptionKeyPage(
Text( Text(
text = "Encryption Key", text = "Encryption Key",
color = Color.White, color = Color.White,
fontSize = 22.sp, fontSize = 20.sp,
fontWeight = FontWeight.SemiBold fontWeight = FontWeight.SemiBold
) )
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
TextButton(onClick = onCopy) { TextButton(onClick = onCopy) {
Text( Text(
text = "Copy", text = "Copy",
color = Color.White, color = Color.White.copy(alpha = 0.9f),
fontWeight = FontWeight.Medium fontWeight = FontWeight.Medium
) )
} }
} }
// Two-half layout like Telegram
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .weight(1f)
.padding(horizontal = 20.dp, vertical = 16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) {
Surface(
modifier = Modifier.widthIn(max = 420.dp),
color = keyPanelColor,
shape = RoundedCornerShape(16.dp)
) { ) {
// Top half - Identicon image on gray background
Box( Box(
modifier = Modifier.padding(16.dp), modifier = Modifier
.fillMaxWidth()
.weight(1f)
.background(identiconBg)
.padding(24.dp),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
DesktopStyleKeyImage( TelegramStyleIdenticon(
keyRender = encryptionKey, keyRender = encryptionKey,
size = imageSize, size = 280.dp,
radius = 0.dp, isDarkTheme = isDarkTheme
palette = keyImagePalette
) )
} }
}
Spacer(modifier = Modifier.height(14.dp)) // Bottom half - Code + Description on white background
Column(
Surface( modifier = Modifier
modifier = Modifier.fillMaxWidth(), .fillMaxWidth()
color = detailsPanelColor, .weight(1f)
shape = RoundedCornerShape(14.dp) .background(bottomBg)
.verticalScroll(rememberScrollState())
.padding(horizontal = 24.dp, vertical = 20.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
// Hex code display
SelectionContainer { SelectionContainer {
Column(modifier = Modifier.padding(horizontal = 14.dp, vertical = 12.dp)) { Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
displayLines.forEach { line -> displayLines.forEach { line ->
Text( Text(
text = line, text = line,
color = keyCodeColor, color = codeColor,
fontSize = 13.sp, fontSize = 14.sp,
fontFamily = FontFamily.Monospace fontFamily = FontFamily.Monospace,
) letterSpacing = 0.5.sp,
}
}
}
}
Spacer(modifier = Modifier.height(16.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 textAlign = TextAlign.Center
) )
} }
Spacer(modifier = Modifier.height(14.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 = "Learn more at rosetta.im",
color = linkColor,
fontSize = 14.sp,
textAlign = TextAlign.Center,
modifier = Modifier.clickable { uriHandler.openUri("https://rosetta.im/") }
)
Spacer(modifier = Modifier.height(16.dp))
}
} }
} }
} }
@@ -2001,14 +2029,24 @@ private fun encodeGroupKeyForDisplay(encryptKey: String): List<String> {
val normalized = encryptKey.trim() val normalized = encryptKey.trim()
if (normalized.isBlank()) return emptyList() if (normalized.isBlank()) return emptyList()
val lines = mutableListOf<String>() // Telegram-style: each char → XOR 27 → 2-char hex, grouped as:
normalized.chunked(16).forEach { chunk -> // "ab cd ef 12 34 56 78 9a" (4 pairs + double space + 4 pairs per line)
val bytes = mutableListOf<String>() val hexPairs = normalized.map { symbol ->
chunk.forEach { symbol -> (symbol.code xor 27).toString(16).padStart(2, '0')
val encoded = (symbol.code xor 27).toString(16).padStart(2, '0')
bytes.add(encoded)
} }
lines.add(bytes.joinToString(" "))
val lines = mutableListOf<String>()
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(sb.toString())
} }
return lines return lines
} }

View File

@@ -59,6 +59,7 @@ import com.rosetta.messenger.network.ProtocolState
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue as AppPrimaryBlue
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import org.json.JSONArray import org.json.JSONArray
import java.io.File import java.io.File
@@ -785,8 +786,11 @@ private fun SearchSkeleton(isDarkTheme: Boolean) {
/** Parsed media item for the grid display */ /** Parsed media item for the grid display */
private data class MediaItem( private data class MediaItem(
val messageId: String, val messageId: String,
val attachmentId: String,
val timestamp: Long, 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 width: Int,
val height: Int val height: Int
) )
@@ -802,6 +806,30 @@ private fun MediaTabContent(
var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) } var mediaItems by remember { mutableStateOf<List<MediaItem>>(emptyList()) }
var isLoading by remember { mutableStateOf(true) } 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) { LaunchedEffect(currentUserPublicKey) {
if (currentUserPublicKey.isBlank()) { if (currentUserPublicKey.isBlank()) {
isLoading = false isLoading = false
@@ -820,17 +848,23 @@ private fun MediaTabContent(
val obj = arr.getJSONObject(i) val obj = arr.getJSONObject(i)
val type = obj.optInt("type", -1) val type = obj.optInt("type", -1)
if (type == AttachmentType.IMAGE.value) { if (type == AttachmentType.IMAGE.value) {
val attId = obj.optString("id", "")
if (attId.isNotBlank()) {
items.add( items.add(
MediaItem( MediaItem(
messageId = msg.messageId, messageId = msg.messageId,
attachmentId = attId,
timestamp = msg.timestamp, timestamp = msg.timestamp,
preview = obj.optString("preview", ""), preview = obj.optString("preview", ""),
chachaKey = msg.chachaKey,
senderPublicKey = msg.fromPublicKey,
width = obj.optInt("width", 100), width = obj.optInt("width", 100),
height = obj.optInt("height", 100) height = obj.optInt("height", 100)
) )
) )
} }
} }
}
} catch (_: Exception) { } } catch (_: Exception) { }
} }
mediaItems = items mediaItems = items
@@ -839,6 +873,7 @@ private fun MediaTabContent(
isLoading = false isLoading = false
} }
Box(modifier = Modifier.fillMaxSize()) {
if (isLoading) { if (isLoading) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
CircularProgressIndicator(color = PrimaryBlue, modifier = Modifier.size(32.dp)) CircularProgressIndicator(color = PrimaryBlue, modifier = Modifier.size(32.dp))
@@ -860,43 +895,134 @@ private fun MediaTabContent(
horizontalArrangement = Arrangement.spacedBy(2.dp), horizontalArrangement = Arrangement.spacedBy(2.dp),
verticalArrangement = Arrangement.spacedBy(2.dp) verticalArrangement = Arrangement.spacedBy(2.dp)
) { ) {
items(mediaItems, key = { "${it.messageId}_${it.preview.hashCode()}" }) { item -> items(mediaItems, key = { it.attachmentId }) { item ->
MediaGridItem(item = item, isDarkTheme = isDarkTheme) 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 @Composable
private fun MediaGridItem(item: MediaItem, isDarkTheme: Boolean) { private fun MediaGridItem(
val bitmap = remember(item.preview) { item: MediaItem,
if (item.preview.isNotBlank() && !item.preview.contains("::")) { isDarkTheme: Boolean,
try { currentUserPublicKey: String,
// Try blurhash decode privateKey: String,
com.vanniktech.blurhash.BlurHash.decode( onClick: () -> Unit = {}
item.preview, 32, 32 ) {
) val context = LocalContext.current
} catch (_: Exception) { val cacheKey = "img_${item.attachmentId}"
null
// 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 } 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( Box(
modifier = Modifier modifier = Modifier
.aspectRatio(1f) .aspectRatio(1f)
.background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8)) .background(if (isDarkTheme) Color(0xFF2A2A2A) else Color(0xFFE8E8E8))
.clip(RoundedCornerShape(2.dp)), .clip(RoundedCornerShape(2.dp))
.clickable(onClick = onClick),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
if (bitmap != null) { when {
imageBitmap != null -> {
Image( Image(
bitmap = bitmap.asImageBitmap(), bitmap = imageBitmap!!.asImageBitmap(),
contentDescription = null, contentDescription = null,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop contentScale = ContentScale.Crop
) )
} else { }
blurhashBitmap != null -> {
Image(
bitmap = blurhashBitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
else -> {
Icon( Icon(
painter = painterResource(R.drawable.search_media_filled), painter = painterResource(R.drawable.search_media_filled),
contentDescription = null, contentDescription = null,
@@ -905,6 +1031,7 @@ private fun MediaGridItem(item: MediaItem, isDarkTheme: Boolean) {
) )
} }
} }
}
} }
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════

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.Block
import androidx.compose.material.icons.filled.Check import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.ContentCopy 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.Link
import androidx.compose.material.icons.filled.PersonAdd import androidx.compose.material.icons.filled.PersonAdd
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
@@ -604,7 +605,7 @@ fun MessageBubble(
val bubblePadding = val bubblePadding =
when { when {
isSafeSystemMessage -> PaddingValues(0.dp) isSafeSystemMessage -> PaddingValues(0.dp)
isStandaloneGroupInvite -> PaddingValues(0.dp) isStandaloneGroupInvite -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
hasOnlyMedia -> PaddingValues(0.dp) hasOnlyMedia -> PaddingValues(0.dp)
hasImageWithCaption -> PaddingValues(0.dp) hasImageWithCaption -> PaddingValues(0.dp)
else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp) else -> PaddingValues(horizontal = 10.dp, vertical = 8.dp)
@@ -685,7 +686,7 @@ fun MessageBubble(
if (isSafeSystemMessage) { if (isSafeSystemMessage) {
Modifier.widthIn(min = 220.dp, max = 320.dp) Modifier.widthIn(min = 220.dp, max = 320.dp)
} else if (isStandaloneGroupInvite) { } else if (isStandaloneGroupInvite) {
Modifier.widthIn(min = 220.dp, max = 320.dp) Modifier.widthIn(min = 180.dp, max = 260.dp)
} else if (hasImageWithCaption || hasOnlyMedia) { } else if (hasImageWithCaption || hasOnlyMedia) {
Modifier.width( Modifier.width(
photoWidth photoWidth
@@ -714,7 +715,7 @@ fun MessageBubble(
onLongClick = onLongClick onLongClick = onLongClick
) )
.then( .then(
if (isStandaloneGroupInvite) { if (false) {
Modifier Modifier
} else { } else {
Modifier.clip(bubbleShape) Modifier.clip(bubbleShape)
@@ -1281,9 +1282,10 @@ private fun GroupInviteInlineCard(
val normalizedInvite = remember(inviteText) { inviteText.trim() } val normalizedInvite = remember(inviteText) { inviteText.trim() }
val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) } val parsedInvite = remember(normalizedInvite) { groupRepository.parseInviteString(normalizedInvite) }
var status by remember(normalizedInvite) { mutableStateOf<GroupStatus>(GroupStatus.NOT_JOINED) } val cachedInfo = remember(normalizedInvite) { parsedInvite?.let { groupRepository.getCachedInviteInfo(it.groupId) } }
var membersCount by remember(normalizedInvite) { mutableStateOf(0) } var status by remember(normalizedInvite) { mutableStateOf(cachedInfo?.status ?: GroupStatus.NOT_JOINED) }
var statusLoading by remember(normalizedInvite) { mutableStateOf(true) } var membersCount by remember(normalizedInvite) { mutableStateOf(cachedInfo?.membersCount ?: 0) }
var statusLoading by remember(normalizedInvite) { mutableStateOf(cachedInfo == null) }
var actionLoading by remember(normalizedInvite) { mutableStateOf(false) } var actionLoading by remember(normalizedInvite) { mutableStateOf(false) }
LaunchedEffect(normalizedInvite, accountPublicKey) { LaunchedEffect(normalizedInvite, accountPublicKey) {
@@ -1294,7 +1296,9 @@ private fun GroupInviteInlineCard(
return@LaunchedEffect return@LaunchedEffect
} }
if (cachedInfo == null) {
statusLoading = true statusLoading = true
}
val localGroupExists = val localGroupExists =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
@@ -1311,12 +1315,13 @@ private fun GroupInviteInlineCard(
} }
membersCount = inviteInfo?.membersCount ?: 0 membersCount = inviteInfo?.membersCount ?: 0
status = val newStatus = when {
when {
localGroupExists -> GroupStatus.JOINED localGroupExists -> GroupStatus.JOINED
inviteInfo != null -> inviteInfo.status inviteInfo != null -> inviteInfo.status
else -> GroupStatus.NOT_JOINED else -> GroupStatus.NOT_JOINED
} }
status = newStatus
groupRepository.cacheInviteInfo(parsedInvite.groupId, newStatus, membersCount)
statusLoading = false 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 = val subtitleColor =
if (isOutgoing) Color.White.copy(alpha = 0.82f) if (isOutgoing) Color.White.copy(alpha = 0.82f)
else if (isDarkTheme) Color(0xFFA9AFBA) else if (isDarkTheme) Color(0xFFA9AFBA)
@@ -1444,6 +1429,7 @@ private fun GroupInviteInlineCard(
if (joinResult.success) { if (joinResult.success) {
status = GroupStatus.JOINED status = GroupStatus.JOINED
groupRepository.cacheInviteInfo(parsedInvite.groupId, GroupStatus.JOINED, membersCount)
openParsedGroup() openParsedGroup()
} else { } else {
status = joinResult.status status = joinResult.status
@@ -1458,30 +1444,27 @@ private fun GroupInviteInlineCard(
} }
} }
Surface( Column {
modifier = Modifier.fillMaxWidth(), // Icon + Title row
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)
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
// Group icon circle
Box( Box(
modifier = modifier = Modifier
Modifier.size(34.dp) .size(40.dp)
.clip(CircleShape) .clip(CircleShape)
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)), .background(
if (isOutgoing) Color.White.copy(alpha = 0.15f)
else accentColor.copy(alpha = 0.12f)
),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Icon( Icon(
imageVector = Icons.Default.Link, imageVector = Icons.Default.Groups,
contentDescription = null, contentDescription = null,
tint = accentColor, tint = if (isOutgoing) Color.White else accentColor,
modifier = Modifier.size(18.dp) modifier = Modifier.size(20.dp)
) )
} }
@@ -1490,68 +1473,72 @@ private fun GroupInviteInlineCard(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = title, text = title,
color = titleColor, color = if (isOutgoing) Color.White else if (isDarkTheme) Color.White else Color(0xFF1A1A1A),
fontSize = 14.sp, fontSize = 15.sp,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(1.dp))
Text( Text(
text = subtitle, text = subtitle,
color = subtitleColor, color = subtitleColor,
fontSize = 11.sp, fontSize = 12.sp,
maxLines = 2, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
}
}
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
// Action button (full width)
Surface( Surface(
modifier = modifier = Modifier
Modifier.clip(RoundedCornerShape(8.dp)).clickable( .fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(
enabled = actionEnabled, enabled = actionEnabled,
onClick = ::handleAction onClick = ::handleAction
), ),
color = color = if (isOutgoing) Color.White.copy(alpha = 0.18f)
if (isOutgoing) { else accentColor.copy(alpha = 0.10f),
Color.White.copy(alpha = 0.2f)
} else {
accentColor.copy(alpha = 0.14f)
},
shape = RoundedCornerShape(8.dp) shape = RoundedCornerShape(8.dp)
) { ) {
Row( Row(
modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp), modifier = Modifier.padding(vertical = 7.dp),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Spacer(modifier = Modifier.weight(1f))
if (actionLoading || statusLoading) { if (actionLoading || statusLoading) {
CircularProgressIndicator( CircularProgressIndicator(
modifier = Modifier.size(12.dp), modifier = Modifier.size(13.dp),
strokeWidth = 1.8.dp, strokeWidth = 1.5.dp,
color = accentColor color = if (isOutgoing) Color.White else accentColor
) )
} else { } else {
Icon( Icon(
imageVector = actionIcon, imageVector = actionIcon,
contentDescription = null, contentDescription = null,
tint = accentColor, tint = if (isOutgoing) Color.White else accentColor,
modifier = Modifier.size(12.dp) modifier = Modifier.size(14.dp)
) )
} }
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = actionLabel, text = actionLabel,
color = accentColor, color = if (isOutgoing) Color.White else accentColor,
fontSize = 11.sp, fontSize = 13.sp,
fontWeight = FontWeight.Medium fontWeight = FontWeight.SemiBold
) )
} Spacer(modifier = Modifier.weight(1f))
}
} }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(4.dp))
// Time + status row
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
@@ -1560,8 +1547,7 @@ private fun GroupInviteInlineCard(
Text( Text(
text = timeFormat.format(timestamp), text = timeFormat.format(timestamp),
color = timeColor, color = timeColor,
fontSize = 11.sp, fontSize = 11.sp
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
) )
if (isOutgoing) { if (isOutgoing) {
Spacer(modifier = Modifier.width(2.dp)) Spacer(modifier = Modifier.width(2.dp))
@@ -1577,7 +1563,6 @@ private fun GroupInviteInlineCard(
} }
} }
} }
}
} }
@Composable @Composable