Browse Source

Show badges for new invites (#355)

Show badges for new invites

Closes #238
feature/jme/open-room-member-details-when-clicking-on-user-data
Chris Smith 1 year ago committed by GitHub
parent
commit
ee909fcbd8
  1. 1
      changelog.d/238.feature
  2. 25
      features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/SeenInvitesStore.kt
  3. 2
      features/invitelist/impl/build.gradle.kts
  4. 59
      features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultSeenInvitesStore.kt
  5. 112
      features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt
  6. 38
      features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt
  7. 3
      features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummary.kt
  8. 1
      features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummaryProvider.kt
  9. 126
      features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt
  10. 29
      features/invitelist/test/build.gradle.kts
  11. 40
      features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt
  12. 5
      features/roomlist/impl/build.gradle.kts
  13. 71
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/DefaultInviteStateDataSource.kt
  14. 26
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/InviteStateDataSource.kt
  15. 12
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt
  16. 8
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt
  17. 5
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt
  18. 20
      features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt
  19. 137
      features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultInviteStateDataSourceTest.kt
  20. 33
      features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/FakeInviteDataSource.kt
  21. 30
      features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt
  22. 2
      samples/minimal/build.gradle.kts
  23. 16
      samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt
  24. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png
  25. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png
  26. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_2,NEXUS_5,1.0,en].png
  27. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_3,NEXUS_5,1.0,en].png
  28. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_0,NEXUS_5,1.0,en].png
  29. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_1,NEXUS_5,1.0,en].png
  30. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_2,NEXUS_5,1.0,en].png
  31. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_3,NEXUS_5,1.0,en].png
  32. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png
  33. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png
  34. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png
  35. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png
  36. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png
  37. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png
  38. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png
  39. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png
  40. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png
  41. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png
  42. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png
  43. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png
  44. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png
  45. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png
  46. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png
  47. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png
  48. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_6,NEXUS_5,1.0,en].png
  49. BIN
      tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_7,NEXUS_5,1.0,en].png

1
changelog.d/238.feature

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

25
features/invitelist/api/src/main/kotlin/io/element/android/features/invitelist/api/SeenInvitesStore.kt

@ -0,0 +1,25 @@ @@ -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>)
}

2
features/invitelist/impl/build.gradle.kts

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

59
features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/DefaultSeenInvitesStore.kt

@ -0,0 +1,59 @@ @@ -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()
}
}
}

112
features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/InviteListPresenter.kt

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

38
features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/components/InviteSummaryRow.kt

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

3
features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummary.kt

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

1
features/invitelist/impl/src/main/kotlin/io/element/android/features/invitelist/impl/model/InviteListInviteSummaryProvider.kt

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

126
features/invitelist/impl/src/test/kotlin/io/element/android/features/invitelist/impl/InviteListPresenterTests.kt

@ -20,6 +20,7 @@ import app.cash.molecule.RecompositionClock @@ -20,6 +20,7 @@ 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.architecture.Async
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.RoomId
@ -29,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomSummary @@ -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.test.AN_AVATAR_URL
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_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
@ -50,7 +52,8 @@ class InviteListPresenterTests { @@ -50,7 +52,8 @@ class InviteListPresenterTests {
FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource,
)
),
FakeSeenInvitesStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -58,21 +61,7 @@ class InviteListPresenterTests { @@ -58,21 +61,7 @@ class InviteListPresenterTests {
val initialState = awaitItem()
Truth.assertThat(initialState.inviteList).isEmpty()
invitesDataSource.postRoomSummary(
listOf(
RoomSummary.Filled(
RoomSummaryDetails(
roomId = A_ROOM_ID,
name = A_ROOM_NAME,
avatarURLString = null,
isDirect = false,
lastMessage = null,
lastMessageTimestamp = null,
unreadNotificationCount = 0,
)
)
)
)
invitesDataSource.postRoomSummary(listOf(aRoomSummary()))
val withInviteState = awaitItem()
Truth.assertThat(withInviteState.inviteList.size).isEqualTo(1)
@ -88,7 +77,8 @@ class InviteListPresenterTests { @@ -88,7 +77,8 @@ class InviteListPresenterTests {
FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource,
)
),
FakeSeenInvitesStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -117,7 +107,8 @@ class InviteListPresenterTests { @@ -117,7 +107,8 @@ class InviteListPresenterTests {
FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource,
)
),
FakeSeenInvitesStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -144,7 +135,8 @@ class InviteListPresenterTests { @@ -144,7 +135,8 @@ class InviteListPresenterTests {
FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource,
)
),
FakeSeenInvitesStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -169,7 +161,8 @@ class InviteListPresenterTests { @@ -169,7 +161,8 @@ class InviteListPresenterTests {
FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource,
)
),
FakeSeenInvitesStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -194,7 +187,8 @@ class InviteListPresenterTests { @@ -194,7 +187,8 @@ class InviteListPresenterTests {
FakeMatrixClient(
sessionId = A_SESSION_ID,
invitesDataSource = invitesDataSource,
)
),
FakeSeenInvitesStore(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -219,7 +213,7 @@ class InviteListPresenterTests { @@ -219,7 +213,7 @@ class InviteListPresenterTests {
invitesDataSource = invitesDataSource,
)
val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client)
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
@ -246,7 +240,7 @@ class InviteListPresenterTests { @@ -246,7 +240,7 @@ class InviteListPresenterTests {
invitesDataSource = invitesDataSource,
)
val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client)
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
@ -278,7 +272,7 @@ class InviteListPresenterTests { @@ -278,7 +272,7 @@ class InviteListPresenterTests {
invitesDataSource = invitesDataSource,
)
val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client)
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val ex = Throwable("Ruh roh!")
room.givenRejectInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
@ -311,7 +305,7 @@ class InviteListPresenterTests { @@ -311,7 +305,7 @@ class InviteListPresenterTests {
invitesDataSource = invitesDataSource,
)
val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client)
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
client.givenGetRoomResult(A_ROOM_ID, room)
moleculeFlow(RecompositionClock.Immediate) {
@ -335,7 +329,7 @@ class InviteListPresenterTests { @@ -335,7 +329,7 @@ class InviteListPresenterTests {
invitesDataSource = invitesDataSource,
)
val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client)
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
@ -361,7 +355,7 @@ class InviteListPresenterTests { @@ -361,7 +355,7 @@ class InviteListPresenterTests {
invitesDataSource = invitesDataSource,
)
val room = FakeMatrixRoom()
val presenter = InviteListPresenter(client)
val presenter = InviteListPresenter(client, FakeSeenInvitesStore())
val ex = Throwable("Ruh roh!")
room.givenAcceptInviteResult(Result.failure(ex))
client.givenGetRoomResult(A_ROOM_ID, room)
@ -381,6 +375,71 @@ class InviteListPresenterTests { @@ -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 {
postRoomSummary(
listOf(
@ -438,4 +497,17 @@ class InviteListPresenterTests { @@ -438,4 +497,17 @@ class InviteListPresenterTests {
)
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,
)
)
}

29
features/invitelist/test/build.gradle.kts

@ -0,0 +1,29 @@ @@ -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)
}

40
features/invitelist/test/src/main/kotlin/io/element/android/features/invitelist/test/FakeSeenInvitesStore.kt

@ -0,0 +1,40 @@ @@ -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()
}
}

5
features/roomlist/impl/build.gradle.kts

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

71
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/DefaultInviteStateDataSource.kt

@ -0,0 +1,71 @@ @@ -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 }

26
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/InviteStateDataSource.kt

@ -0,0 +1,26 @@ @@ -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
}

12
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListPresenter.kt

@ -61,6 +61,7 @@ class RoomListPresenter @Inject constructor( @@ -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( @@ -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( @@ -112,7 +106,7 @@ class RoomListPresenter @Inject constructor(
if (displaySearchResults) {
filter = ""
}
displaySearchResults =! displaySearchResults
displaySearchResults = !displaySearchResults
}
}
}
@ -136,7 +130,7 @@ class RoomListPresenter @Inject constructor( @@ -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
)

8
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListState.kt

@ -31,7 +31,13 @@ data class RoomListState( @@ -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,
}

5
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListStateProvider.kt

@ -35,7 +35,8 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> { @@ -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( @@ -49,7 +50,7 @@ internal fun aRoomListState() = RoomListState(
hasNetworkConnection = true,
snackbarMessage = null,
displayVerificationPrompt = false,
displayInvites = false,
invitesState = InvitesState.NoInvites,
displaySearchResults = false,
eventSink = {}
)

20
features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt

@ -206,19 +206,23 @@ fun RoomListContent( @@ -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,
)

137
features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/DefaultInviteStateDataSourceTest.kt

@ -0,0 +1,137 @@ @@ -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)
}
}
}

33
features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/FakeInviteDataSource.kt

@ -0,0 +1,33 @@ @@ -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
}
}

30
features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt

@ -38,10 +38,12 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -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 { @@ -52,6 +54,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -79,6 +82,7 @@ class RoomListPresenterTests { @@ -79,6 +82,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -99,6 +103,7 @@ class RoomListPresenterTests { @@ -99,6 +103,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -127,6 +132,7 @@ class RoomListPresenterTests { @@ -127,6 +132,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -158,6 +164,7 @@ class RoomListPresenterTests { @@ -158,6 +164,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -195,6 +202,7 @@ class RoomListPresenterTests { @@ -195,6 +202,7 @@ class RoomListPresenterTests {
FakeSessionVerificationService(),
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -245,6 +253,7 @@ class RoomListPresenterTests { @@ -245,6 +253,7 @@ class RoomListPresenterTests {
},
FakeNetworkMonitor(),
SnackbarDispatcher(),
FakeInviteDataSource(),
)
moleculeFlow(RecompositionClock.Immediate) {
presenter.present()
@ -258,31 +267,34 @@ class RoomListPresenterTests { @@ -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)
}
}

2
samples/minimal/build.gradle.kts

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

16
samples/minimal/src/main/kotlin/io/element/android/samples/minimal/RoomListScreen.kt

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

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowDarkPreview_0_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_1,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl.components_null_DefaultGroup_InviteSummaryRowLightPreview_0_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_0,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_2,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_3,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.invitelist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_6,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewDarkPreview_0_null_7,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_4,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_5,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_6,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.

BIN
tests/uitests/src/test/snapshots/images/io.element.android.tests.uitests_ScreenshotTest_preview_tests[io.element.android.features.roomlist.impl_null_DefaultGroup_RoomListViewLightPreview_0_null_7,NEXUS_5,1.0,en].png (Stored with Git LFS)

Binary file not shown.
Loading…
Cancel
Save