mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 04:15:12 +00:00
Merge branch 'develop' into feature/fga/local_network_permission
# Conflicts: # libraries/androidutils/src/main/AndroidManifest.xml
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
android:name="android.hardware.microphone"
|
||||
android:required="false" />
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.contentscanner.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import extension.setupDependencyInjection
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.features.contentscanner.impl"
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
api(projects.features.contentscanner.api)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
}
|
||||
+49
-18
@@ -15,15 +15,23 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ExperimentalLayoutApi
|
||||
import androidx.compose.foundation.layout.FlowRow
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.onFocusChanged
|
||||
import androidx.compose.ui.res.pluralStringResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
@@ -32,6 +40,8 @@ import io.element.android.features.lockscreen.impl.pin.model.PinEntry
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.pinDigitBg
|
||||
import io.element.android.libraries.ui.strings.CommonPlurals
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun PinEntryTextField(
|
||||
@@ -39,16 +49,25 @@ fun PinEntryTextField(
|
||||
isSecured: Boolean,
|
||||
onValueChange: (String) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
initialIsFocus: Boolean = false,
|
||||
) {
|
||||
var isFocused by remember { mutableStateOf(initialIsFocus) }
|
||||
val filledCount = pinEntry.digits.count { it is PinDigit.Filled }
|
||||
val pinFieldLabel = stringResource(CommonStrings.a11y_pin_field)
|
||||
val digitsEnteredLabel = pluralStringResource(CommonPlurals.a11y_digits_entered, filledCount, filledCount)
|
||||
BasicTextField(
|
||||
modifier = modifier,
|
||||
modifier = modifier
|
||||
.onFocusChanged { isFocused = it.isFocused }
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = "$pinFieldLabel, $digitsEnteredLabel"
|
||||
},
|
||||
value = pinEntry.toText(),
|
||||
onValueChange = {
|
||||
onValueChange(it)
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
|
||||
decorationBox = {
|
||||
PinEntryRow(pinEntry = pinEntry, isSecured = isSecured)
|
||||
PinEntryRow(pinEntry = pinEntry, isSecured = isSecured, isFocused = isFocused)
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -58,13 +77,19 @@ fun PinEntryTextField(
|
||||
private fun PinEntryRow(
|
||||
pinEntry: PinEntry,
|
||||
isSecured: Boolean,
|
||||
isFocused: Boolean,
|
||||
) {
|
||||
FlowRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (digit in pinEntry.digits) {
|
||||
PinDigitView(digit = digit, isSecured = isSecured)
|
||||
val focusedIndex = pinEntry.digits.indexOfFirst { it is PinDigit.Empty }
|
||||
pinEntry.digits.forEachIndexed { index, digit ->
|
||||
PinDigitView(
|
||||
digit = digit,
|
||||
isSecured = isSecured,
|
||||
isFocused = isFocused && index == focusedIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -73,11 +98,16 @@ private fun PinEntryRow(
|
||||
private fun PinDigitView(
|
||||
digit: PinDigit,
|
||||
isSecured: Boolean,
|
||||
isFocused: Boolean,
|
||||
) {
|
||||
val shape = RoundedCornerShape(8.dp)
|
||||
val appearanceModifier = when (digit) {
|
||||
PinDigit.Empty -> {
|
||||
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
|
||||
if (isFocused) {
|
||||
Modifier.border(2.dp, ElementTheme.colors.borderFocused, shape)
|
||||
} else {
|
||||
Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
|
||||
}
|
||||
}
|
||||
is PinDigit.Filled -> {
|
||||
Modifier.background(ElementTheme.colors.pinDigitBg, shape)
|
||||
@@ -108,18 +138,19 @@ private fun PinDigitView(
|
||||
internal fun PinEntryTextFieldPreview() {
|
||||
ElementPreview {
|
||||
val pinEntry = PinEntry.createEmpty(4).fillWith("12")
|
||||
Column {
|
||||
PinEntryTextField(
|
||||
pinEntry = pinEntry,
|
||||
isSecured = true,
|
||||
onValueChange = {},
|
||||
)
|
||||
Spacer(modifier = Modifier.size(16.dp))
|
||||
PinEntryTextField(
|
||||
pinEntry = pinEntry,
|
||||
isSecured = false,
|
||||
onValueChange = {},
|
||||
)
|
||||
Column(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
listOf(true, false).forEach { isSecured ->
|
||||
listOf(true, false).forEach { initialIsFocus ->
|
||||
PinEntryTextField(
|
||||
pinEntry = pinEntry,
|
||||
isSecured = isSecured,
|
||||
initialIsFocus = initialIsFocus,
|
||||
onValueChange = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+11
-5
@@ -288,8 +288,7 @@ class MessageComposerPresenter(
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
|
||||
// Reset composer since the attachment has been sent
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
resetComposerModeAfterAttaching()
|
||||
}
|
||||
is MessageComposerEvent.SetMode -> {
|
||||
localCoroutineScope.setMode(event.composerMode, markdownTextEditorState, richTextEditorState)
|
||||
@@ -638,8 +637,7 @@ class MessageComposerPresenter(
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
navigator.navigateToPreviewAttachments(persistentListOf(mediaAttachment), inReplyToEventId)
|
||||
|
||||
// Reset composer since the attachment will be sent in a separate flow
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
resetComposerModeAfterAttaching()
|
||||
}
|
||||
|
||||
private fun handlePickedMediaList(
|
||||
@@ -663,7 +661,15 @@ class MessageComposerPresenter(
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
|
||||
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
resetComposerModeAfterAttaching()
|
||||
}
|
||||
|
||||
private fun resetComposerModeAfterAttaching() {
|
||||
// An attachment is sent as its own message, so a Reply/Normal mode is consumed and reset.
|
||||
// An in-progress edit must survive: the typed text still edits the original on the next send.
|
||||
if (!messageComposerContext.composerMode.isEditing) {
|
||||
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun sendMedia(
|
||||
|
||||
+245
-24
@@ -8,61 +8,206 @@
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.indication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.ui.window.Popup
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePadding
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePicker
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSize
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSpacing
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.aSkinList
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toDp
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
@Composable
|
||||
fun EmojiItem(
|
||||
item: Emoji,
|
||||
isSelected: Boolean,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
onLongPress: (Emoji) -> Unit,
|
||||
onDismissSkinPicker: () -> Unit,
|
||||
skinPickerEmoji: Emoji?,
|
||||
selectedSkinUnicodes: ImmutableSet<String>,
|
||||
hasSelectedSkin: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
emojiSize: TextUnit = 20.sp,
|
||||
) {
|
||||
val backgroundColor = if (isSelected) {
|
||||
ElementTheme.colors.bgActionPrimaryRest
|
||||
} else {
|
||||
Color.Transparent
|
||||
val backgroundColor = when {
|
||||
isSelected -> ElementTheme.colors.bgActionPrimaryRest
|
||||
hasSelectedSkin -> ElementTheme.colors.bgActionTertiarySelected
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
var itemSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var itemOffsetPercent by remember { mutableFloatStateOf(0f) }
|
||||
var hoveredIndex by remember { mutableIntStateOf(-1) }
|
||||
var dismissed by remember { mutableStateOf(false) }
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val description = a11yReactionAction(
|
||||
emoji = item.unicode,
|
||||
userAlreadyReacted = isSelected,
|
||||
)
|
||||
val hasSkinTones = !item.skins.isNullOrEmpty()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.onPlaced { coordinates ->
|
||||
val position = coordinates.positionInParent()
|
||||
val parentBounds = coordinates.parentLayoutCoordinates?.size ?: return@onPlaced
|
||||
// Calculate the offset of the item inside its parent as a percentage, to be able to set the initial offset
|
||||
// for the selection inside the skin tone picker relative to its size.
|
||||
itemOffsetPercent = if (parentBounds.width > 0) {
|
||||
position.x / parentBounds.width
|
||||
} else {
|
||||
0f
|
||||
}
|
||||
}
|
||||
.sizeIn(minWidth = 40.dp, minHeight = 40.dp)
|
||||
.onSizeChanged { itemSize = it }
|
||||
.background(backgroundColor, CircleShape)
|
||||
.clickable(
|
||||
enabled = true,
|
||||
onClick = { onSelectEmoji(item) },
|
||||
indication = ripple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp),
|
||||
interactionSource = remember { MutableInteractionSource() }
|
||||
.indication(interactionSource, ripple())
|
||||
.pointerInput(item) {
|
||||
// Always detect long press and tap gestures
|
||||
detectTapGestures(
|
||||
onPress = { pressOffset ->
|
||||
val press = PressInteraction.Press(pressOffset)
|
||||
interactionSource.emit(press)
|
||||
if (tryAwaitRelease()) {
|
||||
interactionSource.emit(PressInteraction.Release(press))
|
||||
if (hasSkinTones) {
|
||||
onSelectEmoji(item)
|
||||
}
|
||||
} else {
|
||||
interactionSource.emit(PressInteraction.Cancel(press))
|
||||
}
|
||||
},
|
||||
onTap = { onSelectEmoji(item) },
|
||||
)
|
||||
}
|
||||
.then(
|
||||
// Only detect drag after long press for those items which have a skin tone picker
|
||||
if (hasSkinTones) {
|
||||
Modifier.pointerInput(item) {
|
||||
val slotWidthPx = with(density) { SkinToneSlotSize.toPx() }
|
||||
val spacingPx = with(density) { SkinToneSlotSpacing.toPx() }
|
||||
val paddingPx = with(density) { SkinTonePadding.toPx() }
|
||||
var startOffset = Offset.Zero
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { position ->
|
||||
startOffset = position
|
||||
dismissed = false
|
||||
onLongPress(item)
|
||||
hoveredIndex = -1
|
||||
},
|
||||
onDrag = { change, _ ->
|
||||
if (!dismissed) {
|
||||
change.consume()
|
||||
// It's a valid drag event if it's within ~2 items height above, so, on top of the current one or the skin tone selector,
|
||||
// or 1 item below, which should be far enough to not be triggered by mistake
|
||||
val isValidDrag = if (change.position.y < startOffset.y) {
|
||||
startOffset.y - change.position.y <= itemSize.height * 2f
|
||||
} else {
|
||||
change.position.y - startOffset.y <= itemSize.height
|
||||
}
|
||||
if (!isValidDrag) {
|
||||
dismissed = true
|
||||
hoveredIndex = -1
|
||||
onDismissSkinPicker()
|
||||
} else {
|
||||
val skinItemsCount = item.skins!!.size
|
||||
// Original + variants
|
||||
val totalSlots = 1 + skinItemsCount
|
||||
// Calculate the whole size of the skin tone picker
|
||||
val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx
|
||||
// Calculate the initial offset inside the picker, given the relative position of the item inside its parent
|
||||
val initialOffset = pickerWidthPx * itemOffsetPercent
|
||||
val xInPicker = initialOffset + change.position.x
|
||||
|
||||
// If it's a valid offset, calculate the hovered index, otherwise, set it to -1 to indicate that no item is hovered
|
||||
val index = if (xInPicker in 0f..pickerWidthPx) {
|
||||
(xInPicker / slotWidthPx).toInt().coerceIn(0, totalSlots - 1)
|
||||
} else {
|
||||
-1
|
||||
}
|
||||
hoveredIndex = index
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
if (!dismissed) {
|
||||
val idx = hoveredIndex
|
||||
val skinCount = item.skins?.size ?: 0
|
||||
if (idx in 0..skinCount) {
|
||||
if (idx == 0) {
|
||||
onSelectEmoji(item)
|
||||
} else {
|
||||
val skin = item.skins?.getOrNull(idx - 1)
|
||||
if (skin != null) {
|
||||
onSelectEmoji(item.copy(unicode = skin.unicode))
|
||||
}
|
||||
}
|
||||
} else if (idx < 0) {
|
||||
onSelectEmoji(item)
|
||||
}
|
||||
}
|
||||
dismissed = false
|
||||
hoveredIndex = -1
|
||||
onDismissSkinPicker()
|
||||
},
|
||||
onDragCancel = {
|
||||
dismissed = false
|
||||
hoveredIndex = -1
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = description
|
||||
@@ -73,6 +218,42 @@ fun EmojiItem(
|
||||
text = item.unicode,
|
||||
style = LocalTextStyle.current.copy(fontSize = emojiSize),
|
||||
)
|
||||
if (hasSkinTones) {
|
||||
val pickerIndicatorColor = ElementTheme.colors.borderInteractivePrimary
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(8.dp),
|
||||
) {
|
||||
val path = Path().apply {
|
||||
moveTo(size.width, size.height)
|
||||
lineTo(size.width, 0f)
|
||||
lineTo(0f, size.height)
|
||||
close()
|
||||
}
|
||||
drawPath(path, color = pickerIndicatorColor, style = Fill)
|
||||
}
|
||||
}
|
||||
|
||||
if (skinPickerEmoji == item) {
|
||||
val pickerHeight = SkinTonePadding * 2 + SkinToneSlotSize
|
||||
val popupOffsetPx = with(density) { -pickerHeight.roundToPx() }
|
||||
Popup(
|
||||
onDismissRequest = { onDismissSkinPicker() },
|
||||
alignment = Alignment.BottomCenter,
|
||||
offset = IntOffset(0, popupOffsetPx),
|
||||
) {
|
||||
SkinTonePicker(
|
||||
emoji = item,
|
||||
onSelect = { selectedEmoji ->
|
||||
onSelectEmoji(selectedEmoji)
|
||||
onDismissSkinPicker()
|
||||
},
|
||||
hoveredIndex = hoveredIndex,
|
||||
selectedUnicodes = selectedSkinUnicodes,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,21 +261,61 @@ fun EmojiItem(
|
||||
@Composable
|
||||
internal fun EmojiItemPreview() = ElementPreview {
|
||||
Row(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (isSelected in listOf(true, false)) {
|
||||
EmojiItem(
|
||||
item = Emoji(
|
||||
hexcode = "",
|
||||
label = "",
|
||||
tags = null,
|
||||
shortcodes = persistentListOf(),
|
||||
unicode = "👍",
|
||||
skins = null
|
||||
),
|
||||
isSelected = isSelected,
|
||||
onSelectEmoji = {},
|
||||
)
|
||||
for (isSelected in listOf(false, true)) {
|
||||
for (skinList in listOf(persistentListOf(), aSkinList())) {
|
||||
val hasSelectedSkinValues = if (skinList.isNotEmpty()) listOf(false, true) else listOf(false)
|
||||
for (hasSelectedSkin in hasSelectedSkinValues) {
|
||||
EmojiItem(
|
||||
item = Emoji(
|
||||
hexcode = "",
|
||||
label = "",
|
||||
tags = null,
|
||||
shortcodes = persistentListOf(),
|
||||
unicode = "👍",
|
||||
skins = skinList,
|
||||
),
|
||||
isSelected = isSelected,
|
||||
onSelectEmoji = {},
|
||||
hasSelectedSkin = hasSelectedSkin,
|
||||
onLongPress = {},
|
||||
onDismissSkinPicker = {},
|
||||
skinPickerEmoji = null,
|
||||
selectedSkinUnicodes = persistentSetOf(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmojiItemWithPopupPreview() = ElementPreview {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 100.dp, bottom = 4.dp, start = 200.dp, end = 200.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val item = Emoji(
|
||||
hexcode = "",
|
||||
label = "",
|
||||
tags = null,
|
||||
shortcodes = persistentListOf(),
|
||||
unicode = "👍",
|
||||
skins = aSkinList(),
|
||||
)
|
||||
EmojiItem(
|
||||
item = item,
|
||||
isSelected = false,
|
||||
onSelectEmoji = {},
|
||||
hasSelectedSkin = false,
|
||||
onLongPress = {},
|
||||
onDismissSkinPicker = {},
|
||||
skinPickerEmoji = item,
|
||||
selectedSkinUnicodes = persistentSetOf("👍🏾"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+23
@@ -25,7 +25,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SecondaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
@@ -54,6 +58,8 @@ fun EmojiPicker(
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(pageCount = { state.categories.size })
|
||||
var skinPickerEmoji by remember { mutableStateOf<Emoji?>(null) }
|
||||
|
||||
Column(modifier) {
|
||||
SearchBar(
|
||||
modifier = Modifier.padding(bottom = 10.dp),
|
||||
@@ -68,6 +74,10 @@ fun EmojiPicker(
|
||||
emojis = emojis,
|
||||
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
onLongPress = { skinPickerEmoji = it },
|
||||
skinPickerEmoji = skinPickerEmoji,
|
||||
onDismissSkinPicker = { skinPickerEmoji = null },
|
||||
selectedEmojis = selectedEmojis,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -106,6 +116,10 @@ fun EmojiPicker(
|
||||
emojis = emojis,
|
||||
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
onLongPress = { skinPickerEmoji = it },
|
||||
skinPickerEmoji = skinPickerEmoji,
|
||||
onDismissSkinPicker = { skinPickerEmoji = null },
|
||||
selectedEmojis = selectedEmojis,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -117,6 +131,10 @@ private fun EmojiResults(
|
||||
emojis: ImmutableList<Emoji>,
|
||||
isEmojiSelected: (Emoji) -> Boolean,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
onLongPress: (Emoji) -> Unit,
|
||||
skinPickerEmoji: Emoji?,
|
||||
onDismissSkinPicker: () -> Unit,
|
||||
selectedEmojis: ImmutableSet<String>,
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
@@ -131,7 +149,12 @@ private fun EmojiResults(
|
||||
item = item,
|
||||
isSelected = isEmojiSelected(item),
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
onLongPress = onLongPress,
|
||||
skinPickerEmoji = skinPickerEmoji,
|
||||
onDismissSkinPicker = onDismissSkinPicker,
|
||||
emojiSize = 32.dp.toSp(),
|
||||
selectedSkinUnicodes = selectedEmojis,
|
||||
hasSelectedSkin = item.skins?.any { skin -> skin.unicode in selectedEmojis } == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojiSkin
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toSp
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
val SkinToneSlotSize = 48.dp
|
||||
val SkinToneSlotSpacing = 2.dp
|
||||
val SkinTonePadding = 4.dp
|
||||
|
||||
@Composable
|
||||
fun SkinTonePicker(
|
||||
emoji: Emoji,
|
||||
hoveredIndex: Int,
|
||||
selectedUnicodes: ImmutableSet<String>,
|
||||
onSelect: (Emoji) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val skins = emoji.skins.orEmpty()
|
||||
val emojiSize = 32.dp.toSp()
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = ElementTheme.colors.bgCanvasDefault,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(SkinTonePadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(SkinToneSlotSpacing),
|
||||
) {
|
||||
SkinToneSlot(
|
||||
unicode = emoji.unicode,
|
||||
emojiSize = emojiSize,
|
||||
isHovered = hoveredIndex == 0,
|
||||
isSelected = emoji.unicode in selectedUnicodes,
|
||||
onClick = { onSelect(emoji) },
|
||||
)
|
||||
skins.forEachIndexed { index, skin ->
|
||||
SkinToneSlot(
|
||||
unicode = skin.unicode,
|
||||
emojiSize = emojiSize,
|
||||
isHovered = hoveredIndex == index + 1,
|
||||
isSelected = skin.unicode in selectedUnicodes,
|
||||
onClick = { onSelect(emoji.copy(unicode = skin.unicode)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkinToneSlot(
|
||||
unicode: String,
|
||||
emojiSize: TextUnit,
|
||||
isHovered: Boolean,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val background = when {
|
||||
isSelected && isHovered -> ElementTheme.colors.bgActionPrimaryPressed
|
||||
isSelected -> ElementTheme.colors.bgActionPrimaryRest
|
||||
isHovered -> ElementTheme.colors.bgActionPrimaryHovered
|
||||
else -> Color.Transparent
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(SkinToneSlotSize)
|
||||
.clip(CircleShape)
|
||||
.background(background, CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = unicode,
|
||||
style = LocalTextStyle.current.copy(fontSize = emojiSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SkinTonePickerPreview() = ElementPreview {
|
||||
SkinTonePicker(
|
||||
emoji = Emoji(
|
||||
hexcode = "1F44D",
|
||||
label = "thumbs up",
|
||||
tags = null,
|
||||
shortcodes = persistentListOf("+1", "thumbsup"),
|
||||
unicode = "👍",
|
||||
skins = aSkinList(),
|
||||
),
|
||||
hoveredIndex = -1,
|
||||
selectedUnicodes = persistentSetOf(),
|
||||
onSelect = {},
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aSkinList() = persistentListOf(
|
||||
EmojiSkin("1F44D-1F3FB", "thumbs up: light skin tone", "👍🏻"),
|
||||
EmojiSkin("1F44D-1F3FC", "thumbs up: medium-light skin tone", "👍🏼"),
|
||||
EmojiSkin("1F44D-1F3FD", "thumbs up: medium skin tone", "👍🏽"),
|
||||
EmojiSkin("1F44D-1F3FE", "thumbs up: medium-dark skin tone", "👍🏾"),
|
||||
EmojiSkin("1F44D-1F3FF", "thumbs up: dark skin tone", "👍🏿"),
|
||||
)
|
||||
+74
@@ -34,6 +34,7 @@ import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
@@ -758,6 +759,79 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Pick media from gallery while editing keeps the edit pending`() = runTest {
|
||||
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment>, _: EventId? -> }
|
||||
val navigator = FakeMessagesNavigator(
|
||||
onPreviewAttachmentLambda = onPreviewAttachmentLambda
|
||||
)
|
||||
val presenter = createPresenter(navigator = navigator)
|
||||
pickerProvider.givenMimeType(MimeTypes.Images)
|
||||
presenter.test {
|
||||
var state = awaitFirstItem()
|
||||
val editMode = anEditMode()
|
||||
state.eventSink(MessageComposerEvent.SetMode(editMode))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(editMode)
|
||||
// The media is sent as a new message while the edit stays active. If the mode reset,
|
||||
// a Normal-mode state would be emitted and expectNoEvents() below would fail.
|
||||
state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery)
|
||||
onPreviewAttachmentLambda.assertions().isCalledOnce()
|
||||
expectNoEvents()
|
||||
assertThat(state.mode).isEqualTo(editMode)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Pick media from gallery while replying clears the reply mode`() = runTest {
|
||||
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment>, _: EventId? -> }
|
||||
val navigator = FakeMessagesNavigator(
|
||||
onPreviewAttachmentLambda = onPreviewAttachmentLambda
|
||||
)
|
||||
val presenter = createPresenter(navigator = navigator)
|
||||
pickerProvider.givenMimeType(MimeTypes.Images)
|
||||
presenter.test {
|
||||
var state = awaitFirstItem()
|
||||
state.eventSink(MessageComposerEvent.SetMode(aReplyMode()))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isInstanceOf(MessageComposerMode.Reply::class.java)
|
||||
// The media becomes the reply, so the reply intent is consumed and the composer resets.
|
||||
state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery)
|
||||
onPreviewAttachmentLambda.assertions().isCalledOnce()
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(MessageComposerMode.Normal)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Pick multiple media from gallery while editing keeps the edit pending`() = runTest {
|
||||
val onPreviewAttachmentLambda = lambdaRecorder { _: ImmutableList<Attachment>, _: EventId? -> }
|
||||
val navigator = FakeMessagesNavigator(
|
||||
onPreviewAttachmentLambda = onPreviewAttachmentLambda
|
||||
)
|
||||
val presenter = createPresenter(
|
||||
navigator = navigator,
|
||||
featureFlagService = FakeFeatureFlagService(
|
||||
initialState = mapOf(FeatureFlags.SendGalleryMessages.key to true)
|
||||
),
|
||||
)
|
||||
pickerProvider.givenMimeType(MimeTypes.Images)
|
||||
// Two Uris take the multi-item branch (handlePickedMediaList) instead of delegating to the
|
||||
// single-item path, so the edit is preserved for gallery messages too.
|
||||
pickerProvider.givenMultipleResults(listOf(mockk(), mockk()))
|
||||
presenter.test {
|
||||
var state = awaitFirstItem()
|
||||
val editMode = anEditMode()
|
||||
state.eventSink(MessageComposerEvent.SetMode(editMode))
|
||||
state = awaitItem()
|
||||
assertThat(state.mode).isEqualTo(editMode)
|
||||
state.eventSink(MessageComposerEvent.PickAttachmentSource.FromGallery)
|
||||
onPreviewAttachmentLambda.assertions().isCalledOnce()
|
||||
expectNoEvents()
|
||||
assertThat(state.mode).isEqualTo(editMode)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Pick media from gallery & cancel does nothing`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
|
||||
+5
-1
@@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.enterprise.api.SessionEnterpriseService
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
||||
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
|
||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
@@ -57,6 +58,7 @@ class PreferencesRootPresenter(
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val sessionStore: SessionStore,
|
||||
private val sessionEnterpriseService: SessionEnterpriseService,
|
||||
private val userStatusPresenter: Presenter<UserStatusState>,
|
||||
) : Presenter<PreferencesRootState> {
|
||||
@Composable
|
||||
override fun present(): PreferencesRootState {
|
||||
@@ -66,13 +68,14 @@ class PreferencesRootPresenter(
|
||||
// Force a refresh of the profile
|
||||
matrixClient.getUserProfile()
|
||||
}
|
||||
|
||||
val isMultiAccountEnabled by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
|
||||
}.collectAsState(initial = false)
|
||||
val showLinkNewDevice by remember {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
|
||||
}.collectAsState(initial = false)
|
||||
val isUserStatusEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.UserStatus).collectAsState(initial = false)
|
||||
val userStatusState = if (isUserStatusEnabled) userStatusPresenter.present() else null
|
||||
|
||||
val otherSessions by remember {
|
||||
sessionStore.sessionsFlow().map { list ->
|
||||
@@ -137,6 +140,7 @@ class PreferencesRootPresenter(
|
||||
|
||||
return PreferencesRootState(
|
||||
myUser = matrixUser.value,
|
||||
userStatusState = userStatusState,
|
||||
version = remember { versionFormatter.get() },
|
||||
deviceId = matrixClient.deviceId,
|
||||
isMultiAccountEnabled = isMultiAccountEnabled,
|
||||
|
||||
+2
@@ -9,6 +9,7 @@
|
||||
package io.element.android.features.preferences.impl.root
|
||||
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
@@ -16,6 +17,7 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class PreferencesRootState(
|
||||
val myUser: MatrixUser,
|
||||
val userStatusState: UserStatusState?,
|
||||
val version: String,
|
||||
val deviceId: DeviceId?,
|
||||
val isMultiAccountEnabled: Boolean,
|
||||
|
||||
+7
-1
@@ -11,6 +11,9 @@ package io.element.android.features.preferences.impl.root
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusPickerState
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
||||
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
@@ -64,8 +67,9 @@ open class PreferencesRootStateProvider : PreviewParameterProvider<PreferencesRo
|
||||
showDeveloperSettings = true,
|
||||
canDeactivateAccount = true,
|
||||
),
|
||||
aPreferencesRootState(userStatusState = aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker)),
|
||||
// Minimal state
|
||||
aPreferencesRootState(),
|
||||
aPreferencesRootState(userStatusState = null),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,6 +90,7 @@ fun aPreferencesRootState(
|
||||
nbOfBlockedUsers: Int = 0,
|
||||
showLabsItem: Boolean = false,
|
||||
directLogoutState: DirectLogoutState = aDirectLogoutState(),
|
||||
userStatusState: UserStatusState? = aUserStatusState(),
|
||||
snackbarMessage: SnackbarMessage? = null,
|
||||
eventSink: (PreferencesRootEvent) -> Unit = {},
|
||||
) = PreferencesRootState(
|
||||
@@ -105,6 +110,7 @@ fun aPreferencesRootState(
|
||||
nbOfBlockedUsers = nbOfBlockedUsers,
|
||||
showLabsItem = showLabsItem,
|
||||
directLogoutState = directLogoutState,
|
||||
userStatusState = userStatusState,
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
+27
-3
@@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.root
|
||||
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -23,6 +24,8 @@ import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.features.preferences.impl.user.UserPreferences
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusView
|
||||
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
@@ -85,10 +88,10 @@ fun PreferencesRootView(
|
||||
state = state,
|
||||
onAddAccountClick = onAddAccountClick,
|
||||
)
|
||||
} else {
|
||||
HorizontalDivider()
|
||||
}
|
||||
// User status will be added here
|
||||
if (state.userStatusState != null) {
|
||||
UserStatusSection(state.userStatusState, showTopDivider = !state.isMultiAccountEnabled)
|
||||
}
|
||||
// 'Account' section
|
||||
ManageAccountSection(
|
||||
state = state,
|
||||
@@ -129,6 +132,27 @@ fun PreferencesRootView(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.UserStatusSection(
|
||||
userStatusState: UserStatusState,
|
||||
showTopDivider: Boolean,
|
||||
) {
|
||||
if (showTopDivider) {
|
||||
HorizontalDivider(
|
||||
thickness = 8.dp,
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
)
|
||||
}
|
||||
UserStatusView(
|
||||
state = userStatusState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
HorizontalDivider(
|
||||
thickness = 1.dp,
|
||||
color = ElementTheme.colors.bgSubtleSecondary,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ColumnScope.MultiAccountSection(
|
||||
state: PreferencesRootState,
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.userstatus
|
||||
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.Binds
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesTo(SessionScope::class)
|
||||
@BindingContainer
|
||||
interface UserStatusBindingContainer {
|
||||
@Binds
|
||||
fun bindUserStatusPresenter(presenter: UserStatusPresenter): Presenter<UserStatusState>
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.userstatus
|
||||
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
|
||||
sealed interface UserStatusEvent {
|
||||
data object OpenPicker : UserStatusEvent
|
||||
data object DismissPicker : UserStatusEvent
|
||||
data class SetStatus(val status: UserStatus) : UserStatusEvent
|
||||
data object OpenCustomInput : UserStatusEvent
|
||||
data object CancelCustomInput : UserStatusEvent
|
||||
data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent
|
||||
data object ClearStatus : UserStatusEvent
|
||||
}
|
||||
+75
@@ -0,0 +1,75 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.userstatus
|
||||
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class UserStatusPresenter(
|
||||
private val matrixClient: MatrixClient,
|
||||
) : Presenter<UserStatusState> {
|
||||
@Composable
|
||||
override fun present(): UserStatusState {
|
||||
val userProfile by matrixClient.userProfile.collectAsState()
|
||||
var pickerState by remember { mutableStateOf<UserStatusPickerState>(UserStatusPickerState.Hidden) }
|
||||
val customTextFieldState = rememberTextFieldState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun handleEvent(event: UserStatusEvent) {
|
||||
when (event) {
|
||||
UserStatusEvent.OpenPicker -> pickerState = UserStatusPickerState.ShowingPicker
|
||||
UserStatusEvent.DismissPicker -> pickerState = UserStatusPickerState.Hidden
|
||||
UserStatusEvent.OpenCustomInput -> {
|
||||
val raw = userProfile.rawStatus
|
||||
if (raw != null) {
|
||||
customTextFieldState.setTextAndPlaceCursorAtEnd(raw.text)
|
||||
} else {
|
||||
customTextFieldState.clearText()
|
||||
}
|
||||
pickerState = UserStatusPickerState.CustomInput(
|
||||
emoji = raw?.emoji ?: "😀",
|
||||
textFieldState = customTextFieldState,
|
||||
)
|
||||
}
|
||||
is UserStatusEvent.SetStatus -> {
|
||||
pickerState = UserStatusPickerState.Hidden
|
||||
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
|
||||
}
|
||||
UserStatusEvent.ClearStatus -> {
|
||||
pickerState = UserStatusPickerState.Hidden
|
||||
coroutineScope.launch { matrixClient.clearUserStatus() }
|
||||
}
|
||||
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
|
||||
is UserStatusEvent.UpdateCustomEmoji -> {
|
||||
val current = pickerState as? UserStatusPickerState.CustomInput ?: return
|
||||
pickerState = current.copy(emoji = event.emoji)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return UserStatusState(
|
||||
displayedStatus = userProfile.displayedStatus,
|
||||
rawStatus = userProfile.rawStatus,
|
||||
pickerState = pickerState,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.userstatus
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
|
||||
data class UserStatusState(
|
||||
val displayedStatus: DisplayedStatus?,
|
||||
val rawStatus: UserStatus?,
|
||||
val pickerState: UserStatusPickerState,
|
||||
val eventSink: (UserStatusEvent) -> Unit,
|
||||
)
|
||||
|
||||
@Immutable
|
||||
sealed interface UserStatusPickerState {
|
||||
data object Hidden : UserStatusPickerState
|
||||
data object ShowingPicker : UserStatusPickerState
|
||||
data class CustomInput(
|
||||
val emoji: String,
|
||||
val textFieldState: TextFieldState,
|
||||
) : UserStatusPickerState
|
||||
}
|
||||
|
||||
enum class PredefinedUserStatus(val emoji: String, @StringRes val labelRes: Int) {
|
||||
IN_A_MEETING("💬", R.string.common_user_status_in_a_meeting),
|
||||
FOCUS_TIME("💡", R.string.common_user_status_focus_time),
|
||||
ON_THE_ROAD("🚙", R.string.common_user_status_on_the_road),
|
||||
BE_RIGHT_BACK("☕", R.string.common_user_status_be_right_back),
|
||||
AWAY("🌴", R.string.common_user_status_away),
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.userstatus
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
|
||||
internal class UserStatusStateProvider : PreviewParameterProvider<UserStatusState> {
|
||||
override val values get() = sequenceOf(
|
||||
aUserStatusState(),
|
||||
aUserStatusState(displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away"))),
|
||||
aUserStatusState(displayedStatus = DisplayedStatus.InCall(callJoinedTs = 0L)),
|
||||
aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker),
|
||||
aUserStatusState(
|
||||
displayedStatus = DisplayedStatus.UserSet(UserStatus("🌴", "Away")),
|
||||
rawStatus = UserStatus("🌴", "Away"),
|
||||
pickerState = UserStatusPickerState.ShowingPicker,
|
||||
),
|
||||
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "😀", textFieldState = TextFieldState())),
|
||||
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "🚀", textFieldState = TextFieldState("Working on something"))),
|
||||
)
|
||||
}
|
||||
|
||||
fun aUserStatusState(
|
||||
displayedStatus: DisplayedStatus? = null,
|
||||
rawStatus: UserStatus? = null,
|
||||
pickerState: UserStatusPickerState = UserStatusPickerState.Hidden,
|
||||
eventSink: (UserStatusEvent) -> Unit = {},
|
||||
) = UserStatusState(
|
||||
displayedStatus = displayedStatus,
|
||||
rawStatus = rawStatus,
|
||||
pickerState = pickerState,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
+315
@@ -0,0 +1,315 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.userstatus
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.text.input.InputTransformation
|
||||
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.foundation.text.input.clearText
|
||||
import androidx.compose.foundation.text.input.maxLength
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.layout.Layout
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.ListItem
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||
import io.element.android.libraries.designsystem.theme.components.hide
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun UserStatusView(
|
||||
state: UserStatusState,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (val pickerState = state.pickerState) {
|
||||
UserStatusPickerState.Hidden, UserStatusPickerState.ShowingPicker -> {
|
||||
val displayedStatus = state.displayedStatus
|
||||
if (displayedStatus != null) {
|
||||
CurrentStatusRow(
|
||||
displayedStatus = displayedStatus,
|
||||
onClick = { state.eventSink(UserStatusEvent.OpenPicker) },
|
||||
onClear = { state.eventSink(UserStatusEvent.ClearStatus) },
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
EmptyStatusRow(
|
||||
onClick = { state.eventSink(UserStatusEvent.OpenPicker) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
if (pickerState == UserStatusPickerState.ShowingPicker) {
|
||||
UserStatusPickerBottomSheet(
|
||||
currentRawStatus = state.rawStatus,
|
||||
onDismiss = { state.eventSink(UserStatusEvent.DismissPicker) },
|
||||
onSelectPredefinedStatus = { status -> state.eventSink(UserStatusEvent.SetStatus(status)) },
|
||||
onSelectCustomStatus = { state.eventSink(UserStatusEvent.OpenCustomInput) },
|
||||
)
|
||||
}
|
||||
}
|
||||
is UserStatusPickerState.CustomInput -> {
|
||||
CustomStatusInputRow(
|
||||
emoji = pickerState.emoji,
|
||||
textFieldState = pickerState.textFieldState,
|
||||
rawStatus = state.rawStatus,
|
||||
onConfirm = {
|
||||
state.eventSink(
|
||||
UserStatusEvent.SetStatus(UserStatus(pickerState.emoji, pickerState.textFieldState.text.toString()))
|
||||
)
|
||||
},
|
||||
onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) },
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmptyStatusRow(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(
|
||||
text = stringResource(R.string.screen_preferences_user_status_placeholder),
|
||||
modifier = Modifier.padding(vertical = 16.dp),
|
||||
)
|
||||
},
|
||||
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Reaction())),
|
||||
modifier = modifier.clickable(onClick = onClick),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CurrentStatusRow(
|
||||
displayedStatus: DisplayedStatus,
|
||||
onClick: () -> Unit,
|
||||
onClear: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val (emoji, text) = when (displayedStatus) {
|
||||
is DisplayedStatus.UserSet -> displayedStatus.status.emoji to displayedStatus.status.text
|
||||
is DisplayedStatus.InCall -> "🎧" to stringResource(R.string.common_user_status_on_a_call)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = { Text(text = text, modifier = Modifier.padding(vertical = 16.dp)) },
|
||||
leadingContent = ListItemContent.Custom { EmojiText(emoji) },
|
||||
trailingContent = ListItemContent.Custom({
|
||||
IconButton(
|
||||
onClick = onClear,
|
||||
// Remove the extra padding caused by IconButton
|
||||
modifier = Modifier.offset(x = 12.dp)
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_clear),
|
||||
)
|
||||
}
|
||||
}),
|
||||
onClick = onClick,
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiText(emoji: String) {
|
||||
// Ensure the emoji doesn't scale with fontSize to match icons size
|
||||
val fontSize = with(LocalDensity.current) { 16.dp.toSp() }
|
||||
Text(
|
||||
modifier = Modifier.sizeIn(minWidth = 24.dp, minHeight = 24.dp),
|
||||
text = emoji,
|
||||
textAlign = TextAlign.Center,
|
||||
style = ElementTheme.typography.fontBodyLgRegular.copy(fontSize = fontSize),
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun UserStatusPickerBottomSheet(
|
||||
currentRawStatus: UserStatus?,
|
||||
onDismiss: () -> Unit,
|
||||
onSelectPredefinedStatus: (UserStatus) -> Unit,
|
||||
onSelectCustomStatus: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val sheetState = rememberBottomSheetState(
|
||||
initialValue = SheetValue.Hidden,
|
||||
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded)
|
||||
)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
scrollable = true,
|
||||
modifier = modifier,
|
||||
) {
|
||||
PredefinedUserStatus.entries.forEach { predefined ->
|
||||
val label = stringResource(predefined.labelRes)
|
||||
val predefinedUserStatus = UserStatus(emoji = predefined.emoji, text = label)
|
||||
val isSelected = currentRawStatus == predefinedUserStatus
|
||||
ListItem(
|
||||
headlineContent = { Text(text = label) },
|
||||
leadingContent = ListItemContent.Custom { EmojiText(predefined.emoji) },
|
||||
trailingContent = if (isSelected) {
|
||||
ListItemContent.Icon(IconSource.Vector(CompoundIcons.Check()), tintColor = ElementTheme.colors.iconAccentPrimary)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
sheetState.hide(coroutineScope) {
|
||||
onSelectPredefinedStatus(predefinedUserStatus)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
Text(text = stringResource(R.string.common_user_status_custom))
|
||||
},
|
||||
leadingContent = ListItemContent.Custom { EmojiText("✏️") },
|
||||
onClick = {
|
||||
sheetState.hide(coroutineScope) {
|
||||
onSelectCustomStatus()
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val CUSTOM_STATUS_TEXT_MAX_LENGTH = 30
|
||||
|
||||
@Composable
|
||||
private fun CustomStatusInputRow(
|
||||
emoji: String,
|
||||
textFieldState: TextFieldState,
|
||||
rawStatus: UserStatus?,
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } }
|
||||
val hasChanges by remember(emoji, rawStatus) {
|
||||
derivedStateOf {
|
||||
val text = textFieldState.text.toString()
|
||||
text.isNotBlank() && UserStatus(emoji, text) != rawStatus
|
||||
}
|
||||
}
|
||||
val saveLabel = stringResource(CommonStrings.action_save)
|
||||
val cancelLabel = stringResource(CommonStrings.action_cancel)
|
||||
val focusRequester = remember { FocusRequester() }
|
||||
LaunchedEffect(Unit) { focusRequester.requestFocus() }
|
||||
ListItem(
|
||||
headlineContent = {
|
||||
TextField(
|
||||
modifier = Modifier.focusRequester(focusRequester),
|
||||
state = textFieldState,
|
||||
placeholder = stringResource(R.string.screen_preferences_user_status_custom_hint),
|
||||
inputTransformation = InputTransformation.maxLength(CUSTOM_STATUS_TEXT_MAX_LENGTH),
|
||||
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
|
||||
onKeyboardAction = { if (hasChanges) onConfirm() },
|
||||
lineLimits = TextFieldLineLimits.SingleLine,
|
||||
trailingIcon = if (hasText) {
|
||||
{
|
||||
Box(modifier = Modifier.clickable { textFieldState.clearText() }) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = stringResource(CommonStrings.action_cancel),
|
||||
modifier = Modifier.size(22.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
},
|
||||
trailingContent = ListItemContent.Custom({
|
||||
// Layout measures both buttons to determine the max natural width,
|
||||
// then places only the active one.
|
||||
Layout(
|
||||
content = {
|
||||
TextButton(onClick = onConfirm, text = saveLabel)
|
||||
TextButton(onClick = onCancel, text = cancelLabel)
|
||||
}
|
||||
) { measurables, constraints ->
|
||||
val placeables = measurables.map { it.measure(constraints) }
|
||||
val maxWidth = placeables.maxOf { it.width }
|
||||
val maxHeight = placeables.maxOf { it.height }
|
||||
layout(maxWidth, maxHeight) {
|
||||
val active = if (hasChanges) placeables[0] else placeables[1]
|
||||
active.placeRelative(
|
||||
x = (maxWidth - active.width) / 2,
|
||||
y = (maxHeight - active.height) / 2,
|
||||
)
|
||||
}
|
||||
}
|
||||
}),
|
||||
leadingContent = ListItemContent.Custom({
|
||||
Surface(
|
||||
shape = CircleShape,
|
||||
border = BorderStroke(1.dp, ElementTheme.colors.bgSubtleSecondary),
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable { },
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
EmojiText(emoji)
|
||||
}
|
||||
}
|
||||
}),
|
||||
modifier = modifier,
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun UserStatusViewPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview {
|
||||
UserStatusView(state = state)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
~ Copyright (c) 2026 Element Creations Ltd.
|
||||
~
|
||||
~ SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
~ Please see LICENSE files in the repository root for full details.
|
||||
-->
|
||||
<resources>
|
||||
<string name="common_user_status_in_a_meeting">In a meeting</string>
|
||||
<string name="common_user_status_focus_time">Focus time</string>
|
||||
<string name="common_user_status_on_the_road">On the road</string>
|
||||
<string name="common_user_status_be_right_back">Be right back</string>
|
||||
<string name="common_user_status_away">Away</string>
|
||||
<string name="common_user_status_on_a_call">On a call</string>
|
||||
<string name="common_user_status_custom">Custom…</string>
|
||||
<string name="screen_preferences_user_status_placeholder">What\'s your status?</string>
|
||||
<string name="screen_preferences_user_status_custom_hint">Custom status…</string>
|
||||
</resources>
|
||||
+2
@@ -15,6 +15,7 @@ import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.api.SessionEnterpriseService
|
||||
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
|
||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
|
||||
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
|
||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||
import io.element.android.libraries.core.meta.BuildType
|
||||
@@ -346,5 +347,6 @@ class PreferencesRootPresenterTest {
|
||||
featureFlagService = featureFlagService,
|
||||
sessionStore = sessionStore,
|
||||
sessionEnterpriseService = sessionEnterpriseService,
|
||||
userStatusPresenter = { aUserStatusState() },
|
||||
)
|
||||
}
|
||||
|
||||
+14
-6
@@ -12,8 +12,11 @@ package io.element.android.features.preferences.impl.root
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.hasClickAction
|
||||
import androidx.compose.ui.test.hasText
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
@@ -199,7 +202,8 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
),
|
||||
onOpenAnalytics = callback,
|
||||
)
|
||||
clickOn(CommonStrings.common_analytics)
|
||||
val text = activity!!.getString(CommonStrings.common_analytics)
|
||||
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -226,7 +230,8 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
),
|
||||
onOpenRageShake = callback,
|
||||
)
|
||||
clickOn(CommonStrings.common_report_a_problem)
|
||||
val text = activity!!.getString(CommonStrings.common_report_a_problem)
|
||||
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,7 +286,8 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
),
|
||||
onOpenDeveloperSettings = callback,
|
||||
)
|
||||
clickOn(CommonStrings.common_developer_options)
|
||||
val text = activity!!.getString(CommonStrings.common_developer_options)
|
||||
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,7 +395,8 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
),
|
||||
onSignOutClick = callback,
|
||||
)
|
||||
clickOn(CommonStrings.action_signout)
|
||||
val text = activity!!.getString(CommonStrings.action_signout)
|
||||
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +411,8 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
),
|
||||
onDeactivateClick = callback,
|
||||
)
|
||||
clickOn(CommonStrings.action_delete_account)
|
||||
val text = activity!!.getString(CommonStrings.action_delete_account)
|
||||
onNode(hasText(text) and hasClickAction()).performScrollTo().performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -430,7 +438,7 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
onNodeWithText(version).performClick()
|
||||
onNodeWithText(version).performScrollTo().performClick()
|
||||
eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick)
|
||||
}
|
||||
}
|
||||
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.preferences.impl.userstatus
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class UserStatusPresenterTest {
|
||||
private fun createPresenter(client: FakeMatrixClient = FakeMatrixClient()) =
|
||||
UserStatusPresenter(matrixClient = client)
|
||||
|
||||
@Test
|
||||
fun `initial state - no status set`() = runTest {
|
||||
createPresenter().test {
|
||||
val state = awaitItem()
|
||||
assertThat(state.displayedStatus).isNull()
|
||||
assertThat(state.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Open event transitions to ShowingPicker`() = runTest {
|
||||
createPresenter().test {
|
||||
val state = awaitItem()
|
||||
state.eventSink(UserStatusEvent.OpenPicker)
|
||||
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.ShowingPicker)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Dismiss event from ShowingPicker goes back to Hidden`() = runTest {
|
||||
createPresenter().test {
|
||||
val state = awaitItem()
|
||||
state.eventSink(UserStatusEvent.OpenPicker)
|
||||
awaitItem() // ShowingPicker
|
||||
state.eventSink(UserStatusEvent.DismissPicker)
|
||||
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Set event calls setUserStatus and transitions to Hidden with updated status`() = runTest {
|
||||
val client = FakeMatrixClient()
|
||||
createPresenter(client).test {
|
||||
val status = UserStatus(emoji = "💬", text = "In a meeting")
|
||||
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
|
||||
val newState = awaitItem()
|
||||
assertThat(newState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
assertThat(newState.displayedStatus).isEqualTo(DisplayedStatus.UserSet(status))
|
||||
assertThat(client.setUserStatusCalled).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OpenCustomInput with no existing status uses defaults`() = runTest {
|
||||
createPresenter().test {
|
||||
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
|
||||
val state = awaitItem()
|
||||
val pickerState = state.pickerState as UserStatusPickerState.CustomInput
|
||||
assertThat(pickerState.emoji).isEqualTo("😀")
|
||||
assertThat(pickerState.textFieldState.text.toString()).isEqualTo("")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `OpenCustomInput with existing rawStatus pre-fills values`() = runTest {
|
||||
val client = FakeMatrixClient()
|
||||
createPresenter(client).test {
|
||||
val status = UserStatus(emoji = "🌴", text = "Away")
|
||||
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
|
||||
// Single emission: pickerState=Hidden and displayedStatus both set in one recomposition
|
||||
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
|
||||
val state = awaitItem()
|
||||
val pickerState = state.pickerState as UserStatusPickerState.CustomInput
|
||||
assertThat(pickerState.emoji).isEqualTo("🌴")
|
||||
assertThat(pickerState.textFieldState.text.toString()).isEqualTo("Away")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `UpdateCustomEmoji updates emoji in CustomInput state`() = runTest {
|
||||
createPresenter().test {
|
||||
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
|
||||
awaitItem().eventSink(UserStatusEvent.UpdateCustomEmoji("🚀"))
|
||||
val state = awaitItem()
|
||||
assertThat((state.pickerState as UserStatusPickerState.CustomInput).emoji).isEqualTo("🚀")
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `CancelCustomInput goes back to Hidden`() = runTest {
|
||||
createPresenter().test {
|
||||
awaitItem().eventSink(UserStatusEvent.OpenCustomInput)
|
||||
awaitItem().eventSink(UserStatusEvent.CancelCustomInput) // CustomInput state, fire Cancel
|
||||
assertThat(awaitItem().pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Clear event calls clearUserStatus and clears displayed status`() = runTest {
|
||||
val client = FakeMatrixClient()
|
||||
createPresenter(client).test {
|
||||
val status = UserStatus(emoji = "☕", text = "Be right back")
|
||||
awaitItem().eventSink(UserStatusEvent.SetStatus(status))
|
||||
// Single emission: pickerState=Hidden and displayedStatus both set in one recomposition
|
||||
awaitItem().eventSink(UserStatusEvent.ClearStatus)
|
||||
val clearedState = awaitItem()
|
||||
assertThat(clearedState.pickerState).isEqualTo(UserStatusPickerState.Hidden)
|
||||
assertThat(clearedState.displayedStatus).isNull()
|
||||
assertThat(client.clearUserStatusCalled).isTrue()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user