mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 16:10:00 +00:00
Merge pull request #7094 from element-hq/feature/fga/set_user_status
WIP : Set user status
This commit is contained in:
+5
-1
@@ -21,6 +21,7 @@ import androidx.compose.runtime.setValue
|
|||||||
import dev.zacsweers.metro.Inject
|
import dev.zacsweers.metro.Inject
|
||||||
import io.element.android.features.enterprise.api.SessionEnterpriseService
|
import io.element.android.features.enterprise.api.SessionEnterpriseService
|
||||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
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.preferences.impl.utils.ShowDeveloperSettingsProvider
|
||||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
@@ -57,6 +58,7 @@ class PreferencesRootPresenter(
|
|||||||
private val featureFlagService: FeatureFlagService,
|
private val featureFlagService: FeatureFlagService,
|
||||||
private val sessionStore: SessionStore,
|
private val sessionStore: SessionStore,
|
||||||
private val sessionEnterpriseService: SessionEnterpriseService,
|
private val sessionEnterpriseService: SessionEnterpriseService,
|
||||||
|
private val userStatusPresenter: Presenter<UserStatusState>,
|
||||||
) : Presenter<PreferencesRootState> {
|
) : Presenter<PreferencesRootState> {
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): PreferencesRootState {
|
override fun present(): PreferencesRootState {
|
||||||
@@ -66,13 +68,14 @@ class PreferencesRootPresenter(
|
|||||||
// Force a refresh of the profile
|
// Force a refresh of the profile
|
||||||
matrixClient.getUserProfile()
|
matrixClient.getUserProfile()
|
||||||
}
|
}
|
||||||
|
|
||||||
val isMultiAccountEnabled by remember {
|
val isMultiAccountEnabled by remember {
|
||||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount)
|
||||||
}.collectAsState(initial = false)
|
}.collectAsState(initial = false)
|
||||||
val showLinkNewDevice by remember {
|
val showLinkNewDevice by remember {
|
||||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
|
||||||
}.collectAsState(initial = false)
|
}.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 {
|
val otherSessions by remember {
|
||||||
sessionStore.sessionsFlow().map { list ->
|
sessionStore.sessionsFlow().map { list ->
|
||||||
@@ -137,6 +140,7 @@ class PreferencesRootPresenter(
|
|||||||
|
|
||||||
return PreferencesRootState(
|
return PreferencesRootState(
|
||||||
myUser = matrixUser.value,
|
myUser = matrixUser.value,
|
||||||
|
userStatusState = userStatusState,
|
||||||
version = remember { versionFormatter.get() },
|
version = remember { versionFormatter.get() },
|
||||||
deviceId = matrixClient.deviceId,
|
deviceId = matrixClient.deviceId,
|
||||||
isMultiAccountEnabled = isMultiAccountEnabled,
|
isMultiAccountEnabled = isMultiAccountEnabled,
|
||||||
|
|||||||
+2
@@ -9,6 +9,7 @@
|
|||||||
package io.element.android.features.preferences.impl.root
|
package io.element.android.features.preferences.impl.root
|
||||||
|
|
||||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
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.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
@@ -16,6 +17,7 @@ import kotlinx.collections.immutable.ImmutableList
|
|||||||
|
|
||||||
data class PreferencesRootState(
|
data class PreferencesRootState(
|
||||||
val myUser: MatrixUser,
|
val myUser: MatrixUser,
|
||||||
|
val userStatusState: UserStatusState?,
|
||||||
val version: String,
|
val version: String,
|
||||||
val deviceId: DeviceId?,
|
val deviceId: DeviceId?,
|
||||||
val isMultiAccountEnabled: Boolean,
|
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 androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
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.designsystem.utils.snackbar.SnackbarMessage
|
||||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
@@ -64,8 +67,9 @@ open class PreferencesRootStateProvider : PreviewParameterProvider<PreferencesRo
|
|||||||
showDeveloperSettings = true,
|
showDeveloperSettings = true,
|
||||||
canDeactivateAccount = true,
|
canDeactivateAccount = true,
|
||||||
),
|
),
|
||||||
|
aPreferencesRootState(userStatusState = aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker)),
|
||||||
// Minimal state
|
// Minimal state
|
||||||
aPreferencesRootState(),
|
aPreferencesRootState(userStatusState = null),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,6 +90,7 @@ fun aPreferencesRootState(
|
|||||||
nbOfBlockedUsers: Int = 0,
|
nbOfBlockedUsers: Int = 0,
|
||||||
showLabsItem: Boolean = false,
|
showLabsItem: Boolean = false,
|
||||||
directLogoutState: DirectLogoutState = aDirectLogoutState(),
|
directLogoutState: DirectLogoutState = aDirectLogoutState(),
|
||||||
|
userStatusState: UserStatusState? = aUserStatusState(),
|
||||||
snackbarMessage: SnackbarMessage? = null,
|
snackbarMessage: SnackbarMessage? = null,
|
||||||
eventSink: (PreferencesRootEvent) -> Unit = {},
|
eventSink: (PreferencesRootEvent) -> Unit = {},
|
||||||
) = PreferencesRootState(
|
) = PreferencesRootState(
|
||||||
@@ -105,6 +110,7 @@ fun aPreferencesRootState(
|
|||||||
nbOfBlockedUsers = nbOfBlockedUsers,
|
nbOfBlockedUsers = nbOfBlockedUsers,
|
||||||
showLabsItem = showLabsItem,
|
showLabsItem = showLabsItem,
|
||||||
directLogoutState = directLogoutState,
|
directLogoutState = directLogoutState,
|
||||||
|
userStatusState = userStatusState,
|
||||||
snackbarMessage = snackbarMessage,
|
snackbarMessage = snackbarMessage,
|
||||||
eventSink = eventSink,
|
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.clickable
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
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.compound.tokens.generated.CompoundIcons
|
||||||
import io.element.android.features.preferences.impl.R
|
import io.element.android.features.preferences.impl.R
|
||||||
import io.element.android.features.preferences.impl.user.UserPreferences
|
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.architecture.coverage.ExcludeFromCoverage
|
||||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||||
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
import io.element.android.libraries.designsystem.components.list.ListItemContent
|
||||||
@@ -85,10 +88,10 @@ fun PreferencesRootView(
|
|||||||
state = state,
|
state = state,
|
||||||
onAddAccountClick = onAddAccountClick,
|
onAddAccountClick = onAddAccountClick,
|
||||||
)
|
)
|
||||||
} else {
|
|
||||||
HorizontalDivider()
|
|
||||||
}
|
}
|
||||||
// User status will be added here
|
if (state.userStatusState != null) {
|
||||||
|
UserStatusSection(state.userStatusState, showTopDivider = !state.isMultiAccountEnabled)
|
||||||
|
}
|
||||||
// 'Account' section
|
// 'Account' section
|
||||||
ManageAccountSection(
|
ManageAccountSection(
|
||||||
state = state,
|
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
|
@Composable
|
||||||
private fun ColumnScope.MultiAccountSection(
|
private fun ColumnScope.MultiAccountSection(
|
||||||
state: PreferencesRootState,
|
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.api.SessionEnterpriseService
|
||||||
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
|
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
|
||||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
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.preferences.impl.utils.ShowDeveloperSettingsProvider
|
||||||
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||||
import io.element.android.libraries.core.meta.BuildType
|
import io.element.android.libraries.core.meta.BuildType
|
||||||
@@ -346,5 +347,6 @@ class PreferencesRootPresenterTest {
|
|||||||
featureFlagService = featureFlagService,
|
featureFlagService = featureFlagService,
|
||||||
sessionStore = sessionStore,
|
sessionStore = sessionStore,
|
||||||
sessionEnterpriseService = sessionEnterpriseService,
|
sessionEnterpriseService = sessionEnterpriseService,
|
||||||
|
userStatusPresenter = { aUserStatusState() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-6
@@ -12,8 +12,11 @@ package io.element.android.features.preferences.impl.root
|
|||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||||
import androidx.compose.ui.test.ExperimentalTestApi
|
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.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
|
import androidx.compose.ui.test.performScrollTo
|
||||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||||
import io.element.android.features.preferences.impl.R
|
import io.element.android.features.preferences.impl.R
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
@@ -199,7 +202,8 @@ class PreferencesRootViewTest : RobolectricTest() {
|
|||||||
),
|
),
|
||||||
onOpenAnalytics = callback,
|
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,
|
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,
|
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,
|
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,
|
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,
|
eventSink = eventsRecorder,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
onNodeWithText(version).performClick()
|
onNodeWithText(version).performScrollTo().performClick()
|
||||||
eventsRecorder.assertSingle(PreferencesRootEvent.OnVersionInfoClick)
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
@@ -24,6 +24,11 @@ import androidx.compose.foundation.shape.RoundedCornerShape
|
|||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
import androidx.compose.foundation.text.input.InputTransformation
|
||||||
|
import androidx.compose.foundation.text.input.KeyboardActionHandler
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldDecorator
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldLineLimits
|
||||||
|
import androidx.compose.foundation.text.input.TextFieldState
|
||||||
import androidx.compose.material3.LocalContentColor
|
import androidx.compose.material3.LocalContentColor
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
@@ -161,6 +166,55 @@ fun TextField(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun TextField(
|
||||||
|
state: TextFieldState,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
label: String? = null,
|
||||||
|
supportingText: String? = null,
|
||||||
|
placeholder: String? = null,
|
||||||
|
leadingIcon: @Composable (() -> Unit)? = null,
|
||||||
|
trailingIcon: @Composable (() -> Unit)? = null,
|
||||||
|
validity: TextFieldValidity = TextFieldValidity.None,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
readOnly: Boolean = false,
|
||||||
|
inputTransformation: InputTransformation? = null,
|
||||||
|
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
||||||
|
onKeyboardAction: KeyboardActionHandler? = null,
|
||||||
|
lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default,
|
||||||
|
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
||||||
|
) {
|
||||||
|
val isFocused by interactionSource.collectIsFocusedAsState()
|
||||||
|
BasicTextField(
|
||||||
|
state = state,
|
||||||
|
modifier = modifier,
|
||||||
|
textStyle = textFieldStyle(enabled),
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
enabled = enabled,
|
||||||
|
readOnly = readOnly,
|
||||||
|
inputTransformation = inputTransformation,
|
||||||
|
cursorBrush = SolidColor(ElementTheme.colors.textPrimary),
|
||||||
|
keyboardOptions = keyboardOptions,
|
||||||
|
onKeyboardAction = onKeyboardAction,
|
||||||
|
lineLimits = lineLimits,
|
||||||
|
decorator = TextFieldDecorator { innerTextField ->
|
||||||
|
DecorationBox(
|
||||||
|
label = label,
|
||||||
|
readOnly = readOnly,
|
||||||
|
enabled = enabled,
|
||||||
|
isFocused = isFocused,
|
||||||
|
validity = validity,
|
||||||
|
leadingIcon = leadingIcon,
|
||||||
|
placeholder = placeholder,
|
||||||
|
isTextEmpty = state.text.isEmpty(),
|
||||||
|
innerTextField = innerTextField,
|
||||||
|
trailingIcon = trailingIcon,
|
||||||
|
supportingText = supportingText,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun DecorationBox(
|
private fun DecorationBox(
|
||||||
label: String?,
|
label: String?,
|
||||||
|
|||||||
+7
@@ -152,4 +152,11 @@ enum class FeatureFlags(
|
|||||||
defaultValue = { false },
|
defaultValue = { false },
|
||||||
isFinished = false,
|
isFinished = false,
|
||||||
),
|
),
|
||||||
|
UserStatus(
|
||||||
|
key = "feature.user_status",
|
||||||
|
title = "User status",
|
||||||
|
description = "Allow users to set a status (e.g. In a meeting, Away) visible to their contacts.",
|
||||||
|
defaultValue = { false },
|
||||||
|
isFinished = false,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
|
|||||||
+5
@@ -45,6 +45,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService
|
|||||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
@@ -86,6 +87,10 @@ interface MatrixClient {
|
|||||||
suspend fun setDisplayName(displayName: String): Result<Unit>
|
suspend fun setDisplayName(displayName: String): Result<Unit>
|
||||||
suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result<Unit>
|
suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result<Unit>
|
||||||
suspend fun removeAvatar(): Result<Unit>
|
suspend fun removeAvatar(): Result<Unit>
|
||||||
|
suspend fun setUserStatus(status: UserStatus): Result<Unit>
|
||||||
|
|
||||||
|
/** Clears both m.status and m.call profile fields (maps to DELETE on the profile endpoint per MSC4426). */
|
||||||
|
suspend fun clearUserStatus(): Result<Unit>
|
||||||
suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?>
|
suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?>
|
||||||
suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?>
|
suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?>
|
||||||
suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?>
|
suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List<String>): Result<RoomInfo?>
|
||||||
|
|||||||
+26
@@ -0,0 +1,26 @@
|
|||||||
|
/*
|
||||||
|
* 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.libraries.matrix.api.user
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Immutable
|
||||||
|
sealed interface DisplayedStatus : Parcelable {
|
||||||
|
/** Status set manually by the user via m.status. */
|
||||||
|
@Parcelize
|
||||||
|
data class UserSet(val status: UserStatus) : DisplayedStatus
|
||||||
|
|
||||||
|
/** Status set automatically when the user is in a call via m.call. */
|
||||||
|
@Parcelize
|
||||||
|
data class InCall(
|
||||||
|
/** Unix timestamp in seconds when the call was joined (from m.call.call_joined_ts). */
|
||||||
|
val callJoinedTs: Long,
|
||||||
|
) : DisplayedStatus
|
||||||
|
}
|
||||||
+2
@@ -17,4 +17,6 @@ data class MatrixUser(
|
|||||||
val userId: UserId,
|
val userId: UserId,
|
||||||
val displayName: String? = null,
|
val displayName: String? = null,
|
||||||
val avatarUrl: String? = null,
|
val avatarUrl: String? = null,
|
||||||
|
val displayedStatus: DisplayedStatus? = null,
|
||||||
|
val rawStatus: UserStatus? = null,
|
||||||
) : Parcelable
|
) : Parcelable
|
||||||
|
|||||||
+17
@@ -0,0 +1,17 @@
|
|||||||
|
/*
|
||||||
|
* 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.libraries.matrix.api.user
|
||||||
|
|
||||||
|
import android.os.Parcelable
|
||||||
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
@Parcelize
|
||||||
|
data class UserStatus(
|
||||||
|
val emoji: String,
|
||||||
|
val text: String,
|
||||||
|
) : Parcelable
|
||||||
+33
-1
@@ -49,8 +49,10 @@ import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
|||||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||||
|
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||||
import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService
|
import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService
|
||||||
import io.element.android.libraries.matrix.impl.exception.mapClientException
|
import io.element.android.libraries.matrix.impl.exception.mapClientException
|
||||||
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler
|
import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler
|
||||||
@@ -137,6 +139,7 @@ import org.matrix.rustcomponents.sdk.CreateRoomParameters as RustCreateRoomParam
|
|||||||
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
|
import org.matrix.rustcomponents.sdk.RoomPreset as RustRoomPreset
|
||||||
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
|
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
|
||||||
|
|
||||||
|
@Suppress("LargeClass")
|
||||||
class RustMatrixClient(
|
class RustMatrixClient(
|
||||||
override val sessionPaths: SessionPaths,
|
override val sessionPaths: SessionPaths,
|
||||||
private val innerClient: Client,
|
private val innerClient: Client,
|
||||||
@@ -260,6 +263,8 @@ class RustMatrixClient(
|
|||||||
|
|
||||||
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
|
private var clientDelegateTaskHandle: TaskHandle? = innerClient.setDelegate(sessionDelegate)
|
||||||
|
|
||||||
|
@Volatile private var localUserStatus: UserStatus? = null
|
||||||
|
|
||||||
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
|
||||||
MatrixUser(
|
MatrixUser(
|
||||||
userId = sessionId,
|
userId = sessionId,
|
||||||
@@ -445,7 +450,12 @@ class RustMatrixClient(
|
|||||||
|
|
||||||
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
|
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
|
||||||
.onSuccess { matrixUser ->
|
.onSuccess { matrixUser ->
|
||||||
_userProfile.emit(matrixUser)
|
_userProfile.emit(
|
||||||
|
matrixUser.copy(
|
||||||
|
rawStatus = localUserStatus,
|
||||||
|
displayedStatus = localUserStatus?.let { DisplayedStatus.UserSet(it) },
|
||||||
|
)
|
||||||
|
)
|
||||||
// Also update our session storage
|
// Also update our session storage
|
||||||
sessionStore.updateUserProfile(
|
sessionStore.updateUserProfile(
|
||||||
sessionId = sessionId.value,
|
sessionId = sessionId.value,
|
||||||
@@ -476,6 +486,28 @@ class RustMatrixClient(
|
|||||||
runCatchingExceptions { innerClient.removeAvatar() }
|
runCatchingExceptions { innerClient.removeAvatar() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun setUserStatus(status: UserStatus): Result<Unit> = withContext(sessionDispatcher) {
|
||||||
|
localUserStatus = status
|
||||||
|
_userProfile.emit(
|
||||||
|
_userProfile.value.copy(
|
||||||
|
rawStatus = status,
|
||||||
|
displayedStatus = DisplayedStatus.UserSet(status),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearUserStatus(): Result<Unit> = withContext(sessionDispatcher) {
|
||||||
|
localUserStatus = null
|
||||||
|
_userProfile.emit(
|
||||||
|
_userProfile.value.copy(
|
||||||
|
rawStatus = null,
|
||||||
|
displayedStatus = null,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
Result.success(Unit)
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
|
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = withContext(sessionDispatcher) {
|
||||||
runCatchingExceptions {
|
runCatchingExceptions {
|
||||||
innerClient.joinRoomById(roomId.value).destroy()
|
innerClient.joinRoomById(roomId.value).destroy()
|
||||||
|
|||||||
+30
@@ -42,8 +42,10 @@ import io.element.android.libraries.matrix.api.scanner.ContentScanner
|
|||||||
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
import io.element.android.libraries.matrix.api.spaces.SpaceService
|
||||||
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
import io.element.android.libraries.matrix.api.sync.SlidingSyncVersion
|
||||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||||
|
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
|
||||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||||
|
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||||
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader
|
||||||
@@ -131,6 +133,12 @@ class FakeMatrixClient(
|
|||||||
private set
|
private set
|
||||||
var removeAvatarCalled: Boolean = false
|
var removeAvatarCalled: Boolean = false
|
||||||
private set
|
private set
|
||||||
|
var setUserStatusCalled: Boolean = false
|
||||||
|
private set
|
||||||
|
var clearUserStatusCalled: Boolean = false
|
||||||
|
private set
|
||||||
|
var setUserStatusResult: Result<Unit> = Result.success(Unit)
|
||||||
|
var clearUserStatusResult: Result<Unit> = Result.success(Unit)
|
||||||
|
|
||||||
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
|
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
|
||||||
override val userProfile: StateFlow<MatrixUser> = _userProfile
|
override val userProfile: StateFlow<MatrixUser> = _userProfile
|
||||||
@@ -253,6 +261,28 @@ class FakeMatrixClient(
|
|||||||
return removeAvatarResult
|
return removeAvatarResult
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override suspend fun setUserStatus(status: UserStatus): Result<Unit> {
|
||||||
|
setUserStatusCalled = true
|
||||||
|
if (setUserStatusResult.isSuccess) {
|
||||||
|
_userProfile.emit(_userProfile.value.copy(
|
||||||
|
rawStatus = status,
|
||||||
|
displayedStatus = DisplayedStatus.UserSet(status),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return setUserStatusResult
|
||||||
|
}
|
||||||
|
|
||||||
|
override suspend fun clearUserStatus(): Result<Unit> {
|
||||||
|
clearUserStatusCalled = true
|
||||||
|
if (clearUserStatusResult.isSuccess) {
|
||||||
|
_userProfile.emit(_userProfile.value.copy(
|
||||||
|
rawStatus = null,
|
||||||
|
displayedStatus = null,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
return clearUserStatusResult
|
||||||
|
}
|
||||||
|
|
||||||
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = joinRoomLambda(roomId)
|
override suspend fun joinRoom(roomId: RoomId): Result<RoomInfo?> = joinRoomLambda(roomId)
|
||||||
|
|
||||||
override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> {
|
override suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List<String>): Result<RoomInfo?> {
|
||||||
|
|||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:837ebeb30aebfb154e98b1b4603156cf0ac877b58efae17d534aaf269ce8ba10
|
||||||
|
size 20037
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:07ad7e092d6b8e41064bcbce131ef53d70a552dd6f9292dd286dd7c10948cf9a
|
||||||
|
size 20459
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c14e7ad5b7c320873dab0b97a6261b2b62a7405d133e9c53fe2184e9757becac
|
||||||
|
size 9203
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:35f0fb99a74477ebfa53a019ded68043035de9ba0f86a6a71b032137a125d107
|
||||||
|
size 7168
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6432133a87271013cab796baa7d48ab8e96f65f049b92a9074ec03f540e11545
|
||||||
|
size 7269
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ebc88a04da18e07b5f94cc52f75ad033fa01917de6c32ac77a1b400fd27c62cd
|
||||||
|
size 28944
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3cff5ee1c0cc217b20c68bb4f4d2335ab03e9dd8d16045550358e748002c538b
|
||||||
|
size 27460
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b5dc5c492ed3fd552e77c0be85b639d55bc7e976f15b761f927b0b80ecca601d
|
||||||
|
size 12115
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5896eb5df6f5398bdb94e74280d0d5c04b6a5a53c2378b7377c60fa58eef544b
|
||||||
|
size 12013
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:343ddb8692135fb051766c340a4ac6143a5dc57917f0cc16fe11678f2b9bae6e
|
||||||
|
size 8774
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b4b5eb36080cc5750c4cd36ae66ba618c033e794fdf0a4fb5afbae5e1fa10824
|
||||||
|
size 6789
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:bf614dc03afdb990baae40b020bd3f3d6396226b45fc4eee18f2d55100df5bff
|
||||||
|
size 6918
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fff529404e3219d00ded2f701066c48506b96512b08b1750665d3c2c0a75ed97
|
||||||
|
size 27700
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1017de34abdb57805946efdd6d486ab76adecdc458d17eb9b1128be0dbe8ff00
|
||||||
|
size 26227
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e7ce8a8c6b4d1692e4b127b7f17f507552666c631893025082c385b93d835a73
|
||||||
|
size 11726
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8b93c08531efbeacb55f84b17ac793a7b226e5a2db7bc5072e94bc040cd1e9bc
|
||||||
|
size 11386
|
||||||
Reference in New Issue
Block a user