Browse Source
Show badges for new invites Closes #238feature/jme/open-room-member-details-when-clicking-on-user-data
Chris Smith
1 year ago
committed by
GitHub
49 changed files with 746 additions and 155 deletions
@ -0,0 +1 @@
@@ -0,0 +1 @@
|
||||
[Create and join rooms] New invites are now marked with a badge |
@ -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>) |
||||
} |
@ -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() |
||||
} |
||||
} |
||||
|
||||
} |
@ -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) |
||||
} |
@ -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() |
||||
} |
||||
} |
@ -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 } |
@ -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 |
||||
|
||||
} |
@ -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) |
||||
} |
||||
} |
||||
|
||||
} |
@ -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 |
||||
} |
||||
} |
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loading…
Reference in new issue