From beb117d46aeb27e704773e250acf3d029afa8c60 Mon Sep 17 00:00:00 2001 From: Alex Hart Date: Fri, 4 Sep 2026 14:58:32 -0300 Subject: [PATCH] Wrap chat screen in a BottomSheetScaffold. --- .../components/InputAwareConstraintLayout.kt | 163 --------- .../mediakeyboard/MediaKeyboardController.kt | 77 ++++ .../mediakeyboard/MediaKeyboardEvents.kt | 34 ++ .../compose/mediakeyboard/MediaKeyboardKey.kt | 15 + .../mediakeyboard/MediaKeyboardScaffold.kt | 329 ++++++++++++++++++ .../mediakeyboard/MediaKeyboardScope.kt | 53 +++ .../conversation/v2/ChatInputController.kt | 155 +++++++++ .../conversation/v2/ChatKeyboards.kt | 14 + .../securesms/conversation/v2/ChatScreen.kt | 138 ++++++++ .../conversation/v2/ChatScreenViewModel.kt | 38 ++ .../conversation/v2/ChatScrimState.kt | 36 ++ .../conversation/v2/ConversationFragment.kt | 190 +++++----- .../v2/ConversationToolbarOnScrollHelper.kt | 8 +- .../conversation/v2/ConversationViewModel.kt | 12 +- .../keyboard/KeyboardPagerFragment.kt | 12 +- .../res/layout/v2_conversation_background.xml | 27 ++ .../res/layout/v2_conversation_fragment.xml | 135 +++---- 17 files changed, 1088 insertions(+), 348 deletions(-) delete mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/InputAwareConstraintLayout.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardController.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardEvents.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardKey.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScaffold.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScope.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatInputController.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatKeyboards.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreenViewModel.kt create mode 100644 app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScrimState.kt create mode 100644 app/src/main/res/layout/v2_conversation_background.xml diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareConstraintLayout.kt b/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareConstraintLayout.kt deleted file mode 100644 index 4453f8ba82..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/components/InputAwareConstraintLayout.kt +++ /dev/null @@ -1,163 +0,0 @@ -/* - * Copyright 2023 Signal Messenger, LLC - * SPDX-License-Identifier: AGPL-3.0-only - */ - -package org.thoughtcrime.securesms.components - -import android.content.Context -import android.util.AttributeSet -import android.widget.EditText -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager -import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.util.ViewUtil - -/** - * A flavor of [InsetAwareConstraintLayout] that allows "replacing" the keyboard with our - * own input fragment. - */ -class InputAwareConstraintLayout @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0 -) : InsetAwareConstraintLayout(context, attrs, defStyleAttr) { - - private var inputId: Int? = null - private var input: Fragment? = null - private var wasKeyboardVisibleBeforeToggle: Boolean = false - private val listeners: MutableSet = mutableSetOf() - - val isInputShowing: Boolean - get() = input != null - - lateinit var fragmentManager: FragmentManager - - fun addInputListener(listener: Listener) { - listeners.add(listener) - } - - fun removeInputListener(listener: Listener) { - listeners.remove(listener) - } - - fun showSoftkey(editText: EditText) { - ViewUtil.focusAndShowKeyboard(editText) - hideInput(resetKeyboardGuideline = false) - } - - fun hideAll(imeTarget: EditText) { - wasKeyboardVisibleBeforeToggle = false - ViewUtil.hideKeyboard(context, imeTarget) - hideInput(resetKeyboardGuideline = true) - } - - fun runAfterAllHidden(imeTarget: EditText, onHidden: () -> Unit) { - if (isInputShowing || isKeyboardShowing) { - val listener = object : Listener, KeyboardStateListener { - override fun onInputHidden() { - onHidden() - removeInputListener(this) - removeKeyboardStateListener(this) - } - - override fun onKeyboardHidden() { - onHidden() - removeInputListener(this) - removeKeyboardStateListener(this) - } - - override fun onInputShown(fragmentCreatorId: Int) = Unit - override fun onKeyboardShown() = Unit - } - - addInputListener(listener) - addKeyboardStateListener(listener) - hideAll(imeTarget) - } else { - onHidden() - } - } - - fun toggleInput(fragmentCreator: FragmentCreator, imeTarget: EditText, showSoftKeyOnHide: Boolean = wasKeyboardVisibleBeforeToggle) { - if (fragmentCreator.id == inputId) { - if (showSoftKeyOnHide) { - showSoftkey(imeTarget) - } else { - hideInput(resetKeyboardGuideline = true) - } - } else { - wasKeyboardVisibleBeforeToggle = isKeyboardShowing - hideInput(resetKeyboardGuideline = false) - showInput(fragmentCreator, imeTarget) - } - } - - fun hideInput() { - hideInput(resetKeyboardGuideline = true) - wasKeyboardVisibleBeforeToggle = false - } - - fun hideKeyboard(imeTarget: EditText, keepHeightOverride: Boolean = false) { - if (isKeyboardShowing) { - if (keepHeightOverride) { - overrideKeyboardGuidelineWithPreviousHeight() - } - ViewUtil.hideKeyboard(context, imeTarget) - } - } - - private fun showInput(fragmentCreator: FragmentCreator, imeTarget: EditText) { - inputId = fragmentCreator.id - input = fragmentCreator.create() - - fragmentManager - .beginTransaction() - .replace(R.id.input_container, input!!) - .runOnCommit { (input as? InputFragment)?.show() } - .commit() - - overrideKeyboardGuidelineWithPreviousHeight() - ViewUtil.hideKeyboard(context, imeTarget) - - listeners.forEach { it.onInputShown(fragmentCreator.id) } - } - - private fun hideInput(resetKeyboardGuideline: Boolean) { - val inputHidden = input != null - input?.let { - (input as? InputFragment)?.hide() - fragmentManager - .beginTransaction() - .remove(it) - .commit() - } - input = null - inputId = null - - if (resetKeyboardGuideline) { - resetKeyboardGuideline() - } else { - clearKeyboardGuidelineOverride() - } - - if (inputHidden) { - listeners.forEach { it.onInputHidden() } - } - } - - interface FragmentCreator { - val id: Int - fun create(): Fragment - } - - interface Listener { - fun onInputShown(fragmentCreatorId: Int) - fun onInputHidden() - } - - interface InputFragment { - fun show() - fun hide() - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardController.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardController.kt new file mode 100644 index 0000000000..7d9afbd0be --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardController.kt @@ -0,0 +1,77 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.compose.mediakeyboard + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue + +/** + * Asks a [MediaKeyboardScaffold] for a keyboard. + * + * Stable and safe to hold outside composition, so view code can call into it. Everything flowing the + * other way is a [MediaKeyboardEvents]. + * + * @param initialKeyboardHeightPx Height to use before one has been measured, typically persisted + * from an earlier run. + */ +@Stable +class MediaKeyboardController(initialKeyboardHeightPx: Int = 0) { + + /** The keyboard currently up, or null when none of ours is. */ + var current: MediaKeyboardKey? by mutableStateOf(null) + private set + + /** Whether the system keyboard is up, as reported by the IME inset. */ + var isSystemKeyboardVisible: Boolean by mutableStateOf(false) + internal set + + /** + * True while the system keyboard has been asked for in place of one of ours but has yet to settle. + * Holds the space across that gap, which the IME service round trip would otherwise leave empty. + */ + var awaitingSystemKeyboard: Boolean by mutableStateOf(false) + internal set + + /** How tall a keyboard of ours should be, replaced whenever a system keyboard is measured. */ + var keyboardHeightPx: Int by mutableStateOf(initialKeyboardHeightPx) + internal set + + val isShowing: Boolean get() = current != null + + fun show(key: MediaKeyboardKey) { + current = key + awaitingSystemKeyboard = false + } + + fun hide() { + current = null + awaitingSystemKeyboard = false + } + + /** Closes [key] if it is already up, otherwise swaps to it. */ + fun toggle(key: MediaKeyboardKey) { + if (current == key) hide() else show(key) + } + + /** + * Puts ours away because the system keyboard is being brought up instead. Only holds the space if + * one of ours was up; with nothing to hand over, content should just follow the keyboard in. + */ + fun hideForSystemKeyboard() { + awaitingSystemKeyboard = current != null + current = null + } +} + +/** @param initialKeyboardHeightPx See [MediaKeyboardController]. */ +@Composable +fun rememberMediaKeyboardController(initialKeyboardHeightPx: Int = 0): MediaKeyboardController { + return remember { MediaKeyboardController(initialKeyboardHeightPx) } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardEvents.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardEvents.kt new file mode 100644 index 0000000000..18639245fb --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardEvents.kt @@ -0,0 +1,34 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.compose.mediakeyboard + +/** + * What a [MediaKeyboardScaffold] reports back. Requests flow the other way, through + * [MediaKeyboardController]. + */ +sealed interface MediaKeyboardEvents { + /** @param key The keyboard now showing. */ + data class KeyboardShown(val key: MediaKeyboardKey) : MediaKeyboardEvents + + /** None of ours is showing any more. */ + data object KeyboardHidden : MediaKeyboardEvents + + /** One of ours was dismissed by a back gesture rather than by a request. */ + data object DismissedByBack : MediaKeyboardEvents + + /** @param visible Whether the system keyboard is up, on the target state rather than the animated one. */ + data class SystemKeyboardVisibilityChanged(val visible: Boolean) : MediaKeyboardEvents + + /** The system keyboard finished animating, in either direction. */ + data object SystemKeyboardAnimationEnded : MediaKeyboardEvents + + /** + * A trustworthy system keyboard height was observed. The scaffold does not persist it. + * + * @param heightPx The measured height. + */ + data class SystemKeyboardHeightMeasured(val heightPx: Int) : MediaKeyboardEvents +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardKey.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardKey.kt new file mode 100644 index 0000000000..d45d11e4d5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardKey.kt @@ -0,0 +1,15 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.compose.mediakeyboard + +/** + * Identifies a keyboard a [MediaKeyboardScaffold] can put up. Callers declare their own, so a screen + * only knows about the keyboards it offers. + * + * @param name Unique identifier for the keyboard. + */ +@JvmInline +value class MediaKeyboardKey(val name: String) diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScaffold.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScaffold.kt new file mode 100644 index 0000000000..7f8a8af7dc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScaffold.kt @@ -0,0 +1,329 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.compose.mediakeyboard + +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.displayCutout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imeAnimationSource +import androidx.compose.foundation.layout.imeAnimationTarget +import androidx.compose.foundation.layout.isImeVisible +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.onConsumedWindowInsetsChanged +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.BottomSheetScaffold +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SheetState +import androidx.compose.material3.SheetValue +import androidx.compose.material3.rememberBottomSheetScaffoldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.layout.layout +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalResources +import androidx.compose.ui.platform.LocalWindowInfo +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import org.signal.core.ui.getWindowSizeClass +import org.signal.core.ui.isHeightCompact +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.roundToInt +import kotlin.time.Duration.Companion.seconds + +/** Shapes back gesture progress into travel. Matches the platform's IME hide curve. */ +private val BACK_TRACKING_EASING = FastOutLinearInEasing + +/** Settles the keyboard once a back gesture lets go. Lower stiffness is heavier. */ +private val BACK_SETTLE_MOTION = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMediumLow +) + +/** How long to hold space for a system keyboard that was asked for but never appeared. */ +private val SYSTEM_KEYBOARD_ARRIVAL_TIMEOUT = 1.seconds + +private val SHEET_POSITIONAL_THRESHOLD = 56.dp +private val SHEET_VELOCITY_THRESHOLD = 125.dp + +/** + * Displays [content] alongside keyboards of our own that stand in for the system keyboard. + * + * Only one is ever up, and never alongside the system keyboard. The scaffold owns every window inset, + * handing [content] an already-inset space to lay out in. + * + * @param controller Requests which keyboard is up. Stable, so view code may hold one. + * @param onEvent Receives everything the host may need to react to. + * @param keyboardsProvider Declares the available keyboards. + * @param keyboardHeight Bounds on how tall a keyboard may be. + * @param adjustContentForInput False to let a keyboard cover [content] rather than resize it. + */ +@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) +@Composable +fun MediaKeyboardScaffold( + controller: MediaKeyboardController, + onEvent: (MediaKeyboardEvents) -> Unit, + keyboardsProvider: MediaKeyboardScope.() -> Unit, + modifier: Modifier = Modifier, + keyboardHeight: MediaKeyboardHeight = MediaKeyboardHeight(), + adjustContentForInput: Boolean = true, + content: @Composable () -> Unit +) { + val registry = remember(keyboardsProvider) { MediaKeyboardRegistry().apply(keyboardsProvider) } + + val density = LocalDensity.current + val imeAnimationSource = WindowInsets.imeAnimationSource + val imeAnimationTarget = WindowInsets.imeAnimationTarget + val windowHeightPx = LocalWindowInfo.current.containerSize.height + + val resources = LocalResources.current + val configuration = LocalConfiguration.current + val isHeightCompact = remember(resources, configuration) { resources.getWindowSizeClass().isHeightCompact } + + val minimumHeightPx = with(density) { keyboardHeight.minimum.roundToPx() } + val topMarginPx = with(density) { keyboardHeight.topMargin.roundToPx() } + + val activeKey = controller.current?.takeIf { registry.isEnabled(it) } + + // The target state, so it does not read as hidden for the whole closing animation. + val systemKeyboardVisible = WindowInsets.isImeVisible + + var hasReportedKeyboardVisibility by remember { mutableStateOf(false) } + LaunchedEffect(systemKeyboardVisible) { + controller.isSystemKeyboardVisible = systemKeyboardVisible + if (hasReportedKeyboardVisibility) { + onEvent(MediaKeyboardEvents.SystemKeyboardVisibilityChanged(systemKeyboardVisible)) + } + hasReportedKeyboardVisibility = true + } + + LaunchedEffect(controller.awaitingSystemKeyboard) { + if (controller.awaitingSystemKeyboard) { + delay(SYSTEM_KEYBOARD_ARRIVAL_TIMEOUT) + controller.awaitingSystemKeyboard = false + } + } + + // From the animation target, not the live inset, which walks down through every closing frame. + LaunchedEffect(imeAnimationTarget, density, minimumHeightPx, isHeightCompact) { + if (isHeightCompact) { + return@LaunchedEffect + } + + snapshotFlow { imeAnimationTarget.getBottom(density) } + .filter { it > minimumHeightPx } + .distinctUntilChanged() + .collect { + controller.keyboardHeightPx = it + onEvent(MediaKeyboardEvents.SystemKeyboardHeightMeasured(it)) + } + } + + LaunchedEffect(imeAnimationSource, imeAnimationTarget, density) { + snapshotFlow { imeAnimationSource.getBottom(density) == imeAnimationTarget.getBottom(density) } + .distinctUntilChanged() + .filter { settled -> settled } + .drop(1) + .collect { + controller.awaitingSystemKeyboard = false + onEvent(MediaKeyboardEvents.SystemKeyboardAnimationEnded) + } + } + + val heightPx = keyboardHeight.resolve( + preferredPx = controller.keyboardHeightPx, + windowHeightPx = windowHeightPx, + minimumPx = minimumHeightPx, + topMarginPx = topMarginPx + ) + val height = with(density) { heightPx.toDp() } + + val backProgress = remember { Animatable(0f) } + val scope = rememberCoroutineScope() + + val sheetState = remember { + SheetState( + skipPartiallyExpanded = true, + positionalThreshold = { with(density) { SHEET_POSITIONAL_THRESHOLD.toPx() } }, + velocityThreshold = { with(density) { SHEET_VELOCITY_THRESHOLD.toPx() } }, + initialValue = SheetValue.Hidden, + skipHiddenState = false + ) + } + val scaffoldState = rememberBottomSheetScaffoldState(bottomSheetState = sheetState) + + // This just makes sure the previously visible state doesn't go away too early while we're mid swap. + var visibleKey by remember { mutableStateOf(null) } + + LaunchedEffect(activeKey) { + if (activeKey != null) { + if (visibleKey != null && visibleKey != activeKey) { + onEvent(MediaKeyboardEvents.KeyboardHidden) + } + + visibleKey = activeKey + backProgress.snapTo(0f) + sheetState.expand() + onEvent(MediaKeyboardEvents.KeyboardShown(activeKey)) + } else if (visibleKey != null) { + // A gesture may already have carried it off screen; hold that until the hide completes. + sheetState.hide() + backProgress.snapTo(0f) + visibleKey = null + onEvent(MediaKeyboardEvents.KeyboardHidden) + } + } + + PredictiveBackHandler(enabled = activeKey != null) { progress -> + try { + progress.collect { backEvent -> backProgress.snapTo(BACK_TRACKING_EASING.transform(backEvent.progress)) } + + backProgress.animateTo(1f, BACK_SETTLE_MOTION) + controller.hide() + onEvent(MediaKeyboardEvents.DismissedByBack) + } catch (cancelled: CancellationException) { + // PredictiveBackHandler cancels this job, so the unwind must run somewhere that outlives it. + scope.launch { backProgress.animateTo(0f, BACK_SETTLE_MOTION) } + throw cancelled + } + } + + val systemKeyboardTakingOverSpace = activeKey == null && + ( + controller.awaitingSystemKeyboard || + ( + imeAnimationTarget.getBottom(density) > 0 && + imeAnimationSource.getBottom(density) != imeAnimationTarget.getBottom(density) + ) + ) + + val claimedBottomPx = { + if (activeKey != null) { + (heightPx * (1f - backProgress.value)).roundToInt().coerceAtLeast(0) + } else if (systemKeyboardTakingOverSpace) { + heightPx + } else { + 0 + } + } + + var ancestorConsumedBottomPx by remember { mutableIntStateOf(0) } + val safeDrawingInsets = WindowInsets.safeDrawing + + val windowInsets = if (adjustContentForInput) { + safeDrawingInsets + } else { + WindowInsets.systemBars.add(WindowInsets.displayCutout) + } + + Box(modifier = modifier.fillMaxSize()) { + BottomSheetScaffold( + scaffoldState = scaffoldState, + sheetPeekHeight = 0.dp, + sheetShape = RectangleShape, + sheetDragHandle = null, + sheetSwipeEnabled = false, + sheetContainerColor = Color.Transparent, + sheetTonalElevation = 0.dp, + sheetShadowElevation = 0.dp, + containerColor = Color.Transparent, + sheetContent = { + Box( + modifier = Modifier + .fillMaxWidth() + .height(height) + .graphicsLayer { translationY = backProgress.value * heightPx } + .background(registry.containerColorFor(visibleKey).takeOrElse { MaterialTheme.colorScheme.surfaceContainerLow }) + .navigationBarsPadding() + ) { + registry.contentFor(visibleKey)?.invoke() + } + } + ) { _ -> + Box( + modifier = Modifier + .fillMaxSize() + .onConsumedWindowInsetsChanged { ancestorConsumedBottomPx = it.getBottom(density) } + .windowInsetsPadding(windowInsets) + .layout { measurable, constraints -> + // Window insets are already out of these constraints; take only the excess claim. + val windowBottomPx = (safeDrawingInsets.getBottom(this) - ancestorConsumedBottomPx).coerceAtLeast(0) + val extraPx = if (adjustContentForInput) (claimedBottomPx() - windowBottomPx).coerceAtLeast(0) else 0 + + val available = (constraints.maxHeight - extraPx).coerceAtLeast(0) + val placeable = measurable.measure(constraints.copy(minHeight = available, maxHeight = available)) + layout(constraints.maxWidth, constraints.maxHeight) { + placeable.place(0, 0) + } + } + ) { + content() + } + } + } +} + +/** + * Bounds on how tall a keyboard may be. The height it wants comes from + * [MediaKeyboardController.keyboardHeightPx]. + * + * Holds a lambda, so callers should [remember] it or the scaffold cannot skip recomposition. + * + * @param minimum Floor, used before any keyboard has been measured. + * @param topMargin Kept clear of the top of the window, capping the height. + * @param overrideForWindow Derives a height from the window instead, for windows unlike the one a + * keyboard was measured in. + */ +@Immutable +data class MediaKeyboardHeight( + val minimum: Dp = 260.dp, + val topMargin: Dp = 170.dp, + val overrideForWindow: ((windowHeightPx: Int) -> Int)? = null +) { + internal fun resolve(preferredPx: Int, windowHeightPx: Int, minimumPx: Int, topMarginPx: Int): Int { + if (windowHeightPx <= 0) { + return maxOf(preferredPx, minimumPx) + } + + val maximumPx = (windowHeightPx - topMarginPx).coerceAtLeast(minimumPx) + + overrideForWindow?.let { return it(windowHeightPx).coerceAtMost(maximumPx) } + + return preferredPx.coerceIn(minimumPx, maximumPx) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScope.kt b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScope.kt new file mode 100644 index 0000000000..efceaa0cf4 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/components/compose/mediakeyboard/MediaKeyboardScope.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.components.compose.mediakeyboard + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color + +/** Declares which keyboards a [MediaKeyboardScaffold] offers. */ +interface MediaKeyboardScope { + /** + * Offers the keyboard identified by [key]. + * + * @param key Identifies the keyboard. + * @param enabled False to keep the key known but refuse requests for it. + * @param containerColor Fills the sheet, navigation bar included, so it should match whatever + * [content] paints its edges with. Unspecified falls back to the scaffold's surface. + * @param content The keyboard itself. + */ + fun keyboard( + key: MediaKeyboardKey, + enabled: Boolean = true, + containerColor: Color = Color.Unspecified, + content: @Composable () -> Unit + ) +} + +internal class MediaKeyboardRegistry : MediaKeyboardScope { + private val entries = LinkedHashMap() + + override fun keyboard( + key: MediaKeyboardKey, + enabled: Boolean, + containerColor: Color, + content: @Composable () -> Unit + ) { + entries[key] = Entry(enabled, containerColor, content) + } + + fun isEnabled(key: MediaKeyboardKey?): Boolean = key != null && entries[key]?.enabled == true + + fun contentFor(key: MediaKeyboardKey?): (@Composable () -> Unit)? { + return key?.let { entries[it] }?.takeIf { it.enabled }?.content + } + + fun containerColorFor(key: MediaKeyboardKey?): Color { + return key?.let { entries[it] }?.takeIf { it.enabled }?.containerColor ?: Color.Unspecified + } + + private class Entry(val enabled: Boolean, val containerColor: Color, val content: @Composable () -> Unit) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatInputController.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatInputController.kt new file mode 100644 index 0000000000..156ee3c1fc --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatInputController.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.content.Context +import android.widget.EditText +import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardController +import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardKey +import org.thoughtcrime.securesms.util.ViewUtil + +/** + * Adapts [MediaKeyboardController] to the conversation's view code, which asks for keyboards from + * click listeners rather than from composition. + * + * @param context Used to hide the system keyboard. + * @param controller The controller to drive. + */ +class ChatInputController( + private val context: Context, + private val controller: MediaKeyboardController +) { + + private var wasKeyboardVisibleBeforeToggle: Boolean = false + + private val listeners: MutableSet = mutableSetOf() + private val keyboardStateListeners: MutableSet = mutableSetOf() + + val isInputShowing: Boolean + get() = controller.isShowing + + val isKeyboardShowing: Boolean + get() = controller.isSystemKeyboardVisible + + fun addInputListener(listener: Listener) { + listeners.add(listener) + } + + fun removeInputListener(listener: Listener) { + listeners.remove(listener) + } + + fun addKeyboardStateListener(listener: KeyboardStateListener) { + keyboardStateListeners.add(listener) + } + + fun removeKeyboardStateListener(listener: KeyboardStateListener) { + keyboardStateListeners.remove(listener) + } + + /** Drops everything still listening, for a host whose view is going away. */ + fun clearListeners() { + listeners.clear() + keyboardStateListeners.clear() + } + + fun onKeyboardVisibilityChanged(visible: Boolean) { + keyboardStateListeners.toList().forEach { + if (visible) it.onKeyboardShown() else it.onKeyboardHidden() + } + } + + fun onKeyboardAnimationEnded() { + keyboardStateListeners.toList().forEach { it.onKeyboardAnimationEnded() } + } + + fun onInputShown(key: MediaKeyboardKey) { + listeners.toList().forEach { it.onInputShown(key) } + } + + fun onInputHidden() { + listeners.toList().forEach { it.onInputHidden() } + } + + fun showSoftkey(editText: EditText) { + controller.hideForSystemKeyboard() + ViewUtil.focusAndShowKeyboard(editText) + } + + fun hideAll(imeTarget: EditText) { + wasKeyboardVisibleBeforeToggle = false + controller.hide() + ViewUtil.hideKeyboard(context, imeTarget) + } + + fun hideInput() { + wasKeyboardVisibleBeforeToggle = false + controller.hide() + } + + fun hideKeyboard(imeTarget: EditText) { + if (isKeyboardShowing) { + ViewUtil.hideKeyboard(context, imeTarget) + } + } + + fun runAfterAllHidden(imeTarget: EditText, onHidden: () -> Unit) { + if (isInputShowing || isKeyboardShowing) { + val listener = object : Listener, KeyboardStateListener { + override fun onInputHidden() { + onHidden() + removeInputListener(this) + removeKeyboardStateListener(this) + } + + override fun onKeyboardHidden() { + onHidden() + removeInputListener(this) + removeKeyboardStateListener(this) + } + + override fun onInputShown(key: MediaKeyboardKey) = Unit + override fun onKeyboardShown() = Unit + } + + addInputListener(listener) + addKeyboardStateListener(listener) + hideAll(imeTarget) + } else { + onHidden() + } + } + + /** + * @param key The keyboard to bring up, or take away if already showing. + * @param imeTarget The field the system keyboard belongs to. + * @param showSoftKeyOnHide Whether the system keyboard replaces [key] when it is taken away. + */ + fun toggleInput(key: MediaKeyboardKey, imeTarget: EditText, showSoftKeyOnHide: Boolean = wasKeyboardVisibleBeforeToggle) { + if (controller.current == key) { + if (showSoftKeyOnHide) { + showSoftkey(imeTarget) + } else { + hideInput() + } + } else { + wasKeyboardVisibleBeforeToggle = isKeyboardShowing + controller.show(key) + ViewUtil.hideKeyboard(context, imeTarget) + } + } + + interface Listener { + fun onInputShown(key: MediaKeyboardKey) + fun onInputHidden() + } + + interface KeyboardStateListener { + fun onKeyboardShown() + fun onKeyboardHidden() + fun onKeyboardAnimationEnded() = Unit + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatKeyboards.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatKeyboards.kt new file mode 100644 index 0000000000..041edba9a0 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatKeyboards.kt @@ -0,0 +1,14 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardKey + +/** The keyboards the conversation offers in place of the system keyboard. */ +object ChatKeyboards { + val Media = MediaKeyboardKey("conversation.media") + val Attachment = MediaKeyboardKey("conversation.attachment") +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt new file mode 100644 index 0000000000..6f98817706 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreen.kt @@ -0,0 +1,138 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.view.View +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBars +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.windowInsetsBottomHeight +import androidx.compose.foundation.layout.windowInsetsTopHeight +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.dimensionResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.fragment.compose.AndroidFragment +import org.signal.core.ui.util.ThemeUtil +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardController +import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardEvents +import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardHeight +import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardScaffold +import org.thoughtcrime.securesms.conversation.v2.keyboard.AttachmentKeyboardFragment +import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment +import kotlin.math.roundToInt + +/** A bubble's keyboard takes a little over half the window, as it does for the older hosts. */ +private const val BUBBLE_HEIGHT_FRACTION = 0.55f + +/** + * Displays a chat screen for a given conversation. + * + * @param controller The MediaKeyboardController to control the media keyboard + * @param onEvent The MediaKeyboard events stream for interacting with the media keyboard + * @param scrim The color information for the top and bottom scrim + * @param isBubble Whether we're displaying content in a bubble + * @param backgroundView The chat wallpaper + * @param contentView The area that actually moves up when the keyboards appear + */ +@Composable +fun ChatScreen( + controller: MediaKeyboardController, + onEvent: (MediaKeyboardEvents) -> Unit, + scrims: ChatScrimState, + isBubble: Boolean, + backgroundView: View, + contentView: View, + modifier: Modifier = Modifier +) { + val minimumHeight = dimensionResource(R.dimen.default_custom_keyboard_size) + val topMargin = dimensionResource(R.dimen.min_custom_keyboard_top_margin_portrait) + val mediaKeyboardColor = Color(ThemeUtil.getThemedColor(LocalContext.current, R.attr.mediaKeyboardBottomBarBackgroundColor)) + val attachmentKeyboardColor = scrims.attachmentKeyboardColor + + val keyboardHeight = remember(minimumHeight, topMargin, isBubble) { + MediaKeyboardHeight( + minimum = minimumHeight, + topMargin = topMargin, + overrideForWindow = if (isBubble) { + { windowHeightPx -> (windowHeightPx * BUBBLE_HEIGHT_FRACTION).roundToInt() } + } else { + null + } + ) + } + + Box( + modifier = modifier + .fillMaxSize() + // A bubble's host has already accounted for the system bars. + .then(if (isBubble) Modifier.consumeWindowInsets(WindowInsets.safeDrawing) else Modifier) + ) { + AndroidView( + factory = { backgroundView }, + modifier = Modifier.fillMaxSize() + ) + + Box( + modifier = Modifier + .align(Alignment.TopCenter) + .fillMaxWidth() + .windowInsetsTopHeight(WindowInsets.statusBars) + .background(Color(scrims.statusBarColor)) + ) + + Box( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .windowInsetsBottomHeight(WindowInsets.navigationBars) + .background(Color(scrims.navigationBarColor)) + ) + + MediaKeyboardScaffold( + controller = controller, + onEvent = onEvent, + keyboardsProvider = { + keyboard( + key = ChatKeyboards.Media, + containerColor = mediaKeyboardColor + ) { + AndroidFragment( + clazz = KeyboardPagerFragment::class.java, + modifier = Modifier.fillMaxSize() + ) + } + + keyboard( + key = ChatKeyboards.Attachment, + containerColor = attachmentKeyboardColor + ) { + AndroidFragment( + clazz = AttachmentKeyboardFragment::class.java, + modifier = Modifier.fillMaxSize() + ) + } + }, + keyboardHeight = keyboardHeight + ) { + AndroidView( + factory = { contentView }, + modifier = Modifier.fillMaxSize() + ) + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreenViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreenViewModel.kt new file mode 100644 index 0000000000..7d7de138c5 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScreenViewModel.kt @@ -0,0 +1,38 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import androidx.lifecycle.ViewModel +import org.signal.core.util.logging.Log +import org.thoughtcrime.securesms.keyvalue.SignalStore + +/** Remembers how tall the system keyboard is across runs. The scaffold measures it but does not persist it. */ +class ChatScreenViewModel : ViewModel() { + + companion object { + private val TAG = Log.tag(ChatScreenViewModel::class.java) + } + + /** + * @param isLandscape Which stored height to read. + * @param minimumPx Floor, used when nothing usable has been persisted. + */ + fun getStoredKeyboardHeight(isLandscape: Boolean, minimumPx: Int): Int { + val stored = if (isLandscape) SignalStore.misc.keyboardLandscapeHeight else SignalStore.misc.keyboardPortraitHeight + if (stored <= minimumPx) { + Log.w(TAG, "Saved keyboard height ($stored) is too low, using default size ($minimumPx)") + } + return maxOf(stored, minimumPx) + } + + fun setKeyboardHeight(isLandscape: Boolean, heightPx: Int) { + if (isLandscape) { + SignalStore.misc.keyboardLandscapeHeight = heightPx + } else { + SignalStore.misc.keyboardPortraitHeight = heightPx + } + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScrimState.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScrimState.kt new file mode 100644 index 0000000000..a051938b65 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ChatScrimState.kt @@ -0,0 +1,36 @@ +/* + * Copyright 2026 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.conversation.v2 + +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.graphics.Color as ComposeColor + +/** + * Colours [ChatScreen] paints behind the conversation. Pushed in from the fragment, since none of them + * are derivable from anything Compose can see. + */ +@Stable +class ChatScrimState { + + /** Matches the toolbar scrim, so the two read as one band. */ + @get:ColorInt + var statusBarColor: Int by mutableIntStateOf(Color.TRANSPARENT) + + @get:ColorInt + var navigationBarColor: Int by mutableIntStateOf(Color.TRANSPARENT) + + /** + * Fills the sheet behind the attachment keyboard, which does not paint its own edges. Unspecified + * until known, since a specified transparent would satisfy the scaffold and show through. + */ + var attachmentKeyboardColor: ComposeColor by mutableStateOf(ComposeColor.Unspecified) +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt index cc2a65e44b..930444dc48 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationFragment.kt @@ -32,6 +32,7 @@ import android.provider.Settings import android.text.Editable import android.text.TextWatcher import android.view.KeyEvent +import android.view.LayoutInflater import android.view.Menu import android.view.MenuItem import android.view.MotionEvent @@ -52,6 +53,9 @@ import androidx.annotation.MainThread import androidx.annotation.StringRes import androidx.appcompat.content.res.AppCompatResources import androidx.appcompat.widget.SearchView +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy import androidx.constraintlayout.widget.ConstraintSet import androidx.core.app.ActivityOptionsCompat import androidx.core.content.ContextCompat @@ -64,7 +68,6 @@ import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.core.view.updatePadding import androidx.fragment.app.DialogFragment -import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentResultListener import androidx.fragment.app.activityViewModels import androidx.fragment.app.commit @@ -113,11 +116,11 @@ import org.signal.core.models.database.StickerRecord import org.signal.core.models.media.Media import org.signal.core.models.media.TransformProperties import org.signal.core.ui.BottomSheetUtil +import org.signal.core.ui.compose.theme.SignalTheme import org.signal.core.ui.getWindowSizeClass import org.signal.core.ui.isSplitPane import org.signal.core.ui.logging.LoggingFragment import org.signal.core.ui.permissions.Permissions -import org.signal.core.ui.util.ThemeUtil import org.signal.core.ui.view.Stub import org.signal.core.util.ByteLimitInputFilter import org.signal.core.util.Debouncer @@ -156,9 +159,7 @@ import org.thoughtcrime.securesms.components.AnimatingToggle import org.thoughtcrime.securesms.components.ComposeText import org.thoughtcrime.securesms.components.ConversationSearchBottomBar import org.thoughtcrime.securesms.components.HidingLinearLayout -import org.thoughtcrime.securesms.components.InputAwareConstraintLayout import org.thoughtcrime.securesms.components.InputPanel -import org.thoughtcrime.securesms.components.InsetAwareConstraintLayout import org.thoughtcrime.securesms.components.ProgressCardDialogFragment import org.thoughtcrime.securesms.components.RotatedTiledDrawable import org.thoughtcrime.securesms.components.ScrollToPositionDelegate @@ -166,6 +167,9 @@ import org.thoughtcrime.securesms.components.SendButton import org.thoughtcrime.securesms.components.SignalProgressDialog import org.thoughtcrime.securesms.components.ViewBinderDelegate import org.thoughtcrime.securesms.components.compose.ActionModeTopBarView +import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardController +import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardEvents +import org.thoughtcrime.securesms.components.compose.mediakeyboard.MediaKeyboardKey import org.thoughtcrime.securesms.components.emoji.MediaKeyboard import org.thoughtcrime.securesms.components.emoji.RecentEmojiPageModel import org.thoughtcrime.securesms.components.location.SignalPlace @@ -259,6 +263,7 @@ import org.thoughtcrime.securesms.database.model.MessageRecord import org.thoughtcrime.securesms.database.model.MmsMessageRecord import org.thoughtcrime.securesms.database.model.Quote import org.thoughtcrime.securesms.database.model.databaseprotos.BodyRangeList +import org.thoughtcrime.securesms.databinding.V2ConversationBackgroundBinding import org.thoughtcrime.securesms.databinding.V2ConversationFragmentBinding import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.events.GroupCallPeekEvent @@ -284,7 +289,6 @@ import org.thoughtcrime.securesms.invites.InviteActions import org.thoughtcrime.securesms.jobs.AttachmentBackfill import org.thoughtcrime.securesms.jobs.ServiceOutageDetectionJob import org.thoughtcrime.securesms.keyboard.KeyboardPage -import org.thoughtcrime.securesms.keyboard.KeyboardPagerFragment import org.thoughtcrime.securesms.keyboard.KeyboardPagerViewModel import org.thoughtcrime.securesms.keyboard.KeyboardUtil import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment @@ -405,7 +409,7 @@ import org.signal.core.ui.R as CoreUiR * A single unified fragment for Conversations. */ class ConversationFragment : - LoggingFragment(R.layout.v2_conversation_fragment), + LoggingFragment(), ReactWithAnyEmojiBottomSheetDialogFragment.Callback, ReactionsBottomSheetDialogFragment.Callback, EmojiKeyboardPageFragment.Callback, @@ -438,9 +442,6 @@ class ConversationFragment : private const val SCROLL_HEADER_CLOSE_DELAY: Long = SCROLL_HEADER_ANIMATION_DURATION * 4 private const val IS_SCROLLED_TO_BOTTOM_THRESHOLD: Int = 2 - private const val ATTACHMENT_KEYBOARD_FRAGMENT_CREATOR_ID = 1 - private const val MEDIA_KEYBOARD_FRAGMENT_CREATOR_ID = 2 - private val RECEIVE_CONTENT_MIME_TYPES = arrayOf( "image/jpeg", "image/png", @@ -469,7 +470,8 @@ class ConversationFragment : } private val disposables = LifecycleDisposable() - private val binding by ViewBinderDelegate(bindingFactory = V2ConversationFragmentBinding::bind, onBindingWillBeDestroyed = { _binding -> + private val backgroundBinding by ViewBinderDelegate(bindingFactory = { V2ConversationBackgroundBinding.bind(conversationBackground) }) + private val binding by ViewBinderDelegate(bindingFactory = { V2ConversationFragmentBinding.bind(conversationContent) }, onBindingWillBeDestroyed = { _binding -> _binding.conversationInputPanel.embeddedTextEditor.apply { setOnEditorActionListener(null) setCursorPositionChangedListener(null) @@ -549,7 +551,7 @@ class ConversationFragment : args.threadId, inlineQueryViewModel, inputPanel, - (requireView() as ViewGroup), + (conversationContent as ViewGroup), composeText ) } @@ -620,8 +622,30 @@ class ConversationFragment : private val motionEventRelay: MotionEventRelay by viewModels(ownerProducer = { requireActivity() }) - private val container: InputAwareConstraintLayout - get() = requireView() as InputAwareConstraintLayout + /** The conversation's view hierarchy, hosted inside the ComposeView returned by [onCreateView]. */ + private lateinit var conversationContent: View + + /** The wallpaper, drawn behind [conversationContent]. */ + private lateinit var conversationBackground: View + + private val chatScreenViewModel: ChatScreenViewModel by viewModels() + + /** Stable across recomposition, so view code can ask for a keyboard from a click listener. */ + private val mediaKeyboardController: MediaKeyboardController by lazy(LazyThreadSafetyMode.NONE) { + MediaKeyboardController( + initialKeyboardHeightPx = chatScreenViewModel.getStoredKeyboardHeight( + isLandscape = isLandscape(), + minimumPx = resources.getDimensionPixelSize(R.dimen.default_custom_keyboard_size) + ) + ) + } + + /** Colours for the scrims ChatScreen paints. */ + private val chatScrims = ChatScrimState() + + private val container: ChatInputController by lazy(LazyThreadSafetyMode.NONE) { + ChatInputController(requireContext(), mediaKeyboardController) + } private val inputPanel: InputPanel get() = binding.conversationInputPanel.root @@ -672,20 +696,45 @@ class ConversationFragment : registerForResults() } - override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - viewModel.resetBackPressedState() - binding.toolbar.isBackInvokedCallbackEnabled = false - binding.root.setUseWindowTypes(args.conversationScreenType == ConversationScreenType.NORMAL && !resources.isSplitPane()) - if (args.conversationScreenType == ConversationScreenType.BUBBLE) { - binding.root.setNavigationBarInsetOverride(0) - view.post { - if (isAdded && this@ConversationFragment.view != null) { - ViewCompat.requestApplyInsets(binding.root) - binding.root.requestLayout() + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { + conversationBackground = inflater.inflate(R.layout.v2_conversation_background, container, false) + conversationContent = inflater.inflate(R.layout.v2_conversation_fragment, container, false) + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + SignalTheme { + ChatScreen( + controller = mediaKeyboardController, + onEvent = ::onMediaKeyboardEvent, + scrims = chatScrims, + isBubble = args.conversationScreenType == ConversationScreenType.BUBBLE, + backgroundView = conversationBackground, + contentView = conversationContent + ) } } } + } + private fun onMediaKeyboardEvent(event: MediaKeyboardEvents) { + when (event) { + is MediaKeyboardEvents.SystemKeyboardVisibilityChanged -> container.onKeyboardVisibilityChanged(event.visible) + MediaKeyboardEvents.SystemKeyboardAnimationEnded -> container.onKeyboardAnimationEnded() + is MediaKeyboardEvents.SystemKeyboardHeightMeasured -> chatScreenViewModel.setKeyboardHeight(isLandscape(), event.heightPx) + is MediaKeyboardEvents.KeyboardShown -> container.onInputShown(event.key) + MediaKeyboardEvents.KeyboardHidden -> container.onInputHidden() + MediaKeyboardEvents.DismissedByBack -> Unit + } + } + + private fun isLandscape(): Boolean { + return resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + viewModel.resetBackPressedState() + binding.toolbar.isBackInvokedCallbackEnabled = false disposables.bindTo(viewLifecycleOwner) if (requireActivity() is ConversationActivity) { @@ -695,13 +744,14 @@ class ConversationFragment : markReadHelper = MarkReadHelper(ConversationId.forConversation(args.threadId), requireContext(), viewLifecycleOwner, args.isIncognito) markReadHelper.ignoreViewReveals() - attachmentManager = AttachmentManager(requireContext(), requireView(), AttachmentManagerListener()) + attachmentManager = AttachmentManager(requireContext(), conversationContent, AttachmentManagerListener()) initializeConversationThreadUi() val conversationToolbarOnScrollHelper = ConversationToolbarOnScrollHelper( requireActivity(), - binding.toolbarBackground, + listOf(binding.toolbarBackground), + { color -> chatScrims.statusBarColor = color }, viewModel::wallpaperSnapshot, { viewModel.recipientSnapshot?.isReleaseNotes == true }, viewLifecycleOwner, @@ -726,8 +776,6 @@ class ConversationFragment : ) .addTo(disposables) - container.fragmentManager = childFragmentManager - childFragmentManager.setFragmentResultListener(MemberLabelEducationSheet.RESULT_EDIT_MEMBER_LABEL, viewLifecycleOwner) { _, bundle -> val groupId = bundle.requireParcelableCompat(MemberLabelEducationSheet.KEY_GROUP_ID, GroupId.V2::class.java) startActivity(MemberLabelActivity.createIntent(requireContext(), groupId)) @@ -882,6 +930,9 @@ class ConversationFragment : } keyboardEvents = null + // Fragment-scoped, so anything still waiting on a hide would outlive the binding. + container.clearListeners() + if (!requireActivity().isChangingConfigurations) { (requireActivity().supportFragmentManager.findFragmentByTag(MESSAGE_DETAILS_TAG) as? DialogFragment)?.dismissAllowingStateLoss() DeletedMessageTombstoneCache.clearThread(args.threadId) @@ -1102,15 +1153,6 @@ class ConversationFragment : state.isInActionMode -> finishActionMode() - state.isMediaKeyboardShowing -> { - if (container.isInputShowing) { - container.hideInput() - } else { - Log.d(TAG, "handleBackPressed() - media keyboard state was stale, clearing") - viewModel.setIsMediaKeyboardShowing(false) - } - } - else -> { // State has changed since the back handler was enabled. Let the back press proceed // to the next handler by triggering onBackPressed again after setting a skip flag @@ -1322,7 +1364,7 @@ class ConversationFragment : sendEditButton.setOnClickListener { handleSendEditMessage() } val attachListener = { _: View -> - container.toggleInput(AttachmentKeyboardFragmentCreator, composeText) + container.toggleInput(ChatKeyboards.Attachment, composeText) } binding.conversationInputPanel.attachButton.setOnClickListener(attachListener) binding.conversationInputPanel.inlineAttachmentButton.setOnClickListener(attachListener) @@ -1621,7 +1663,7 @@ class ConversationFragment : inputPanel.setHideForMessageRequestState(inputDisabled) if (inputDisabled && !isReleaseNotes) { - binding.navBar.setBackgroundColor(disabledInputView.color) + chatScrims.navigationBarColor = disabledInputView.color } else if (!inputDisabled) { disabledInputView.clear() } @@ -1642,7 +1684,7 @@ class ConversationFragment : val navBarInset = ViewCompat.getRootWindowInsets(binding.root)?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0 binding.conversationItemRecycler.updatePadding(bottom = ViewUtil.dpToPx(72) + navBarInset) - binding.navBar.setBackgroundColor(Color.TRANSPARENT) + chatScrims.navigationBarColor = Color.TRANSPARENT ConstraintSet().apply { clone(binding.root) @@ -1833,7 +1875,7 @@ class ConversationFragment : ) binding.toolbar.setNavigationContentDescription(R.string.ConversationFragment__content_description_back_button) binding.toolbar.setNavigationOnClickListener { - binding.root.hideKeyboard(composeText) + container.hideKeyboard(composeText) requireActivity().onBackPressedDispatcher.onBackPressed() } binding.toolbar.setContentInsetsRelative( @@ -1925,7 +1967,7 @@ class ConversationFragment : binding.toolbar.setActionItemTint(toolbarTint) binding.toolbar.navigationIcon?.setTint(toolbarTint) - binding.conversationWallpaper.visible = wallpaperEnabled + backgroundBinding.conversationWallpaper.visible = wallpaperEnabled binding.scrollToBottom.setWallpaperEnabled(wallpaperEnabled) binding.scrollToMention.setWallpaperEnabled(wallpaperEnabled) binding.conversationDisabledInput.setWallpaperEnabled(wallpaperEnabled) @@ -1966,18 +2008,18 @@ class ConversationFragment : ) val bitmap = DrawableUtil.toBitmap(tinted, tinted.intrinsicWidth, tinted.intrinsicHeight) - binding.conversationWallpaper.scaleType = ImageView.ScaleType.MATRIX - binding.conversationWallpaper.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.release_notes_background)) - binding.conversationWallpaper.setImageDrawable(RotatedTiledDrawable(bitmap, -45f)) - binding.conversationWallpaperDim.visible = false + backgroundBinding.conversationWallpaper.scaleType = ImageView.ScaleType.MATRIX + backgroundBinding.conversationWallpaper.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.release_notes_background)) + backgroundBinding.conversationWallpaper.setImageDrawable(RotatedTiledDrawable(bitmap, -45f)) + backgroundBinding.conversationWallpaperDim.visible = false } private fun applyChatWallpaper(chatWallpaper: ChatWallpaper?) { if (chatWallpaper != null) { - chatWallpaper.loadInto(binding.conversationWallpaper) - ChatWallpaperDimLevelUtil.applyDimLevelForNightMode(binding.conversationWallpaperDim, chatWallpaper) + chatWallpaper.loadInto(backgroundBinding.conversationWallpaper) + ChatWallpaperDimLevelUtil.applyDimLevelForNightMode(backgroundBinding.conversationWallpaperDim, chatWallpaper) } else { - binding.conversationWallpaperDim.visible = false + backgroundBinding.conversationWallpaperDim.visible = false } } @@ -1988,7 +2030,13 @@ class ConversationFragment : CoreUiR.color.signal_colorBackground } - binding.navBar.setBackgroundColor(ContextCompat.getColor(requireContext(), navColor)) + chatScrims.navigationBarColor = ContextCompat.getColor(requireContext(), navColor) + chatScrims.attachmentKeyboardColor = androidx.compose.ui.graphics.Color( + ContextCompat.getColor( + requireContext(), + if (hasWallpaper) R.color.wallpaper_compose_background else R.color.signal_background_primary + ) + ) } private fun presentChatColors(chatColors: ChatColors) { @@ -4128,7 +4176,7 @@ class ConversationFragment : ViewUtil.fadeOut(target.quotedIndicatorView!!, 150, View.INVISIBLE) } - container.hideKeyboard(composeText, keepHeightOverride = true) + container.hideKeyboard(composeText) viewModel.setHideScrollButtonsForReactionOverlay(true) @@ -4872,7 +4920,7 @@ class ConversationFragment : inputPanel.onSaveRecordDraft() } - ScheduleMessageContextMenu.show(sendButton, (requireView() as ViewGroup)) { time -> + ScheduleMessageContextMenu.show(sendButton, (conversationContent as ViewGroup)) { time -> if (time == -1L) { showSchedule(childFragmentManager) } else { @@ -5041,7 +5089,7 @@ class ConversationFragment : } override fun onEmojiToggle() { - container.toggleInput(MediaKeyboardFragmentCreator, composeText, showSoftKeyOnHide = true) + container.toggleInput(ChatKeyboards.Media, composeText, showSoftKeyOnHide = true) } override fun onLinkPreviewCanceled() { @@ -5156,11 +5204,6 @@ class ConversationFragment : } } - private object AttachmentKeyboardFragmentCreator : InputAwareConstraintLayout.FragmentCreator { - override val id: Int = ATTACHMENT_KEYBOARD_FRAGMENT_CREATOR_ID - override fun create(): Fragment = AttachmentKeyboardFragment() - } - private inner class AttachmentKeyboardFragmentListener : FragmentResultListener { @Suppress("DEPRECATION") override fun onFragmentResult(requestKey: String, result: Bundle) { @@ -5199,40 +5242,19 @@ class ConversationFragment : } } - private object MediaKeyboardFragmentCreator : InputAwareConstraintLayout.FragmentCreator { - override val id: Int = MEDIA_KEYBOARD_FRAGMENT_CREATOR_ID - override fun create(): Fragment = KeyboardPagerFragment() - } - private inner class KeyboardEvents : - InputAwareConstraintLayout.Listener, - InsetAwareConstraintLayout.KeyboardStateListener { + ChatInputController.Listener, + ChatInputController.KeyboardStateListener { - override fun onInputShown(fragmentCreatorId: Int) { - when (fragmentCreatorId) { - ATTACHMENT_KEYBOARD_FRAGMENT_CREATOR_ID -> { - if (viewModel.recipientSnapshot?.wallpaper != null) { - binding.navBar.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.wallpaper_compose_background)) - } else { - binding.navBar.setBackgroundColor(ContextCompat.getColor(requireContext(), R.color.signal_background_primary)) - } - } - - MEDIA_KEYBOARD_FRAGMENT_CREATOR_ID -> { - binding.navBar.setBackgroundColor(ThemeUtil.getThemedColor(requireContext(), R.attr.mediaKeyboardBottomBarBackgroundColor)) - } - - else -> { - Log.w(TAG, "Not setting navbar coloring for unknown creator id $fragmentCreatorId") - } + override fun onInputShown(key: MediaKeyboardKey) { + if (key == ChatKeyboards.Media) { + onShown() } - - viewModel.setIsMediaKeyboardShowing(true) } override fun onInputHidden() { setNavBarBackgroundColor(viewModel.wallpaperSnapshot != null || viewModel.recipientSnapshot?.isReleaseNotes == true) - viewModel.setIsMediaKeyboardShowing(false) + onHidden() } override fun onKeyboardShown() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationToolbarOnScrollHelper.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationToolbarOnScrollHelper.kt index 3f0ebf7781..3835de8cc1 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationToolbarOnScrollHelper.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationToolbarOnScrollHelper.kt @@ -11,17 +11,21 @@ import org.signal.core.ui.R as CoreUiR /** * Scroll helper to manage the color state of the top bar and status bar. + * + * @param onSetToolbarColor Receives every animated frame, for scrims drawn outside the view hierarchy. */ class ConversationToolbarOnScrollHelper( activity: FragmentActivity, - toolbarBackground: View, + toolbarBackgrounds: List, + onSetToolbarColor: (Int) -> Unit, private val wallpaperProvider: () -> ChatWallpaper?, private val releaseNotesProvider: () -> Boolean, lifecycleOwner: LifecycleOwner, private val incognito: Boolean = false ) : Material3OnScrollHelper( activity = activity, - views = listOf(toolbarBackground), + views = toolbarBackgrounds, + onSetToolbarColor = onSetToolbarColor, lifecycleOwner = lifecycleOwner ) { override val activeColorSet: ColorSet diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt index 508c5ceb9c..7f8a9a97be 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/v2/ConversationViewModel.kt @@ -739,12 +739,6 @@ class ConversationViewModel( } } - fun setIsMediaKeyboardShowing(isMediaKeyboardShowing: Boolean) { - internalBackPressedState.update { - it.copy(isMediaKeyboardShowing = isMediaKeyboardShowing) - } - } - fun resetBackPressedState() { internalBackPressedState.value = BackPressedState() } @@ -846,12 +840,12 @@ class ConversationViewModel( data object Cancelled : PlaintextExportState } + /** A media keyboard is absent by design: MediaKeyboardScaffold registers its own back handler. */ data class BackPressedState( val isReactionDelegateShowing: Boolean = false, val isSearchRequested: Boolean = false, - val isInActionMode: Boolean = false, - val isMediaKeyboardShowing: Boolean = false + val isInActionMode: Boolean = false ) { - fun shouldHandleBackPressed() = isSearchRequested || isReactionDelegateShowing || isInActionMode || isMediaKeyboardShowing + fun shouldHandleBackPressed() = isSearchRequested || isReactionDelegateShowing || isInActionMode } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerFragment.kt index 0739cacaea..8b3bad45f4 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/keyboard/KeyboardPagerFragment.kt @@ -7,7 +7,6 @@ import android.view.ViewGroup import androidx.fragment.app.Fragment import androidx.lifecycle.ViewModelProvider import org.thoughtcrime.securesms.R -import org.thoughtcrime.securesms.components.InputAwareConstraintLayout import org.thoughtcrime.securesms.components.emoji.MediaKeyboard import org.thoughtcrime.securesms.keyboard.emoji.EmojiKeyboardPageFragment import org.thoughtcrime.securesms.keyboard.gif.GifKeyboardPageFragment @@ -19,7 +18,7 @@ import org.thoughtcrime.securesms.util.fragments.findListener import org.thoughtcrime.securesms.util.visible import kotlin.reflect.KClass -class KeyboardPagerFragment : Fragment(), InputAwareConstraintLayout.InputFragment { +class KeyboardPagerFragment : Fragment() { private lateinit var emojiButton: View private lateinit var stickerButton: View @@ -102,14 +101,19 @@ class KeyboardPagerFragment : Fragment(), InputAwareConstraintLayout.InputFragme transaction.commitAllowingStateLoss() } - override fun show() { + /** + * Called by hosts that show and hide this fragment in place rather than creating it fresh, such as + * [org.thoughtcrime.securesms.components.emoji.MediaKeyboard]. ChatScreen creates and destroys it + * instead, so it does not need these. + */ + fun show() { findListener()?.onShown() if (isAdded && view != null) { viewModel.page().value?.let(this::onPageSelected) } } - override fun hide() { + fun hide() { findListener()?.onHidden() if (isAdded && view != null) { val transaction = childFragmentManager.beginTransaction() diff --git a/app/src/main/res/layout/v2_conversation_background.xml b/app/src/main/res/layout/v2_conversation_background.xml new file mode 100644 index 0000000000..d1818064ff --- /dev/null +++ b/app/src/main/res/layout/v2_conversation_background.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/app/src/main/res/layout/v2_conversation_fragment.xml b/app/src/main/res/layout/v2_conversation_fragment.xml index 92d34d0a17..35c43630f8 100644 --- a/app/src/main/res/layout/v2_conversation_fragment.xml +++ b/app/src/main/res/layout/v2_conversation_fragment.xml @@ -1,31 +1,10 @@ - - - - - - - + android:clipChildren="false"> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> @@ -224,7 +203,7 @@ android:visibility="invisible" app:cstv_scroll_button_src="@drawable/ic_chevron_down_20" app:layout_constraintBottom_toTopOf="@id/conversation_bottom_panel_barrier" - app:layout_constraintEnd_toEndOf="@id/parent_end_guideline" + app:layout_constraintEnd_toEndOf="parent" tools:visibility="visible" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + app:constraint_referenced_ids="emoji_search_container" /> @@ -341,37 +320,21 @@ app:layout_constraintBottom_toTopOf="@id/conversation_bottom_panel_barrier" tools:visibility="visible" /> - - - - + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + app:layout_constraintBottom_toBottomOf="parent" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" /> + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent" /> - +