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:
Jorge Martin Espinosa 2025-09-26 13:04:34 +02:00 committed by GitHub
parent 4dc65d9c08
commit f1cd80ede8
45 changed files with 572 additions and 208 deletions

View file

@ -33,7 +33,6 @@ import io.element.android.x.BuildConfig
import io.element.android.x.R import io.element.android.x.R
import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.MainScope import kotlinx.coroutines.MainScope
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import java.io.File import java.io.File
@ -107,11 +106,7 @@ object AppModule {
@Provides @Provides
@SingleIn(AppScope::class) @SingleIn(AppScope::class)
fun providesCoroutineDispatchers(): CoroutineDispatchers { fun providesCoroutineDispatchers(): CoroutineDispatchers {
return CoroutineDispatchers( return CoroutineDispatchers.Default
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
)
} }
@Provides @Provides

View file

@ -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.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers 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.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.components.avatar.AvatarData 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.EncryptionService
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState 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.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.JoinedRoom
import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomInfo
@ -121,6 +123,7 @@ class MessagesPresenter(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val encryptionService: EncryptionService, private val encryptionService: EncryptionService,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val addRecentEmoji: AddRecentEmoji,
) : Presenter<MessagesState> { ) : Presenter<MessagesState> {
@AssistedFactory @AssistedFactory
interface Factory { interface Factory {
@ -398,6 +401,7 @@ class MessagesPresenter(
) = launch(dispatchers.io) { ) = launch(dispatchers.io) {
timelineController.invokeOnCurrentTimeline { timelineController.invokeOnCurrentTimeline {
toggleReaction(emoji, eventOrTransactionId) toggleReaction(emoji, eventOrTransactionId)
.flatMap { added -> if (added) addRecentEmoji(emoji) else Result.success(Unit) }
.onFailure { Timber.e(it) } .onFailure { Timber.e(it) }
} }
} }

View file

@ -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.matrix.api.timeline.Timeline
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.aTextEditorStateRich import io.element.android.libraries.textcomposer.model.aTextEditorStateRich
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
@ -178,9 +179,11 @@ fun aReactionSummaryState(
fun aCustomReactionState( fun aCustomReactionState(
target: CustomReactionState.Target = CustomReactionState.Target.None, target: CustomReactionState.Target = CustomReactionState.Target.None,
recentEmojis: ImmutableList<String> = persistentListOf(),
eventSink: (CustomReactionEvents) -> Unit = {}, eventSink: (CustomReactionEvents) -> Unit = {},
) = CustomReactionState( ) = CustomReactionState(
target = target, target = target,
recentEmojis = recentEmojis,
selectedEmoji = persistentSetOf(), selectedEmoji = persistentSetOf(),
eventSink = eventSink, eventSink = eventSink,
) )

View file

@ -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.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId 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.room.BaseRoom
import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
@ -73,6 +74,7 @@ class DefaultActionListPresenter(
private val userSendFailureFactory: VerifiedUserSendFailureFactory, private val userSendFailureFactory: VerifiedUserSendFailureFactory,
private val dateFormatter: DateFormatter, private val dateFormatter: DateFormatter,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val getRecentEmojis: GetRecentEmojis,
) : ActionListPresenter { ) : ActionListPresenter {
@AssistedFactory @AssistedFactory
@ContributesBinding(RoomScope::class) @ContributesBinding(RoomScope::class)
@ -153,14 +155,15 @@ class DefaultActionListPresenter(
), ),
displayEmojiReactions = displayEmojiReactions, displayEmojiReactions = displayEmojiReactions,
verifiedUserSendFailure = verifiedUserSendFailure, verifiedUserSendFailure = verifiedUserSendFailure,
actions = actions.toImmutableList() actions = actions.toImmutableList(),
recentEmojis = getRecentEmojis().getOrNull()?.toImmutableList() ?: persistentListOf()
) )
} else { } else {
target.value = ActionListState.Target.None target.value = ActionListState.Target.None
} }
} }
private suspend fun buildActions( private fun buildActions(
timelineItem: TimelineItem.Event, timelineItem: TimelineItem.Event,
usersEventPermissions: UserEventPermissions, usersEventPermissions: UserEventPermissions,
isDeveloperModeEnabled: Boolean, isDeveloperModeEnabled: Boolean,

View file

@ -26,6 +26,7 @@ data class ActionListState(
val event: TimelineItem.Event, val event: TimelineItem.Event,
val sentTimeFull: String, val sentTimeFull: String,
val displayEmojiReactions: Boolean, val displayEmojiReactions: Boolean,
val recentEmojis: ImmutableList<String>,
val verifiedUserSendFailure: VerifiedUserSendFailure, val verifiedUserSendFailure: VerifiedUserSendFailure,
val actions: ImmutableList<TimelineItemAction>, val actions: ImmutableList<TimelineItemAction>,
) : Target ) : Target

View file

@ -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.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.MessageShield
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentList import kotlinx.collections.immutable.toPersistentList
open class ActionListStateProvider : PreviewParameterProvider<ActionListState> { open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
@ -41,6 +42,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
recentEmojis = persistentListOf(),
) )
), ),
anActionListState( anActionListState(
@ -56,6 +58,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
actions = aTimelineItemActionList( actions = aTimelineItemActionList(
copyAction = TimelineItemAction.CopyCaption, copyAction = TimelineItemAction.CopyCaption,
), ),
recentEmojis = persistentListOf(),
) )
), ),
anActionListState( anActionListState(
@ -70,6 +73,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
actions = aTimelineItemActionList( actions = aTimelineItemActionList(
copyAction = TimelineItemAction.CopyCaption, copyAction = TimelineItemAction.CopyCaption,
), ),
recentEmojis = persistentListOf(),
) )
), ),
anActionListState( anActionListState(
@ -84,6 +88,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
actions = aTimelineItemActionList( actions = aTimelineItemActionList(
copyAction = null, copyAction = null,
), ),
recentEmojis = persistentListOf(),
) )
), ),
anActionListState( anActionListState(
@ -98,6 +103,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
actions = aTimelineItemActionList( actions = aTimelineItemActionList(
copyAction = TimelineItemAction.CopyCaption, copyAction = TimelineItemAction.CopyCaption,
), ),
recentEmojis = persistentListOf(),
) )
), ),
anActionListState( anActionListState(
@ -112,6 +118,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
actions = aTimelineItemActionList( actions = aTimelineItemActionList(
copyAction = null, copyAction = null,
), ),
recentEmojis = persistentListOf(),
) )
), ),
anActionListState( anActionListState(
@ -124,6 +131,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
recentEmojis = persistentListOf(),
) )
), ),
anActionListState( anActionListState(
@ -136,6 +144,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
displayEmojiReactions = false, displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
recentEmojis = persistentListOf(),
), ),
), ),
anActionListState( anActionListState(
@ -148,6 +157,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
displayEmojiReactions = false, displayEmojiReactions = false,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemPollActionList(), actions = aTimelineItemPollActionList(),
recentEmojis = persistentListOf(),
), ),
), ),
anActionListState( anActionListState(
@ -160,6 +170,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
recentEmojis = persistentListOf(),
) )
), ),
anActionListState( anActionListState(
@ -169,6 +180,7 @@ open class ActionListStateProvider : PreviewParameterProvider<ActionListState> {
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = anUnsignedDeviceSendFailure(), verifiedUserSendFailure = anUnsignedDeviceSendFailure(),
actions = aTimelineItemActionList(), actions = aTimelineItemActionList(),
recentEmojis = persistentListOf(),
) )
), ),
) )

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth 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.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
@ -35,6 +38,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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.graphics.Color
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource 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.matrix.ui.messages.sender.SenderNameMode
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -218,6 +227,7 @@ private fun ActionListViewContent(
if (target.displayEmojiReactions) { if (target.displayEmojiReactions) {
item { item {
EmojiReactionsRow( EmojiReactionsRow(
recentEmojis = target.recentEmojis,
highlightedEmojis = target.event.reactionsState.highlightedKeys, highlightedEmojis = target.event.reactionsState.highlightedKeys,
onEmojiReactionClick = onEmojiReactionClick, onEmojiReactionClick = onEmojiReactionClick,
onCustomReactionClick = onCustomReactionClick, onCustomReactionClick = onCustomReactionClick,
@ -335,43 +345,67 @@ private fun MessageSummary(
} }
private val emojiRippleRadius = 24.dp private val emojiRippleRadius = 24.dp
private val suggestedEmojis = persistentListOf("👍️", "👎️", "🔥", "❤️", "👏")
@Composable @Composable
private fun EmojiReactionsRow( private fun EmojiReactionsRow(
recentEmojis: ImmutableList<String>,
highlightedEmojis: ImmutableList<String>, highlightedEmojis: ImmutableList<String>,
onEmojiReactionClick: (String) -> Unit, onEmojiReactionClick: (String) -> Unit,
onCustomReactionClick: () -> Unit, onCustomReactionClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Row( Row(
horizontalArrangement = Arrangement.SpaceBetween, modifier = modifier.padding(end = 16.dp, top = 16.dp, bottom = 16.dp),
modifier = modifier.padding(horizontal = 24.dp, vertical = 16.dp)
) { ) {
// TODO use most recently used emojis here when available from the Rust SDK val backgroundColor = ElementTheme.colors.bgCanvasDefault
val defaultEmojis = sequenceOf(
"👍️", val emojis = remember(recentEmojis) {
"👎️", (suggestedEmojis + recentEmojis.filter { it !in suggestedEmojis })
"🔥", .take(100)
"❤️", .toImmutableList()
"👏"
)
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
)
} }
Box(
LazyRow(
modifier = Modifier modifier = Modifier
.size(48.dp), .weight(1f, fill = true)
contentAlignment = Alignment.Center, .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( Icon(
imageVector = CompoundIcons.ReactionAdd(), imageVector = CompoundIcons.ReactionAdd(),

View file

@ -17,6 +17,7 @@ import androidx.compose.ui.Modifier
import io.element.android.emojibasebindings.Emoji 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.EmojiPicker
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPickerPresenter 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.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.hide import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@ -50,7 +51,13 @@ fun CustomReactionBottomSheet(
sheetState = sheetState, sheetState = sheetState,
modifier = modifier modifier = modifier
) { ) {
val presenter = remember { EmojiPickerPresenter(target.emojibaseStore) } val presenter = remember {
EmojiPickerPresenter(
emojibaseStore = target.emojibaseStore,
recentEmojis = state.recentEmojis,
coroutineDispatchers = CoroutineDispatchers.Default,
)
}
EmojiPicker( EmojiPicker(
onSelectEmoji = ::onEmojiSelectedDismiss, onSelectEmoji = ::onEmojiSelectedDismiss,
state = presenter.present(), state = presenter.present(),

View file

@ -9,29 +9,39 @@ package io.element.android.features.messages.impl.timeline.components.customreac
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.libraries.architecture.Presenter 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.collections.immutable.toImmutableSet
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@Inject @Inject
class CustomReactionPresenter( class CustomReactionPresenter(
private val emojibaseProvider: EmojibaseProvider private val emojibaseProvider: EmojibaseProvider,
private val getRecentEmojis: GetRecentEmojis,
) : Presenter<CustomReactionState> { ) : Presenter<CustomReactionState> {
@Composable @Composable
override fun present(): CustomReactionState { override fun present(): CustomReactionState {
val localCoroutineScope = rememberCoroutineScope()
var recentEmojis by remember { mutableStateOf<ImmutableList<String>>(persistentListOf()) }
val target: MutableState<CustomReactionState.Target> = remember { val target: MutableState<CustomReactionState.Target> = remember {
mutableStateOf(CustomReactionState.Target.None) mutableStateOf(CustomReactionState.Target.None)
} }
val localCoroutineScope = rememberCoroutineScope()
fun handleShowCustomReactionSheet(event: TimelineItem.Event) { fun handleShowCustomReactionSheet(event: TimelineItem.Event) {
target.value = CustomReactionState.Target.Loading(event) target.value = CustomReactionState.Target.Loading(event)
localCoroutineScope.launch { localCoroutineScope.launch {
recentEmojis = getRecentEmojis().getOrNull().orEmpty().toImmutableList()
target.value = CustomReactionState.Target.Success( target.value = CustomReactionState.Target.Success(
event = event, event = event,
emojibaseStore = emojibaseProvider.emojibaseStore emojibaseStore = emojibaseProvider.emojibaseStore
@ -56,9 +66,11 @@ class CustomReactionPresenter(
?.mapNotNull { if (it.isHighlighted) it.key else null } ?.mapNotNull { if (it.isHighlighted) it.key else null }
.orEmpty() .orEmpty()
.toImmutableSet() .toImmutableSet()
return CustomReactionState( return CustomReactionState(
target = target.value, target = target.value,
selectedEmoji = selectedEmoji, selectedEmoji = selectedEmoji,
recentEmojis = recentEmojis,
eventSink = { handleEvents(it) } eventSink = { handleEvents(it) }
) )
} }

View file

@ -9,11 +9,13 @@ package io.element.android.features.messages.impl.timeline.components.customreac
import io.element.android.emojibasebindings.EmojibaseStore import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
data class CustomReactionState( data class CustomReactionState(
val target: Target, val target: Target,
val selectedEmoji: ImmutableSet<String>, val selectedEmoji: ImmutableSet<String>,
val recentEmojis: ImmutableList<String>,
val eventSink: (CustomReactionEvents) -> Unit, val eventSink: (CustomReactionEvents) -> Unit,
) { ) {
sealed interface Target { sealed interface Target {

View file

@ -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 * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details. * 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.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.emojibasebindings.Emoji 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.EmojiItem
import io.element.android.features.messages.impl.timeline.components.customreaction.icon 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.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp 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.Icon
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.SearchBar import io.element.android.libraries.designsystem.theme.components.SearchBar
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@ -53,9 +53,7 @@ fun EmojiPicker(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val categories = state.categories val pagerState = rememberPagerState(pageCount = { state.categories.size })
val pagerState = rememberPagerState(pageCount = { EmojibaseCategory.entries.size })
Column(modifier) { Column(modifier) {
SearchBar( SearchBar(
modifier = Modifier.padding(bottom = 10.dp), modifier = Modifier.padding(bottom = 10.dp),
@ -66,36 +64,31 @@ fun EmojiPicker(
onActiveChange = { state.eventSink(EmojiPickerEvents.ToggleSearchActive(it)) }, onActiveChange = { state.eventSink(EmojiPickerEvents.ToggleSearchActive(it)) },
windowInsets = WindowInsets(0, 0, 0, 0), windowInsets = WindowInsets(0, 0, 0, 0),
placeHolderTitle = stringResource(CommonStrings.emoji_picker_search_placeholder), placeHolderTitle = stringResource(CommonStrings.emoji_picker_search_placeholder),
) { results -> ) { emojis ->
val emojis = results EmojiResults(
LazyVerticalGrid( emojis = emojis,
modifier = Modifier.fillMaxSize(), isEmojiSelected = { selectedEmojis.contains(it.unicode) },
columns = GridCells.Adaptive(minSize = 48.dp), onSelectEmoji = onSelectEmoji,
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,
)
}
}
} }
if (!state.isSearchActive) { if (!state.isSearchActive) {
SecondaryTabRow( SecondaryTabRow(
selectedTabIndex = pagerState.currentPage, selectedTabIndex = pagerState.currentPage,
) { ) {
EmojibaseCategory.entries.forEachIndexed { index, category -> state.categories.forEachIndexed { index, category ->
Tab( Tab(
icon = { icon = {
Icon( when (category.icon) {
imageVector = category.icon, is IconSource.Resource -> Icon(
contentDescription = stringResource(id = category.title) 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, selected = pagerState.currentPage == index,
onClick = { onClick = {
@ -109,41 +102,40 @@ fun EmojiPicker(
state = pagerState, state = pagerState,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) { index -> ) { index ->
val category = EmojibaseCategory.entries[index] val emojis = state.categories[index].emojis
val emojis = categories[category] ?: listOf() EmojiResults(
LazyVerticalGrid( emojis = emojis,
modifier = Modifier.fillMaxSize(), isEmojiSelected = { selectedEmojis.contains(it.unicode) },
columns = GridCells.Adaptive(minSize = 48.dp), onSelectEmoji = onSelectEmoji,
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,
)
}
}
} }
} }
} }
} }
@Composable @Composable
private fun SelectableEmojiItem( private fun EmojiResults(
item: Emoji, emojis: ImmutableList<Emoji>,
selectedEmojis: ImmutableSet<String>, isEmojiSelected: (Emoji) -> Boolean,
onSelectEmoji: (Emoji) -> Unit, onSelectEmoji: (Emoji) -> Unit,
) { ) {
EmojiItem( LazyVerticalGrid(
modifier = Modifier.aspectRatio(1f), modifier = Modifier.fillMaxSize(),
item = item, columns = GridCells.Adaptive(minSize = 48.dp),
isSelected = selectedEmojis.contains(item.unicode), contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
onSelectEmoji = onSelectEmoji, horizontalArrangement = Arrangement.spacedBy(8.dp),
emojiSize = 32.dp.toSp(), 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 @PreviewsDayNight

View file

@ -14,26 +14,57 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalInspectionMode 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.Emoji
import io.element.android.emojibasebindings.EmojibaseStore 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.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 io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class EmojiPickerPresenter( class EmojiPickerPresenter(
private val emojibaseStore: EmojibaseStore, private val emojibaseStore: EmojibaseStore,
private val recentEmojis: ImmutableList<String>,
private val coroutineDispatchers: CoroutineDispatchers,
) : Presenter<EmojiPickerState> { ) : Presenter<EmojiPickerState> {
@Composable @Composable
override fun present(): EmojiPickerState { override fun present(): EmojiPickerState {
var searchQuery by remember { mutableStateOf("") } var searchQuery by remember { mutableStateOf("") }
var isSearchActive by remember { mutableStateOf(false) } var isSearchActive by remember { mutableStateOf(false) }
var emojiResults by remember { mutableStateOf<SearchBarResultState<ImmutableList<Emoji>>>(SearchBarResultState.Initial()) } 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) { LaunchedEffect(searchQuery) {
emojiResults = if (searchQuery.isEmpty()) { emojiResults = if (searchQuery.isEmpty()) {
@ -43,7 +74,7 @@ class EmojiPickerPresenter(
delay(100.milliseconds) delay(100.milliseconds)
val lowercaseQuery = searchQuery.lowercase() val lowercaseQuery = searchQuery.lowercase()
val results = withContext(Dispatchers.Default) { val results = withContext(coroutineDispatchers.computation) {
emojibaseStore.allEmojis emojibaseStore.allEmojis
.asSequence() .asSequence()
.filter { emoji -> .filter { emoji ->
@ -71,6 +102,7 @@ class EmojiPickerPresenter(
return EmojiPickerState( return EmojiPickerState(
categories = categories, categories = categories,
allEmojis = emojibaseStore.allEmojis,
searchQuery = searchQuery, searchQuery = searchQuery,
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
searchResults = emojiResults, searchResults = emojiResults,

View file

@ -7,16 +7,26 @@
package io.element.android.features.messages.impl.timeline.components.customreaction.picker 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.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 io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
data class EmojiPickerState( data class EmojiPickerState(
val categories: ImmutableMap<EmojibaseCategory, ImmutableList<Emoji>>, val categories: ImmutableList<EmojiCategory>,
val allEmojis: ImmutableList<Emoji>,
val searchQuery: String, val searchQuery: String,
val isSearchActive: Boolean, val isSearchActive: Boolean,
val searchResults: SearchBarResultState<ImmutableList<Emoji>>, val searchResults: SearchBarResultState<ImmutableList<Emoji>>,
val eventSink: (EmojiPickerEvents) -> Unit, 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>,
)

View file

@ -10,11 +10,15 @@ package io.element.android.features.messages.impl.timeline.components.customreac
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.emojibasebindings.Emoji import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory 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 io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableMap
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableList
class EmojiPickerStateProvider : PreviewParameterProvider<EmojiPickerState> { class EmojiPickerStateProvider : PreviewParameterProvider<EmojiPickerState> {
override val values: Sequence<EmojiPickerState> override val values: Sequence<EmojiPickerState>
@ -25,57 +29,52 @@ class EmojiPickerStateProvider : PreviewParameterProvider<EmojiPickerState> {
anEmojiPickerState( anEmojiPickerState(
isSearchActive = true, isSearchActive = true,
searchQuery = "smile", searchQuery = "smile",
searchResults = SearchBarResultState.Results( searchResults = SearchBarResultState.Results(emojiList())
persistentListOf(
Emoji(
"0x00",
"grinning face",
persistentListOf("grinning"),
persistentListOf("smile, grin"),
"😀",
null
),
Emoji(
"0x01",
"crying face",
persistentListOf("crying"),
persistentListOf("smile, crying"),
"\uD83E\uDD72",
null
),
)
)
), ),
) )
} }
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( internal fun anEmojiPickerState(
categories: ImmutableMap<EmojibaseCategory, ImmutableList<Emoji>> = EmojibaseCategory.entries.associateWith { categories: ImmutableList<EmojiCategory> = (listOf(recentEmojisCategory()) + EmojibaseCategory.entries.map {
persistentListOf( EmojiCategory(
Emoji( titleId = it.title,
"0x00", icon = IconSource.Vector(it.icon),
"grinning face", emojis = emojiList(),
persistentListOf("grinning"),
persistentListOf("smile, grin"),
"😀",
null
),
Emoji(
"0x01",
"crying face",
persistentListOf("crying"),
persistentListOf("smile, crying"),
"\uD83E\uDD72",
null
),
) )
}.toImmutableMap(), }).toImmutableList(),
allEmojis: ImmutableList<Emoji> = categories.flatMap { it.emojis }.toImmutableList(),
searchQuery: String = "", searchQuery: String = "",
isSearchActive: Boolean = false, isSearchActive: Boolean = false,
searchResults: SearchBarResultState<ImmutableList<Emoji>> = SearchBarResultState.Initial(), searchResults: SearchBarResultState<ImmutableList<Emoji>> = SearchBarResultState.Initial(),
eventSink: (EmojiPickerEvents) -> Unit = {}, eventSink: (EmojiPickerEvents) -> Unit = {},
) = EmojiPickerState( ) = EmojiPickerState(
categories = categories, categories = categories,
allEmojis = allEmojis,
searchQuery = searchQuery, searchQuery = searchQuery,
isSearchActive = isSearchActive, isSearchActive = isSearchActive,
searchResults = searchResults, searchResults = searchResults,

View file

@ -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.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.media.MediaSource 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.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.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembersState
import io.element.android.libraries.matrix.api.room.RoomMembershipState 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_THREAD_ID
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_ID_2 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.core.aBuildMeta
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
@ -1269,6 +1271,7 @@ class MessagesPresenterTest {
encryptionService: FakeEncryptionService = FakeEncryptionService(), encryptionService: FakeEncryptionService = FakeEncryptionService(),
featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(), featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(),
actionListEventSink: (ActionListEvents) -> Unit = {}, actionListEventSink: (ActionListEvents) -> Unit = {},
addRecentEmoji: AddRecentEmoji = AddRecentEmoji(FakeMatrixClient(), testCoroutineDispatchers()),
): MessagesPresenter { ): MessagesPresenter {
return MessagesPresenter( return MessagesPresenter(
room = joinedRoom, room = joinedRoom,
@ -1297,6 +1300,7 @@ class MessagesPresenterTest {
encryptionService = encryptionService, encryptionService = encryptionService,
analyticsService = analyticsService, analyticsService = analyticsService,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
addRecentEmoji = addRecentEmoji,
) )
} }
} }

View file

@ -370,6 +370,7 @@ class MessagesViewTest {
displayEmojiReactions = true, displayEmojiReactions = true,
actions = persistentListOf(TimelineItemAction.Edit), actions = persistentListOf(TimelineItemAction.Edit),
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
recentEmojis = persistentListOf(),
) )
), ),
) )
@ -462,6 +463,7 @@ class MessagesViewTest {
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf(TimelineItemAction.Edit), actions = persistentListOf(TimelineItemAction.Edit),
recentEmojis = persistentListOf(),
), ),
), ),
customReactionState = aCustomReactionState( customReactionState = aCustomReactionState(
@ -491,6 +493,7 @@ class MessagesViewTest {
displayEmojiReactions = true, displayEmojiReactions = true,
verifiedUserSendFailure = aChangedIdentitySendFailure(), verifiedUserSendFailure = aChangedIdentitySendFailure(),
actions = persistentListOf(), actions = persistentListOf(),
recentEmojis = persistentListOf(),
), ),
), ),
timelineState = aTimelineState(eventSink = eventsRecorder) timelineState = aTimelineState(eventSink = eventsRecorder)

View file

@ -94,7 +94,8 @@ class ActionListPresenterTest {
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -135,7 +136,8 @@ class ActionListPresenterTest {
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -182,7 +184,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyText, TimelineItemAction.CopyText,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -228,7 +231,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyText, TimelineItemAction.CopyText,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -274,7 +278,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyText, TimelineItemAction.CopyText,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -322,7 +327,8 @@ class ActionListPresenterTest {
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -370,7 +376,8 @@ class ActionListPresenterTest {
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -417,7 +424,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyText, TimelineItemAction.CopyText,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -463,7 +471,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyText, TimelineItemAction.CopyText,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -509,7 +518,8 @@ class ActionListPresenterTest {
TimelineItemAction.Pin, TimelineItemAction.Pin,
TimelineItemAction.CopyText, TimelineItemAction.CopyText,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -552,7 +562,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.CopyText, TimelineItemAction.CopyText,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -599,7 +610,8 @@ class ActionListPresenterTest {
TimelineItemAction.Pin, TimelineItemAction.Pin,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -650,7 +662,8 @@ class ActionListPresenterTest {
TimelineItemAction.RemoveCaption, TimelineItemAction.RemoveCaption,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -699,7 +712,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyCaption, TimelineItemAction.CopyCaption,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.ReportContent, TimelineItemAction.ReportContent,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -739,7 +753,8 @@ class ActionListPresenterTest {
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -812,7 +827,8 @@ class ActionListPresenterTest {
TimelineItemAction.Pin, TimelineItemAction.Pin,
TimelineItemAction.CopyText, TimelineItemAction.CopyText,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -858,7 +874,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyText, TimelineItemAction.CopyText,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -911,7 +928,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyText, TimelineItemAction.CopyText,
TimelineItemAction.ViewSource, TimelineItemAction.ViewSource,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
initialState.eventSink.invoke(ActionListEvents.Clear) initialState.eventSink.invoke(ActionListEvents.Clear)
@ -1004,7 +1022,8 @@ class ActionListPresenterTest {
TimelineItemAction.Edit, TimelineItemAction.Edit,
TimelineItemAction.CopyText, TimelineItemAction.CopyText,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
} }
@ -1048,7 +1067,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Pin, TimelineItemAction.Pin,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
} }
@ -1091,7 +1111,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Pin, TimelineItemAction.Pin,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
} }
@ -1133,7 +1154,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Pin, TimelineItemAction.Pin,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
} }
@ -1178,7 +1200,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Pin, TimelineItemAction.Pin,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
} }
@ -1215,7 +1238,8 @@ class ActionListPresenterTest {
verifiedUserSendFailure = VerifiedUserSendFailure.None, verifiedUserSendFailure = VerifiedUserSendFailure.None,
actions = persistentListOf( actions = persistentListOf(
TimelineItemAction.ViewSource TimelineItemAction.ViewSource
) ),
recentEmojis = persistentListOf(),
) )
) )
} }
@ -1292,7 +1316,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Pin, TimelineItemAction.Pin,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
} }
@ -1345,7 +1370,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Pin, TimelineItemAction.Pin,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
} }
@ -1399,7 +1425,8 @@ class ActionListPresenterTest {
TimelineItemAction.CopyLink, TimelineItemAction.CopyLink,
TimelineItemAction.Pin, TimelineItemAction.Pin,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
} }
@ -1450,7 +1477,8 @@ class ActionListPresenterTest {
// Can't reply in thread for local events // Can't reply in thread for local events
TimelineItemAction.Reply, TimelineItemAction.Reply,
TimelineItemAction.Redact, TimelineItemAction.Redact,
) ),
recentEmojis = persistentListOf(),
) )
) )
} }
@ -1472,5 +1500,6 @@ private fun createActionListPresenter(
dateFormatter = FakeDateFormatter(), dateFormatter = FakeDateFormatter(),
timelineMode = timelineMode, timelineMode = timelineMode,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
getRecentEmojis = { Result.success(persistentListOf()) },
) )
} }

View file

@ -23,7 +23,10 @@ class CustomReactionPresenterTest {
@get:Rule @get:Rule
val warmUpRule = WarmUpRule() val warmUpRule = WarmUpRule()
private val presenter = CustomReactionPresenter(emojibaseProvider = FakeEmojibaseProvider()) private val presenter = CustomReactionPresenter(
emojibaseProvider = FakeEmojibaseProvider(),
getRecentEmojis = { Result.success(emptyList()) },
)
@Test @Test
fun `present - handle selecting and de-selecting an event`() = runTest { fun `present - handle selecting and de-selecting an event`() = runTest {

View file

@ -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()
}
}
}

View file

@ -8,9 +8,18 @@
package io.element.android.libraries.core.coroutine package io.element.android.libraries.core.coroutine
import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
data class CoroutineDispatchers( data class CoroutineDispatchers(
val io: CoroutineDispatcher, val io: CoroutineDispatcher,
val computation: CoroutineDispatcher, val computation: CoroutineDispatcher,
val main: CoroutineDispatcher, val main: CoroutineDispatcher,
) ) {
companion object {
val Default = CoroutineDispatchers(
io = Dispatchers.IO,
computation = Dispatchers.Default,
main = Dispatchers.Main,
)
}
}

View file

@ -173,6 +173,16 @@ interface MatrixClient {
* Returns the maximum file upload size allowed by the Matrix server. * Returns the maximum file upload size allowed by the Matrix server.
*/ */
suspend fun getMaxFileUploadSize(): Result<Long> 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>
} }
/** /**

View file

@ -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)
}
}

View file

@ -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()
}
}

View file

@ -708,6 +708,18 @@ class RustMatrixClient(
runCatchingExceptions { innerClient.getMaxMediaUploadSize().toLong() } 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( private suspend fun File.getCacheSize(
includeCryptoDb: Boolean = false, includeCryptoDb: Boolean = false,
): Long = withContext(sessionDispatcher) { ): Long = withContext(sessionDispatcher) {

View file

@ -97,6 +97,8 @@ class FakeMatrixClient(
override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()), override val ignoredUsersFlow: StateFlow<ImmutableList<UserId>> = MutableStateFlow(persistentListOf()),
private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() }, private val getMaxUploadSizeResult: () -> Result<Long> = { lambdaError() },
private val getJoinedRoomIdsResult: () -> Result<Set<RoomId>> = { Result.success(emptySet()) }, 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 { ) : MatrixClient {
var setDisplayNameCalled: Boolean = false var setDisplayNameCalled: Boolean = false
private set private set
@ -349,4 +351,12 @@ class FakeMatrixClient(
override suspend fun getMaxFileUploadSize(): Result<Long> { override suspend fun getMaxFileUploadSize(): Result<Long> {
return getMaxUploadSizeResult() return getMaxUploadSizeResult()
} }
override suspend fun addRecentEmoji(emoji: String): Result<Unit> {
return addRecentEmojiLambda(emoji)
}
override suspend fun getRecentEmojis(): Result<List<String>> {
return getRecentEmojisLambda()
}
} }

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e85eb3d968eca031af366d557a1afff7aa6f427f9c5761c4568b807562248fb9 oid sha256:cefbe57afc5b78b598d004fa9f6cc9d42e00127ffb7900a2f5346eeee0b1531c
size 48880 size 49183

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:29276b02a32bfb0eacc9fc4853711f0d43b4e9e4dc927d7066ed0d0b1ee12680 oid sha256:03fbd054c197e783d2079cf4b3953f1a4f15dc7c38889dc826cb5de3b7c1f4d0
size 50357 size 50597

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:494e17d6cb91effd9351b9e331ffb4d8c4da927b28bfa8e740fdb7d865bc7fe3 oid sha256:34c5fd10902041682c6ab2c3022ea44977035f7de6ea9844d94876b921327ced
size 43609 size 43960

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3ec6dd722d802baa463784587d4d8844edb9a38e623fffe2fc347970e437cac6 oid sha256:531e376ef91fdb4f535a951222f5e25ecacc7ded6d6693e30551ec68a16dcd33
size 46583 size 46911

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:204de297913cef7d13569bb8b51135c6a296ea6eec058dc1ce31ebd547137887 oid sha256:c18f38af96a7f2d0e6dd30827d9ef5cae25d7c864df81ba763044f782f3bb7c7
size 44947 size 45233

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:66bb9985aee2e4988f3beaf58e7979b0812876570d5905a31406069546aaf0e9 oid sha256:96472d116b4c961b4dc44e7a5e8fcf9a7751ed7662e8c4a169722ff53a53ceed
size 42010 size 42380

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:7729240a99b1fbf3caba5c480806f88f441d4c17f796808d6f50979ae76ecbe9 oid sha256:055df03c64585976d1453c0c3b5b0460cefaf1aaa93a47d17b98cc80fba11f29
size 45125 size 45525

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:0f6bec6647ac0678ef3420e222ea2373c4d618f7eee32347548c0fe35a7620b2 oid sha256:21a5e088364ec86448df788e0d54c27dcba3c6f696a86e7f97289da0792ede75
size 43261 size 43598

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:6a97331fddcbc4c89743e15bd79cc6e753b59be0f33f41de2d8cc184b3e1f3ec oid sha256:e356e49f62609b0c96b294a51af0c3dba861906d0cf18492460b2d199dcd1003
size 45084 size 45368

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:48ca8baddefc71d98aa4060d730f7785e654ed078f5c9aa21459df5b730b3acc oid sha256:a883e703a5f8c9a794ca274e52b493c490815f39c84c7c7b9c9cef348ecfb766
size 47969 size 48368

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:c0d19fe576a9cb4895ccae8c3f862239220b9542aa0c4c102cf5a99a0184ad94 oid sha256:c44806a243336438401a21e265c8d513fff7e4f2cbf68a0c745f5918c89d639a
size 49392 size 49828

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d81fdfb3340f8d8d63a0529eb2f4d035ebd4789aed0c98272bdbcb9c4340c7c9 oid sha256:9725a74d334baddaf3946675b871abcc24f20e3518fe572f6eaffdcd5e0bf98e
size 42544 size 43028

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:e882786b51e7692f52f1938cf1a29581ec299e4bcb7a3253ba69009140a29233 oid sha256:78379d56dda45b77f6bcfbf32e12e78f72e88c760d7a0911bb9e8c9f244c39b8
size 45837 size 46151

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:fbf6bda60c2f68bfb41405919d669b7aec8db408d49593ab0520b5b9afec8627 oid sha256:b8213b5f1c9620cc8edaaa9971906f09c8b466d42ce253a9f52d30419af2e732
size 44217 size 44722

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d68f839ba6d6aa3d9bd9fde6d71a68d95130011aebaf43c87eaf8ff826f48a4d oid sha256:5c94f65df581c57f83346e9c8d697a50eb95cffa5ac3fc4198017a41b9d72537
size 40689 size 41215

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:3c139a1a328cef4752219fd11df4c79b19c47501df26278811cb7775269988c4 oid sha256:9fc2b4034704979785701cb42fe2b967256a6151ac90934cecd5b8b1922f780b
size 44501 size 44987

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f17461d5b74d2c1c493e58236793978a5e96bf5bf15b2ab0179dd0b39b9e626a oid sha256:d14b2d18fb60fcabb3e657001c97d95f55b82e87cf8efa34c8a5fb2ab237a359
size 41948 size 42385

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:f5a920e128caf9d5609d9030e5d37cf951011bd51009c988d84873c993fff95d oid sha256:5d081025bedebd0993968f3522a6e4266ecd20526284c37794a7180305d07c7c
size 44331 size 44806

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:af699468bfa61662538fd3b187ebb1490f6a8e692c40bd13fc44beeb26744c93 oid sha256:65a59efc8cfd19f4dddf0ed9d39c4cab634ebc720c2da7737f382dd2e325ad37
size 21790 size 22517

View file

@ -1,3 +1,3 @@
version https://git-lfs.github.com/spec/v1 version https://git-lfs.github.com/spec/v1
oid sha256:d79e3b8f699dab27d180942bc8f68f0193148e0e04602c03ec7592e4b73278f0 oid sha256:18b5a57b061028e9b0d265f725e5c2b08439589c778c49f054822ab3baab68bb
size 20876 size 21551