mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-09-24 02:33:00 +01:00
Wrap chat screen in a BottomSheetScaffold.
This commit is contained in:
-163
@@ -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()
|
||||
}
|
||||
}
|
||||
+77
@@ -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) }
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
+15
@@ -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)
|
||||
+329
@@ -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)
|
||||
}
|
||||
}
|
||||
+53
@@ -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)
|
||||
}
|
||||
+106
-84
@@ -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() {
|
||||
|
||||
+6
-2
@@ -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
|
||||
|
||||
+3
-9
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user