feat: implement caching for group invite info and enhance message bubble layout for group invites
This commit is contained in:
@@ -16,6 +16,7 @@ import com.rosetta.messenger.network.PacketGroupLeave
|
|||||||
import com.rosetta.messenger.network.ProtocolManager
|
import 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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
drawRect(
|
val byteIndex = (bitPointer / 8) % keyBytes.size
|
||||||
color = composition[i],
|
val bitOffset = bitPointer % 8
|
||||||
topLeft = Offset(col * cellSize, row * cellSize),
|
val value = (keyBytes[byteIndex].toInt() shr bitOffset) and 0x3
|
||||||
size = Size(cellSize, cellSize)
|
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,
|
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(
|
// Top half - Identicon image on gray background
|
||||||
modifier = Modifier.widthIn(max = 420.dp),
|
Box(
|
||||||
color = keyPanelColor,
|
modifier = Modifier
|
||||||
shape = RoundedCornerShape(16.dp)
|
.fillMaxWidth()
|
||||||
|
.weight(1f)
|
||||||
|
.background(identiconBg)
|
||||||
|
.padding(24.dp),
|
||||||
|
contentAlignment = Alignment.Center
|
||||||
) {
|
) {
|
||||||
Box(
|
TelegramStyleIdenticon(
|
||||||
modifier = Modifier.padding(16.dp),
|
keyRender = encryptionKey,
|
||||||
contentAlignment = Alignment.Center
|
size = 280.dp,
|
||||||
) {
|
isDarkTheme = isDarkTheme
|
||||||
DesktopStyleKeyImage(
|
)
|
||||||
keyRender = encryptionKey,
|
|
||||||
size = imageSize,
|
|
||||||
radius = 0.dp,
|
|
||||||
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,
|
||||||
|
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(
|
||||||
text = "Learn more at rosetta.im",
|
text = "Learn more at rosetta.im",
|
||||||
color = accentColor,
|
color = linkColor,
|
||||||
fontSize = 15.sp,
|
fontSize = 14.sp,
|
||||||
textAlign = TextAlign.Center
|
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()
|
val normalized = encryptKey.trim()
|
||||||
if (normalized.isBlank()) return emptyList()
|
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>()
|
val lines = mutableListOf<String>()
|
||||||
normalized.chunked(16).forEach { chunk ->
|
for (lineStart in hexPairs.indices step 8) {
|
||||||
val bytes = mutableListOf<String>()
|
val lineEnd = minOf(lineStart + 8, hexPairs.size)
|
||||||
chunk.forEach { symbol ->
|
val linePairs = hexPairs.subList(lineStart, lineEnd)
|
||||||
val encoded = (symbol.code xor 27).toString(16).padStart(2, '0')
|
val sb = StringBuilder()
|
||||||
bytes.add(encoded)
|
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
|
return lines
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,15 +848,21 @@ 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) {
|
||||||
items.add(
|
val attId = obj.optString("id", "")
|
||||||
MediaItem(
|
if (attId.isNotBlank()) {
|
||||||
messageId = msg.messageId,
|
items.add(
|
||||||
timestamp = msg.timestamp,
|
MediaItem(
|
||||||
preview = obj.optString("preview", ""),
|
messageId = msg.messageId,
|
||||||
width = obj.optInt("width", 100),
|
attachmentId = attId,
|
||||||
height = obj.optInt("height", 100)
|
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) { }
|
} catch (_: Exception) { }
|
||||||
@@ -839,70 +873,163 @@ private fun MediaTabContent(
|
|||||||
isLoading = false
|
isLoading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
if (isLoading) {
|
||||||
CircularProgressIndicator(color = PrimaryBlue, modifier = Modifier.size(32.dp))
|
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)
|
|
||||||
}
|
}
|
||||||
|
} 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
|
@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 {
|
||||||
Image(
|
imageBitmap != null -> {
|
||||||
bitmap = bitmap.asImageBitmap(),
|
Image(
|
||||||
contentDescription = null,
|
bitmap = imageBitmap!!.asImageBitmap(),
|
||||||
modifier = Modifier.fillMaxSize(),
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop
|
modifier = Modifier.fillMaxSize(),
|
||||||
)
|
contentScale = ContentScale.Crop
|
||||||
} else {
|
)
|
||||||
Icon(
|
}
|
||||||
painter = painterResource(R.drawable.search_media_filled),
|
blurhashBitmap != null -> {
|
||||||
contentDescription = null,
|
Image(
|
||||||
tint = if (isDarkTheme) Color(0xFF555555) else Color(0xFFCCCCCC),
|
bitmap = blurhashBitmap.asImageBitmap(),
|
||||||
modifier = Modifier.size(28.dp)
|
contentDescription = null,
|
||||||
)
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentScale = ContentScale.Crop
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
Icon(
|
||||||
|
painter = painterResource(R.drawable.search_media_filled),
|
||||||
|
contentDescription = null,
|
||||||
|
tint = if (isDarkTheme) Color(0xFF555555) else Color(0xFFCCCCCC),
|
||||||
|
modifier = Modifier.size(28.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import androidx.compose.material.icons.Icons
|
|||||||
import androidx.compose.material.icons.filled.Block
|
import androidx.compose.material.icons.filled.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
|
||||||
}
|
}
|
||||||
|
|
||||||
statusLoading = true
|
if (cachedInfo == null) {
|
||||||
|
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,123 +1444,122 @@ private fun GroupInviteInlineCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
Column {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
// Icon + Title row
|
||||||
color = cardBackground,
|
Row(
|
||||||
shape = RoundedCornerShape(14.dp),
|
verticalAlignment = Alignment.CenterVertically
|
||||||
border = androidx.compose.foundation.BorderStroke(1.dp, cardBorder)
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 10.dp, vertical = 10.dp)
|
|
||||||
) {
|
) {
|
||||||
Row(
|
// Group icon circle
|
||||||
verticalAlignment = Alignment.CenterVertically
|
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(
|
Icon(
|
||||||
modifier =
|
imageVector = Icons.Default.Groups,
|
||||||
Modifier.size(34.dp)
|
contentDescription = null,
|
||||||
.clip(CircleShape)
|
tint = if (isOutgoing) Color.White else accentColor,
|
||||||
.background(accentColor.copy(alpha = if (isOutgoing) 0.25f else 0.15f)),
|
modifier = Modifier.size(20.dp)
|
||||||
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
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.padding(vertical = 7.dp),
|
||||||
horizontalArrangement = Arrangement.End,
|
horizontalArrangement = Arrangement.Center,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Text(
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
text = timeFormat.format(timestamp),
|
if (actionLoading || statusLoading) {
|
||||||
color = timeColor,
|
CircularProgressIndicator(
|
||||||
fontSize = 11.sp,
|
modifier = Modifier.size(13.dp),
|
||||||
fontStyle = androidx.compose.ui.text.font.FontStyle.Italic
|
strokeWidth = 1.5.dp,
|
||||||
)
|
color = if (isOutgoing) Color.White else accentColor
|
||||||
if (isOutgoing) {
|
)
|
||||||
Spacer(modifier = Modifier.width(2.dp))
|
} else {
|
||||||
AnimatedMessageStatus(
|
Icon(
|
||||||
status = messageStatus,
|
imageVector = actionIcon,
|
||||||
timeColor = statusColor,
|
contentDescription = null,
|
||||||
isDarkTheme = isDarkTheme,
|
tint = if (isOutgoing) Color.White else accentColor,
|
||||||
isOutgoing = true,
|
modifier = Modifier.size(14.dp)
|
||||||
timestamp = timestamp.time,
|
|
||||||
onRetry = onRetry,
|
|
||||||
onDelete = onDelete
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.width(6.dp))
|
||||||
|
Text(
|
||||||
|
text = actionLabel,
|
||||||
|
color = if (isOutgoing) Color.White else accentColor,
|
||||||
|
fontSize = 13.sp,
|
||||||
|
fontWeight = FontWeight.SemiBold
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.weight(1f))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
|
||||||
|
// Time + status row
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = timeFormat.format(timestamp),
|
||||||
|
color = timeColor,
|
||||||
|
fontSize = 11.sp
|
||||||
|
)
|
||||||
|
if (isOutgoing) {
|
||||||
|
Spacer(modifier = Modifier.width(2.dp))
|
||||||
|
AnimatedMessageStatus(
|
||||||
|
status = messageStatus,
|
||||||
|
timeColor = statusColor,
|
||||||
|
isDarkTheme = isDarkTheme,
|
||||||
|
isOutgoing = true,
|
||||||
|
timestamp = timestamp.time,
|
||||||
|
onRetry = onRetry,
|
||||||
|
onDelete = onDelete
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user