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

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

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