diff --git a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt index ff1d259..45b88a3 100644 --- a/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/GroupRepository.kt @@ -1,6 +1,7 @@ package com.rosetta.messenger.data import android.content.Context +import android.util.Log import com.rosetta.messenger.crypto.CryptoManager import com.rosetta.messenger.database.GroupEntity import com.rosetta.messenger.database.MessageEntity @@ -23,6 +24,7 @@ import kotlin.coroutines.resume class GroupRepository private constructor(context: Context) { + private val appContext = context.applicationContext private val db = RosettaDatabase.getDatabase(context.applicationContext) private val groupDao = db.groupDao() private val messageDao = db.messageDao() @@ -31,9 +33,11 @@ class GroupRepository private constructor(context: Context) { private val inviteInfoCache = ConcurrentHashMap() companion object { + private const val TAG = "GroupRepository" private const val GROUP_PREFIX = "#group:" private const val GROUP_INVITE_PASSWORD = "rosetta_group" private const val GROUP_WAIT_TIMEOUT_MS = 15_000L + private const val GROUP_CREATED_MARKER = "\$a=Group created" @Volatile private var INSTANCE: GroupRepository? = null @@ -232,7 +236,20 @@ class GroupRepository private constructor(context: Context) { return GroupJoinResult(success = false, error = "Failed to construct invite") } - return joinGroup(accountPublicKey, accountPrivateKey, invite) + val joinResult = joinGroup(accountPublicKey, accountPrivateKey, invite) + + if (joinResult.success) { + val dialogPublicKey = joinResult.dialogPublicKey + if (!dialogPublicKey.isNullOrBlank()) { + emitGroupCreatedMarker( + accountPublicKey = accountPublicKey, + accountPrivateKey = accountPrivateKey, + dialogPublicKey = dialogPublicKey + ) + } + } + + return joinResult } suspend fun joinGroup( @@ -455,6 +472,23 @@ class GroupRepository private constructor(context: Context) { ) } + private suspend fun emitGroupCreatedMarker( + accountPublicKey: String, + accountPrivateKey: String, + dialogPublicKey: String + ) { + try { + val messages = MessageRepository.getInstance(appContext) + messages.initialize(accountPublicKey, accountPrivateKey) + messages.sendMessage( + toPublicKey = dialogPublicKey, + text = GROUP_CREATED_MARKER + ) + } catch (e: Exception) { + Log.w(TAG, "Failed to emit group-created marker for sync visibility", e) + } + } + private fun buildStoredGroupKey(groupKey: String, privateKey: String): String { val encrypted = CryptoManager.encryptWithPassword(groupKey, privateKey) return "group:$encrypted" diff --git a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt index be22180..859e8bd 100644 --- a/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/data/MessageRepository.kt @@ -767,7 +767,7 @@ class MessageRepository private constructor(private val context: Context) { // 📸 Обрабатываем AVATAR attachments: // в личке — сохраняем аватар отправителя, в группе — аватар группы (desktop parity) val avatarOwnerKey = - if (isGroupMessage) packet.toPublicKey else packet.fromPublicKey + if (isGroupMessage) toGroupDialogPublicKey(packet.toPublicKey) else packet.fromPublicKey processAvatarAttachments( packet.attachments, avatarOwnerKey, @@ -1179,6 +1179,11 @@ class MessageRepository private constructor(private val context: Context) { } } + private fun toGroupDialogPublicKey(value: String): String { + val groupId = normalizeGroupId(value) + return if (groupId.isBlank()) value.trim() else "#group:$groupId" + } + private fun buildStoredGroupKey(groupKey: String, privateKey: String): String { return "group:${CryptoManager.encryptWithPassword(groupKey, privateKey)}" } diff --git a/app/src/main/java/com/rosetta/messenger/database/AvatarEntities.kt b/app/src/main/java/com/rosetta/messenger/database/AvatarEntities.kt index 745e9f9..efda353 100644 --- a/app/src/main/java/com/rosetta/messenger/database/AvatarEntities.kt +++ b/app/src/main/java/com/rosetta/messenger/database/AvatarEntities.kt @@ -42,12 +42,18 @@ interface AvatarDao { */ @Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC") fun getAvatars(publicKey: String): Flow> + + @Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC") + fun getAvatarsByKeys(publicKeys: List): Flow> /** * Получить последний аватар пользователя */ @Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC LIMIT 1") suspend fun getLatestAvatar(publicKey: String): AvatarCacheEntity? + + @Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC LIMIT 1") + suspend fun getLatestAvatarByKeys(publicKeys: List): AvatarCacheEntity? /** * Получить последний аватар пользователя как Flow diff --git a/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt b/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt index 57d4111..5012006 100644 --- a/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt +++ b/app/src/main/java/com/rosetta/messenger/repository/AvatarRepository.kt @@ -53,6 +53,36 @@ class AvatarRepository( return false } } + + private fun normalizeOwnerKey(publicKey: String): String { + val trimmed = publicKey.trim() + if (trimmed.isBlank()) return trimmed + return when { + trimmed.startsWith("#group:") -> { + val groupId = trimmed.removePrefix("#group:").trim() + if (groupId.isBlank()) trimmed else "#group:$groupId" + } + trimmed.startsWith("group:", ignoreCase = true) -> { + val groupId = trimmed.substringAfter(':').trim() + if (groupId.isBlank()) trimmed else "#group:$groupId" + } + else -> trimmed + } + } + + private fun lookupKeys(publicKey: String): List { + val normalized = normalizeOwnerKey(publicKey) + if (normalized.isBlank()) return emptyList() + val keys = linkedSetOf(normalized) + if (normalized.startsWith("#group:")) { + keys.add(normalized.removePrefix("#group:")) + } + val trimmed = publicKey.trim() + if (trimmed.isNotBlank()) { + keys.add(trimmed) + } + return keys.toList() + } /** * Получить аватары пользователя @@ -60,14 +90,20 @@ class AvatarRepository( * @param allDecode true = вся история, false = только последний (для списков) */ fun getAvatars(publicKey: String, allDecode: Boolean = false): StateFlow> { + val normalizedKey = normalizeOwnerKey(publicKey) + val keys = lookupKeys(publicKey) + if (normalizedKey.isBlank() || keys.isEmpty()) { + return MutableStateFlow(emptyList()).asStateFlow() + } + // Проверяем LRU cache (accessOrder=true обновляет позицию при get) - memoryCache[publicKey]?.let { return it.flow.asStateFlow() } + memoryCache[normalizedKey]?.let { return it.flow.asStateFlow() } // Создаем новый flow для этого пользователя val flow = MutableStateFlow>(emptyList()) // Подписываемся на изменения в БД - val job = avatarDao.getAvatars(publicKey) + val job = avatarDao.getAvatarsByKeys(keys) .onEach { entities -> val avatars = if (allDecode) { // Параллельная загрузка всей истории @@ -86,7 +122,7 @@ class AvatarRepository( } .launchIn(repositoryScope) - memoryCache[publicKey] = CacheEntry(flow, job) + memoryCache[normalizedKey] = CacheEntry(flow, job) return flow.asStateFlow() } @@ -94,7 +130,9 @@ class AvatarRepository( * Получить последний аватар пользователя (suspend версия) */ suspend fun getLatestAvatar(publicKey: String): AvatarInfo? { - val entity = avatarDao.getLatestAvatar(publicKey) ?: return null + val keys = lookupKeys(publicKey) + if (keys.isEmpty()) return null + val entity = avatarDao.getLatestAvatarByKeys(keys) ?: return null return loadAndDecryptAvatar(entity) } @@ -108,22 +146,24 @@ class AvatarRepository( suspend fun saveAvatar(fromPublicKey: String, base64Image: String) { withContext(Dispatchers.IO) { try { + val ownerKey = normalizeOwnerKey(fromPublicKey) + if (ownerKey.isBlank()) return@withContext // Сохраняем файл - val filePath = AvatarFileManager.saveAvatar(context, base64Image, fromPublicKey) + val filePath = AvatarFileManager.saveAvatar(context, base64Image, ownerKey) // Сохраняем в БД val entity = AvatarCacheEntity( - publicKey = fromPublicKey, + publicKey = ownerKey, avatar = filePath, timestamp = System.currentTimeMillis() ) avatarDao.insertAvatar(entity) // Очищаем старые аватары (оставляем только последние N) - avatarDao.deleteOldAvatars(fromPublicKey, MAX_AVATAR_HISTORY) + avatarDao.deleteOldAvatars(ownerKey, MAX_AVATAR_HISTORY) // 🔄 Обновляем memory cache если он существует - val cached = memoryCache[fromPublicKey] + val cached = memoryCache[ownerKey] if (cached != null) { val avatarInfo = loadAndDecryptAvatar(entity) if (avatarInfo != null) { diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt index 447c75f..7e4af98 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/ChatDetailScreen.kt @@ -2,7 +2,13 @@ package com.rosetta.messenger.ui.chats import android.app.Activity import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.Shader +import android.graphics.drawable.BitmapDrawable import android.net.Uri +import android.view.Gravity +import android.view.View import android.view.inputmethod.InputMethodManager import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult @@ -66,6 +72,7 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.viewinterop.AndroidView import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleEventObserver import androidx.lifecycle.viewmodel.compose.viewModel @@ -187,6 +194,10 @@ fun ChatDetailScreen( // 🔥 MESSAGE SELECTION STATE - для Reply/Forward var selectedMessages by remember { mutableStateOf>(emptySet()) } val isSelectionMode = selectedMessages.isNotEmpty() + // После long press AndroidView текста может прислать tap на отпускание. + // В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался. + var longPressSuppressedMessageId by remember { mutableStateOf(null) } + var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) } // 💬 MESSAGE CONTEXT MENU STATE var contextMenuMessage by remember { mutableStateOf(null) } @@ -211,13 +222,22 @@ fun ChatDetailScreen( } } - // 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager - val hideKeyboardAndBack: () -> Unit = { - // Используем нативный InputMethodManager для МГНОВЕННОГО закрытия + val hideInputOverlays: () -> Unit = { val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() + window?.let { win -> + androidx.core.view.WindowCompat.getInsetsController(win, view) + .hide(androidx.core.view.WindowInsetsCompat.Type.ime()) + } + keyboardController?.hide() + focusManager.clearFocus(force = true) + showEmojiPicker = false + } + + // 🔥 Закрытие экрана - мгновенно прячем клавиатуру через InputMethodManager + val hideKeyboardAndBack: () -> Unit = { + hideInputOverlays() onBack() } @@ -229,10 +249,7 @@ fun ChatDetailScreen( else user.title.ifEmpty { user.publicKey.take(10) } val openDialogInfo: () -> Unit = { - val imm = - context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager - imm.hideSoftInputFromWindow(view.windowToken, 0) - focusManager.clearFocus() + hideInputOverlays() showContextMenu = false contextMenuMessage = null if (isGroupChat) { @@ -532,6 +549,31 @@ fun ChatDetailScreen( } } + // Long press должен только включать selection для сообщения (идемпотентно), + // иначе при двойном колбэке (text + bubble) сообщение мгновенно "откатывается". + val selectMessageOnLongPress: (messageId: String, canSelect: Boolean) -> Unit = + { messageId, canSelect -> + if (canSelect && !selectedMessages.contains(messageId)) { + selectedMessages = selectedMessages + messageId + } + } + val suppressTapAfterLongPress: (messageId: String) -> Unit = + { messageId -> + longPressSuppressedMessageId = messageId + longPressSuppressUntilMs = System.currentTimeMillis() + 350L + } + val shouldIgnoreTapAfterLongPress: (messageId: String) -> Boolean = + { messageId -> + val now = System.currentTimeMillis() + val isSuppressed = + longPressSuppressedMessageId == messageId && + now <= longPressSuppressUntilMs + if (isSuppressed || now > longPressSuppressUntilMs) { + longPressSuppressedMessageId = null + } + isSuppressed + } + // 🔥 ПАГИНАЦИЯ: Загружаем старые сообщения при прокрутке вверх // NOTE: Не нужен ручной scrollToItem - LazyColumn с reverseLayout=true // автоматически сохраняет позицию благодаря стабильным ключам (key = message.id) @@ -1254,12 +1296,8 @@ fun ChatDetailScreen( Box { IconButton( onClick = { - // Закрываем - // клавиатуру перед открытием меню - keyboardController - ?.hide() - focusManager - .clearFocus() + // Закрываем клавиатуру/emoji перед открытием меню + hideInputOverlays() showMenu = true }, @@ -1305,6 +1343,7 @@ fun ChatDetailScreen( onGroupInfoClick = { showMenu = false + hideInputOverlays() onGroupInfoClick( user ) @@ -1312,6 +1351,7 @@ fun ChatDetailScreen( onSearchMembersClick = { showMenu = false + hideInputOverlays() onGroupInfoClick( user ) @@ -1875,11 +1915,10 @@ fun ChatDetailScreen( // Keep wallpaper on a fixed full-screen layer so it doesn't rescale // when content paddings (bottom bar/IME) change. if (chatWallpaperResId != null) { - Image( - painter = painterResource(id = chatWallpaperResId), - contentDescription = "Chat wallpaper", + TiledChatWallpaper( + wallpaperResId = chatWallpaperResId, modifier = Modifier.matchParentSize(), - contentScale = ContentScale.Crop + tileScale = 0.9f ) } else { Box( @@ -2233,12 +2272,21 @@ fun ChatDetailScreen( .clearFocus() showEmojiPicker = false - toggleMessageSelection( + selectMessageOnLongPress( selectionKey, true ) + suppressTapAfterLongPress( + selectionKey + ) }, onClick = { + if (shouldIgnoreTapAfterLongPress( + selectionKey + ) + ) { + return@MessageBubble + } val hasAvatar = message.attachments .any { @@ -3018,3 +3066,62 @@ fun ChatDetailScreen( } // Закрытие outer Box } + +@Composable +private fun TiledChatWallpaper( + wallpaperResId: Int, + modifier: Modifier = Modifier, + tileScale: Float = 0.9f +) { + val context = LocalContext.current + val wallpaperDrawable = + remember(wallpaperResId, tileScale, context) { + val decoded = BitmapFactory.decodeResource(context.resources, wallpaperResId) + val normalizedScale = tileScale.coerceIn(0.2f, 2f) + + val scaledBitmap = + decoded?.let { original -> + if (normalizedScale == 1f) { + original + } else { + val width = + (original.width * normalizedScale) + .toInt() + .coerceAtLeast(1) + val height = + (original.height * normalizedScale) + .toInt() + .coerceAtLeast(1) + val scaled = + Bitmap.createScaledBitmap( + original, + width, + height, + true + ) + if (scaled != original) { + original.recycle() + } + scaled + } + } + + val safeBitmap = + scaledBitmap + ?: Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + .apply { + eraseColor(android.graphics.Color.TRANSPARENT) + } + + BitmapDrawable(context.resources, safeBitmap).apply { + setTileModeXY(Shader.TileMode.REPEAT, Shader.TileMode.REPEAT) + gravity = Gravity.TOP or Gravity.START + } + } + + AndroidView( + modifier = modifier, + factory = { ctx -> View(ctx).apply { background = wallpaperDrawable } }, + update = { view -> view.background = wallpaperDrawable } + ) +} 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 9b31a02..a7de082 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 @@ -1,8 +1,12 @@ package com.rosetta.messenger.ui.chats import android.app.Activity +import android.content.Context +import android.view.inputmethod.InputMethodManager import android.widget.Toast import androidx.activity.compose.BackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut @@ -88,6 +92,7 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.platform.LocalView @@ -131,8 +136,14 @@ import com.rosetta.messenger.ui.components.AvatarImage import com.rosetta.messenger.ui.components.SharedMediaFastScrollOverlay import com.rosetta.messenger.ui.components.VerifiedBadge import com.rosetta.messenger.ui.icons.TelegramIcons +import com.rosetta.messenger.ui.settings.FullScreenAvatarViewer +import com.rosetta.messenger.ui.settings.ProfilePhotoPicker +import com.rosetta.messenger.utils.AvatarFileManager +import com.rosetta.messenger.utils.ImageCropHelper import androidx.lifecycle.viewmodel.compose.viewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -238,6 +249,7 @@ fun GroupInfoScreen( ) { val context = androidx.compose.ui.platform.LocalContext.current val view = LocalView.current + val focusManager = LocalFocusManager.current val clipboardManager = androidx.compose.ui.platform.LocalClipboardManager.current val hapticFeedback = LocalHapticFeedback.current val scope = rememberCoroutineScope() @@ -254,6 +266,19 @@ fun GroupInfoScreen( val accentColor = if (isDarkTheme) Color(0xFF5AA5FF) else Color(0xFF228BE6) val actionContentColor = if (isDarkTheme) Color.White else Color(0xFF1C1C1E) + LaunchedEffect(Unit) { + val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager + repeat(3) { + imm.hideSoftInputFromWindow(view.windowToken, 0) + (context as? Activity)?.window?.let { window -> + WindowCompat.getInsetsController(window, view) + .hide(androidx.core.view.WindowInsetsCompat.Type.ime()) + } + focusManager.clearFocus(force = true) + delay(16) + } + } + // Keep status bar unified with group header color. DisposableEffect(topSurfaceColor, view) { val window = (view.context as? Activity)?.window @@ -301,6 +326,10 @@ fun GroupInfoScreen( var encryptionKeyLoading by remember { mutableStateOf(false) } var membersLoading by remember { mutableStateOf(false) } var isMuted by remember { mutableStateOf(false) } + var showGroupAvatarPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } + var showGroupAvatarViewer by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } + var groupAvatarViewerTimestamp by rememberSaveable(dialogPublicKey) { mutableStateOf(0L) } + var groupAvatarViewerBitmap by remember(dialogPublicKey) { mutableStateOf(null) } var showAddMembersPicker by rememberSaveable(dialogPublicKey) { mutableStateOf(false) } var pendingInviteText by rememberSaveable(dialogPublicKey) { mutableStateOf("") } var isRefreshingMembers by remember(dialogPublicKey) { mutableStateOf(false) } @@ -390,6 +419,49 @@ fun GroupInfoScreen( groupEntity?.description?.trim().orEmpty() } + val cropLauncher = + rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val croppedUri = ImageCropHelper.getCroppedImageUri(result) + val cropError = ImageCropHelper.getCropError(result) + if (croppedUri != null) { + scope.launch { + val preparedBase64 = + withContext(Dispatchers.IO) { + val imageBytes = + runCatching { + context.contentResolver.openInputStream(croppedUri)?.use { stream -> + stream.readBytes() + } + }.getOrNull() + imageBytes?.let { bytes -> + val prepared = AvatarFileManager.imagePrepareForNetworkTransfer(context, bytes) + if (prepared.isBlank()) null else "data:image/png;base64,$prepared" + } + } + + val repository = avatarRepository + val saved = + preparedBase64 != null && + repository != null && + runCatching { + withContext(Dispatchers.IO) { + repository.saveAvatar(dialogPublicKey, preparedBase64) + } + }.isSuccess + + Toast.makeText( + context, + if (saved) "Group avatar updated" else "Failed to update group avatar", + Toast.LENGTH_SHORT + ).show() + } + } else if (cropError != null) { + Toast.makeText(context, "Failed to crop photo", Toast.LENGTH_SHORT).show() + } + } + LaunchedEffect(currentUserPublicKey, dialogPublicKey) { if (currentUserPublicKey.isNotBlank() && dialogPublicKey.isNotBlank()) { isMuted = preferencesManager.isChatMuted(currentUserPublicKey, dialogPublicKey) @@ -581,6 +653,20 @@ fun GroupInfoScreen( members.firstOrNull()?.trim()?.equals(normalizedCurrentUserKey, ignoreCase = true) == true } } + + fun openGroupAvatarViewer() { + val repository = avatarRepository ?: return + scope.launch { + val latestAvatar = repository.getAvatars(dialogPublicKey, allDecode = false).first().firstOrNull() + ?: return@launch + groupAvatarViewerTimestamp = latestAvatar.timestamp / 1000L + groupAvatarViewerBitmap = + withContext(Dispatchers.IO) { + AvatarFileManager.base64ToBitmap(latestAvatar.base64Data) + } + showGroupAvatarViewer = true + } + } var swipedMemberKey by remember(dialogPublicKey) { mutableStateOf(null) } var memberToKick by remember(dialogPublicKey) { mutableStateOf(null) } var isKickingMember by remember(dialogPublicKey) { mutableStateOf(false) } @@ -861,6 +947,13 @@ fun GroupInfoScreen( avatarRepository = avatarRepository, size = 86.dp, isDarkTheme = isDarkTheme, + onClick = { + if (currentUserIsAdmin) { + showGroupAvatarPicker = true + } else { + openGroupAvatarViewer() + } + }, displayName = groupTitle ) @@ -1408,6 +1501,27 @@ fun GroupInfoScreen( } ) } + + ProfilePhotoPicker( + isVisible = showGroupAvatarPicker, + onDismiss = { showGroupAvatarPicker = false }, + onPhotoSelected = { uri -> + showGroupAvatarPicker = false + val cropIntent = ImageCropHelper.createCropIntent(context, uri, isDarkTheme) + cropLauncher.launch(cropIntent) + }, + isDarkTheme = isDarkTheme + ) + + FullScreenAvatarViewer( + isVisible = showGroupAvatarViewer, + onDismiss = { showGroupAvatarViewer = false }, + displayName = groupTitle.ifBlank { shortPublicKey(dialogPublicKey) }, + avatarTimestamp = groupAvatarViewerTimestamp, + avatarBitmap = groupAvatarViewerBitmap, + publicKey = dialogPublicKey, + isDarkTheme = isDarkTheme + ) } @Composable diff --git a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt index b860866..9420f29 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/chats/components/AttachmentComponents.kt @@ -80,6 +80,19 @@ private val whitespaceRegex = "\\s+".toRegex() private fun isGroupStoredKey(value: String): Boolean = value.startsWith("group:") +private fun canonicalGroupDialogKey(value: String): String { + val trimmed = value.trim() + if (trimmed.isBlank()) return "" + val groupId = + when { + trimmed.startsWith("#group:") -> trimmed.removePrefix("#group:").trim() + trimmed.startsWith("group:", ignoreCase = true) -> + trimmed.substringAfter(':').trim() + else -> return trimmed + } + return if (groupId.isBlank()) "" else "#group:$groupId" +} + private fun decodeGroupPassword(storedKey: String, privateKey: String): String? { if (!isGroupStoredKey(storedKey)) return null val encoded = storedKey.removePrefix("group:") @@ -1931,11 +1944,12 @@ fun AvatarAttachment( // Если это исходящее сообщение с аватаром, сохраняем для текущего // пользователя val normalizedDialogKey = dialogPublicKey.trim() + val canonicalDialogKey = canonicalGroupDialogKey(normalizedDialogKey) val isGroupAvatarAttachment = isGroupChat || isGroupStoredKey(chachaKey) val targetPublicKey = when { - isGroupAvatarAttachment && normalizedDialogKey.isNotEmpty() -> - normalizedDialogKey + isGroupAvatarAttachment && canonicalDialogKey.isNotEmpty() -> + canonicalDialogKey isOutgoing && currentUserPublicKey.isNotEmpty() -> currentUserPublicKey else -> senderPublicKey diff --git a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt index f771de8..b9608d1 100644 --- a/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt +++ b/app/src/main/java/com/rosetta/messenger/ui/settings/ThemeScreen.kt @@ -575,7 +575,7 @@ private fun ChatPreview(isDarkTheme: Boolean, wallpaperId: String) { painter = painterResource(id = wallpaperResId), contentDescription = "Chat wallpaper preview", modifier = Modifier.fillMaxSize(), - contentScale = ContentScale.Crop + contentScale = ContentScale.FillBounds ) }