Skip to content

Commit 3e9f5bb

Browse files
authored
Merge pull request #1351 from vector-im/feature/jme/1302-allow-users-to-change-their-avatars
Add preference screen for user profile
2 parents d4d837c + 24fb8da commit 3e9f5bb

File tree

43 files changed

+1281
-99
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

43 files changed

+1281
-99
lines changed

features/preferences/impl/build.gradle.kts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,10 +44,12 @@ dependencies {
4444
implementation(projects.libraries.preferences.api)
4545
implementation(projects.libraries.testtags)
4646
implementation(projects.libraries.uiStrings)
47+
implementation(projects.libraries.matrixui)
48+
implementation(projects.libraries.mediapickers.api)
49+
implementation(projects.libraries.mediaupload.api)
4750
implementation(projects.features.rageshake.api)
4851
implementation(projects.features.analytics.api)
4952
implementation(projects.features.ftue.api)
50-
implementation(projects.libraries.matrixui)
5153
implementation(projects.features.logout.api)
5254
implementation(projects.services.analytics.api)
5355
implementation(projects.services.toolbox.api)
@@ -64,8 +66,11 @@ dependencies {
6466
testImplementation(libs.molecule.runtime)
6567
testImplementation(libs.test.truth)
6668
testImplementation(libs.test.turbine)
69+
testImplementation(libs.test.mockk)
6770
testImplementation(projects.libraries.matrix.test)
6871
testImplementation(projects.libraries.featureflag.test)
72+
testImplementation(projects.libraries.mediapickers.test)
73+
testImplementation(projects.libraries.mediaupload.test)
6974
testImplementation(projects.libraries.preferences.test)
7075
testImplementation(projects.libraries.pushstore.test)
7176
testImplementation(projects.features.rageshake.test)

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,12 @@ import io.element.android.features.preferences.impl.developer.tracing.ConfigureT
3838
import io.element.android.features.preferences.impl.notifications.NotificationSettingsNode
3939
import io.element.android.features.preferences.impl.notifications.edit.EditDefaultNotificationSettingNode
4040
import io.element.android.features.preferences.impl.root.PreferencesRootNode
41+
import io.element.android.features.preferences.impl.user.editprofile.EditUserProfileNode
4142
import io.element.android.libraries.architecture.BackstackNode
4243
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
4344
import io.element.android.libraries.architecture.createNode
4445
import io.element.android.libraries.di.SessionScope
46+
import io.element.android.libraries.matrix.api.user.MatrixUser
4547
import kotlinx.parcelize.Parcelize
4648

4749
@ContributesNode(SessionScope::class)
@@ -81,6 +83,9 @@ class PreferencesFlowNode @AssistedInject constructor(
8183

8284
@Parcelize
8385
data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget
86+
87+
@Parcelize
88+
data class UserProfile(val matrixUser: MatrixUser) : NavTarget
8489
}
8590

8691
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -114,6 +119,10 @@ class PreferencesFlowNode @AssistedInject constructor(
114119
override fun onOpenAdvancedSettings() {
115120
backstack.push(NavTarget.AdvancedSettings)
116121
}
122+
123+
override fun onOpenUserProfile(matrixUser: MatrixUser) {
124+
backstack.push(NavTarget.UserProfile(matrixUser))
125+
}
117126
}
118127
createNode<PreferencesRootNode>(buildContext, plugins = listOf(callback))
119128
}
@@ -149,6 +158,10 @@ class PreferencesFlowNode @AssistedInject constructor(
149158
NavTarget.AdvancedSettings -> {
150159
createNode<AdvancedSettingsNode>(buildContext)
151160
}
161+
is NavTarget.UserProfile -> {
162+
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
163+
createNode<EditUserProfileNode>(buildContext, listOf(inputs))
164+
}
152165
}
153166
}
154167

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import dagger.assisted.AssistedInject
2929
import io.element.android.anvilannotations.ContributesNode
3030
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
3131
import io.element.android.libraries.di.SessionScope
32+
import io.element.android.libraries.matrix.api.user.MatrixUser
3233
import io.element.android.libraries.theme.ElementTheme
3334
import timber.log.Timber
3435

@@ -47,6 +48,7 @@ class PreferencesRootNode @AssistedInject constructor(
4748
fun onOpenDeveloperSettings()
4849
fun onOpenNotificationSettings()
4950
fun onOpenAdvancedSettings()
51+
fun onOpenUserProfile(matrixUser: MatrixUser)
5052
}
5153

5254
private fun onOpenBugReport() {
@@ -91,6 +93,10 @@ class PreferencesRootNode @AssistedInject constructor(
9193
plugins<Callback>().forEach { it.onOpenNotificationSettings() }
9294
}
9395

96+
private fun onOpenUserProfile(matrixUser: MatrixUser) {
97+
plugins<Callback>().forEach { it.onOpenUserProfile(matrixUser) }
98+
}
99+
94100
@Composable
95101
override fun View(modifier: Modifier) {
96102
val state = presenter.present()
@@ -108,7 +114,8 @@ class PreferencesRootNode @AssistedInject constructor(
108114
onOpenAdvancedSettings = this::onOpenAdvancedSettings,
109115
onSuccessLogout = { onSuccessLogout(activity, it) },
110116
onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) },
111-
onOpenNotificationSettings = this::onOpenNotificationSettings
117+
onOpenNotificationSettings = this::onOpenNotificationSettings,
118+
onOpenUserProfile = this::onOpenUserProfile,
112119
)
113120
}
114121

features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
package io.element.android.features.preferences.impl.root
1818

19+
import androidx.compose.foundation.clickable
1920
import androidx.compose.foundation.layout.fillMaxWidth
2021
import androidx.compose.foundation.layout.padding
2122
import androidx.compose.material.icons.Icons
@@ -62,6 +63,7 @@ fun PreferencesRootView(
6263
onOpenAdvancedSettings: () -> Unit,
6364
onSuccessLogout: (logoutUrlResult: String?) -> Unit,
6465
onOpenNotificationSettings: () -> Unit,
66+
onOpenUserProfile: (MatrixUser) -> Unit,
6567
modifier: Modifier = Modifier,
6668
) {
6769
val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage)
@@ -73,7 +75,12 @@ fun PreferencesRootView(
7375
title = stringResource(id = CommonStrings.common_settings),
7476
snackbarHost = { SnackbarHost(snackbarHostState) }
7577
) {
76-
UserPreferences(state.myUser)
78+
UserPreferences(
79+
modifier = Modifier.clickable {
80+
state.myUser?.let(onOpenUserProfile)
81+
},
82+
user = state.myUser,
83+
)
7784
if (state.showCompleteVerification) {
7885
PreferenceText(
7986
title = stringResource(id = CommonStrings.action_complete_verification),
@@ -181,5 +188,6 @@ private fun ContentToPreview(matrixUser: MatrixUser) {
181188
onSuccessLogout = {},
182189
onManageAccountClicked = {},
183190
onOpenNotificationSettings = {},
191+
onOpenUserProfile = {},
184192
)
185193
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
/*
2+
* Copyright (c) 2023 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.element.android.features.preferences.impl.user.editprofile
18+
19+
import io.element.android.libraries.matrix.ui.media.AvatarAction
20+
21+
sealed interface EditUserProfileEvents {
22+
data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents
23+
data class UpdateDisplayName(val name: String) : EditUserProfileEvents
24+
data object Save : EditUserProfileEvents
25+
data object CancelSaveChanges : EditUserProfileEvents
26+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/*
2+
* Copyright (c) 2023 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.element.android.features.preferences.impl.user.editprofile
18+
19+
import androidx.compose.runtime.Composable
20+
import androidx.compose.ui.Modifier
21+
import com.bumble.appyx.core.modality.BuildContext
22+
import com.bumble.appyx.core.node.Node
23+
import com.bumble.appyx.core.plugin.Plugin
24+
import dagger.assisted.Assisted
25+
import dagger.assisted.AssistedInject
26+
import io.element.android.anvilannotations.ContributesNode
27+
import io.element.android.libraries.architecture.NodeInputs
28+
import io.element.android.libraries.architecture.inputs
29+
import io.element.android.libraries.di.SessionScope
30+
import io.element.android.libraries.matrix.api.user.MatrixUser
31+
32+
@ContributesNode(SessionScope::class)
33+
class EditUserProfileNode @AssistedInject constructor(
34+
@Assisted buildContext: BuildContext,
35+
@Assisted plugins: List<Plugin>,
36+
presenterFactory: EditUserProfilePresenter.Factory,
37+
) : Node(buildContext, plugins = plugins) {
38+
39+
data class Inputs(
40+
val matrixUser: MatrixUser
41+
) : NodeInputs
42+
43+
val matrixUser = inputs<Inputs>().matrixUser
44+
val presenter = presenterFactory.create(matrixUser)
45+
46+
@Composable
47+
override fun View(modifier: Modifier) {
48+
val state = presenter.present()
49+
EditUserProfileView(
50+
state = state,
51+
onBackPressed = ::navigateUp,
52+
onProfileEdited = ::navigateUp,
53+
modifier = modifier
54+
)
55+
}
56+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/*
2+
* Copyright (c) 2023 New Vector Ltd
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package io.element.android.features.preferences.impl.user.editprofile
18+
19+
import android.net.Uri
20+
import androidx.compose.runtime.Composable
21+
import androidx.compose.runtime.MutableState
22+
import androidx.compose.runtime.derivedStateOf
23+
import androidx.compose.runtime.getValue
24+
import androidx.compose.runtime.mutableStateOf
25+
import androidx.compose.runtime.remember
26+
import androidx.compose.runtime.rememberCoroutineScope
27+
import androidx.compose.runtime.saveable.rememberSaveable
28+
import androidx.compose.runtime.setValue
29+
import androidx.core.net.toUri
30+
import dagger.assisted.Assisted
31+
import dagger.assisted.AssistedFactory
32+
import dagger.assisted.AssistedInject
33+
import io.element.android.libraries.architecture.Async
34+
import io.element.android.libraries.architecture.Presenter
35+
import io.element.android.libraries.architecture.runCatchingUpdatingState
36+
import io.element.android.libraries.core.mimetype.MimeTypes
37+
import io.element.android.libraries.matrix.api.MatrixClient
38+
import io.element.android.libraries.matrix.api.user.MatrixUser
39+
import io.element.android.libraries.matrix.ui.media.AvatarAction
40+
import io.element.android.libraries.mediapickers.api.PickerProvider
41+
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
42+
import kotlinx.collections.immutable.toImmutableList
43+
import kotlinx.coroutines.CoroutineScope
44+
import kotlinx.coroutines.launch
45+
import timber.log.Timber
46+
47+
class EditUserProfilePresenter @AssistedInject constructor(
48+
@Assisted private val matrixUser: MatrixUser,
49+
private val matrixClient: MatrixClient,
50+
private val mediaPickerProvider: PickerProvider,
51+
private val mediaPreProcessor: MediaPreProcessor,
52+
) : Presenter<EditUserProfileState> {
53+
54+
@AssistedFactory
55+
interface Factory {
56+
fun create(matrixUser: MatrixUser): EditUserProfilePresenter
57+
}
58+
59+
@Composable
60+
override fun present(): EditUserProfileState {
61+
var userAvatarUri by rememberSaveable { mutableStateOf(matrixUser.avatarUrl?.let { Uri.parse(it) }) }
62+
var userDisplayName by rememberSaveable { mutableStateOf(matrixUser.displayName) }
63+
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker(
64+
onResult = { uri -> if (uri != null) userAvatarUri = uri }
65+
)
66+
val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker(
67+
onResult = { uri -> if (uri != null) userAvatarUri = uri }
68+
)
69+
70+
val avatarActions by remember(userAvatarUri) {
71+
derivedStateOf {
72+
listOfNotNull(
73+
AvatarAction.TakePhoto,
74+
AvatarAction.ChoosePhoto,
75+
AvatarAction.Remove.takeIf { userAvatarUri != null },
76+
).toImmutableList()
77+
}
78+
}
79+
80+
val saveAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
81+
val localCoroutineScope = rememberCoroutineScope()
82+
fun handleEvents(event: EditUserProfileEvents) {
83+
when (event) {
84+
is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges(userDisplayName, userAvatarUri, matrixUser, saveAction)
85+
is EditUserProfileEvents.HandleAvatarAction -> {
86+
when (event.action) {
87+
AvatarAction.ChoosePhoto -> galleryImagePicker.launch()
88+
AvatarAction.TakePhoto -> cameraPhotoPicker.launch()
89+
AvatarAction.Remove -> userAvatarUri = null
90+
}
91+
}
92+
93+
is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name
94+
EditUserProfileEvents.CancelSaveChanges -> saveAction.value = Async.Uninitialized
95+
}
96+
}
97+
98+
val canSave = remember(userDisplayName, userAvatarUri) {
99+
val hasProfileChanged = hasDisplayNameChanged(userDisplayName, matrixUser) ||
100+
hasAvatarUrlChanged(userAvatarUri, matrixUser)
101+
!userDisplayName.isNullOrBlank() && hasProfileChanged
102+
}
103+
104+
return EditUserProfileState(
105+
userId = matrixUser.userId,
106+
displayName = userDisplayName.orEmpty(),
107+
userAvatarUrl = userAvatarUri,
108+
avatarActions = avatarActions,
109+
saveButtonEnabled = canSave && saveAction.value !is Async.Loading,
110+
saveAction = saveAction.value,
111+
eventSink = { handleEvents(it) },
112+
)
113+
}
114+
115+
private fun hasDisplayNameChanged(name: String?, currentUser: MatrixUser) =
116+
name?.trim() != currentUser.displayName?.trim()
117+
118+
private fun hasAvatarUrlChanged(avatarUri: Uri?, currentUser: MatrixUser) =
119+
// Need to call `toUri()?.toString()` to make the test pass (we mockk Uri)
120+
avatarUri?.toString()?.trim() != currentUser.avatarUrl?.toUri()?.toString()?.trim()
121+
122+
private fun CoroutineScope.saveChanges(name: String?, avatarUri: Uri?, currentUser: MatrixUser, action: MutableState<Async<Unit>>) = launch {
123+
val results = mutableListOf<Result<Unit>>()
124+
suspend {
125+
if (!name.isNullOrEmpty() && name.trim() != currentUser.displayName.orEmpty().trim()) {
126+
results.add(matrixClient.setDisplayName(name).onFailure {
127+
Timber.e(it, "Failed to set user's display name")
128+
})
129+
}
130+
if (avatarUri?.toString()?.trim() != currentUser.avatarUrl?.trim()) {
131+
results.add(updateAvatar(avatarUri).onFailure {
132+
Timber.e(it, "Failed to update user's avatar")
133+
})
134+
}
135+
if (results.all { it.isSuccess }) Unit else results.first { it.isFailure }.getOrThrow()
136+
}.runCatchingUpdatingState(action)
137+
}
138+
139+
private suspend fun updateAvatar(avatarUri: Uri?): Result<Unit> {
140+
return runCatching {
141+
if (avatarUri != null) {
142+
val preprocessed = mediaPreProcessor.process(avatarUri, MimeTypes.Jpeg, compressIfPossible = false).getOrThrow()
143+
matrixClient.uploadAvatar(MimeTypes.Jpeg, preprocessed.file.readBytes()).getOrThrow()
144+
} else {
145+
matrixClient.removeAvatar().getOrThrow()
146+
}
147+
}.onFailure { Timber.e(it, "Unable to update avatar") }
148+
}
149+
}

0 commit comments

Comments
 (0)