diff --git a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt index 7ad7ba3..d897b9a 100644 --- a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt @@ -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() + 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( diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt index 8438507..b923518 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupInfoScreen.kt @@ -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>(emptyList()) } val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf() } + // Real online status from PacketOnlineState (0x05), NOT from SearchUser.online + val memberOnlineStatus = remember(dialogPublicKey) { mutableStateMapOf() } 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 = 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 { 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() - normalized.chunked(16).forEach { chunk -> - val bytes = mutableListOf() - 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 } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt index cfef6c4..8119f8d 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/SearchScreen.kt @@ -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>(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) + ) + } } } } diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt index 06a795c..e0c9728 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/ChatDetailComponents.kt @@ -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.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 + ) } } }