Skip to content

Commit 9cc5cf9

Browse files
jmartinespbmarty
andauthored
Add total unread notification count to home screen (#7891)
* Add total unread notification count to home screen This will be calculated on each sync heartbeat (for now, using the state updates for the room list service). * Simplify `counter` parameter logic * Read the total unread count when the home screen is first displayed --------- Co-authored-by: Benoit Marty <benoitm@element.io>
1 parent 50aa723 commit 9cc5cf9

13 files changed

Lines changed: 121 additions & 14 deletions

File tree

‎appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt‎

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
1818
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
1919
import io.element.android.libraries.matrix.api.core.SessionId
2020
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
21-
import io.element.android.libraries.matrix.api.roomlist.RoomListService
21+
import io.element.android.libraries.matrix.api.sync.SyncService
2222
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
2323
import io.element.android.libraries.push.api.notifications.NotificationCleaner
2424
import io.element.android.libraries.ui.strings.CommonStrings
@@ -34,7 +34,7 @@ class LoggedInEventProcessor(
3434
@Assisted private val snackbarDispatcher: SnackbarDispatcher,
3535
private val roomMembershipObserver: RoomMembershipObserver,
3636
private val sessionId: SessionId,
37-
private val roomListService: RoomListService,
37+
private val syncService: SyncService,
3838
private val notificationCleaner: NotificationCleaner,
3939
private val dispatchers: CoroutineDispatchers
4040
) {
@@ -73,9 +73,8 @@ class LoggedInEventProcessor(
7373
}
7474
.launchIn(childScope)
7575

76-
// Use the room list service state as a 'heartbeat' update to check existing notifications
77-
roomListService.state
78-
.filter { it == RoomListService.State.Running }
76+
// Use the sync service heartbeat as a 'heartbeat' update to check existing notifications
77+
syncService.heartBeat
7978
.onEach {
8079
notificationCleaner.clearReadRoomsNotifications(sessionId)
8180
}

‎appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt‎

Lines changed: 8 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
1212
import io.element.android.libraries.matrix.api.core.SessionId
1313
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
1414
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
15-
import io.element.android.libraries.matrix.api.roomlist.RoomListService
1615
import io.element.android.libraries.matrix.test.A_ROOM_ID
1716
import io.element.android.libraries.matrix.test.A_SESSION_ID
18-
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
17+
import io.element.android.libraries.matrix.test.sync.FakeSyncService
1918
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
2019
import io.element.android.tests.testutils.lambda.lambdaRecorder
2120
import io.element.android.tests.testutils.lambda.value
@@ -155,24 +154,24 @@ class LoggedInEventProcessorTest {
155154
}
156155

157156
@Test
158-
fun `test notifications are cleared when room list service state is running`() = runTest {
157+
fun `test notifications are cleared when sync heartbeat is emitted`() = runTest {
159158
val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> }
160159
val snackbarDispatcher = SnackbarDispatcher()
161160
val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda)
162-
val roomListService = FakeRoomListService()
161+
val syncService = FakeSyncService()
163162

164163
val loggedInEventProcessor = createLoggedInEventProcessor(
165164
snackbarDispatcher = snackbarDispatcher,
166165
notificationCleaner = notificationCleaner,
167-
roomListService = roomListService,
166+
syncService = syncService,
168167
)
169168

170169
loggedInEventProcessor.observeEvents(backgroundScope)
171170

172171
testScheduler.runCurrent()
173172

174-
// Simulate the room list service state changing to Running
175-
roomListService.postState(RoomListService.State.Running)
173+
// Simulate a sync heartbeat
174+
syncService.emitHeartBeat()
176175

177176
testScheduler.runCurrent()
178177

@@ -184,13 +183,13 @@ class LoggedInEventProcessorTest {
184183
snackbarDispatcher: SnackbarDispatcher,
185184
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
186185
sessionId: SessionId = A_SESSION_ID,
187-
roomListService: FakeRoomListService = FakeRoomListService(),
186+
syncService: FakeSyncService = FakeSyncService(),
188187
notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(),
189188
) = LoggedInEventProcessor(
190189
snackbarDispatcher = snackbarDispatcher,
191190
roomMembershipObserver = roomMembershipObserver,
192191
sessionId = sessionId,
193-
roomListService = roomListService,
192+
syncService = syncService,
194193
notificationCleaner = notificationCleaner,
195194
dispatchers = testCoroutineDispatchers(),
196195
)

‎features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import androidx.compose.runtime.collectAsState
1414
import androidx.compose.runtime.derivedStateOf
1515
import androidx.compose.runtime.getValue
1616
import androidx.compose.runtime.mutableIntStateOf
17+
import androidx.compose.runtime.produceState
1718
import androidx.compose.runtime.remember
1819
import androidx.compose.runtime.rememberCoroutineScope
1920
import androidx.compose.runtime.saveable.rememberSaveable
@@ -34,9 +35,14 @@ import io.element.android.libraries.matrix.api.MatrixClient
3435
import io.element.android.libraries.matrix.api.sync.SyncService
3536
import io.element.android.libraries.sessionstorage.api.SessionStore
3637
import kotlinx.collections.immutable.persistentListOf
38+
import kotlinx.coroutines.ExperimentalCoroutinesApi
39+
import kotlinx.coroutines.FlowPreview
3740
import kotlinx.coroutines.flow.combine
41+
import kotlinx.coroutines.flow.debounce
3842
import kotlinx.coroutines.flow.filter
43+
import kotlinx.coroutines.flow.mapLatest
3944
import kotlinx.coroutines.launch
45+
import kotlin.time.Duration.Companion.milliseconds
4046

4147
@Inject
4248
class HomePresenter(
@@ -53,10 +59,20 @@ class HomePresenter(
5359
) : Presenter<HomeState> {
5460
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
5561

62+
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
5663
@Composable
5764
override fun present(): HomeState {
5865
val coroutineState = rememberCoroutineScope()
5966
val matrixUser by client.userProfile.collectAsState()
67+
68+
val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) {
69+
syncService.heartBeat
70+
// Delay the heartbeat so the SDK has time to update the unread count before we read it.
71+
.debounce(100.milliseconds)
72+
.mapLatest { client.totalUnreadCount().getOrDefault(0).toInt() }
73+
.collect { value = it }
74+
}
75+
6076
val currentUserAndNeighbors by remember {
6177
combine(
6278
client.userProfile,
@@ -130,6 +146,7 @@ class HomePresenter(
130146
snackbarMessage = snackbarMessage,
131147
canReportBug = canReportBug,
132148
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
149+
totalUnreadCount = totalUnreadCount,
133150
eventSink = ::handleEvent,
134151
)
135152
}

‎features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ data class HomeState(
2929
val snackbarMessage: SnackbarMessage?,
3030
val canReportBug: Boolean,
3131
val showMultiAccountAnnouncement: Boolean,
32+
val totalUnreadCount: Int,
3233
val eventSink: (HomeEvent) -> Unit,
3334
) {
3435
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected

‎features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ internal fun aHomeState(
6464
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
6565
canReportBug: Boolean = true,
6666
showMultiAccountAnnouncement: Boolean = false,
67+
totalUnreadCount: Int = 0,
6768
eventSink: (HomeEvent) -> Unit = {}
6869
) = HomeState(
6970
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
@@ -75,5 +76,6 @@ internal fun aHomeState(
7576
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
7677
roomListState = roomListState,
7778
homeSpacesState = homeSpacesState,
79+
totalUnreadCount = totalUnreadCount,
7880
eventSink = eventSink,
7981
)

‎features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ private fun HomeScaffold(
259259
}
260260
}
261261
},
262+
totalUnreadCount = state.totalUnreadCount,
262263
)
263264
},
264265
floatingActionButtonPosition = FabPosition.Center,
@@ -341,6 +342,7 @@ private fun HomeFloatingActionButton(
341342

342343
@Composable
343344
private fun HomeBottomBar(
345+
totalUnreadCount: Int,
344346
currentHomeNavigationBarItem: HomeNavigationBarItem,
345347
onItemClick: (HomeNavigationBarItem) -> Unit,
346348
modifier: Modifier = Modifier,
@@ -361,6 +363,7 @@ private fun HomeBottomBar(
361363
tooltipLabel = stringResource(item.labelRes),
362364
isSelected = isSelected,
363365
onClick = { onItemClick(item) },
366+
counter = totalUnreadCount.takeIf { item == HomeNavigationBarItem.Chats && it > 0 }
364367
)
365368
}
366369
}

‎features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,11 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
4040
import io.element.android.tests.testutils.lambda.value
4141
import io.element.android.tests.testutils.test
4242
import kotlinx.coroutines.flow.flowOf
43+
import kotlinx.coroutines.test.advanceTimeBy
4344
import kotlinx.coroutines.test.runTest
4445
import org.junit.Rule
4546
import org.junit.Test
47+
import kotlin.time.Duration.Companion.milliseconds
4648

4749
class HomePresenterTest {
4850
@get:Rule
@@ -117,6 +119,38 @@ class HomePresenterTest {
117119
}
118120
}
119121

122+
@Test
123+
fun `present - total unread count is updated on sync heartbeat`() = runTest {
124+
var unreadCount = 0L
125+
val syncService = FakeSyncService()
126+
val presenter = createHomePresenter(
127+
client = FakeMatrixClient(totalUnreadCountResult = { Result.success(unreadCount) }),
128+
syncService = syncService,
129+
)
130+
presenter.test {
131+
assertThat(awaitItem().totalUnreadCount).isEqualTo(0)
132+
unreadCount = 5
133+
syncService.emitHeartBeat()
134+
advanceTimeBy(200.milliseconds)
135+
assertThat(awaitItem().totalUnreadCount).isEqualTo(5)
136+
}
137+
}
138+
139+
@Test
140+
fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest {
141+
val syncService = FakeSyncService()
142+
val presenter = createHomePresenter(
143+
client = FakeMatrixClient(totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }),
144+
syncService = syncService,
145+
)
146+
presenter.test {
147+
assertThat(awaitItem().totalUnreadCount).isEqualTo(0)
148+
syncService.emitHeartBeat()
149+
advanceTimeBy(200.milliseconds)
150+
expectNoEvents()
151+
}
152+
}
153+
120154
@Test
121155
fun `present - should start with no user and then load user with error`() = runTest {
122156
val matrixClient = FakeMatrixClient(
@@ -245,6 +279,16 @@ class HomePresenterTest {
245279
.with(value(Announcement.MultiAccount))
246280
}
247281
}
282+
283+
@Test
284+
fun `present - total unread count is loaded in the initial state`() = runTest {
285+
val presenter = createHomePresenter(
286+
client = FakeMatrixClient(totalUnreadCountResult = { Result.success(5) }),
287+
)
288+
presenter.test {
289+
assertThat(awaitItem().totalUnreadCount).isEqualTo(5)
290+
}
291+
}
248292
}
249293

250294
internal fun createHomePresenter(

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -505,6 +505,12 @@ interface MatrixClient : ClientUrlContentFetcher {
505505

506506
/** Returns a provider for the capabilities the homeserver advertises, such as whether the display name can be changed. */
507507
fun homeserverCapabilities(): HomeserverCapabilitiesProvider
508+
509+
/**
510+
* Returns the total number of unread notifications across all rooms, as currently known by the client.
511+
* This is a snapshot and may be out of date if the sync is running.
512+
*/
513+
fun totalUnreadCount(): Result<Long>
508514
}
509515

510516
/**

‎libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
package io.element.android.libraries.matrix.api.sync
1010

11+
import kotlinx.coroutines.flow.Flow
1112
import kotlinx.coroutines.flow.StateFlow
1213

1314
/**
@@ -37,4 +38,10 @@ interface SyncService {
3738
* This is `false` only while the state is [SyncState.Offline]; an idle or errored sync still counts as online.
3839
*/
3940
val isOnline: StateFlow<Boolean>
41+
42+
/**
43+
* Flow that will emit every time the sync has received a response from the server, even if it was empty.
44+
* Useful for triggering UI updates when the sync is running.
45+
*/
46+
val heartBeat: Flow<Unit>
4047
}

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1024,6 +1024,10 @@ class RustMatrixClient(
10241024
return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities())
10251025
}
10261026

1027+
override fun totalUnreadCount(): Result<Long> = runCatchingExceptions {
1028+
innerClient.totalUnreadNotifications().toLong()
1029+
}
1030+
10271031
private fun scheduleBackgroundSearchBackfill() {
10281032
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch)
10291033
.distinctUntilChanged()

0 commit comments

Comments
 (0)