Use shared recent emoji reactions from account data (#5402)

* Use shared recent emoji reactions from account data

- Add `AddRecentEmoji` and `GetRecentEmojis` use cases to avoid injecting the whole `MatrixClient` for just one of these operations.
- Update the UI and logic of the emoji picker and message context menu to include the recent emojis.
- Add `CoroutineDispatchers.Default` with the defaults coroutines to use in the app for ease of use.

* Instead of replacing suggested emojis, concatenate recent ones removing duplicates

* Update screenshots

---------

Co-authored-by: ElementBot <android@element.io>
This commit is contained in:
Jorge Martin Espinosa
2025-09-26 13:04:34 +02:00
committed by GitHub
parent 4dc65d9c08
commit f1cd80ede8
45 changed files with 572 additions and 208 deletions

View File

@@ -33,7 +33,6 @@ import io.element.android.x.BuildConfig
import io.element.android.x.R
import 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

View File

@@ -57,6 +57,7 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.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) }
}
}

View File

@@ -48,6 +48,7 @@ import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom
import io.element.android.libraries.matrix.api.timeline.Timeline
import io.element.android.libraries.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,
)

View File

@@ -43,6 +43,7 @@ import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.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,

View File

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

View File

@@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.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(),
)
),
)

View File

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

View File

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

View File

@@ -9,29 +9,39 @@ package io.element.android.features.messages.impl.timeline.components.customreac
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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) }
)
}

View File

@@ -9,11 +9,13 @@ package io.element.android.features.messages.impl.timeline.components.customreac
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.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 {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2025 New Vector Ltd.
* Copyright 2023, 2024 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* 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

View File

@@ -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,

View File

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

View File

@@ -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,

View File

@@ -57,6 +57,7 @@ import io.element.android.libraries.matrix.api.core.toThreadId
import io.element.android.libraries.matrix.api.encryption.identity.IdentityState
import io.element.android.libraries.matrix.api.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,
)
}
}

View File

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

View File

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

View File

@@ -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 {

View File

@@ -0,0 +1,125 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
import androidx.compose.runtime.InternalComposeApi
import androidx.compose.runtime.currentComposer
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentMap
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class EmojiPickerPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `UpdateSearchQuery loads new results`() = runTest {
testPresenter {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.searchQuery).isEmpty()
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
initialState.eventSink(EmojiPickerEvents.UpdateSearchQuery("smile"))
assertThat(awaitItem().searchQuery).isEqualTo("smile")
val stateWithResults = awaitItem()
assertThat(stateWithResults.searchQuery).isEqualTo("smile")
assertThat(stateWithResults.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
}
}
@Test
fun `ToggleSearchActive toggles the search state`() = runTest {
testPresenter {
skipItems(1)
val initialState = awaitItem()
assertThat(initialState.isSearchActive).isFalse()
initialState.eventSink(EmojiPickerEvents.ToggleSearchActive(true))
assertThat(awaitItem().isSearchActive).isTrue()
initialState.eventSink(EmojiPickerEvents.ToggleSearchActive(false))
assertThat(awaitItem().isSearchActive).isFalse()
}
}
@Test
fun `recent emojis are automatically added to the categories if present`() = runTest {
val providedCategories = persistentListOf(emojiCategory(EmojibaseCategory.Activity))
val presenter = createPresenter(
categories = providedCategories,
recentEmojis = persistentListOf("😊"),
)
testPresenter(presenter) {
skipItems(1)
val initialState = awaitItem()
assertThat(providedCategories.size).isNotEqualTo(initialState.categories.size)
assertThat(initialState.categories.size).isEqualTo(2)
}
}
private fun TestScope.createPresenter(
categories: ImmutableList<Pair<EmojibaseCategory, ImmutableList<Emoji>>> = persistentListOf(emojiCategory()),
recentEmojis: ImmutableList<String> = persistentListOf(),
) = EmojiPickerPresenter(
emojibaseStore = EmojibaseStore(categories.toMap().toPersistentMap()),
recentEmojis = recentEmojis,
coroutineDispatchers = testCoroutineDispatchers(),
)
private fun emojiCategory(
category: EmojibaseCategory = EmojibaseCategory.Activity,
emojis: ImmutableList<Emoji> = persistentListOf(
Emoji("1F3C3", "Smile", persistentListOf("smile"), persistentListOf("smile"), "😊", skins = null)
)
) = category to emojis
@OptIn(InternalComposeApi::class)
private suspend fun TestScope.testPresenter(
presenter: EmojiPickerPresenter = createPresenter(),
testBlock: suspend TurbineTestContext<EmojiPickerState>.() -> Unit,
) {
moleculeFlow(RecompositionMode.Immediate) {
// These are needed to load the history icon in the presenter
currentComposer.startProviders(arrayOf(
LocalContext provides InstrumentationRegistry.getInstrumentation().context,
LocalConfiguration provides InstrumentationRegistry.getInstrumentation().context.resources.configuration,
))
val state = presenter.present()
currentComposer.endProviders()
state
}.test {
testBlock()
}
}
}

View File

@@ -8,9 +8,18 @@
package io.element.android.libraries.core.coroutine
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,
)
}
}

View File

@@ -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>
}
/**

View File

@@ -0,0 +1,23 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.recentemojis
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.withContext
@Inject
class AddRecentEmoji(
private val client: MatrixClient,
private val dispatchers: CoroutineDispatchers,
) {
suspend operator fun invoke(emoji: String): Result<Unit> = withContext(dispatchers.io) {
client.addRecentEmoji(emoji)
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.matrix.api.recentemojis
import dev.zacsweers.metro.ContributesBinding
import dev.zacsweers.metro.Inject
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.coroutines.withContext
fun interface GetRecentEmojis {
suspend operator fun invoke(): Result<List<String>>
}
@ContributesBinding(SessionScope::class)
@Inject
class DefaultGetRecentEmojis(
private val client: MatrixClient,
private val dispatchers: CoroutineDispatchers,
) : GetRecentEmojis {
override suspend operator fun invoke(): Result<List<String>> = withContext(dispatchers.io) {
client.getRecentEmojis()
}
}

View File

@@ -708,6 +708,18 @@ class RustMatrixClient(
runCatchingExceptions { innerClient.getMaxMediaUploadSize().toLong() }
}
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) {

View File

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