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