feat: implement group members caching and enhance emoji picker functionality in GroupSetupScreen

This commit is contained in:
2026-03-02 15:31:38 +05:00
parent df8fbfc5d3
commit 16c48992a5
2 changed files with 291 additions and 86 deletions

View File

@@ -149,6 +149,40 @@ private data class GroupSharedStats(
val linksCount: Int = 0 val linksCount: Int = 0
) )
private data class GroupMembersCacheEntry(
val members: List<String>,
val memberInfoByKey: Map<String, SearchUser>,
val updatedAtMs: Long
)
private object GroupMembersMemoryCache {
private const val TTL_MS = 90_000L
private val cache = mutableMapOf<String, GroupMembersCacheEntry>()
fun getAny(key: String): GroupMembersCacheEntry? = synchronized(cache) { cache[key] }
fun getFresh(key: String): GroupMembersCacheEntry? = synchronized(cache) {
val entry = cache[key] ?: return@synchronized null
if (System.currentTimeMillis() - entry.updatedAtMs <= TTL_MS) entry else null
}
fun put(key: String, members: List<String>, memberInfoByKey: Map<String, SearchUser>) {
if (key.isBlank()) return
synchronized(cache) {
cache[key] =
GroupMembersCacheEntry(
members = members,
memberInfoByKey = memberInfoByKey,
updatedAtMs = System.currentTimeMillis()
)
}
}
fun remove(key: String) {
synchronized(cache) { cache.remove(key) }
}
}
private data class GroupMediaItem( private data class GroupMediaItem(
val key: String, val key: String,
val attachment: MessageAttachment, val attachment: MessageAttachment,
@@ -249,9 +283,13 @@ fun GroupInfoScreen(
var isMuted by remember { mutableStateOf(false) } var isMuted by remember { mutableStateOf(false) }
var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") } var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) }
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>() }
val membersCacheKey = remember(currentUserPublicKey, normalizedGroupId) {
"${currentUserPublicKey.trim().lowercase()}|${normalizedGroupId.trim().lowercase()}"
}
val groupEntity by produceState<com.rosetta.messenger.database.GroupEntity?>( val groupEntity by produceState<com.rosetta.messenger.database.GroupEntity?>(
initialValue = null, initialValue = null,
@@ -341,41 +379,76 @@ fun GroupInfoScreen(
} }
} }
fun refreshMembers() { fun refreshMembers(force: Boolean = false, showLoader: Boolean = true) {
if (normalizedGroupId.isBlank()) return if (normalizedGroupId.isBlank()) return
if (isRefreshingMembers && !force) return
scope.launch { scope.launch {
membersLoading = true if (!force) {
val fetchedMembers = withContext(Dispatchers.IO) { GroupMembersMemoryCache.getFresh(membersCacheKey)?.let { cached ->
groupRepository.requestGroupMembers(normalizedGroupId).orEmpty() members = cached.members
memberInfoByKey.clear()
memberInfoByKey.putAll(cached.memberInfoByKey)
return@launch
}
} }
members = fetchedMembers.distinct()
membersLoading = false
if (members.isEmpty()) return@launch val hasAnyCache = GroupMembersMemoryCache.getAny(membersCacheKey) != null
val shouldShowLoader = showLoader && members.isEmpty() && !hasAnyCache
if (shouldShowLoader) membersLoading = true
isRefreshingMembers = true
try {
val fetchedMembers = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(normalizedGroupId).orEmpty()
}
val distinctMembers = fetchedMembers.distinct()
if (distinctMembers.isNotEmpty() || members.isEmpty()) {
members = distinctMembers
}
val resolvedUsers = withContext(Dispatchers.IO) { if (members.isEmpty()) return@launch
val resolvedMap = LinkedHashMap<String, SearchUser>()
members.forEach { memberKey -> val resolvedUsers = withContext(Dispatchers.IO) {
val cached = ProtocolManager.getCachedUserInfo(memberKey) val resolvedMap = LinkedHashMap<String, SearchUser>()
if (cached != null) { members.forEach { memberKey ->
resolvedMap[memberKey] = cached val cached = ProtocolManager.getCachedUserInfo(memberKey)
} else { if (cached != null) {
ProtocolManager.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser -> resolvedMap[memberKey] = cached
resolvedMap[memberKey] = resolvedUser } else {
ProtocolManager.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser ->
resolvedMap[memberKey] = resolvedUser
}
} }
} }
resolvedMap
} }
resolvedMap if (resolvedUsers.isNotEmpty()) {
} memberInfoByKey.putAll(resolvedUsers)
if (resolvedUsers.isNotEmpty()) { }
memberInfoByKey.putAll(resolvedUsers)
GroupMembersMemoryCache.put(
key = membersCacheKey,
members = members,
memberInfoByKey = memberInfoByKey.toMap()
)
} finally {
if (shouldShowLoader) membersLoading = false
isRefreshingMembers = false
} }
} }
} }
LaunchedEffect(normalizedGroupId) { LaunchedEffect(membersCacheKey) {
refreshMembers() val cachedEntry = GroupMembersMemoryCache.getAny(membersCacheKey)
cachedEntry?.let { cached ->
members = cached.members
memberInfoByKey.clear()
memberInfoByKey.putAll(cached.memberInfoByKey)
}
if (GroupMembersMemoryCache.getFresh(membersCacheKey) == null) {
refreshMembers(force = true, showLoader = cachedEntry == null)
}
} }
val onlineCount by remember(members, memberInfoByKey) { val onlineCount by remember(members, memberInfoByKey) {
@@ -541,6 +614,7 @@ fun GroupInfoScreen(
} }
isLeaving = false isLeaving = false
if (left) { if (left) {
GroupMembersMemoryCache.remove(membersCacheKey)
onGroupLeft() onGroupLeft()
} else { } else {
Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show()
@@ -580,7 +654,12 @@ fun GroupInfoScreen(
if (removed) { if (removed) {
members = members.filterNot { it.trim().equals(memberKey, ignoreCase = true) } members = members.filterNot { it.trim().equals(memberKey, ignoreCase = true) }
memberInfoByKey.remove(member.publicKey) memberInfoByKey.remove(member.publicKey)
refreshMembers() GroupMembersMemoryCache.put(
key = membersCacheKey,
members = members,
memberInfoByKey = memberInfoByKey.toMap()
)
refreshMembers(force = true, showLoader = false)
Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Member removed", Toast.LENGTH_SHORT).show()
} else { } else {
Toast.makeText(context, "Failed to remove member", Toast.LENGTH_SHORT).show() Toast.makeText(context, "Failed to remove member", Toast.LENGTH_SHORT).show()
@@ -1117,42 +1196,85 @@ fun GroupInfoScreen(
if (showEncryptionDialog) { if (showEncryptionDialog) {
val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) } val displayLines = remember(encryptionKey) { encodeGroupKeyForDisplay(encryptionKey) }
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 keyCardColor = if (isDarkTheme) Color(0xFF1F1F22) else Color(0xFFF7F9FC)
val keyCodeColor = if (isDarkTheme) Color(0xFFC7D6EA) else Color(0xFF34495E)
AlertDialog( AlertDialog(
onDismissRequest = { showEncryptionDialog = false }, onDismissRequest = { showEncryptionDialog = false },
title = { Text("Encryption key") }, containerColor = cardColor,
shape = RoundedCornerShape(20.dp),
title = {
Text(
text = "Encryption key",
color = primaryText,
fontWeight = FontWeight.SemiBold
)
},
text = { text = {
Column { Column {
Box( Surface(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center color = keyCardColor,
shape = RoundedCornerShape(16.dp)
) { ) {
DesktopStyleKeyImage( Box(
keyRender = encryptionKey, modifier = Modifier
size = 180.dp, .fillMaxWidth()
radius = 14.dp .padding(vertical = 14.dp),
) contentAlignment = Alignment.Center
} ) {
Spacer(modifier = Modifier.height(12.dp)) DesktopStyleKeyImage(
SelectionContainer { keyRender = encryptionKey,
Column { size = 180.dp,
if (displayLines.isNotEmpty()) { radius = 14.dp,
displayLines.forEach { line -> palette = keyImagePalette
Text(
text = line,
color = secondaryText,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
Spacer(modifier = Modifier.height(10.dp))
}
Text(
text = "This key encrypts and decrypts group messages.",
color = secondaryText,
fontSize = 12.sp
) )
} }
} }
Spacer(modifier = Modifier.height(10.dp))
SelectionContainer {
Surface(
modifier = Modifier.fillMaxWidth(),
color = sectionColor,
shape = RoundedCornerShape(12.dp)
) {
Column(modifier = Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) {
if (displayLines.isNotEmpty()) {
displayLines.forEach { line ->
Text(
text = line,
color = keyCodeColor,
fontSize = 12.sp,
fontFamily = FontFamily.Monospace
)
}
}
}
}
}
Spacer(modifier = Modifier.height(10.dp))
Text(
text = "This key encrypts and decrypts group messages.",
color = secondaryText,
fontSize = 12.sp
)
} }
}, },
confirmButton = { confirmButton = {
@@ -1167,7 +1289,7 @@ fun GroupInfoScreen(
}, },
dismissButton = { dismissButton = {
TextButton(onClick = { showEncryptionDialog = false }) { TextButton(onClick = { showEncryptionDialog = false }) {
Text("Close", color = secondaryText) Text("Close", color = primaryText)
} }
} }
) )
@@ -1244,15 +1366,17 @@ fun GroupInfoScreen(
private fun DesktopStyleKeyImage( private fun DesktopStyleKeyImage(
keyRender: String, keyRender: String,
size: androidx.compose.ui.unit.Dp, size: androidx.compose.ui.unit.Dp,
radius: androidx.compose.ui.unit.Dp = 0.dp radius: androidx.compose.ui.unit.Dp = 0.dp,
palette: List<Color> = KEY_IMAGE_COLORS
) { ) {
val composition = remember(keyRender) { val colors = if (palette.isNotEmpty()) palette else KEY_IMAGE_COLORS
val composition = remember(keyRender, colors) {
buildList(64) { buildList(64) {
val source = if (keyRender.isBlank()) "rosetta" else keyRender val source = if (keyRender.isBlank()) "rosetta" else keyRender
for (i in 0 until 64) { for (i in 0 until 64) {
val code = source[i % source.length].code val code = source[i % source.length].code
val colorIndex = code % KEY_IMAGE_COLORS.size val colorIndex = code % colors.size
add(KEY_IMAGE_COLORS[colorIndex]) add(colors[colorIndex])
} }
} }
} }
@@ -1261,7 +1385,7 @@ private fun DesktopStyleKeyImage(
modifier = Modifier modifier = Modifier
.size(size) .size(size)
.clip(RoundedCornerShape(radius)) .clip(RoundedCornerShape(radius))
.background(KEY_IMAGE_COLORS.first()) .background(colors.first())
) { ) {
val cells = 8 val cells = 8
val cellSize = this.size.minDimension / cells.toFloat() val cellSize = this.size.minDimension / cells.toFloat()

View File

@@ -1,7 +1,9 @@
package com.rosetta.messenger.ui.chats package com.rosetta.messenger.ui.chats
import android.content.Context
import android.net.Uri import android.net.Uri
import android.app.Activity import android.app.Activity
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -17,7 +19,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.ime import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@@ -39,6 +40,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -75,12 +77,15 @@ import com.rosetta.messenger.data.GroupRepository
import com.rosetta.messenger.network.GroupStatus import com.rosetta.messenger.network.GroupStatus
import com.rosetta.messenger.network.SearchUser import com.rosetta.messenger.network.SearchUser
import com.rosetta.messenger.repository.AvatarRepository import com.rosetta.messenger.repository.AvatarRepository
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.AvatarImage
import com.rosetta.messenger.ui.icons.TelegramIcons import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.utils.AvatarFileManager import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.ImageCropHelper import com.rosetta.messenger.utils.ImageCropHelper
import com.rosetta.messenger.ui.settings.ProfilePhotoPicker import com.rosetta.messenger.ui.settings.ProfilePhotoPicker
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -118,6 +123,9 @@ fun GroupSetupScreen(
var errorText by rememberSaveable { mutableStateOf<String?>(null) } var errorText by rememberSaveable { mutableStateOf<String?>(null) }
var showEmojiKeyboard by rememberSaveable { mutableStateOf(false) } var showEmojiKeyboard by rememberSaveable { mutableStateOf(false) }
var showPhotoPicker by rememberSaveable { mutableStateOf(false) } var showPhotoPicker by rememberSaveable { mutableStateOf(false) }
val coordinator = rememberKeyboardTransitionCoordinator()
var lastToggleTime by remember { mutableLongStateOf(0L) }
val toggleCooldownMs = 500L
val cropLauncher = val cropLauncher =
rememberLauncherForActivityResult( rememberLauncherForActivityResult(
@@ -217,21 +225,91 @@ fun GroupSetupScreen(
val actionEnabled = if (step == GroupSetupStep.DETAILS) canGoNext else canCreate val actionEnabled = if (step == GroupSetupStep.DETAILS) canGoNext else canCreate
val density = LocalDensity.current val density = LocalDensity.current
val imeBottomPx = WindowInsets.ime.getBottom(density) val imeBottomPx = WindowInsets.ime.getBottom(density)
val navBottomPx = WindowInsets.navigationBars.getBottom(density) val imeBottomDp = with(density) { imeBottomPx.toDp() }
val keyboardHeightPx = (imeBottomPx - navBottomPx).coerceAtLeast(0) val keyboardOrEmojiHeight =
if (coordinator.isEmojiBoxVisible) coordinator.emojiHeight else imeBottomDp
val fabBottomPadding = val fabBottomPadding =
if (keyboardHeightPx > 0) { if (keyboardOrEmojiHeight > 0.dp) {
with(density) { keyboardHeightPx.toDp() } + 14.dp keyboardOrEmojiHeight + 14.dp
} else { } else {
18.dp 18.dp
} }
LaunchedEffect(step) { LaunchedEffect(step) {
if (step != GroupSetupStep.DETAILS) { if (step != GroupSetupStep.DETAILS) {
showEmojiKeyboard = false if (showEmojiKeyboard || coordinator.isEmojiVisible || coordinator.isEmojiBoxVisible) {
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false })
}
} }
} }
LaunchedEffect(Unit) {
val savedPx = KeyboardHeightProvider.getSavedKeyboardHeight(context)
if (savedPx > 0) {
coordinator.initializeEmojiHeight(with(density) { savedPx.toDp() })
}
}
var lastStableKeyboardHeight by remember { mutableStateOf(0.dp) }
LaunchedEffect(imeBottomPx) {
val currentImeHeight = with(density) { imeBottomPx.toDp() }
coordinator.updateKeyboardHeight(currentImeHeight)
if (currentImeHeight > 100.dp) {
coordinator.syncHeights()
lastStableKeyboardHeight = currentImeHeight
}
}
fun toggleEmojiPicker() {
val now = System.currentTimeMillis()
if (now - lastToggleTime < toggleCooldownMs || step != GroupSetupStep.DETAILS || isLoading) return
lastToggleTime = now
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
if (coordinator.isEmojiVisible) {
coordinator.requestShowKeyboard(
showKeyboard = {
nameFocusRequester.requestFocus()
keyboardController?.show()
@Suppress("DEPRECATION")
imm.showSoftInput(view, InputMethodManager.SHOW_IMPLICIT)
},
hideEmoji = { showEmojiKeyboard = false }
)
return
}
if (imeBottomPx > 0) {
coordinator.requestShowEmoji(
hideKeyboard = {
imm.hideSoftInputFromWindow(view.windowToken, 0)
keyboardController?.hide()
},
showEmoji = {
focusManager.clearFocus(force = true)
showEmojiKeyboard = true
}
)
return
}
if (coordinator.emojiHeight == 0.dp) {
val fallbackHeight = if (lastStableKeyboardHeight > 0.dp) {
lastStableKeyboardHeight
} else {
with(density) { KeyboardHeightProvider.getSavedKeyboardHeight(context).toDp() }
}
coordinator.initializeEmojiHeight(fallbackHeight)
}
coordinator.openEmojiOnly(
showEmoji = {
focusManager.clearFocus(force = true)
showEmojiKeyboard = true
}
)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (step == GroupSetupStep.DETAILS) { if (step == GroupSetupStep.DETAILS) {
delay(120) delay(120)
@@ -270,16 +348,22 @@ fun GroupSetupScreen(
) )
}, },
bottomBar = { bottomBar = {
if (step == GroupSetupStep.DETAILS && showEmojiKeyboard) { if (step == GroupSetupStep.DETAILS) {
OptimizedEmojiPicker( AnimatedKeyboardTransition(
isVisible = true, coordinator = coordinator,
isDarkTheme = isDarkTheme, showEmojiPicker = showEmojiKeyboard
onEmojiSelected = { emojiCode -> ) {
val emoji = decodeEmojiCodeToUnicode(emojiCode) OptimizedEmojiPicker(
title = (title + emoji).take(80) isVisible = true,
}, isDarkTheme = isDarkTheme,
modifier = Modifier.fillMaxWidth().navigationBarsPadding() onEmojiSelected = { emojiCode ->
) val emoji = decodeEmojiCodeToUnicode(emojiCode)
title = (title + emoji).take(80)
},
onClose = { toggleEmojiPicker() },
modifier = Modifier.fillMaxWidth()
)
}
} }
} }
) { paddingValues -> ) { paddingValues ->
@@ -366,8 +450,11 @@ fun GroupSetupScreen(
.fillMaxWidth() .fillMaxWidth()
.focusRequester(nameFocusRequester) .focusRequester(nameFocusRequester)
.onFocusChanged { focusState -> .onFocusChanged { focusState ->
if (focusState.isFocused) { if (focusState.isFocused &&
showEmojiKeyboard = false showEmojiKeyboard &&
!coordinator.isTransitioning
) {
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false })
} }
} }
.padding(vertical = 2.dp), .padding(vertical = 2.dp),
@@ -391,21 +478,15 @@ fun GroupSetupScreen(
} }
IconButton( IconButton(
onClick = { onClick = { toggleEmojiPicker() },
if (showEmojiKeyboard) {
showEmojiKeyboard = false
nameFocusRequester.requestFocus()
keyboardController?.show()
} else {
showEmojiKeyboard = true
focusManager.clearFocus(force = true)
keyboardController?.hide()
}
},
enabled = !isLoading enabled = !isLoading
) { ) {
Icon( Icon(
painter = TelegramIcons.Smile, painter = if (showEmojiKeyboard || coordinator.isEmojiBoxVisible) {
TelegramIcons.Keyboard
} else {
TelegramIcons.Smile
},
contentDescription = "Emoji", contentDescription = "Emoji",
tint = secondaryTextColor, tint = secondaryTextColor,
modifier = Modifier.size(22.dp) modifier = Modifier.size(22.dp)