Merge pull request #2397 from element-hq/feature/fga/mark_room_as_favorite

Feature/fga/mark room as favorite
This commit is contained in:
ganfra 2024-02-15 17:18:24 +01:00 committed by GitHub
commit 8fab748c9a
61 changed files with 405 additions and 205 deletions

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

@ -0,0 +1 @@
Mark a room or dm as favourite.

View file

@ -20,4 +20,5 @@ sealed interface RoomDetailsEvent {
data object LeaveRoom : RoomDetailsEvent data object LeaveRoom : RoomDetailsEvent
data object MuteNotification : RoomDetailsEvent data object MuteNotification : RoomDetailsEvent
data object UnmuteNotification : RoomDetailsEvent data object UnmuteNotification : RoomDetailsEvent
data class SetFavorite(val isFavorite: Boolean) : RoomDetailsEvent
} }

View file

@ -31,6 +31,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@ -65,12 +66,13 @@ class RoomDetailsPresenter @Inject constructor(
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val leaveRoomState = leaveRoomPresenter.present() val leaveRoomState = leaveRoomPresenter.present()
val canShowNotificationSettings = remember { mutableStateOf(false) } val canShowNotificationSettings = remember { mutableStateOf(false) }
val roomInfo = room.roomInfoFlow.collectAsState(initial = null).value val roomInfo by room.roomInfoFlow.collectAsState(initial = null)
val roomAvatar by remember { derivedStateOf { roomInfo?.avatarUrl ?: room.avatarUrl } } val roomAvatar by remember { derivedStateOf { roomInfo?.avatarUrl ?: room.avatarUrl } }
val roomName by remember { derivedStateOf { (roomInfo?.name ?: room.name ?: room.displayName).trim() } } val roomName by remember { derivedStateOf { (roomInfo?.name ?: room.name ?: room.displayName).trim() } }
val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } } val roomTopic by remember { derivedStateOf { roomInfo?.topic ?: room.topic } }
val isFavorite by remember { derivedStateOf { roomInfo?.isFavorite.orFalse() } }
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings) canShowNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
@ -122,6 +124,11 @@ class RoomDetailsPresenter @Inject constructor(
client.notificationSettingsService().unmuteRoom(room.roomId, room.isEncrypted, room.isOneToOne) client.notificationSettingsService().unmuteRoom(room.roomId, room.isEncrypted, room.isOneToOne)
} }
} }
is RoomDetailsEvent.SetFavorite -> {
scope.launch {
room.setIsFavorite(event.isFavorite)
}
}
} }
} }
@ -142,6 +149,7 @@ class RoomDetailsPresenter @Inject constructor(
roomMemberDetailsState = roomMemberDetailsState, roomMemberDetailsState = roomMemberDetailsState,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(),
isFavorite = isFavorite,
eventSink = ::handleEvents, eventSink = ::handleEvents,
) )
} }

View file

@ -36,6 +36,7 @@ data class RoomDetailsState(
val canShowNotificationSettings: Boolean, val canShowNotificationSettings: Boolean,
val leaveRoomState: LeaveRoomState, val leaveRoomState: LeaveRoomState,
val roomNotificationSettings: RoomNotificationSettings?, val roomNotificationSettings: RoomNotificationSettings?,
val isFavorite: Boolean,
val eventSink: (RoomDetailsEvent) -> Unit val eventSink: (RoomDetailsEvent) -> Unit
) )

View file

@ -36,6 +36,7 @@ open class RoomDetailsStateProvider : PreviewParameterProvider<RoomDetailsState>
aDmRoomDetailsState().copy(roomName = "Daniel"), aDmRoomDetailsState().copy(roomName = "Daniel"),
aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"), aDmRoomDetailsState(isDmMemberIgnored = true).copy(roomName = "Daniel"),
aRoomDetailsState().copy(canInvite = true), aRoomDetailsState().copy(canInvite = true),
aRoomDetailsState().copy(isFavorite = true),
aRoomDetailsState().copy( aRoomDetailsState().copy(
canEdit = true, canEdit = true,
// Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed // Also test the roomNotificationSettings ALL_MESSAGES in the same screenshot. Icon 'Mute' should be displayed
@ -86,6 +87,7 @@ fun aRoomDetailsState() = RoomDetailsState(
roomMemberDetailsState = null, roomMemberDetailsState = null,
leaveRoomState = aLeaveRoomState(), leaveRoomState = aLeaveRoomState(),
roomNotificationSettings = RoomNotificationSettings(mode = RoomNotificationMode.MUTE, isDefault = false), roomNotificationSettings = RoomNotificationSettings(mode = RoomNotificationMode.MUTE, isDefault = false),
isFavorite = false,
eventSink = {} eventSink = {}
) )

View file

@ -61,6 +61,7 @@ import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.MainActionButton import io.element.android.libraries.designsystem.components.button.MainActionButton
import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText import io.element.android.libraries.designsystem.components.preferences.PreferenceText
import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.ElementPreviewLight
@ -163,6 +164,13 @@ fun RoomDetailsView(
) )
} }
FavoriteSection(
isFavorite = state.isFavorite,
onFavoriteChanges = {
state.eventSink(RoomDetailsEvent.SetFavorite(it))
}
)
if (state.roomType is RoomDetailsType.Room) { if (state.roomType is RoomDetailsType.Room) {
MembersSection( MembersSection(
memberCount = state.memberCount, memberCount = state.memberCount,
@ -356,6 +364,22 @@ private fun NotificationSection(
} }
} }
@Composable
private fun FavoriteSection(
isFavorite: Boolean,
onFavoriteChanges: (Boolean) -> Unit,
modifier: Modifier = Modifier
) {
PreferenceCategory(modifier = modifier) {
PreferenceSwitch(
icon = CompoundIcons.Favourite(),
title = stringResource(id = CommonStrings.common_favourite),
isChecked = isFavorite,
onCheckedChange = onFavoriteChanges
)
}
}
@Composable @Composable
private fun MembersSection( private fun MembersSection(
memberCount: Long, memberCount: Long,

View file

@ -19,6 +19,7 @@ package io.element.android.features.roomdetails
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import app.cash.molecule.RecompositionMode import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
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.createroom.test.FakeStartDMAction import io.element.android.features.createroom.test.FakeStartDMAction
@ -27,6 +28,7 @@ import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter import io.element.android.features.leaveroom.fake.FakeLeaveRoomPresenter
import io.element.android.features.roomdetails.impl.RoomDetailsEvent import io.element.android.features.roomdetails.impl.RoomDetailsEvent
import io.element.android.features.roomdetails.impl.RoomDetailsPresenter import io.element.android.features.roomdetails.impl.RoomDetailsPresenter
import io.element.android.features.roomdetails.impl.RoomDetailsState
import io.element.android.features.roomdetails.impl.RoomDetailsType import io.element.android.features.roomdetails.impl.RoomDetailsType
import io.element.android.features.roomdetails.impl.RoomTopicState import io.element.android.features.roomdetails.impl.RoomTopicState
import io.element.android.features.roomdetails.impl.members.aRoomMember import io.element.android.features.roomdetails.impl.members.aRoomMember
@ -71,10 +73,10 @@ class RoomDetailsPresenterTests {
} }
private fun TestScope.createRoomDetailsPresenter( private fun TestScope.createRoomDetailsPresenter(
room: MatrixRoom, room: MatrixRoom = aMatrixRoom(),
leaveRoomPresenter: LeaveRoomPresenter = FakeLeaveRoomPresenter(), leaveRoomPresenter: LeaveRoomPresenter = FakeLeaveRoomPresenter(),
dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService() notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(),
): RoomDetailsPresenter { ): RoomDetailsPresenter {
val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService)
val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory { val roomMemberDetailsPresenterFactory = object : RoomMemberDetailsPresenter.Factory {
@ -86,25 +88,29 @@ class RoomDetailsPresenterTests {
mapOf(FeatureFlags.NotificationSettings.key to true) mapOf(FeatureFlags.NotificationSettings.key to true)
) )
return RoomDetailsPresenter( return RoomDetailsPresenter(
matrixClient, client = matrixClient,
room, room = room,
featureFlagService, featureFlagService = featureFlagService,
matrixClient.notificationSettingsService(), notificationSettingsService = matrixClient.notificationSettingsService(),
roomMemberDetailsPresenterFactory, roomMembersDetailsPresenterFactory = roomMemberDetailsPresenterFactory,
leaveRoomPresenter, leaveRoomPresenter = leaveRoomPresenter,
dispatchers dispatchers = dispatchers,
) )
} }
private suspend fun RoomDetailsPresenter.test(validate: suspend TurbineTestContext<RoomDetailsState>.() -> Unit) {
moleculeFlow(RecompositionMode.Immediate) {
withFakeLifecycleOwner(fakeLifecycleOwner) {
present()
}
}.test(validate = validate)
}
@Test @Test
fun `present - initial state is created from room if roomInfo is null`() = runTest { fun `present - initial state is created from room if roomInfo is null`() = runTest {
val room = aMatrixRoom() val room = aMatrixRoom()
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.roomId).isEqualTo(room.roomId.value) assertThat(initialState.roomId).isEqualTo(room.roomId.value)
assertThat(initialState.roomName).isEqualTo(room.name) assertThat(initialState.roomName).isEqualTo(room.name)
@ -124,11 +130,7 @@ class RoomDetailsPresenterTests {
givenRoomInfo(roomInfo) givenRoomInfo(roomInfo)
} }
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
skipItems(1) skipItems(1)
val updatedState = awaitItem() val updatedState = awaitItem()
assertThat(updatedState.roomName).isEqualTo(roomInfo.name) assertThat(updatedState.roomName).isEqualTo(roomInfo.name)
@ -143,11 +145,7 @@ class RoomDetailsPresenterTests {
fun `present - initial state with no room name`() = runTest { fun `present - initial state with no room name`() = runTest {
val room = aMatrixRoom(name = null) val room = aMatrixRoom(name = null)
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.roomName).isEqualTo(room.displayName) assertThat(initialState.roomName).isEqualTo(room.displayName)
@ -167,11 +165,7 @@ class RoomDetailsPresenterTests {
givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers)) givenRoomMembersState(MatrixRoomMembersState.Ready(roomMembers))
} }
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember)) assertThat(initialState.roomType).isEqualTo(RoomDetailsType.Dm(otherRoomMember))
@ -185,11 +179,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(true)) givenCanInviteResult(Result.success(true))
} }
val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers()) val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers())
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
// Initially false // Initially false
assertThat(awaitItem().canInvite).isFalse() assertThat(awaitItem().canInvite).isFalse()
// Then the asynchronous check completes and it becomes true // Then the asynchronous check completes and it becomes true
@ -205,11 +195,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false)) givenCanInviteResult(Result.success(false))
} }
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
assertThat(awaitItem().canInvite).isFalse() assertThat(awaitItem().canInvite).isFalse()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@ -222,11 +208,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.failure(Throwable("Whoops"))) givenCanInviteResult(Result.failure(Throwable("Whoops")))
} }
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
assertThat(awaitItem().canInvite).isFalse() assertThat(awaitItem().canInvite).isFalse()
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
@ -242,11 +224,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false)) givenCanInviteResult(Result.success(false))
} }
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
// Initially false // Initially false
assertThat(awaitItem().canEdit).isFalse() assertThat(awaitItem().canEdit).isFalse()
// Then the asynchronous check completes and it becomes true // Then the asynchronous check completes and it becomes true
@ -273,11 +251,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false)) givenCanInviteResult(Result.success(false))
} }
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
// Initially false // Initially false
assertThat(awaitItem().canEdit).isFalse() assertThat(awaitItem().canEdit).isFalse()
// Then the asynchronous check completes, but editing is still disallowed because it's a DM // Then the asynchronous check completes, but editing is still disallowed because it's a DM
@ -305,11 +279,7 @@ class RoomDetailsPresenterTests {
givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true)) givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true))
} }
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
skipItems(1) skipItems(1)
// There's no topic, so we hide the entire UI for DMs // There's no topic, so we hide the entire UI for DMs
@ -328,11 +298,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false)) givenCanInviteResult(Result.success(false))
} }
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
// Initially false // Initially false
assertThat(awaitItem().canEdit).isFalse() assertThat(awaitItem().canEdit).isFalse()
// Then the asynchronous check completes and it becomes true // Then the asynchronous check completes and it becomes true
@ -351,11 +317,7 @@ class RoomDetailsPresenterTests {
givenCanInviteResult(Result.success(false)) givenCanInviteResult(Result.success(false))
} }
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
// Initially false, and no further events // Initially false, and no further events
assertThat(awaitItem().canEdit).isFalse() assertThat(awaitItem().canEdit).isFalse()
@ -371,11 +333,7 @@ class RoomDetailsPresenterTests {
} }
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
// The initial state is "hidden" and no further state changes happen // The initial state is "hidden" and no further state changes happen
assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden) assertThat(awaitItem().roomTopic).isEqualTo(RoomTopicState.Hidden)
@ -392,11 +350,7 @@ class RoomDetailsPresenterTests {
} }
val presenter = createRoomDetailsPresenter(room) val presenter = createRoomDetailsPresenter(room)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
// Ignore the initial state // Ignore the initial state
skipItems(1) skipItems(1)
@ -416,11 +370,7 @@ class RoomDetailsPresenterTests {
leaveRoomPresenter = leaveRoomPresenter, leaveRoomPresenter = leaveRoomPresenter,
dispatchers = testCoroutineDispatchers() dispatchers = testCoroutineDispatchers()
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
awaitItem().eventSink(RoomDetailsEvent.LeaveRoom) awaitItem().eventSink(RoomDetailsEvent.LeaveRoom)
assertThat(leaveRoomPresenter.events).contains(LeaveRoomEvent.ShowConfirmation(room.roomId)) assertThat(leaveRoomPresenter.events).contains(LeaveRoomEvent.ShowConfirmation(room.roomId))
@ -439,11 +389,7 @@ class RoomDetailsPresenterTests {
leaveRoomPresenter = leaveRoomPresenter, leaveRoomPresenter = leaveRoomPresenter,
notificationSettingsService = notificationSettingsService, notificationSettingsService = notificationSettingsService,
) )
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
notificationSettingsService.setRoomNotificationMode(room.roomId, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) notificationSettingsService.setRoomNotificationMode(room.roomId, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val updatedState = consumeItemsUntilPredicate { val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY it.roomNotificationSettings?.mode == RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
@ -458,11 +404,7 @@ class RoomDetailsPresenterTests {
val notificationSettingsService = FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) val notificationSettingsService = FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService) val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
val presenter = createRoomDetailsPresenter(room = room, notificationSettingsService = notificationSettingsService) val presenter = createRoomDetailsPresenter(room = room, notificationSettingsService = notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
awaitItem().eventSink(RoomDetailsEvent.MuteNotification) awaitItem().eventSink(RoomDetailsEvent.MuteNotification)
val updatedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { val updatedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) {
it.roomNotificationSettings?.mode == RoomNotificationMode.MUTE it.roomNotificationSettings?.mode == RoomNotificationMode.MUTE
@ -480,11 +422,7 @@ class RoomDetailsPresenterTests {
) )
val room = aMatrixRoom(notificationSettingsService = notificationSettingsService) val room = aMatrixRoom(notificationSettingsService = notificationSettingsService)
val presenter = createRoomDetailsPresenter(room = room, notificationSettingsService = notificationSettingsService) val presenter = createRoomDetailsPresenter(room = room, notificationSettingsService = notificationSettingsService)
moleculeFlow(RecompositionMode.Immediate) { presenter.test {
withFakeLifecycleOwner(fakeLifecycleOwner) {
presenter.present()
}
}.test {
awaitItem().eventSink(RoomDetailsEvent.UnmuteNotification) awaitItem().eventSink(RoomDetailsEvent.UnmuteNotification)
val updatedState = consumeItemsUntilPredicate { val updatedState = consumeItemsUntilPredicate {
it.roomNotificationSettings?.mode == RoomNotificationMode.ALL_MESSAGES it.roomNotificationSettings?.mode == RoomNotificationMode.ALL_MESSAGES
@ -493,6 +431,37 @@ class RoomDetailsPresenterTests {
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
} }
} }
@Test
fun `present - when set is favorite event is emitted, then the action is called`() = runTest {
val room = FakeMatrixRoom()
val presenter = createRoomDetailsPresenter(room = room)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(RoomDetailsEvent.SetFavorite(true))
assertThat(room.setIsFavoriteCalls).isEqualTo(listOf(true))
initialState.eventSink(RoomDetailsEvent.SetFavorite(false))
assertThat(room.setIsFavoriteCalls).isEqualTo(listOf(true, false))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - changes in room info updates the is favorite flag`() = runTest {
val room = aMatrixRoom()
val presenter = createRoomDetailsPresenter(room = room)
presenter.test {
room.givenRoomInfo(aRoomInfo(isFavorite = true))
consumeItemsUntilPredicate { it.isFavorite }.last().let { state ->
assertThat(state.isFavorite).isTrue()
}
room.givenRoomInfo(aRoomInfo(isFavorite = false))
consumeItemsUntilPredicate { !it.isFavorite }.last().let { state ->
assertThat(state.isFavorite).isFalse()
}
cancelAndIgnoreRemainingEvents()
}
}
} }
fun aMatrixRoom( fun aMatrixRoom(

View file

@ -41,7 +41,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun RoomListContextMenu( fun RoomListContextMenu(
contextMenu: RoomListState.ContextMenu.Shown, contextMenu: RoomListState.ContextMenu.Shown,
eventSink: (RoomListEvents.RoomListBottomSheetEvents) -> Unit, eventSink: (RoomListEvents.ContextMenuEvents) -> Unit,
onRoomSettingsClicked: (roomId: RoomId) -> Unit, onRoomSettingsClicked: (roomId: RoomId) -> Unit,
) { ) {
ModalBottomSheet( ModalBottomSheet(
@ -64,7 +64,10 @@ fun RoomListContextMenu(
onLeaveRoomClicked = { onLeaveRoomClicked = {
eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvents.HideContextMenu)
eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId)) eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId))
} },
onFavoriteChanged = { isFavorite ->
eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite))
},
) )
} }
} }
@ -72,10 +75,11 @@ fun RoomListContextMenu(
@Composable @Composable
private fun RoomListModalBottomSheetContent( private fun RoomListModalBottomSheetContent(
contextMenu: RoomListState.ContextMenu.Shown, contextMenu: RoomListState.ContextMenu.Shown,
onRoomMarkReadClicked: () -> Unit,
onRoomMarkUnreadClicked: () -> Unit,
onRoomSettingsClicked: () -> Unit, onRoomSettingsClicked: () -> Unit,
onLeaveRoomClicked: () -> Unit, onLeaveRoomClicked: () -> Unit,
onFavoriteChanged: (isFavorite: Boolean) -> Unit,
onRoomMarkReadClicked: () -> Unit,
onRoomMarkUnreadClicked: () -> Unit,
) { ) {
Column( Column(
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
@ -120,6 +124,30 @@ private fun RoomListModalBottomSheetContent(
style = ListItemStyle.Primary, style = ListItemStyle.Primary,
) )
} }
ListItem(
headlineContent = {
Text(
text = stringResource(id = CommonStrings.common_favourite),
style = MaterialTheme.typography.bodyLarge,
)
},
leadingContent = ListItemContent.Icon(
iconSource = IconSource.Vector(
CompoundIcons.Favourite(),
contentDescription = stringResource(id = CommonStrings.common_favourite),
)
),
trailingContent = ListItemContent.Switch(
checked = contextMenu.isFavorite,
onChange = { isFavorite ->
onFavoriteChanged(isFavorite)
},
),
onClick = {
onFavoriteChanged(!contextMenu.isFavorite)
},
style = ListItemStyle.Primary,
)
ListItem( ListItem(
headlineContent = { headlineContent = {
Text( Text(
@ -170,7 +198,8 @@ internal fun RoomListModalBottomSheetContentPreview() = ElementPreview {
onRoomMarkReadClicked = {}, onRoomMarkReadClicked = {},
onRoomMarkUnreadClicked = {}, onRoomMarkUnreadClicked = {},
onRoomSettingsClicked = {}, onRoomSettingsClicked = {},
onLeaveRoomClicked = {} onLeaveRoomClicked = {},
onFavoriteChanged = {},
) )
} }
@ -182,6 +211,7 @@ internal fun RoomListModalBottomSheetContentForDmPreview() = ElementPreview {
onRoomMarkReadClicked = {}, onRoomMarkReadClicked = {},
onRoomMarkUnreadClicked = {}, onRoomMarkUnreadClicked = {},
onRoomSettingsClicked = {}, onRoomSettingsClicked = {},
onLeaveRoomClicked = {} onLeaveRoomClicked = {},
onFavoriteChanged = {},
) )
} }

View file

@ -27,9 +27,10 @@ sealed interface RoomListEvents {
data object ToggleSearchResults : RoomListEvents data object ToggleSearchResults : RoomListEvents
data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents data class ShowContextMenu(val roomListRoomSummary: RoomListRoomSummary) : RoomListEvents
sealed interface RoomListBottomSheetEvents : RoomListEvents sealed interface ContextMenuEvents : RoomListEvents
data object HideContextMenu : RoomListBottomSheetEvents data object HideContextMenu : ContextMenuEvents
data class LeaveRoom(val roomId: RoomId) : RoomListBottomSheetEvents data class LeaveRoom(val roomId: RoomId) : ContextMenuEvents
data class MarkAsRead(val roomId: RoomId) : RoomListBottomSheetEvents data class MarkAsRead(val roomId: RoomId) : ContextMenuEvents
data class MarkAsUnread(val roomId: RoomId) : RoomListBottomSheetEvents data class MarkAsUnread(val roomId: RoomId) : ContextMenuEvents
data class SetRoomIsFavorite(val roomId: RoomId, val isFavorite: Boolean) : ContextMenuEvents
} }

View file

@ -28,6 +28,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
@ -51,7 +52,14 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.getCurrentUser import io.element.android.libraries.matrix.api.user.getCurrentUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.takeWhile
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
@ -111,15 +119,11 @@ class RoomListPresenter @Inject constructor(
} }
} }
val markAsUnreadFeatureFlagEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.MarkAsUnread)
.collectAsState(initial = null)
// Avatar indicator // Avatar indicator
val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator() val showAvatarIndicator by indicatorService.showRoomListTopBarIndicator()
var displaySearchResults by rememberSaveable { mutableStateOf(false) } var displaySearchResults by rememberSaveable { mutableStateOf(false) }
val contextMenu = remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
var contextMenu by remember { mutableStateOf<RoomListState.ContextMenu>(RoomListState.ContextMenu.Hidden) }
fun handleEvents(event: RoomListEvents) { fun handleEvents(event: RoomListEvents) {
when (event) { when (event) {
@ -134,16 +138,18 @@ class RoomListPresenter @Inject constructor(
displaySearchResults = !displaySearchResults displaySearchResults = !displaySearchResults
} }
is RoomListEvents.ShowContextMenu -> { is RoomListEvents.ShowContextMenu -> {
contextMenu = RoomListState.ContextMenu.Shown( coroutineScope.showContextMenu(event, contextMenu)
roomId = event.roomListRoomSummary.roomId, }
roomName = event.roomListRoomSummary.name, is RoomListEvents.HideContextMenu -> {
isDm = event.roomListRoomSummary.isDm, contextMenu.value = RoomListState.ContextMenu.Hidden
markAsUnreadFeatureFlagEnabled = markAsUnreadFeatureFlagEnabled == true,
hasNewContent = event.roomListRoomSummary.hasNewContent
)
} }
is RoomListEvents.HideContextMenu -> contextMenu = RoomListState.ContextMenu.Hidden
is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId)) is RoomListEvents.LeaveRoom -> leaveRoomState.eventSink(LeaveRoomEvent.ShowConfirmation(event.roomId))
is RoomListEvents.SetRoomIsFavorite -> coroutineScope.launch {
client.getRoom(event.roomId)?.use { room ->
room.setIsFavorite(event.isFavorite)
}
}
is RoomListEvents.MarkAsRead -> coroutineScope.launch { is RoomListEvents.MarkAsRead -> coroutineScope.launch {
client.getRoom(event.roomId)?.use { room -> client.getRoom(event.roomId)?.use { room ->
room.setUnreadFlag(isUnread = false) room.setUnreadFlag(isUnread = false)
@ -177,7 +183,7 @@ class RoomListPresenter @Inject constructor(
hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online, hasNetworkConnection = networkConnectionStatus == NetworkStatus.Online,
invitesState = inviteStateDataSource.inviteState(), invitesState = inviteStateDataSource.inviteState(),
displaySearchResults = displaySearchResults, displaySearchResults = displaySearchResults,
contextMenu = contextMenu, contextMenu = contextMenu.value,
leaveRoomState = leaveRoomState, leaveRoomState = leaveRoomState,
displayMigrationStatus = isMigrating, displayMigrationStatus = isMigrating,
eventSink = ::handleEvents eventSink = ::handleEvents
@ -188,6 +194,37 @@ class RoomListPresenter @Inject constructor(
matrixUser.value = client.getCurrentUser() matrixUser.value = client.getCurrentUser()
} }
@OptIn(ExperimentalCoroutinesApi::class)
private fun CoroutineScope.showContextMenu(event: RoomListEvents.ShowContextMenu, contextMenuState: MutableState<RoomListState.ContextMenu>) = launch {
val initialState = RoomListState.ContextMenu.Shown(
roomId = event.roomListRoomSummary.roomId,
roomName = event.roomListRoomSummary.name,
isDm = event.roomListRoomSummary.isDm,
isFavorite = event.roomListRoomSummary.isFavorite,
markAsUnreadFeatureFlagEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.MarkAsUnread),
hasNewContent = event.roomListRoomSummary.hasNewContent
)
contextMenuState.value = initialState
client.getRoom(event.roomListRoomSummary.roomId)?.use { room ->
val isShowingContextMenuFlow = snapshotFlow { contextMenuState.value is RoomListState.ContextMenu.Shown }
.distinctUntilChanged()
val isFavoriteFlow = room.roomInfoFlow
.map { it.isFavorite }
.distinctUntilChanged()
isFavoriteFlow
.onEach { isFavorite ->
contextMenuState.value = initialState.copy(isFavorite = isFavorite)
}
.flatMapLatest { isShowingContextMenuFlow }
.takeWhile { isShowingContextMenu -> isShowingContextMenu }
.collect()
}
}
private fun updateVisibleRange(range: IntRange) { private fun updateVisibleRange(range: IntRange) {
if (range.isEmpty()) return if (range.isEmpty()) return
val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2 val midExtendedRangeSize = EXTENDED_RANGE_SIZE / 2

View file

@ -49,6 +49,7 @@ data class RoomListState(
val roomId: RoomId, val roomId: RoomId,
val roomName: String, val roomName: String,
val isDm: Boolean, val isDm: Boolean,
val isFavorite: Boolean,
val markAsUnreadFeatureFlagEnabled: Boolean, val markAsUnreadFeatureFlagEnabled: Boolean,
val hasNewContent: Boolean, val hasNewContent: Boolean,
) : ContextMenu ) : ContextMenu

View file

@ -44,6 +44,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()), aRoomListState().copy(displaySearchResults = true, filter = "", filteredRoomList = persistentListOf()),
aRoomListState().copy(displaySearchResults = true), aRoomListState().copy(displaySearchResults = true),
aRoomListState().copy(contextMenu = aContextMenuShown(roomName = "A nice room name")), aRoomListState().copy(contextMenu = aContextMenuShown(roomName = "A nice room name")),
aRoomListState().copy(contextMenu = aContextMenuShown(isFavorite = true)),
aRoomListState().copy(displayRecoveryKeyPrompt = true), aRoomListState().copy(displayRecoveryKeyPrompt = true),
aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())), aRoomListState().copy(roomList = AsyncData.Success(persistentListOf())),
aRoomListState().copy(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())), aRoomListState().copy(roomList = AsyncData.Loading(prevData = RoomListRoomSummaryFactory.createFakeList())),
@ -102,10 +103,12 @@ internal fun aContextMenuShown(
roomName: String = "aRoom", roomName: String = "aRoom",
isDm: Boolean = false, isDm: Boolean = false,
hasNewContent: Boolean = false, hasNewContent: Boolean = false,
isFavorite: Boolean = false,
) = RoomListState.ContextMenu.Shown( ) = RoomListState.ContextMenu.Shown(
roomId = RoomId("!aRoom:aDomain"), roomId = RoomId("!aRoom:aDomain"),
roomName = roomName, roomName = roomName,
isDm = isDm, isDm = isDm,
markAsUnreadFeatureFlagEnabled = true, markAsUnreadFeatureFlagEnabled = true,
hasNewContent = hasNewContent, hasNewContent = hasNewContent,
isFavorite = isFavorite,
) )

View file

@ -49,6 +49,7 @@ class RoomListRoomSummaryFactory @Inject constructor(
userDefinedNotificationMode = null, userDefinedNotificationMode = null,
hasRoomCall = false, hasRoomCall = false,
isDm = false, isDm = false,
isFavorite = false,
) )
} }
@ -84,6 +85,7 @@ class RoomListRoomSummaryFactory @Inject constructor(
userDefinedNotificationMode = roomSummary.details.userDefinedNotificationMode, userDefinedNotificationMode = roomSummary.details.userDefinedNotificationMode,
hasRoomCall = roomSummary.details.hasRoomCall, hasRoomCall = roomSummary.details.hasRoomCall,
isDm = roomSummary.details.isDm, isDm = roomSummary.details.isDm,
isFavorite = roomSummary.details.isFavorite,
) )
} }
} }

View file

@ -37,6 +37,7 @@ data class RoomListRoomSummary(
val userDefinedNotificationMode: RoomNotificationMode?, val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean, val hasRoomCall: Boolean,
val isDm: Boolean, val isDm: Boolean,
val isFavorite: Boolean,
) { ) {
val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE && val isHighlighted = userDefinedNotificationMode != RoomNotificationMode.MUTE &&
(numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) || (numberOfUnreadNotifications > 0 || numberOfUnreadMentions > 0) ||

View file

@ -97,6 +97,7 @@ internal fun aRoomListRoomSummary(
hasRoomCall: Boolean = false, hasRoomCall: Boolean = false,
avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem), avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
isDm: Boolean = false, isDm: Boolean = false,
isFavorite: Boolean = false,
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = id, id = id,
roomId = RoomId(id), roomId = RoomId(id),
@ -112,4 +113,5 @@ internal fun aRoomListRoomSummary(
userDefinedNotificationMode = notificationMode, userDefinedNotificationMode = notificationMode,
hasRoomCall = hasRoomCall, hasRoomCall = hasRoomCall,
isDm = isDm, isDm = isDm,
isFavorite = isFavorite,
) )

View file

@ -19,6 +19,7 @@ package io.element.android.features.roomlist.impl
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithParam import io.element.android.tests.testutils.EnsureCalledOnceWithParam
import io.element.android.tests.testutils.EnsureNeverCalledWithParam import io.element.android.tests.testutils.EnsureNeverCalledWithParam
@ -128,4 +129,24 @@ class RoomListContextMenuTest {
eventsRecorder.assertSingle(RoomListEvents.HideContextMenu) eventsRecorder.assertSingle(RoomListEvents.HideContextMenu)
callback.assertSuccess() callback.assertSuccess()
} }
@Test
fun `clicking on Favourites generates expected Event`() {
val eventsRecorder = EventsRecorder<RoomListEvents>()
val contextMenu = aContextMenuShown(isDm = false, isFavorite = false)
val callback = EnsureNeverCalledWithParam<RoomId>()
rule.setContent {
RoomListContextMenu(
contextMenu = contextMenu,
eventSink = eventsRecorder,
onRoomSettingsClicked = callback,
)
}
rule.clickOn(CommonStrings.common_favourite)
eventsRecorder.assertList(
listOf(
RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, true),
)
)
}
} }

View file

@ -62,6 +62,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled import io.element.android.libraries.matrix.test.room.aRoomSummaryFilled
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
@ -340,26 +341,49 @@ class RoomListPresenterTests {
@Test @Test
fun `present - show context menu`() = runTest { fun `present - show context menu`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob()) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(coroutineScope = scope) val room = FakeMatrixRoom()
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val presenter = createRoomListPresenter(client = client, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
skipItems(1) skipItems(1)
val initialState = awaitItem() val initialState = awaitItem()
val summary = createRoomListRoomSummary() val summary = createRoomListRoomSummary()
initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
val shownState = awaitItem() awaitItem().also { state ->
assertThat(shownState.contextMenu).isEqualTo( assertThat(state.contextMenu)
RoomListState.ContextMenu.Shown( .isEqualTo(
roomId = summary.roomId, RoomListState.ContextMenu.Shown(
roomName = summary.name, roomId = summary.roomId,
isDm = false, roomName = summary.name,
markAsUnreadFeatureFlagEnabled = true, isDm = false,
hasNewContent = false, isFavorite = false,
) markAsUnreadFeatureFlagEnabled = true,
hasNewContent = false,
)
)
}
room.givenRoomInfo(
aRoomInfo(isFavorite = true)
) )
awaitItem().also { state ->
assertThat(state.contextMenu)
.isEqualTo(
RoomListState.ContextMenu.Shown(
roomId = summary.roomId,
roomName = summary.name,
isDm = false,
isFavorite = true,
markAsUnreadFeatureFlagEnabled = true,
hasNewContent = false,
)
)
}
scope.cancel() scope.cancel()
} }
} }
@ -367,7 +391,11 @@ class RoomListPresenterTests {
@Test @Test
fun `present - hide context menu`() = runTest { fun `present - hide context menu`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob()) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(coroutineScope = scope) val room = FakeMatrixRoom()
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val presenter = createRoomListPresenter(client = client, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -378,15 +406,18 @@ class RoomListPresenterTests {
initialState.eventSink(RoomListEvents.ShowContextMenu(summary)) initialState.eventSink(RoomListEvents.ShowContextMenu(summary))
val shownState = awaitItem() val shownState = awaitItem()
assertThat(shownState.contextMenu).isEqualTo( assertThat(shownState.contextMenu)
RoomListState.ContextMenu.Shown( .isEqualTo(
roomId = summary.roomId, RoomListState.ContextMenu.Shown(
roomName = summary.name, roomId = summary.roomId,
isDm = false, roomName = summary.name,
markAsUnreadFeatureFlagEnabled = true, isDm = false,
hasNewContent = false, isFavorite = false,
markAsUnreadFeatureFlagEnabled = true,
hasNewContent = false,
)
) )
)
shownState.eventSink(RoomListEvents.HideContextMenu) shownState.eventSink(RoomListEvents.HideContextMenu)
val hiddenState = awaitItem() val hiddenState = awaitItem()
@ -440,6 +471,26 @@ class RoomListPresenterTests {
} }
@Test @Test
fun `present - when set is favorite event is emitted, then the action is called`() = runTest {
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val room = FakeMatrixRoom()
val client = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, room)
}
val presenter = createRoomListPresenter(client = client, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
initialState.eventSink(RoomListEvents.SetRoomIsFavorite(A_ROOM_ID, true))
assertThat(room.setIsFavoriteCalls).isEqualTo(listOf(true))
initialState.eventSink(RoomListEvents.SetRoomIsFavorite(A_ROOM_ID, false))
assertThat(room.setIsFavoriteCalls).isEqualTo(listOf(true, false))
cancelAndIgnoreRemainingEvents()
scope.cancel()
}
}
fun `present - change in migration presenter state modifies isMigrating`() = runTest { fun `present - change in migration presenter state modifies isMigrating`() = runTest {
val client = FakeMatrixClient(sessionId = A_SESSION_ID) val client = FakeMatrixClient(sessionId = A_SESSION_ID)
val migrationStore = InMemoryMigrationScreenStore() val migrationStore = InMemoryMigrationScreenStore()

View file

@ -80,6 +80,7 @@ internal fun createRoomListRoomSummary(
numberOfUnreadNotifications: Int = 0, numberOfUnreadNotifications: Int = 0,
isMarkedUnread: Boolean = false, isMarkedUnread: Boolean = false,
userDefinedNotificationMode: RoomNotificationMode? = null, userDefinedNotificationMode: RoomNotificationMode? = null,
isFavorite: Boolean = false,
) = RoomListRoomSummary( ) = RoomListRoomSummary(
id = A_ROOM_ID.value, id = A_ROOM_ID.value,
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -95,4 +96,5 @@ internal fun createRoomListRoomSummary(
userDefinedNotificationMode = userDefinedNotificationMode, userDefinedNotificationMode = userDefinedNotificationMode,
hasRoomCall = false, hasRoomCall = false,
isDm = false, isDm = false,
isFavorite = isFavorite,
) )

View file

@ -152,6 +152,8 @@ interface MatrixRoom : Closeable {
suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit> suspend fun reportContent(eventId: EventId, reason: String, blockUserId: UserId?): Result<Unit>
suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit>
/** /**
* Mark the room as read by trying to attach an unthreaded read receipt to the latest room event. * Mark the room as read by trying to attach an unthreaded read receipt to the latest room event.
* @param receiptType The type of receipt to send. * @param receiptType The type of receipt to send.

View file

@ -30,6 +30,7 @@ data class MatrixRoomInfo(
val isPublic: Boolean, val isPublic: Boolean,
val isSpace: Boolean, val isSpace: Boolean,
val isTombstoned: Boolean, val isTombstoned: Boolean,
val isFavorite: Boolean,
val canonicalAlias: String?, val canonicalAlias: String?,
val alternativeAliases: ImmutableList<String>, val alternativeAliases: ImmutableList<String>,
val currentUserMembership: CurrentUserMembership, val currentUserMembership: CurrentUserMembership,

View file

@ -48,6 +48,7 @@ data class RoomSummaryDetails(
val userDefinedNotificationMode: RoomNotificationMode?, val userDefinedNotificationMode: RoomNotificationMode?,
val hasRoomCall: Boolean, val hasRoomCall: Boolean,
val isDm: Boolean, val isDm: Boolean,
val isFavorite: Boolean,
) { ) {
val lastMessageTimestamp = lastMessage?.originServerTs val lastMessageTimestamp = lastMessage?.originServerTs
} }

View file

@ -40,6 +40,7 @@ class MatrixRoomInfoMapper(
isPublic = it.isPublic, isPublic = it.isPublic,
isSpace = it.isSpace, isSpace = it.isSpace,
isTombstoned = it.isTombstoned, isTombstoned = it.isTombstoned,
isFavorite = it.isFavourite,
canonicalAlias = it.canonicalAlias, canonicalAlias = it.canonicalAlias,
alternativeAliases = it.alternativeAliases.toImmutableList(), alternativeAliases = it.alternativeAliases.toImmutableList(),
currentUserMembership = it.membership.map(), currentUserMembership = it.membership.map(),

View file

@ -442,6 +442,12 @@ class RustMatrixRoom(
} }
} }
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> = withContext(roomDispatcher) {
runCatching {
innerRoom.setIsFavourite(isFavorite, null)
}
}
override suspend fun markAsRead(receiptType: ReceiptType): Result<Unit> = withContext(roomDispatcher) { override suspend fun markAsRead(receiptType: ReceiptType): Result<Unit> = withContext(roomDispatcher) {
runCatching { runCatching {
innerRoom.markAsRead(receiptType.toRustReceiptType()) innerRoom.markAsRead(receiptType.toRustReceiptType())

View file

@ -44,6 +44,7 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto
userDefinedNotificationMode = roomInfo.userDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode), userDefinedNotificationMode = roomInfo.userDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode),
hasRoomCall = roomInfo.hasRoomCall, hasRoomCall = roomInfo.hasRoomCall,
isDm = roomInfo.isDirect && roomInfo.activeMembersCount.toLong() == 2L, isDm = roomInfo.isDirect && roomInfo.activeMembersCount.toLong() == 2L,
isFavorite = roomInfo.isFavourite,
) )
} }
} }

View file

@ -114,6 +114,7 @@ class FakeMatrixRoom(
private var getWidgetDriverResult: Result<MatrixWidgetDriver> = Result.success(FakeWidgetDriver()) private var getWidgetDriverResult: Result<MatrixWidgetDriver> = Result.success(FakeWidgetDriver())
private var canUserTriggerRoomNotificationResult: Result<Boolean> = Result.success(true) private var canUserTriggerRoomNotificationResult: Result<Boolean> = Result.success(true)
private var canUserJoinCallResult: Result<Boolean> = Result.success(true) private var canUserJoinCallResult: Result<Boolean> = Result.success(true)
private var setIsFavoriteResult = Result.success(Unit)
var sendMessageMentions = emptyList<Mention>() var sendMessageMentions = emptyList<Mention>()
val editMessageCalls = mutableListOf<Pair<String, String?>>() val editMessageCalls = mutableListOf<Pair<String, String?>>()
private val _typingRecord = mutableListOf<Boolean>() private val _typingRecord = mutableListOf<Boolean>()
@ -378,6 +379,14 @@ class FakeMatrixRoom(
return reportContentResult return reportContentResult
} }
val setIsFavoriteCalls = mutableListOf<Boolean>()
override suspend fun setIsFavorite(isFavorite: Boolean): Result<Unit> {
return setIsFavoriteResult.also {
setIsFavoriteCalls.add(isFavorite)
}
}
val markAsReadCalls = mutableListOf<ReceiptType>() val markAsReadCalls = mutableListOf<ReceiptType>()
override suspend fun markAsRead(receiptType: ReceiptType): Result<Unit> { override suspend fun markAsRead(receiptType: ReceiptType): Result<Unit> {
@ -590,6 +599,10 @@ class FakeMatrixRoom(
getWidgetDriverResult = result getWidgetDriverResult = result
} }
fun givenSetIsFavoriteResult(result: Result<Unit>) {
setIsFavoriteResult = result
}
fun givenRoomInfo(roomInfo: MatrixRoomInfo) { fun givenRoomInfo(roomInfo: MatrixRoomInfo) {
_roomInfoFlow.tryEmit(roomInfo) _roomInfoFlow.tryEmit(roomInfo)
} }
@ -633,6 +646,7 @@ fun aRoomInfo(
isPublic: Boolean = true, isPublic: Boolean = true,
isSpace: Boolean = false, isSpace: Boolean = false,
isTombstoned: Boolean = false, isTombstoned: Boolean = false,
isFavorite: Boolean = false,
canonicalAlias: String? = null, canonicalAlias: String? = null,
alternativeAliases: List<String> = emptyList(), alternativeAliases: List<String> = emptyList(),
currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED, currentUserMembership: CurrentUserMembership = CurrentUserMembership.JOINED,
@ -655,6 +669,7 @@ fun aRoomInfo(
isPublic = isPublic, isPublic = isPublic,
isSpace = isSpace, isSpace = isSpace,
isTombstoned = isTombstoned, isTombstoned = isTombstoned,
isFavorite = isFavorite,
canonicalAlias = canonicalAlias, canonicalAlias = canonicalAlias,
alternativeAliases = alternativeAliases.toImmutableList(), alternativeAliases = alternativeAliases.toImmutableList(),
currentUserMembership = currentUserMembership, currentUserMembership = currentUserMembership,

View file

@ -68,6 +68,7 @@ fun aRoomSummaryDetails(
canonicalAlias: String? = null, canonicalAlias: String? = null,
hasRoomCall: Boolean = false, hasRoomCall: Boolean = false,
isDm: Boolean = false, isDm: Boolean = false,
isFavorite: Boolean = false,
) = RoomSummaryDetails( ) = RoomSummaryDetails(
roomId = roomId, roomId = roomId,
name = name, name = name,
@ -83,6 +84,7 @@ fun aRoomSummaryDetails(
canonicalAlias = canonicalAlias, canonicalAlias = canonicalAlias,
hasRoomCall = hasRoomCall, hasRoomCall = hasRoomCall,
isDm = isDm, isDm = isDm,
isFavorite = isFavorite,
) )
fun aRoomMessage( fun aRoomMessage(

View file

@ -117,6 +117,7 @@ fun aRoomSummaryDetails(
numUnreadMessages: Int = 0, numUnreadMessages: Int = 0,
numUnreadNotifications: Int = 0, numUnreadNotifications: Int = 0,
isMarkedUnread: Boolean = false, isMarkedUnread: Boolean = false,
isFavorite: Boolean = false,
) = RoomSummaryDetails( ) = RoomSummaryDetails(
roomId = roomId, roomId = roomId,
name = name, name = name,
@ -132,4 +133,5 @@ fun aRoomSummaryDetails(
numUnreadMessages = numUnreadMessages, numUnreadMessages = numUnreadMessages,
numUnreadNotifications = numUnreadNotifications, numUnreadNotifications = numUnreadNotifications,
isMarkedUnread = isMarkedUnread, isMarkedUnread = isMarkedUnread,
isFavorite = isFavorite,
) )

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f2dd92e3cf76367608276fd10030d161ff370294b474c3e08fbf6463508fb1b0 oid sha256:10c67ba7b16d81e030cb44719cc68a0a3fd57acba75e488a37c3bdf4f739cf95
size 42064 size 44537

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bb09fb2b24e1597e07898562b29cee3e62ad63040ab77895c234a13f4836ab08 oid sha256:a17d073bf7c3e993c81e51e234c4f885d2264f6de8bf54a93306f14ee5a02a66
size 30442 size 33354

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a8e482480e6103d81fa6e08857c3308bde87255ec2bed55ea616eca1cf3941cc oid sha256:ea52227dcdf0806e292971e1b7332db28964eadd242882a80b6486d691e9c2c9
size 32698 size 35219

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:63e7bf82b003a869d6ccfbc5057ddfe0f447bd7eb31801df828c410744d15223 oid sha256:0163f939dfc3103eff7ded67b2fbd31dd050f6a4150245c9f0fbf3c7d13daf3e
size 32008 size 34806

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:00ca19bd4146e4b7937627a9aadd900cbf928e809996d7e1c950c0ed03aa888f oid sha256:ab7c7c6ae6951bf173f24fddb70e99feacd92286b088a6921900c6b7e881000a
size 39635 size 42122

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6ea77bd41442b6686fcbe53a4ea1bb29a6040a78ceb8f6e5453fddc0b4ae6b35 oid sha256:4063c0c9f763304aea54a73aaf95efc636d029cab45369b24a3bfc0b3bf3731d
size 39892 size 42774

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6ea77bd41442b6686fcbe53a4ea1bb29a6040a78ceb8f6e5453fddc0b4ae6b35 oid sha256:4063c0c9f763304aea54a73aaf95efc636d029cab45369b24a3bfc0b3bf3731d
size 39892 size 42774

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:beeb97ce692ae6592dd61769cfdf87f3fc3518548b02443e9ef6134e3dec01bb oid sha256:dabfea9a1b7604977444e8fedc977a61ab5576c6115aa97bffd77ad1d5c65038
size 43479 size 46198

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7a5ef5d83bc9dd2e3bef092d6620baaf0f0f94657f41961bb1cf2b547ae10415 oid sha256:d1dfdb2a8cca036992e06a4d2a626c32d40c899e85bd5d5cf3aaba4272d339d8
size 41931 size 44244

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1a6e83b3b6880ee6eec8731e42e744c0fe6f806cbad6c1925cb297e19c1b811b oid sha256:258605b56cbfb5ade4ce390b7e38a37b0602612d45a751b0032e4ba757d8d76f
size 43222 size 45766

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e3d669d780e1b7a3c741d5f6c8f20e8ffe80b283291f133672221cddcca192dc oid sha256:c984e6333a0db11f1e52f246f019b927d97910dd35f5b79aae6d7c4f3094f913
size 31530 size 34667

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ee48e602a928b83bb547efe6352c419d1bf71ea8490226884f715f3266d493e5 oid sha256:613b0bd1053c46b45fe6b58c754b03c69bab0a6cc2d90cd99278b4786f1253b0
size 34080 size 36681

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:22e8f719df1249e31f26022815930577f9baf135454ff260664fa65045c1b690 oid sha256:97416e171d87d7290924270c175b44a10c2b25fd3541156bf9e5258975f0c3a4
size 32687 size 35627

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:ee5dde97c04f0ccdc21349158150e11ad1a1b91c97bfd3aaefacc4e0e1a17a8a oid sha256:28a271552ea0e61954646c47bd8e87048c92cace7ca4176901c7b4d000bbc53f
size 40828 size 43375

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1bfd34cc11af2c77b25e19d4493db998d8cad0efed379dce3a9537b2c49fda28 oid sha256:7a4ddad1a6f2afad344e8e88fd297c91a5064206b2dbcb1130dd15b5fe22fbc9
size 41039 size 44086

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:1bfd34cc11af2c77b25e19d4493db998d8cad0efed379dce3a9537b2c49fda28 oid sha256:7a4ddad1a6f2afad344e8e88fd297c91a5064206b2dbcb1130dd15b5fe22fbc9
size 41039 size 44086

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:567e1b09ae9383c8af71c7d66d2877091c0ce3d98eab1d92c7bc9d2f896875bc oid sha256:36e579b569043783e8890a6cf5ffa1ae511c524c20f923765825ab9a9ccf9bb4
size 44629 size 47592

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:bb8863bcb78be69e715ec9bbf3ef7ee51fd142b484a52a22b381373708d5049e oid sha256:2b27ecb48f63242353d750c5fb2eb5cf2fcb3c11edd046cc9c03db57f3d26122
size 43101 size 45472

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d636ef74890668b0d2cc49af71b1e3734237fa1f9944afb89d8152cb9a839cd4 oid sha256:404fc390042c6d7870e2fbef176eb497ac3fb474c53483c085cd04038d0192ef
size 17391 size 21918

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3c883b6e8c7ae3570f2d7370a929cc9afd2a1983bc6064f50090ad8e8c9295d4 oid sha256:4791d9b6fa50a29c10b618d27786033b69983229a9ce2e10b83e35cbe4a6365a
size 16289 size 20671

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:b6b6fe672b31db43d1aeed7d0835d638b729c09ac01b03fcdfd814b2e1d861d5 oid sha256:d3b0fe4d5ca9f4b735ed43ea167c238d2086a2aa6f8760fe74841bc6f0a1be6c
size 15553 size 20289

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:178e6f2f9aa771831eb3f672cf212d5b3a37532c02dcc5126446965b49be7bdd oid sha256:1d80d0580828e0f657ff2f36915c2d0a7a9d56612007da87c3b9676f882131bc
size 14558 size 19023

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2523a28a889fd1739fe1e0c94edce5d86a5ebcbe6c3973a3c49fbb8fb8a19f79 oid sha256:5f8802121f779f48dd556a9a3a08e6ac1a63e86d5695f6f8c133fbb346d76ef6
size 55617 size 89779

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:13dadbd502163a9bfe81cb1e67f9aa0933c4b7f4fd3c8f731e930a28709485d2 oid sha256:2523a28a889fd1739fe1e0c94edce5d86a5ebcbe6c3973a3c49fbb8fb8a19f79
size 51948 size 55617

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:64c4eb481f40871925405ae317cb80927caf31cb552a8aa9549bfb5658ca91e4 oid sha256:13dadbd502163a9bfe81cb1e67f9aa0933c4b7f4fd3c8f731e930a28709485d2
size 137589 size 51948

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:5f8802121f779f48dd556a9a3a08e6ac1a63e86d5695f6f8c133fbb346d76ef6 oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0
size 89779 size 4462

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:2b25e023153dd094299a0c794d5e488bdc6fc0c478abbfbce7337e52b233715d oid sha256:7cdd93c157565f7f8d3fa23a43c30e02531dab40578a315c5d0d10a99e74f259
size 57463 size 91394

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0a00391ef16a762a14dd7e348ce97b500ca1b42a32ee3ff926a10865f46cd06c oid sha256:2b25e023153dd094299a0c794d5e488bdc6fc0c478abbfbce7337e52b233715d
size 53590 size 57463

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:a7bf47b0a25c455b108d7b3585a42849f03c6652af73a175fe2147cb1ad62a66 oid sha256:0a00391ef16a762a14dd7e348ce97b500ca1b42a32ee3ff926a10865f46cd06c
size 161125 size 53590

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7cdd93c157565f7f8d3fa23a43c30e02531dab40578a315c5d0d10a99e74f259 oid sha256:3764d8bd7dc2783a8af43aad65a217d7e533ed17c4d4367b7994470bf35b62b0
size 91394 size 4462