Merge pull request #1330 from vector-im/dla/feature/room_list_decoration

Show a room list decoration for notification setting applied
This commit is contained in:
David Langley 2023-09-18 17:27:54 +01:00 committed by GitHub
commit abb11215ce
28 changed files with 228 additions and 43 deletions

View file

@ -19,6 +19,7 @@ package io.element.android.features.roomlist.impl.components
import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@ -33,6 +34,9 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.Preview
@ -41,16 +45,20 @@ import androidx.compose.ui.unit.dp
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.RoomListRoomSummaryProvider import io.element.android.features.roomlist.impl.model.RoomListRoomSummaryProvider
import io.element.android.libraries.core.extensions.orEmpty import io.element.android.libraries.core.extensions.orEmpty
import io.element.android.libraries.designsystem.VectorIcons
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
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
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.roomListRoomMessage import io.element.android.libraries.designsystem.theme.roomListRoomMessage
import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate import io.element.android.libraries.designsystem.theme.roomListRoomMessageDate
import io.element.android.libraries.designsystem.theme.roomListRoomName import io.element.android.libraries.designsystem.theme.roomListRoomName
import io.element.android.libraries.designsystem.theme.unreadIndicator import io.element.android.libraries.designsystem.theme.unreadIndicator
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
internal val minHeight = 84.dp internal val minHeight = 84.dp
@ -161,11 +169,39 @@ private fun RowScope.LastMessageAndIndicatorRow(room: RoomListRoomSummary) {
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis overflow = TextOverflow.Ellipsis
) )
// Unread // Unread
UnreadIndicatorAtom( Row(
modifier = Modifier.padding(top = 3.dp), horizontalArrangement = Arrangement.spacedBy(8.dp)
isVisible = room.hasUnread, ) {
) NotificationIcon(room)
if (room.hasUnread) {
UnreadIndicatorAtom(
modifier = Modifier.padding(top = 3.dp),
)
}
}
}
@Composable
private fun NotificationIcon(room: RoomListRoomSummary) {
val tint = if(room.hasUnread) ElementTheme.colors.unreadIndicator else ElementTheme.colors.iconQuaternary
when(room.notificationMode) {
null, RoomNotificationMode.ALL_MESSAGES -> return
RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY ->
Icon(
contentDescription = stringResource(CommonStrings.screen_notification_settings_mode_mentions),
imageVector = ImageVector.vectorResource(VectorIcons.Mention),
tint = tint,
)
RoomNotificationMode.MUTE ->
Icon(
contentDescription = stringResource(CommonStrings.common_mute),
imageVector = ImageVector.vectorResource(VectorIcons.Mute),
tint = tint,
)
}
} }
@Preview @Preview

View file

@ -27,28 +27,37 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
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.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
class RoomListDataSource @Inject constructor( class RoomListDataSource @Inject constructor(
private val roomListService: RoomListService, private val roomListService: RoomListService,
private val lastMessageTimestampFormatter: LastMessageTimestampFormatter, private val lastMessageTimestampFormatter: LastMessageTimestampFormatter,
private val roomLastMessageFormatter: RoomLastMessageFormatter, private val roomLastMessageFormatter: RoomLastMessageFormatter,
private val coroutineDispatchers: CoroutineDispatchers, private val coroutineDispatchers: CoroutineDispatchers,
private val notificationSettingsService: NotificationSettingsService,
private val appScope: CoroutineScope,
) { ) {
init {
observeNotificationSettings()
}
private val _filter = MutableStateFlow("") private val _filter = MutableStateFlow("")
private val _allRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf()) private val _allRooms = MutableStateFlow<ImmutableList<RoomListRoomSummary>>(persistentListOf())
@ -92,6 +101,16 @@ class RoomListDataSource @Inject constructor(
val allRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _allRooms val allRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _allRooms
val filteredRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _filteredRooms val filteredRooms: StateFlow<ImmutableList<RoomListRoomSummary>> = _filteredRooms
@OptIn(FlowPreview::class)
private fun observeNotificationSettings() {
notificationSettingsService.notificationSettingsChangeFlow
.debounce(0.5.seconds)
.onEach {
roomListService.rebuildRoomSummaries()
}
.launchIn(appScope)
}
private suspend fun replaceWith(roomSummaries: List<RoomSummary>) = withContext(coroutineDispatchers.computation) { private suspend fun replaceWith(roomSummaries: List<RoomSummary>) = withContext(coroutineDispatchers.computation) {
lock.withLock { lock.withLock {
diffCacheUpdater.updateWith(roomSummaries) diffCacheUpdater.updateWith(roomSummaries)
@ -120,10 +139,7 @@ class RoomListDataSource @Inject constructor(
} }
} }
private fun buildAndCacheItem( private fun buildAndCacheItem(roomSummaries: List<RoomSummary>, index: Int): RoomListRoomSummary? {
roomSummaries: List<RoomSummary>,
index: Int
): RoomListRoomSummary? {
val roomListRoomSummary = when (val roomSummary = roomSummaries.getOrNull(index)) { val roomListRoomSummary = when (val roomSummary = roomSummaries.getOrNull(index)) {
is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier) is RoomSummary.Empty -> RoomListRoomSummaryPlaceholders.create(roomSummary.identifier)
is RoomSummary.Filled -> { is RoomSummary.Filled -> {
@ -144,10 +160,12 @@ class RoomListDataSource @Inject constructor(
roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect) roomLastMessageFormatter.format(message.event, roomSummary.details.isDirect)
}.orEmpty(), }.orEmpty(),
avatarData = avatarData, avatarData = avatarData,
notificationMode = roomSummary.details.notificationMode,
) )
} }
null -> null null -> null
} }
diffCache[index] = roomListRoomSummary diffCache[index] = roomListRoomSummary
return roomListRoomSummary return roomListRoomSummary
} }

View file

@ -20,6 +20,7 @@ import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.room.RoomNotificationMode
@Immutable @Immutable
data class RoomListRoomSummary constructor( data class RoomListRoomSummary constructor(
@ -31,4 +32,5 @@ data class RoomListRoomSummary constructor(
val lastMessage: CharSequence? = null, val lastMessage: CharSequence? = null,
val avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem), val avatarData: AvatarData = AvatarData(id, name, size = AvatarSize.RoomListItem),
val isPlaceholder: Boolean = false, val isPlaceholder: Boolean = false,
val notificationMode: RoomNotificationMode? = null,
) )

View file

@ -20,14 +20,16 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.room.RoomNotificationMode
open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> { open class RoomListRoomSummaryProvider : PreviewParameterProvider<RoomListRoomSummary> {
override val values: Sequence<RoomListRoomSummary> override val values: Sequence<RoomListRoomSummary>
get() = sequenceOf( get() = sequenceOf(
aRoomListRoomSummary(), aRoomListRoomSummary(),
aRoomListRoomSummary().copy(lastMessage = null), aRoomListRoomSummary().copy(lastMessage = null),
aRoomListRoomSummary().copy(hasUnread = true), aRoomListRoomSummary().copy(hasUnread = true, notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY),
aRoomListRoomSummary().copy(timestamp = "88:88"), aRoomListRoomSummary().copy(timestamp = "88:88", notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY),
aRoomListRoomSummary().copy(timestamp = "88:88", notificationMode = RoomNotificationMode.MUTE),
aRoomListRoomSummary().copy(timestamp = "88:88", hasUnread = true), aRoomListRoomSummary().copy(timestamp = "88:88", hasUnread = true),
aRoomListRoomSummary().copy(isPlaceholder = true, timestamp = "88:88"), aRoomListRoomSummary().copy(isPlaceholder = true, timestamp = "88:88"),
aRoomListRoomSummary().copy( aRoomListRoomSummary().copy(

View file

@ -38,6 +38,7 @@ import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter import io.element.android.libraries.eventformatter.test.FakeRoomLastMessageFormatter
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_AVATAR_URL
@ -47,12 +48,16 @@ import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
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
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.testCoroutineDispatchers import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -66,7 +71,8 @@ class RoomListPresenterTests {
@Test @Test
fun `present - should start with no user and then load user with success`() = runTest { fun `present - should start with no user and then load user with success`() = runTest {
val presenter = createRoomListPresenter() val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -77,6 +83,7 @@ class RoomListPresenterTests {
Truth.assertThat(withUserState.matrixUser!!.userId).isEqualTo(A_USER_ID) Truth.assertThat(withUserState.matrixUser!!.userId).isEqualTo(A_USER_ID)
Truth.assertThat(withUserState.matrixUser!!.displayName).isEqualTo(A_USER_NAME) Truth.assertThat(withUserState.matrixUser!!.displayName).isEqualTo(A_USER_NAME)
Truth.assertThat(withUserState.matrixUser!!.avatarUrl).isEqualTo(AN_AVATAR_URL) Truth.assertThat(withUserState.matrixUser!!.avatarUrl).isEqualTo(AN_AVATAR_URL)
scope.cancel()
} }
} }
@ -86,7 +93,8 @@ class RoomListPresenterTests {
userDisplayName = Result.failure(AN_EXCEPTION), userDisplayName = Result.failure(AN_EXCEPTION),
userAvatarURLString = Result.failure(AN_EXCEPTION), userAvatarURLString = Result.failure(AN_EXCEPTION),
) )
val presenter = createRoomListPresenter(matrixClient) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -94,12 +102,14 @@ class RoomListPresenterTests {
Truth.assertThat(initialState.matrixUser).isNull() Truth.assertThat(initialState.matrixUser).isNull()
val withUserState = awaitItem() val withUserState = awaitItem()
Truth.assertThat(withUserState.matrixUser).isNotNull() Truth.assertThat(withUserState.matrixUser).isNotNull()
scope.cancel()
} }
} }
@Test @Test
fun `present - should filter room with success`() = runTest { fun `present - should filter room with success`() = runTest {
val presenter = createRoomListPresenter() val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -109,8 +119,8 @@ class RoomListPresenterTests {
withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t")) withUserState.eventSink.invoke(RoomListEvents.UpdateFilter("t"))
val withFilterState = awaitItem() val withFilterState = awaitItem()
Truth.assertThat(withFilterState.filter).isEqualTo("t") Truth.assertThat(withFilterState.filter).isEqualTo("t")
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
scope.cancel()
} }
} }
@ -120,7 +130,8 @@ class RoomListPresenterTests {
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService roomListService = roomListService
) )
val presenter = createRoomListPresenter(matrixClient) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -133,6 +144,7 @@ class RoomListPresenterTests {
Truth.assertThat(withRoomState.roomList.size).isEqualTo(1) Truth.assertThat(withRoomState.roomList.size).isEqualTo(1)
Truth.assertThat(withRoomState.roomList.first()) Truth.assertThat(withRoomState.roomList.first())
.isEqualTo(aRoomListRoomSummary) .isEqualTo(aRoomListRoomSummary)
scope.cancel()
} }
} }
@ -142,7 +154,8 @@ class RoomListPresenterTests {
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService roomListService = roomListService
) )
val presenter = createRoomListPresenter(matrixClient) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -160,6 +173,7 @@ class RoomListPresenterTests {
val withNotFilteredRoomState = consumeItemsUntilPredicate { state -> state.filteredRoomList.size == 0 }.last() val withNotFilteredRoomState = consumeItemsUntilPredicate { state -> state.filteredRoomList.size == 0 }.last()
Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo("tada") Truth.assertThat(withNotFilteredRoomState.filter).isEqualTo("tada")
Truth.assertThat(withNotFilteredRoomState.filteredRoomList).isEmpty() Truth.assertThat(withNotFilteredRoomState.filteredRoomList).isEmpty()
scope.cancel()
} }
} }
@ -169,7 +183,8 @@ class RoomListPresenterTests {
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService roomListService = roomListService
) )
val presenter = createRoomListPresenter(matrixClient) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(client = matrixClient, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -200,6 +215,7 @@ class RoomListPresenterTests {
Truth.assertThat(roomListService.latestSlidingSyncRange) Truth.assertThat(roomListService.latestSlidingSyncRange)
.isEqualTo(IntRange(129, 279)) .isEqualTo(IntRange(129, 279))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
scope.cancel()
} }
} }
@ -209,12 +225,14 @@ class RoomListPresenterTests {
val matrixClient = FakeMatrixClient( val matrixClient = FakeMatrixClient(
roomListService = roomListService, roomListService = roomListService,
) )
val scope = CoroutineScope(context = coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter( val presenter = createRoomListPresenter(
client = matrixClient, client = matrixClient,
sessionVerificationService = FakeSessionVerificationService().apply { sessionVerificationService = FakeSessionVerificationService().apply {
givenIsReady(true) givenIsReady(true)
givenVerifiedStatus(SessionVerifiedStatus.NotVerified) givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
}, },
coroutineScope = scope,
) )
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -224,6 +242,7 @@ class RoomListPresenterTests {
eventSink(RoomListEvents.DismissRequestVerificationPrompt) eventSink(RoomListEvents.DismissRequestVerificationPrompt)
Truth.assertThat(awaitItem().displayVerificationPrompt).isFalse() Truth.assertThat(awaitItem().displayVerificationPrompt).isFalse()
scope.cancel()
} }
} }
@ -231,7 +250,8 @@ class RoomListPresenterTests {
fun `present - sets invite state`() = runTest { fun `present - sets invite state`() = runTest {
val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites) val inviteStateFlow = MutableStateFlow(InvitesState.NoInvites)
val inviteStateDataSource = FakeInviteDataSource(inviteStateFlow) val inviteStateDataSource = FakeInviteDataSource(inviteStateFlow)
val presenter = createRoomListPresenter(inviteStateDataSource = inviteStateDataSource) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(inviteStateDataSource = inviteStateDataSource, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -246,12 +266,14 @@ class RoomListPresenterTests {
inviteStateFlow.value = InvitesState.NoInvites inviteStateFlow.value = InvitesState.NoInvites
Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites) Truth.assertThat(awaitItem().invitesState).isEqualTo(InvitesState.NoInvites)
scope.cancel()
} }
} }
@Test @Test
fun `present - show context menu`() = runTest { fun `present - show context menu`() = runTest {
val presenter = createRoomListPresenter() val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -264,12 +286,14 @@ class RoomListPresenterTests {
val shownState = awaitItem() val shownState = awaitItem()
Truth.assertThat(shownState.contextMenu) Truth.assertThat(shownState.contextMenu)
.isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name)) .isEqualTo(RoomListState.ContextMenu.Shown(summary.roomId, summary.name))
scope.cancel()
} }
} }
@Test @Test
fun `present - hide context menu`() = runTest { fun `present - hide context menu`() = runTest {
val presenter = createRoomListPresenter() val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -286,13 +310,15 @@ class RoomListPresenterTests {
val hiddenState = awaitItem() val hiddenState = awaitItem()
Truth.assertThat(hiddenState.contextMenu).isEqualTo(RoomListState.ContextMenu.Hidden) Truth.assertThat(hiddenState.contextMenu).isEqualTo(RoomListState.ContextMenu.Hidden)
scope.cancel()
} }
} }
@Test @Test
fun `present - leave room calls into leave room presenter`() = runTest { fun `present - leave room calls into leave room presenter`() = runTest {
val leaveRoomPresenter = LeaveRoomPresenterFake() val leaveRoomPresenter = LeaveRoomPresenterFake()
val presenter = createRoomListPresenter(leaveRoomPresenter = leaveRoomPresenter) val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(leaveRoomPresenter = leaveRoomPresenter, coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -300,6 +326,35 @@ class RoomListPresenterTests {
initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID)) initialState.eventSink(RoomListEvents.LeaveRoom(A_ROOM_ID))
Truth.assertThat(leaveRoomPresenter.events).containsExactly(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID)) Truth.assertThat(leaveRoomPresenter.events).containsExactly(LeaveRoomEvent.ShowConfirmation(A_ROOM_ID))
cancelAndIgnoreRemainingEvents() cancelAndIgnoreRemainingEvents()
scope.cancel()
}
}
@Test
fun `present - change in notification settings updates the summary for decorations`() = runTest {
val userDefinedMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY
val notificationSettingsService = FakeNotificationSettingsService()
val roomListService = FakeRoomListService()
roomListService.postAllRooms(listOf(aRoomSummaryFilled(notificationMode = userDefinedMode)))
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
notificationSettingsService = notificationSettingsService
)
val scope = CoroutineScope(coroutineContext + SupervisorJob())
val presenter = createRoomListPresenter(client = matrixClient , coroutineScope = scope)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
notificationSettingsService.setRoomNotificationMode(A_ROOM_ID, userDefinedMode)
val updatedState = consumeItemsUntilPredicate { state ->
state.roomList.any { it.id == A_ROOM_ID.value && it.notificationMode == userDefinedMode }
}.last()
val room = updatedState.roomList.find { it.id == A_ROOM_ID.value }
Truth.assertThat(room?.notificationMode).isEqualTo(userDefinedMode)
cancelAndIgnoreRemainingEvents()
scope.cancel()
} }
} }
@ -313,7 +368,8 @@ class RoomListPresenterTests {
lastMessageTimestampFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter().apply { lastMessageTimestampFormatter: LastMessageTimestampFormatter = FakeLastMessageTimestampFormatter().apply {
givenFormat(A_FORMATTED_DATE) givenFormat(A_FORMATTED_DATE)
}, },
roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter() roomLastMessageFormatter: RoomLastMessageFormatter = FakeRoomLastMessageFormatter(),
coroutineScope: CoroutineScope = this,
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
sessionVerificationService = sessionVerificationService, sessionVerificationService = sessionVerificationService,
@ -325,7 +381,9 @@ class RoomListPresenterTests {
client.roomListService, client.roomListService,
lastMessageTimestampFormatter, lastMessageTimestampFormatter,
roomLastMessageFormatter, roomLastMessageFormatter,
coroutineDispatchers = testCoroutineDispatchers() coroutineDispatchers = testCoroutineDispatchers(),
notificationSettingsService = client.notificationSettingsService(),
appScope = coroutineScope
) )
) )
} }

View file

@ -41,6 +41,8 @@ object VectorIcons {
val Quote = R.drawable.ic_quote val Quote = R.drawable.ic_quote
val Strikethrough = R.drawable.ic_strikethrough val Strikethrough = R.drawable.ic_strikethrough
val Underline = R.drawable.ic_underline val Underline = R.drawable.ic_underline
val Mention = R.drawable.ic_mention
val Mute = R.drawable.ic_mute
val ThreadDecoration = R.drawable.ic_thread_decoration val ThreadDecoration = R.drawable.ic_thread_decoration
val Plus = R.drawable.ic_plus val Plus = R.drawable.ic_plus
val Cancel = R.drawable.ic_cancel val Cancel = R.drawable.ic_cancel

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<group>
<clip-path
android:pathData="M0,0h16v16h-16z"/>
<path
android:pathData="M8,14.667C7.077,14.667 6.211,14.492 5.4,14.142C4.589,13.792 3.883,13.317 3.283,12.717C2.683,12.117 2.208,11.411 1.858,10.6C1.508,9.789 1.333,8.922 1.333,8C1.333,7.078 1.508,6.211 1.858,5.4C2.208,4.589 2.683,3.883 3.283,3.283C3.883,2.683 4.589,2.208 5.4,1.858C6.211,1.508 7.077,1.333 8,1.333C8.922,1.333 9.789,1.508 10.6,1.858C11.411,2.208 12.116,2.683 12.716,3.283C13.316,3.883 13.791,4.589 14.141,5.4C14.491,6.211 14.666,7.078 14.666,8V9.017C14.666,9.672 14.441,10.228 13.991,10.683C13.541,11.139 12.989,11.367 12.333,11.367C11.944,11.367 11.578,11.286 11.233,11.125C10.889,10.964 10.6,10.733 10.366,10.433C10.055,10.744 9.694,10.981 9.283,11.142C8.872,11.303 8.444,11.383 8,11.383C7.055,11.383 6.255,11.056 5.6,10.4C4.944,9.744 4.616,8.944 4.616,8C4.616,7.055 4.944,6.255 5.6,5.6C6.255,4.944 7.055,4.617 8,4.617C8.944,4.617 9.744,4.944 10.4,5.6C11.055,6.255 11.383,7.055 11.383,8V9.017C11.383,9.272 11.477,9.494 11.666,9.683C11.855,9.872 12.078,9.967 12.333,9.967C12.589,9.967 12.808,9.872 12.991,9.683C13.175,9.494 13.266,9.272 13.266,9.017V8C13.266,6.533 12.755,5.289 11.733,4.267C10.711,3.244 9.466,2.733 8,2.733C6.533,2.733 5.289,3.244 4.266,4.267C3.244,5.289 2.733,6.533 2.733,8C2.733,9.467 3.244,10.711 4.266,11.733C5.289,12.755 6.533,13.267 8,13.267H10.6C10.789,13.267 10.953,13.336 11.091,13.475C11.23,13.614 11.3,13.778 11.3,13.967C11.3,14.156 11.23,14.319 11.091,14.458C10.953,14.597 10.789,14.667 10.6,14.667H8ZM8,9.983C8.555,9.983 9.025,9.792 9.408,9.408C9.791,9.025 9.983,8.555 9.983,8C9.983,7.444 9.791,6.975 9.408,6.592C9.025,6.208 8.555,6.017 8,6.017C7.444,6.017 6.975,6.208 6.591,6.592C6.208,6.975 6.016,7.444 6.016,8C6.016,8.555 6.208,9.025 6.591,9.408C6.975,9.792 7.444,9.983 8,9.983Z"
android:fillColor="#A6ADB7"/>
</group>
</vector>

View file

@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="15dp"
android:height="15dp"
android:viewportWidth="15"
android:viewportHeight="15">
<path
android:pathData="M1.905,0.491C1.515,0.1 0.882,0.1 0.491,0.491C0.101,0.881 0.101,1.515 0.491,1.905L3.152,4.566C3.033,4.951 2.969,5.359 2.969,5.781V8.703C2.969,9.477 2.539,9.992 2.032,10.293C1.689,10.499 1.25,10.783 1.25,11.127C1.25,11.522 1.5,11.788 2.015,11.788H7.266H10.374L13.095,14.509C13.486,14.9 14.119,14.9 14.509,14.509C14.9,14.119 14.9,13.486 14.509,13.095L1.905,0.491ZM11.563,8.703C11.563,8.779 11.567,8.852 11.575,8.923L4.886,2.234C5.346,1.921 5.864,1.693 6.411,1.576C6.408,1.546 6.407,1.515 6.407,1.484C6.407,1.01 6.791,0.625 7.266,0.625C7.741,0.625 8.125,1.01 8.125,1.484C8.125,1.515 8.124,1.546 8.121,1.576C10.02,1.984 11.563,3.713 11.563,5.781V8.703ZM6.054,12.656C6.003,12.794 5.977,12.931 5.977,13.086C5.977,13.799 6.553,14.375 7.266,14.375C7.979,14.375 8.555,13.799 8.555,13.086C8.555,12.931 8.521,12.794 8.478,12.656H6.054Z"
android:fillColor="#A6ADB7"
android:fillType="evenOdd"/>
</vector>

View file

@ -54,6 +54,11 @@ interface RoomListService {
*/ */
fun updateAllRoomsVisibleRange(range: IntRange) fun updateAllRoomsVisibleRange(range: IntRange)
/**
* Rebuild the room summaries, required when we know some data may have changed. (E.g. room notification settings)
*/
fun rebuildRoomSummaries()
/** /**
* The sync indicator as a flow. * The sync indicator as a flow.
*/ */

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.api.roomlist
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.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.room.message.RoomMessage import io.element.android.libraries.matrix.api.room.message.RoomMessage
sealed interface RoomSummary { sealed interface RoomSummary {
@ -42,4 +43,5 @@ data class RoomSummaryDetails(
val lastMessageTimestamp: Long?, val lastMessageTimestamp: Long?,
val unreadNotificationCount: Int, val unreadNotificationCount: Int,
val inviter: RoomMember? = null, val inviter: RoomMember? = null,
val notificationMode: RoomNotificationMode? = null,
) )

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.matrix.impl.roomlist
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.roomlist.RoomSummaryDetails import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.impl.notificationsettings.RoomNotificationSettingsMapper
import io.element.android.libraries.matrix.impl.room.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.RoomMemberMapper
import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory import io.element.android.libraries.matrix.impl.room.message.RoomMessageFactory
import org.matrix.rustcomponents.sdk.RoomInfo import org.matrix.rustcomponents.sdk.RoomInfo
@ -39,6 +40,7 @@ class RoomSummaryDetailsFactory(private val roomMessageFactory: RoomMessageFacto
lastMessage = latestRoomMessage, lastMessage = latestRoomMessage,
lastMessageTimestamp = latestRoomMessage?.originServerTs, lastMessageTimestamp = latestRoomMessage?.originServerTs,
inviter = roomInfo.inviter?.let(RoomMemberMapper::map), inviter = roomInfo.inviter?.let(RoomMemberMapper::map),
notificationMode = roomInfo.userDefinedNotificationMode?.let(RoomNotificationSettingsMapper::mapMode),
) )
} }
} }

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.core.coroutine.parallelMap
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate
@ -59,6 +60,17 @@ class RoomSummaryListProcessor(
} }
} }
suspend fun rebuildRoomSummaries() {
updateRoomSummaries {
forEachIndexed { i, summary ->
this[i] = when(summary) {
is RoomSummary.Empty -> summary
is RoomSummary.Filled -> buildAndCacheRoomSummaryForIdentifier(summary.identifier())
}
}
}
}
private fun MutableList<RoomSummary>.applyUpdate(update: RoomListEntriesUpdate) { private fun MutableList<RoomSummary>.applyUpdate(update: RoomListEntriesUpdate) {
when (update) { when (update) {
is RoomListEntriesUpdate.Append -> { is RoomListEntriesUpdate.Append -> {

View file

@ -107,6 +107,12 @@ class RustRoomListService(
} }
} }
override fun rebuildRoomSummaries() {
sessionCoroutineScope.launch {
allRoomsListProcessor.rebuildRoomSummaries()
}
}
override val syncIndicator: StateFlow<RoomListService.SyncIndicator> = override val syncIndicator: StateFlow<RoomListService.SyncIndicator> =
innerRoomListService.syncIndicator() innerRoomListService.syncIndicator()
.map { it.toSyncIndicator() } .map { it.toSyncIndicator() }

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
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.core.TransactionId import io.element.android.libraries.matrix.api.core.TransactionId
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.room.RoomNotificationMode
import io.element.android.libraries.matrix.api.roomlist.RoomSummary import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
import io.element.android.libraries.matrix.api.room.message.RoomMessage import io.element.android.libraries.matrix.api.room.message.RoomMessage
@ -48,6 +49,7 @@ fun aRoomSummaryFilled(
lastMessage: RoomMessage? = aRoomMessage(), lastMessage: RoomMessage? = aRoomMessage(),
lastMessageTimestamp: Long? = null, lastMessageTimestamp: Long? = null,
unreadNotificationCount: Int = 2, unreadNotificationCount: Int = 2,
notificationMode: RoomNotificationMode? = null,
) = RoomSummary.Filled( ) = RoomSummary.Filled(
aRoomSummaryDetail( aRoomSummaryDetail(
roomId = roomId, roomId = roomId,
@ -57,6 +59,7 @@ fun aRoomSummaryFilled(
lastMessage = lastMessage, lastMessage = lastMessage,
lastMessageTimestamp = lastMessageTimestamp, lastMessageTimestamp = lastMessageTimestamp,
unreadNotificationCount = unreadNotificationCount, unreadNotificationCount = unreadNotificationCount,
notificationMode = notificationMode,
) )
) )
@ -68,6 +71,7 @@ fun aRoomSummaryDetail(
lastMessage: RoomMessage? = aRoomMessage(), lastMessage: RoomMessage? = aRoomMessage(),
lastMessageTimestamp: Long? = null, lastMessageTimestamp: Long? = null,
unreadNotificationCount: Int = 2, unreadNotificationCount: Int = 2,
notificationMode: RoomNotificationMode? = null,
) = RoomSummaryDetails( ) = RoomSummaryDetails(
roomId = roomId, roomId = roomId,
name = name, name = name,
@ -76,6 +80,7 @@ fun aRoomSummaryDetail(
lastMessage = lastMessage, lastMessage = lastMessage,
lastMessageTimestamp = lastMessageTimestamp, lastMessageTimestamp = lastMessageTimestamp,
unreadNotificationCount = unreadNotificationCount, unreadNotificationCount = unreadNotificationCount,
notificationMode = notificationMode
) )
fun aRoomMessage( fun aRoomMessage(

View file

@ -58,6 +58,10 @@ class FakeRoomListService : RoomListService {
latestSlidingSyncRange = range latestSlidingSyncRange = range
} }
override fun rebuildRoomSummaries() {
}
override fun allRooms(): RoomList { override fun allRooms(): RoomList {
return SimpleRoomList( return SimpleRoomList(
summaries = allRoomSummariesFlow, summaries = allRoomSummariesFlow,

View file

@ -78,6 +78,8 @@ class RoomListScreen(
stateContentFormatter = StateContentFormatter(stringProvider), stateContentFormatter = StateContentFormatter(stringProvider),
), ),
coroutineDispatchers = coroutineDispatchers, coroutineDispatchers = coroutineDispatchers,
notificationSettingsService = matrixClient.notificationSettingsService(),
appScope = Singleton.appScope
) )
) )

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e710857b4a1c3da12ceb51e7400229d0fd7f42d6d6158a07e2c2814c728abfdc oid sha256:a4451ed5c83109d1a36d262b9d6cca1df4f65998fa2933e77b30ffeec2a1688e
size 12229 size 12980

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:db014e76a88015053b1be458ac758dde99f8cf01a7a78850c0bf6ff4db27dcbc oid sha256:6ca678d3cb26b3224c1fe44169eebd0a96ddcc344e2d9d45ff84c41116c03a0e
size 13173 size 13829

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:4d062feda6fc808aa87df5a5f4b3229153f7e50925e3c55a8c8a99a583212857 oid sha256:8618c1af337c98cbd812632fc6dbb468b64a3b53e2141a7073a5ea52656ddaa7
size 13458 size 13611

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7cd7f6969aa48fdde75e1ad046d595882ebdadfc310cd6b6753f43895aec7b99 oid sha256:4d062feda6fc808aa87df5a5f4b3229153f7e50925e3c55a8c8a99a583212857
size 6281 size 13458

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:79b24ca5c6426aa966024d79bdbbc4a612169037cf6bda7496299ea298fa7517 oid sha256:7cd7f6969aa48fdde75e1ad046d595882ebdadfc310cd6b6753f43895aec7b99
size 21951 size 6281

View file

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

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:9c9b33e480bfad9c55d55e25cef2a0ab1f25235382e1b53c93107a75e95fa90e oid sha256:496059d16235c52562acf31c209d917b3eff6142324e07476e01a732d9aaf816
size 11840 size 12661

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:824e708f277ea66a161f17cb07a3070e03ddcfc326b9dd73d0ad6b7e62da32c1 oid sha256:6f6bfe205fe096afe6d976ed242d823d02d06a01b8f81e5feca7b9a71d7b5767
size 12807 size 13543

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:39b7b8354b98bca791698ad65f4e21619136f446b2fd050c302bdd27ce138a4a oid sha256:8337468ce85f5f1aa9fec867d9037b71c7460fa823c7f6fdcbf0efe48ad97d40
size 13263 size 13293

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c978bc799ab79290f89568de692d3ace219a4192ec9706fef63fe977a853f74d oid sha256:39b7b8354b98bca791698ad65f4e21619136f446b2fd050c302bdd27ce138a4a
size 6046 size 13263

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:64e7ca21b0213413ed45d0640295ceb2764b01b2bce20a01518e55e620925086 oid sha256:c978bc799ab79290f89568de692d3ace219a4192ec9706fef63fe977a853f74d
size 22193 size 6046

View file

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