Use shared recent emoji reactions from account data (#5402)
* Use shared recent emoji reactions from account data - Add `AddRecentEmoji` and `GetRecentEmojis` use cases to avoid injecting the whole `MatrixClient` for just one of these operations. - Update the UI and logic of the emoji picker and message context menu to include the recent emojis. - Add `CoroutineDispatchers.Default` with the defaults coroutines to use in the app for ease of use. * Instead of replacing suggested emojis, concatenate recent ones removing duplicates * Update screenshots --------- Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
parent
c412d6c53f
commit
a8c4d5d019
45 changed files with 572 additions and 208 deletions
|
|
@ -33,7 +33,6 @@ import io.element.android.x.BuildConfig
|
|||
import io.element.android.x.R
|
||||
import kotlinx.coroutines.CoroutineName
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.MainScope
|
||||
import kotlinx.coroutines.plus
|
||||
import java.io.File
|
||||
|
|
@ -107,11 +106,7 @@ object AppModule {
|
|||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun providesCoroutineDispatchers(): CoroutineDispatchers {
|
||||
return CoroutineDispatchers(
|
||||
io = Dispatchers.IO,
|
||||
computation = Dispatchers.Default,
|
||||
main = Dispatchers.Main,
|
||||
)
|
||||
return CoroutineDispatchers.Default
|
||||
}
|
||||
|
||||
@Provides
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
|
|||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
|
|
@ -70,6 +71,7 @@ import io.element.android.libraries.matrix.api.core.toThreadId
|
|||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.recentemojis.AddRecentEmoji
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||
|
|
@ -121,6 +123,7 @@ class MessagesPresenter(
|
|||
private val analyticsService: AnalyticsService,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val addRecentEmoji: AddRecentEmoji,
|
||||
) : Presenter<MessagesState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
|
|
@ -398,6 +401,7 @@ class MessagesPresenter(
|
|||
) = launch(dispatchers.io) {
|
||||
timelineController.invokeOnCurrentTimeline {
|
||||
toggleReaction(emoji, eventOrTransactionId)
|
||||
.flatMap { added -> if (added) addRecentEmoji(emoji) else Result.success(Unit) }
|
||||
.onFailure { Timber.e(it) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,7 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
|
|||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
|
|
@ -178,9 +179,11 @@ fun aReactionSummaryState(
|
|||
|
||||
fun aCustomReactionState(
|
||||
target: CustomReactionState.Target = CustomReactionState.Target.None,
|
||||
recentEmojis: ImmutableList<String> = persistentListOf(),
|
||||
eventSink: (CustomReactionEvents) -> Unit = {},
|
||||
) = CustomReactionState(
|
||||
target = target,
|
||||
recentEmojis = recentEmojis,
|
||||
selectedEmoji = persistentSetOf(),
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ import io.element.android.libraries.di.RoomScope
|
|||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.recentemojis.GetRecentEmojis
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
|
|
@ -73,6 +74,7 @@ class DefaultActionListPresenter(
|
|||
private val userSendFailureFactory: VerifiedUserSendFailureFactory,
|
||||
private val dateFormatter: DateFormatter,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val getRecentEmojis: GetRecentEmojis,
|
||||
) : ActionListPresenter {
|
||||
@AssistedFactory
|
||||
@ContributesBinding(RoomScope::class)
|
||||
|
|
@ -153,14 +155,15 @@ class DefaultActionListPresenter(
|
|||
),
|
||||
displayEmojiReactions = displayEmojiReactions,
|
||||
verifiedUserSendFailure = verifiedUserSendFailure,
|
||||
actions = actions.toImmutableList()
|
||||
actions = actions.toImmutableList(),
|
||||
recentEmojis = getRecentEmojis().getOrNull()?.toImmutableList() ?: persistentListOf()
|
||||
)
|
||||
} else {
|
||||
target.value = ActionListState.Target.None
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun buildActions(
|
||||
private fun buildActions(
|
||||
timelineItem: TimelineItem.Event,
|
||||
usersEventPermissions: UserEventPermissions,
|
||||
isDeveloperModeEnabled: Boolean,
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ data class ActionListState(
|
|||
val event: TimelineItem.Event,
|
||||
val sentTimeFull: String,
|
||||
val displayEmojiReactions: Boolean,
|
||||
val recentEmojis: ImmutableList<String>,
|
||||
val verifiedUserSendFailure: VerifiedUserSendFailure,
|
||||
val actions: ImmutableList<TimelineItemAction>,
|
||||
) : Target
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
|
|||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentList
|
||||
|
||||
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
||||
|
|
@ -41,6 +42,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
|
|
@ -56,6 +58,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
actions = aTimelineItemActionList(
|
||||
copyAction = TimelineItemAction.CopyCaption,
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
|
|
@ -70,6 +73,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
actions = aTimelineItemActionList(
|
||||
copyAction = TimelineItemAction.CopyCaption,
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
|
|
@ -84,6 +88,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
actions = aTimelineItemActionList(
|
||||
copyAction = null,
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
|
|
@ -98,6 +103,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
actions = aTimelineItemActionList(
|
||||
copyAction = TimelineItemAction.CopyCaption,
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
|
|
@ -112,6 +118,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
actions = aTimelineItemActionList(
|
||||
copyAction = null,
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
|
|
@ -124,6 +131,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
|
|
@ -136,6 +144,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
displayEmojiReactions = false,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(),
|
||||
recentEmojis = persistentListOf(),
|
||||
),
|
||||
),
|
||||
anActionListState(
|
||||
|
|
@ -148,6 +157,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
displayEmojiReactions = false,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemPollActionList(),
|
||||
recentEmojis = persistentListOf(),
|
||||
),
|
||||
),
|
||||
anActionListState(
|
||||
|
|
@ -160,6 +170,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = aTimelineItemActionList(),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
),
|
||||
anActionListState(
|
||||
|
|
@ -169,6 +180,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
|
|||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = anUnsignedDeviceSendFailure(),
|
||||
actions = aTimelineItemActionList(),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
|
|||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
|
|
@ -20,9 +21,11 @@ import androidx.compose.foundation.layout.height
|
|||
import androidx.compose.foundation.layout.imePadding
|
||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.requiredSize
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
|
|
@ -35,6 +38,10 @@ import androidx.compose.runtime.remember
|
|||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.drawWithContent
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.geometry.Size
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.stringResource
|
||||
|
|
@ -90,6 +97,8 @@ import io.element.android.libraries.matrix.ui.messages.sender.SenderName
|
|||
import io.element.android.libraries.matrix.ui.messages.sender.SenderNameMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
|
|
@ -218,6 +227,7 @@ private fun ActionListViewContent(
|
|||
if (target.displayEmojiReactions) {
|
||||
item {
|
||||
EmojiReactionsRow(
|
||||
recentEmojis = target.recentEmojis,
|
||||
highlightedEmojis = target.event.reactionsState.highlightedKeys,
|
||||
onEmojiReactionClick = onEmojiReactionClick,
|
||||
onCustomReactionClick = onCustomReactionClick,
|
||||
|
|
@ -335,43 +345,67 @@ private fun MessageSummary(
|
|||
}
|
||||
|
||||
private val emojiRippleRadius = 24.dp
|
||||
private val suggestedEmojis = persistentListOf("👍️", "👎️", "🔥", "❤️", "👏")
|
||||
|
||||
@Composable
|
||||
private fun EmojiReactionsRow(
|
||||
recentEmojis: ImmutableList<String>,
|
||||
highlightedEmojis: ImmutableList<String>,
|
||||
onEmojiReactionClick: (String) -> Unit,
|
||||
onCustomReactionClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = modifier.padding(horizontal = 24.dp, vertical = 16.dp)
|
||||
modifier = modifier.padding(end = 16.dp, top = 16.dp, bottom = 16.dp),
|
||||
) {
|
||||
// TODO use most recently used emojis here when available from the Rust SDK
|
||||
val defaultEmojis = sequenceOf(
|
||||
"👍️",
|
||||
"👎️",
|
||||
"🔥",
|
||||
"❤️",
|
||||
"👏"
|
||||
)
|
||||
for (emoji in defaultEmojis) {
|
||||
val isHighlighted = highlightedEmojis.contains(emoji)
|
||||
EmojiButton(
|
||||
modifier = Modifier
|
||||
// Make it appear after the more useful actions for the accessibility service
|
||||
.semantics {
|
||||
traversalIndex = 1f
|
||||
},
|
||||
emoji = emoji,
|
||||
isHighlighted = isHighlighted,
|
||||
onClick = onEmojiReactionClick
|
||||
)
|
||||
val backgroundColor = ElementTheme.colors.bgCanvasDefault
|
||||
|
||||
val emojis = remember(recentEmojis) {
|
||||
(suggestedEmojis + recentEmojis.filter { it !in suggestedEmojis })
|
||||
.take(100)
|
||||
.toImmutableList()
|
||||
}
|
||||
Box(
|
||||
|
||||
LazyRow(
|
||||
modifier = Modifier
|
||||
.size(48.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
.weight(1f, fill = true)
|
||||
.drawWithContent {
|
||||
val gradientWidth = 24.dp.toPx()
|
||||
val width = size.width
|
||||
drawContent()
|
||||
|
||||
drawRect(
|
||||
brush = Brush.horizontalGradient(
|
||||
0.0f to Color.Transparent,
|
||||
1.0f to backgroundColor,
|
||||
startX = width - gradientWidth,
|
||||
endX = width,
|
||||
),
|
||||
topLeft = Offset(width - gradientWidth, 0f),
|
||||
size = Size(gradientWidth, size.height)
|
||||
)
|
||||
},
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
items(emojis) { emoji ->
|
||||
val isHighlighted = highlightedEmojis.contains(emoji)
|
||||
EmojiButton(
|
||||
modifier = Modifier
|
||||
// Make it appear after the more useful actions for the accessibility service
|
||||
.semantics {
|
||||
traversalIndex = 1f
|
||||
},
|
||||
emoji = emoji,
|
||||
isHighlighted = isHighlighted,
|
||||
onClick = onEmojiReactionClick
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier.padding(end = 10.dp).requiredSize(48.dp),
|
||||
contentAlignment = Alignment.CenterEnd,
|
||||
) {
|
||||
Icon(
|
||||
imageVector = CompoundIcons.ReactionAdd(),
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier
|
|||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPicker
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPickerPresenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
|
||||
import io.element.android.libraries.designsystem.theme.components.hide
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
|
||||
|
|
@ -50,7 +51,13 @@ fun CustomReactionBottomSheet(
|
|||
sheetState = sheetState,
|
||||
modifier = modifier
|
||||
) {
|
||||
val presenter = remember { EmojiPickerPresenter(target.emojibaseStore) }
|
||||
val presenter = remember {
|
||||
EmojiPickerPresenter(
|
||||
emojibaseStore = target.emojibaseStore,
|
||||
recentEmojis = state.recentEmojis,
|
||||
coroutineDispatchers = CoroutineDispatchers.Default,
|
||||
)
|
||||
}
|
||||
EmojiPicker(
|
||||
onSelectEmoji = ::onEmojiSelectedDismiss,
|
||||
state = presenter.present(),
|
||||
|
|
|
|||
|
|
@ -9,29 +9,39 @@ package io.element.android.features.messages.impl.timeline.components.customreac
|
|||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.recentemojis.GetRecentEmojis
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class CustomReactionPresenter(
|
||||
private val emojibaseProvider: EmojibaseProvider
|
||||
private val emojibaseProvider: EmojibaseProvider,
|
||||
private val getRecentEmojis: GetRecentEmojis,
|
||||
) : Presenter<CustomReactionState> {
|
||||
@Composable
|
||||
override fun present(): CustomReactionState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
var recentEmojis by remember { mutableStateOf<ImmutableList<String>>(persistentListOf()) }
|
||||
|
||||
val target: MutableState<CustomReactionState.Target> = remember {
|
||||
mutableStateOf(CustomReactionState.Target.None)
|
||||
}
|
||||
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
fun handleShowCustomReactionSheet(event: TimelineItem.Event) {
|
||||
target.value = CustomReactionState.Target.Loading(event)
|
||||
localCoroutineScope.launch {
|
||||
recentEmojis = getRecentEmojis().getOrNull().orEmpty().toImmutableList()
|
||||
target.value = CustomReactionState.Target.Success(
|
||||
event = event,
|
||||
emojibaseStore = emojibaseProvider.emojibaseStore
|
||||
|
|
@ -56,9 +66,11 @@ class CustomReactionPresenter(
|
|||
?.mapNotNull { if (it.isHighlighted) it.key else null }
|
||||
.orEmpty()
|
||||
.toImmutableSet()
|
||||
|
||||
return CustomReactionState(
|
||||
target = target.value,
|
||||
selectedEmoji = selectedEmoji,
|
||||
recentEmojis = recentEmojis,
|
||||
eventSink = { handleEvents(it) }
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,11 +9,13 @@ package io.element.android.features.messages.impl.timeline.components.customreac
|
|||
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
import io.element.android.features.messages.impl.timeline.model.TimelineItem
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
data class CustomReactionState(
|
||||
val target: Target,
|
||||
val selectedEmoji: ImmutableSet<String>,
|
||||
val recentEmojis: ImmutableList<String>,
|
||||
val eventSink: (CustomReactionEvents) -> Unit,
|
||||
) {
|
||||
sealed interface Target {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
* Copyright 2023, 2024 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
|
|
@ -30,16 +30,16 @@ import androidx.compose.ui.res.stringResource
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojibaseCategory
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojiItem
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.icon
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.title
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toSp
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.launch
|
||||
|
|
@ -53,9 +53,7 @@ fun EmojiPicker(
|
|||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val categories = state.categories
|
||||
val pagerState = rememberPagerState(pageCount = { EmojibaseCategory.entries.size })
|
||||
|
||||
val pagerState = rememberPagerState(pageCount = { state.categories.size })
|
||||
Column(modifier) {
|
||||
SearchBar(
|
||||
modifier = Modifier.padding(bottom = 10.dp),
|
||||
|
|
@ -66,36 +64,31 @@ fun EmojiPicker(
|
|||
onActiveChange = { state.eventSink(EmojiPickerEvents.ToggleSearchActive(it)) },
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
placeHolderTitle = stringResource(CommonStrings.emoji_picker_search_placeholder),
|
||||
) { results ->
|
||||
val emojis = results
|
||||
LazyVerticalGrid(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
columns = GridCells.Adaptive(minSize = 48.dp),
|
||||
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(emojis, key = { it.unicode }) { item ->
|
||||
SelectableEmojiItem(
|
||||
item = item,
|
||||
selectedEmojis = selectedEmojis,
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
)
|
||||
}
|
||||
}
|
||||
) { emojis ->
|
||||
EmojiResults(
|
||||
emojis = emojis,
|
||||
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
)
|
||||
}
|
||||
|
||||
if (!state.isSearchActive) {
|
||||
SecondaryTabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
) {
|
||||
EmojibaseCategory.entries.forEachIndexed { index, category ->
|
||||
state.categories.forEachIndexed { index, category ->
|
||||
Tab(
|
||||
icon = {
|
||||
Icon(
|
||||
imageVector = category.icon,
|
||||
contentDescription = stringResource(id = category.title)
|
||||
)
|
||||
when (category.icon) {
|
||||
is IconSource.Resource -> Icon(
|
||||
resourceId = category.icon.id,
|
||||
contentDescription = stringResource(id = category.titleId)
|
||||
)
|
||||
is IconSource.Vector -> Icon(
|
||||
imageVector = category.icon.vector,
|
||||
contentDescription = stringResource(id = category.titleId)
|
||||
)
|
||||
}
|
||||
},
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = {
|
||||
|
|
@ -109,41 +102,40 @@ fun EmojiPicker(
|
|||
state = pagerState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { index ->
|
||||
val category = EmojibaseCategory.entries[index]
|
||||
val emojis = categories[category] ?: listOf()
|
||||
LazyVerticalGrid(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
columns = GridCells.Adaptive(minSize = 48.dp),
|
||||
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(emojis, key = { it.unicode }) { item ->
|
||||
SelectableEmojiItem(
|
||||
item = item,
|
||||
selectedEmojis = selectedEmojis,
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
)
|
||||
}
|
||||
}
|
||||
val emojis = state.categories[index].emojis
|
||||
EmojiResults(
|
||||
emojis = emojis,
|
||||
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectableEmojiItem(
|
||||
item: Emoji,
|
||||
selectedEmojis: ImmutableSet<String>,
|
||||
private fun EmojiResults(
|
||||
emojis: ImmutableList<Emoji>,
|
||||
isEmojiSelected: (Emoji) -> Boolean,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
) {
|
||||
EmojiItem(
|
||||
modifier = Modifier.aspectRatio(1f),
|
||||
item = item,
|
||||
isSelected = selectedEmojis.contains(item.unicode),
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
emojiSize = 32.dp.toSp(),
|
||||
)
|
||||
LazyVerticalGrid(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
columns = GridCells.Adaptive(minSize = 48.dp),
|
||||
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(emojis, key = { it.unicode }) { item ->
|
||||
EmojiItem(
|
||||
modifier = Modifier.aspectRatio(1f),
|
||||
item = item,
|
||||
isSelected = isEmojiSelected(item),
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
emojiSize = 32.dp.toSp(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
|
|
|
|||
|
|
@ -14,26 +14,57 @@ import androidx.compose.runtime.mutableStateOf
|
|||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.icon
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.title
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class EmojiPickerPresenter(
|
||||
private val emojibaseStore: EmojibaseStore,
|
||||
private val recentEmojis: ImmutableList<String>,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : Presenter<EmojiPickerState> {
|
||||
@Composable
|
||||
override fun present(): EmojiPickerState {
|
||||
var searchQuery by remember { mutableStateOf("") }
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
var emojiResults by remember { mutableStateOf<SearchBarResultState<ImmutableList<Emoji>>>(SearchBarResultState.Initial()) }
|
||||
val categories = remember { emojibaseStore.categories }
|
||||
|
||||
val recentEmojiIcon = CompoundIcons.History()
|
||||
val categories = remember {
|
||||
val providedCategories = emojibaseStore.categories.map { (category, emojis) ->
|
||||
EmojiCategory(
|
||||
titleId = category.title,
|
||||
icon = IconSource.Vector(category.icon),
|
||||
emojis = emojis
|
||||
)
|
||||
}
|
||||
if (recentEmojis.isNotEmpty()) {
|
||||
val recentEmojis = recentEmojis.mapNotNull { recentEmoji ->
|
||||
emojibaseStore.allEmojis.find { it.unicode == recentEmoji }
|
||||
}.toImmutableList()
|
||||
val recentCategory =
|
||||
EmojiCategory(
|
||||
titleId = R.string.emoji_picker_category_recent,
|
||||
icon = IconSource.Vector(recentEmojiIcon),
|
||||
emojis = recentEmojis
|
||||
)
|
||||
(listOf(recentCategory) + providedCategories).toImmutableList()
|
||||
} else {
|
||||
providedCategories.toImmutableList()
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(searchQuery) {
|
||||
emojiResults = if (searchQuery.isEmpty()) {
|
||||
|
|
@ -43,7 +74,7 @@ class EmojiPickerPresenter(
|
|||
delay(100.milliseconds)
|
||||
|
||||
val lowercaseQuery = searchQuery.lowercase()
|
||||
val results = withContext(Dispatchers.Default) {
|
||||
val results = withContext(coroutineDispatchers.computation) {
|
||||
emojibaseStore.allEmojis
|
||||
.asSequence()
|
||||
.filter { emoji ->
|
||||
|
|
@ -71,6 +102,7 @@ class EmojiPickerPresenter(
|
|||
|
||||
return EmojiPickerState(
|
||||
categories = categories,
|
||||
allEmojis = emojibaseStore.allEmojis,
|
||||
searchQuery = searchQuery,
|
||||
isSearchActive = isSearchActive,
|
||||
searchResults = emojiResults,
|
||||
|
|
|
|||
|
|
@ -7,16 +7,26 @@
|
|||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojibaseCategory
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
|
||||
data class EmojiPickerState(
|
||||
val categories: ImmutableMap<EmojibaseCategory, ImmutableList<Emoji>>,
|
||||
val categories: ImmutableList<EmojiCategory>,
|
||||
val allEmojis: ImmutableList<Emoji>,
|
||||
val searchQuery: String,
|
||||
val isSearchActive: Boolean,
|
||||
val searchResults: SearchBarResultState<ImmutableList<Emoji>>,
|
||||
val eventSink: (EmojiPickerEvents) -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a category of emojis with a title id, icon, and the list of associated emojis.
|
||||
*/
|
||||
data class EmojiCategory(
|
||||
@StringRes val titleId: Int,
|
||||
val icon: IconSource,
|
||||
val emojis: ImmutableList<Emoji>,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -10,11 +10,15 @@ package io.element.android.features.messages.impl.timeline.components.customreac
|
|||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojibaseCategory
|
||||
import io.element.android.features.messages.impl.R
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.icon
|
||||
import io.element.android.features.messages.impl.timeline.components.customreaction.title
|
||||
import io.element.android.libraries.designsystem.icons.CompoundDrawables
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableMap
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class EmojiPickerStateProvider : PreviewParameterProvider<EmojiPickerState> {
|
||||
override val values: Sequence<EmojiPickerState>
|
||||
|
|
@ -25,57 +29,52 @@ class EmojiPickerStateProvider : PreviewParameterProvider<EmojiPickerState> {
|
|||
anEmojiPickerState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "smile",
|
||||
searchResults = SearchBarResultState.Results(
|
||||
persistentListOf(
|
||||
Emoji(
|
||||
"0x00",
|
||||
"grinning face",
|
||||
persistentListOf("grinning"),
|
||||
persistentListOf("smile, grin"),
|
||||
"😀",
|
||||
null
|
||||
),
|
||||
Emoji(
|
||||
"0x01",
|
||||
"crying face",
|
||||
persistentListOf("crying"),
|
||||
persistentListOf("smile, crying"),
|
||||
"\uD83E\uDD72",
|
||||
null
|
||||
),
|
||||
)
|
||||
)
|
||||
searchResults = SearchBarResultState.Results(emojiList())
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun recentEmojisCategory() = EmojiCategory(
|
||||
titleId = R.string.emoji_picker_category_recent,
|
||||
icon = IconSource.Resource(CompoundDrawables.ic_compound_history),
|
||||
emojis = emojiList(),
|
||||
)
|
||||
|
||||
private fun emojiList(): ImmutableList<Emoji> = persistentListOf(
|
||||
Emoji(
|
||||
"0x00",
|
||||
"grinning face",
|
||||
persistentListOf("grinning"),
|
||||
persistentListOf("smile, grin"),
|
||||
"😀",
|
||||
null
|
||||
),
|
||||
Emoji(
|
||||
"0x01",
|
||||
"crying face",
|
||||
persistentListOf("crying"),
|
||||
persistentListOf("smile, crying"),
|
||||
"\uD83E\uDD72",
|
||||
null
|
||||
)
|
||||
)
|
||||
|
||||
internal fun anEmojiPickerState(
|
||||
categories: ImmutableMap<EmojibaseCategory, ImmutableList<Emoji>> = EmojibaseCategory.entries.associateWith {
|
||||
persistentListOf(
|
||||
Emoji(
|
||||
"0x00",
|
||||
"grinning face",
|
||||
persistentListOf("grinning"),
|
||||
persistentListOf("smile, grin"),
|
||||
"😀",
|
||||
null
|
||||
),
|
||||
Emoji(
|
||||
"0x01",
|
||||
"crying face",
|
||||
persistentListOf("crying"),
|
||||
persistentListOf("smile, crying"),
|
||||
"\uD83E\uDD72",
|
||||
null
|
||||
),
|
||||
categories: ImmutableList<EmojiCategory> = (listOf(recentEmojisCategory()) + EmojibaseCategory.entries.map {
|
||||
EmojiCategory(
|
||||
titleId = it.title,
|
||||
icon = IconSource.Vector(it.icon),
|
||||
emojis = emojiList(),
|
||||
)
|
||||
}.toImmutableMap(),
|
||||
}).toImmutableList(),
|
||||
allEmojis: ImmutableList<Emoji> = categories.flatMap { it.emojis }.toImmutableList(),
|
||||
searchQuery: String = "",
|
||||
isSearchActive: Boolean = false,
|
||||
searchResults: SearchBarResultState<ImmutableList<Emoji>> = SearchBarResultState.Initial(),
|
||||
eventSink: (EmojiPickerEvents) -> Unit = {},
|
||||
) = EmojiPickerState(
|
||||
categories = categories,
|
||||
allEmojis = allEmojis,
|
||||
searchQuery = searchQuery,
|
||||
isSearchActive = isSearchActive,
|
||||
searchResults = searchResults,
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ import io.element.android.libraries.matrix.api.core.toThreadId
|
|||
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
|
||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.matrix.api.recentemojis.AddRecentEmoji
|
||||
import io.element.android.libraries.matrix.api.room.MessageEventType
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembersState
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
|
|
@ -75,6 +76,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
|||
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
|
|
@ -1269,6 +1271,7 @@ class MessagesPresenterTest {
|
|||
encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
||||
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
|
||||
actionListEventSink: (ActionListEvents) -> Unit = {},
|
||||
addRecentEmoji: AddRecentEmoji = AddRecentEmoji(FakeMatrixClient(), testCoroutineDispatchers()),
|
||||
): MessagesPresenter {
|
||||
return MessagesPresenter(
|
||||
room = joinedRoom,
|
||||
|
|
@ -1297,6 +1300,7 @@ class MessagesPresenterTest {
|
|||
encryptionService = encryptionService,
|
||||
analyticsService = analyticsService,
|
||||
featureFlagService = featureFlagService,
|
||||
addRecentEmoji = addRecentEmoji,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,6 +370,7 @@ class MessagesViewTest {
|
|||
displayEmojiReactions = true,
|
||||
actions = persistentListOf(TimelineItemAction.Edit),
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
),
|
||||
)
|
||||
|
|
@ -462,6 +463,7 @@ class MessagesViewTest {
|
|||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(TimelineItemAction.Edit),
|
||||
recentEmojis = persistentListOf(),
|
||||
),
|
||||
),
|
||||
customReactionState = aCustomReactionState(
|
||||
|
|
@ -491,6 +493,7 @@ class MessagesViewTest {
|
|||
displayEmojiReactions = true,
|
||||
verifiedUserSendFailure = aChangedIdentitySendFailure(),
|
||||
actions = persistentListOf(),
|
||||
recentEmojis = persistentListOf(),
|
||||
),
|
||||
),
|
||||
timelineState = aTimelineState(eventSink = eventsRecorder)
|
||||
|
|
|
|||
|
|
@ -94,7 +94,8 @@ class ActionListPresenterTest {
|
|||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.ViewSource,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -135,7 +136,8 @@ class ActionListPresenterTest {
|
|||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.ViewSource,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -182,7 +184,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -228,7 +231,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -274,7 +278,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -322,7 +327,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -370,7 +376,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -417,7 +424,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -463,7 +471,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -509,7 +518,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -552,7 +562,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -599,7 +610,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -650,7 +662,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.RemoveCaption,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -699,7 +712,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyCaption,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.ReportContent,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -739,7 +753,8 @@ class ActionListPresenterTest {
|
|||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.ViewSource,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -812,7 +827,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -858,7 +874,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -911,7 +928,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.ViewSource,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
initialState.eventSink.invoke(ActionListEvents.Clear)
|
||||
|
|
@ -1004,7 +1022,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.Edit,
|
||||
TimelineItemAction.CopyText,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1048,7 +1067,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1091,7 +1111,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1133,7 +1154,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1178,7 +1200,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1215,7 +1238,8 @@ class ActionListPresenterTest {
|
|||
verifiedUserSendFailure = VerifiedUserSendFailure.None,
|
||||
actions = persistentListOf(
|
||||
TimelineItemAction.ViewSource
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1292,7 +1316,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1345,7 +1370,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1399,7 +1425,8 @@ class ActionListPresenterTest {
|
|||
TimelineItemAction.CopyLink,
|
||||
TimelineItemAction.Pin,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1450,7 +1477,8 @@ class ActionListPresenterTest {
|
|||
// Can't reply in thread for local events
|
||||
TimelineItemAction.Reply,
|
||||
TimelineItemAction.Redact,
|
||||
)
|
||||
),
|
||||
recentEmojis = persistentListOf(),
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -1472,5 +1500,6 @@ private fun createActionListPresenter(
|
|||
dateFormatter = FakeDateFormatter(),
|
||||
timelineMode = timelineMode,
|
||||
featureFlagService = featureFlagService,
|
||||
getRecentEmojis = { Result.success(persistentListOf()) },
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ class CustomReactionPresenterTest {
|
|||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val presenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider())
|
||||
private val presenter = CustomReactionPresenter(
|
||||
emojibaseProvider = FakeEmojibaseProvider(),
|
||||
getRecentEmojis = { Result.success(emptyList()) },
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `present - handle selecting and de-selecting an event`() = runTest {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
|
||||
|
||||
import androidx.compose.runtime.InternalComposeApi
|
||||
import androidx.compose.runtime.currentComposer
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojibaseCategory
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toPersistentMap
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class EmojiPickerPresenterTest {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `UpdateSearchQuery loads new results`() = runTest {
|
||||
testPresenter {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.searchQuery).isEmpty()
|
||||
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
|
||||
|
||||
initialState.eventSink(EmojiPickerEvents.UpdateSearchQuery("smile"))
|
||||
assertThat(awaitItem().searchQuery).isEqualTo("smile")
|
||||
|
||||
val stateWithResults = awaitItem()
|
||||
assertThat(stateWithResults.searchQuery).isEqualTo("smile")
|
||||
assertThat(stateWithResults.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToggleSearchActive toggles the search state`() = runTest {
|
||||
testPresenter {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isSearchActive).isFalse()
|
||||
|
||||
initialState.eventSink(EmojiPickerEvents.ToggleSearchActive(true))
|
||||
assertThat(awaitItem().isSearchActive).isTrue()
|
||||
|
||||
initialState.eventSink(EmojiPickerEvents.ToggleSearchActive(false))
|
||||
assertThat(awaitItem().isSearchActive).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent emojis are automatically added to the categories if present`() = runTest {
|
||||
val providedCategories = persistentListOf(emojiCategory(EmojibaseCategory.Activity))
|
||||
val presenter = createPresenter(
|
||||
categories = providedCategories,
|
||||
recentEmojis = persistentListOf("😊"),
|
||||
)
|
||||
testPresenter(presenter) {
|
||||
skipItems(1)
|
||||
|
||||
val initialState = awaitItem()
|
||||
assertThat(providedCategories.size).isNotEqualTo(initialState.categories.size)
|
||||
assertThat(initialState.categories.size).isEqualTo(2)
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createPresenter(
|
||||
categories: ImmutableList<Pair<EmojibaseCategory, ImmutableList<Emoji>>> = persistentListOf(emojiCategory()),
|
||||
recentEmojis: ImmutableList<String> = persistentListOf(),
|
||||
) = EmojiPickerPresenter(
|
||||
emojibaseStore = EmojibaseStore(categories.toMap().toPersistentMap()),
|
||||
recentEmojis = recentEmojis,
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
)
|
||||
|
||||
private fun emojiCategory(
|
||||
category: EmojibaseCategory = EmojibaseCategory.Activity,
|
||||
emojis: ImmutableList<Emoji> = persistentListOf(
|
||||
Emoji("1F3C3", "Smile", persistentListOf("smile"), persistentListOf("smile"), "😊", skins = null)
|
||||
)
|
||||
) = category to emojis
|
||||
|
||||
@OptIn(InternalComposeApi::class)
|
||||
private suspend fun TestScope.testPresenter(
|
||||
presenter: EmojiPickerPresenter = createPresenter(),
|
||||
testBlock: suspend TurbineTestContext<EmojiPickerState>.() -> Unit,
|
||||
) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
// These are needed to load the history icon in the presenter
|
||||
currentComposer.startProviders(arrayOf(
|
||||
LocalContext provides InstrumentationRegistry.getInstrumentation().context,
|
||||
LocalConfiguration provides InstrumentationRegistry.getInstrumentation().context.resources.configuration,
|
||||
))
|
||||
val state = presenter.present()
|
||||
currentComposer.endProviders()
|
||||
state
|
||||
}.test {
|
||||
testBlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -8,9 +8,18 @@
|
|||
package io.element.android.libraries.core.coroutine
|
||||
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
|
||||
data class CoroutineDispatchers(
|
||||
val io: CoroutineDispatcher,
|
||||
val computation: CoroutineDispatcher,
|
||||
val main: CoroutineDispatcher,
|
||||
)
|
||||
) {
|
||||
companion object {
|
||||
val Default = CoroutineDispatchers(
|
||||
io = Dispatchers.IO,
|
||||
computation = Dispatchers.Default,
|
||||
main = Dispatchers.Main,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,6 +173,16 @@ interface MatrixClient {
|
|||
* Returns the maximum file upload size allowed by the Matrix server.
|
||||
*/
|
||||
suspend fun getMaxFileUploadSize(): Result<Long>
|
||||
|
||||
/**
|
||||
* Returns the list of shared recent emoji reactions for this account.
|
||||
*/
|
||||
suspend fun getRecentEmojis(): Result<List<String>>
|
||||
|
||||
/**
|
||||
* Adds an emoji to the list of recent emoji reactions for this account.
|
||||
*/
|
||||
suspend fun addRecentEmoji(emoji: String): Result<Unit>
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.recentemojis
|
||||
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@Inject
|
||||
class AddRecentEmoji(
|
||||
private val client: MatrixClient,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) {
|
||||
suspend operator fun invoke(emoji: String): Result<Unit> = withContext(dispatchers.io) {
|
||||
client.addRecentEmoji(emoji)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.api.recentemojis
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
fun interface GetRecentEmojis {
|
||||
suspend operator fun invoke(): Result<List<String>>
|
||||
}
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
@Inject
|
||||
class DefaultGetRecentEmojis(
|
||||
private val client: MatrixClient,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : GetRecentEmojis {
|
||||
override suspend operator fun invoke(): Result<List<String>> = withContext(dispatchers.io) {
|
||||
client.getRecentEmojis()
|
||||
}
|
||||
}
|
||||
|
|
@ -708,6 +708,18 @@ class RustMatrixClient(
|
|||
runCatchingExceptions { innerClient.getMaxMediaUploadSize().toLong() }
|
||||
}
|
||||
|
||||
override suspend fun addRecentEmoji(emoji: String): Result<Unit> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions {
|
||||
innerClient.addRecentEmoji(emoji)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getRecentEmojis(): Result<List<String>> = withContext(sessionDispatcher) {
|
||||
runCatchingExceptions {
|
||||
innerClient.getRecentEmojis().map { it.emoji }
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun File.getCacheSize(
|
||||
includeCryptoDb: Boolean = false,
|
||||
): Long = withContext(sessionDispatcher) {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,8 @@ class FakeMatrixClient(
|
|||
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
|
||||
private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() },
|
||||
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) },
|
||||
private val getRecentEmojisLambda: () -> Result<List<String>> = { Result.success(emptyList()) },
|
||||
private val addRecentEmojiLambda: (String) -> Result<Unit> = { Result.success(Unit) },
|
||||
) : MatrixClient {
|
||||
var setDisplayNameCalled: Boolean = false
|
||||
private set
|
||||
|
|
@ -349,4 +351,12 @@ class FakeMatrixClient(
|
|||
override suspend fun getMaxFileUploadSize(): Result<Long> {
|
||||
return getMaxUploadSizeResult()
|
||||
}
|
||||
|
||||
override suspend fun addRecentEmoji(emoji: String): Result<Unit> {
|
||||
return addRecentEmojiLambda(emoji)
|
||||
}
|
||||
|
||||
override suspend fun getRecentEmojis(): Result<List<String>> {
|
||||
return getRecentEmojisLambda()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e85eb3d968eca031af366d557a1afff7aa6f427f9c5761c4568b807562248fb9
|
||||
size 48880
|
||||
oid sha256:cefbe57afc5b78b598d004fa9f6cc9d42e00127ffb7900a2f5346eeee0b1531c
|
||||
size 49183
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:29276b02a32bfb0eacc9fc4853711f0d43b4e9e4dc927d7066ed0d0b1ee12680
|
||||
size 50357
|
||||
oid sha256:03fbd054c197e783d2079cf4b3953f1a4f15dc7c38889dc826cb5de3b7c1f4d0
|
||||
size 50597
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:494e17d6cb91effd9351b9e331ffb4d8c4da927b28bfa8e740fdb7d865bc7fe3
|
||||
size 43609
|
||||
oid sha256:34c5fd10902041682c6ab2c3022ea44977035f7de6ea9844d94876b921327ced
|
||||
size 43960
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3ec6dd722d802baa463784587d4d8844edb9a38e623fffe2fc347970e437cac6
|
||||
size 46583
|
||||
oid sha256:531e376ef91fdb4f535a951222f5e25ecacc7ded6d6693e30551ec68a16dcd33
|
||||
size 46911
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:204de297913cef7d13569bb8b51135c6a296ea6eec058dc1ce31ebd547137887
|
||||
size 44947
|
||||
oid sha256:c18f38af96a7f2d0e6dd30827d9ef5cae25d7c864df81ba763044f782f3bb7c7
|
||||
size 45233
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:66bb9985aee2e4988f3beaf58e7979b0812876570d5905a31406069546aaf0e9
|
||||
size 42010
|
||||
oid sha256:96472d116b4c961b4dc44e7a5e8fcf9a7751ed7662e8c4a169722ff53a53ceed
|
||||
size 42380
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:7729240a99b1fbf3caba5c480806f88f441d4c17f796808d6f50979ae76ecbe9
|
||||
size 45125
|
||||
oid sha256:055df03c64585976d1453c0c3b5b0460cefaf1aaa93a47d17b98cc80fba11f29
|
||||
size 45525
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0f6bec6647ac0678ef3420e222ea2373c4d618f7eee32347548c0fe35a7620b2
|
||||
size 43261
|
||||
oid sha256:21a5e088364ec86448df788e0d54c27dcba3c6f696a86e7f97289da0792ede75
|
||||
size 43598
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6a97331fddcbc4c89743e15bd79cc6e753b59be0f33f41de2d8cc184b3e1f3ec
|
||||
size 45084
|
||||
oid sha256:e356e49f62609b0c96b294a51af0c3dba861906d0cf18492460b2d199dcd1003
|
||||
size 45368
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:48ca8baddefc71d98aa4060d730f7785e654ed078f5c9aa21459df5b730b3acc
|
||||
size 47969
|
||||
oid sha256:a883e703a5f8c9a794ca274e52b493c490815f39c84c7c7b9c9cef348ecfb766
|
||||
size 48368
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c0d19fe576a9cb4895ccae8c3f862239220b9542aa0c4c102cf5a99a0184ad94
|
||||
size 49392
|
||||
oid sha256:c44806a243336438401a21e265c8d513fff7e4f2cbf68a0c745f5918c89d639a
|
||||
size 49828
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d81fdfb3340f8d8d63a0529eb2f4d035ebd4789aed0c98272bdbcb9c4340c7c9
|
||||
size 42544
|
||||
oid sha256:9725a74d334baddaf3946675b871abcc24f20e3518fe572f6eaffdcd5e0bf98e
|
||||
size 43028
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e882786b51e7692f52f1938cf1a29581ec299e4bcb7a3253ba69009140a29233
|
||||
size 45837
|
||||
oid sha256:78379d56dda45b77f6bcfbf32e12e78f72e88c760d7a0911bb9e8c9f244c39b8
|
||||
size 46151
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:fbf6bda60c2f68bfb41405919d669b7aec8db408d49593ab0520b5b9afec8627
|
||||
size 44217
|
||||
oid sha256:b8213b5f1c9620cc8edaaa9971906f09c8b466d42ce253a9f52d30419af2e732
|
||||
size 44722
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d68f839ba6d6aa3d9bd9fde6d71a68d95130011aebaf43c87eaf8ff826f48a4d
|
||||
size 40689
|
||||
oid sha256:5c94f65df581c57f83346e9c8d697a50eb95cffa5ac3fc4198017a41b9d72537
|
||||
size 41215
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3c139a1a328cef4752219fd11df4c79b19c47501df26278811cb7775269988c4
|
||||
size 44501
|
||||
oid sha256:9fc2b4034704979785701cb42fe2b967256a6151ac90934cecd5b8b1922f780b
|
||||
size 44987
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f17461d5b74d2c1c493e58236793978a5e96bf5bf15b2ab0179dd0b39b9e626a
|
||||
size 41948
|
||||
oid sha256:d14b2d18fb60fcabb3e657001c97d95f55b82e87cf8efa34c8a5fb2ab237a359
|
||||
size 42385
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:f5a920e128caf9d5609d9030e5d37cf951011bd51009c988d84873c993fff95d
|
||||
size 44331
|
||||
oid sha256:5d081025bedebd0993968f3522a6e4266ecd20526284c37794a7180305d07c7c
|
||||
size 44806
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:af699468bfa61662538fd3b187ebb1490f6a8e692c40bd13fc44beeb26744c93
|
||||
size 21790
|
||||
oid sha256:65a59efc8cfd19f4dddf0ed9d39c4cab634ebc720c2da7737f382dd2e325ad37
|
||||
size 22517
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d79e3b8f699dab27d180942bc8f68f0193148e0e04602c03ec7592e4b73278f0
|
||||
size 20876
|
||||
oid sha256:18b5a57b061028e9b0d265f725e5c2b08439589c778c49f054822ab3baab68bb
|
||||
size 21551
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue