Исправлены синхронизация групп, выделение сообщений и фон чата

This commit is contained in:
2026-03-07 23:43:09 +05:00
parent 364b166581
commit 85bddb798c
8 changed files with 352 additions and 32 deletions

View File

@@ -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<String, GroupInviteInfoResult>()
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"

View File

@@ -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)}"
}

View File

@@ -42,12 +42,18 @@ interface AvatarDao {
*/
@Query("SELECT * FROM avatar_cache WHERE public_key = :publicKey ORDER BY timestamp DESC")
fun getAvatars(publicKey: String): Flow<List<AvatarCacheEntity>>
@Query("SELECT * FROM avatar_cache WHERE public_key IN (:publicKeys) ORDER BY timestamp DESC")
fun getAvatarsByKeys(publicKeys: List<String>): Flow<List<AvatarCacheEntity>>
/**
* Получить последний аватар пользователя
*/
@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<String>): AvatarCacheEntity?
/**
* Получить последний аватар пользователя как Flow

View File

@@ -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<String> {
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<List<AvatarInfo>> {
val normalizedKey = normalizeOwnerKey(publicKey)
val keys = lookupKeys(publicKey)
if (normalizedKey.isBlank() || keys.isEmpty()) {
return MutableStateFlow(emptyList<AvatarInfo>()).asStateFlow()
}
// Проверяем LRU cache (accessOrder=true обновляет позицию при get)
memoryCache[publicKey]?.let { return it.flow.asStateFlow() }
memoryCache[normalizedKey]?.let { return it.flow.asStateFlow() }
// Создаем новый flow для этого пользователя
val flow = MutableStateFlow<List<AvatarInfo>>(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) {

View File

@@ -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<Set<String>>(emptySet()) }
val isSelectionMode = selectedMessages.isNotEmpty()
// После long press AndroidView текста может прислать tap на отпускание.
// В этом окне игнорируем tap по этому же сообщению, чтобы selection не снимался.
var longPressSuppressedMessageId by remember { mutableStateOf<String?>(null) }
var longPressSuppressUntilMs by remember { mutableLongStateOf(0L) }
// 💬 MESSAGE CONTEXT MENU STATE
var contextMenuMessage by remember { mutableStateOf<ChatMessage?>(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 }
)
}

View File

@@ -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<android.graphics.Bitmap?>(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<String?>(null) }
var memberToKick by remember(dialogPublicKey) { mutableStateOf<GroupMemberUi?>(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

View File

@@ -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

View File

@@ -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
)
}