Исправлены множественная пересылка и поведение media picker в gesture-навигации

This commit is contained in:
2026-03-13 23:11:51 +07:00
parent 160ba4e2e7
commit 179f65872d
5 changed files with 361 additions and 36 deletions

View File

@@ -2135,6 +2135,47 @@ fun ChatDetailScreen(
viewModel
.clearReplyMessages()
},
onShowForwardOptions = { panelMessages ->
if (panelMessages.isEmpty()) {
return@MessageInputBar
}
val forwardMessages =
panelMessages.map { msg ->
ForwardManager.ForwardMessage(
messageId =
msg.messageId,
text = msg.text,
timestamp =
msg.timestamp,
isOutgoing =
msg.isOutgoing,
senderPublicKey =
msg.publicKey.ifEmpty {
if (msg.isOutgoing) currentUserPublicKey
else user.publicKey
},
originalChatPublicKey =
user.publicKey,
senderName =
msg.senderName.ifEmpty {
if (msg.isOutgoing) currentUserName.ifEmpty { "You" }
else user.title.ifEmpty { user.username.ifEmpty { "User" } }
},
attachments =
msg.attachments
.filter {
it.type != AttachmentType.MESSAGES
}
.map { it.copy(localUri = "") }
)
}
ForwardManager.setForwardMessages(
forwardMessages,
showPicker = false
)
showForwardPicker = true
},
chatTitle = chatTitle,
isBlocked = isBlocked,
showEmojiPicker =
@@ -3313,12 +3354,40 @@ fun ChatDetailScreen(
}
val forwardMessages = ForwardManager.consumeForwardMessages()
ForwardManager.clear()
if (forwardMessages.isEmpty()) {
ForwardManager.clear()
return@ForwardChatPickerBottomSheet
}
// Реальная отправка forward во все выбранные чаты.
// Desktop parity: если выбран один чат, не отправляем сразу.
// Открываем чат с forward-панелью, чтобы пользователь мог
// добавить подпись и отправить вручную.
if (selectedDialogs.size == 1) {
val targetDialog = selectedDialogs.first()
ForwardManager.setForwardMessages(
forwardMessages,
showPicker = false
)
ForwardManager.selectChat(targetDialog.opponentKey)
if (targetDialog.opponentKey != user.publicKey) {
val searchUser =
SearchUser(
title = targetDialog.opponentTitle,
username =
targetDialog.opponentUsername,
publicKey = targetDialog.opponentKey,
verified = targetDialog.verified,
online = targetDialog.isOnline
)
onNavigateToChat(searchUser)
}
return@ForwardChatPickerBottomSheet
}
ForwardManager.clear()
// Мультивыбор оставляем прямой отправкой как раньше.
selectedDialogs.forEach { dialog ->
viewModel.sendForwardDirectly(
dialog.opponentKey,

View File

@@ -2243,6 +2243,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val sender = myPublicKey
val privateKey = myPrivateKey
val replyMsgs = _replyMessages.value
val replyMsgsToSend = replyMsgs.toList()
val isForward = _isForwardMode.value
// Разрешаем отправку пустого текста если есть reply/forward
@@ -2271,10 +2272,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val timestamp = System.currentTimeMillis()
// 🔥 Формируем ReplyData для отображения в UI (только первое сообщение)
// Работает и для reply, и для forward
// Используется для обычного reply (не forward).
val replyData: ReplyData? =
if (replyMsgs.isNotEmpty()) {
val firstReply = replyMsgs.first()
if (replyMsgsToSend.isNotEmpty()) {
val firstReply = replyMsgsToSend.first()
// 🖼️ Получаем attachments из текущих сообщений для превью
// Fallback на firstReply.attachments для forward из другого чата
val replyAttachments =
@@ -2302,8 +2303,38 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
)
} else null
// Сохраняем reply для отправки ПЕРЕД очисткой
val replyMsgsToSend = replyMsgs.toList()
// 📨 В forward режиме показываем ВСЕ пересылаемые сообщения в optimistic bubble,
// а не только первое. Иначе визуально выглядит как будто отправилось одно сообщение.
val optimisticForwardedMessages: List<ReplyData> =
if (isForward && replyMsgsToSend.isNotEmpty()) {
replyMsgsToSend.map { msg ->
val senderDisplayName =
if (msg.isOutgoing) "You"
else msg.senderName.ifEmpty {
opponentTitle.ifEmpty { opponentUsername.ifEmpty { "User" } }
}
val resolvedAttachments =
_messages.value.find { it.id == msg.messageId }?.attachments
?: msg.attachments.filter { it.type != AttachmentType.MESSAGES }
ReplyData(
messageId = msg.messageId,
senderName = senderDisplayName,
text = msg.text,
isFromMe = msg.isOutgoing,
isForwarded = true,
forwardedFromName = senderDisplayName,
attachments = resolvedAttachments,
senderPublicKey = msg.publicKey.ifEmpty {
if (msg.isOutgoing) myPublicKey ?: "" else opponentKey ?: ""
},
recipientPrivateKey = myPrivateKey ?: ""
)
}
} else {
emptyList()
}
// Сохраняем режим forward для отправки ПЕРЕД очисткой
val isForwardToSend = isForward
// 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble
@@ -2314,7 +2345,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isOutgoing = true,
timestamp = Date(timestamp),
status = MessageStatus.SENDING,
replyData = replyData // Данные для reply bubble
replyData = if (isForwardToSend) null else replyData,
forwardedMessages = optimisticForwardedMessages
)
// <20> Безопасное добавление с проверкой дубликатов

View File

@@ -46,6 +46,7 @@ import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.utils.NavigationModeUtils
import kotlinx.coroutines.Job
import kotlinx.coroutines.launch
import android.net.Uri
@@ -158,6 +159,9 @@ fun ChatAttachAlert(
viewModel: AttachAlertViewModel = viewModel()
) {
val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
NavigationModeUtils.hasNativeNavigationBar(context)
}
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
val focusManager = LocalFocusManager.current
@@ -204,6 +208,23 @@ fun ChatAttachAlert(
// Keyboard helpers
// ═══════════════════════════════════════════════════════════
fun hideUnderlyingChatKeyboard() {
focusManager.clearFocus(force = true)
activity?.currentFocus?.clearFocus()
val imm = context.getSystemService(Context.INPUT_METHOD_SERVICE) as InputMethodManager
val servedToken =
activity?.currentFocus?.windowToken
?: activity?.window?.decorView?.findFocus()?.windowToken
?: activity?.window?.decorView?.windowToken
servedToken?.let { token ->
imm.hideSoftInputFromWindow(token, 0)
imm.hideSoftInputFromWindow(token, InputMethodManager.HIDE_NOT_ALWAYS)
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
activity?.window?.insetsController?.hide(android.view.WindowInsets.Type.ime())
}
}
fun hideKeyboard() {
AttachAlertDebugLog.log("AttachAlert", "hideKeyboard() called, captionInputActive=$captionInputActive, shouldShow=$shouldShow, isClosing=$isClosing")
pendingCaptionFocus = false
@@ -277,10 +298,14 @@ fun ChatAttachAlert(
if (coordinator.emojiHeight == 0.dp) {
// Use saved keyboard height minus nav bar (same as spacer)
val savedPx = KeyboardHeightProvider.getSavedKeyboardHeight(context)
val navBarPx = (context as? Activity)?.window?.decorView?.let { view ->
androidx.core.view.ViewCompat.getRootWindowInsets(view)
?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
} ?: 0
val navBarPx = if (hasNativeNavigationBar) {
(context as? Activity)?.window?.decorView?.let { view ->
androidx.core.view.ViewCompat.getRootWindowInsets(view)
?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
} ?: 0
} else {
0
}
val effectivePx = if (savedPx > 0) (savedPx - navBarPx).coerceAtLeast(0) else 0
coordinator.emojiHeight = if (effectivePx > 0) {
with(density) { effectivePx.toDp() }
@@ -302,7 +327,11 @@ fun ChatAttachAlert(
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val screenHeightPx = with(density) { screenHeight.toPx() }
val navigationBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat()
val navigationBarInsetPx = if (hasNativeNavigationBar) {
WindowInsets.navigationBars.getBottom(density).toFloat()
} else {
0f
}
val statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat()
val collapsedHeightPx = screenHeightPx * 0.72f
@@ -465,6 +494,9 @@ fun ChatAttachAlert(
LaunchedEffect(showSheet) {
if (showSheet && state.editingItem == null) {
// Close chat keyboard before showing picker so no IME layer remains under it.
hideUnderlyingChatKeyboard()
kotlinx.coroutines.delay(16)
// Telegram pattern: set ADJUST_NOTHING on Activity before showing popup
// This prevents the system from resizing the layout when focus changes
activity?.window?.let { win ->
@@ -677,11 +709,20 @@ fun ChatAttachAlert(
val origNavBarColor = window?.navigationBarColor ?: 0
val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true
val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false
val origContrastEnforced =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window?.isNavigationBarContrastEnforced
} else {
null
}
onDispose {
window?.statusBarColor = origStatusBarColor
window?.navigationBarColor = origNavBarColor
insetsController?.isAppearanceLightNavigationBars = origLightNav
insetsController?.isAppearanceLightStatusBars = origLightStatus
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && origContrastEnforced != null) {
window?.isNavigationBarContrastEnforced = origContrastEnforced
}
}
} else {
onDispose { }
@@ -711,16 +752,28 @@ fun ChatAttachAlert(
window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0)
insetsController?.isAppearanceLightStatusBars = false
}
// Telegram-like: nav bar follows picker surface, not black scrim.
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
window.navigationBarColor = android.graphics.Color.argb(
navAlpha,
android.graphics.Color.red(navBaseColor),
android.graphics.Color.green(navBaseColor),
android.graphics.Color.blue(navBaseColor)
)
insetsController?.isAppearanceLightNavigationBars = !dark
if (hasNativeNavigationBar) {
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
window.navigationBarColor = android.graphics.Color.argb(
navAlpha,
android.graphics.Color.red(navBaseColor),
android.graphics.Color.green(navBaseColor),
android.graphics.Color.blue(navBaseColor)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
// Telegram-like on gesture navigation: transparent stable nav area.
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
}
}

View File

@@ -69,6 +69,7 @@ import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.components.AppleEmojiTextField
import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker
import com.rosetta.messenger.ui.utils.NavigationModeUtils
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import kotlinx.coroutines.Dispatchers
@@ -136,6 +137,9 @@ fun MediaPickerBottomSheet(
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null
) {
val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
NavigationModeUtils.hasNativeNavigationBar(context)
}
val scope = rememberCoroutineScope()
val density = LocalDensity.current
val imeInsets = WindowInsets.ime
@@ -295,7 +299,11 @@ fun MediaPickerBottomSheet(
val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp
val screenHeightPx = with(density) { screenHeight.toPx() }
val navigationBarInsetPx = WindowInsets.navigationBars.getBottom(density).toFloat()
val navigationBarInsetPx = if (hasNativeNavigationBar) {
WindowInsets.navigationBars.getBottom(density).toFloat()
} else {
0f
}
val statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat()
// 🔄 Высоты в пикселях для точного контроля
@@ -384,6 +392,9 @@ fun MediaPickerBottomSheet(
// Запускаем анимацию когда showSheet меняется
LaunchedEffect(showSheet) {
if (showSheet && editingItem == null) {
// Ensure IME from chat is closed before picker opens.
hideKeyboard()
delay(16)
shouldShow = true
isClosing = false
showAlbumMenu = false
@@ -562,12 +573,21 @@ fun MediaPickerBottomSheet(
val origNavBarColor = window?.navigationBarColor ?: 0
val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true
val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false
val origContrastEnforced =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window?.isNavigationBarContrastEnforced
} else {
null
}
onDispose {
window?.statusBarColor = origStatusBarColor
window?.navigationBarColor = origNavBarColor
insetsController?.isAppearanceLightNavigationBars = origLightNav
insetsController?.isAppearanceLightStatusBars = origLightStatus
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && origContrastEnforced != null) {
window?.isNavigationBarContrastEnforced = origContrastEnforced
}
}
} else {
onDispose { }
@@ -601,16 +621,28 @@ fun MediaPickerBottomSheet(
)
insetsController?.isAppearanceLightStatusBars = false
}
// Telegram-like: nav bar follows picker surface, not scrim.
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
window.navigationBarColor = android.graphics.Color.argb(
navAlpha,
android.graphics.Color.red(navBaseColor),
android.graphics.Color.green(navBaseColor),
android.graphics.Color.blue(navBaseColor)
)
insetsController?.isAppearanceLightNavigationBars = !dark
if (hasNativeNavigationBar) {
// Telegram-like: for 2/3-button navigation keep picker-surface tint.
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
window.navigationBarColor = android.graphics.Color.argb(
navAlpha,
android.graphics.Color.red(navBaseColor),
android.graphics.Color.green(navBaseColor),
android.graphics.Color.blue(navBaseColor)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = true
}
insetsController?.isAppearanceLightNavigationBars = !dark
} else {
// Telegram-like on gesture navigation: transparent stable nav area.
window.navigationBarColor = android.graphics.Color.TRANSPARENT
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window.isNavigationBarContrastEnforced = false
}
insetsController?.isAppearanceLightNavigationBars = !dark
}
}
}
@@ -2207,6 +2239,9 @@ fun PhotoPreviewWithCaptionScreen(
val backgroundColor = if (isDarkTheme) Color.Black else Color.White
val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
NavigationModeUtils.hasNativeNavigationBar(context)
}
val view = LocalView.current
val focusManager = LocalFocusManager.current
val density = LocalDensity.current
@@ -2291,7 +2326,8 @@ fun PhotoPreviewWithCaptionScreen(
// 🔥 КЛЮЧЕВОЕ: imePadding применяется ТОЛЬКО когда emoji box НЕ виден
val shouldUseImePadding = !coordinator.isEmojiBoxVisible
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible
val shouldAddNavBarPadding =
hasNativeNavigationBar && !isKeyboardVisible && !coordinator.isEmojiBoxVisible
// Логируем состояние при каждой рекомпозиции
Surface(

View File

@@ -36,6 +36,8 @@ 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.window.Popup
import androidx.compose.ui.window.PopupProperties
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import coil.compose.AsyncImage
@@ -88,6 +90,7 @@ fun MessageInputBar(
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
isForwardMode: Boolean = false,
onCloseReply: () -> Unit = {},
onShowForwardOptions: (List<ChatViewModel.ReplyMessage>) -> Unit = {},
chatTitle: String = "",
isBlocked: Boolean = false,
showEmojiPicker: Boolean = false,
@@ -218,6 +221,11 @@ fun MessageInputBar(
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
var isSending by remember { mutableStateOf(false) }
var showForwardCancelDialog by remember { mutableStateOf(false) }
var forwardCancelDialogCount by remember { mutableIntStateOf(0) }
var forwardCancelDialogMessages by remember {
mutableStateOf<List<ChatViewModel.ReplyMessage>>(emptyList())
}
val mentionPattern = remember { Regex("@([\\w\\d_]*)$") }
val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") }
val mentionMatch = remember(value, isGroupChat) { if (isGroupChat) mentionPattern.find(value) else null }
@@ -661,7 +669,18 @@ fun MessageInputBar(
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onCloseReply
onClick = {
if (panelIsForwardMode && panelReplyMessages.isNotEmpty()) {
val sourceMessages =
if (replyMessages.isNotEmpty()) replyMessages
else panelReplyMessages
forwardCancelDialogCount = sourceMessages.size
forwardCancelDialogMessages = sourceMessages
showForwardCancelDialog = true
} else {
onCloseReply()
}
}
),
contentAlignment = Alignment.Center
) {
@@ -765,6 +784,122 @@ fun MessageInputBar(
}
}
if (showForwardCancelDialog) {
val titleText =
if (forwardCancelDialogCount == 1) "1 message"
else "$forwardCancelDialogCount messages"
val bodyText =
"What would you like to do with $titleText from your chat with ${chatTitle.ifBlank { "this user" }}?"
val popupInteraction = remember { MutableInteractionSource() }
val cardInteraction = remember { MutableInteractionSource() }
val dividerColor = if (isDarkTheme) Color(0xFF3A3A3C) else Color(0xFFE5E5EA)
val cardColor = if (isDarkTheme) Color(0xFF2C2C2E) else Color.White
val bodyColor = if (isDarkTheme) Color(0xFFD1D1D6) else Color(0xFF3C3C43)
Popup(
alignment = Alignment.Center,
onDismissRequest = {
showForwardCancelDialog = false
forwardCancelDialogMessages = emptyList()
},
properties =
PopupProperties(
focusable = false,
dismissOnBackPress = true,
dismissOnClickOutside = true,
clippingEnabled = false
)
) {
Box(
modifier =
Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.36f))
.clickable(
interactionSource = popupInteraction,
indication = null
) { showForwardCancelDialog = false },
contentAlignment = Alignment.Center
) {
Surface(
modifier =
Modifier
.fillMaxWidth(0.88f)
.widthIn(max = 360.dp)
.clip(RoundedCornerShape(16.dp))
.clickable(
interactionSource = cardInteraction,
indication = null
) {},
color = cardColor,
shadowElevation = 18.dp
) {
Column(modifier = Modifier.fillMaxWidth()) {
Spacer(modifier = Modifier.height(18.dp))
Text(
text = titleText,
fontSize = 22.sp,
fontWeight = FontWeight.Bold,
color = if (isDarkTheme) Color.White else Color.Black,
modifier = Modifier.padding(horizontal = 20.dp)
)
Spacer(modifier = Modifier.height(10.dp))
Text(
text = bodyText,
fontSize = 16.sp,
lineHeight = 21.sp,
color = bodyColor,
modifier = Modifier.padding(horizontal = 20.dp)
)
Spacer(modifier = Modifier.height(14.dp))
Divider(thickness = 0.5.dp, color = dividerColor)
TextButton(
onClick = {
showForwardCancelDialog = false
val panelMessages =
if (replyMessages.isNotEmpty()) {
replyMessages
} else if (forwardCancelDialogMessages.isNotEmpty()) {
forwardCancelDialogMessages
} else if (liveReplyMessages.isNotEmpty()) {
liveReplyMessages
} else {
animatedReplyMessages
}
onShowForwardOptions(panelMessages)
forwardCancelDialogMessages = emptyList()
},
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
Text(
text = "Show forwarding options",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = PrimaryBlue
)
}
TextButton(
onClick = {
showForwardCancelDialog = false
forwardCancelDialogMessages = emptyList()
onCloseReply()
},
modifier = Modifier.fillMaxWidth().height(52.dp)
) {
Text(
text = "Cancel Forwarding",
fontSize = 18.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFFFF5D73)
)
}
Spacer(modifier = Modifier.height(6.dp))
}
}
}
}
}
// EMOJI PICKER
if (!isBlocked) {
AnimatedKeyboardTransition(