Wrap chat screen in a BottomSheetScaffold.

This commit is contained in:
Alex Hart
2026-09-04 15:34:02 -03:00
parent e760512352
commit beb117d46a
17 changed files with 1088 additions and 348 deletions
@@ -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<Listener> = 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()
}
}
@@ -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) }
}
@@ -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
}
@@ -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)
@@ -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<Float>(
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<MediaKeyboardKey?>(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)
}
}
@@ -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<MediaKeyboardKey, Entry>()
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)
}
@@ -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<Listener> = mutableSetOf()
private val keyboardStateListeners: MutableSet<KeyboardStateListener> = 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
}
}
@@ -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")
}
@@ -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()
)
}
}
}
@@ -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
}
}
}
@@ -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)
}
@@ -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() {
@@ -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<View>,
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
@@ -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
}
}
@@ -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<MediaKeyboard.MediaKeyboardListener>()?.onShown()
if (isAdded && view != null) {
viewModel.page().value?.let(this::onPageSelected)
}
}
override fun hide() {
fun hide() {
findListener<MediaKeyboard.MediaKeyboardListener>()?.onHidden()
if (isAdded && view != null) {
val transaction = childFragmentManager.beginTransaction()
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?><!--
The wallpaper, pulled up out of v2_conversation_fragment so that insetting the conversation content
never shrinks it. The system bar scrims that used to live here are drawn by ChatScreen, which can
size them from the insets that are actually left after a host has consumed its own.
-->
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ImageView
android:id="@+id/conversation_wallpaper"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="centerCrop" />
<View
android:id="@+id/conversation_wallpaper_dim"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:visibility="gone"
tools:alpha="0.2f"
tools:visibility="visible" />
</FrameLayout>
@@ -1,31 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<org.thoughtcrime.securesms.components.InputAwareConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipChildren="false"
app:animateKeyboardChanges="true">
<include layout="@layout/system_ui_guidelines" />
<ImageView
android:id="@+id/conversation_wallpaper"
android:layout_width="0dp"
android:layout_height="match_parent"
android:importantForAccessibility="no"
android:scaleType="centerCrop"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
<View
android:id="@+id/conversation_wallpaper_dim"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/black"
android:visibility="gone"
tools:alpha="0.2f"
tools:visibility="visible" />
android:clipChildren="false">
<FrameLayout
android:id="@+id/conversation_video_container"
@@ -41,8 +20,8 @@
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/conversation_bottom_panel_barrier"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<org.thoughtcrime.securesms.conversation.mutiselect.MultiselectRecyclerView
@@ -85,8 +64,8 @@
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="@id/toolbar"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<org.thoughtcrime.securesms.util.views.DarkOverflowToolbar
@@ -100,9 +79,9 @@
android:theme="?attr/actionBarStyle"
app:contentInsetStart="46dp"
app:contentInsetStartWithNavigation="0dp"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintTop_toTopOf="@id/status_bar_guideline">
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent">
<LinearLayout
android:layout_width="match_parent"
@@ -143,18 +122,18 @@
android:layout_width="0dp"
android:layout_height="@dimen/signal_m3_toolbar_height"
android:visibility="gone"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_min="@dimen/signal_m3_toolbar_height"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintTop_toTopOf="@id/status_bar_guideline" />
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<FrameLayout
android:id="@+id/conversation_banner_frame"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:clipChildren="true"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/toolbar">
<org.thoughtcrime.securesms.conversation.v2.ConversationBannerView
@@ -211,7 +190,7 @@
android:visibility="invisible"
app:cstv_scroll_button_src="@drawable/ic_at_20"
app:layout_constraintBottom_toTopOf="@id/scroll_to_bottom"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_goneMarginBottom="20dp"
tools:visibility="visible" />
@@ -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" />
<androidx.fragment.app.FragmentContainerView
@@ -232,8 +211,8 @@
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toTopOf="@id/conversation_bottom_panel_barrier"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/toolbar" />
<androidx.constraintlayout.widget.Barrier
@@ -250,8 +229,8 @@
android:inflatedId="@+id/attachment_editor_stub"
android:layout="@layout/conversation_activity_attachment_editor_stub"
app:layout_constraintBottom_toTopOf="@+id/conversation_input_panel"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ViewStub
android:id="@+id/scheduled_messages_stub"
@@ -261,8 +240,8 @@
android:inflatedId="@+id/scheduled_messages"
android:layout="@layout/conversation_activity_scheduled_messages_stub"
app:layout_constraintBottom_toTopOf="@+id/attachment_editor_stub"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_goneMarginBottom="0dp" />
<include
@@ -271,8 +250,8 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@+id/conversation_input_space_left"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<include
android:id="@+id/conversation_search_bottom_bar"
@@ -280,17 +259,17 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/keyboard_guideline"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<org.thoughtcrime.securesms.conversation.v2.DisabledInputView
android:id="@+id/conversation_disabled_input"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toTopOf="@id/keyboard_guideline"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/conversation_release_notes_floating_label"
@@ -306,16 +285,16 @@
android:textAppearance="@style/Signal.Text.BodyMedium"
android:textColor="@color/signal_text_primary"
android:visibility="gone"
app:layout_constraintBottom_toTopOf="@id/keyboard_guideline"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/conversation_input_panel_barrier"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
app:barrierDirection="top"
app:constraint_referenced_ids="emoji_search_container,keyboard_guideline,navigation_bar_guideline" />
app:constraint_referenced_ids="emoji_search_container" />
<TextView
android:id="@+id/conversation_input_space_left"
@@ -326,8 +305,8 @@
android:paddingEnd="5dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/conversation_input_panel_barrier"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
tools:text="160/160 (1)"
tools:visibility="visible" />
@@ -341,37 +320,21 @@
app:layout_constraintBottom_toTopOf="@id/conversation_bottom_panel_barrier"
tools:visibility="visible" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/input_container"
android:layout_width="0dp"
android:layout_height="0dp"
app:layout_constraintBottom_toBottomOf="@id/navigation_bar_guideline"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline"
app:layout_constraintTop_toTopOf="@id/keyboard_guideline" />
<View
android:id="@+id/nav_bar"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toTopOf="@id/navigation_bar_guideline"
app:layout_constraintBottom_toBottomOf="parent" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/emoji_search_container"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:layout_constraintBottom_toBottomOf="@id/keyboard_guideline"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<androidx.fragment.app.FragmentContainerView
android:id="@+id/polls_create"
android:layout_width="0dp"
android:layout_height="match_parent"
app:layout_constraintBottom_toBottomOf="@id/keyboard_guideline"
app:layout_constraintEnd_toEndOf="@id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@id/parent_start_guideline" />
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<FrameLayout
android:id="@+id/reactions_shade"
@@ -388,9 +351,9 @@
android:layout_marginHorizontal="16dp"
android:layout_marginBottom="16dp"
android:visibility="gone"
app:layout_constraintBottom_toBottomOf="@id/keyboard_guideline"
app:layout_constraintEnd_toEndOf="@+id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@+id/parent_start_guideline" />
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<ViewStub
android:id="@+id/conversation_reaction_scrubber_stub"
@@ -399,8 +362,8 @@
android:inflatedId="@+id/conversation_reaction_scrubber"
android:layout="@layout/conversation_reaction_scrubber"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/parent_end_guideline"
app:layout_constraintStart_toStartOf="@+id/parent_start_guideline"
app:layout_constraintTop_toTopOf="@+id/status_bar_guideline" />
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</org.thoughtcrime.securesms.components.InputAwareConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>