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 6447e4fe36
commit 2171f1a8ab
49 changed files with 744 additions and 153 deletions

1
changelog.d/238.feature Normal file
View file

@ -0,0 +1 @@
[Create and join rooms] New invites are now marked with a badge

View file

@ -0,0 +1,25 @@
/*
* 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.invitelist.api
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
interface SeenInvitesStore {
fun seenRoomIds(): Flow<Set<RoomId>>
suspend fun markAsSeen(roomIds: Set<RoomId>)
}

View file

@ -35,6 +35,7 @@ dependencies {
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
anvil(projects.anvilcodegen) anvil(projects.anvilcodegen)
api(projects.features.invitelist.api) api(projects.features.invitelist.api)
implementation(libs.androidx.datastore.preferences)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
@ -48,6 +49,7 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.invitelist.test)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -0,0 +1,59 @@
/*
* 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.invitelist.impl
import android.content.Context
import androidx.datastore.core.DataStore
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.invitelist.api.SeenInvitesStore
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private val Context.dataStore: DataStore<Preferences> by preferencesDataStore(name = "elementx_seeninvites")
private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
@ContributesBinding(SessionScope::class)
class DefaultSeenInvitesStore @Inject constructor(
@ApplicationContext context: Context
) : SeenInvitesStore {
private val store = context.dataStore
override fun seenRoomIds(): Flow<Set<RoomId>> =
store.data.map { prefs ->
prefs[seenInvitesKey]
.orEmpty()
.map { RoomId(it) }
.toSet()
}
override suspend fun markAsSeen(roomIds: Set<RoomId>) {
store.edit { prefs ->
prefs[seenInvitesKey] = roomIds.map { it.value }.toSet()
}
}
}

View file

@ -17,12 +17,15 @@
package io.element.android.features.invitelist.impl package io.element.android.features.invitelist.impl
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import io.element.android.features.invitelist.api.SeenInvitesStore
import io.element.android.features.invitelist.impl.model.InviteListInviteSummary import io.element.android.features.invitelist.impl.model.InviteListInviteSummary
import io.element.android.features.invitelist.impl.model.InviteSender import io.element.android.features.invitelist.impl.model.InviteSender
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
@ -34,11 +37,13 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomSummary import io.element.android.libraries.matrix.api.room.RoomSummary
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class InviteListPresenter @Inject constructor( class InviteListPresenter @Inject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val store: SeenInvitesStore,
) : Presenter<InviteListState> { ) : Presenter<InviteListState> {
@Composable @Composable
@ -48,6 +53,21 @@ class InviteListPresenter @Inject constructor(
.roomSummaries() .roomSummaries()
.collectAsState() .collectAsState()
var seenInvites by remember { mutableStateOf<Set<RoomId>>(emptySet()) }
LaunchedEffect(Unit) {
seenInvites = store.seenRoomIds().first()
}
LaunchedEffect(invites) {
store.markAsSeen(
invites
.filterIsInstance<RoomSummary.Filled>()
.map { it.details.roomId }
.toSet()
)
}
val localCoroutineScope = rememberCoroutineScope() val localCoroutineScope = rememberCoroutineScope()
val acceptedAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) } val acceptedAction: MutableState<Async<RoomId>> = remember { mutableStateOf(Async.Uninitialized) }
val declinedAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) } val declinedAction: MutableState<Async<Unit>> = remember { mutableStateOf(Async.Uninitialized) }
@ -86,8 +106,17 @@ class InviteListPresenter @Inject constructor(
} }
} }
val inviteList = remember(seenInvites, invites) {
invites
.filterIsInstance<RoomSummary.Filled>()
.map {
it.toInviteSummary(seenInvites.contains(it.details.roomId))
}
.toPersistentList()
}
return InviteListState( return InviteListState(
inviteList = invites.mapNotNull(::toInviteSummary).toPersistentList(), inviteList = inviteList,
declineConfirmationDialog = decliningInvite.value?.let { declineConfirmationDialog = decliningInvite.value?.let {
InviteDeclineConfirmationDialog.Visible( InviteDeclineConfirmationDialog.Visible(
isDirect = it.isDirect, isDirect = it.isDirect,
@ -113,49 +142,44 @@ class InviteListPresenter @Inject constructor(
}.execute(declinedAction) }.execute(declinedAction)
} }
private fun toInviteSummary(roomSummary: RoomSummary): InviteListInviteSummary? { private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run {
return when (roomSummary) { val i = inviter
is RoomSummary.Filled -> roomSummary.details.run { val avatarData = if (isDirect && i != null)
val i = inviter AvatarData(
val avatarData = if (isDirect && i != null) id = i.userId.value,
AvatarData( name = i.displayName,
id = i.userId.value, url = i.avatarUrl,
name = i.displayName, )
url = i.avatarUrl, else
) AvatarData(
else id = roomId.value,
AvatarData( name = name,
id = roomId.value, url = avatarURLString
name = name, )
url = avatarURLString
)
val alias = if (isDirect) val alias = if (isDirect)
inviter?.userId?.value inviter?.userId?.value
else else
canonicalAlias canonicalAlias
InviteListInviteSummary( InviteListInviteSummary(
roomId = roomId, roomId = roomId,
roomName = name, roomName = name,
roomAlias = alias, roomAlias = alias,
roomAvatarData = avatarData, roomAvatarData = avatarData,
isDirect = isDirect, isDirect = isDirect,
sender = if (isDirect) null else inviter?.let { isNew = !seen,
InviteSender( sender = if (isDirect) null else inviter?.run {
userId = it.userId, InviteSender(
displayName = it.displayName ?: "", userId = userId,
avatarData = AvatarData( displayName = displayName ?: "",
id = it.userId.value, avatarData = AvatarData(
name = it.displayName, id = userId.value,
url = it.avatarUrl, name = displayName,
), url = avatarUrl,
) ),
},
) )
} },
)
else -> null
}
} }
} }

View file

@ -16,6 +16,7 @@
package io.element.android.features.invitelist.impl.components package io.element.android.features.invitelist.impl.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
@ -27,13 +28,17 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.InlineTextContent import androidx.compose.foundation.text.InlineTextContent
import androidx.compose.foundation.text.appendInlineContent import androidx.compose.foundation.text.appendInlineContent
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.Placeholder import androidx.compose.ui.text.Placeholder
@ -59,6 +64,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListUnreadIndicator
import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.persistentMapOf
import io.element.android.libraries.ui.strings.R as StringR import io.element.android.libraries.ui.strings.R as StringR
@ -73,8 +79,8 @@ internal fun InviteSummaryRow(
) { ) {
Box( Box(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(min = minHeight) .heightIn(min = minHeight)
) { ) {
DefaultInviteSummaryRow( DefaultInviteSummaryRow(
invite = invite, invite = invite,
@ -92,19 +98,20 @@ internal fun DefaultInviteSummaryRow(
) { ) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp) .padding(horizontal = 16.dp, vertical = 12.dp)
.height(IntrinsicSize.Min), .height(IntrinsicSize.Min),
verticalAlignment = Alignment.Top verticalAlignment = Alignment.Top
) { ) {
Avatar( Avatar(
invite.roomAvatarData, invite.roomAvatarData,
) )
Column( Column(
modifier = Modifier modifier = Modifier
.padding(start = 12.dp, end = 4.dp) .padding(start = 12.dp, end = 4.dp)
.alignByBaseline() .alignByBaseline()
.weight(1f) .weight(1f)
) { ) {
// Name // Name
Text( Text(
@ -152,6 +159,15 @@ internal fun DefaultInviteSummaryRow(
) )
} }
} }
val unreadIndicatorColor = if (invite.isNew) MaterialTheme.roomListUnreadIndicator() else Color.Transparent
Box(
modifier = Modifier
.size(12.dp)
.clip(CircleShape)
.background(unreadIndicatorColor)
)
} }
} }
@ -183,7 +199,11 @@ private fun SenderRow(sender: InviteSender) {
Placeholder(20.dp.toSp(), 20.dp.toSp(), PlaceholderVerticalAlign.Center) Placeholder(20.dp.toSp(), 20.dp.toSp(), PlaceholderVerticalAlign.Center)
} }
) { ) {
Box(Modifier.fillMaxHeight().padding(end = 4.dp)) { Box(
Modifier
.fillMaxHeight()
.padding(end = 4.dp)
) {
Avatar( Avatar(
avatarData = sender.avatarData.copy(size = AvatarSize.Custom(16.dp)), avatarData = sender.avatarData.copy(size = AvatarSize.Custom(16.dp)),
modifier = Modifier.align(Alignment.Center) modifier = Modifier.align(Alignment.Center)

View file

@ -28,7 +28,8 @@ data class InviteListInviteSummary(
val roomAlias: String? = null, val roomAlias: String? = null,
val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName), val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName),
val sender: InviteSender? = null, val sender: InviteSender? = null,
val isDirect: Boolean = false val isDirect: Boolean = false,
val isNew: Boolean = false,
) )
data class InviteSender( data class InviteSender(

View file

@ -26,6 +26,7 @@ open class InviteListInviteSummaryProvider : PreviewParameterProvider<InviteList
aInviteListInviteSummary(), aInviteListInviteSummary(),
aInviteListInviteSummary().copy(roomAlias = "#someroom:example.com"), aInviteListInviteSummary().copy(roomAlias = "#someroom:example.com"),
aInviteListInviteSummary().copy(roomName = "Alice", sender = null), aInviteListInviteSummary().copy(roomName = "Alice", sender = null),
aInviteListInviteSummary().copy(isNew = true)
) )
} }

View file

@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionClock
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.invitelist.test.FakeSeenInvitesStore
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
@ -29,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomSummary
import io.element.android.libraries.matrix.api.room.RoomSummaryDetails import io.element.android.libraries.matrix.api.room.RoomSummaryDetails
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.A_ROOM_ID 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.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
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_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
@ -50,7 +52,8 @@ class InviteListPresenterTests {
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) ),
FakeSeenInvitesStore(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -58,21 +61,7 @@ class InviteListPresenterTests {
val initialState = awaitItem() val initialState = awaitItem()
Truth.assertThat(initialState.inviteList).isEmpty() Truth.assertThat(initialState.inviteList).isEmpty()
invitesDataSource.postRoomSummary( invitesDataSource.postRoomSummary(listOf(aRoomSummary()))
listOf(
RoomSummary.Filled(
RoomSummaryDetails(
roomId = A_ROOM_ID,
name = A_ROOM_NAME,
avatarURLString = null,
isDirect = false,
lastMessage = null,
lastMessageTimestamp = null,
unreadNotificationCount = 0,
)
)
)
)
val withInviteState = awaitItem() val withInviteState = awaitItem()
Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1) Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1)
@ -88,7 +77,8 @@ class InviteListPresenterTests {
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) ),
FakeSeenInvitesStore(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -117,7 +107,8 @@ class InviteListPresenterTests {
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) ),
FakeSeenInvitesStore(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -144,7 +135,8 @@ class InviteListPresenterTests {
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) ),
FakeSeenInvitesStore(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -169,7 +161,8 @@ class InviteListPresenterTests {
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) ),
FakeSeenInvitesStore(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -194,7 +187,8 @@ class InviteListPresenterTests {
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) ),
FakeSeenInvitesStore(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -219,7 +213,7 @@ class InviteListPresenterTests {
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client) val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -246,7 +240,7 @@ class InviteListPresenterTests {
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client) val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex)) room.givenRejectInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -278,7 +272,7 @@ class InviteListPresenterTests {
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client) val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex)) room.givenRejectInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -311,7 +305,7 @@ class InviteListPresenterTests {
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client) val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
@ -335,7 +329,7 @@ class InviteListPresenterTests {
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client) val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex)) room.givenAcceptInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -361,7 +355,7 @@ class InviteListPresenterTests {
invitesDataSource = invitesDataSource, invitesDataSource = invitesDataSource,
) )
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client) val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val ex = Throwable("Ruh roh!") val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex)) room.givenAcceptInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room) client.givenGetRoomResult(A_ROOM_ID, room)
@ -381,6 +375,71 @@ class InviteListPresenterTests {
} }
} }
@Test
fun `present - stores seen invites when received`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource()
val store = FakeSeenInvitesStore()
val presenter = InviteListPresenter(
FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource,
),
store,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
awaitItem()
// When one invite is received, that ID is saved
invitesDataSource.postRoomSummary(listOf(aRoomSummary()))
awaitItem()
Truth.assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID))
// When a second is added, both are saved
invitesDataSource.postRoomSummary(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
awaitItem()
Truth.assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID, A_ROOM_ID_2))
// When they're both dismissed, an empty set is saved
invitesDataSource.postRoomSummary(listOf())
awaitItem()
Truth.assertThat(store.getProvidedRoomIds()).isEmpty()
}
}
@Test
fun `present - marks invite as new if they're unseen`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource()
val store = FakeSeenInvitesStore()
store.publishRoomIds(setOf(A_ROOM_ID))
val presenter = InviteListPresenter(
FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource,
),
store,
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
}.test {
awaitItem()
invitesDataSource.postRoomSummary(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
skipItems(1)
val withInviteState = awaitItem()
Truth.assertThat(withInviteState.inviteList.size).isEqualTo(2)
Truth.assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
Truth.assertThat(withInviteState.inviteList[0].isNew).isFalse()
Truth.assertThat(withInviteState.inviteList[1].roomId).isEqualTo(A_ROOM_ID_2)
Truth.assertThat(withInviteState.inviteList[1].isNew).isTrue()
}
}
private suspend fun FakeRoomSummaryDataSource.withRoomInvitation(): FakeRoomSummaryDataSource { private suspend fun FakeRoomSummaryDataSource.withRoomInvitation(): FakeRoomSummaryDataSource {
postRoomSummary( postRoomSummary(
listOf( listOf(
@ -438,4 +497,17 @@ class InviteListPresenterTests {
) )
return this return this
} }
private fun aRoomSummary(id: RoomId = A_ROOM_ID) = RoomSummary.Filled(
RoomSummaryDetails(
roomId = id,
name = A_ROOM_NAME,
avatarURLString = null,
isDirect = false,
lastMessage = null,
lastMessageTimestamp = null,
unreadNotificationCount = 0,
)
)
} }

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2022 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.
*/
plugins {
id("io.element.android-library")
}
android {
namespace = "io.element.android.features.invitelist.test"
}
dependencies {
implementation(libs.coroutines.core)
implementation(projects.libraries.matrix.api)
api(projects.features.invitelist.api)
}

View file

@ -0,0 +1,40 @@
/*
* 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.invitelist.test
import io.element.android.features.invitelist.api.SeenInvitesStore
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeSeenInvitesStore : SeenInvitesStore {
private var existing = MutableStateFlow(emptySet<RoomId>())
private var provided: Set<RoomId>? = null
fun publishRoomIds(invites: Set<RoomId>) {
existing.value = invites
}
fun getProvidedRoomIds() = provided
override fun seenRoomIds(): Flow<Set<RoomId>> = existing
override suspend fun markAsSeen(roomIds: Set<RoomId>) {
provided = roomIds.toSet()
}
}

View file

@ -48,6 +48,7 @@ dependencies {
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
implementation(projects.features.invitelist.api)
implementation(projects.features.networkmonitor.api) implementation(projects.features.networkmonitor.api)
implementation(libs.accompanist.placeholder) implementation(libs.accompanist.placeholder)
api(projects.features.roomlist.api) api(projects.features.roomlist.api)
@ -61,8 +62,10 @@ dependencies {
testImplementation(libs.test.robolectric) testImplementation(libs.test.robolectric)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.libraries.permissions.noop) testImplementation(projects.libraries.permissions.noop)
testImplementation(projects.features.invitelist.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.tests.testutils)
androidTestImplementation(libs.test.junitext) androidTestImplementation(libs.test.junitext)
} }

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

View file

@ -31,7 +31,13 @@ data class RoomListState(
val displayVerificationPrompt: Boolean, val displayVerificationPrompt: Boolean,
val hasNetworkConnection: Boolean, val hasNetworkConnection: Boolean,
val snackbarMessage: SnackbarMessage?, val snackbarMessage: SnackbarMessage?,
val displayInvites: Boolean, val invitesState: InvitesState,
val displaySearchResults: Boolean, val displaySearchResults: Boolean,
val eventSink: (RoomListEvents) -> Unit 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(displayVerificationPrompt = true),
aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)), aRoomListState().copy(snackbarMessage = SnackbarMessage(StringR.string.common_verification_complete)),
aRoomListState().copy(hasNetworkConnection = false), 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, filter = "", filteredRoomList = persistentListOf()),
aRoomListState().copy(displaySearchResults = true), aRoomListState().copy(displaySearchResults = true),
) )
@ -49,7 +50,7 @@ internal fun aRoomListState() = RoomListState(
hasNetworkConnection = true, hasNetworkConnection = true,
snackbarMessage = null, snackbarMessage = null,
displayVerificationPrompt = false, displayVerificationPrompt = false,
displayInvites = false, invitesState = InvitesState.NoInvites,
displaySearchResults = false, displaySearchResults = false,
eventSink = {} eventSink = {}
) )

View file

@ -206,19 +206,23 @@ fun RoomListContent(
} }
} }
if (state.displayInvites) { if (state.invitesState != InvitesState.NoInvites) {
item { item {
Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxSize()) { Row(horizontalArrangement = Arrangement.End, modifier = Modifier.fillMaxSize()) {
TextButton( TextButton(
content = { content = {
Text(stringResource(StringR.string.action_invites_list)) Text(stringResource(StringR.string.action_invites_list))
Spacer(Modifier.size(8.dp))
Box( if (state.invitesState == InvitesState.NewInvites) {
modifier = Modifier Spacer(Modifier.size(8.dp))
.size(12.dp)
.clip(CircleShape) Box(
.background(MaterialTheme.roomListUnreadIndicator()) modifier = Modifier
) .size(12.dp)
.clip(CircleShape)
.background(MaterialTheme.roomListUnreadIndicator())
)
}
}, },
onClick = onInvitesClicked, 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.FakeRoomSummaryDataSource
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
class RoomListPresenterTests { @OptIn(ExperimentalCoroutinesApi::class) 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 {
@ -52,6 +54,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -79,6 +82,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -99,6 +103,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -127,6 +132,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -158,6 +164,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -195,6 +202,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -245,6 +253,7 @@ class RoomListPresenterTests {
}, },
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
@ -258,31 +267,34 @@ class RoomListPresenterTests {
} }
@Test @Test
fun `present - displays invites row if any invites exist`() = runTest { fun `present - sets invite state`() = runTest {
val invitesDataSource = FakeRoomSummaryDataSource() val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
val presenter = RoomListPresenter( val presenter = RoomListPresenter(
FakeMatrixClient( FakeMatrixClient(
sessionId = A_SESSION_ID, sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource
), ),
createDateFormatter(), createDateFormatter(),
FakeRoomLastMessageFormatter(), FakeRoomLastMessageFormatter(),
FakeSessionVerificationService(), FakeSessionVerificationService(),
FakeNetworkMonitor(), FakeNetworkMonitor(),
SnackbarDispatcher(), SnackbarDispatcher(),
FakeInviteDataSource(inviteStateFlow),
) )
moleculeFlow(RecompositionClock.Immediate) { moleculeFlow(RecompositionClock.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
Truth.assertThat(awaitItem().displayInvites).isFalse() Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites)
invitesDataSource.postRoomSummary(listOf(aRoomSummaryFilled())) inviteStateFlow.value = InvitesState.SeenInvites
Truth.assertThat(awaitItem().displayInvites).isTrue() Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.SeenInvites)
invitesDataSource.postRoomSummary(listOf()) inviteStateFlow.value = InvitesState.NewInvites
Truth.assertThat(awaitItem().displayInvites).isFalse() Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NewInvites)
inviteStateFlow.value = InvitesState.NoInvites
Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites)
} }
} }

View file

@ -46,6 +46,7 @@ android {
dependencies { dependencies {
implementation(libs.androidx.activity.compose) implementation(libs.androidx.activity.compose)
implementation(libs.androidx.preference)
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrix.impl) implementation(projects.libraries.matrix.impl)
implementation(projects.libraries.permissions.noop) implementation(projects.libraries.permissions.noop)
@ -54,6 +55,7 @@ dependencies {
implementation(projects.libraries.architecture) implementation(projects.libraries.architecture)
implementation(projects.libraries.core) implementation(projects.libraries.core)
implementation(projects.libraries.dateformatter.impl) implementation(projects.libraries.dateformatter.impl)
implementation(projects.features.invitelist.impl)
implementation(projects.features.roomlist.impl) implementation(projects.features.roomlist.impl)
implementation(projects.features.login.impl) implementation(projects.features.login.impl)
implementation(projects.features.networkmonitor.impl) implementation(projects.features.networkmonitor.impl)

View file

@ -20,20 +20,26 @@ import android.content.Context
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import io.element.android.features.invitelist.impl.DefaultSeenInvitesStore
import io.element.android.features.networkmonitor.impl.NetworkMonitorImpl import io.element.android.features.networkmonitor.impl.NetworkMonitorImpl
import io.element.android.features.roomlist.impl.DefaultInviteStateDataSource
import io.element.android.features.roomlist.impl.DefaultRoomLastMessageFormatter import io.element.android.features.roomlist.impl.DefaultRoomLastMessageFormatter
import io.element.android.features.roomlist.impl.RoomListPresenter import io.element.android.features.roomlist.impl.RoomListPresenter
import io.element.android.features.roomlist.impl.RoomListView import io.element.android.features.roomlist.impl.RoomListView
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.dateformatter.impl.DateFormatters import io.element.android.libraries.dateformatter.impl.DateFormatters
import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter import io.element.android.libraries.dateformatter.impl.DefaultLastMessageTimestampFormatter
import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider import io.element.android.libraries.dateformatter.impl.LocalDateTimeProvider
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.asCoroutineDispatcher
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.datetime.Clock import kotlinx.datetime.Clock
import kotlinx.datetime.TimeZone import kotlinx.datetime.TimeZone
import java.util.Locale import java.util.Locale
import java.util.concurrent.Executors
class RoomListScreen( class RoomListScreen(
context: Context, context: Context,
@ -52,6 +58,16 @@ class RoomListScreen(
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
networkMonitor = NetworkMonitorImpl(context), networkMonitor = NetworkMonitorImpl(context),
snackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher = SnackbarDispatcher(),
inviteStateDataSource = DefaultInviteStateDataSource(
matrixClient,
DefaultSeenInvitesStore(context),
CoroutineDispatchers(
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
diffUpdateDispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
)
)
) )
@Composable @Composable

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3b63f562e8b5360a66546fdd6e77de9a23530338b0e1a031c5c2d052f585985b oid sha256:a93f55fcc13131a84047147626b21abccc253c3e7e131afd56f85dfed783563f
size 48392 size 48527

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9f6d775fe3ecffe0c60b931e6d951a7da20d9832d940076d972821d268c58361 oid sha256:db30fa092d0d6d2158ec0f6d5fe1ca9b2707e648f0067e431584aa9f721a04a9
size 59646 size 59721

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a57b775eaad1b6e211d47588048c3722ec76083ca515ccface863b15ee497b5b oid sha256:71e7579a781fb092c14614b86736d16f168fd34e8cd30937b899601285bc5db9
size 34216 size 34299

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:39ce49cd2193bcf2439845835c469ce5e6b0304536ba403f679e0ea76e77a85c
size 49400

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:682acde00a6cbf13aa356ff4ec91368b11eae9fa2f8750d1590ceea334045ebd oid sha256:eef4fb6ed40c5b81737c967150694f2c7e3b3ecb09f50dabee92596ec80fc6b8
size 47079 size 47082

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:237e3727252f29ec3953fb0a27d6cee741cd5b485c9466e81799315f3852f0c7 oid sha256:566de16a101483d6bca48ee95037699e56c2b8b0fb5161ddbde4cbbc30f0e08c
size 58267 size 58261

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f2a733da2b24280b2d096e458a8bf470f2f8279008cc823553eee1072e9e3df5 oid sha256:0998c041de2ca2f7ccfd8c6fa3bc14201610cd66ce79b1e17be6b1d0c62f98e9
size 33238 size 33275

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c2a14823ece6aaf58128b3615e1c012dd906ae58b5ff52cb2b0841b49e43361d
size 47924

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e4e085e53b63f83a51bb726fc3f38f37c8fb6412e5b8ed3ec893b24c87fa63a4 oid sha256:fc812d7b9418a67040d8bd9a007444d611c00108662064baf79ae5805432f081
size 56723 size 56695

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e8e44bafcf2fd7119562d6a92a8deff74a7ba470b4a7a87951cf77e19eee2eb0 oid sha256:4031fc3820333ed5b28f2356792b957bc38cba1559b599b0ac0fef66074252e0
size 43576 size 43572

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b72ad15218e4f17b1c47018925194f3b21c70630bf8744395352707dc257ab70 oid sha256:8936f72285c8a0f483080a4ff007703e40f187abfe0f67bb3ae33eb57901a1af
size 44025 size 44022

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ac02a683f4a6297427e6e74bd331c45e9ee7efb044767568bc46566ae46f2274 oid sha256:6dd33c2e92caac62963ccc75c068aa2b27037fe075b48823d2a8b75c88b4f661
size 39618 size 39408

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ac02a683f4a6297427e6e74bd331c45e9ee7efb044767568bc46566ae46f2274 oid sha256:6dd33c2e92caac62963ccc75c068aa2b27037fe075b48823d2a8b75c88b4f661
size 39618 size 39408

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:151331de0ea941330ff611e312833aa9c731ed0cd49ed4f31f724dbb5c7ba252 oid sha256:e87dd78bba7e3fd682e647258d989e5d720b7a0e879d698f96611b98867cdaaf
size 55174 size 55009

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b8e00ba1ff01242bf3e1f4858cb66b5ed87681aec76090a545e8d729361e5e37 oid sha256:dbd348146e2161e9b2a57af31d0ad48c44dd5f0274fbebfd885ea628235e08f9
size 43034 size 42903

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:27b3164386391fc115d3ecdcf202495a3f03509c2c2c5564880ed756d56ed3b5 oid sha256:9c7eb8bd040fb4321ecf28b5e00619f5356f23e5b07f59feb3894dc6620b9643
size 43463 size 43335

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:64ec3959fddb9dccc271ac0fa28275fa43df893f8f672b99f9f594004cef438e oid sha256:63e2e25f5e92c1890dd23ccc8f0e9c352587c0ac1190c012e430c21b7b8d728e
size 39052 size 38709

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:64ec3959fddb9dccc271ac0fa28275fa43df893f8f672b99f9f594004cef438e oid sha256:63e2e25f5e92c1890dd23ccc8f0e9c352587c0ac1190c012e430c21b7b8d728e
size 39052 size 38709

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c2a23141c6cc8aa6e7e5f0757bda4d1117bf7f752412ccc4649ea560113b3e3f oid sha256:6b6263f24f080bbf87704a8bf8784fdfab360c08fbf1cd048f82b2db84f7611d
size 39030 size 38738

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9095546c30bb5bc9800c852456fa9cd82d14e873a5e1488d29496af088e951da oid sha256:c2a23141c6cc8aa6e7e5f0757bda4d1117bf7f752412ccc4649ea560113b3e3f
size 4882 size 39030

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:8cfe321535e1ce223a0460435123dc59e74c436bdd8696cf4bdb2169f511832b oid sha256:9095546c30bb5bc9800c852456fa9cd82d14e873a5e1488d29496af088e951da
size 28541 size 4882

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8cfe321535e1ce223a0460435123dc59e74c436bdd8696cf4bdb2169f511832b
size 28541

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d427b479f9eb6227bb92aecd997bab97a735ef85dd279a575d777e754effd258 oid sha256:fa79302cd00b188661ef35107645568741eb04ebde4d6d8f31c5c044ec5c2d6f
size 38639 size 38310

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d463ab6d045cc310973c5ff900cf4d9ae04e93cb1c7eac3f9b2aa0ea9b827cee oid sha256:d427b479f9eb6227bb92aecd997bab97a735ef85dd279a575d777e754effd258
size 4815 size 38639

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:97b11203623c0c98da88dfedf85cb80d1f35cc55da570e12061b1385691bf1f0 oid sha256:d463ab6d045cc310973c5ff900cf4d9ae04e93cb1c7eac3f9b2aa0ea9b827cee
size 27758 size 4815

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:97b11203623c0c98da88dfedf85cb80d1f35cc55da570e12061b1385691bf1f0
size 27758