Remove the green badge on a pending invite after a first preview (#4532)

* Remove condition on displayType as I believe, that it has no effect.

* Remove the green badge on a pending invite after a first preview

* Update screenshots

* Fix test

* Improve DefaultSeenInvitesStore, clear it on logout, and on clear cache. Also create a store per session.

* Remember the returned flow.

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Benoit Marty 2025-04-04 16:51:31 +02:00 committed by GitHub
parent 77a7c0b2e5
commit ef8eeb804e
26 changed files with 326 additions and 29 deletions

View file

@ -0,0 +1,37 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.invite.api
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
interface SeenInvitesStore {
/**
* Returns a flow of seen room IDs of invitation.
*/
fun seenRoomIds(): Flow<Set<RoomId>>
/**
* Mark the invitation as seen.
* Call this when the invitation details are shown to the user.
* @param roomId the room ID of the invitation to mark as seen.
*/
suspend fun markAsSeen(roomId: RoomId)
/**
* Mark the invitation as unseen.
* Call this when the invitation has been accepted or declined.
* @param roomId the room ID of the invitation to mark as unseen.
*/
suspend fun markAsUnSeen(roomId: RoomId)
/**
* Delete the store.
*/
suspend fun clear()
}

View file

@ -21,6 +21,7 @@ setupAnvil()
dependencies { dependencies {
api(projects.features.invite.api) api(projects.features.invite.api)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
implementation(projects.libraries.androidutils)
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)
@ -35,6 +36,7 @@ dependencies {
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)

View file

@ -0,0 +1,90 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.invite.impl
import android.content.Context
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
import androidx.datastore.preferences.core.edit
import androidx.datastore.preferences.core.stringSetPreferencesKey
import androidx.datastore.preferences.preferencesDataStoreFile
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.libraries.androidutils.file.safeDelete
import io.element.android.libraries.androidutils.hash.hash
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.sessionstorage.api.observer.SessionListener
import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import javax.inject.Inject
private val seenInvitesKey = stringSetPreferencesKey("seenInvites")
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class)
class DefaultSeenInvitesStore @Inject constructor(
@ApplicationContext context: Context,
currentSessionIdHolder: CurrentSessionIdHolder,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
sessionObserver: SessionObserver,
) : SeenInvitesStore {
private val sessionId: SessionId = currentSessionIdHolder.current
init {
sessionObserver.addListener(object : SessionListener {
override suspend fun onSessionCreated(userId: String) = Unit
override suspend fun onSessionDeleted(userId: String) {
if (sessionId.value == userId) {
clear()
}
}
})
}
private val dataStoreFile = sessionId.value.hash().take(16).let { hashedUserId ->
context.preferencesDataStoreFile("session_${hashedUserId}_seen-invites")
}
private val store = PreferenceDataStoreFactory.create(
scope = sessionCoroutineScope,
migrations = emptyList(),
) {
dataStoreFile
}
override fun seenRoomIds(): Flow<Set<RoomId>> =
store.data.map { prefs ->
prefs[seenInvitesKey]
.orEmpty()
.map { RoomId(it) }
.toSet()
}
override suspend fun markAsSeen(roomId: RoomId) {
store.edit { prefs ->
prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() + roomId.value
}
}
override suspend fun markAsUnSeen(roomId: RoomId) {
store.edit { prefs ->
prefs[seenInvitesKey] = prefs[seenInvitesKey].orEmpty() - roomId.value
}
}
override suspend fun clear() {
dataStoreFile.safeDelete()
}
}

View file

@ -13,6 +13,7 @@ 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 im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
@ -34,6 +35,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
private val client: MatrixClient, private val client: MatrixClient,
private val joinRoom: JoinRoom, private val joinRoom: JoinRoom,
private val notificationCleaner: NotificationCleaner, private val notificationCleaner: NotificationCleaner,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<AcceptDeclineInviteState> { ) : Presenter<AcceptDeclineInviteState> {
@Composable @Composable
override fun present(): AcceptDeclineInviteState { override fun present(): AcceptDeclineInviteState {
@ -107,6 +109,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
) )
.onSuccess { .onSuccess {
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId) notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, roomId)
seenInvitesStore.markAsUnSeen(roomId)
} }
.map { roomId } .map { roomId }
} }
@ -125,6 +128,7 @@ class AcceptDeclineInvitePresenter @Inject constructor(
client.ignoreUser(inviteData.senderId).getOrThrow() client.ignoreUser(inviteData.senderId).getOrThrow()
} }
notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId) notificationCleaner.clearMembershipNotificationForRoom(client.sessionId, inviteData.roomId)
seenInvitesStore.markAsUnSeen(inviteData.roomId)
inviteData.roomId inviteData.roomId
}.runCatchingUpdatingState(declinedAction) }.runCatchingUpdatingState(declinedAction)
} }

View file

@ -9,9 +9,11 @@ package io.element.android.features.invite.impl.response
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.ConfirmingDeclineInvite import io.element.android.features.invite.api.response.ConfirmingDeclineInvite
import io.element.android.features.invite.api.response.InviteData import io.element.android.features.invite.api.response.InviteData
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncAction
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
@ -20,6 +22,8 @@ import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
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_ID_3
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
@ -33,6 +37,7 @@ import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -54,7 +59,10 @@ class AcceptDeclineInvitePresenterTest {
@Test @Test
fun `present - declining invite cancel flow`() = runTest { fun `present - declining invite cancel flow`() = runTest {
val presenter = createAcceptDeclineInvitePresenter() val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
seenInvitesStore = seenInvitesStore,
)
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
@ -72,6 +80,7 @@ class AcceptDeclineInvitePresenterTest {
assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java) assertThat(state.declineAction).isInstanceOf(AsyncAction.Uninitialized::class.java)
} }
} }
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
} }
@Test @Test
@ -84,7 +93,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure)) Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
} }
) )
val presenter = createAcceptDeclineInvitePresenter(client = client) val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
seenInvitesStore = seenInvitesStore,
)
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
@ -111,6 +124,7 @@ class AcceptDeclineInvitePresenterTest {
cancelAndConsumeRemainingEvents() cancelAndConsumeRemainingEvents()
} }
assert(declineInviteFailure).isCalledOnce() assert(declineInviteFailure).isCalledOnce()
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
} }
@Test @Test
@ -129,9 +143,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess)) Result.success(FakeRoomPreview(declineInviteResult = declineInviteSuccess))
} }
) )
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter( val presenter = createAcceptDeclineInvitePresenter(
client = client, client = client,
notificationCleaner = fakeNotificationCleaner, notificationCleaner = fakeNotificationCleaner,
seenInvitesStore = seenInvitesStore,
) )
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
@ -156,6 +172,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions() clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID)) .with(value(A_SESSION_ID), value(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
} }
@Test @Test
@ -174,9 +191,11 @@ class AcceptDeclineInvitePresenterTest {
}, },
ignoreUserResult = ignoreUserSuccess ignoreUserResult = ignoreUserSuccess
) )
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter( val presenter = createAcceptDeclineInvitePresenter(
client = client, client = client,
notificationCleaner = fakeNotificationCleaner, notificationCleaner = fakeNotificationCleaner,
seenInvitesStore = seenInvitesStore,
) )
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
@ -202,6 +221,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions() clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID)) .with(value(A_SESSION_ID), value(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
} }
@Test @Test
@ -214,7 +234,11 @@ class AcceptDeclineInvitePresenterTest {
Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure)) Result.success(FakeRoomPreview(declineInviteResult = declineInviteFailure))
} }
) )
val presenter = createAcceptDeclineInvitePresenter(client = client) val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
client = client,
seenInvitesStore = seenInvitesStore,
)
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
@ -230,6 +254,7 @@ class AcceptDeclineInvitePresenterTest {
} }
assertThat(awaitItem().declineAction.isLoading()).isTrue() assertThat(awaitItem().declineAction.isLoading()).isTrue()
} }
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
} }
@Test @Test
@ -237,7 +262,11 @@ class AcceptDeclineInvitePresenterTest {
val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger -> val joinRoomFailure = lambdaRecorder { roomIdOrAlias: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
Result.failure<Unit>(RuntimeException("Failed to join room $roomIdOrAlias")) Result.failure<Unit>(RuntimeException("Failed to join room $roomIdOrAlias"))
} }
val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomFailure) val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter(
joinRoomLambda = joinRoomFailure,
seenInvitesStore = seenInvitesStore,
)
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
awaitItem().also { state -> awaitItem().also { state ->
@ -266,6 +295,7 @@ class AcceptDeclineInvitePresenterTest {
value(emptyList<String>()), value(emptyList<String>()),
value(JoinedRoom.Trigger.Invite) value(JoinedRoom.Trigger.Invite)
) )
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
} }
@Test @Test
@ -279,9 +309,11 @@ class AcceptDeclineInvitePresenterTest {
val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger -> val joinRoomSuccess = lambdaRecorder { _: RoomIdOrAlias, _: List<String>, _: JoinedRoom.Trigger ->
Result.success(Unit) Result.success(Unit)
} }
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3))
val presenter = createAcceptDeclineInvitePresenter( val presenter = createAcceptDeclineInvitePresenter(
joinRoomLambda = joinRoomSuccess, joinRoomLambda = joinRoomSuccess,
notificationCleaner = fakeNotificationCleaner, notificationCleaner = fakeNotificationCleaner,
seenInvitesStore = seenInvitesStore,
) )
presenter.test { presenter.test {
val inviteData = anInviteData() val inviteData = anInviteData()
@ -308,6 +340,7 @@ class AcceptDeclineInvitePresenterTest {
clearMembershipNotificationForRoomLambda.assertions() clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce() .isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID)) .with(value(A_SESSION_ID), value(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(A_ROOM_ID_2, A_ROOM_ID_3)
} }
private fun anInviteData( private fun anInviteData(
@ -330,11 +363,13 @@ class AcceptDeclineInvitePresenterTest {
Result.success(Unit) Result.success(Unit)
}, },
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): AcceptDeclineInvitePresenter { ): AcceptDeclineInvitePresenter {
return AcceptDeclineInvitePresenter( return AcceptDeclineInvitePresenter(
client = client, client = client,
joinRoom = FakeJoinRoom(joinRoomLambda), joinRoom = FakeJoinRoom(joinRoomLambda),
notificationCleaner = notificationCleaner, notificationCleaner = notificationCleaner,
seenInvitesStore = seenInvitesStore,
) )
} }
} }

View file

@ -0,0 +1,29 @@
/*
* Copyright (c) 2025 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.invite.test"
}
dependencies {
implementation(libs.coroutines.core)
implementation(projects.libraries.matrix.api)
api(projects.features.invite.api)
}

View file

@ -0,0 +1,33 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.invite.test
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class InMemorySeenInvitesStore(
initialRoomIds: Set<RoomId> = emptySet(),
) : SeenInvitesStore {
private val roomIds = MutableStateFlow(initialRoomIds)
override fun seenRoomIds(): Flow<Set<RoomId>> = roomIds
override suspend fun markAsSeen(roomId: RoomId) {
roomIds.value += roomId
}
override suspend fun markAsUnSeen(roomId: RoomId) {
roomIds.value -= roomId
}
override suspend fun clear() {
roomIds.value = emptySet()
}
}

View file

@ -42,6 +42,7 @@ dependencies {
testImplementation(libs.test.robolectric) testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.features.invite.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit) testImplementation(libs.androidx.compose.ui.test.junit)

View file

@ -9,6 +9,7 @@ package io.element.android.features.joinroom.impl
import androidx.annotation.VisibleForTesting import androidx.annotation.VisibleForTesting
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
@ -22,6 +23,7 @@ import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData import io.element.android.features.invite.api.response.InviteData
@ -67,6 +69,7 @@ class JoinRoomPresenter @AssistedInject constructor(
private val forgetRoom: ForgetRoom, private val forgetRoom: ForgetRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<JoinRoomState> { ) : Presenter<JoinRoomState> {
interface Factory { interface Factory {
fun create( fun create(
@ -149,6 +152,10 @@ class JoinRoomPresenter @AssistedInject constructor(
} }
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present() val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
LaunchedEffect(contentState) {
contentState.markRoomInviteAsSeen()
}
fun handleEvents(event: JoinRoomEvents) { fun handleEvents(event: JoinRoomEvents) {
when (event) { when (event) {
JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction) JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction)
@ -236,6 +243,12 @@ class JoinRoomPresenter @AssistedInject constructor(
forgetRoom.invoke(roomId) forgetRoom.invoke(roomId)
} }
} }
private suspend fun ContentState.markRoomInviteAsSeen() {
if ((this as? ContentState.Loaded)?.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited != null) {
seenInvitesStore.markAsSeen(roomId)
}
}
} }
private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState { private fun RoomPreviewInfo.toContentState(senderMember: RoomMember?, reason: String?): ContentState {

View file

@ -11,6 +11,7 @@ import com.squareup.anvil.annotations.ContributesTo
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.joinroom.impl.JoinRoomPresenter import io.element.android.features.joinroom.impl.JoinRoomPresenter
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
@ -35,6 +36,7 @@ object JoinRoomModule {
forgetRoom: ForgetRoom, forgetRoom: ForgetRoom,
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
buildMeta: BuildMeta, buildMeta: BuildMeta,
seenInvitesStore: SeenInvitesStore,
): JoinRoomPresenter.Factory { ): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory { return object : JoinRoomPresenter.Factory {
override fun create( override fun create(
@ -57,6 +59,7 @@ object JoinRoomModule {
cancelKnockRoom = cancelKnockRoom, cancelKnockRoom = cancelKnockRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
buildMeta = buildMeta, buildMeta = buildMeta,
seenInvitesStore = seenInvitesStore,
) )
} }
} }

View file

@ -9,9 +9,11 @@ package io.element.android.features.joinroom.impl
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.JoinedRoom import im.vector.app.features.analytics.plan.JoinedRoom
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.joinroom.impl.di.CancelKnockRoom import io.element.android.features.joinroom.impl.di.CancelKnockRoom
import io.element.android.features.joinroom.impl.di.ForgetRoom import io.element.android.features.joinroom.impl.di.ForgetRoom
import io.element.android.features.joinroom.impl.di.KnockRoom import io.element.android.features.joinroom.impl.di.KnockRoom
@ -52,6 +54,7 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
@ -111,14 +114,19 @@ class JoinRoomPresenterTest {
flowOf(Optional.of(roomSummary)) flowOf(Optional.of(roomSummary))
} }
} }
val seenInvitesStore = InMemorySeenInvitesStore()
val presenter = createJoinRoomPresenter( val presenter = createJoinRoomPresenter(
matrixClient = matrixClient matrixClient = matrixClient,
seenInvitesStore = seenInvitesStore,
) )
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
presenter.test { presenter.test {
skipItems(1) skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null)) assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
} }
// Check that the roomId is stored in the seen invites store
assertThat(seenInvitesStore.seenRoomIds().first()).containsExactly(roomSummary.roomId)
} }
} }
@ -759,7 +767,8 @@ class JoinRoomPresenterTest {
cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(), cancelKnockRoom: CancelKnockRoom = FakeCancelKnockRoom(),
forgetRoom: ForgetRoom = FakeForgetRoom(), forgetRoom: ForgetRoom = FakeForgetRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"), buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() } acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(),
): JoinRoomPresenter { ): JoinRoomPresenter {
return JoinRoomPresenter( return JoinRoomPresenter(
roomId = roomId, roomId = roomId,
@ -773,7 +782,8 @@ class JoinRoomPresenterTest {
cancelKnockRoom = cancelKnockRoom, cancelKnockRoom = cancelKnockRoom,
forgetRoom = forgetRoom, forgetRoom = forgetRoom,
buildMeta = buildMeta, buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
seenInvitesStore = seenInvitesStore,
) )
} }

View file

@ -74,6 +74,7 @@ dependencies {
implementation(projects.features.licenses.api) implementation(projects.features.licenses.api)
implementation(projects.features.logout.api) implementation(projects.features.logout.api)
implementation(projects.features.deactivation.api) implementation(projects.features.deactivation.api)
implementation(projects.features.invite.api)
implementation(projects.features.roomlist.api) implementation(projects.features.roomlist.api)
implementation(projects.services.analytics.api) implementation(projects.services.analytics.api)
implementation(projects.services.analytics.compose) implementation(projects.services.analytics.compose)
@ -103,6 +104,7 @@ dependencies {
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.ftue.test) testImplementation(projects.features.ftue.test)
testImplementation(projects.features.invite.test)
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl) testImplementation(projects.features.rageshake.impl)
testImplementation(projects.features.logout.test) testImplementation(projects.features.logout.test)

View file

@ -11,6 +11,7 @@ import android.content.Context
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
import com.squareup.anvil.annotations.ContributesBinding import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.ApplicationContext
@ -35,6 +36,7 @@ class DefaultClearCacheUseCase @Inject constructor(
private val okHttpClient: Provider<OkHttpClient>, private val okHttpClient: Provider<OkHttpClient>,
private val ftueService: FtueService, private val ftueService: FtueService,
private val pushService: PushService, private val pushService: PushService,
private val seenInvitesStore: SeenInvitesStore,
) : ClearCacheUseCase { ) : ClearCacheUseCase {
override suspend fun invoke() = withContext(coroutineDispatchers.io) { override suspend fun invoke() = withContext(coroutineDispatchers.io) {
// Clear Matrix cache // Clear Matrix cache
@ -50,6 +52,7 @@ class DefaultClearCacheUseCase @Inject constructor(
context.cacheDir.deleteRecursively() context.cacheDir.deleteRecursively()
// Clear some settings // Clear some settings
ftueService.reset() ftueService.reset()
seenInvitesStore.clear()
// Ensure any error will be displayed again // Ensure any error will be displayed again
pushService.setIgnoreRegistrationError(matrixClient.sessionId, false) pushService.setIgnoreRegistrationError(matrixClient.sessionId, false)
// Ensure the app is restarted // Ensure the app is restarted

View file

@ -11,13 +11,16 @@ import androidx.test.platform.app.InstrumentationRegistry
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.ftue.test.FakeFtueService import io.element.android.features.ftue.test.FakeFtueService
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.preferences.impl.DefaultCacheService import io.element.android.features.preferences.impl.DefaultCacheService
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.push.test.FakePushService import io.element.android.libraries.push.test.FakePushService
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import org.junit.Test import org.junit.Test
@ -41,6 +44,8 @@ class DefaultClearCacheUseCaseTest {
val pushService = FakePushService( val pushService = FakePushService(
setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda setIgnoreRegistrationErrorLambda = setIgnoreRegistrationErrorLambda
) )
val seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID))
assertThat(seenInvitesStore.seenRoomIds().first()).isNotEmpty()
val sut = DefaultClearCacheUseCase( val sut = DefaultClearCacheUseCase(
context = InstrumentationRegistry.getInstrumentation().context, context = InstrumentationRegistry.getInstrumentation().context,
matrixClient = matrixClient, matrixClient = matrixClient,
@ -49,6 +54,7 @@ class DefaultClearCacheUseCaseTest {
okHttpClient = { OkHttpClient.Builder().build() }, okHttpClient = { OkHttpClient.Builder().build() },
ftueService = ftueService, ftueService = ftueService,
pushService = pushService, pushService = pushService,
seenInvitesStore = seenInvitesStore,
) )
defaultCacheService.clearedCacheEventFlow.test { defaultCacheService.clearedCacheEventFlow.test {
sut.invoke() sut.invoke()
@ -57,6 +63,7 @@ class DefaultClearCacheUseCaseTest {
setIgnoreRegistrationErrorLambda.assertions().isCalledOnce() setIgnoreRegistrationErrorLambda.assertions().isCalledOnce()
.with(value(matrixClient.sessionId), value(false)) .with(value(matrixClient.sessionId), value(false))
assertThat(awaitItem()).isEqualTo(matrixClient.sessionId) assertThat(awaitItem()).isEqualTo(matrixClient.sessionId)
assertThat(seenInvitesStore.seenRoomIds().first()).isEmpty()
} }
} }
} }

View file

@ -61,6 +61,9 @@ dependencies {
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric) testImplementation(libs.test.robolectric)
testImplementation(projects.features.invite.test)
testImplementation(projects.features.logout.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.dateformatter.test)
@ -72,7 +75,5 @@ dependencies {
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.services.toolbox.test) testImplementation(projects.services.toolbox.test)
testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.logout.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
} }

View file

@ -11,8 +11,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentSet
open class RoomListContentStateProvider : PreviewParameterProvider<RoomListContentState> { open class RoomListContentStateProvider : PreviewParameterProvider<RoomListContentState> {
override val values: Sequence<RoomListContentState> override val values: Sequence<RoomListContentState>
@ -29,10 +31,12 @@ internal fun aRoomsContentState(
securityBannerState: SecurityBannerState = SecurityBannerState.None, securityBannerState: SecurityBannerState = SecurityBannerState.None,
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(), summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
seenRoomInvites: Set<RoomId> = emptySet(),
) = RoomListContentState.Rooms( ) = RoomListContentState.Rooms(
securityBannerState = securityBannerState, securityBannerState = securityBannerState,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState, fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
summaries = summaries, summaries = summaries,
seenRoomInvites = seenRoomInvites.toPersistentSet(),
) )
internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16) internal fun aSkeletonContentState() = RoomListContentState.Skeleton(16)

View file

@ -24,6 +24,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.snapshotFlow
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData import io.element.android.features.invite.api.response.InviteData
@ -57,6 +58,7 @@ import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toPersistentSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -92,6 +94,7 @@ class RoomListPresenter @Inject constructor(
private val logoutPresenter: Presenter<DirectLogoutState>, private val logoutPresenter: Presenter<DirectLogoutState>,
private val appPreferencesStore: AppPreferencesStore, private val appPreferencesStore: AppPreferencesStore,
private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability,
private val seenInvitesStore: SeenInvitesStore,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
private val encryptionService: EncryptionService = client.encryptionService() private val encryptionService: EncryptionService = client.encryptionService()
@ -227,6 +230,7 @@ class RoomListPresenter @Inject constructor(
loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading loadingState == RoomList.LoadingState.NotLoaded || roomSummaries is AsyncData.Loading
} }
} }
val seenRoomInvites by remember { seenInvitesStore.seenRoomIds() }.collectAsState(emptySet())
val securityBannerState by rememberSecurityBannerState(securityBannerDismissed) val securityBannerState by rememberSecurityBannerState(securityBannerDismissed)
return when { return when {
showEmpty -> RoomListContentState.Empty(securityBannerState = securityBannerState) showEmpty -> RoomListContentState.Empty(securityBannerState = securityBannerState)
@ -235,7 +239,8 @@ class RoomListPresenter @Inject constructor(
RoomListContentState.Rooms( RoomListContentState.Rooms(
securityBannerState = securityBannerState, securityBannerState = securityBannerState,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(), fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList() summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList(),
seenRoomInvites = seenRoomInvites.toPersistentSet(),
) )
} }
} }

View file

@ -19,6 +19,7 @@ import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermiss
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
@Immutable @Immutable
data class RoomListState( data class RoomListState(
@ -65,9 +66,11 @@ sealed interface RoomListContentState {
data class Empty( data class Empty(
val securityBannerState: SecurityBannerState, val securityBannerState: SecurityBannerState,
) : RoomListContentState ) : RoomListContentState
data class Rooms( data class Rooms(
val securityBannerState: SecurityBannerState, val securityBannerState: SecurityBannerState,
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState, val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
val summaries: ImmutableList<RoomListRoomSummary>, val summaries: ImmutableList<RoomListRoomSummary>,
val seenRoomInvites: ImmutableSet<RoomId>,
) : RoomListContentState ) : RoomListContentState
} }

View file

@ -46,6 +46,7 @@ import io.element.android.features.roomlist.impl.filters.RoomListFiltersState
import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState import io.element.android.features.roomlist.impl.filters.aRoomListFiltersState
import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState import io.element.android.features.roomlist.impl.filters.selection.FilterSelectionState
import io.element.android.features.roomlist.impl.model.RoomListRoomSummary import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.features.roomlist.impl.model.RoomSummaryDisplayType
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
@ -239,6 +240,8 @@ private fun RoomsViewList(
) { index, room -> ) { index, room ->
RoomSummaryRow( RoomSummaryRow(
room = room, room = room,
isInviteSeen = room.displayType == RoomSummaryDisplayType.INVITE &&
state.seenRoomInvites.contains(room.roomId),
onClick = onRoomClick, onClick = onRoomClick,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -68,6 +68,7 @@ internal val minHeight = 84.dp
@Composable @Composable
internal fun RoomSummaryRow( internal fun RoomSummaryRow(
room: RoomListRoomSummary, room: RoomListRoomSummary,
isInviteSeen: Boolean,
onClick: (RoomListRoomSummary) -> Unit, onClick: (RoomListRoomSummary) -> Unit,
eventSink: (RoomListEvents) -> Unit, eventSink: (RoomListEvents) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -85,7 +86,7 @@ internal fun RoomSummaryRow(
Timber.d("Long click on invite room") Timber.d("Long click on invite room")
}, },
) { ) {
InviteNameAndIndicatorRow(name = room.name) InviteNameAndIndicatorRow(name = room.name, isInviteSeen = isInviteSeen)
InviteSubtitle(isDm = room.isDm, inviteSender = room.inviteSender) InviteSubtitle(isDm = room.isDm, inviteSender = room.inviteSender)
if (!room.isDm && room.inviteSender != null) { if (!room.isDm && room.inviteSender != null) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
@ -300,6 +301,7 @@ private fun LastMessageAndIndicatorRow(
@Composable @Composable
private fun InviteNameAndIndicatorRow( private fun InviteNameAndIndicatorRow(
name: String?, name: String?,
isInviteSeen: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
@ -316,9 +318,11 @@ private fun InviteNameAndIndicatorRow(
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
UnreadIndicatorAtom( if (!isInviteSeen) {
color = ElementTheme.colors.unreadIndicator UnreadIndicatorAtom(
) color = ElementTheme.colors.unreadIndicator
)
}
} }
} }
@ -384,6 +388,8 @@ private fun MentionIndicatorAtom() {
internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) = ElementPreview { internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryProvider::class) data: RoomListRoomSummary) = ElementPreview {
RoomSummaryRow( RoomSummaryRow(
room = data, room = data,
// Set isInviteSeen to true for the preview when the room has name "Bob"
isInviteSeen = data.name == "Bob",
onClick = {}, onClick = {},
eventSink = {}, eventSink = {},
) )

View file

@ -39,12 +39,10 @@ data class RoomListRoomSummary(
) { ) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE && val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) || (numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||
isMarkedUnread || isMarkedUnread
displayType == RoomSummaryDisplayType.INVITE
val hasNewContent = numberOfUnreadMessages > 0 || val hasNewContent = numberOfUnreadMessages > 0 ||
numberOfUnreadMentions > 0 || numberOfUnreadMentions > 0 ||
numberOfUnreadNotifications > 0 || numberOfUnreadNotifications > 0 ||
isMarkedUnread || isMarkedUnread
displayType == RoomSummaryDisplayType.INVITE
} }

View file

@ -173,6 +173,8 @@ private fun RoomListSearchContent(
) { room -> ) { room ->
RoomSummaryRow( RoomSummaryRow(
room = room, room = room,
// TODO
isInviteSeen = false,
onClick = ::onRoomClick, onClick = ::onRoomClick,
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -12,9 +12,11 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.analytics.plan.Interaction
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.invite.test.InMemorySeenInvitesStore
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import io.element.android.features.leaveroom.api.aLeaveRoomState import io.element.android.features.leaveroom.api.aLeaveRoomState
@ -169,10 +171,11 @@ class RoomListPresenterTest {
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService roomListService = roomListService
) )
val presenter = createRoomListPresenter(client = matrixClient) val presenter = createRoomListPresenter(
moleculeFlow(RecompositionMode.Immediate) { client = matrixClient,
presenter.present() seenInvitesStore = InMemorySeenInvitesStore(setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)),
}.test { )
presenter.test {
val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last() val initialState = consumeItemsUntilPredicate { state -> state.contentState is RoomListContentState.Skeleton }.last()
assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java) assertThat(initialState.contentState).isInstanceOf(RoomListContentState.Skeleton::class.java)
roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1)) roomListService.postAllRoomsLoadingState(RoomList.LoadingState.Loaded(1))
@ -194,6 +197,7 @@ class RoomListPresenterTest {
timestamp = "0 TimeOrDate true", timestamp = "0 TimeOrDate true",
) )
) )
assertThat(withRoomsState.contentAsRooms().seenRoomInvites).containsExactly(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3)
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@ -680,6 +684,7 @@ class RoomListPresenterTest {
notificationCleaner: NotificationCleaner = FakeNotificationCleaner(), notificationCleaner: NotificationCleaner = FakeNotificationCleaner(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true }, rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { true },
seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore()
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
syncService = syncService, syncService = syncService,
@ -711,6 +716,7 @@ class RoomListPresenterTest {
logoutPresenter = { aDirectLogoutState() }, logoutPresenter = { aDirectLogoutState() },
appPreferencesStore = appPreferencesStore, appPreferencesStore = appPreferencesStore,
rageshakeFeatureAvailability = rageshakeFeatureAvailability, rageshakeFeatureAvailability = rageshakeFeatureAvailability,
seenInvitesStore = seenInvitesStore,
) )
} }

View file

@ -65,12 +65,12 @@ class RoomListRoomSummaryTest {
} }
@Test @Test
fun `when display type is invite then isHighlighted and hasNewContent are true`() { fun `when display type is invite then isHighlighted and hasNewContent are false`() {
val sut = createRoomListRoomSummary( val sut = createRoomListRoomSummary(
displayType = RoomSummaryDisplayType.INVITE, displayType = RoomSummaryDisplayType.INVITE,
) )
assertThat(sut.isHighlighted).isTrue() assertThat(sut.isHighlighted).isFalse()
assertThat(sut.hasNewContent).isTrue() assertThat(sut.hasNewContent).isFalse()
} }
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f64d2899886ab2e20bbd2dd0e0e2bd909f461751e60f4b6d93e506963c41ce03 oid sha256:a92aa815f9c74893ea1b9b98912f0eefd86159af900ece588a3eb426264d076a
size 15832 size 15473

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:20a0dfc19f05e9727cf70cf7eaf83503c805423cc36edbefaa119c2692eaadd2 oid sha256:8287ed2eda7e0d486f6122c5bd61517f7f6ccc5ea21413f2eea085683d15ba68
size 15678 size 15370