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 4fe05e0..3eaad20 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 @@ -149,6 +149,40 @@ private data class GroupSharedStats( val linksCount: Int = 0 ) +private data class GroupMembersCacheEntry( + val members: List, + val memberInfoByKey: Map, + val updatedAtMs: Long +) + +private object GroupMembersMemoryCache { + private const val TTL_MS = 90_000L + private val cache = mutableMapOf() + + 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, memberInfoByKey: Map) { + 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>(emptyList()) } val memberInfoByKey = remember(dialogPublicKey) { mutableStateMapOf() } + val membersCacheKey = remember(currentUserPublicKey, normalizedGroupId) { + "${currentUserPublicKey.trim().lowercase()}|${normalizedGroupId.trim().lowercase()}" + } val groupEntity by produceState( 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() - 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() + 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 = 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() diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt index 87ac59d..1e6f674 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/GroupSetupScreen.kt @@ -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(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)