Wire the emoji picker into the user-status feature

This commit is contained in:
ganfra
2026-07-22 15:35:21 +02:00
parent 89deff8a53
commit 4dadb3c8c8
12 changed files with 189 additions and 20 deletions
+34
View File
@@ -1,5 +1,39 @@
<component name="ProjectCodeStyleConfiguration">
<code_scheme name="Project" version="173">
<JavaCodeStyleSettings>
<option name="IMPORT_LAYOUT_TABLE">
<value>
<package name="android" withSubpackages="true" static="true" />
<package name="androidx" withSubpackages="true" static="true" />
<package name="com" withSubpackages="true" static="true" />
<package name="junit" withSubpackages="true" static="true" />
<package name="net" withSubpackages="true" static="true" />
<package name="org" withSubpackages="true" static="true" />
<package name="java" withSubpackages="true" static="true" />
<package name="javax" withSubpackages="true" static="true" />
<package name="" withSubpackages="true" static="true" />
<emptyLine />
<package name="android" withSubpackages="true" static="false" />
<emptyLine />
<package name="androidx" withSubpackages="true" static="false" />
<emptyLine />
<package name="com" withSubpackages="true" static="false" />
<emptyLine />
<package name="junit" withSubpackages="true" static="false" />
<emptyLine />
<package name="net" withSubpackages="true" static="false" />
<emptyLine />
<package name="org" withSubpackages="true" static="false" />
<emptyLine />
<package name="java" withSubpackages="true" static="false" />
<emptyLine />
<package name="javax" withSubpackages="true" static="false" />
<emptyLine />
<package name="" withSubpackages="true" static="false" />
<emptyLine />
</value>
</option>
</JavaCodeStyleSettings>
<JetCodeStyleSettings>
<option name="LINE_BREAK_AFTER_MULTILINE_WHEN_ENTRY" value="false" />
<option name="CODE_STYLE_DEFAULTS" value="KOTLIN_OFFICIAL" />
@@ -81,6 +81,7 @@ dependencies {
implementation(projects.features.deactivation.api)
implementation(projects.features.home.api)
implementation(projects.features.invite.api)
implementation(projects.libraries.emoji.api)
implementation(projects.services.analytics.api)
implementation(projects.services.analytics.compose)
implementation(projects.services.appnavstate.api)
@@ -98,6 +99,7 @@ dependencies {
testCommonDependencies(libs, true)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.emoji.test)
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.mediapickers.test)
testImplementation(projects.libraries.mediaupload.test)
@@ -24,6 +24,7 @@ import io.element.android.features.logout.api.direct.DirectLogoutView
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.user.MatrixUser
@ContributesNode(SessionScope::class)
@@ -33,6 +34,7 @@ class PreferencesRootNode(
@Assisted plugins: List<Plugin>,
private val presenter: PreferencesRootPresenter,
private val directLogoutView: DirectLogoutView,
private val emojiPickerRenderer: EmojiPickerRenderer,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun navigateToAddAccount()
@@ -75,6 +77,7 @@ class PreferencesRootNode(
val isDark = ElementTheme.isLightTheme.not()
PreferencesRootView(
state = state,
emojiPickerRenderer = emojiPickerRenderer,
modifier = modifier,
onBackClick = this::navigateUp,
onAddAccountClick = callback::navigateToAddAccount,
@@ -41,6 +41,8 @@ import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.DeviceId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
@@ -49,6 +51,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun PreferencesRootView(
state: PreferencesRootState,
emojiPickerRenderer: EmojiPickerRenderer,
onBackClick: () -> Unit,
onAddAccountClick: () -> Unit,
onSecureBackupClick: () -> Unit,
@@ -90,7 +93,11 @@ fun PreferencesRootView(
)
}
if (state.userStatusState != null) {
UserStatusSection(state.userStatusState, showTopDivider = !state.isMultiAccountEnabled)
UserStatusSection(
userStatusState = state.userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
showTopDivider = !state.isMultiAccountEnabled,
)
}
// 'Account' section
ManageAccountSection(
@@ -135,6 +142,7 @@ fun PreferencesRootView(
@Composable
private fun ColumnScope.UserStatusSection(
userStatusState: UserStatusState,
emojiPickerRenderer: EmojiPickerRenderer,
showTopDivider: Boolean,
) {
if (showTopDivider) {
@@ -145,6 +153,7 @@ private fun ColumnScope.UserStatusSection(
}
UserStatusView(
state = userStatusState,
emojiPickerRenderer = emojiPickerRenderer,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider(
@@ -369,6 +378,7 @@ internal fun PreferencesRootViewDarkPreview(@PreviewParameter(PreferencesRootSta
private fun ContentToPreview(state: PreferencesRootState) {
PreferencesRootView(
state = state,
emojiPickerRenderer = NoOpEmojiPickerRenderer,
onBackClick = {},
onAddAccountClick = {},
onOpenAnalytics = {},
@@ -16,5 +16,7 @@ sealed interface UserStatusEvent {
data object OpenCustomInput : UserStatusEvent
data object CancelCustomInput : UserStatusEvent
data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent
data object OpenEmojiPicker : UserStatusEvent
data object DismissEmojiPicker : UserStatusEvent
data object ClearStatus : UserStatusEvent
}
@@ -19,36 +19,32 @@ 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.emoji.api.picker.EmojiPickerPresenter
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.launch
@Inject
class UserStatusPresenter(
private val matrixClient: MatrixClient,
emojiPickerPresenterFactory: EmojiPickerPresenter.Factory,
) : Presenter<UserStatusState> {
private val emojiPickerPresenter = emojiPickerPresenterFactory.create(EmptyGetRecentEmojis)
@Composable
override fun present(): UserStatusState {
val userProfile by matrixClient.userProfile.collectAsState()
var pickerState by remember { mutableStateOf<UserStatusPickerState>(UserStatusPickerState.Hidden) }
var isEmojiPickerVisible by remember { mutableStateOf(false) }
val customTextFieldState = rememberTextFieldState()
val coroutineScope = rememberCoroutineScope()
fun handleEvent(event: UserStatusEvent) {
when (event) {
// Mode transitions
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,
)
}
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
is UserStatusEvent.SetStatus -> {
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
@@ -57,19 +53,56 @@ class UserStatusPresenter(
pickerState = UserStatusPickerState.Hidden
coroutineScope.launch { matrixClient.clearUserStatus() }
}
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
UserStatusEvent.OpenCustomInput -> {
val raw = userProfile.rawStatus
if (raw != null) {
customTextFieldState.setTextAndPlaceCursorAtEnd(raw.text)
} else {
customTextFieldState.clearText()
}
isEmojiPickerVisible = false
pickerState = UserStatusPickerState.CustomInput(
emoji = raw?.emoji ?: "😀",
textFieldState = customTextFieldState,
emojiPickerSheetState = EmojiPickerSheetState.Hidden,
)
}
// Custom-input mutations
is UserStatusEvent.UpdateCustomEmoji -> {
isEmojiPickerVisible = false
val current = pickerState as? UserStatusPickerState.CustomInput ?: return
pickerState = current.copy(emoji = event.emoji)
}
// Emoji-picker visibility (only meaningful within CustomInput mode)
UserStatusEvent.OpenEmojiPicker -> isEmojiPickerVisible = true
UserStatusEvent.DismissEmojiPicker -> isEmojiPickerVisible = false
}
}
val effectivePickerState = when (val current = pickerState) {
is UserStatusPickerState.CustomInput -> {
val emojiPickerState = presentEmojiPickerSheetState(isEmojiPickerVisible)
current.copy(emojiPickerSheetState = emojiPickerState)
}
else -> current
}
return UserStatusState(
displayedStatus = userProfile.displayedStatus,
rawStatus = userProfile.rawStatus,
pickerState = pickerState,
pickerState = effectivePickerState,
eventSink = ::handleEvent,
)
}
@Composable
private fun presentEmojiPickerSheetState(isVisible: Boolean): EmojiPickerSheetState {
if (!isVisible) return EmojiPickerSheetState.Hidden
val emojiPickerState = emojiPickerPresenter.present()
return if (emojiPickerState.isReady) {
EmojiPickerSheetState.Shown(emojiPickerState)
} else {
EmojiPickerSheetState.Loading
}
}
}
@@ -11,6 +11,7 @@ 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.emoji.api.picker.EmojiPickerState
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.UserStatus
@@ -28,9 +29,17 @@ sealed interface UserStatusPickerState {
data class CustomInput(
val emoji: String,
val textFieldState: TextFieldState,
val emojiPickerSheetState: EmojiPickerSheetState,
) : UserStatusPickerState
}
@Immutable
sealed interface EmojiPickerSheetState {
data object Hidden : EmojiPickerSheetState
data object Loading : EmojiPickerSheetState
data class Shown(val state: EmojiPickerState) : EmojiPickerSheetState
}
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),
@@ -23,8 +23,20 @@ internal class UserStatusStateProvider : PreviewParameterProvider<UserStatusStat
rawStatus = UserStatus("🌴", "Away"),
pickerState = UserStatusPickerState.ShowingPicker,
),
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "😀", textFieldState = TextFieldState())),
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "🚀", textFieldState = TextFieldState("Working on something"))),
aUserStatusState(
pickerState = UserStatusPickerState.CustomInput(
emoji = "😀",
textFieldState = TextFieldState(),
emojiPickerSheetState = EmojiPickerSheetState.Hidden
)
),
aUserStatusState(
pickerState = UserStatusPickerState.CustomInput(
emoji = "🚀",
textFieldState = TextFieldState("Working on something"),
emojiPickerSheetState = EmojiPickerSheetState.Hidden
)
),
)
}
@@ -10,6 +10,7 @@ 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.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
@@ -58,13 +59,17 @@ 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.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
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
import kotlinx.collections.immutable.persistentSetOf
@Composable
fun UserStatusView(
state: UserStatusState,
emojiPickerRenderer: EmojiPickerRenderer,
modifier: Modifier = Modifier,
) {
when (val pickerState = state.pickerState) {
@@ -103,8 +108,18 @@ fun UserStatusView(
)
},
onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) },
onEmojiClick = { state.eventSink(UserStatusEvent.OpenEmojiPicker) },
modifier = modifier,
)
when (val sheet = pickerState.emojiPickerSheetState) {
EmojiPickerSheetState.Hidden, EmojiPickerSheetState.Loading -> Unit
is EmojiPickerSheetState.Shown -> EmojiPickerBottomSheet(
pickerState = sheet.state,
emojiPickerRenderer = emojiPickerRenderer,
onSelectEmoji = { emoji -> state.eventSink(UserStatusEvent.UpdateCustomEmoji(emoji.unicode)) },
onDismiss = { state.eventSink(UserStatusEvent.DismissEmojiPicker) },
)
}
}
}
}
@@ -231,6 +246,7 @@ private fun CustomStatusInputRow(
rawStatus: UserStatus?,
onConfirm: () -> Unit,
onCancel: () -> Unit,
onEmojiClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } }
@@ -297,7 +313,7 @@ private fun CustomStatusInputRow(
modifier = Modifier
.size(50.dp)
.clip(CircleShape)
.clickable { },
.clickable(onClick = onEmojiClick),
) {
Box(contentAlignment = Alignment.Center) {
EmojiText(emoji)
@@ -308,8 +324,39 @@ private fun CustomStatusInputRow(
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun EmojiPickerBottomSheet(
pickerState: io.element.android.libraries.emoji.api.picker.EmojiPickerState,
emojiPickerRenderer: EmojiPickerRenderer,
onSelectEmoji: (io.element.android.emojibasebindings.Emoji) -> Unit,
onDismiss: () -> Unit,
) {
val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden)
val coroutineScope = rememberCoroutineScope()
ModalBottomSheet(
onDismissRequest = onDismiss,
sheetState = sheetState,
scrollable = false,
) {
emojiPickerRenderer.Render(
state = pickerState,
onSelectEmoji = { emoji ->
sheetState.hide(coroutineScope) {
onSelectEmoji(emoji)
}
},
selectedEmojis = persistentSetOf(),
modifier = Modifier.fillMaxSize(),
)
}
}
@PreviewsDayNight
@Composable
internal fun UserStatusViewPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview {
UserStatusView(state = state)
UserStatusView(
state = state,
emojiPickerRenderer = NoOpEmojiPickerRenderer,
)
}
@@ -19,6 +19,7 @@ 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.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@@ -466,6 +467,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
setContent {
PreferencesRootView(
state = state,
emojiPickerRenderer = NoOpEmojiPickerRenderer,
onBackClick = onBackClick,
onAddAccountClick = onAddAccountClick,
onSecureBackupClick = onSecureBackupClick,
@@ -8,6 +8,7 @@
package io.element.android.features.preferences.impl.userstatus
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory
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
@@ -17,7 +18,10 @@ import org.junit.Test
class UserStatusPresenterTest {
private fun createPresenter(client: FakeMatrixClient = FakeMatrixClient()) =
UserStatusPresenter(matrixClient = client)
UserStatusPresenter(
matrixClient = client,
emojiPickerPresenterFactory = fakeEmojiPickerPresenterFactory(),
)
@Test
fun `initial state - no status set`() = runTest {
@@ -31,3 +31,14 @@ interface EmojiPickerRenderer {
contentDescription: @Composable (Emoji, Boolean) -> String = { emoji, _ -> emoji.unicode },
)
}
object NoOpEmojiPickerRenderer : EmojiPickerRenderer {
@Composable
override fun Render(
state: EmojiPickerState,
onSelectEmoji: (Emoji) -> Unit,
selectedEmojis: ImmutableSet<String>,
modifier: Modifier,
contentDescription: @Composable (Emoji, Boolean) -> String,
) = Unit
}