mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-10-31 08:15:02 +00:00
Wire the emoji picker into the user-status feature
This commit is contained in:
Generated
+34
@@ -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)
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+11
-1
@@ -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 = {},
|
||||
|
||||
+2
@@ -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
|
||||
}
|
||||
|
||||
+47
-14
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -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),
|
||||
|
||||
+14
-2
@@ -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
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+49
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+5
-1
@@ -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 {
|
||||
|
||||
+11
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user