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
4dc65d9c08
commit
f1cd80ede8
45 changed files with 572 additions and 208 deletions
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue