Browse Source

Merge pull request #2565 from element-hq/feature/bma/userDataCache

Read user avatar from cache
feature/bma/fixNotificationContent
Benoit Marty 6 months ago committed by GitHub
parent
commit
fb64018110
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 1
      changelog.d/2488.bugfix
  2. 14
      features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
  3. 2
      features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt
  4. 7
      features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt
  5. 4
      features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt
  6. 8
      features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt
  7. 2
      features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt
  8. 13
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
  9. 2
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
  10. 4
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
  11. 29
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt
  12. 38
      features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
  13. 5
      libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt
  14. 8
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt
  15. 33
      libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/CurrentUser.kt
  16. 32
      libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt
  17. 17
      libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt
  18. 41
      libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt
  19. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_11,NEXUS_5,1.0,en].png
  20. BIN
      tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_11,NEXUS_5,1.0,en].png

1
changelog.d/2488.bugfix

@ -0,0 +1 @@
Use user avatar from cache if available.

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

@ -24,7 +24,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import io.element.android.features.logout.api.direct.DirectLogoutPresenter import io.element.android.features.logout.api.direct.DirectLogoutPresenter
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
@ -35,8 +34,6 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -58,11 +55,10 @@ class PreferencesRootPresenter @Inject constructor(
) : Presenter<PreferencesRootState> { ) : Presenter<PreferencesRootState> {
@Composable @Composable
override fun present(): PreferencesRootState { override fun present(): PreferencesRootState {
val matrixUser: MutableState<MatrixUser?> = rememberSaveable { val matrixUser = matrixClient.userProfile.collectAsState()
mutableStateOf(null)
}
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
initialLoad(matrixUser) // Force a refresh of the profile
matrixClient.getUserProfile()
} }
val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState()
@ -121,10 +117,6 @@ class PreferencesRootPresenter @Inject constructor(
) )
} }
private fun CoroutineScope.initialLoad(matrixUser: MutableState<MatrixUser?>) = launch {
matrixUser.value = matrixClient.getCurrentUser()
}
private fun CoroutineScope.initAccountManagementUrl( private fun CoroutineScope.initAccountManagementUrl(
accountManagementUrl: MutableState<String?>, accountManagementUrl: MutableState<String?>,
devicesManagementUrl: MutableState<String?>, devicesManagementUrl: MutableState<String?>,

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

@ -21,7 +21,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
data class PreferencesRootState( data class PreferencesRootState(
val myUser: MatrixUser?, val myUser: MatrixUser,
val version: String, val version: String,
val deviceId: String?, val deviceId: String?,
val showCompleteVerification: Boolean, val showCompleteVerification: Boolean,

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

@ -18,10 +18,13 @@ package io.element.android.features.preferences.impl.root
import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState
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.user.MatrixUser
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
fun aPreferencesRootState() = PreferencesRootState( fun aPreferencesRootState(
myUser = null, myUser: MatrixUser,
) = PreferencesRootState(
myUser = myUser,
version = "Version 1.1 (1)", version = "Version 1.1 (1)",
deviceId = "ILAKNDNASDLK", deviceId = "ILAKNDNASDLK",
showCompleteVerification = true, showCompleteVerification = true,

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

@ -77,7 +77,7 @@ fun PreferencesRootView(
) { ) {
UserPreferences( UserPreferences(
modifier = Modifier.clickable { modifier = Modifier.clickable {
state.myUser?.let(onOpenUserProfile) onOpenUserProfile(state.myUser)
}, },
user = state.myUser, user = state.myUser,
) )
@ -225,7 +225,7 @@ internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider
@Composable @Composable
private fun ContentToPreview(matrixUser: MatrixUser) { private fun ContentToPreview(matrixUser: MatrixUser) {
PreferencesRootView( PreferencesRootView(
state = aPreferencesRootState().copy(myUser = matrixUser), state = aPreferencesRootState(myUser = matrixUser),
onBackPressed = {}, onBackPressed = {},
onOpenAnalytics = {}, onOpenAnalytics = {},
onOpenRageShake = {}, onOpenRageShake = {},

8
features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt

@ -75,7 +75,13 @@ class PreferencesRootPresenterTest {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.myUser).isNull() assertThat(initialState.myUser).isEqualTo(
MatrixUser(
userId = matrixClient.sessionId,
displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL
)
)
assertThat(initialState.version).isEqualTo("A Version") assertThat(initialState.version).isEqualTo("A Version")
val loadedState = awaitItem() val loadedState = awaitItem()
assertThat(loadedState.myUser).isEqualTo( assertThat(loadedState.myUser).isEqualTo(

2
features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/members/RoomMemberListPresenterTests.kt

@ -223,7 +223,7 @@ private class FakeRoomMemberListNavigator : RoomMemberListNavigator {
var openRoomMemberDetailsCallCount = 0 var openRoomMemberDetailsCallCount = 0
private set private set
override fun openRoomMemberDetails(userId: UserId) { override fun openRoomMemberDetails(roomMemberId: UserId) {
openRoomMemberDetailsCallCount++ openRoomMemberDetailsCallCount++
} }
} }

13
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt

@ -58,8 +58,6 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
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.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.api.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@ -101,16 +99,15 @@ class RoomListPresenter @Inject constructor(
override fun present(): RoomListState { override fun present(): RoomListState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present() val leaveRoomState = leaveRoomPresenter.present()
val matrixUser: MutableState<MatrixUser?> = rememberSaveable { val matrixUser = client.userProfile.collectAsState()
mutableStateOf(null)
}
val networkConnectionStatus by networkMonitor.connectivity.collectAsState() val networkConnectionStatus by networkMonitor.connectivity.collectAsState()
val filtersState = filtersPresenter.present() val filtersState = filtersPresenter.present()
val searchState = searchPresenter.present() val searchState = searchPresenter.present()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
roomListDataSource.launchIn(this) roomListDataSource.launchIn(this)
initialLoad(matrixUser) // Force a refresh of the profile
client.getUserProfile()
} }
var securityBannerDismissed by rememberSaveable { mutableStateOf(false) } var securityBannerDismissed by rememberSaveable { mutableStateOf(false) }
@ -157,10 +154,6 @@ class RoomListPresenter @Inject constructor(
) )
} }
private fun CoroutineScope.initialLoad(matrixUser: MutableState<MatrixUser?>) = launch {
matrixUser.value = client.getCurrentUser()
}
@Composable @Composable
private fun securityBannerState( private fun securityBannerState(
securityBannerDismissed: Boolean, securityBannerDismissed: Boolean,

2
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt

@ -28,7 +28,7 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
data class RoomListState( data class RoomListState(
val matrixUser: MatrixUser?, val matrixUser: MatrixUser,
val showAvatarIndicator: Boolean, val showAvatarIndicator: Boolean,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,

4
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt

@ -49,14 +49,14 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)), aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation)),
aRoomListState(contentState = anEmptyContentState()), aRoomListState(contentState = anEmptyContentState()),
aRoomListState(contentState = aSkeletonContentState()), aRoomListState(contentState = aSkeletonContentState()),
aRoomListState(matrixUser = null, contentState = aMigrationContentState()), aRoomListState(matrixUser = MatrixUser(userId = UserId("@id:domain")), contentState = aMigrationContentState()),
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")), aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
aRoomListState(filtersState = aRoomListFiltersState(isFeatureEnabled = true)), aRoomListState(filtersState = aRoomListFiltersState(isFeatureEnabled = true)),
) )
} }
internal fun aRoomListState( internal fun aRoomListState(
matrixUser: MatrixUser? = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"), matrixUser: MatrixUser = MatrixUser(userId = UserId("@id:domain"), displayName = "User#1"),
showAvatarIndicator: Boolean = false, showAvatarIndicator: Boolean = false,
hasNetworkConnection: Boolean = true, hasNetworkConnection: Boolean = true,
snackbarMessage: SnackbarMessage? = null, snackbarMessage: SnackbarMessage? = null,

29
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt

@ -21,10 +21,8 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarDefaults
@ -73,7 +71,6 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi
import io.element.android.libraries.designsystem.theme.components.Icon 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.IconButton
import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar import io.element.android.libraries.designsystem.theme.components.MediumTopAppBar
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.Text
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
@ -87,7 +84,7 @@ private val avatarBloomSize = 430.dp
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun RoomListTopBar( fun RoomListTopBar(
matrixUser: MatrixUser?, matrixUser: MatrixUser,
showAvatarIndicator: Boolean, showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean, areSearchResultsDisplayed: Boolean,
onToggleSearch: () -> Unit, onToggleSearch: () -> Unit,
@ -117,7 +114,7 @@ fun RoomListTopBar(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun DefaultRoomListTopBar( private fun DefaultRoomListTopBar(
matrixUser: MatrixUser?, matrixUser: MatrixUser,
showAvatarIndicator: Boolean, showAvatarIndicator: Boolean,
areSearchResultsDisplayed: Boolean, areSearchResultsDisplayed: Boolean,
scrollBehavior: TopAppBarScrollBehavior, scrollBehavior: TopAppBarScrollBehavior,
@ -142,7 +139,7 @@ private fun DefaultRoomListTopBar(
val avatarData by remember(matrixUser) { val avatarData by remember(matrixUser) {
derivedStateOf { derivedStateOf {
matrixUser?.getAvatarData(size = AvatarSize.CurrentUserTopBar) matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
} }
} }
@ -295,7 +292,7 @@ private fun DefaultRoomListTopBar(
@Composable @Composable
private fun NavigationIcon( private fun NavigationIcon(
avatarData: AvatarData?, avatarData: AvatarData,
showAvatarIndicator: Boolean, showAvatarIndicator: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
) { ) {
@ -304,20 +301,10 @@ private fun NavigationIcon(
onClick = onClick, onClick = onClick,
) { ) {
Box { Box {
if (avatarData != null) { Avatar(
Avatar( avatarData = avatarData,
avatarData = avatarData, contentDescription = stringResource(CommonStrings.common_settings),
contentDescription = stringResource(CommonStrings.common_settings), )
)
} else {
// Placeholder avatar until the avatarData is available
Surface(
modifier = Modifier.size(AvatarSize.CurrentUserTopBar.dp),
shape = CircleShape,
color = ElementTheme.colors.iconSecondary,
content = {}
)
}
if (showAvatarIndicator) { if (showAvatarIndicator) {
RedIndicatorAtom( RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd) modifier = Modifier.align(Alignment.TopEnd)

38
features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt

@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomList import io.element.android.libraries.matrix.api.roomlist.RoomList
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.timeline.ReceiptType import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -93,17 +94,24 @@ class RoomListPresenterTests {
@Test @Test
fun `present - should start with no user and then load user with success`() = runTest { fun `present - should start with no user and then load user with success`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob()) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(coroutineScope = scope) val matrixClient = FakeMatrixClient(
userDisplayName = null,
userAvatarUrl = null,
)
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.success(MatrixUser(matrixClient.sessionId, A_USER_NAME, AN_AVATAR_URL)))
val presenter = createRoomListPresenter(
client = matrixClient,
coroutineScope = scope,
)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.matrixUser).isNull() assertThat(initialState.matrixUser).isEqualTo(MatrixUser(A_USER_ID))
val withUserState = awaitItem() val withUserState = awaitItem()
assertThat(withUserState.matrixUser).isNotNull() assertThat(withUserState.matrixUser.userId).isEqualTo(A_USER_ID)
assertThat(withUserState.matrixUser!!.userId).isEqualTo(A_USER_ID) assertThat(withUserState.matrixUser.displayName).isEqualTo(A_USER_NAME)
assertThat(withUserState.matrixUser!!.displayName).isEqualTo(A_USER_NAME) assertThat(withUserState.matrixUser.avatarUrl).isEqualTo(AN_AVATAR_URL)
assertThat(withUserState.matrixUser!!.avatarUrl).isEqualTo(AN_AVATAR_URL)
assertThat(withUserState.showAvatarIndicator).isTrue() assertThat(withUserState.showAvatarIndicator).isTrue()
scope.cancel() scope.cancel()
} }
@ -128,7 +136,6 @@ class RoomListPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.showAvatarIndicator).isTrue() assertThat(initialState.showAvatarIndicator).isTrue()
sessionVerificationService.givenCanVerifySession(false) sessionVerificationService.givenCanVerifySession(false)
assertThat(awaitItem().showAvatarIndicator).isTrue()
encryptionService.emitBackupState(BackupState.ENABLED) encryptionService.emitBackupState(BackupState.ENABLED)
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.showAvatarIndicator).isFalse() assertThat(finalState.showAvatarIndicator).isFalse()
@ -139,19 +146,18 @@ class RoomListPresenterTests {
@Test @Test
fun `present - should start with no user and then load user with error`() = runTest { fun `present - should start with no user and then load user with error`() = runTest {
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
userDisplayName = Result.failure(AN_EXCEPTION), userDisplayName = null,
userAvatarUrl = Result.failure(AN_EXCEPTION), userAvatarUrl = null,
) )
matrixClient.givenGetProfileResult(matrixClient.sessionId, Result.failure(AN_EXCEPTION))
val scope = CoroutineScope(coroutineContext + SupervisorJob()) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope) val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.matrixUser).isNull() assertThat(initialState.matrixUser).isEqualTo(MatrixUser(matrixClient.sessionId))
val withUserState = awaitItem() // No new state is coming
assertThat(withUserState.matrixUser).isNotNull()
scope.cancel()
} }
} }
@ -364,7 +370,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
val summary = createRoomListRoomSummary() val summary = createRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
@ -414,8 +419,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
val summary = createRoomListRoomSummary() val summary = createRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
@ -473,7 +476,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
eventRecorder.assertEmpty() eventRecorder.assertEmpty()
initialState.eventSink(RoomListEvents.ToggleSearchResults) initialState.eventSink(RoomListEvents.ToggleSearchResults)
@ -558,7 +560,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
// The migration screen is shown if the migration screen has not been shown before // The migration screen is shown if the migration screen has not been shown before
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Migration::class.java) assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Migration::class.java)
@ -585,7 +586,6 @@ class RoomListPresenterTests {
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1)
assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Empty::class.java) assertThat(awaitItem().contentState).isInstanceOf(RoomListContentState.Empty::class.java)
scope.cancel() scope.cancel()
} }

5
libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt

@ -302,7 +302,7 @@ fun Modifier.bloom(
/** /**
* Bloom effect modifier for avatars. Applies a bloom effect to the component. * Bloom effect modifier for avatars. Applies a bloom effect to the component.
* @param avatarData The avatar data to use as the bloom source. * @param avatarData The avatar data to use as the bloom source.
* If the avatar data has a URL it will be used as the bloom source, otherwise the initials will be used. If `null` is passed, no bloom effect will be applied. * If the avatar data has a URL it will be used as the bloom source, otherwise the initials will be used.
* @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent. * @param background The background color to use for the bloom effect. Since we use blend modes it must be non-transparent.
* @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component. * @param blurSize The size of the bloom effect. If not specified the bloom effect will be the size of the component.
* @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component. * @param offset The offset to use for the bloom effect. If not specified the bloom effect will be centered on the component.
@ -313,7 +313,7 @@ fun Modifier.bloom(
* @param alpha The alpha value to apply to the bloom effect. * @param alpha The alpha value to apply to the bloom effect.
*/ */
fun Modifier.avatarBloom( fun Modifier.avatarBloom(
avatarData: AvatarData?, avatarData: AvatarData,
background: Color, background: Color,
blurSize: DpSize = DpSize.Unspecified, blurSize: DpSize = DpSize.Unspecified,
offset: DpOffset = DpOffset.Unspecified, offset: DpOffset = DpOffset.Unspecified,
@ -327,7 +327,6 @@ fun Modifier.avatarBloom(
) = composed { ) = composed {
// Bloom only works on API 29+ // Bloom only works on API 29+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return@composed this
avatarData ?: return@composed this
// Request the avatar contents to use as the bloom source // Request the avatar contents to use as the bloom source
val context = LocalContext.current val context = LocalContext.current

8
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt

@ -42,6 +42,7 @@ import java.io.Closeable
interface MatrixClient : Closeable { interface MatrixClient : Closeable {
val sessionId: SessionId val sessionId: SessionId
val deviceId: String val deviceId: String
val userProfile: StateFlow<MatrixUser>
val roomListService: RoomListService val roomListService: RoomListService
val mediaLoader: MatrixMediaLoader val mediaLoader: MatrixMediaLoader
val sessionCoroutineScope: CoroutineScope val sessionCoroutineScope: CoroutineScope
@ -77,8 +78,11 @@ interface MatrixClient : Closeable {
* @param ignoreSdkError if true, the SDK will ignore any error and delete the session data anyway. * @param ignoreSdkError if true, the SDK will ignore any error and delete the session data anyway.
*/ */
suspend fun logout(ignoreSdkError: Boolean): String? suspend fun logout(ignoreSdkError: Boolean): String?
suspend fun loadUserDisplayName(): Result<String>
suspend fun loadUserAvatarUrl(): Result<String?> /**
* Retrieve the user profile, will also eventually emit a new value to [userProfile].
*/
suspend fun getUserProfile(): Result<MatrixUser>
suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?> suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?>
suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String> suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String>
fun roomMembershipObserver(): RoomMembershipObserver fun roomMembershipObserver(): RoomMembershipObserver

33
libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/user/CurrentUser.kt

@ -1,33 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.user
import io.element.android.libraries.matrix.api.MatrixClient
/**
* Get the current user, as [MatrixUser], using [MatrixClient.loadUserAvatarUrl]
* and [MatrixClient.loadUserDisplayName].
*/
suspend fun MatrixClient.getCurrentUser(): MatrixUser {
val userAvatarUrl = loadUserAvatarUrl().getOrNull()
val userDisplayName = loadUserDisplayName().getOrNull()
return MatrixUser(
userId = sessionId,
displayName = userDisplayName,
avatarUrl = userAvatarUrl,
)
}

32
libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt

@ -73,7 +73,9 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@ -249,6 +251,17 @@ class RustMatrixClient(
private val clientDelegateTaskHandle: TaskHandle? = client.setDelegate(clientDelegate) private val clientDelegateTaskHandle: TaskHandle? = client.setDelegate(clientDelegate)
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(
MatrixUser(
userId = sessionId,
// TODO cache for displayName?
displayName = null,
avatarUrl = client.cachedAvatarUrl(),
)
)
override val userProfile: StateFlow<MatrixUser> = _userProfile
override val ignoredUsersFlow = mxCallbackFlow<ImmutableList<UserId>> { override val ignoredUsersFlow = mxCallbackFlow<ImmutableList<UserId>> {
client.subscribeToIgnoredUsers(object : IgnoredUsersListener { client.subscribeToIgnoredUsers(object : IgnoredUsersListener {
override fun call(ignoredUserIds: List<String>) { override fun call(ignoredUserIds: List<String>) {
@ -265,6 +278,10 @@ class RustMatrixClient(
setupVerificationControllerIfNeeded() setupVerificationControllerIfNeeded()
} }
}.launchIn(sessionCoroutineScope) }.launchIn(sessionCoroutineScope)
sessionCoroutineScope.launch {
// Force a refresh of the profile
getUserProfile()
}
} }
override suspend fun getRoom(roomId: RoomId): MatrixRoom? = withContext(sessionDispatcher) { override suspend fun getRoom(roomId: RoomId): MatrixRoom? = withContext(sessionDispatcher) {
@ -374,6 +391,9 @@ class RustMatrixClient(
} }
} }
override suspend fun getUserProfile(): Result<MatrixUser> = getProfile(sessionId)
.onSuccess { _userProfile.tryEmit(it) }
override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> = override suspend fun searchUsers(searchTerm: String, limit: Long): Result<MatrixSearchUserResults> =
withContext(sessionDispatcher) { withContext(sessionDispatcher) {
runCatching { runCatching {
@ -471,18 +491,6 @@ class RustMatrixClient(
} }
} }
override suspend fun loadUserDisplayName(): Result<String> = withContext(sessionDispatcher) {
runCatching {
client.displayName()
}
}
override suspend fun loadUserAvatarUrl(): Result<String?> = withContext(sessionDispatcher) {
runCatching {
client.avatarUrl()
}
}
override suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String> = withContext(sessionDispatcher) { override suspend fun uploadMedia(mimeType: String, data: ByteArray, progressCallback: ProgressCallback?): Result<String> = withContext(sessionDispatcher) {
runCatching { runCatching {
client.uploadMedia(mimeType, data, progressCallback?.toProgressWatcher()) client.uploadMedia(mimeType, data, progressCallback?.toProgressWatcher())

17
libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt

@ -48,14 +48,15 @@ import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
class FakeMatrixClient( class FakeMatrixClient(
override val sessionId: SessionId = A_SESSION_ID, override val sessionId: SessionId = A_SESSION_ID,
override val deviceId: String = "A_DEVICE_ID", override val deviceId: String = "A_DEVICE_ID",
override val sessionCoroutineScope: CoroutineScope = TestScope(), override val sessionCoroutineScope: CoroutineScope = TestScope(),
private val userDisplayName: Result<String> = Result.success(A_USER_NAME), private val userDisplayName: String? = A_USER_NAME,
private val userAvatarUrl: Result<String> = Result.success(AN_AVATAR_URL), private val userAvatarUrl: String? = AN_AVATAR_URL,
override val roomListService: RoomListService = FakeRoomListService(), override val roomListService: RoomListService = FakeRoomListService(),
override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(), override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(),
private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
@ -73,6 +74,8 @@ class FakeMatrixClient(
var removeAvatarCalled: Boolean = false var removeAvatarCalled: Boolean = false
private set private set
private val _userProfile: MutableStateFlow<MatrixUser> = MutableStateFlow(MatrixUser(sessionId, userDisplayName, userAvatarUrl))
override val userProfile: StateFlow<MatrixUser> = _userProfile
override val ignoredUsersFlow: MutableStateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()) override val ignoredUsersFlow: MutableStateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf())
private var ignoreUserResult: Result<Unit> = Result.success(Unit) private var ignoreUserResult: Result<Unit> = Result.success(Unit)
@ -140,12 +143,10 @@ class FakeMatrixClient(
override fun close() = Unit override fun close() = Unit
override suspend fun loadUserDisplayName(): Result<String> { override suspend fun getUserProfile(): Result<MatrixUser> = simulateLongTask {
return userDisplayName val result = getProfileResults[sessionId]?.getOrNull() ?: MatrixUser(sessionId, userDisplayName, userAvatarUrl)
} _userProfile.tryEmit(result)
return Result.success(result)
override suspend fun loadUserAvatarUrl(): Result<String?> {
return userAvatarUrl
} }
override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?> { override suspend fun getAccountManagementUrl(action: AccountManagementAction?): Result<String?> {

41
libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt

@ -292,23 +292,30 @@ class DefaultNotificationDrawerManager @Inject constructor(
eventsForSessions.forEach { (sessionId, notifiableEvents) -> eventsForSessions.forEach { (sessionId, notifiableEvents) ->
val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow() val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow()
val imageLoader = imageLoaderHolder.get(client) val imageLoader = imageLoaderHolder.get(client)
val currentUser = tryOrNull( val userFromCache = client.userProfile.value
onError = { Timber.tag(loggerTag.value).e(it, "Unable to retrieve info for user ${sessionId.value}") }, val currentUser = if (userFromCache.avatarUrl != null && userFromCache.displayName.isNullOrEmpty().not()) {
operation = { // We have an avatar and a display name, use it
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash userFromCache
val myUserDisplayName = client.loadUserDisplayName().getOrNull() ?: sessionId.value } else {
val userAvatarUrl = client.loadUserAvatarUrl().getOrNull() tryOrNull(
MatrixUser( onError = { Timber.tag(loggerTag.value).e(it, "Unable to retrieve info for user ${sessionId.value}") },
userId = sessionId, operation = {
displayName = myUserDisplayName, client.getUserProfile().getOrNull()
avatarUrl = userAvatarUrl ?.let {
) // displayName cannot be empty else NotificationCompat.MessagingStyle() will crash
} if (it.displayName.isNullOrEmpty()) {
) ?: MatrixUser( it.copy(displayName = sessionId.value)
userId = sessionId, } else {
displayName = sessionId.value, it
avatarUrl = null }
) }
}
) ?: MatrixUser(
userId = sessionId,
displayName = sessionId.value,
avatarUrl = null
)
}
notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents, imageLoader) notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents, imageLoader)
} }

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Day-3_4_null_11,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/ui_S_t[f.roomlist.impl_RoomListView_null_RoomListView-Night-3_5_null_11,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save