diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt index 80d68bd17d..7564db5d95 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt @@ -28,8 +28,6 @@ import io.element.android.libraries.di.BaseDirectory import io.element.android.libraries.di.CacheDirectory import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.ApplicationContext -import io.element.android.libraries.emoji.api.EmojibaseProvider -import io.element.android.libraries.emoji.impl.DefaultEmojibaseProvider import io.element.android.x.BuildConfig import io.element.android.x.R import kotlinx.coroutines.CoroutineName diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index b40aebab39..d442e1bb95 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -107,6 +107,5 @@ dependencies { testImplementation(projects.features.poll.test) testImplementation(projects.libraries.eventformatter.test) testImplementation(projects.libraries.emoji.impl) - testImplementation(projects.libraries.emoji.test) testImplementation(projects.libraries.slashcommands.test) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index 18c02d8f40..d4fce982e8 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -38,6 +38,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.timeline.TimelineEvent import io.element.android.features.messages.impl.timeline.TimelinePresenter +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories import io.element.android.features.messages.impl.timeline.model.TimelineItem @@ -54,6 +55,7 @@ import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -100,6 +102,7 @@ class MessagesNode( private val knockRequestsBannerRenderer: KnockRequestsBannerRenderer, private val roomMemberModerationRenderer: RoomMemberModerationRenderer, private val eventContentValidationCache: EventContentValidationCache, + private val emojiPickerRenderer: EmojiPickerRenderer, ) : Node(buildContext, plugins = plugins), MessagesNavigator { data class Inputs( val focusedEventId: EventId?, @@ -329,6 +332,15 @@ class MessagesNode( onViewRequestsClick = callback::navigateToKnockRequestsList, ) }, + customReactionBottomSheet = { + CustomReactionBottomSheet( + state = state.customReactionState, + onSelectEmoji = { uniqueId, emoji -> + state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId)) + }, + emojiPickerRenderer = emojiPickerRenderer, + ) + }, onThreadsListClick = callback::navigateToThreadsList, ) roomMemberModerationRenderer.Render( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 6389089e07..f47e76fbef 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -53,7 +53,6 @@ import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown import io.element.android.libraries.textcomposer.model.aTextEditorStateRich -import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf @@ -193,11 +192,9 @@ fun aReactionSummaryState( fun aCustomReactionState( target: CustomReactionState.Target = CustomReactionState.Target.None, - recentEmojis: ImmutableList = persistentListOf(), eventSink: (CustomReactionEvent) -> Unit = {}, ) = CustomReactionState( target = target, - recentEmojis = recentEmojis, selectedEmoji = persistentSetOf(), eventSink = eventSink, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index ec014a4ff6..74d6c71aab 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -81,7 +81,6 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemDaySepara import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineState import io.element.android.features.messages.impl.timeline.components.CallMenuItem -import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryView @@ -149,6 +148,7 @@ fun MessagesView( modifier: Modifier = Modifier, forceJumpToBottomVisibility: Boolean = false, knockRequestsBannerView: @Composable () -> Unit, + customReactionBottomSheet: @Composable () -> Unit, ) { val eventContentValidationState = LocalEventContentValidationState.current @@ -409,12 +409,7 @@ fun MessagesView( }, ) - CustomReactionBottomSheet( - state = state.customReactionState, - onSelectEmoji = { uniqueId, emoji -> - state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId)) - } - ) + customReactionBottomSheet() ReactionSummaryView(state = state.reactionSummaryState) ReadReceiptBottomSheet( @@ -669,6 +664,7 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) onViewAllPinnedMessagesClick = { }, forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, + customReactionBottomSheet = {}, onThreadsListClick = {}, ) } @@ -726,5 +722,6 @@ internal fun MessagesViewA11yPreview() = ElementPreview { onThreadsListClick = {}, forceJumpToBottomVisibility = true, knockRequestsBannerView = {}, + customReactionBottomSheet = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt index d7dd95eb95..4e9bf9249a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/identity/MessagesViewWithIdentityChangePreview.kt @@ -43,6 +43,7 @@ internal fun MessagesViewWithIdentityChangePreview( onJoinCallClick = {}, onViewAllPinnedMessagesClick = {}, knockRequestsBannerView = {}, + customReactionBottomSheet = {}, onThreadsListClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt index c3e06b880b..b56268eb02 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/threads/ThreadedMessagesNode.kt @@ -322,6 +322,7 @@ class ThreadedMessagesNode( onViewAllPinnedMessagesClick = {}, modifier = modifier, knockRequestsBannerView = {}, + customReactionBottomSheet = {}, onThreadsListClick = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt index 5b66a7a9ba..cc971e16ba 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt @@ -13,15 +13,13 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope 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.features.messages.impl.timeline.a11y.a11yReactionAction import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.hide +import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId @OptIn(ExperimentalMaterial3Api::class) @@ -29,46 +27,45 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransa fun CustomReactionBottomSheet( state: CustomReactionState, onSelectEmoji: (EventOrTransactionId, Emoji) -> Unit, + emojiPickerRenderer: EmojiPickerRenderer, modifier: Modifier = Modifier, ) { - val sheetState = rememberBottomSheetState( - initialValue = SheetValue.Hidden, - ) + val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden) val coroutineScope = rememberCoroutineScope() - val target = state.target as? CustomReactionState.Target.Success - fun onDismiss() { - state.eventSink(CustomReactionEvent.DismissCustomReactionSheet) - } + when (state.target) { + CustomReactionState.Target.None, + is CustomReactionState.Target.Loading -> Unit + is CustomReactionState.Target.Success -> { - fun onEmojiSelectedDismiss(emoji: Emoji) { - if (target?.event == null) return - sheetState.hide(coroutineScope) { - state.eventSink(CustomReactionEvent.DismissCustomReactionSheet) - onSelectEmoji(target.event.eventOrTransactionId, emoji) - } - } + fun onEmojiSelectedDismiss(emoji: Emoji) { + sheetState.hide(coroutineScope) { + state.eventSink(CustomReactionEvent.DismissCustomReactionSheet) + onSelectEmoji(state.target.event.eventOrTransactionId, emoji) + } + } - if (target?.emojibaseStore != null && target.event.eventId != null) { - ModalBottomSheet( - onDismissRequest = ::onDismiss, - sheetState = sheetState, - modifier = modifier, - scrollable = false, - ) { - val presenter = remember { - EmojiPickerPresenter( - emojibaseStore = target.emojibaseStore, - recentEmojis = state.recentEmojis, - coroutineDispatchers = CoroutineDispatchers.Default, + fun onDismiss() { + state.eventSink(CustomReactionEvent.DismissCustomReactionSheet) + } + + + ModalBottomSheet( + onDismissRequest = ::onDismiss, + sheetState = sheetState, + modifier = modifier, + scrollable = false, + ) { + emojiPickerRenderer.Render( + state = state.target.emojiPickerState, + onSelectEmoji = ::onEmojiSelectedDismiss, + selectedEmojis = state.selectedEmoji, + modifier = Modifier.fillMaxSize(), + contentDescription = { emoji, isSelected -> + a11yReactionAction(emoji = emoji.unicode, userAlreadyReacted = isSelected) + }, ) } - EmojiPicker( - onSelectEmoji = ::onEmojiSelectedDismiss, - state = presenter.present(), - selectedEmojis = state.selectedEmoji, - modifier = Modifier.fillMaxSize(), - ) } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt index 1fddf4e3bb..7a9f5fe4ff 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenter.kt @@ -9,59 +9,50 @@ package io.element.android.features.messages.impl.timeline.components.customreaction 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.emoji.api.EmojibaseProvider -import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf +import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter import kotlinx.collections.immutable.toImmutableSet -import kotlinx.coroutines.launch @Inject class CustomReactionPresenter( - private val emojibaseProvider: EmojibaseProvider, - private val getRecentEmojis: GetRecentEmojis, + private val emojiPickerPresenter: EmojiPickerPresenter, ) : Presenter { @Composable override fun present(): CustomReactionState { - val localCoroutineScope = rememberCoroutineScope() - var recentEmojis by remember { mutableStateOf>(persistentListOf()) } - - val target: MutableState = remember { - mutableStateOf(CustomReactionState.Target.None) - } - - fun handleShowCustomReactionSheet(event: TimelineItem.Event) { - target.value = CustomReactionState.Target.Loading(event) - localCoroutineScope.launch { - recentEmojis = getRecentEmojis().getOrNull() ?: persistentListOf() - target.value = CustomReactionState.Target.Success( - event = event, - emojibaseStore = emojibaseProvider.emojibaseStore - ) - } - } - - fun handleDismissCustomReactionSheet() { - target.value = CustomReactionState.Target.None - } + var internalTarget by remember { mutableStateOf(InternalTarget.None) } + val emojiPickerState = emojiPickerPresenter.present() fun handleEvent(event: CustomReactionEvent) { when (event) { - is CustomReactionEvent.ShowCustomReactionSheet -> handleShowCustomReactionSheet(event.event) - is CustomReactionEvent.DismissCustomReactionSheet -> handleDismissCustomReactionSheet() + is CustomReactionEvent.ShowCustomReactionSheet -> { + internalTarget = InternalTarget.Showing(event.event) + } + is CustomReactionEvent.DismissCustomReactionSheet -> { + internalTarget = InternalTarget.None + } } } - val event = (target.value as? CustomReactionState.Target.Success)?.event - val selectedEmoji = event + + val computedTarget = when (val target = internalTarget) { + InternalTarget.None -> CustomReactionState.Target.None + is InternalTarget.Showing -> if (emojiPickerState.isReady) { + CustomReactionState.Target.Success( + event = target.event, + emojiPickerState = emojiPickerState, + ) + } else { + CustomReactionState.Target.Loading(target.event) + } + } + + val eventForSelection = (computedTarget as? CustomReactionState.Target.Success)?.event + val selectedEmoji = eventForSelection ?.reactionsState ?.reactions ?.mapNotNull { if (it.isHighlighted) it.key else null } @@ -69,10 +60,14 @@ class CustomReactionPresenter( .toImmutableSet() return CustomReactionState( - target = target.value, + target = computedTarget, selectedEmoji = selectedEmoji, - recentEmojis = recentEmojis, eventSink = ::handleEvent, ) } } + +private sealed interface InternalTarget { + data object None : InternalTarget + data class Showing(val event: TimelineItem.Event) : InternalTarget +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt index 1897d4e5a7..566453b32b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionState.kt @@ -8,15 +8,13 @@ package io.element.android.features.messages.impl.timeline.components.customreaction -import io.element.android.emojibasebindings.EmojibaseStore import io.element.android.features.messages.impl.timeline.model.TimelineItem -import kotlinx.collections.immutable.ImmutableList +import io.element.android.libraries.emoji.api.picker.EmojiPickerState import kotlinx.collections.immutable.ImmutableSet data class CustomReactionState( val target: Target, val selectedEmoji: ImmutableSet, - val recentEmojis: ImmutableList, val eventSink: (CustomReactionEvent) -> Unit, ) { sealed interface Target { @@ -24,7 +22,7 @@ data class CustomReactionState( data class Loading(val event: TimelineItem.Event) : Target data class Success( val event: TimelineItem.Event, - val emojibaseStore: EmojibaseStore, + val emojiPickerState: EmojiPickerState, ) : Target } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerPresenter.kt deleted file mode 100644 index 3a4cf6c9ce..0000000000 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerPresenter.kt +++ /dev/null @@ -1,114 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * 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.foundation.text.input.rememberTextFieldState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -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.delay -import kotlinx.coroutines.withContext -import kotlin.time.Duration.Companion.milliseconds - -class EmojiPickerPresenter( - private val emojibaseStore: EmojibaseStore, - private val recentEmojis: ImmutableList, - private val coroutineDispatchers: CoroutineDispatchers, -) : Presenter { - @Composable - override fun present(): EmojiPickerState { - val queryState = rememberTextFieldState() - var isSearchActive by remember { mutableStateOf(false) } - var emojiResults by remember { mutableStateOf>>(SearchBarResultState.Initial()) } - - 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() - } - } - - val searchQuery = queryState.text.toString() - LaunchedEffect(searchQuery) { - emojiResults = if (searchQuery.isEmpty()) { - SearchBarResultState.Initial() - } else { - // Add a small delay to avoid doing too many computations when the user is typing quickly - delay(100.milliseconds) - - val lowercaseQuery = searchQuery.lowercase() - val results = withContext(coroutineDispatchers.computation) { - emojibaseStore.allEmojis - .asSequence() - .filter { emoji -> - emoji.tags.orEmpty().any { it.contains(lowercaseQuery) } || - emoji.shortcodes.any { it.contains(lowercaseQuery) } - } - .take(60) - .toImmutableList() - } - - SearchBarResultState.Results(results) - } - } - - val isInPreview = LocalInspectionMode.current - fun handleEvent(event: EmojiPickerEvent) { - when (event) { - // For some reason, in preview mode the SearchBar emits this event with an `isActive = true` value automatically - is EmojiPickerEvent.ToggleSearchActive -> if (!isInPreview) { - isSearchActive = event.isActive - } - } - } - - return EmojiPickerState( - categories = categories, - allEmojis = emojibaseStore.allEmojis, - searchQuery = queryState, - isSearchActive = isSearchActive, - searchResults = emojiResults, - eventSink = ::handleEvent, - ) - } -} diff --git a/features/messages/impl/src/main/res/values-be/translations.xml b/features/messages/impl/src/main/res/values-be/translations.xml index 8b40c72040..b6cac43616 100644 --- a/features/messages/impl/src/main/res/values-be/translations.xml +++ b/features/messages/impl/src/main/res/values-be/translations.xml @@ -6,14 +6,6 @@ "Зашыфравана невядомай ці выдаленай прыладай." "Зашыфравана прыладай, не пацверджанай яе ўладальнікам." "Зашыфравана неправераным карыстальнікам." - "Актыўнасці" - "Сцягі" - "Ежа & Напоі" - "Жывёлы & Прырода" - "Аб\'екты" - "Усмешкі & Удзельнікі" - "Падарожжы & Месцы" - "Сімвалы" "Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз." "Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз." "Апрацоўка…" diff --git a/features/messages/impl/src/main/res/values-bg/translations.xml b/features/messages/impl/src/main/res/values-bg/translations.xml index 1e675f52ae..2db2e6930f 100644 --- a/features/messages/impl/src/main/res/values-bg/translations.xml +++ b/features/messages/impl/src/main/res/values-bg/translations.xml @@ -1,14 +1,6 @@ "Без шифроване" - "Дейности" - "Знамена" - "Храна & Напитки" - "Животни & Природа" - "Обекти" - "Усмивки & Хора" - "Пътуване & Места" - "Символи" "Докоснете, за да промените качеството на качване на видео" "Файлът не можа да бъде качен." "Неуспешна обработка на мултимедия за качване, моля, опитайте отново." diff --git a/features/messages/impl/src/main/res/values-ca/translations.xml b/features/messages/impl/src/main/res/values-ca/translations.xml index f9b2685c7d..44fe948683 100644 --- a/features/messages/impl/src/main/res/values-ca/translations.xml +++ b/features/messages/impl/src/main/res/values-ca/translations.xml @@ -7,15 +7,6 @@ "Xifrat per un dispositiu desconegut o eliminat." "Xifrat per un dispositiu no verificat pel seu propietari." "Xifrat per un usuari no verificat." - "Activitats" - "Banderes" - "Menjar & begudes" - "Animals i naturalesa" - "Objectes" - "Cares i persones" - "Viatges i llocs" - "Emoticones recents" - "Símbols" "És possible que les llegendes no siguin visibles pels que utilitzin aplicacions antigues." "No s\'ha pogut processar el contingut que s\'havia de pujar. Torna-ho a provar." "No s\'ha pogut pujar el contingut. Torna-ho a provar." diff --git a/features/messages/impl/src/main/res/values-cs/translations.xml b/features/messages/impl/src/main/res/values-cs/translations.xml index 9bf1c5c079..6c39d67583 100644 --- a/features/messages/impl/src/main/res/values-cs/translations.xml +++ b/features/messages/impl/src/main/res/values-cs/translations.xml @@ -7,15 +7,6 @@ "Šifrováno neznámým nebo smazaným zařízením." "Šifrováno zařízením, které nebylo ověřeno jeho vlastníkem." "Šifrováno neověřeným uživatelem." - "Aktivity" - "Vlajky" - "Jídlo a nápoje" - "Zvířata a příroda" - "Předměty" - "Smajlíci a lidé" - "Cestování a místa" - "Nedávné emotikony" - "Symboly" "Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace." "Klepnutím změníte kvalitu nahrávání videa" "Soubor nelze nahrát." diff --git a/features/messages/impl/src/main/res/values-cy/translations.xml b/features/messages/impl/src/main/res/values-cy/translations.xml index 736a9a34f9..f0db099f8e 100644 --- a/features/messages/impl/src/main/res/values-cy/translations.xml +++ b/features/messages/impl/src/main/res/values-cy/translations.xml @@ -7,15 +7,6 @@ "Wedi\'i amgryptio gan ddyfais anhysbys neu wedi\'i dileu." "Wedi\'i amgryptio gan ddyfais nad yw wedi\'i wirio gan ei pherchennog." "Wedi\'i amgryptio gan ddefnyddiwr heb ei wirio." - "Gweithgareddau" - "Baneri" - "Bwyd a Diod" - "Anifeiliaid a Natur" - "Gwrthrychau" - "Wynebau Hapus a Phobl" - "Teithio a Llefydd" - "Emojis diweddar" - "Symbolau" "Efallai na fydd capsiynau yn weladwy i bobl sy\'n defnyddio apiau hŷn." "Tapiwch i newid ansawdd llwytho\'r fideo" "Nid oedd modd llwytho\'r ffeil." diff --git a/features/messages/impl/src/main/res/values-da/translations.xml b/features/messages/impl/src/main/res/values-da/translations.xml index e068df293a..f168dd7896 100644 --- a/features/messages/impl/src/main/res/values-da/translations.xml +++ b/features/messages/impl/src/main/res/values-da/translations.xml @@ -7,15 +7,6 @@ "Krypteret af en ukendt eller slettet enhed." "Krypteret af en enhed, der ikke er verificeret af sin ejer." "Krypteret af en ikke-verificeret bruger." - "Aktiviteter" - "Flag" - "Mad og drikke" - "Dyr og natur" - "Objekter" - "Smileys og personer" - "Rejser og steder" - "Seneste emojis" - "Symboler" "Billedtekster er muligvis ikke synlige for personer, der bruger ældre apps." "Tryk for at ændre videokvaliteten i uploadet" "Filen kunne ikke uploades." diff --git a/features/messages/impl/src/main/res/values-de/translations.xml b/features/messages/impl/src/main/res/values-de/translations.xml index 4848f1f282..1bdb3e0b16 100644 --- a/features/messages/impl/src/main/res/values-de/translations.xml +++ b/features/messages/impl/src/main/res/values-de/translations.xml @@ -7,15 +7,6 @@ "Verschlüsselt von einem unbekannten oder gelöschten Gerät." "Verschlüsselt durch ein Gerät, das nicht von seinem Besitzer verifiziert wurde." "Verschlüsselt durch einen nicht verifizierten Nutzer." - "Aktivitäten" - "Flaggen" - "Essen & Trinken" - "Tiere & Natur" - "Objekte" - "Smileys & Menschen" - "Reisen & Orte" - "Zuletzt verwendete Emojis" - "Symbole" "Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar." "Tippe, um die Qualität des Video-Uploads zu ändern" "Die Datei konnte nicht hochgeladen werden." diff --git a/features/messages/impl/src/main/res/values-el/translations.xml b/features/messages/impl/src/main/res/values-el/translations.xml index bfecdbb944..4a944d5250 100644 --- a/features/messages/impl/src/main/res/values-el/translations.xml +++ b/features/messages/impl/src/main/res/values-el/translations.xml @@ -7,15 +7,6 @@ "Κρυπτογραφημένο από άγνωστη ή διαγεγραμμένη συσκευή." "Κρυπτογραφημένο από μια συσκευή που δεν έχει επαληθευτεί από τον ιδιοκτήτη της." "Κρυπτογραφημένο από μη επαληθευμένο χρήστη." - "Δραστηριότητες" - "Σημαίες" - "Φαγητό & Ποτό" - "Ζώα & Φύση" - "Αντικείμενα" - "Φατσούλες & Άνθρωποι" - "Ταξίδια & Μέρη" - "Πρόσφατα emoji" - "Σύμβολα" "Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές." "Πατήστε για να αλλάξετε την ποιότητα μεταφόρτωσης βίντεο" "Δεν ήταν δυνατή η μεταφόρτωση του αρχείου." diff --git a/features/messages/impl/src/main/res/values-es/translations.xml b/features/messages/impl/src/main/res/values-es/translations.xml index 81ca655676..0a0b57cc5e 100644 --- a/features/messages/impl/src/main/res/values-es/translations.xml +++ b/features/messages/impl/src/main/res/values-es/translations.xml @@ -7,14 +7,6 @@ "Cifrado por un dispositivo desconocido o eliminado." "Cifrado por un dispositivo no verificado por su propietario." "Cifrado por un usuario no verificado." - "Actividades" - "Banderas" - "Comida y bebida" - "Animales y naturaleza" - "Objetos" - "Emojis y personas" - "Viajes y lugares" - "Símbolos" "Es posible que las leyendas no sean visibles para las personas que usan aplicaciones más antiguas." "Error al procesar el contenido multimedia, por favor inténtalo de nuevo." "Error al subir el contenido multimedia, por favor inténtalo de nuevo." diff --git a/features/messages/impl/src/main/res/values-et/translations.xml b/features/messages/impl/src/main/res/values-et/translations.xml index 86b1995f32..aa6576fc17 100644 --- a/features/messages/impl/src/main/res/values-et/translations.xml +++ b/features/messages/impl/src/main/res/values-et/translations.xml @@ -7,15 +7,6 @@ "Krüptitud tundmatu või kustutatud seadme poolt." "Krüptitud seadme poolt, mida tema omanik pole verifitseerinud." "Krüptitud verifitseerimata kasutaja poolt." - "Tegevused" - "Lipud" - "Toit ja jook" - "Loomad ja loodus" - "Esemed" - "Emotikonid ja inimesed" - "Reisimine ja kohad" - "Hiljutised emojid" - "Sümbolid" "Pööra pilti ümber rõhttelje" "Pilt on pööratud ümber rõhttelje" "Pildi algne variant" diff --git a/features/messages/impl/src/main/res/values-eu/translations.xml b/features/messages/impl/src/main/res/values-eu/translations.xml index 6c76b4dfd9..8c158d688a 100644 --- a/features/messages/impl/src/main/res/values-eu/translations.xml +++ b/features/messages/impl/src/main/res/values-eu/translations.xml @@ -1,14 +1,6 @@ "Ez dago zifratuta." - "Jarduerak" - "Banderak" - "Jana eta edana" - "Animaliak eta natura" - "Objektuak" - "Irribartxoak eta jendea" - "Bidaiak eta tokiak" - "Ikurrak" "Huts egin du multimedia igotzeak, saiatu berriro." "Blokeatu erabiltzailea" "Mezua zure zerbitzariko administratzaileari jakinaraziko zaio. Ezingo dute zifratutako mezurik irakurri." diff --git a/features/messages/impl/src/main/res/values-fa/translations.xml b/features/messages/impl/src/main/res/values-fa/translations.xml index 19f55a5559..4f8f737f9c 100644 --- a/features/messages/impl/src/main/res/values-fa/translations.xml +++ b/features/messages/impl/src/main/res/values-fa/translations.xml @@ -6,15 +6,6 @@ "رمز شده به دست افزاره‌ای ناشناخته یا حذف شده." "رمز شده به دست افزاره‌ای که از سوی مالکش تأیید نشده." "رمز شده به دست کاربری تأیید نشده." - "فعّالیت‌ها" - "پرچم‌ها" - "غذا و نوشیدنی" - "حیوانات و طبعیت" - "اشیا" - "شکلک‌ها و افراد" - "سفر و مکان‌ها" - "شکلک‌های اخیر" - "نمادها" "پردازش رسانه برای بارگذاری شکست خورد. لطفاً دوباره تلاش کنید." "بارگذاری رسانه شکست خورد. لطفاً دوباره تلاش کنید." "پردازش کردن…" diff --git a/features/messages/impl/src/main/res/values-fi/translations.xml b/features/messages/impl/src/main/res/values-fi/translations.xml index 881a5386e7..3705047087 100644 --- a/features/messages/impl/src/main/res/values-fi/translations.xml +++ b/features/messages/impl/src/main/res/values-fi/translations.xml @@ -7,15 +7,6 @@ "Tuntemattoman tai poistetun laitteen salaama." "Salattu laitteella, jota sen omistaja ei ole vahvistanut." "Vahvistamattoman käyttäjän salaama." - "Aktiviteetit" - "Liput" - "Ruoka ja juoma" - "Eläimet ja luonto" - "Esineet" - "Hymiöt ja ihmiset" - "Matkustaminen ja paikat" - "Viimeaikaiset emojit" - "Symbolit" "Käännä kuva vaakasuunnassa" "Käännetty vaakasuunnassa" "Alkuperäinen" diff --git a/features/messages/impl/src/main/res/values-fr/translations.xml b/features/messages/impl/src/main/res/values-fr/translations.xml index 3505fd520d..0062421326 100644 --- a/features/messages/impl/src/main/res/values-fr/translations.xml +++ b/features/messages/impl/src/main/res/values-fr/translations.xml @@ -7,15 +7,6 @@ "Chiffré par un appareil inconnu ou supprimé." "Chiffré par un appareil non vérifié par son propriétaire." "Chiffré par un utilisateur non vérifié." - "Activités" - "Drapeaux" - "Nourriture et boissons" - "Animaux et nature" - "Objets" - "Émoticônes et personnes" - "Voyages & lieux" - "Emojis récents" - "Symboles" "Retourner l’image horizontalement" "Retournée horizontalement" "Originale" diff --git a/features/messages/impl/src/main/res/values-hr/translations.xml b/features/messages/impl/src/main/res/values-hr/translations.xml index 0f04f2179a..23912aaf78 100644 --- a/features/messages/impl/src/main/res/values-hr/translations.xml +++ b/features/messages/impl/src/main/res/values-hr/translations.xml @@ -7,15 +7,6 @@ "Šifrirano nepoznatim ili izbrisanim uređajem." "Šifrirano uređajem koji vlasnik nije potvrdio." "Šifrirao neprovjereni korisnik." - "Aktivnosti" - "Zastave" - "Hrana i piće" - "Životinje i priroda" - "Objekti" - "Emotikoni i osobe" - "Putovanja i mjesta" - "Nedavni emotikoni" - "Simboli" "Okreni sliku vodoravno" "Okrenuto vodoravno" "Izvornik" diff --git a/features/messages/impl/src/main/res/values-hu/translations.xml b/features/messages/impl/src/main/res/values-hu/translations.xml index bb33520cb6..2b428f92b2 100644 --- a/features/messages/impl/src/main/res/values-hu/translations.xml +++ b/features/messages/impl/src/main/res/values-hu/translations.xml @@ -7,15 +7,6 @@ "Ismeretlen vagy törölt eszköz által titkosítva." "A tulajdonos által nem ellenőrzött eszköz által titkosítva." "Nem ellenőrzött felhasználó által titkosítva." - "Tevékenységek" - "Zászlók" - "Étel és ital" - "Állatok és természet" - "Tárgyak" - "Mosolyok és emberek" - "Utazás és helyek" - "Legutóbbi emodzsik" - "Szimbólumok" "Kép vízszintes tükrözése" "Vízszintesen tükrözve" "Eredeti" diff --git a/features/messages/impl/src/main/res/values-in/translations.xml b/features/messages/impl/src/main/res/values-in/translations.xml index d052efb769..00233facfd 100644 --- a/features/messages/impl/src/main/res/values-in/translations.xml +++ b/features/messages/impl/src/main/res/values-in/translations.xml @@ -7,15 +7,6 @@ "Dienkripsi oleh perangkat yang tidak dikenal atau dihapus." "Dienkripsi oleh perangkat yang tidak diverifikasi oleh pemiliknya." "Dienkripsi oleh pengguna yang tidak terverifikasi." - "Aktivitas" - "Bendera" - "Makanan & Minuman" - "Hewan & Alam" - "Objek" - "Senyuman & Orang" - "Wisata & Tempat" - "Emojis Sebelumnya" - "Simbol" "Putar gambar ke kiri" "%1$d derajat" diff --git a/features/messages/impl/src/main/res/values-it/translations.xml b/features/messages/impl/src/main/res/values-it/translations.xml index 0f2e0d37be..53812093ee 100644 --- a/features/messages/impl/src/main/res/values-it/translations.xml +++ b/features/messages/impl/src/main/res/values-it/translations.xml @@ -7,15 +7,6 @@ "Cifrato da un dispositivo sconosciuto o eliminato." "Cifrato da un dispositivo non verificato dal proprietario." "Cifrato da un utente non verificato." - "Attività" - "Bandiere" - "Cibi & Bevande" - "Animali & Natura" - "Oggetti" - "Faccine & Persone" - "Viaggi & Luoghi" - "Emoji recenti" - "Simboli" "Capovolgi l\'immagine orizzontalmente" "Capovolto orizzontalmente" "Originale" diff --git a/features/messages/impl/src/main/res/values-ja/translations.xml b/features/messages/impl/src/main/res/values-ja/translations.xml index 26d33c4aa1..15851b893e 100644 --- a/features/messages/impl/src/main/res/values-ja/translations.xml +++ b/features/messages/impl/src/main/res/values-ja/translations.xml @@ -7,15 +7,6 @@ "削除されたまたは不明な端末により暗号化されています。" "所有者に検証されていない端末により暗号化されています。" "未検証のユーザーにより暗号化されています。" - "アクティビティ" - "旗" - "食べ物" - "動物・自然" - "物" - "顔・人" - "旅・場所" - "最近使用" - "記号" "水平に反転" "垂直に反転" "元画像" diff --git a/features/messages/impl/src/main/res/values-ka/translations.xml b/features/messages/impl/src/main/res/values-ka/translations.xml index 842fa4bf5b..54002be4b2 100644 --- a/features/messages/impl/src/main/res/values-ka/translations.xml +++ b/features/messages/impl/src/main/res/values-ka/translations.xml @@ -1,13 +1,5 @@ - "აქტივობები" - "დროშები" - "Საჭმელ-სასმელი" - "ცხოველები & ბუნება" - "ობიექტები" - "ღიმილები & ხალხი" - "მოგზაურობა და ადგილები" - "სიმბოლოები" "მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." "მედიის ატვირთვა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." "მომხმარებლის დაბლოკვა" diff --git a/features/messages/impl/src/main/res/values-ko/translations.xml b/features/messages/impl/src/main/res/values-ko/translations.xml index fbcb0cab52..82d81f42cd 100644 --- a/features/messages/impl/src/main/res/values-ko/translations.xml +++ b/features/messages/impl/src/main/res/values-ko/translations.xml @@ -7,15 +7,6 @@ "알 수 없거나 삭제된 장치에 의해 암호화됩니다." "소유자가 확인하지 않은 장치에 의해 암호화되었습니다." "검증되지 않은 사용자에 의해 암호화되었습니다." - "활동" - "깃발" - "음식 & 음료" - "동물 & 자연" - "사물" - "표정 & 사람" - "여행 & 장소" - "최근 이모지" - "상징" "이미지를 왼쪽으로 회전" "%1$d도" diff --git a/features/messages/impl/src/main/res/values-lt/translations.xml b/features/messages/impl/src/main/res/values-lt/translations.xml index 379f9ae853..39f1b43627 100644 --- a/features/messages/impl/src/main/res/values-lt/translations.xml +++ b/features/messages/impl/src/main/res/values-lt/translations.xml @@ -1,13 +1,5 @@ - "Veikla" - "Vėliavos" - "Maistas ir Gėrimai" - "Gyvūnai ir Gamta" - "Objektai" - "Šypsenėlės ir Žmonės" - "Kelionės ir Vietovės" - "Simboliai" "Nepavyko apdoroti įkeliamos laikmenos, bandykite dar kartą." "Nepavyko įkelti laikmenos, pabandykite dar kartą." "Blokuoti vartotoją" diff --git a/features/messages/impl/src/main/res/values-nb/translations.xml b/features/messages/impl/src/main/res/values-nb/translations.xml index a0dafdd136..a68aeabcdf 100644 --- a/features/messages/impl/src/main/res/values-nb/translations.xml +++ b/features/messages/impl/src/main/res/values-nb/translations.xml @@ -7,15 +7,6 @@ "Kryptert av en ukjent eller slettet enhet." "Kryptert med en enhet som ikke er verifisert av eieren." "Kryptert av en ubekreftet bruker." - "Aktiviteter" - "Flagg" - "Mat og drikke" - "Dyr og natur" - "Gjenstander" - "Smilefjes og mennesker" - "Reising og steder" - "Nylige emojier" - "Symboler" "Teksting er kanskje ikke synlig for personer som bruker eldre apper." "Trykk for å endre kvaliteten på videoopplastingen" "Filen kunne ikke lastes opp." diff --git a/features/messages/impl/src/main/res/values-nl/translations.xml b/features/messages/impl/src/main/res/values-nl/translations.xml index d2305229a7..dbce48fc97 100644 --- a/features/messages/impl/src/main/res/values-nl/translations.xml +++ b/features/messages/impl/src/main/res/values-nl/translations.xml @@ -6,14 +6,6 @@ "Versleuteld door een onbekend of verwijderd apparaat." "Versleuteld door een apparaat dat niet is geverifieerd door de eigenaar." "Versleuteld door een niet-geverifieerde gebruiker." - "Activiteiten" - "Vlaggen" - "Eten & Drinken" - "Dieren & Natuur" - "Voorwerpen" - "Smileys & Mensen" - "Reizen & Locaties" - "Symbolen" "Het verwerken van media voor uploaden is mislukt. Probeer het opnieuw." "Het uploaden van media is mislukt. Probeer het opnieuw." "Gebruiker blokkeren" diff --git a/features/messages/impl/src/main/res/values-pl/translations.xml b/features/messages/impl/src/main/res/values-pl/translations.xml index d8ffaac54c..651a8e460e 100644 --- a/features/messages/impl/src/main/res/values-pl/translations.xml +++ b/features/messages/impl/src/main/res/values-pl/translations.xml @@ -7,15 +7,6 @@ "Zaszyfrowana przez nieznane lub usunięte urządzenie." "Zaszyfrowana przez urządzenie niezweryfikowane przez jego właściciela." "Zaszyfrowana przez niezweryfikowanego użytkownika." - "Aktywności" - "Flagi" - "Jedzenie i napoje" - "Zwierzęta i natura" - "Obiekty" - "Buźki i osoby" - "Podróż i miejsca" - "Ostatnie emoji" - "Symbole" "Odwróć obraz w poziomie" "Odwrócono w poziomie" "Oryginalny" diff --git a/features/messages/impl/src/main/res/values-pt-rBR/translations.xml b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml index f93f0d71ca..5c8b62e5dd 100644 --- a/features/messages/impl/src/main/res/values-pt-rBR/translations.xml +++ b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml @@ -7,15 +7,6 @@ "Criptografado por um dispositivo desconhecido ou apagado." "Criptografado por um dispositivo que não foi verificado pelo seu dono." "Criptografado por um usuário não verificado." - "Atividades" - "Bandeiras" - "Comida & Bebida" - "Animais & Natureza" - "Objetos" - "Sorrisos & Pessoas" - "Viagens & Lugares" - "Emojis recentes" - "Símbolos" "As legendas podem não ser visíveis para pessoas que usam apps mais antigos." "Toque para alterar a qualidade do envio do vídeo" "O arquivo não pôde ser enviado." diff --git a/features/messages/impl/src/main/res/values-pt/translations.xml b/features/messages/impl/src/main/res/values-pt/translations.xml index 2fb5173169..6329956b05 100644 --- a/features/messages/impl/src/main/res/values-pt/translations.xml +++ b/features/messages/impl/src/main/res/values-pt/translations.xml @@ -7,15 +7,6 @@ "Cifragem com origem num dispositivo eliminado ou desconhecido." "Cifragem com origem num dispositivo não verificado pelo seu dono." "Cifragem com origem num utilizador não verificado." - "Atividades" - "Bandeiras" - "Comidas e Bebidas" - "Animais e Natureza" - "Objetos" - "Caras e Pessoas" - "Viagens e Lugares" - "Emojis recentes" - "Símbolos" "As legendas poderão não ser visíveis em versões mais antigas da aplicação." "Toca para alterar a qualidade de carregamento do vídeo" "Não foi possível enviar o ficheiro" diff --git a/features/messages/impl/src/main/res/values-ro/translations.xml b/features/messages/impl/src/main/res/values-ro/translations.xml index 67b72dc2a8..e9e4da87c3 100644 --- a/features/messages/impl/src/main/res/values-ro/translations.xml +++ b/features/messages/impl/src/main/res/values-ro/translations.xml @@ -7,15 +7,6 @@ "Criptat de un dispozitiv necunoscut sau șters." "Criptat de un dispozitiv care nu este verificat de proprietarul său." "Criptat de un utilizator neverificat." - "Activități" - "Steaguri" - "Mâncare & Băutură" - "Animale și Natură" - "Obiecte" - "Fețe zâmbitoare & Oameni" - "Călătorii & Locuri" - "Emoticoane recente" - "Simboluri" "Este posibil ca descrierile să nu fie vizibile pentru persoanele care folosesc aplicații mai vechi." "Atingeți pentru a modifica calitatea încărcării videoclipului" "Fișierul nu a putut fi încărcat." diff --git a/features/messages/impl/src/main/res/values-ru/translations.xml b/features/messages/impl/src/main/res/values-ru/translations.xml index 3744beff8f..32e6e16980 100644 --- a/features/messages/impl/src/main/res/values-ru/translations.xml +++ b/features/messages/impl/src/main/res/values-ru/translations.xml @@ -7,15 +7,6 @@ "Зашифровано неизвестным или удаленным устройством." "Зашифровано устройством, не проверенным его владельцем." "Зашифровано непроверенным пользователем." - "Активности" - "Флаги" - "Еда и напитки" - "Животные и природа" - "Объекты" - "Эмодзи и люди" - "Путешествия и места" - "Недавние эмодзи" - "Символы" "Перевернуть изображение по горизонтали" "Перевернуто по горизонтали" "Оригинал" diff --git a/features/messages/impl/src/main/res/values-sk/translations.xml b/features/messages/impl/src/main/res/values-sk/translations.xml index d2022c4882..c64e5ba62e 100644 --- a/features/messages/impl/src/main/res/values-sk/translations.xml +++ b/features/messages/impl/src/main/res/values-sk/translations.xml @@ -7,15 +7,6 @@ "Zašifrované neznámym alebo odstráneným zariadením." "Šifrované zariadením, ktoré nie je overené jeho majiteľom." "Šifrované neovereným používateľom." - "Aktivity" - "Vlajky" - "Jedlo a nápoje" - "Zvieratá a príroda" - "Predmety" - "Smajlíky a ľudia" - "Cestovanie a miesta" - "Nedávne emotikony" - "Symboly" "Titulky nemusia byť viditeľné pre ľudí používajúcich staršie aplikácie." "Ťuknutím zmeníte kvalitu nahratého videa" "Súbor sa nepodarilo nahrať." diff --git a/features/messages/impl/src/main/res/values-sv/translations.xml b/features/messages/impl/src/main/res/values-sv/translations.xml index 2e10fd3166..6de0b1ebcc 100644 --- a/features/messages/impl/src/main/res/values-sv/translations.xml +++ b/features/messages/impl/src/main/res/values-sv/translations.xml @@ -7,14 +7,6 @@ "Krypterad av en okänd eller raderad enhet." "Krypterad av en enhet som inte verifierats av ägaren." "Krypterad av en overifierad användare." - "Aktiviteter" - "Flaggor" - "Mat & dryck" - "Djur & natur" - "Föremål" - "Smileys & personer" - "Resor & platser" - "Symboler" "Bildtexter kanske inte är synliga för personer som använder äldre appar." "Tryck för att ändra videouppladdningskvaliteten" "Filen kunde inte laddas upp." diff --git a/features/messages/impl/src/main/res/values-tr/translations.xml b/features/messages/impl/src/main/res/values-tr/translations.xml index a40687444b..a987f63ce3 100644 --- a/features/messages/impl/src/main/res/values-tr/translations.xml +++ b/features/messages/impl/src/main/res/values-tr/translations.xml @@ -7,14 +7,6 @@ "Bilinmeyen veya silinmiş bir cihaz tarafından şifrelenmiştir." "Sahibi tarafından doğrulanmamış bir cihaz tarafından şifrelenmiştir." "Doğrulanmamış bir kullanıcı tarafından şifrelenmiştir." - "Aktiviteler" - "Bayraklar" - "Yiyecek ve İçecek" - "Hayvanlar ve Doğa" - "Nesneler" - "İfadeler ve İnsanlar" - "Seyahat ve Yerler" - "Simgeler" "Açıklamalar, eski uygulamaları kullanan kişiler tarafından görülemeyebilir." "Medya yüklenemedi, lütfen tekrar deneyin." "Medya yüklenemedi, lütfen tekrar deneyin." diff --git a/features/messages/impl/src/main/res/values-uk/translations.xml b/features/messages/impl/src/main/res/values-uk/translations.xml index cae9560768..c5f5ddde91 100644 --- a/features/messages/impl/src/main/res/values-uk/translations.xml +++ b/features/messages/impl/src/main/res/values-uk/translations.xml @@ -7,15 +7,6 @@ "Зашифровано невідомим або видаленим пристроєм." "Зашифровано пристроєм, який не звірено його власником." "Зашифровано не звіреним користувачем." - "Дії" - "Прапори" - "Їжа та напої" - "Тварини та природа" - "Об\'єкти" - "Смайлики та люди" - "Подорожі та місця" - "Нещодавні емодзі" - "Символи" "Повернути зображення по горизонталі" "Перевернуто по горизонталі" "Оригінал" diff --git a/features/messages/impl/src/main/res/values-ur/translations.xml b/features/messages/impl/src/main/res/values-ur/translations.xml index cb110ef5c6..6f549a5ba3 100644 --- a/features/messages/impl/src/main/res/values-ur/translations.xml +++ b/features/messages/impl/src/main/res/values-ur/translations.xml @@ -6,14 +6,6 @@ "کسی نامعلوم یا حذف شدہ آلے کے ذریعے مرموز کردہ۔" "کسی ایسے آلے کے ذریعے مرموز کردہ جس کی توثیق اسکے مالک سے نہیں ہوئی۔" "ایک غیر توثیق شدہ صارف کے ذریعے مرموز کردہ۔" - "سرگرمیاں" - "جھنڈے" - "طعم و مشروب" - "حیوانات و فطرت" - "اشیاء" - "مسکراہٹیں و لوگ" - "سفر و مقامات" - "علامتیں" "وسائط کا معالجہ برائے ترفیع ناکام، برائے مہربانی دوبارہ کوشش کریں۔" "وسائط رفع کرنے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔" "صارف کو مسدود کریں" diff --git a/features/messages/impl/src/main/res/values-uz/translations.xml b/features/messages/impl/src/main/res/values-uz/translations.xml index 03a1f6ef7e..8941917f5f 100644 --- a/features/messages/impl/src/main/res/values-uz/translations.xml +++ b/features/messages/impl/src/main/res/values-uz/translations.xml @@ -7,15 +7,6 @@ "Nomaʼlum yoki oʻchirib tashlangan qurilma tomonidan shifrlangan." "Egasi tasdiqlamagan qurilma tomonidan shifrlangan." "Tasdiqlanmagan foydalanuvchi tomonidan shifrlangan." - "Faoliyatlar" - "Bayroqlar" - "Oziq-ovqat va ichimliklar" - "Hayvonlar va tabiat" - "Ob\'ektlar" - "Smayllar va odamlar" - "Sayohat va Joylar" - "Oxirgi kulgichlar" - "Belgilar" "Rasmni gorizontal ravishda aylantiring" "Gorizontal ravishda aylantirildi" "Asl nusxasi" diff --git a/features/messages/impl/src/main/res/values-vi/translations.xml b/features/messages/impl/src/main/res/values-vi/translations.xml index 5104cd5a5b..a16d7378b3 100644 --- a/features/messages/impl/src/main/res/values-vi/translations.xml +++ b/features/messages/impl/src/main/res/values-vi/translations.xml @@ -7,15 +7,6 @@ "Được mã hóa bởi một thiết bị không xác định hoặc đã bị xóa." "Được mã hóa bởi một thiết bị chưa được chủ sở hữu xác minh." "Được mã hóa bởi một người dùng chưa được xác minh." - "Hoạt động" - "Cờ" - "Thực phẩm và đồ uống" - "Động vật và thiên nhiên" - "Đồ vật" - "Mặt cười & mọi người" - "Du lịch và địa danh" - "Biểu tượng cảm xúc gần đây" - "Biểu tượng" "Xử lý phương tiện tải lên không thành công, vui lòng thử lại." "Không thể tải lên tệp phương tiện. Vui lòng thử lại." "Chặn người dùng" diff --git a/features/messages/impl/src/main/res/values-zh-rTW/translations.xml b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml index 0eea6e808e..e8cd285b5e 100644 --- a/features/messages/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml @@ -7,15 +7,6 @@ "由未知或已刪除的裝置加密。" "由未經其擁有者驗證的裝置加密。" "由未經驗證的使用者加密。" - "活動" - "旗幟" - "食物與飲料" - "動物與大自然" - "物品" - "表情與人物" - "旅行與景點" - "最近使用的表情符號" - "標誌" "使用舊應用程式的使用者可能看不到標題。" "輕點即可變更影片上傳品質" "無法上傳檔案。" diff --git a/features/messages/impl/src/main/res/values-zh/translations.xml b/features/messages/impl/src/main/res/values-zh/translations.xml index e2300912ae..41e55e1cce 100644 --- a/features/messages/impl/src/main/res/values-zh/translations.xml +++ b/features/messages/impl/src/main/res/values-zh/translations.xml @@ -7,15 +7,6 @@ "由未知或已删除的设备加密。" "由未经其所有者验证的设备加密。" "由未经验证的用户加密。" - "节假日" - "旗帜" - "饮食" - "动物和自然" - "日常物品" - "表情和人物" - "文旅景点" - "最近的 Emoji" - "符号" "水平翻转图像" "水平翻转" "原始" diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml index e4790feba0..556ab00ee0 100644 --- a/features/messages/impl/src/main/res/values/localazy.xml +++ b/features/messages/impl/src/main/res/values/localazy.xml @@ -7,15 +7,6 @@ "Encrypted by an unknown or deleted device." "Encrypted by a device not verified by its owner." "Encrypted by an unverified user." - "Activities" - "Flags" - "Food & Drink" - "Animals & Nature" - "Objects" - "Smileys & People" - "Travel & Places" - "Recent emojis" - "Symbols" "Flip image horizontally" "Flipped horizontally" "Original" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index 5d2dceaf31..2800c737ad 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -31,7 +31,6 @@ import androidx.compose.ui.test.v2.runAndroidComposeUiTest import androidx.compose.ui.text.AnnotatedString import io.element.android.emojibasebindings.Emoji import io.element.android.emojibasebindings.EmojibaseCategory -import io.element.android.emojibasebindings.EmojibaseStore import io.element.android.features.messages.impl.actionlist.ActionListEvent import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.anActionListState @@ -48,6 +47,7 @@ import io.element.android.features.messages.impl.timeline.aTimelineItemList import io.element.android.features.messages.impl.timeline.aTimelineItemReadReceipts import io.element.android.features.messages.impl.timeline.aTimelineRoomInfo import io.element.android.features.messages.impl.timeline.aTimelineState +import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvent import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvent @@ -56,6 +56,8 @@ import io.element.android.features.messages.impl.timeline.components.receipt.bot import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent import io.element.android.features.roomcall.api.aStandByCallState +import io.element.android.libraries.emoji.impl.picker.DefaultEmojiPickerRenderer +import io.element.android.libraries.emoji.impl.picker.anEmojiPickerState import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom @@ -538,24 +540,23 @@ class MessagesViewTest : RobolectricTest() { eventSink = eventsRecorder, ) val timelineItem = state.timelineState.timelineItems.first() as TimelineItem.Event + val emojisByCategory = persistentMapOf( + EmojibaseCategory.People to persistentListOf( + Emoji( + hexcode = "", + label = "", + tags = persistentListOf(), + shortcodes = persistentListOf(), + unicode = aUnicode, + skins = null, + ) + ) + ) val stateWithCustomReactionState = state.copy( customReactionState = aCustomReactionState( target = CustomReactionState.Target.Success( event = timelineItem, - emojibaseStore = EmojibaseStore( - categories = persistentMapOf( - EmojibaseCategory.People to persistentListOf( - Emoji( - hexcode = "", - label = "", - tags = persistentListOf(), - shortcodes = persistentListOf(), - unicode = aUnicode, - skins = null, - ) - ) - ) - ), + emojiPickerState = anEmojiPickerState(emojis = emojisByCategory), ), eventSink = customReactionStateEventsRecorder ), @@ -695,6 +696,7 @@ private fun AndroidComposeUiTest.setMessagesView( onViewAllPinnedMessagesClick: () -> Unit = EnsureNeverCalled(), onThreadsListClicked: () -> Unit = EnsureNeverCalled(), ) { + val emojiPickerRenderer = DefaultEmojiPickerRenderer() setSafeContent { // Cannot use the RichTextEditor, so simulate a LocalInspectionMode CompositionLocalProvider(LocalInspectionMode provides true) { @@ -711,6 +713,15 @@ private fun AndroidComposeUiTest.setMessagesView( onJoinCallClick = onJoinCallClick, onViewAllPinnedMessagesClick = onViewAllPinnedMessagesClick, knockRequestsBannerView = {}, + customReactionBottomSheet = { + CustomReactionBottomSheet( + state = state.customReactionState, + onSelectEmoji = { uniqueId, emoji -> + state.eventSink(MessagesEvent.ToggleReaction(emoji.unicode, uniqueId)) + }, + emojiPickerRenderer = emojiPickerRenderer, + ) + }, onThreadsListClick = onThreadsListClicked, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTest.kt index d0765bd5e7..be765554de 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionPresenterTest.kt @@ -11,11 +11,10 @@ package io.element.android.features.messages.impl.timeline.components.customreac import com.google.common.truth.Truth.assertThat import io.element.android.features.messages.impl.timeline.aTimelineItemEvent import io.element.android.features.messages.impl.timeline.aTimelineItemReactions -import io.element.android.libraries.emoji.test.FakeEmojibaseProvider +import io.element.android.libraries.emoji.impl.picker.anEmojiPickerState import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test -import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -25,8 +24,7 @@ class CustomReactionPresenterTest { val warmUpRule = WarmUpRule() private val presenter = CustomReactionPresenter( - emojibaseProvider = FakeEmojibaseProvider(), - getRecentEmojis = { Result.success(persistentListOf()) }, + emojiPickerPresenter = { anEmojiPickerState() }, ) @Test diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerPresenterTest.kt deleted file mode 100644 index 9ebbfd5a3a..0000000000 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerPresenterTest.kt +++ /dev/null @@ -1,125 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * 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.foundation.text.input.setTextAndPlaceCursorAtEnd -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.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.robolectric.RobolectricTest -import io.element.android.tests.testutils.testCoroutineDispatchers -import kotlinx.collections.immutable.ImmutableList -import kotlinx.collections.immutable.persistentListOf -import kotlinx.collections.immutable.toImmutableMap -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.runTest -import org.junit.Rule -import org.junit.Test - -class EmojiPickerPresenterTest : RobolectricTest() { - @get:Rule - val warmUpRule = WarmUpRule() - - @Test - fun `updating search query loads new results`() = runTest { - testPresenter { - skipItems(1) - - val initialState = awaitItem() - assertThat(initialState.searchQuery.text.toString()).isEmpty() - assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java) - - initialState.searchQuery.setTextAndPlaceCursorAtEnd("smile") - assertThat(awaitItem().searchQuery.text.toString()).isEqualTo("smile") - - val stateWithResults = awaitItem() - assertThat(stateWithResults.searchQuery.text.toString()).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(EmojiPickerEvent.ToggleSearchActive(true)) - assertThat(awaitItem().isSearchActive).isTrue() - - initialState.eventSink(EmojiPickerEvent.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>> = persistentListOf(emojiCategory()), - recentEmojis: ImmutableList = persistentListOf(), - ) = EmojiPickerPresenter( - emojibaseStore = EmojibaseStore(categories.toMap().toImmutableMap()), - recentEmojis = recentEmojis, - coroutineDispatchers = testCoroutineDispatchers(), - ) - - private fun emojiCategory( - category: EmojibaseCategory = EmojibaseCategory.Activity, - emojis: ImmutableList = 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.() -> 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() - } - } -} diff --git a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerPresenter.kt b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerPresenter.kt new file mode 100644 index 0000000000..5e29ee01ab --- /dev/null +++ b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerPresenter.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2026 Element Creations 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.emoji.api.picker + +import io.element.android.libraries.architecture.Presenter + +fun interface EmojiPickerPresenter : Presenter diff --git a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerRenderer.kt b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerRenderer.kt new file mode 100644 index 0000000000..6d2f67a5a6 --- /dev/null +++ b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerRenderer.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2026 Element Creations 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.emoji.api.picker + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.ui.Modifier +import io.element.android.emojibasebindings.Emoji +import kotlinx.collections.immutable.ImmutableSet + +@Immutable +interface EmojiPickerRenderer { + /** + * @param state opaque state produced by [EmojiPickerPresenter.present]. + * @param onSelectEmoji invoked when the user taps an emoji (including a skin-tone variant). + * @param selectedEmojis emojis to visually mark as already selected. + * @param modifier layout modifier for the picker container. + * @param contentDescription accessibility label for each emoji cell. + */ + @Composable + fun Render( + state: EmojiPickerState, + onSelectEmoji: (Emoji) -> Unit, + selectedEmojis: ImmutableSet, + modifier: Modifier = Modifier, + contentDescription: @Composable (Emoji, Boolean) -> String = { emoji, _ -> emoji.unicode }, + ) +} diff --git a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerState.kt b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerState.kt new file mode 100644 index 0000000000..a5c820e648 --- /dev/null +++ b/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/picker/EmojiPickerState.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2026 Element Creations 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.emoji.api.picker + +/** + * Opaque state produced by an [EmojiPickerPresenter] and consumed by an [EmojiPickerRenderer]. + */ +interface EmojiPickerState { + /** + * `true` once the picker has finished loading its data and is safe to display. + * Callers should typically defer showing the picker (or its container, e.g. a bottom sheet) + * until this flips to `true` to avoid flashing an empty picker. + */ + val isReady: Boolean +} diff --git a/libraries/emoji/impl/build.gradle.kts b/libraries/emoji/impl/build.gradle.kts index 871c19a41a..1cb274e6c0 100644 --- a/libraries/emoji/impl/build.gradle.kts +++ b/libraries/emoji/impl/build.gradle.kts @@ -35,7 +35,6 @@ dependencies { implementation(libs.kotlinx.collections.immutable) implementation(libs.matrix.emojibase.bindings) - testImplementation(projects.libraries.emoji.test) testImplementation(projects.libraries.matrix.test) testCommonDependencies(libs) } diff --git a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/DefaultEmojibaseProvider.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/DefaultEmojibaseProvider.kt index f0ad797648..9f64879a80 100644 --- a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/DefaultEmojibaseProvider.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/DefaultEmojibaseProvider.kt @@ -11,16 +11,28 @@ package io.element.android.libraries.emoji.impl import android.content.Context import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn import io.element.android.emojibasebindings.EmojibaseDatasource import io.element.android.emojibasebindings.EmojibaseStore +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.di.annotations.ApplicationContext -import io.element.android.libraries.emoji.api.EmojibaseProvider +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.CoroutineStart +import kotlinx.coroutines.async +@Inject @SingleIn(AppScope::class) @ContributesBinding(AppScope::class) -class DefaultEmojibaseProvider(@ApplicationContext val context: Context) : EmojibaseProvider { - override val emojibaseStore: EmojibaseStore by lazy { +class DefaultEmojibaseProvider( + @ApplicationContext private val context: Context, + dispatchers: CoroutineDispatchers, + @AppCoroutineScope appScope: CoroutineScope, +) : EmojibaseProvider { + private val deferred = appScope.async(dispatchers.io, start = CoroutineStart.LAZY) { EmojibaseDatasource().load(context) } + + override suspend fun getStore(): EmojibaseStore = deferred.await() } diff --git a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/EmojibaseProvider.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/EmojibaseProvider.kt similarity index 60% rename from libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/EmojibaseProvider.kt rename to libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/EmojibaseProvider.kt index d8f9c92777..ddd18f8861 100644 --- a/libraries/emoji/api/src/main/kotlin/io/element/android/libraries/emoji/api/EmojibaseProvider.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/EmojibaseProvider.kt @@ -1,15 +1,14 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * Copyright (c) 2026 Element Creations 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.emoji.api +package io.element.android.libraries.emoji.impl import io.element.android.emojibasebindings.EmojibaseStore interface EmojibaseProvider { - val emojibaseStore: EmojibaseStore + suspend fun getStore(): EmojibaseStore } diff --git a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/DefaultEmojiPickerPresenter.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/DefaultEmojiPickerPresenter.kt new file mode 100644 index 0000000000..e7afe50c0f --- /dev/null +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/DefaultEmojiPickerPresenter.kt @@ -0,0 +1,133 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * 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.emoji.impl.picker + +import androidx.compose.foundation.text.input.rememberTextFieldState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalInspectionMode +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import io.element.android.emojibasebindings.Emoji +import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.designsystem.theme.components.IconSource +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter +import io.element.android.libraries.emoji.api.picker.EmojiPickerState +import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis +import io.element.android.libraries.emoji.impl.EmojibaseProvider +import io.element.android.libraries.emoji.impl.R +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.async +import kotlinx.coroutines.delay +import kotlinx.coroutines.withContext +import kotlin.time.Duration.Companion.milliseconds + +@Inject +@ContributesBinding(SessionScope::class) +class DefaultEmojiPickerPresenter( + private val emojibaseProvider: EmojibaseProvider, + private val getRecentEmojis: GetRecentEmojis, + private val coroutineDispatchers: CoroutineDispatchers, +) : EmojiPickerPresenter { + @Composable + override fun present(): EmojiPickerState { + val queryState = rememberTextFieldState() + var isSearchActive by remember { mutableStateOf(false) } + var emojiResults by remember { mutableStateOf>>(SearchBarResultState.Initial()) } + + val data by produceState(EmojiPickerData.Empty) { + val storeDeferred = async { emojibaseProvider.getStore() } + val recentsDeferred = async { getRecentEmojis().getOrNull() ?: persistentListOf() } + val store = storeDeferred.await() + val recentEmojiUnicodes = recentsDeferred.await() + value = withContext(coroutineDispatchers.computation) { + val baseCategories = store.categories.map { (category, emojis) -> + EmojiCategory( + titleId = category.title, + icon = IconSource.Vector(category.icon), + emojis = emojis, + ) + } + val recentEmojis = recentEmojiUnicodes + .mapNotNull { unicode -> store.allEmojis.find { it.unicode == unicode } } + .toImmutableList() + val categories = if (recentEmojis.isEmpty()) { + baseCategories.toImmutableList() + } else { + val recentCategory = EmojiCategory( + titleId = R.string.emoji_picker_category_recent, + icon = IconSource.Resource(io.element.android.compound.R.drawable.ic_compound_history), + emojis = recentEmojis, + ) + (listOf(recentCategory) + baseCategories).toImmutableList() + } + EmojiPickerData( + categories = categories, + allEmojis = store.allEmojis, + ) + } + } + + val searchQuery = queryState.text.toString() + LaunchedEffect(searchQuery, data) { + if (searchQuery.isEmpty() || data.allEmojis.isEmpty()) { + emojiResults = SearchBarResultState.Initial() + return@LaunchedEffect + } + delay(100.milliseconds) + val lowercaseQuery = searchQuery.lowercase() + val results = withContext(coroutineDispatchers.computation) { + data.allEmojis + .asSequence() + .filter { emoji -> + emoji.tags.orEmpty().any { it.contains(lowercaseQuery) } || + emoji.shortcodes.any { it.contains(lowercaseQuery) } + } + .take(60) + .toImmutableList() + } + emojiResults = SearchBarResultState.Results(results) + } + + val isInPreview = LocalInspectionMode.current + fun handleEvent(event: EmojiPickerEvent) { + when (event) { + is EmojiPickerEvent.ToggleSearchActive -> if (!isInPreview) { + isSearchActive = event.isActive + } + } + } + + return DefaultEmojiPickerState( + categories = data.categories, + searchQuery = queryState, + isSearchActive = isSearchActive, + searchResults = emojiResults, + eventSink = ::handleEvent, + ) + } +} + +private data class EmojiPickerData( + val categories: ImmutableList, + val allEmojis: ImmutableList, +) { + companion object { + val Empty = EmojiPickerData(persistentListOf(), persistentListOf()) + } +} diff --git a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/DefaultEmojiPickerRenderer.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/DefaultEmojiPickerRenderer.kt new file mode 100644 index 0000000000..4b6fcd8554 --- /dev/null +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/DefaultEmojiPickerRenderer.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2026 Element Creations 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.emoji.impl.picker + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.Inject +import io.element.android.emojibasebindings.Emoji +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer +import io.element.android.libraries.emoji.api.picker.EmojiPickerState +import kotlinx.collections.immutable.ImmutableSet + +@Inject +@ContributesBinding(SessionScope::class) +class DefaultEmojiPickerRenderer : EmojiPickerRenderer { + @Composable + override fun Render( + state: EmojiPickerState, + onSelectEmoji: (Emoji) -> Unit, + selectedEmojis: ImmutableSet, + modifier: Modifier, + contentDescription: @Composable (Emoji, Boolean) -> String, + ) { + if (state is DefaultEmojiPickerState) { + EmojiPickerView( + state = state, + onSelectEmoji = onSelectEmoji, + selectedEmojis = selectedEmojis, + contentDescription = contentDescription, + modifier = modifier, + ) + } else { + error("Unsupported state type: ${state::class.java}") + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerState.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/DefaultEmojiPickerState.kt similarity index 72% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerState.kt rename to libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/DefaultEmojiPickerState.kt index c1d306dece..8b3835724b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerState.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/DefaultEmojiPickerState.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.messages.impl.timeline.components.customreaction.picker +package io.element.android.libraries.emoji.impl.picker import androidx.annotation.StringRes import androidx.compose.foundation.text.input.TextFieldState @@ -14,23 +14,21 @@ import androidx.compose.runtime.Immutable import io.element.android.emojibasebindings.Emoji import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.emoji.api.picker.EmojiPickerState import kotlinx.collections.immutable.ImmutableList -// Emoji is unstable (because from an external library?), so we annotate with @Immutable @Immutable -data class EmojiPickerState( +internal data class DefaultEmojiPickerState( val categories: ImmutableList, - val allEmojis: ImmutableList, val searchQuery: TextFieldState, val isSearchActive: Boolean, val searchResults: SearchBarResultState>, val eventSink: (EmojiPickerEvent) -> Unit, -) +) : EmojiPickerState { + override val isReady = categories.isNotEmpty() +} -/** - * Represents a category of emojis with a title id, icon, and the list of associated emojis. - */ -data class EmojiCategory( +internal data class EmojiCategory( @StringRes val titleId: Int, val icon: IconSource, val emojis: ImmutableList, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerStateProvider.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/DefaultEmojiPickerStateProvider.kt similarity index 58% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerStateProvider.kt rename to libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/DefaultEmojiPickerStateProvider.kt index 958b3849f5..0a091a2b66 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerStateProvider.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/DefaultEmojiPickerStateProvider.kt @@ -6,29 +6,26 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.messages.impl.timeline.components.customreaction.picker +package io.element.android.libraries.emoji.impl.picker import androidx.compose.foundation.text.input.TextFieldState 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 io.element.android.libraries.emoji.api.picker.EmojiPickerState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList -class EmojiPickerStateProvider : PreviewParameterProvider { - override val values: Sequence +internal class DefaultEmojiPickerStateProvider : PreviewParameterProvider { + override val values: Sequence get() = sequenceOf( - anEmojiPickerState(), - anEmojiPickerState(isSearchActive = true), - anEmojiPickerState(isSearchActive = true, searchQuery = "smile"), - anEmojiPickerState( + aDefaultEmojiPickerState(), + aDefaultEmojiPickerState(isSearchActive = true), + aDefaultEmojiPickerState(isSearchActive = true, searchQuery = "smile"), + aDefaultEmojiPickerState( isSearchActive = true, searchQuery = "smile", searchResults = SearchBarResultState.Results(emojiList()) @@ -36,12 +33,6 @@ class EmojiPickerStateProvider : PreviewParameterProvider { ) } -private fun recentEmojisCategory() = EmojiCategory( - titleId = R.string.emoji_picker_category_recent, - icon = IconSource.Resource(CompoundDrawables.ic_compound_history), - emojis = emojiList(), -) - private fun emojiList(): ImmutableList = persistentListOf( Emoji( "0x00", @@ -56,29 +47,46 @@ private fun emojiList(): ImmutableList = persistentListOf( "crying face", persistentListOf("crying"), persistentListOf("smile, crying"), - "\uD83E\uDD72", + "🥲", null ) ) -internal fun anEmojiPickerState( - categories: ImmutableList = (listOf(recentEmojisCategory()) + EmojibaseCategory.entries.map { +internal fun aDefaultEmojiPickerState( + categories: ImmutableList = EmojibaseCategory.entries.map { EmojiCategory( titleId = it.title, icon = IconSource.Vector(it.icon), emojis = emojiList(), ) - }).toImmutableList(), - allEmojis: ImmutableList = categories.flatMap { it.emojis }.toImmutableList(), + }.toImmutableList(), searchQuery: String = "", isSearchActive: Boolean = false, searchResults: SearchBarResultState> = SearchBarResultState.Initial(), eventSink: (EmojiPickerEvent) -> Unit = {}, -) = EmojiPickerState( +) = DefaultEmojiPickerState( categories = categories, - allEmojis = allEmojis, searchQuery = TextFieldState(initialText = searchQuery), isSearchActive = isSearchActive, searchResults = searchResults, eventSink = eventSink, ) + +/** + * Public helper for external tests that need an [EmojiPickerState] instance the impl's + * [DefaultEmojiPickerRenderer] will accept. The no-arg overload returns a ready state + * (see [EmojiPickerState.isReady]); use the [emojis] overload to inject specific categories. + */ +fun anEmojiPickerState(): EmojiPickerState = aDefaultEmojiPickerState() + +fun anEmojiPickerState( + emojis: Map>, +): EmojiPickerState = aDefaultEmojiPickerState( + categories = emojis.map { (category, list) -> + EmojiCategory( + titleId = category.title, + icon = IconSource.Vector(category.icon), + emojis = list, + ) + }.toImmutableList(), +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiItem.kt similarity index 86% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt rename to libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiItem.kt index db3a70715d..27c80af961 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiItem.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.messages.impl.timeline.components.customreaction +package io.element.android.libraries.emoji.impl.picker import androidx.compose.foundation.Canvas import androidx.compose.foundation.background @@ -52,12 +52,6 @@ import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Popup import io.element.android.compound.theme.ElementTheme import io.element.android.emojibasebindings.Emoji -import io.element.android.features.messages.impl.timeline.a11y.a11yReactionAction -import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePadding -import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinTonePicker -import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSize -import io.element.android.features.messages.impl.timeline.components.customreaction.picker.SkinToneSlotSpacing -import io.element.android.features.messages.impl.timeline.components.customreaction.picker.aSkinList import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Text @@ -66,7 +60,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf @Composable -fun EmojiItem( +internal fun EmojiItem( item: Emoji, isSelected: Boolean, onSelectEmoji: (Emoji) -> Unit, @@ -75,6 +69,7 @@ fun EmojiItem( skinPickerEmoji: Emoji?, selectedSkinUnicodes: ImmutableSet, hasSelectedSkin: Boolean, + contentDescription: @Composable (emoji: Emoji, isSelected: Boolean) -> String = { emoji, _ -> emoji.unicode }, modifier: Modifier = Modifier, emojiSize: TextUnit = 20.sp, ) { @@ -89,18 +84,13 @@ fun EmojiItem( var hoveredIndex by remember { mutableIntStateOf(-1) } var dismissed by remember { mutableStateOf(false) } val interactionSource = remember { MutableInteractionSource() } - val description = a11yReactionAction( - emoji = item.unicode, - userAlreadyReacted = isSelected, - ) + val description = contentDescription(item, isSelected) val hasSkinTones = !item.skins.isNullOrEmpty() Box( modifier = modifier .onPlaced { coordinates -> val position = coordinates.positionInParent() val parentBounds = coordinates.parentLayoutCoordinates?.size ?: return@onPlaced - // Calculate the offset of the item inside its parent as a percentage, to be able to set the initial offset - // for the selection inside the skin tone picker relative to its size. itemOffsetPercent = if (parentBounds.width > 0) { position.x / parentBounds.width } else { @@ -112,7 +102,6 @@ fun EmojiItem( .background(backgroundColor, CircleShape) .indication(interactionSource, ripple()) .pointerInput(item) { - // Always detect long press and tap gestures detectTapGestures( onPress = { pressOffset -> val press = PressInteraction.Press(pressOffset) @@ -130,7 +119,6 @@ fun EmojiItem( ) } .then( - // Only detect drag after long press for those items which have a skin tone picker if (hasSkinTones) { Modifier.pointerInput(item) { val slotWidthPx = with(density) { SkinToneSlotSize.toPx() } @@ -147,8 +135,6 @@ fun EmojiItem( onDrag = { change, _ -> if (!dismissed) { change.consume() - // It's a valid drag event if it's within ~2 items height above, so, on top of the current one or the skin tone selector, - // or 1 item below, which should be far enough to not be triggered by mistake val isValidDrag = if (change.position.y < startOffset.y) { startOffset.y - change.position.y <= itemSize.height * 2f } else { @@ -160,15 +146,11 @@ fun EmojiItem( onDismissSkinPicker() } else { val skinItemsCount = item.skins!!.size - // Original + variants val totalSlots = 1 + skinItemsCount - // Calculate the whole size of the skin tone picker val pickerWidthPx = 2 * paddingPx + totalSlots * slotWidthPx + (totalSlots - 1) * spacingPx - // Calculate the initial offset inside the picker, given the relative position of the item inside its parent val initialOffset = pickerWidthPx * itemOffsetPercent val xInPicker = initialOffset + change.position.x - // If it's a valid offset, calculate the hovered index, otherwise, set it to -1 to indicate that no item is hovered val index = if (xInPicker in 0f..pickerWidthPx) { (xInPicker / slotWidthPx).toInt().coerceIn(0, totalSlots - 1) } else { @@ -210,7 +192,7 @@ fun EmojiItem( } ) .clearAndSetSemantics { - contentDescription = description + this.contentDescription = description }, contentAlignment = Alignment.Center ) { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerEvent.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerEvent.kt similarity index 71% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerEvent.kt rename to libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerEvent.kt index 6ea5eea0f6..57cbf8e08b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPickerEvent.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerEvent.kt @@ -6,8 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.messages.impl.timeline.components.customreaction.picker +package io.element.android.libraries.emoji.impl.picker -sealed interface EmojiPickerEvent { +internal sealed interface EmojiPickerEvent { data class ToggleSearchActive(val isActive: Boolean) : EmojiPickerEvent } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt similarity index 91% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt rename to libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt index ce65a435f4..325f1d792b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/EmojiPicker.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerView.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.messages.impl.timeline.components.customreaction.picker +package io.element.android.libraries.emoji.impl.picker import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column @@ -35,7 +35,6 @@ 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.features.messages.impl.timeline.components.customreaction.EmojiItem import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toSp @@ -50,11 +49,12 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @Composable -fun EmojiPicker( +internal fun EmojiPickerView( + state: DefaultEmojiPickerState, onSelectEmoji: (Emoji) -> Unit, - state: EmojiPickerState, selectedEmojis: ImmutableSet, modifier: Modifier = Modifier, + contentDescription: @Composable (emoji: Emoji, isSelected: Boolean) -> String = { emoji, _ -> emoji.unicode }, ) { val coroutineScope = rememberCoroutineScope() val pagerState = rememberPagerState(pageCount = { state.categories.size }) @@ -78,6 +78,7 @@ fun EmojiPicker( skinPickerEmoji = skinPickerEmoji, onDismissSkinPicker = { skinPickerEmoji = null }, selectedEmojis = selectedEmojis, + contentDescription = contentDescription, ) } @@ -120,6 +121,7 @@ fun EmojiPicker( skinPickerEmoji = skinPickerEmoji, onDismissSkinPicker = { skinPickerEmoji = null }, selectedEmojis = selectedEmojis, + contentDescription = contentDescription, ) } } @@ -135,6 +137,7 @@ private fun EmojiResults( skinPickerEmoji: Emoji?, onDismissSkinPicker: () -> Unit, selectedEmojis: ImmutableSet, + contentDescription: @Composable (emoji: Emoji, isSelected: Boolean) -> String, ) { LazyVerticalGrid( modifier = Modifier.fillMaxSize(), @@ -155,6 +158,7 @@ private fun EmojiResults( emojiSize = 32.dp.toSp(), selectedSkinUnicodes = selectedEmojis, hasSelectedSkin = item.skins?.any { skin -> skin.unicode in selectedEmojis } == true, + contentDescription = contentDescription, ) } } @@ -162,10 +166,10 @@ private fun EmojiResults( @PreviewsDayNight @Composable -internal fun EmojiPickerPreview(@PreviewParameter(EmojiPickerStateProvider::class) state: EmojiPickerState) = ElementPreview { - EmojiPicker( - onSelectEmoji = {}, +internal fun EmojiPickerViewPreview(@PreviewParameter(DefaultEmojiPickerStateProvider::class) state: DefaultEmojiPickerState) = ElementPreview { + EmojiPickerView( state = state, + onSelectEmoji = {}, selectedEmojis = persistentSetOf("😀", "😄", "😃"), modifier = Modifier.fillMaxWidth(), ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojibaseExtensions.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojibaseExtensions.kt similarity index 91% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojibaseExtensions.kt rename to libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojibaseExtensions.kt index 0f967cc41d..c6b428a13d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojibaseExtensions.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/EmojibaseExtensions.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.messages.impl.timeline.components.customreaction +package io.element.android.libraries.emoji.impl.picker import androidx.annotation.StringRes import androidx.compose.material.icons.Icons @@ -20,10 +20,10 @@ import androidx.compose.material.icons.outlined.EmojiSymbols import androidx.compose.material.icons.outlined.EmojiTransportation import androidx.compose.ui.graphics.vector.ImageVector import io.element.android.emojibasebindings.EmojibaseCategory -import io.element.android.features.messages.impl.R +import io.element.android.libraries.emoji.impl.R @get:StringRes -val EmojibaseCategory.title: Int +internal val EmojibaseCategory.title: Int get() = when (this) { EmojibaseCategory.People -> R.string.emoji_picker_category_people EmojibaseCategory.Nature -> R.string.emoji_picker_category_nature @@ -35,7 +35,7 @@ val EmojibaseCategory.title: Int EmojibaseCategory.Flags -> R.string.emoji_picker_category_flags } -val EmojibaseCategory.icon: ImageVector +internal val EmojibaseCategory.icon: ImageVector get() = when (this) { EmojibaseCategory.People -> Icons.Outlined.EmojiPeople EmojibaseCategory.Nature -> Icons.Outlined.EmojiNature diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/SkinTonePicker.kt similarity index 95% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt rename to libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/SkinTonePicker.kt index 1ac66b5378..6d2618873a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/picker/SkinTonePicker.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/picker/SkinTonePicker.kt @@ -5,7 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.messages.impl.timeline.components.customreaction.picker +package io.element.android.libraries.emoji.impl.picker import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -36,12 +36,12 @@ import kotlinx.collections.immutable.ImmutableSet import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf -val SkinToneSlotSize = 48.dp -val SkinToneSlotSpacing = 2.dp -val SkinTonePadding = 4.dp +internal val SkinToneSlotSize = 48.dp +internal val SkinToneSlotSpacing = 2.dp +internal val SkinTonePadding = 4.dp @Composable -fun SkinTonePicker( +internal fun SkinTonePicker( emoji: Emoji, hoveredIndex: Int, selectedUnicodes: ImmutableSet, diff --git a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/recentemojis/DefaultGetRecentEmojis.kt b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/recentemojis/DefaultGetRecentEmojis.kt index acd5e012ce..412332e984 100644 --- a/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/recentemojis/DefaultGetRecentEmojis.kt +++ b/libraries/emoji/impl/src/main/kotlin/io/element/android/libraries/emoji/impl/recentemojis/DefaultGetRecentEmojis.kt @@ -11,8 +11,8 @@ package io.element.android.libraries.emoji.impl.recentemojis import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.SessionScope -import io.element.android.libraries.emoji.api.EmojibaseProvider import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis +import io.element.android.libraries.emoji.impl.EmojibaseProvider import io.element.android.libraries.matrix.api.MatrixClient import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -25,7 +25,7 @@ class DefaultGetRecentEmojis( private val emojibaseProvider: EmojibaseProvider, ) : GetRecentEmojis { override suspend operator fun invoke(): Result> = withContext(dispatchers.io) { - val allEmojis = emojibaseProvider.emojibaseStore.allEmojis + val allEmojis = emojibaseProvider.getStore().allEmojis client.getRecentEmojis() .map { emojis -> // Remove any possible duplicates diff --git a/libraries/emoji/impl/src/main/res/values/temporary.xml b/libraries/emoji/impl/src/main/res/values/temporary.xml new file mode 100644 index 0000000000..34467795b9 --- /dev/null +++ b/libraries/emoji/impl/src/main/res/values/temporary.xml @@ -0,0 +1,13 @@ + + + + "Activities" + "Flags" + "Food & Drink" + "Animals & Nature" + "Objects" + "Smileys & People" + "Travel & Places" + "Recent emojis" + "Symbols" + diff --git a/libraries/emoji/test/src/main/kotlin/io/element/android/libraries/emoji/test/FakeEmojibaseProvider.kt b/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/fixtures/FakeEmojibaseProvider.kt similarity index 60% rename from libraries/emoji/test/src/main/kotlin/io/element/android/libraries/emoji/test/FakeEmojibaseProvider.kt rename to libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/fixtures/FakeEmojibaseProvider.kt index 0e7c42e538..f01b6a1268 100644 --- a/libraries/emoji/test/src/main/kotlin/io/element/android/libraries/emoji/test/FakeEmojibaseProvider.kt +++ b/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/fixtures/FakeEmojibaseProvider.kt @@ -6,18 +6,18 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.libraries.emoji.test +package io.element.android.libraries.emoji.impl.fixtures import io.element.android.emojibasebindings.Emoji import io.element.android.emojibasebindings.EmojibaseCategory import io.element.android.emojibasebindings.EmojibaseStore -import io.element.android.libraries.emoji.api.EmojibaseProvider +import io.element.android.libraries.emoji.impl.EmojibaseProvider import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentMap -class FakeEmojibaseProvider( - val emojis: Map> = mapOf(), +internal class FakeEmojibaseProvider( + emojis: Map> = mapOf(), ) : EmojibaseProvider { - override val emojibaseStore: EmojibaseStore - get() = EmojibaseStore(emojis.toPersistentMap()) + private val store = EmojibaseStore(emojis.toPersistentMap()) + override suspend fun getStore(): EmojibaseStore = store } diff --git a/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerPresenterTest.kt b/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerPresenterTest.kt new file mode 100644 index 0000000000..cf1a68f9d5 --- /dev/null +++ b/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/picker/EmojiPickerPresenterTest.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * 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.emoji.impl.picker + +import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd +import app.cash.turbine.TurbineTestContext +import com.google.common.truth.Truth.assertThat +import io.element.android.emojibasebindings.Emoji +import io.element.android.emojibasebindings.EmojibaseCategory +import io.element.android.libraries.designsystem.theme.components.SearchBarResultState +import io.element.android.libraries.emoji.api.picker.EmojiPickerState +import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis +import io.element.android.libraries.emoji.impl.fixtures.FakeEmojibaseProvider +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.robolectric.RobolectricTest +import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class EmojiPickerPresenterTest : RobolectricTest() { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `updating search query loads new results`() = runTest { + createPresenter().test { + val initialState = awaitStoreLoaded() + assertThat(initialState.searchQuery.text.toString()).isEmpty() + assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java) + + initialState.searchQuery.setTextAndPlaceCursorAtEnd("smile") + val stateAfterQuery = awaitItem() as DefaultEmojiPickerState + assertThat(stateAfterQuery.searchQuery.text.toString()).isEqualTo("smile") + + val stateWithResults = awaitItem() as DefaultEmojiPickerState + assertThat(stateWithResults.searchQuery.text.toString()).isEqualTo("smile") + assertThat(stateWithResults.searchResults).isInstanceOf(SearchBarResultState.Results::class.java) + } + } + + @Test + fun `recent emojis are prepended as a category when the source returns any`() = runTest { + createPresenter( + categories = persistentListOf(emojiCategory(EmojibaseCategory.Activity)), + getRecentEmojis = GetRecentEmojis { Result.success(persistentListOf("😊")) }, + ).test { + val state = awaitStoreLoaded() + assertThat(state.categories.size).isEqualTo(2) + } + } + + @Test + fun `ToggleSearchActive toggles the search state`() = runTest { + createPresenter().test { + val initialState = awaitStoreLoaded() + assertThat(initialState.isSearchActive).isFalse() + + initialState.eventSink(EmojiPickerEvent.ToggleSearchActive(true)) + assertThat((awaitItem() as DefaultEmojiPickerState).isSearchActive).isTrue() + + initialState.eventSink(EmojiPickerEvent.ToggleSearchActive(false)) + assertThat((awaitItem() as DefaultEmojiPickerState).isSearchActive).isFalse() + } + } + + @Test + fun `presenter exposes categories from the emoji store when the recent source is empty`() = runTest { + val providedCategories = persistentListOf( + emojiCategory(EmojibaseCategory.Activity), + emojiCategory(EmojibaseCategory.People), + ) + createPresenter(categories = providedCategories).test { + val state = awaitStoreLoaded() + assertThat(state.categories.size).isEqualTo(providedCategories.size) + } + } + + private suspend fun TurbineTestContext.awaitStoreLoaded(): DefaultEmojiPickerState { + var state = awaitItem() as DefaultEmojiPickerState + while (state.categories.isEmpty()) { + state = awaitItem() as DefaultEmojiPickerState + } + return state + } + + private fun TestScope.createPresenter( + categories: ImmutableList>> = persistentListOf(emojiCategory()), + getRecentEmojis: GetRecentEmojis = GetRecentEmojis { Result.success(persistentListOf()) }, + ) = DefaultEmojiPickerPresenter( + emojibaseProvider = FakeEmojibaseProvider(emojis = categories.toMap()), + getRecentEmojis = getRecentEmojis, + coroutineDispatchers = testCoroutineDispatchers(), + ) + + private fun emojiCategory( + category: EmojibaseCategory = EmojibaseCategory.Activity, + emojis: ImmutableList = persistentListOf( + Emoji("1F3C3", "Smile", persistentListOf("smile"), persistentListOf("smile"), "😊", skins = null) + ) + ) = category to emojis +} diff --git a/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/recentemojis/DefaultGetRecentEmojisTest.kt b/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/recentemojis/DefaultGetRecentEmojisTest.kt index 2ae8a86c25..822e6e652a 100644 --- a/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/recentemojis/DefaultGetRecentEmojisTest.kt +++ b/libraries/emoji/impl/src/test/kotlin/io/element/android/libraries/emoji/impl/recentemojis/DefaultGetRecentEmojisTest.kt @@ -12,7 +12,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.emojibasebindings.Emoji import io.element.android.emojibasebindings.EmojibaseCategory import io.element.android.emojibasebindings.EmojibaseCategory.People -import io.element.android.libraries.emoji.test.FakeEmojibaseProvider +import io.element.android.libraries.emoji.impl.fixtures.FakeEmojibaseProvider import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.ImmutableList diff --git a/libraries/emoji/test/build.gradle.kts b/libraries/emoji/test/build.gradle.kts deleted file mode 100644 index 42192c8061..0000000000 --- a/libraries/emoji/test/build.gradle.kts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright (c) 2026 Element Creations Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -plugins { - id("io.element.android-library") -} - -android { - namespace = "io.element.android.libraries.emoji.test" -} - -dependencies { - api(projects.libraries.emoji.api) - api(projects.libraries.matrix.api) - api(libs.coroutines.core) - - implementation(libs.kotlinx.collections.immutable) - implementation(libs.coroutines.test) - implementation(libs.matrix.emojibase.bindings) - implementation(projects.tests.testutils) -}