Исправлены синхронизация групп, выделение сообщений и фон чата
This commit is contained in:
@@ -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 }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user