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
)
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(
val key: String,
val attachment: MessageAttachment,
@@ -249,9 +283,13 @@ fun GroupInfoScreen(
var isMuted by remember { mutableStateOf(false) }
var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) }
var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") }
var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) }
var members by remember(dialogPublicKey) { mutableStateOf<List<String>>(emptyList()) }
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?>(
initialValue = null,
@@ -341,41 +379,76 @@ fun GroupInfoScreen(
}
}
fun refreshMembers() {
fun refreshMembers(force: Boolean = false, showLoader: Boolean = true) {
if (normalizedGroupId.isBlank()) return
if (isRefreshingMembers && !force) return
scope.launch {
membersLoading = true
val fetchedMembers = withContext(Dispatchers.IO) {
groupRepository.requestGroupMembers(normalizedGroupId).orEmpty()
if (!force) {
GroupMembersMemoryCache.getFresh(membersCacheKey)?.let { cached ->
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) {
val resolvedMap = LinkedHashMap<String, SearchUser>()
members.forEach { memberKey ->
val cached = ProtocolManager.getCachedUserInfo(memberKey)
if (cached != null) {
resolvedMap[memberKey] = cached
} else {
ProtocolManager.resolveUserInfo(memberKey, timeoutMs = 2500L)?.let { resolvedUser ->
resolvedMap[memberKey] = resolvedUser
if (members.isEmpty()) return@launch
val resolvedUsers = withContext(Dispatchers.IO) {
val resolvedMap = LinkedHashMap<String, SearchUser>()
members.forEach { memberKey ->
val cached = ProtocolManager.getCachedUserInfo(memberKey)
if (cached != null) {
resolvedMap[memberKey] = cached
} 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) {
refreshMembers()
LaunchedEffect(membersCacheKey) {
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) {
@@ -541,6 +614,7 @@ fun GroupInfoScreen(
}
isLeaving = false
if (left) {
GroupMembersMemoryCache.remove(membersCacheKey)
onGroupLeft()
} else {
Toast.makeText(context, "Failed to leave group", Toast.LENGTH_SHORT).show()
@@ -580,7 +654,12 @@ fun GroupInfoScreen(
if (removed) {
members = members.filterNot { it.trim().equals(memberKey, ignoreCase = true) }
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()
} else {
Toast.makeText(context, "Failed to remove member", Toast.LENGTH_SHORT).show()
@@ -1117,42 +1196,85 @@ fun GroupInfoScreen(
if (showEncryptionDialog) {
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(
onDismissRequest = { showEncryptionDialog = false },
title = { Text("Encryption key") },
containerColor = cardColor,
shape = RoundedCornerShape(20.dp),
title = {
Text(
text = "Encryption key",
color = primaryText,
fontWeight = FontWeight.SemiBold
)
},
text = {
Column {
Box(
Surface(
modifier = Modifier.fillMaxWidth(),
contentAlignment = Alignment.Center
color = keyCardColor,
shape = RoundedCornerShape(16.dp)
) {
DesktopStyleKeyImage(
keyRender = encryptionKey,
size = 180.dp,
radius = 14.dp
)
}
Spacer(modifier = Modifier.height(12.dp))
SelectionContainer {
Column {
if (displayLines.isNotEmpty()) {
displayLines.forEach { line ->
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
Box(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 14.dp),
contentAlignment = Alignment.Center
) {
DesktopStyleKeyImage(
keyRender = encryptionKey,
size = 180.dp,
radius = 14.dp,
palette = keyImagePalette
)
}
}
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 = {
@@ -1167,7 +1289,7 @@ fun GroupInfoScreen(
},
dismissButton = {
TextButton(onClick = { showEncryptionDialog = false }) {
Text("Close", color = secondaryText)
Text("Close", color = primaryText)
}
}
)
@@ -1244,15 +1366,17 @@ fun GroupInfoScreen(
private fun DesktopStyleKeyImage(
keyRender: String,
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) {
val source = if (keyRender.isBlank()) "rosetta" else keyRender
for (i in 0 until 64) {
val code = source[i % source.length].code
val colorIndex = code % KEY_IMAGE_COLORS.size
add(KEY_IMAGE_COLORS[colorIndex])
val colorIndex = code % colors.size
add(colors[colorIndex])
}
}
}
@@ -1261,7 +1385,7 @@ private fun DesktopStyleKeyImage(
modifier = Modifier
.size(size)
.clip(RoundedCornerShape(radius))
.background(KEY_IMAGE_COLORS.first())
.background(colors.first())
) {
val cells = 8
val cellSize = this.size.minDimension / cells.toFloat()

View File

@@ -1,7 +1,9 @@
package com.rosetta.messenger.ui.chats
import android.content.Context
import android.net.Uri
import android.app.Activity
import android.view.inputmethod.InputMethodManager
import androidx.activity.compose.BackHandler
import androidx.activity.compose.rememberLauncherForActivityResult
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.height
import androidx.compose.foundation.layout.ime
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -39,6 +40,7 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableLongStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
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.SearchUser
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.AvatarImage
import com.rosetta.messenger.ui.icons.TelegramIcons
import com.rosetta.messenger.utils.AvatarFileManager
import com.rosetta.messenger.utils.ImageCropHelper
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.delay
import kotlinx.coroutines.launch
@@ -118,6 +123,9 @@ fun GroupSetupScreen(
var errorText by rememberSaveable { mutableStateOf<String?>(null) }
var showEmojiKeyboard by rememberSaveable { mutableStateOf(false) }
var showPhotoPicker by rememberSaveable { mutableStateOf(false) }
val coordinator = rememberKeyboardTransitionCoordinator()
var lastToggleTime by remember { mutableLongStateOf(0L) }
val toggleCooldownMs = 500L
val cropLauncher =
rememberLauncherForActivityResult(
@@ -217,21 +225,91 @@ fun GroupSetupScreen(
val actionEnabled = if (step == GroupSetupStep.DETAILS) canGoNext else canCreate
val density = LocalDensity.current
val imeBottomPx = WindowInsets.ime.getBottom(density)
val navBottomPx = WindowInsets.navigationBars.getBottom(density)
val keyboardHeightPx = (imeBottomPx - navBottomPx).coerceAtLeast(0)
val imeBottomDp = with(density) { imeBottomPx.toDp() }
val keyboardOrEmojiHeight =
if (coordinator.isEmojiBoxVisible) coordinator.emojiHeight else imeBottomDp
val fabBottomPadding =
if (keyboardHeightPx > 0) {
with(density) { keyboardHeightPx.toDp() } + 14.dp
if (keyboardOrEmojiHeight > 0.dp) {
keyboardOrEmojiHeight + 14.dp
} else {
18.dp
}
LaunchedEffect(step) {
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) {
if (step == GroupSetupStep.DETAILS) {
delay(120)
@@ -270,16 +348,22 @@ fun GroupSetupScreen(
)
},
bottomBar = {
if (step == GroupSetupStep.DETAILS && showEmojiKeyboard) {
OptimizedEmojiPicker(
isVisible = true,
isDarkTheme = isDarkTheme,
onEmojiSelected = { emojiCode ->
val emoji = decodeEmojiCodeToUnicode(emojiCode)
title = (title + emoji).take(80)
},
modifier = Modifier.fillMaxWidth().navigationBarsPadding()
)
if (step == GroupSetupStep.DETAILS) {
AnimatedKeyboardTransition(
coordinator = coordinator,
showEmojiPicker = showEmojiKeyboard
) {
OptimizedEmojiPicker(
isVisible = true,
isDarkTheme = isDarkTheme,
onEmojiSelected = { emojiCode ->
val emoji = decodeEmojiCodeToUnicode(emojiCode)
title = (title + emoji).take(80)
},
onClose = { toggleEmojiPicker() },
modifier = Modifier.fillMaxWidth()
)
}
}
}
) { paddingValues ->
@@ -366,8 +450,11 @@ fun GroupSetupScreen(
.fillMaxWidth()
.focusRequester(nameFocusRequester)
.onFocusChanged { focusState ->
if (focusState.isFocused) {
showEmojiKeyboard = false
if (focusState.isFocused &&
showEmojiKeyboard &&
!coordinator.isTransitioning
) {
coordinator.closeEmoji(hideEmoji = { showEmojiKeyboard = false })
}
}
.padding(vertical = 2.dp),
@@ -391,21 +478,15 @@ fun GroupSetupScreen(
}
IconButton(
onClick = {
if (showEmojiKeyboard) {
showEmojiKeyboard = false
nameFocusRequester.requestFocus()
keyboardController?.show()
} else {
showEmojiKeyboard = true
focusManager.clearFocus(force = true)
keyboardController?.hide()
}
},
onClick = { toggleEmojiPicker() },
enabled = !isLoading
) {
Icon(
painter = TelegramIcons.Smile,
painter = if (showEmojiKeyboard || coordinator.isEmojiBoxVisible) {
TelegramIcons.Keyboard
} else {
TelegramIcons.Smile
},
contentDescription = "Emoji",
tint = secondaryTextColor,
modifier = Modifier.size(22.dp)