Исправлены множественная пересылка и поведение 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 viewModel
.clearReplyMessages() .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, chatTitle = chatTitle,
isBlocked = isBlocked, isBlocked = isBlocked,
showEmojiPicker = showEmojiPicker =
@@ -3313,12 +3354,40 @@ fun ChatDetailScreen(
} }
val forwardMessages = ForwardManager.consumeForwardMessages() val forwardMessages = ForwardManager.consumeForwardMessages()
ForwardManager.clear()
if (forwardMessages.isEmpty()) { if (forwardMessages.isEmpty()) {
ForwardManager.clear()
return@ForwardChatPickerBottomSheet 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 -> selectedDialogs.forEach { dialog ->
viewModel.sendForwardDirectly( viewModel.sendForwardDirectly(
dialog.opponentKey, dialog.opponentKey,

View File

@@ -2243,6 +2243,7 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val sender = myPublicKey val sender = myPublicKey
val privateKey = myPrivateKey val privateKey = myPrivateKey
val replyMsgs = _replyMessages.value val replyMsgs = _replyMessages.value
val replyMsgsToSend = replyMsgs.toList()
val isForward = _isForwardMode.value val isForward = _isForwardMode.value
// Разрешаем отправку пустого текста если есть reply/forward // Разрешаем отправку пустого текста если есть reply/forward
@@ -2271,10 +2272,10 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
val timestamp = System.currentTimeMillis() val timestamp = System.currentTimeMillis()
// 🔥 Формируем ReplyData для отображения в UI (только первое сообщение) // 🔥 Формируем ReplyData для отображения в UI (только первое сообщение)
// Работает и для reply, и для forward // Используется для обычного reply (не forward).
val replyData: ReplyData? = val replyData: ReplyData? =
if (replyMsgs.isNotEmpty()) { if (replyMsgsToSend.isNotEmpty()) {
val firstReply = replyMsgs.first() val firstReply = replyMsgsToSend.first()
// 🖼️ Получаем attachments из текущих сообщений для превью // 🖼️ Получаем attachments из текущих сообщений для превью
// Fallback на firstReply.attachments для forward из другого чата // Fallback на firstReply.attachments для forward из другого чата
val replyAttachments = val replyAttachments =
@@ -2302,8 +2303,38 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
) )
} else null } else null
// Сохраняем reply для отправки ПЕРЕД очисткой // 📨 В forward режиме показываем ВСЕ пересылаемые сообщения в optimistic bubble,
val replyMsgsToSend = replyMsgs.toList() // а не только первое. Иначе визуально выглядит как будто отправилось одно сообщение.
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 val isForwardToSend = isForward
// 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble // 1. 🚀 Optimistic UI - мгновенно показываем сообщение с reply bubble
@@ -2314,7 +2345,8 @@ class ChatViewModel(application: Application) : AndroidViewModel(application) {
isOutgoing = true, isOutgoing = true,
timestamp = Date(timestamp), timestamp = Date(timestamp),
status = MessageStatus.SENDING, status = MessageStatus.SENDING,
replyData = replyData // Данные для reply bubble replyData = if (isForwardToSend) null else replyData,
forwardedMessages = optimisticForwardedMessages
) )
// <20> Безопасное добавление с проверкой дубликатов // <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.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator import app.rosette.android.ui.keyboard.rememberKeyboardTransitionCoordinator
import com.rosetta.messenger.ui.onboarding.PrimaryBlue import com.rosetta.messenger.ui.onboarding.PrimaryBlue
import com.rosetta.messenger.ui.utils.NavigationModeUtils
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import android.net.Uri import android.net.Uri
@@ -158,6 +159,9 @@ fun ChatAttachAlert(
viewModel: AttachAlertViewModel = viewModel() viewModel: AttachAlertViewModel = viewModel()
) { ) {
val context = LocalContext.current val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
NavigationModeUtils.hasNativeNavigationBar(context)
}
val density = LocalDensity.current val density = LocalDensity.current
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
@@ -204,6 +208,23 @@ fun ChatAttachAlert(
// Keyboard helpers // 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() { fun hideKeyboard() {
AttachAlertDebugLog.log("AttachAlert", "hideKeyboard() called, captionInputActive=$captionInputActive, shouldShow=$shouldShow, isClosing=$isClosing") AttachAlertDebugLog.log("AttachAlert", "hideKeyboard() called, captionInputActive=$captionInputActive, shouldShow=$shouldShow, isClosing=$isClosing")
pendingCaptionFocus = false pendingCaptionFocus = false
@@ -277,10 +298,14 @@ fun ChatAttachAlert(
if (coordinator.emojiHeight == 0.dp) { if (coordinator.emojiHeight == 0.dp) {
// Use saved keyboard height minus nav bar (same as spacer) // Use saved keyboard height minus nav bar (same as spacer)
val savedPx = KeyboardHeightProvider.getSavedKeyboardHeight(context) val savedPx = KeyboardHeightProvider.getSavedKeyboardHeight(context)
val navBarPx = (context as? Activity)?.window?.decorView?.let { view -> val navBarPx = if (hasNativeNavigationBar) {
androidx.core.view.ViewCompat.getRootWindowInsets(view) (context as? Activity)?.window?.decorView?.let { view ->
?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0 androidx.core.view.ViewCompat.getRootWindowInsets(view)
} ?: 0 ?.getInsets(androidx.core.view.WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
} ?: 0
} else {
0
}
val effectivePx = if (savedPx > 0) (savedPx - navBarPx).coerceAtLeast(0) else 0 val effectivePx = if (savedPx > 0) (savedPx - navBarPx).coerceAtLeast(0) else 0
coordinator.emojiHeight = if (effectivePx > 0) { coordinator.emojiHeight = if (effectivePx > 0) {
with(density) { effectivePx.toDp() } with(density) { effectivePx.toDp() }
@@ -302,7 +327,11 @@ fun ChatAttachAlert(
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp val screenHeight = configuration.screenHeightDp.dp
val screenHeightPx = with(density) { screenHeight.toPx() } 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 statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat()
val collapsedHeightPx = screenHeightPx * 0.72f val collapsedHeightPx = screenHeightPx * 0.72f
@@ -465,6 +494,9 @@ fun ChatAttachAlert(
LaunchedEffect(showSheet) { LaunchedEffect(showSheet) {
if (showSheet && state.editingItem == null) { 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 // Telegram pattern: set ADJUST_NOTHING on Activity before showing popup
// This prevents the system from resizing the layout when focus changes // This prevents the system from resizing the layout when focus changes
activity?.window?.let { win -> activity?.window?.let { win ->
@@ -677,11 +709,20 @@ fun ChatAttachAlert(
val origNavBarColor = window?.navigationBarColor ?: 0 val origNavBarColor = window?.navigationBarColor ?: 0
val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true
val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false
val origContrastEnforced =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window?.isNavigationBarContrastEnforced
} else {
null
}
onDispose { onDispose {
window?.statusBarColor = origStatusBarColor window?.statusBarColor = origStatusBarColor
window?.navigationBarColor = origNavBarColor window?.navigationBarColor = origNavBarColor
insetsController?.isAppearanceLightNavigationBars = origLightNav insetsController?.isAppearanceLightNavigationBars = origLightNav
insetsController?.isAppearanceLightStatusBars = origLightStatus insetsController?.isAppearanceLightStatusBars = origLightStatus
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && origContrastEnforced != null) {
window?.isNavigationBarContrastEnforced = origContrastEnforced
}
} }
} else { } else {
onDispose { } onDispose { }
@@ -711,16 +752,28 @@ fun ChatAttachAlert(
window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0) window.statusBarColor = android.graphics.Color.argb(scrimInt, 0, 0, 0)
insetsController?.isAppearanceLightStatusBars = false insetsController?.isAppearanceLightStatusBars = false
} }
// Telegram-like: nav bar follows picker surface, not black scrim. if (hasNativeNavigationBar) {
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() // Telegram-like: for 2/3-button navigation keep picker-surface tint.
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255) val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
window.navigationBarColor = android.graphics.Color.argb( val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
navAlpha, window.navigationBarColor = android.graphics.Color.argb(
android.graphics.Color.red(navBaseColor), navAlpha,
android.graphics.Color.green(navBaseColor), android.graphics.Color.red(navBaseColor),
android.graphics.Color.blue(navBaseColor) android.graphics.Color.green(navBaseColor),
) android.graphics.Color.blue(navBaseColor)
insetsController?.isAppearanceLightNavigationBars = !dark )
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.AppleEmojiTextField
import com.rosetta.messenger.ui.components.KeyboardHeightProvider import com.rosetta.messenger.ui.components.KeyboardHeightProvider
import com.rosetta.messenger.ui.components.OptimizedEmojiPicker 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.rememberKeyboardTransitionCoordinator
import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition import app.rosette.android.ui.keyboard.AnimatedKeyboardTransition
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -136,6 +137,9 @@ fun MediaPickerBottomSheet(
onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null onPhotoPreviewRequested: ((Uri, ThumbnailPosition?) -> Unit)? = null
) { ) {
val context = LocalContext.current val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
NavigationModeUtils.hasNativeNavigationBar(context)
}
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val density = LocalDensity.current val density = LocalDensity.current
val imeInsets = WindowInsets.ime val imeInsets = WindowInsets.ime
@@ -295,7 +299,11 @@ fun MediaPickerBottomSheet(
val configuration = LocalConfiguration.current val configuration = LocalConfiguration.current
val screenHeight = configuration.screenHeightDp.dp val screenHeight = configuration.screenHeightDp.dp
val screenHeightPx = with(density) { screenHeight.toPx() } 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 statusBarInsetPx = WindowInsets.statusBars.getTop(density).toFloat()
// 🔄 Высоты в пикселях для точного контроля // 🔄 Высоты в пикселях для точного контроля
@@ -384,6 +392,9 @@ fun MediaPickerBottomSheet(
// Запускаем анимацию когда showSheet меняется // Запускаем анимацию когда showSheet меняется
LaunchedEffect(showSheet) { LaunchedEffect(showSheet) {
if (showSheet && editingItem == null) { if (showSheet && editingItem == null) {
// Ensure IME from chat is closed before picker opens.
hideKeyboard()
delay(16)
shouldShow = true shouldShow = true
isClosing = false isClosing = false
showAlbumMenu = false showAlbumMenu = false
@@ -562,12 +573,21 @@ fun MediaPickerBottomSheet(
val origNavBarColor = window?.navigationBarColor ?: 0 val origNavBarColor = window?.navigationBarColor ?: 0
val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true val origLightNav = insetsController?.isAppearanceLightNavigationBars ?: true
val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false val origLightStatus = insetsController?.isAppearanceLightStatusBars ?: false
val origContrastEnforced =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
window?.isNavigationBarContrastEnforced
} else {
null
}
onDispose { onDispose {
window?.statusBarColor = origStatusBarColor window?.statusBarColor = origStatusBarColor
window?.navigationBarColor = origNavBarColor window?.navigationBarColor = origNavBarColor
insetsController?.isAppearanceLightNavigationBars = origLightNav insetsController?.isAppearanceLightNavigationBars = origLightNav
insetsController?.isAppearanceLightStatusBars = origLightStatus insetsController?.isAppearanceLightStatusBars = origLightStatus
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && origContrastEnforced != null) {
window?.isNavigationBarContrastEnforced = origContrastEnforced
}
} }
} else { } else {
onDispose { } onDispose { }
@@ -601,16 +621,28 @@ fun MediaPickerBottomSheet(
) )
insetsController?.isAppearanceLightStatusBars = false insetsController?.isAppearanceLightStatusBars = false
} }
// Telegram-like: nav bar follows picker surface, not scrim. if (hasNativeNavigationBar) {
val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt() // Telegram-like: for 2/3-button navigation keep picker-surface tint.
val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255) val navBaseColor = if (dark) 0xFF1C1C1E.toInt() else 0xFFFFFFFF.toInt()
window.navigationBarColor = android.graphics.Color.argb( val navAlpha = (state.openProgress * 255f).toInt().coerceIn(0, 255)
navAlpha, window.navigationBarColor = android.graphics.Color.argb(
android.graphics.Color.red(navBaseColor), navAlpha,
android.graphics.Color.green(navBaseColor), android.graphics.Color.red(navBaseColor),
android.graphics.Color.blue(navBaseColor) android.graphics.Color.green(navBaseColor),
) android.graphics.Color.blue(navBaseColor)
insetsController?.isAppearanceLightNavigationBars = !dark )
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 backgroundColor = if (isDarkTheme) Color.Black else Color.White
val context = LocalContext.current val context = LocalContext.current
val hasNativeNavigationBar = remember(context) {
NavigationModeUtils.hasNativeNavigationBar(context)
}
val view = LocalView.current val view = LocalView.current
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val density = LocalDensity.current val density = LocalDensity.current
@@ -2291,7 +2326,8 @@ fun PhotoPreviewWithCaptionScreen(
// 🔥 КЛЮЧЕВОЕ: imePadding применяется ТОЛЬКО когда emoji box НЕ виден // 🔥 КЛЮЧЕВОЕ: imePadding применяется ТОЛЬКО когда emoji box НЕ виден
val shouldUseImePadding = !coordinator.isEmojiBoxVisible val shouldUseImePadding = !coordinator.isEmojiBoxVisible
val shouldAddNavBarPadding = !isKeyboardVisible && !coordinator.isEmojiBoxVisible val shouldAddNavBarPadding =
hasNativeNavigationBar && !isKeyboardVisible && !coordinator.isEmojiBoxVisible
// Логируем состояние при каждой рекомпозиции // Логируем состояние при каждой рекомпозиции
Surface( 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.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.AnimatedKeyboardTransition
import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator import app.rosette.android.ui.keyboard.KeyboardTransitionCoordinator
import coil.compose.AsyncImage import coil.compose.AsyncImage
@@ -88,6 +90,7 @@ fun MessageInputBar(
replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(), replyMessages: List<ChatViewModel.ReplyMessage> = emptyList(),
isForwardMode: Boolean = false, isForwardMode: Boolean = false,
onCloseReply: () -> Unit = {}, onCloseReply: () -> Unit = {},
onShowForwardOptions: (List<ChatViewModel.ReplyMessage>) -> Unit = {},
chatTitle: String = "", chatTitle: String = "",
isBlocked: Boolean = false, isBlocked: Boolean = false,
showEmojiPicker: Boolean = false, showEmojiPicker: Boolean = false,
@@ -218,6 +221,11 @@ fun MessageInputBar(
val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply } val canSend = remember(value, hasReply) { value.isNotBlank() || hasReply }
var isSending by remember { mutableStateOf(false) } 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 mentionPattern = remember { Regex("@([\\w\\d_]*)$") }
val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") } val mentionedPattern = remember { Regex("@([\\w\\d_]{1,})") }
val mentionMatch = remember(value, isGroupChat) { if (isGroupChat) mentionPattern.find(value) else null } val mentionMatch = remember(value, isGroupChat) { if (isGroupChat) mentionPattern.find(value) else null }
@@ -661,7 +669,18 @@ fun MessageInputBar(
.clickable( .clickable(
interactionSource = remember { MutableInteractionSource() }, interactionSource = remember { MutableInteractionSource() },
indication = null, 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 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 // EMOJI PICKER
if (!isBlocked) { if (!isBlocked) {
AnimatedKeyboardTransition( AnimatedKeyboardTransition(