Show badges for new invites (#355)

Show badges for new invites

Closes #238
This commit is contained in:
Chris Smith 2023-05-04 16:30:55 +01:00 committed by GitHub
parent c103ed549b
commit ee909fcbd8
49 changed files with 744 additions and 153 deletions

View file

@ -0,0 +1,71 @@
/*
* 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.features.roomlist.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.invitelist.api.SeenInvitesStore
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomSummary
import kotlinx.coroutines.withContext
import javax.inject.Inject
@ContributesBinding(SessionScope::class)
class DefaultInviteStateDataSource @Inject constructor(
private val client: MatrixClient,
private val seenInvitesStore: SeenInvitesStore,
private val coroutineDispatchers: CoroutineDispatchers,
) : InviteStateDataSource {
@Composable
override fun inviteState(): InvitesState {
val invites by client
.invitesDataSource
.roomSummaries()
.collectAsState()
val seenInvites by seenInvitesStore
.seenRoomIds()
.collectAsState(initial = emptySet())
var state by remember { mutableStateOf(InvitesState.NoInvites) }
LaunchedEffect(invites, seenInvites) {
withContext(coroutineDispatchers.computation) {
state = when {
invites.isEmpty() -> InvitesState.NoInvites
seenInvites.containsAll(invites.roomIds) -> InvitesState.SeenInvites
else -> InvitesState.NewInvites
}
}
}
return state
}
}
private val List<RoomSummary>.roomIds: Collection<RoomId>
get() = filterIsInstance<RoomSummary.Filled>().map { it.details.roomId }

View file

@ -0,0 +1,26 @@
/*
* 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.features.roomlist.impl
import androidx.compose.runtime.Composable
interface InviteStateDataSource {
@Composable
fun inviteState(): InvitesState
}

View file

@ -61,6 +61,7 @@ class RoomListPresenter @Inject constructor(
private val sessionVerificationService: SessionVerificationService,
private val networkMonitor: NetworkMonitor,
private val snackbarDispatcher: SnackbarDispatcher,
private val inviteStateDataSource: InviteStateDataSource,
) : Presenter<RoomListState> {
@Composable
@ -86,13 +87,6 @@ class RoomListPresenter @Inject constructor(
initialLoad(matrixUser)
}
val invites by client
.invitesDataSource
.roomSummaries()
.collectAsState()
Timber.v("Invites size = ${invites.size}")
// Session verification status (unknown, not verified, verified)
val sessionVerifiedStatus by sessionVerificationService.sessionVerifiedStatus.collectAsState()
var verificationPromptDismissed by rememberSaveable { mutableStateOf(false) }
@ -112,7 +106,7 @@ class RoomListPresenter @Inject constructor(
if (displaySearchResults) {
filter = ""
}
displaySearchResults =! displaySearchResults
displaySearchResults = !displaySearchResults
}
}
}
@ -136,7 +130,7 @@ class RoomListPresenter @Inject constructor(
displayVerificationPrompt = displayVerificationPrompt,
snackbarMessage = snackbarMessage,
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
displayInvites = invites.isNotEmpty(),
invitesState = inviteStateDataSource.inviteState(),
displaySearchResults = displaySearchResults,
eventSink = ::handleEvents
)

View file

@ -31,7 +31,13 @@ data class RoomListState(
val displayVerificationPrompt: Boolean,
val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?,
val displayInvites: Boolean,
val invitesState: InvitesState,
val displaySearchResults: Boolean,
val eventSink: (RoomListEvents) -> Unit
)
enum class InvitesState {
NoInvites,
SeenInvites,
NewInvites,
}

View file

@ -35,7 +35,8 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState().copy(displayVerificationPrompt = true),
aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)),
aRoomListState().copy(hasNetworkConnection = false),
aRoomListState().copy(displayInvites = true),
aRoomListState().copy(invitesState = InvitesState.SeenInvites),
aRoomListState().copy(invitesState = InvitesState.NewInvites),
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
aRoomListState().copy(displaySearchResults = true),
)
@ -49,7 +50,7 @@ internal fun aRoomListState() = RoomListState(
hasNetworkConnection = true,
snackbarMessage = null,
displayVerificationPrompt = false,
displayInvites = false,
invitesState = InvitesState.NoInvites,
displaySearchResults = false,
eventSink = {}
)

View file

@ -206,19 +206,23 @@ fun RoomListContent(
}
}
if (state.displayInvites) {
if (state.invitesState != InvitesState.NoInvites) {
item {
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxSize()) {
TextButton(
content = {
Text(stringResource(StringR.string.action_invites_list))
Spacer(Modifier.size(8.dp))
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(MaterialTheme.roomListUnreadIndicator())
)
if (state.invitesState == InvitesState.NewInvites) {
Spacer(Modifier.size(8.dp))
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(MaterialTheme.roomListUnreadIndicator())
)
}
},
onClick = onInvitesClicked,
)

View file

@ -0,0 +1,137 @@
/*
* 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.features.roomlist.impl
import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth
import io.element.android.features.invitelist.test.FakeSeenInvitesStore
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
internal class DefaultInviteStateDataSourceTest {
@Test
fun `emits NoInvites state if invites list is empty`() = runTest {
val matrixDataSource = FakeRoomSummaryDataSource()
val client = FakeMatrixClient(invitesDataSource = matrixDataSource)
val seenStore = FakeSeenInvitesStore()
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
moleculeFlow(RecompositionClock.Immediate) {
dataSource.inviteState()
}.test {
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
}
}
@Test
fun `emits NewInvites state if unseen invite exists`() = runTest {
val matrixDataSource = FakeRoomSummaryDataSource()
matrixDataSource.postRoomSummary(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID)))
val client = FakeMatrixClient(invitesDataSource = matrixDataSource)
val seenStore = FakeSeenInvitesStore()
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
moleculeFlow(RecompositionClock.Immediate) {
dataSource.inviteState()
}.test {
skipItems(1)
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
}
}
@Test
fun `emits NewInvites state if multiple invites exist and at least one is unseen`() = runTest {
val matrixDataSource = FakeRoomSummaryDataSource()
matrixDataSource.postRoomSummary(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2)))
val client = FakeMatrixClient(invitesDataSource = matrixDataSource)
val seenStore = FakeSeenInvitesStore()
seenStore.publishRoomIds(setOf(A_ROOM_ID))
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
moleculeFlow(RecompositionClock.Immediate) {
dataSource.inviteState()
}.test {
skipItems(1)
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
}
}
@Test
fun `emits SeenInvites state if invite exists in seen store`() = runTest {
val matrixDataSource = FakeRoomSummaryDataSource()
matrixDataSource.postRoomSummary(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID)))
val client = FakeMatrixClient(invitesDataSource = matrixDataSource)
val seenStore = FakeSeenInvitesStore()
seenStore.publishRoomIds(setOf(A_ROOM_ID))
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
moleculeFlow(RecompositionClock.Immediate) {
dataSource.inviteState()
}.test {
skipItems(1)
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.SeenInvites)
}
}
@Test
fun `emits new state in response to upstream events`() = runTest {
val matrixDataSource = FakeRoomSummaryDataSource()
val client = FakeMatrixClient(invitesDataSource = matrixDataSource)
val seenStore = FakeSeenInvitesStore()
val dataSource = DefaultInviteStateDataSource(client, seenStore, testCoroutineDispatchers())
moleculeFlow(RecompositionClock.Immediate) {
dataSource.inviteState()
}.test {
// Initially there are no invites
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
// When a single invite is received, state should be NewInvites
matrixDataSource.postRoomSummary(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID)))
skipItems(1)
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
// If that invite is marked as seen, then the state becomes SeenInvites
seenStore.publishRoomIds(setOf(A_ROOM_ID))
skipItems(1)
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.SeenInvites)
// Another new invite resets it to NewInvites
matrixDataSource.postRoomSummary(listOf(aRoomSummaryFilled(roomId = A_ROOM_ID), aRoomSummaryFilled(roomId = A_ROOM_ID_2)))
skipItems(1)
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NewInvites)
// All of the invites going away reverts to NoInvites
matrixDataSource.postRoomSummary(emptyList())
skipItems(1)
Truth.assertThat(awaitItem()).isEqualTo(InvitesState.NoInvites)
}
}
}

View file

@ -0,0 +1,33 @@
/*
* 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.features.roomlist.impl
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
class FakeInviteDataSource(
private val flow: Flow<InvitesState> = flowOf()
) : InviteStateDataSource {
@Composable
override fun inviteState(): InvitesState {
val state = flow.collectAsState(initial = InvitesState.NoInvites)
return state.value
}
}

View file

@ -38,10 +38,12 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest
import org.junit.Test
class RoomListPresenterTests {
@OptIn(ExperimentalCoroutinesApi::class) class RoomListPresenterTests {
@Test
fun `present - should start with no user and then load user with success`() = runTest {
@ -52,6 +54,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -79,6 +82,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -99,6 +103,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -127,6 +132,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -158,6 +164,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -195,6 +202,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -245,6 +253,7 @@ class RoomListPresenterTests {
},
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -258,31 +267,34 @@ class RoomListPresenterTests {
}
@Test
fun `present - displays invites row if any invites exist`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource()
fun `present - sets invite state`() = runTest {
val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
val presenter = RoomListPresenter(
FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource
),
createDateFormatter(),
FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(inviteStateFlow),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
skipItems(1)
Truth.assertThat(awaitItem().displayInvites).isFalse()
Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites)
invitesDataSource.postRoomSummary(listOf(aRoomSummaryFilled()))
Truth.assertThat(awaitItem().displayInvites).isTrue()
inviteStateFlow.value = InvitesState.SeenInvites
Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.SeenInvites)
invitesDataSource.postRoomSummary(listOf())
Truth.assertThat(awaitItem().displayInvites).isFalse()
inviteStateFlow.value = InvitesState.NewInvites
Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NewInvites)
inviteStateFlow.value = InvitesState.NoInvites
Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites)
}
}