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.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable 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.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.sync.SyncService 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 io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Inject @Inject
class HomePresenter( class HomePresenter(
@@ -63,20 +58,13 @@ class HomePresenter(
) : Presenter<HomeState> { ) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@Composable @Composable
override fun present(): HomeState { override fun present(): HomeState {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val matrixUser by client.userProfile.collectAsState() val matrixUser by client.userProfile.collectAsState()
val totalUnreadCount by remember { client.totalUnreadCountFlow() }
val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) { // The initial value is only used for the first composition, remember it to avoid calling the SDK on every recomposition.
syncService.heartBeat .collectAsState(initial = remember { client.totalUnreadCount().getOrDefault(0).toInt() })
// 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 currentUserAndNeighbors by remember { val currentUserAndNeighbors by remember {
combine( combine(
client.userProfile, client.userProfile,
@@ -6,6 +6,8 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.home.impl package io.element.android.features.home.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
@@ -129,7 +131,10 @@ class HomePresenterTest {
var unreadCount = 0L var unreadCount = 0L
val syncService = FakeSyncService() val syncService = FakeSyncService()
val presenter = createHomePresenter( val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.success(unreadCount) }), client = FakeMatrixClient(
syncService = syncService,
totalUnreadCountResult = { Result.success(unreadCount) },
),
syncService = syncService, syncService = syncService,
) )
presenter.test { presenter.test {
@@ -146,7 +151,10 @@ class HomePresenterTest {
fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest { fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest {
val syncService = FakeSyncService() val syncService = FakeSyncService()
val presenter = createHomePresenter( val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }), client = FakeMatrixClient(
syncService = syncService,
totalUnreadCountResult = { Result.failure(AN_EXCEPTION) },
),
syncService = syncService, syncService = syncService,
) )
presenter.test { 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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.MatrixClient 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.matrix.api.user.toMatrixUser
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore 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.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope 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.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class)
@Inject @Inject
class PreferencesRootPresenter( class PreferencesRootPresenter(
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
@@ -50,6 +59,7 @@ class PreferencesRootPresenter(
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val locationService: LocationService, private val locationService: LocationService,
private val sessionStore: SessionStore, private val sessionStore: SessionStore,
private val matrixClientProvider: MatrixClientProvider,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val userStatusPresenter: Presenter<UserStatusState>, private val userStatusPresenter: Presenter<UserStatusState>,
private val preferencesAccountPresenter: Presenter<PreferencesAccountState>, private val preferencesAccountPresenter: Presenter<PreferencesAccountState>,
@@ -83,12 +93,20 @@ class PreferencesRootPresenter(
}.collectAsState(initial = Theme.System) }.collectAsState(initial = Theme.System)
val otherSessions by remember { val otherSessions by remember {
sessionStore.sessionsFlow().map { list -> sessionStore.sessionsFlow()
list .map { list -> list.filter { it.userId != matrixClient.sessionId.value } }
.filter { it.userId != matrixClient.sessionId.value } .flatMapLatest { sessions ->
.map { it.toMatrixUser() } if (sessions.isEmpty()) {
.toImmutableList() flowOf(persistentListOf())
} } else {
combine(
sessions.map { sessionData ->
val user = sessionData.toMatrixUser()
unreadCountFlow(user.userId).map { unreadCount -> OtherAccount(user, unreadCount) }
}
) { it.toImmutableList() }
}
}
}.collectAsState(initial = persistentListOf()) }.collectAsState(initial = persistentListOf())
val themeOption by remember { val themeOption by remember {
@@ -156,4 +174,9 @@ class PreferencesRootPresenter(
eventSink = ::handleEvent, 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 version: String,
val isMultiAccountEnabled: Boolean, val isMultiAccountEnabled: Boolean,
val isOtherAccountsSectionExpanded: Boolean, val isOtherAccountsSectionExpanded: Boolean,
val otherSessions: ImmutableList<MatrixUser>, val otherSessions: ImmutableList<OtherAccount>,
val showAnalyticsSettings: Boolean, val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean, val showDeveloperSettings: Boolean,
val showLabsItem: Boolean, val showLabsItem: Boolean,
@@ -29,7 +29,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
myUser = aMatrixUser(avatarUrl = "anAvatarUrl"), myUser = aMatrixUser(avatarUrl = "anAvatarUrl"),
version = "Version 1.1 (1)", version = "Version 1.1 (1)",
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
otherSessions = aMatrixUserList().drop(1).take(1), otherSessions = aMatrixUserList().drop(1).take(1).map { anOtherAccount(it) },
showAnalyticsSettings = true, showAnalyticsSettings = true,
showLabsItem = true, showLabsItem = true,
), ),
@@ -40,7 +40,7 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
aPreferencesRootState( aPreferencesRootState(
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true, isOtherAccountsSectionExpanded = true,
otherSessions = aMatrixUserList().drop(1).take(3), otherSessions = aMatrixUserList().drop(1).take(3).map { anOtherAccount(it) },
), ),
aPreferencesRootState( aPreferencesRootState(
showLabsItem = true, showLabsItem = true,
@@ -55,6 +55,14 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
aPreferencesRootState(userStatusState = null), aPreferencesRootState(userStatusState = null),
// Location sharing not available // Location sharing not available
aPreferencesRootState(showLocationSettings = false), 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, isOtherAccountsSectionExpanded: Boolean = false,
theme: ThemeOption = ThemeOption.System, theme: ThemeOption = ThemeOption.System,
availableThemeOptions: List<ThemeOption> = ThemeOption.entries, availableThemeOptions: List<ThemeOption> = ThemeOption.entries,
otherSessions: List<MatrixUser> = emptyList(), otherSessions: List<OtherAccount> = emptyList(),
showAnalyticsSettings: Boolean = false, showAnalyticsSettings: Boolean = false,
showDeveloperSettings: Boolean = false, showDeveloperSettings: Boolean = false,
showLabsItem: Boolean = false, showLabsItem: Boolean = false,
@@ -91,3 +99,11 @@ fun aPreferencesRootState(
snackbarMessage = snackbarMessage, snackbarMessage = snackbarMessage,
eventSink = eventSink, 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.features.preferences.impl.userstatus.UserStatusView
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage 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.AsyncActionIndicator
import io.element.android.libraries.designsystem.components.async.AsyncIndicator import io.element.android.libraries.designsystem.components.async.AsyncIndicator
import io.element.android.libraries.designsystem.components.avatar.AvatarRow import io.element.android.libraries.designsystem.components.avatar.AvatarRow
@@ -244,7 +245,7 @@ private fun ColumnScope.MultiAccountSection(
AvatarRow( AvatarRow(
avatarDataList = state.otherSessions avatarDataList = state.otherSessions
.take(3) .take(3)
.map { it.getAvatarData(AvatarSize.OtherAccountItem) } .map { it.user.getAvatarData(AvatarSize.OtherAccountItem) }
.toImmutableList(), .toImmutableList(),
avatarType = AvatarType.User, avatarType = AvatarType.User,
lastOnTop = true, lastOnTop = true,
@@ -275,16 +276,21 @@ private fun ColumnScope.MultiAccountSection(
thickness = 1.dp, thickness = 1.dp,
color = ElementTheme.colors.bgSubtleSecondary, color = ElementTheme.colors.bgSubtleSecondary,
) )
state.otherSessions.forEach { matrixUser -> state.otherSessions.forEach { otherAccount ->
MatrixUserRow( MatrixUserRow(
modifier = Modifier modifier = Modifier
.clickable { .clickable {
onSwitchToSession(matrixUser.userId) onSwitchToSession(otherAccount.user.userId)
} }
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp), .padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = matrixUser, matrixUser = otherAccount.user,
avatarSize = AvatarSize.AccountItem, avatarSize = AvatarSize.AccountItem,
verticalSpaceWidth = 16.dp, verticalSpaceWidth = 16.dp,
trailingContent = if (otherAccount.unreadCount > 0) {
{ CounterAtom(count = otherAccount.unreadCount) }
} else {
null
},
) )
} }
AddAccountItem(onAddAccountClick) 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.api.FeatureFlags
import io.element.android.libraries.featureflag.test.FakeFeature import io.element.android.libraries.featureflag.test.FakeFeature
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService 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.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.A_SESSION_ID 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_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.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient 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.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.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.api.SessionStore
@@ -236,16 +241,62 @@ class PreferencesRootPresenterTest {
assertThat(state.isMultiAccountEnabled).isTrue() assertThat(state.isMultiAccountEnabled).isTrue()
assertThat(state.otherSessions).hasSize(1) assertThat(state.otherSessions).hasSize(1)
assertThat(state.otherSessions[0]).isEqualTo( assertThat(state.otherSessions[0]).isEqualTo(
MatrixUser( OtherAccount(
userId = A_SESSION_ID_2, user = MatrixUser(
displayName = "Bob", userId = A_SESSION_ID_2,
avatarUrl = "avatarUrl", displayName = "Bob",
avatarThumbnail = "avatarData", 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 @Test
fun `present - other accounts section is collapsed if the preference says so`() = runTest { fun `present - other accounts section is collapsed if the preference says so`() = runTest {
createPreferencesRootPresenter( createPreferencesRootPresenter(
@@ -285,6 +336,7 @@ class PreferencesRootPresenterTest {
sessionStore: SessionStore = InMemorySessionStore(), sessionStore: SessionStore = InMemorySessionStore(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
locationService: LocationService = FakeLocationService(isServiceAvailable = true), locationService: LocationService = FakeLocationService(isServiceAvailable = true),
matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
) = PreferencesRootPresenter( ) = PreferencesRootPresenter(
matrixClient = matrixClient, matrixClient = matrixClient,
analyticsService = FakeAnalyticsService(), analyticsService = FakeAnalyticsService(),
@@ -294,6 +346,7 @@ class PreferencesRootPresenterTest {
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
locationService = locationService, locationService = locationService,
sessionStore = sessionStore, sessionStore = sessionStore,
matrixClientProvider = matrixClientProvider,
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
userStatusPresenter = { aUserStatusState() }, userStatusPresenter = { aUserStatusState() },
preferencesAccountPresenter = { aPreferencesAccountState() }, preferencesAccountPresenter = { aPreferencesAccountState() },
@@ -78,9 +78,11 @@ class PreferencesRootViewTest : RobolectricTest() {
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true, isOtherAccountsSectionExpanded = true,
otherSessions = listOf( otherSessions = listOf(
aMatrixUser( anOtherAccount(
id = A_USER_ID_2.value, user = aMatrixUser(
displayName = "Bob", id = A_USER_ID_2.value,
displayName = "Bob",
),
) )
), ),
eventSink = eventsRecorder, 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 @Test
fun `clicking on Switch accounts sends a ToggleOtherAccountsExpanded`() = runAndroidComposeUiTest { fun `clicking on Switch accounts sends a ToggleOtherAccountsExpanded`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>() val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
@@ -99,9 +123,11 @@ class PreferencesRootViewTest : RobolectricTest() {
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = false, isOtherAccountsSectionExpanded = false,
otherSessions = listOf( otherSessions = listOf(
aMatrixUser( anOtherAccount(
id = A_USER_ID_2.value, user = aMatrixUser(
displayName = "Bob", id = A_USER_ID_2.value,
displayName = "Bob",
),
) )
), ),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@@ -119,9 +145,11 @@ class PreferencesRootViewTest : RobolectricTest() {
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = false, isOtherAccountsSectionExpanded = false,
otherSessions = listOf( otherSessions = listOf(
aMatrixUser( anOtherAccount(
id = A_USER_ID_2.value, user = aMatrixUser(
displayName = "Bob", id = A_USER_ID_2.value,
displayName = "Bob",
),
) )
), ),
eventSink = eventsRecorder, eventSink = eventsRecorder,
@@ -141,9 +169,11 @@ class PreferencesRootViewTest : RobolectricTest() {
isMultiAccountEnabled = true, isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true, isOtherAccountsSectionExpanded = true,
otherSessions = listOf( otherSessions = listOf(
aMatrixUser( anOtherAccount(
id = A_USER_ID_2.value, user = aMatrixUser(
displayName = "Bob", id = A_USER_ID_2.value,
displayName = "Bob",
),
) )
), ),
eventSink = eventsRecorder, 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 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
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow 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 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. * 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) } ?.takeIf { MatrixPatterns.isRoomAlias(it) }
?.let { tryOrNull { RoomAlias(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()
}