Merge pull request #7922 from element-hq/feature/bma/otherAccountUnreadCount

Show the unread count of the other accounts in the multi-account section
This commit is contained in:
Benoit Marty
2026-10-14 21:30:36 +02:00
committed by GitHub
12 changed files with 221 additions and 49 deletions
@@ -14,7 +14,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
@@ -34,17 +33,13 @@ import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.totalUnreadCountFlow
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Inject
class HomePresenter(
@@ -63,20 +58,13 @@ class HomePresenter(
) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@Composable
override fun present(): HomeState {
val coroutineScope = rememberCoroutineScope()
val matrixUser by client.userProfile.collectAsState()
val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) {
syncService.heartBeat
// Delay the heartbeat so the SDK has time to update the unread count before we read it.
.debounce(100.milliseconds)
.mapLatest { client.totalUnreadCount().getOrDefault(0).toInt() }
.collect { value = it }
}
val totalUnreadCount by remember { client.totalUnreadCountFlow() }
// The initial value is only used for the first composition, remember it to avoid calling the SDK on every recomposition.
.collectAsState(initial = remember { client.totalUnreadCount().getOrDefault(0).toInt() })
val currentUserAndNeighbors by remember {
combine(
client.userProfile,
@@ -6,6 +6,8 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.home.impl
import com.google.common.truth.Truth.assertThat
@@ -129,7 +131,10 @@ class HomePresenterTest {
var unreadCount = 0L
val syncService = FakeSyncService()
val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.success(unreadCount) }),
client = FakeMatrixClient(
syncService = syncService,
totalUnreadCountResult = { Result.success(unreadCount) },
),
syncService = syncService,
)
presenter.test {
@@ -146,7 +151,10 @@ class HomePresenterTest {
fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest {
val syncService = FakeSyncService()
val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }),
client = FakeMatrixClient(
syncService = syncService,
totalUnreadCountResult = { Result.failure(AN_EXCEPTION) },
),
syncService = syncService,
)
presenter.test {
@@ -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.root
import io.element.android.libraries.matrix.api.user.MatrixUser
/**
* A signed-in account which is not the current one.
*
* @param user the user of the account.
* @param unreadCount the total number of unread notifications for this account.
*/
data class OtherAccount(
val user: MatrixUser,
val unreadCount: Int,
)
@@ -30,6 +30,9 @@ import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.totalUnreadCountFlow
import io.element.android.libraries.matrix.api.user.toMatrixUser
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore
@@ -37,9 +40,15 @@ import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
@Inject
class PreferencesRootPresenter(
private val matrixClient: MatrixClient,
@@ -50,6 +59,7 @@ class PreferencesRootPresenter(
private val featureFlagService: FeatureFlagService,
private val locationService: LocationService,
private val sessionStore: SessionStore,
private val matrixClientProvider: MatrixClientProvider,
private val appPreferencesStore: AppPreferencesStore,
private val userStatusPresenter: Presenter<UserStatusState>,
private val preferencesAccountPresenter: Presenter<PreferencesAccountState>,
@@ -83,12 +93,20 @@ class PreferencesRootPresenter(
}.collectAsState(initial = Theme.System)
val otherSessions by remember {
sessionStore.sessionsFlow().map { list ->
list
.filter { it.userId != matrixClient.sessionId.value }
.map { it.toMatrixUser() }
.toImmutableList()
}
sessionStore.sessionsFlow()
.map { list -> list.filter { it.userId != matrixClient.sessionId.value } }
.flatMapLatest { sessions ->
if (sessions.isEmpty()) {
flowOf(persistentListOf())
} else {
combine(
sessions.map { sessionData ->
val user = sessionData.toMatrixUser()
unreadCountFlow(user.userId).map { unreadCount -> OtherAccount(user, unreadCount) }
}
) { it.toImmutableList() }
}
}
}.collectAsState(initial = persistentListOf())
val themeOption by remember {
@@ -156,4 +174,9 @@ class PreferencesRootPresenter(
eventSink = ::handleEvent,
)
}
private fun unreadCountFlow(sessionId: SessionId): Flow<Int> {
val client = matrixClientProvider.getOrNull(sessionId) ?: return flowOf(0)
return client.totalUnreadCountFlow()
}
}
@@ -28,7 +28,7 @@ data class PreferencesRootState(
val version: String,
val isMultiAccountEnabled: Boolean,
val isOtherAccountsSectionExpanded: Boolean,
val otherSessions: ImmutableList<MatrixUser>,
val otherSessions: ImmutableList<OtherAccount>,
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
val showLabsItem: Boolean,
@@ -29,7 +29,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
myUser = aMatrixUser(avatarUrl = "anAvatarUrl"),
version = "Version 1.1 (1)",
isMultiAccountEnabled = true,
otherSessions = aMatrixUserList().drop(1).take(1),
otherSessions = aMatrixUserList().drop(1).take(1).map { anOtherAccount(it) },
showAnalyticsSettings = true,
showLabsItem = true,
),
@@ -40,7 +40,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = aMatrixUserList().drop(1).take(3),
otherSessions = aMatrixUserList().drop(1).take(3).map { anOtherAccount(it) },
),
aPreferencesRootState(
showLabsItem = true,
@@ -55,6 +55,14 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
aPreferencesRootState(userStatusState = null),
// Location sharing not available
aPreferencesRootState(showLocationSettings = false),
// Other accounts with unread notifications
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = aMatrixUserList().drop(1).take(3).zip(listOf(3, 0, 120)) { user, unreadCount ->
anOtherAccount(user = user, unreadCount = unreadCount)
},
),
)
}
@@ -66,7 +74,7 @@ fun aPreferencesRootState(
isOtherAccountsSectionExpanded: Boolean = false,
theme: ThemeOption = ThemeOption.System,
availableThemeOptions: List<ThemeOption> = ThemeOption.entries,
otherSessions: List<MatrixUser> = emptyList(),
otherSessions: List<OtherAccount> = emptyList(),
showAnalyticsSettings: Boolean = false,
showDeveloperSettings: Boolean = false,
showLabsItem: Boolean = false,
@@ -91,3 +99,11 @@ fun aPreferencesRootState(
snackbarMessage = snackbarMessage,
eventSink = eventSink,
)
fun anOtherAccount(
user: MatrixUser = aMatrixUser(),
unreadCount: Int = 0,
) = OtherAccount(
user = user,
unreadCount = unreadCount,
)
@@ -43,6 +43,7 @@ import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.userstatus.UserStatusView
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage
import io.element.android.libraries.designsystem.atomic.atoms.CounterAtom
import io.element.android.libraries.designsystem.components.async.AsyncActionIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.avatar.AvatarRow
@@ -244,7 +245,7 @@ private fun ColumnScope.MultiAccountSection(
AvatarRow(
avatarDataList = state.otherSessions
.take(3)
.map { it.getAvatarData(AvatarSize.OtherAccountItem) }
.map { it.user.getAvatarData(AvatarSize.OtherAccountItem) }
.toImmutableList(),
avatarType = AvatarType.User,
lastOnTop = true,
@@ -275,16 +276,21 @@ private fun ColumnScope.MultiAccountSection(
thickness = 1.dp,
color = ElementTheme.colors.bgSubtleSecondary,
)
state.otherSessions.forEach { matrixUser ->
state.otherSessions.forEach { otherAccount ->
MatrixUserRow(
modifier = Modifier
.clickable {
onSwitchToSession(matrixUser.userId)
onSwitchToSession(otherAccount.user.userId)
}
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = matrixUser,
matrixUser = otherAccount.user,
avatarSize = AvatarSize.AccountItem,
verticalSpaceWidth = 16.dp,
trailingContent = if (otherAccount.unreadCount > 0) {
{ CounterAtom(count = otherAccount.unreadCount) }
} else {
null
},
)
}
AddAccountItem(onAddAccountClick)
@@ -24,13 +24,18 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
import io.element.android.libraries.matrix.api.MatrixClientProvider
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_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID_3
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore
@@ -236,16 +241,62 @@ class PreferencesRootPresenterTest {
assertThat(state.isMultiAccountEnabled).isTrue()
assertThat(state.otherSessions).hasSize(1)
assertThat(state.otherSessions[0]).isEqualTo(
MatrixUser(
userId = A_SESSION_ID_2,
displayName = "Bob",
avatarUrl = "avatarUrl",
avatarThumbnail = "avatarData",
OtherAccount(
user = MatrixUser(
userId = A_SESSION_ID_2,
displayName = "Bob",
avatarUrl = "avatarUrl",
avatarThumbnail = "avatarData",
),
unreadCount = 0,
)
)
}
}
@Test
fun `present - other accounts keep the session store order and get their unread count`() = runTest {
var bobUnreadCount = 3L
val bobSyncService = FakeSyncService()
val bobClient = FakeMatrixClient(
sessionId = A_SESSION_ID_2,
syncService = bobSyncService,
totalUnreadCountResult = { Result.success(bobUnreadCount) },
)
createPreferencesRootPresenter(
matrixClient = FakeMatrixClient(sessionId = A_SESSION_ID),
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = A_USER_ID_3.value),
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(sessionId = A_SESSION_ID_2.value),
)
),
matrixClientProvider = FakeMatrixClientProvider { sessionId ->
when (sessionId) {
A_SESSION_ID_2 -> Result.success(bobClient)
// Client for the third account is not in memory
else -> Result.failure(AN_EXCEPTION)
}
},
).test {
val state = consumeItemsUntilPredicate { it.otherSessions.isNotEmpty() }.last()
assertThat(state.otherSessions.map { it.user.userId to it.unreadCount }).containsExactly(
A_USER_ID_3 to 0,
A_SESSION_ID_2 to 3,
).inOrder()
// The unread count is refreshed on sync heartbeat
bobUnreadCount = 5
bobSyncService.emitHeartBeat()
val updatedState = consumeItemsUntilPredicate { state -> state.otherSessions.any { it.unreadCount == 5 } }.last()
assertThat(updatedState.otherSessions.map { it.user.userId to it.unreadCount }).containsExactly(
A_USER_ID_3 to 0,
A_SESSION_ID_2 to 5,
).inOrder()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - other accounts section is collapsed if the preference says so`() = runTest {
createPreferencesRootPresenter(
@@ -285,6 +336,7 @@ class PreferencesRootPresenterTest {
sessionStore: SessionStore = InMemorySessionStore(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
locationService: LocationService = FakeLocationService(isServiceAvailable = true),
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
) = PreferencesRootPresenter(
matrixClient = matrixClient,
analyticsService = FakeAnalyticsService(),
@@ -294,6 +346,7 @@ class PreferencesRootPresenterTest {
featureFlagService = featureFlagService,
locationService = locationService,
sessionStore = sessionStore,
matrixClientProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore,
userStatusPresenter = { aUserStatusState() },
preferencesAccountPresenter = { aPreferencesAccountState() },
@@ -78,9 +78,11 @@ class PreferencesRootViewTest : RobolectricTest() {
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
anOtherAccount(
user = aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
),
)
),
eventSink = eventsRecorder,
@@ -91,6 +93,28 @@ class PreferencesRootViewTest : RobolectricTest() {
}
}
@Test
fun `unread count of other session is displayed`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf(
anOtherAccount(
user = aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
),
unreadCount = 42,
)
),
eventSink = eventsRecorder,
),
)
onNodeWithText("42").assertExists()
}
@Test
fun `clicking on Switch accounts sends a ToggleOtherAccountsExpanded`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
@@ -99,9 +123,11 @@ class PreferencesRootViewTest : RobolectricTest() {
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = false,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
anOtherAccount(
user = aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
),
)
),
eventSink = eventsRecorder,
@@ -119,9 +145,11 @@ class PreferencesRootViewTest : RobolectricTest() {
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = false,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
anOtherAccount(
user = aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
),
)
),
eventSink = eventsRecorder,
@@ -141,9 +169,11 @@ class PreferencesRootViewTest : RobolectricTest() {
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
anOtherAccount(
user = aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
),
)
),
eventSink = eventsRecorder,
@@ -51,9 +51,15 @@ import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import java.util.Optional
import kotlin.time.Duration.Companion.milliseconds
/**
* Entry point for everything scoped to a single logged in session: the sub-services, room access, and account level operations.
@@ -523,3 +529,18 @@ fun MatrixClient.roomAliasFromName(name: String): RoomAlias? {
?.takeIf { MatrixPatterns.isRoomAlias(it) }
?.let { tryOrNull { RoomAlias(it) } }
}
/**
* Returns a flow of the total number of unread notifications across all rooms.
* The first value is emitted immediately, then the value is refreshed on each sync heartbeat.
* In case of error, 0 is emitted.
*/
@OptIn(FlowPreview::class)
fun MatrixClient.totalUnreadCountFlow(): Flow<Int> {
return syncService.heartBeat
// Delay the heartbeat so the SDK has time to update the unread count before we read it.
.debounce(100.milliseconds)
.onStart { emit(Unit) }
.map { totalUnreadCount().getOrDefault(0).toInt() }
.distinctUntilChanged()
}