mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge pull request #7257 from element-hq/feature/fga/user-status-emoji-picker
Plug emoji picker for custom user status
This commit is contained in:
@@ -51,7 +51,7 @@ dependencies {
|
||||
implementation(projects.libraries.mediaupload.api)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.recentemojis.api)
|
||||
implementation(projects.libraries.emoji.api)
|
||||
implementation(projects.libraries.roomselect.api)
|
||||
implementation(projects.libraries.slashcommands.api)
|
||||
implementation(projects.libraries.audio.api)
|
||||
@@ -106,6 +106,7 @@ dependencies {
|
||||
testImplementation(projects.libraries.testtags)
|
||||
testImplementation(projects.features.poll.test)
|
||||
testImplementation(projects.libraries.eventformatter.test)
|
||||
testImplementation(projects.libraries.recentemojis.test)
|
||||
testImplementation(projects.libraries.emoji.impl)
|
||||
testImplementation(projects.libraries.emoji.test)
|
||||
testImplementation(projects.libraries.slashcommands.test)
|
||||
}
|
||||
|
||||
+12
@@ -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(
|
||||
|
||||
+1
-1
@@ -69,6 +69,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.emoji.api.recentemojis.AddRecentEmoji
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.toThreadId
|
||||
@@ -85,7 +86,6 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransa
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.map
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
import io.element.android.libraries.matrix.ui.room.getDirectRoomMember
|
||||
import io.element.android.libraries.recentemojis.api.AddRecentEmoji
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.services.analytics.api.AnalyticsService
|
||||
|
||||
-3
@@ -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<String> = persistentListOf(),
|
||||
eventSink: (CustomReactionEvent) -> Unit = {},
|
||||
) = CustomReactionState(
|
||||
target = target,
|
||||
recentEmojis = recentEmojis,
|
||||
selectedEmoji = persistentSetOf(),
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
+5
-8
@@ -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
|
||||
@@ -146,9 +145,10 @@ fun MessagesView(
|
||||
onJoinCallClick: (isAudioCall: Boolean) -> Unit,
|
||||
onViewAllPinnedMessagesClick: () -> Unit,
|
||||
onThreadsListClick: () -> Unit,
|
||||
knockRequestsBannerView: @Composable () -> Unit,
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -44,13 +44,13 @@ import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatter
|
||||
import io.element.android.libraries.dateformatter.api.DateFormatterMode
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.room.BaseRoom
|
||||
import io.element.android.libraries.matrix.api.timeline.Timeline
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.recentemojis.api.GetRecentEmojis
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
+1
@@ -43,6 +43,7 @@ internal fun MessagesViewWithIdentityChangePreview(
|
||||
onJoinCallClick = {},
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
knockRequestsBannerView = {},
|
||||
customReactionBottomSheet = {},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
+1
@@ -322,6 +322,7 @@ class ThreadedMessagesNode(
|
||||
onViewAllPinnedMessagesClick = {},
|
||||
modifier = modifier,
|
||||
knockRequestsBannerView = {},
|
||||
customReactionBottomSheet = {},
|
||||
onThreadsListClick = {},
|
||||
)
|
||||
|
||||
|
||||
+31
-36
@@ -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,43 @@ 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) {
|
||||
sheetState.hide(coroutineScope) {
|
||||
state.eventSink(CustomReactionEvent.DismissCustomReactionSheet)
|
||||
onSelectEmoji(state.target.event.eventOrTransactionId, emoji)
|
||||
}
|
||||
}
|
||||
|
||||
fun onEmojiSelectedDismiss(emoji: Emoji) {
|
||||
if (target?.event == null) return
|
||||
sheetState.hide(coroutineScope) {
|
||||
state.eventSink(CustomReactionEvent.DismissCustomReactionSheet)
|
||||
onSelectEmoji(target.event.eventOrTransactionId, emoji)
|
||||
}
|
||||
}
|
||||
fun onDismiss() {
|
||||
state.eventSink(CustomReactionEvent.DismissCustomReactionSheet)
|
||||
}
|
||||
|
||||
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,
|
||||
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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+34
-36
@@ -9,59 +9,53 @@
|
||||
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.recentemojis.api.EmojibaseProvider
|
||||
import io.element.android.libraries.recentemojis.api.GetRecentEmojis
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
|
||||
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
|
||||
import kotlinx.collections.immutable.toImmutableSet
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class CustomReactionPresenter(
|
||||
private val emojibaseProvider: EmojibaseProvider,
|
||||
private val getRecentEmojis: GetRecentEmojis,
|
||||
emojiPickerPresenterFactory: EmojiPickerPresenter.Factory,
|
||||
getRecentEmojis: GetRecentEmojis,
|
||||
) : Presenter<CustomReactionState> {
|
||||
private val emojiPickerPresenter = emojiPickerPresenterFactory.create(getRecentEmojis)
|
||||
@Composable
|
||||
override fun present(): CustomReactionState {
|
||||
val localCoroutineScope = rememberCoroutineScope()
|
||||
var recentEmojis by remember { mutableStateOf<ImmutableList<String>>(persistentListOf()) }
|
||||
|
||||
val target: MutableState<CustomReactionState.Target> = remember {
|
||||
mutableStateOf(CustomReactionState.Target.None)
|
||||
}
|
||||
|
||||
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>(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 +63,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
|
||||
}
|
||||
|
||||
+2
-4
@@ -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<String>,
|
||||
val recentEmojis: ImmutableList<String>,
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
-321
@@ -1,321 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import androidx.compose.foundation.Canvas
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.indication
|
||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||
import androidx.compose.foundation.interaction.PressInteraction
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.sizeIn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.ripple
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableFloatStateOf
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.Path
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.onPlaced
|
||||
import androidx.compose.ui.layout.onSizeChanged
|
||||
import androidx.compose.ui.layout.positionInParent
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.IntSize
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
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
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
@Composable
|
||||
fun EmojiItem(
|
||||
item: Emoji,
|
||||
isSelected: Boolean,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
onLongPress: (Emoji) -> Unit,
|
||||
onDismissSkinPicker: () -> Unit,
|
||||
skinPickerEmoji: Emoji?,
|
||||
selectedSkinUnicodes: ImmutableSet<String>,
|
||||
hasSelectedSkin: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
emojiSize: TextUnit = 20.sp,
|
||||
) {
|
||||
val backgroundColor = when {
|
||||
isSelected -> ElementTheme.colors.bgActionPrimaryRest
|
||||
hasSelectedSkin -> ElementTheme.colors.bgActionTertiarySelected
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val density = LocalDensity.current
|
||||
var itemSize by remember { mutableStateOf(IntSize.Zero) }
|
||||
var itemOffsetPercent by remember { mutableFloatStateOf(0f) }
|
||||
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 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 {
|
||||
0f
|
||||
}
|
||||
}
|
||||
.sizeIn(minWidth = 40.dp, minHeight = 40.dp)
|
||||
.onSizeChanged { itemSize = it }
|
||||
.background(backgroundColor, CircleShape)
|
||||
.indication(interactionSource, ripple())
|
||||
.pointerInput(item) {
|
||||
// Always detect long press and tap gestures
|
||||
detectTapGestures(
|
||||
onPress = { pressOffset ->
|
||||
val press = PressInteraction.Press(pressOffset)
|
||||
interactionSource.emit(press)
|
||||
if (tryAwaitRelease()) {
|
||||
interactionSource.emit(PressInteraction.Release(press))
|
||||
if (hasSkinTones) {
|
||||
onSelectEmoji(item)
|
||||
}
|
||||
} else {
|
||||
interactionSource.emit(PressInteraction.Cancel(press))
|
||||
}
|
||||
},
|
||||
onTap = { onSelectEmoji(item) },
|
||||
)
|
||||
}
|
||||
.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() }
|
||||
val spacingPx = with(density) { SkinToneSlotSpacing.toPx() }
|
||||
val paddingPx = with(density) { SkinTonePadding.toPx() }
|
||||
var startOffset = Offset.Zero
|
||||
detectDragGesturesAfterLongPress(
|
||||
onDragStart = { position ->
|
||||
startOffset = position
|
||||
dismissed = false
|
||||
onLongPress(item)
|
||||
hoveredIndex = -1
|
||||
},
|
||||
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 {
|
||||
change.position.y - startOffset.y <= itemSize.height
|
||||
}
|
||||
if (!isValidDrag) {
|
||||
dismissed = true
|
||||
hoveredIndex = -1
|
||||
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 {
|
||||
-1
|
||||
}
|
||||
hoveredIndex = index
|
||||
}
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
if (!dismissed) {
|
||||
val idx = hoveredIndex
|
||||
val skinCount = item.skins?.size ?: 0
|
||||
if (idx in 0..skinCount) {
|
||||
if (idx == 0) {
|
||||
onSelectEmoji(item)
|
||||
} else {
|
||||
val skin = item.skins?.getOrNull(idx - 1)
|
||||
if (skin != null) {
|
||||
onSelectEmoji(item.copy(unicode = skin.unicode))
|
||||
}
|
||||
}
|
||||
} else if (idx < 0) {
|
||||
onSelectEmoji(item)
|
||||
}
|
||||
}
|
||||
dismissed = false
|
||||
hoveredIndex = -1
|
||||
onDismissSkinPicker()
|
||||
},
|
||||
onDragCancel = {
|
||||
dismissed = false
|
||||
hoveredIndex = -1
|
||||
},
|
||||
)
|
||||
}
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
)
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = description
|
||||
},
|
||||
contentAlignment = Alignment.Center
|
||||
) {
|
||||
Text(
|
||||
text = item.unicode,
|
||||
style = LocalTextStyle.current.copy(fontSize = emojiSize),
|
||||
)
|
||||
if (hasSkinTones) {
|
||||
val pickerIndicatorColor = ElementTheme.colors.borderInteractivePrimary
|
||||
Canvas(
|
||||
modifier = Modifier
|
||||
.align(Alignment.BottomEnd)
|
||||
.size(8.dp),
|
||||
) {
|
||||
val path = Path().apply {
|
||||
moveTo(size.width, size.height)
|
||||
lineTo(size.width, 0f)
|
||||
lineTo(0f, size.height)
|
||||
close()
|
||||
}
|
||||
drawPath(path, color = pickerIndicatorColor, style = Fill)
|
||||
}
|
||||
}
|
||||
|
||||
if (skinPickerEmoji == item) {
|
||||
val pickerHeight = SkinTonePadding * 2 + SkinToneSlotSize
|
||||
val popupOffsetPx = with(density) { -pickerHeight.roundToPx() }
|
||||
Popup(
|
||||
onDismissRequest = { onDismissSkinPicker() },
|
||||
alignment = Alignment.BottomCenter,
|
||||
offset = IntOffset(0, popupOffsetPx),
|
||||
) {
|
||||
SkinTonePicker(
|
||||
emoji = item,
|
||||
onSelect = { selectedEmoji ->
|
||||
onSelectEmoji(selectedEmoji)
|
||||
onDismissSkinPicker()
|
||||
},
|
||||
hoveredIndex = hoveredIndex,
|
||||
selectedUnicodes = selectedSkinUnicodes,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmojiItemPreview() = ElementPreview {
|
||||
Row(
|
||||
modifier = Modifier.padding(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
for (isSelected in listOf(false, true)) {
|
||||
for (skinList in listOf(persistentListOf(), aSkinList())) {
|
||||
val hasSelectedSkinValues = if (skinList.isNotEmpty()) listOf(false, true) else listOf(false)
|
||||
for (hasSelectedSkin in hasSelectedSkinValues) {
|
||||
EmojiItem(
|
||||
item = Emoji(
|
||||
hexcode = "",
|
||||
label = "",
|
||||
tags = null,
|
||||
shortcodes = persistentListOf(),
|
||||
unicode = "👍",
|
||||
skins = skinList,
|
||||
),
|
||||
isSelected = isSelected,
|
||||
onSelectEmoji = {},
|
||||
hasSelectedSkin = hasSelectedSkin,
|
||||
onLongPress = {},
|
||||
onDismissSkinPicker = {},
|
||||
skinPickerEmoji = null,
|
||||
selectedSkinUnicodes = persistentSetOf(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmojiItemWithPopupPreview() = ElementPreview {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.padding(top = 100.dp, bottom = 4.dp, start = 200.dp, end = 200.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
val item = Emoji(
|
||||
hexcode = "",
|
||||
label = "",
|
||||
tags = null,
|
||||
shortcodes = persistentListOf(),
|
||||
unicode = "👍",
|
||||
skins = aSkinList(),
|
||||
)
|
||||
EmojiItem(
|
||||
item = item,
|
||||
isSelected = false,
|
||||
onSelectEmoji = {},
|
||||
hasSelectedSkin = false,
|
||||
onLongPress = {},
|
||||
onDismissSkinPicker = {},
|
||||
skinPickerEmoji = item,
|
||||
selectedSkinUnicodes = persistentSetOf("👍🏾"),
|
||||
)
|
||||
}
|
||||
}
|
||||
-48
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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
|
||||
|
||||
import androidx.annotation.StringRes
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.outlined.EmojiEvents
|
||||
import androidx.compose.material.icons.outlined.EmojiFlags
|
||||
import androidx.compose.material.icons.outlined.EmojiFoodBeverage
|
||||
import androidx.compose.material.icons.outlined.EmojiNature
|
||||
import androidx.compose.material.icons.outlined.EmojiObjects
|
||||
import androidx.compose.material.icons.outlined.EmojiPeople
|
||||
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
|
||||
|
||||
@get:StringRes
|
||||
val EmojibaseCategory.title: Int
|
||||
get() = when (this) {
|
||||
EmojibaseCategory.People -> R.string.emoji_picker_category_people
|
||||
EmojibaseCategory.Nature -> R.string.emoji_picker_category_nature
|
||||
EmojibaseCategory.Foods -> R.string.emoji_picker_category_foods
|
||||
EmojibaseCategory.Activity -> R.string.emoji_picker_category_activity
|
||||
EmojibaseCategory.Places -> R.string.emoji_picker_category_places
|
||||
EmojibaseCategory.Objects -> R.string.emoji_picker_category_objects
|
||||
EmojibaseCategory.Symbols -> R.string.emoji_picker_category_symbols
|
||||
EmojibaseCategory.Flags -> R.string.emoji_picker_category_flags
|
||||
}
|
||||
|
||||
val EmojibaseCategory.icon: ImageVector
|
||||
get() = when (this) {
|
||||
EmojibaseCategory.People -> Icons.Outlined.EmojiPeople
|
||||
EmojibaseCategory.Nature -> Icons.Outlined.EmojiNature
|
||||
EmojibaseCategory.Foods -> Icons.Outlined.EmojiFoodBeverage
|
||||
EmojibaseCategory.Activity -> Icons.Outlined.EmojiEvents
|
||||
EmojibaseCategory.Places -> Icons.Outlined.EmojiTransportation
|
||||
EmojibaseCategory.Objects -> Icons.Outlined.EmojiObjects
|
||||
EmojibaseCategory.Symbols -> Icons.Outlined.EmojiSymbols
|
||||
EmojibaseCategory.Flags -> Icons.Outlined.EmojiFlags
|
||||
}
|
||||
-172
@@ -1,172 +0,0 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2023-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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.aspectRatio
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.foundation.pager.HorizontalPager
|
||||
import androidx.compose.foundation.pager.rememberPagerState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.SecondaryTabRow
|
||||
import androidx.compose.material3.Tab
|
||||
import androidx.compose.runtime.Composable
|
||||
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 androidx.compose.ui.Modifier
|
||||
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
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBar
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun EmojiPicker(
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
state: EmojiPickerState,
|
||||
selectedEmojis: ImmutableSet<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val pagerState = rememberPagerState(pageCount = { state.categories.size })
|
||||
var skinPickerEmoji by remember { mutableStateOf<Emoji?>(null) }
|
||||
|
||||
Column(modifier) {
|
||||
SearchBar(
|
||||
modifier = Modifier.padding(bottom = 10.dp),
|
||||
queryState = state.searchQuery,
|
||||
resultState = state.searchResults,
|
||||
active = state.isSearchActive,
|
||||
onActiveChange = { state.eventSink(EmojiPickerEvent.ToggleSearchActive(it)) },
|
||||
windowInsets = WindowInsets(0, 0, 0, 0),
|
||||
placeHolderTitle = stringResource(CommonStrings.emoji_picker_search_placeholder),
|
||||
) { emojis ->
|
||||
EmojiResults(
|
||||
emojis = emojis,
|
||||
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
onLongPress = { skinPickerEmoji = it },
|
||||
skinPickerEmoji = skinPickerEmoji,
|
||||
onDismissSkinPicker = { skinPickerEmoji = null },
|
||||
selectedEmojis = selectedEmojis,
|
||||
)
|
||||
}
|
||||
|
||||
if (!state.isSearchActive) {
|
||||
SecondaryTabRow(
|
||||
selectedTabIndex = pagerState.currentPage,
|
||||
) {
|
||||
state.categories.forEachIndexed { index, category ->
|
||||
Tab(
|
||||
icon = {
|
||||
when (category.icon) {
|
||||
is IconSource.Resource -> Icon(
|
||||
resourceId = category.icon.id,
|
||||
contentDescription = stringResource(id = category.titleId)
|
||||
)
|
||||
is IconSource.Vector -> Icon(
|
||||
imageVector = category.icon.vector,
|
||||
contentDescription = stringResource(id = category.titleId)
|
||||
)
|
||||
}
|
||||
},
|
||||
selected = pagerState.currentPage == index,
|
||||
onClick = {
|
||||
coroutineScope.launch { pagerState.animateScrollToPage(index) }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
HorizontalPager(
|
||||
state = pagerState,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) { index ->
|
||||
val emojis = state.categories[index].emojis
|
||||
EmojiResults(
|
||||
emojis = emojis,
|
||||
isEmojiSelected = { selectedEmojis.contains(it.unicode) },
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
onLongPress = { skinPickerEmoji = it },
|
||||
skinPickerEmoji = skinPickerEmoji,
|
||||
onDismissSkinPicker = { skinPickerEmoji = null },
|
||||
selectedEmojis = selectedEmojis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiResults(
|
||||
emojis: ImmutableList<Emoji>,
|
||||
isEmojiSelected: (Emoji) -> Boolean,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
onLongPress: (Emoji) -> Unit,
|
||||
skinPickerEmoji: Emoji?,
|
||||
onDismissSkinPicker: () -> Unit,
|
||||
selectedEmojis: ImmutableSet<String>,
|
||||
) {
|
||||
LazyVerticalGrid(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
columns = GridCells.Adaptive(minSize = 48.dp),
|
||||
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
items(emojis, key = { it.unicode }) { item ->
|
||||
EmojiItem(
|
||||
modifier = Modifier.aspectRatio(1f),
|
||||
item = item,
|
||||
isSelected = isEmojiSelected(item),
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
onLongPress = onLongPress,
|
||||
skinPickerEmoji = skinPickerEmoji,
|
||||
onDismissSkinPicker = onDismissSkinPicker,
|
||||
emojiSize = 32.dp.toSp(),
|
||||
selectedSkinUnicodes = selectedEmojis,
|
||||
hasSelectedSkin = item.skins?.any { skin -> skin.unicode in selectedEmojis } == true,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmojiPickerPreview(@PreviewParameter(EmojiPickerStateProvider::class) state: EmojiPickerState) = ElementPreview {
|
||||
EmojiPicker(
|
||||
onSelectEmoji = {},
|
||||
state = state,
|
||||
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
-13
@@ -1,13 +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
|
||||
|
||||
sealed interface EmojiPickerEvent {
|
||||
data class ToggleSearchActive(val isActive: Boolean) : EmojiPickerEvent
|
||||
}
|
||||
-114
@@ -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<String>,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : Presenter<EmojiPickerState> {
|
||||
@Composable
|
||||
override fun present(): EmojiPickerState {
|
||||
val queryState = rememberTextFieldState()
|
||||
var isSearchActive by remember { mutableStateOf(false) }
|
||||
var emojiResults by remember { mutableStateOf<SearchBarResultState<ImmutableList<Emoji>>>(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,
|
||||
)
|
||||
}
|
||||
}
|
||||
-37
@@ -1,37 +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.annotation.StringRes
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
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 kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
// Emoji is unstable (because from an external library?), so we annotate with @Immutable
|
||||
@Immutable
|
||||
data class EmojiPickerState(
|
||||
val categories: ImmutableList<EmojiCategory>,
|
||||
val allEmojis: ImmutableList<Emoji>,
|
||||
val searchQuery: TextFieldState,
|
||||
val isSearchActive: Boolean,
|
||||
val searchResults: SearchBarResultState<ImmutableList<Emoji>>,
|
||||
val eventSink: (EmojiPickerEvent) -> Unit,
|
||||
)
|
||||
|
||||
/**
|
||||
* Represents a category of emojis with a title id, icon, and the list of associated emojis.
|
||||
*/
|
||||
data class EmojiCategory(
|
||||
@StringRes val titleId: Int,
|
||||
val icon: IconSource,
|
||||
val emojis: ImmutableList<Emoji>,
|
||||
)
|
||||
-84
@@ -1,84 +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.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 kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
class EmojiPickerStateProvider : PreviewParameterProvider<EmojiPickerState> {
|
||||
override val values: Sequence<EmojiPickerState>
|
||||
get() = sequenceOf(
|
||||
anEmojiPickerState(),
|
||||
anEmojiPickerState(isSearchActive = true),
|
||||
anEmojiPickerState(isSearchActive = true, searchQuery = "smile"),
|
||||
anEmojiPickerState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "smile",
|
||||
searchResults = SearchBarResultState.Results(emojiList())
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun recentEmojisCategory() = EmojiCategory(
|
||||
titleId = R.string.emoji_picker_category_recent,
|
||||
icon = IconSource.Resource(CompoundDrawables.ic_compound_history),
|
||||
emojis = emojiList(),
|
||||
)
|
||||
|
||||
private fun emojiList(): ImmutableList<Emoji> = persistentListOf(
|
||||
Emoji(
|
||||
"0x00",
|
||||
"grinning face",
|
||||
persistentListOf("grinning"),
|
||||
persistentListOf("smile, grin"),
|
||||
"😀",
|
||||
null
|
||||
),
|
||||
Emoji(
|
||||
"0x01",
|
||||
"crying face",
|
||||
persistentListOf("crying"),
|
||||
persistentListOf("smile, crying"),
|
||||
"\uD83E\uDD72",
|
||||
null
|
||||
)
|
||||
)
|
||||
|
||||
internal fun anEmojiPickerState(
|
||||
categories: ImmutableList<EmojiCategory> = (listOf(recentEmojisCategory()) + EmojibaseCategory.entries.map {
|
||||
EmojiCategory(
|
||||
titleId = it.title,
|
||||
icon = IconSource.Vector(it.icon),
|
||||
emojis = emojiList(),
|
||||
)
|
||||
}).toImmutableList(),
|
||||
allEmojis: ImmutableList<Emoji> = categories.flatMap { it.emojis }.toImmutableList(),
|
||||
searchQuery: String = "",
|
||||
isSearchActive: Boolean = false,
|
||||
searchResults: SearchBarResultState<ImmutableList<Emoji>> = SearchBarResultState.Initial(),
|
||||
eventSink: (EmojiPickerEvent) -> Unit = {},
|
||||
) = EmojiPickerState(
|
||||
categories = categories,
|
||||
allEmojis = allEmojis,
|
||||
searchQuery = TextFieldState(initialText = searchQuery),
|
||||
isSearchActive = isSearchActive,
|
||||
searchResults = searchResults,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
-137
@@ -1,137 +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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojiSkin
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.text.toSp
|
||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun SkinTonePicker(
|
||||
emoji: Emoji,
|
||||
hoveredIndex: Int,
|
||||
selectedUnicodes: ImmutableSet<String>,
|
||||
onSelect: (Emoji) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val skins = emoji.skins.orEmpty()
|
||||
val emojiSize = 32.dp.toSp()
|
||||
|
||||
Surface(
|
||||
modifier = modifier,
|
||||
shape = RoundedCornerShape(12.dp),
|
||||
color = ElementTheme.colors.bgCanvasDefault,
|
||||
tonalElevation = 4.dp,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(SkinTonePadding),
|
||||
horizontalArrangement = Arrangement.spacedBy(SkinToneSlotSpacing),
|
||||
) {
|
||||
SkinToneSlot(
|
||||
unicode = emoji.unicode,
|
||||
emojiSize = emojiSize,
|
||||
isHovered = hoveredIndex == 0,
|
||||
isSelected = emoji.unicode in selectedUnicodes,
|
||||
onClick = { onSelect(emoji) },
|
||||
)
|
||||
skins.forEachIndexed { index, skin ->
|
||||
SkinToneSlot(
|
||||
unicode = skin.unicode,
|
||||
emojiSize = emojiSize,
|
||||
isHovered = hoveredIndex == index + 1,
|
||||
isSelected = skin.unicode in selectedUnicodes,
|
||||
onClick = { onSelect(emoji.copy(unicode = skin.unicode)) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SkinToneSlot(
|
||||
unicode: String,
|
||||
emojiSize: TextUnit,
|
||||
isHovered: Boolean,
|
||||
isSelected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
val background = when {
|
||||
isSelected && isHovered -> ElementTheme.colors.bgActionPrimaryPressed
|
||||
isSelected -> ElementTheme.colors.bgActionPrimaryRest
|
||||
isHovered -> ElementTheme.colors.bgActionPrimaryHovered
|
||||
else -> Color.Transparent
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(SkinToneSlotSize)
|
||||
.clip(CircleShape)
|
||||
.background(background, CircleShape)
|
||||
.clickable(onClick = onClick),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = unicode,
|
||||
style = LocalTextStyle.current.copy(fontSize = emojiSize),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun SkinTonePickerPreview() = ElementPreview {
|
||||
SkinTonePicker(
|
||||
emoji = Emoji(
|
||||
hexcode = "1F44D",
|
||||
label = "thumbs up",
|
||||
tags = null,
|
||||
shortcodes = persistentListOf("+1", "thumbsup"),
|
||||
unicode = "👍",
|
||||
skins = aSkinList(),
|
||||
),
|
||||
hoveredIndex = -1,
|
||||
selectedUnicodes = persistentSetOf(),
|
||||
onSelect = {},
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aSkinList() = persistentListOf(
|
||||
EmojiSkin("1F44D-1F3FB", "thumbs up: light skin tone", "👍🏻"),
|
||||
EmojiSkin("1F44D-1F3FC", "thumbs up: medium-light skin tone", "👍🏼"),
|
||||
EmojiSkin("1F44D-1F3FD", "thumbs up: medium skin tone", "👍🏽"),
|
||||
EmojiSkin("1F44D-1F3FE", "thumbs up: medium-dark skin tone", "👍🏾"),
|
||||
EmojiSkin("1F44D-1F3FF", "thumbs up: dark skin tone", "👍🏿"),
|
||||
)
|
||||
@@ -6,14 +6,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Зашыфравана невядомай ці выдаленай прыладай."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Зашыфравана прыладай, не пацверджанай яе ўладальнікам."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Зашыфравана неправераным карыстальнікам."</string>
|
||||
<string name="emoji_picker_category_activity">"Актыўнасці"</string>
|
||||
<string name="emoji_picker_category_flags">"Сцягі"</string>
|
||||
<string name="emoji_picker_category_foods">"Ежа & Напоі"</string>
|
||||
<string name="emoji_picker_category_nature">"Жывёлы & Прырода"</string>
|
||||
<string name="emoji_picker_category_objects">"Аб\'екты"</string>
|
||||
<string name="emoji_picker_category_people">"Усмешкі & Удзельнікі"</string>
|
||||
<string name="emoji_picker_category_places">"Падарожжы & Месцы"</string>
|
||||
<string name="emoji_picker_category_symbols">"Сімвалы"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз."</string>
|
||||
<string name="screen_media_upload_preview_processing">"Апрацоўка…"</string>
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="crypto_event_authenticity_sent_in_clear">"Без шифроване"</string>
|
||||
<string name="emoji_picker_category_activity">"Дейности"</string>
|
||||
<string name="emoji_picker_category_flags">"Знамена"</string>
|
||||
<string name="emoji_picker_category_foods">"Храна & Напитки"</string>
|
||||
<string name="emoji_picker_category_nature">"Животни & Природа"</string>
|
||||
<string name="emoji_picker_category_objects">"Обекти"</string>
|
||||
<string name="emoji_picker_category_people">"Усмивки & Хора"</string>
|
||||
<string name="emoji_picker_category_places">"Пътуване & Места"</string>
|
||||
<string name="emoji_picker_category_symbols">"Символи"</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Докоснете, за да промените качеството на качване на видео"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Файлът не можа да бъде качен."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Неуспешна обработка на мултимедия за качване, моля, опитайте отново."</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Xifrat per un dispositiu desconegut o eliminat."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Xifrat per un dispositiu no verificat pel seu propietari."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Xifrat per un usuari no verificat."</string>
|
||||
<string name="emoji_picker_category_activity">"Activitats"</string>
|
||||
<string name="emoji_picker_category_flags">"Banderes"</string>
|
||||
<string name="emoji_picker_category_foods">"Menjar & begudes"</string>
|
||||
<string name="emoji_picker_category_nature">"Animals i naturalesa"</string>
|
||||
<string name="emoji_picker_category_objects">"Objectes"</string>
|
||||
<string name="emoji_picker_category_people">"Cares i persones"</string>
|
||||
<string name="emoji_picker_category_places">"Viatges i llocs"</string>
|
||||
<string name="emoji_picker_category_recent">"Emoticones recents"</string>
|
||||
<string name="emoji_picker_category_symbols">"Símbols"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"És possible que les llegendes no siguin visibles pels que utilitzin aplicacions antigues."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"No s\'ha pogut processar el contingut que s\'havia de pujar. Torna-ho a provar."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"No s\'ha pogut pujar el contingut. Torna-ho a provar."</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Šifrováno neznámým nebo smazaným zařízením."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Šifrováno zařízením, které nebylo ověřeno jeho vlastníkem."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Šifrováno neověřeným uživatelem."</string>
|
||||
<string name="emoji_picker_category_activity">"Aktivity"</string>
|
||||
<string name="emoji_picker_category_flags">"Vlajky"</string>
|
||||
<string name="emoji_picker_category_foods">"Jídlo a nápoje"</string>
|
||||
<string name="emoji_picker_category_nature">"Zvířata a příroda"</string>
|
||||
<string name="emoji_picker_category_objects">"Předměty"</string>
|
||||
<string name="emoji_picker_category_people">"Smajlíci a lidé"</string>
|
||||
<string name="emoji_picker_category_places">"Cestování a místa"</string>
|
||||
<string name="emoji_picker_category_recent">"Nedávné emotikony"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboly"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Titulky nemusí být viditelné pro lidi, kteří používají starší aplikace."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Klepnutím změníte kvalitu nahrávání videa"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Soubor nelze nahrát."</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Wedi\'i amgryptio gan ddyfais anhysbys neu wedi\'i dileu."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Wedi\'i amgryptio gan ddyfais nad yw wedi\'i wirio gan ei pherchennog."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Wedi\'i amgryptio gan ddefnyddiwr heb ei wirio."</string>
|
||||
<string name="emoji_picker_category_activity">"Gweithgareddau"</string>
|
||||
<string name="emoji_picker_category_flags">"Baneri"</string>
|
||||
<string name="emoji_picker_category_foods">"Bwyd a Diod"</string>
|
||||
<string name="emoji_picker_category_nature">"Anifeiliaid a Natur"</string>
|
||||
<string name="emoji_picker_category_objects">"Gwrthrychau"</string>
|
||||
<string name="emoji_picker_category_people">"Wynebau Hapus a Phobl"</string>
|
||||
<string name="emoji_picker_category_places">"Teithio a Llefydd"</string>
|
||||
<string name="emoji_picker_category_recent">"Emojis diweddar"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbolau"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Efallai na fydd capsiynau yn weladwy i bobl sy\'n defnyddio apiau hŷn."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tapiwch i newid ansawdd llwytho\'r fideo"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Nid oedd modd llwytho\'r ffeil."</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Krypteret af en ukendt eller slettet enhed."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Krypteret af en enhed, der ikke er verificeret af sin ejer."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Krypteret af en ikke-verificeret bruger."</string>
|
||||
<string name="emoji_picker_category_activity">"Aktiviteter"</string>
|
||||
<string name="emoji_picker_category_flags">"Flag"</string>
|
||||
<string name="emoji_picker_category_foods">"Mad og drikke"</string>
|
||||
<string name="emoji_picker_category_nature">"Dyr og natur"</string>
|
||||
<string name="emoji_picker_category_objects">"Objekter"</string>
|
||||
<string name="emoji_picker_category_people">"Smileys og personer"</string>
|
||||
<string name="emoji_picker_category_places">"Rejser og steder"</string>
|
||||
<string name="emoji_picker_category_recent">"Seneste emojis"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboler"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Billedtekster er muligvis ikke synlige for personer, der bruger ældre apps."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tryk for at ændre videokvaliteten i uploadet"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Filen kunne ikke uploades."</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Verschlüsselt von einem unbekannten oder gelöschten Gerät."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Verschlüsselt durch ein Gerät, das nicht von seinem Besitzer verifiziert wurde."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Verschlüsselt durch einen nicht verifizierten Nutzer."</string>
|
||||
<string name="emoji_picker_category_activity">"Aktivitäten"</string>
|
||||
<string name="emoji_picker_category_flags">"Flaggen"</string>
|
||||
<string name="emoji_picker_category_foods">"Essen & Trinken"</string>
|
||||
<string name="emoji_picker_category_nature">"Tiere & Natur"</string>
|
||||
<string name="emoji_picker_category_objects">"Objekte"</string>
|
||||
<string name="emoji_picker_category_people">"Smileys & Menschen"</string>
|
||||
<string name="emoji_picker_category_places">"Reisen & Orte"</string>
|
||||
<string name="emoji_picker_category_recent">"Zuletzt verwendete Emojis"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbole"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Bildunterschriften sind für Nutzer älterer Apps möglicherweise nicht sichtbar."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tippe, um die Qualität des Video-Uploads zu ändern"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Die Datei konnte nicht hochgeladen werden."</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Κρυπτογραφημένο από άγνωστη ή διαγεγραμμένη συσκευή."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Κρυπτογραφημένο από μια συσκευή που δεν έχει επαληθευτεί από τον ιδιοκτήτη της."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Κρυπτογραφημένο από μη επαληθευμένο χρήστη."</string>
|
||||
<string name="emoji_picker_category_activity">"Δραστηριότητες"</string>
|
||||
<string name="emoji_picker_category_flags">"Σημαίες"</string>
|
||||
<string name="emoji_picker_category_foods">"Φαγητό & Ποτό"</string>
|
||||
<string name="emoji_picker_category_nature">"Ζώα & Φύση"</string>
|
||||
<string name="emoji_picker_category_objects">"Αντικείμενα"</string>
|
||||
<string name="emoji_picker_category_people">"Φατσούλες & Άνθρωποι"</string>
|
||||
<string name="emoji_picker_category_places">"Ταξίδια & Μέρη"</string>
|
||||
<string name="emoji_picker_category_recent">"Πρόσφατα emoji"</string>
|
||||
<string name="emoji_picker_category_symbols">"Σύμβολα"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Οι λεζάντες ενδέχεται να μην είναι ορατές σε άτομα που χρησιμοποιούν παλαιότερες εφαρμογές."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Πατήστε για να αλλάξετε την ποιότητα μεταφόρτωσης βίντεο"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Δεν ήταν δυνατή η μεταφόρτωση του αρχείου."</string>
|
||||
|
||||
@@ -7,14 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Cifrado por un dispositivo desconocido o eliminado."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Cifrado por un dispositivo no verificado por su propietario."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Cifrado por un usuario no verificado."</string>
|
||||
<string name="emoji_picker_category_activity">"Actividades"</string>
|
||||
<string name="emoji_picker_category_flags">"Banderas"</string>
|
||||
<string name="emoji_picker_category_foods">"Comida y bebida"</string>
|
||||
<string name="emoji_picker_category_nature">"Animales y naturaleza"</string>
|
||||
<string name="emoji_picker_category_objects">"Objetos"</string>
|
||||
<string name="emoji_picker_category_people">"Emojis y personas"</string>
|
||||
<string name="emoji_picker_category_places">"Viajes y lugares"</string>
|
||||
<string name="emoji_picker_category_symbols">"Símbolos"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Es posible que las leyendas no sean visibles para las personas que usan aplicaciones más antiguas."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Error al procesar el contenido multimedia, por favor inténtalo de nuevo."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Error al subir el contenido multimedia, por favor inténtalo de nuevo."</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Krüptitud tundmatu või kustutatud seadme poolt."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Krüptitud seadme poolt, mida tema omanik pole verifitseerinud."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Krüptitud verifitseerimata kasutaja poolt."</string>
|
||||
<string name="emoji_picker_category_activity">"Tegevused"</string>
|
||||
<string name="emoji_picker_category_flags">"Lipud"</string>
|
||||
<string name="emoji_picker_category_foods">"Toit ja jook"</string>
|
||||
<string name="emoji_picker_category_nature">"Loomad ja loodus"</string>
|
||||
<string name="emoji_picker_category_objects">"Esemed"</string>
|
||||
<string name="emoji_picker_category_people">"Emotikonid ja inimesed"</string>
|
||||
<string name="emoji_picker_category_places">"Reisimine ja kohad"</string>
|
||||
<string name="emoji_picker_category_recent">"Hiljutised emojid"</string>
|
||||
<string name="emoji_picker_category_symbols">"Sümbolid"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Pööra pilti ümber rõhttelje"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Pilt on pööratud ümber rõhttelje"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Pildi algne variant"</string>
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="crypto_event_authenticity_sent_in_clear">"Ez dago zifratuta."</string>
|
||||
<string name="emoji_picker_category_activity">"Jarduerak"</string>
|
||||
<string name="emoji_picker_category_flags">"Banderak"</string>
|
||||
<string name="emoji_picker_category_foods">"Jana eta edana"</string>
|
||||
<string name="emoji_picker_category_nature">"Animaliak eta natura"</string>
|
||||
<string name="emoji_picker_category_objects">"Objektuak"</string>
|
||||
<string name="emoji_picker_category_people">"Irribartxoak eta jendea"</string>
|
||||
<string name="emoji_picker_category_places">"Bidaiak eta tokiak"</string>
|
||||
<string name="emoji_picker_category_symbols">"Ikurrak"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Huts egin du multimedia igotzeak, saiatu berriro."</string>
|
||||
<string name="screen_report_content_block_user">"Blokeatu erabiltzailea"</string>
|
||||
<string name="screen_report_content_explanation">"Mezua zure zerbitzariko administratzaileari jakinaraziko zaio. Ezingo dute zifratutako mezurik irakurri."</string>
|
||||
|
||||
@@ -6,15 +6,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"رمز شده به دست افزارهای ناشناخته یا حذف شده."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"رمز شده به دست افزارهای که از سوی مالکش تأیید نشده."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"رمز شده به دست کاربری تأیید نشده."</string>
|
||||
<string name="emoji_picker_category_activity">"فعّالیتها"</string>
|
||||
<string name="emoji_picker_category_flags">"پرچمها"</string>
|
||||
<string name="emoji_picker_category_foods">"غذا و نوشیدنی"</string>
|
||||
<string name="emoji_picker_category_nature">"حیوانات و طبعیت"</string>
|
||||
<string name="emoji_picker_category_objects">"اشیا"</string>
|
||||
<string name="emoji_picker_category_people">"شکلکها و افراد"</string>
|
||||
<string name="emoji_picker_category_places">"سفر و مکانها"</string>
|
||||
<string name="emoji_picker_category_recent">"شکلکهای اخیر"</string>
|
||||
<string name="emoji_picker_category_symbols">"نمادها"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"پردازش رسانه برای بارگذاری شکست خورد. لطفاً دوباره تلاش کنید."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"بارگذاری رسانه شکست خورد. لطفاً دوباره تلاش کنید."</string>
|
||||
<string name="screen_media_upload_preview_processing">"پردازش کردن…"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Tuntemattoman tai poistetun laitteen salaama."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Salattu laitteella, jota sen omistaja ei ole vahvistanut."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Vahvistamattoman käyttäjän salaama."</string>
|
||||
<string name="emoji_picker_category_activity">"Aktiviteetit"</string>
|
||||
<string name="emoji_picker_category_flags">"Liput"</string>
|
||||
<string name="emoji_picker_category_foods">"Ruoka ja juoma"</string>
|
||||
<string name="emoji_picker_category_nature">"Eläimet ja luonto"</string>
|
||||
<string name="emoji_picker_category_objects">"Esineet"</string>
|
||||
<string name="emoji_picker_category_people">"Hymiöt ja ihmiset"</string>
|
||||
<string name="emoji_picker_category_places">"Matkustaminen ja paikat"</string>
|
||||
<string name="emoji_picker_category_recent">"Viimeaikaiset emojit"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbolit"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Käännä kuva vaakasuunnassa"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Käännetty vaakasuunnassa"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Alkuperäinen"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Chiffré par un appareil inconnu ou supprimé."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Chiffré par un appareil non vérifié par son propriétaire."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Chiffré par un utilisateur non vérifié."</string>
|
||||
<string name="emoji_picker_category_activity">"Activités"</string>
|
||||
<string name="emoji_picker_category_flags">"Drapeaux"</string>
|
||||
<string name="emoji_picker_category_foods">"Nourriture et boissons"</string>
|
||||
<string name="emoji_picker_category_nature">"Animaux et nature"</string>
|
||||
<string name="emoji_picker_category_objects">"Objets"</string>
|
||||
<string name="emoji_picker_category_people">"Émoticônes et personnes"</string>
|
||||
<string name="emoji_picker_category_places">"Voyages & lieux"</string>
|
||||
<string name="emoji_picker_category_recent">"Emojis récents"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboles"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Retourner l’image horizontalement"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Retournée horizontalement"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Originale"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Šifrirano nepoznatim ili izbrisanim uređajem."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Šifrirano uređajem koji vlasnik nije potvrdio."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Šifrirao neprovjereni korisnik."</string>
|
||||
<string name="emoji_picker_category_activity">"Aktivnosti"</string>
|
||||
<string name="emoji_picker_category_flags">"Zastave"</string>
|
||||
<string name="emoji_picker_category_foods">"Hrana i piće"</string>
|
||||
<string name="emoji_picker_category_nature">"Životinje i priroda"</string>
|
||||
<string name="emoji_picker_category_objects">"Objekti"</string>
|
||||
<string name="emoji_picker_category_people">"Emotikoni i osobe"</string>
|
||||
<string name="emoji_picker_category_places">"Putovanja i mjesta"</string>
|
||||
<string name="emoji_picker_category_recent">"Nedavni emotikoni"</string>
|
||||
<string name="emoji_picker_category_symbols">"Simboli"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Okreni sliku vodoravno"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Okrenuto vodoravno"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Izvornik"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Ismeretlen vagy törölt eszköz által titkosítva."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"A tulajdonos által nem ellenőrzött eszköz által titkosítva."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Nem ellenőrzött felhasználó által titkosítva."</string>
|
||||
<string name="emoji_picker_category_activity">"Tevékenységek"</string>
|
||||
<string name="emoji_picker_category_flags">"Zászlók"</string>
|
||||
<string name="emoji_picker_category_foods">"Étel és ital"</string>
|
||||
<string name="emoji_picker_category_nature">"Állatok és természet"</string>
|
||||
<string name="emoji_picker_category_objects">"Tárgyak"</string>
|
||||
<string name="emoji_picker_category_people">"Mosolyok és emberek"</string>
|
||||
<string name="emoji_picker_category_places">"Utazás és helyek"</string>
|
||||
<string name="emoji_picker_category_recent">"Legutóbbi emodzsik"</string>
|
||||
<string name="emoji_picker_category_symbols">"Szimbólumok"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Kép vízszintes tükrözése"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Vízszintesen tükrözve"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Eredeti"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Dienkripsi oleh perangkat yang tidak dikenal atau dihapus."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Dienkripsi oleh perangkat yang tidak diverifikasi oleh pemiliknya."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Dienkripsi oleh pengguna yang tidak terverifikasi."</string>
|
||||
<string name="emoji_picker_category_activity">"Aktivitas"</string>
|
||||
<string name="emoji_picker_category_flags">"Bendera"</string>
|
||||
<string name="emoji_picker_category_foods">"Makanan & Minuman"</string>
|
||||
<string name="emoji_picker_category_nature">"Hewan & Alam"</string>
|
||||
<string name="emoji_picker_category_objects">"Objek"</string>
|
||||
<string name="emoji_picker_category_people">"Senyuman & Orang"</string>
|
||||
<string name="emoji_picker_category_places">"Wisata & Tempat"</string>
|
||||
<string name="emoji_picker_category_recent">"Emojis Sebelumnya"</string>
|
||||
<string name="emoji_picker_category_symbols">"Simbol"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"Putar gambar ke kiri"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="other">"%1$d derajat"</item>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Cifrato da un dispositivo sconosciuto o eliminato."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Cifrato da un dispositivo non verificato dal proprietario."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Cifrato da un utente non verificato."</string>
|
||||
<string name="emoji_picker_category_activity">"Attività"</string>
|
||||
<string name="emoji_picker_category_flags">"Bandiere"</string>
|
||||
<string name="emoji_picker_category_foods">"Cibi & Bevande"</string>
|
||||
<string name="emoji_picker_category_nature">"Animali & Natura"</string>
|
||||
<string name="emoji_picker_category_objects">"Oggetti"</string>
|
||||
<string name="emoji_picker_category_people">"Faccine & Persone"</string>
|
||||
<string name="emoji_picker_category_places">"Viaggi & Luoghi"</string>
|
||||
<string name="emoji_picker_category_recent">"Emoji recenti"</string>
|
||||
<string name="emoji_picker_category_symbols">"Simboli"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Capovolgi l\'immagine orizzontalmente"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Capovolto orizzontalmente"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Originale"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"削除されたまたは不明な端末により暗号化されています。"</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"所有者に検証されていない端末により暗号化されています。"</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"未検証のユーザーにより暗号化されています。"</string>
|
||||
<string name="emoji_picker_category_activity">"アクティビティ"</string>
|
||||
<string name="emoji_picker_category_flags">"旗"</string>
|
||||
<string name="emoji_picker_category_foods">"食べ物"</string>
|
||||
<string name="emoji_picker_category_nature">"動物・自然"</string>
|
||||
<string name="emoji_picker_category_objects">"物"</string>
|
||||
<string name="emoji_picker_category_people">"顔・人"</string>
|
||||
<string name="emoji_picker_category_places">"旅・場所"</string>
|
||||
<string name="emoji_picker_category_recent">"最近使用"</string>
|
||||
<string name="emoji_picker_category_symbols">"記号"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平に反転"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"垂直に反転"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"元画像"</string>
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="emoji_picker_category_activity">"აქტივობები"</string>
|
||||
<string name="emoji_picker_category_flags">"დროშები"</string>
|
||||
<string name="emoji_picker_category_foods">"Საჭმელ-სასმელი"</string>
|
||||
<string name="emoji_picker_category_nature">"ცხოველები & ბუნება"</string>
|
||||
<string name="emoji_picker_category_objects">"ობიექტები"</string>
|
||||
<string name="emoji_picker_category_people">"ღიმილები & ხალხი"</string>
|
||||
<string name="emoji_picker_category_places">"მოგზაურობა და ადგილები"</string>
|
||||
<string name="emoji_picker_category_symbols">"სიმბოლოები"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"მედიის ატვირთვა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა."</string>
|
||||
<string name="screen_report_content_block_user">"მომხმარებლის დაბლოკვა"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"알 수 없거나 삭제된 장치에 의해 암호화됩니다."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"소유자가 확인하지 않은 장치에 의해 암호화되었습니다."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"검증되지 않은 사용자에 의해 암호화되었습니다."</string>
|
||||
<string name="emoji_picker_category_activity">"활동"</string>
|
||||
<string name="emoji_picker_category_flags">"깃발"</string>
|
||||
<string name="emoji_picker_category_foods">"음식 & 음료"</string>
|
||||
<string name="emoji_picker_category_nature">"동물 & 자연"</string>
|
||||
<string name="emoji_picker_category_objects">"사물"</string>
|
||||
<string name="emoji_picker_category_people">"표정 & 사람"</string>
|
||||
<string name="emoji_picker_category_places">"여행 & 장소"</string>
|
||||
<string name="emoji_picker_category_recent">"최근 이모지"</string>
|
||||
<string name="emoji_picker_category_symbols">"상징"</string>
|
||||
<string name="screen_image_edition_a11y_rotate_to_the_left">"이미지를 왼쪽으로 회전"</string>
|
||||
<plurals name="screen_image_edition_a11y_rotation_state">
|
||||
<item quantity="other">"%1$d도"</item>
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="emoji_picker_category_activity">"Veikla"</string>
|
||||
<string name="emoji_picker_category_flags">"Vėliavos"</string>
|
||||
<string name="emoji_picker_category_foods">"Maistas ir Gėrimai"</string>
|
||||
<string name="emoji_picker_category_nature">"Gyvūnai ir Gamta"</string>
|
||||
<string name="emoji_picker_category_objects">"Objektai"</string>
|
||||
<string name="emoji_picker_category_people">"Šypsenėlės ir Žmonės"</string>
|
||||
<string name="emoji_picker_category_places">"Kelionės ir Vietovės"</string>
|
||||
<string name="emoji_picker_category_symbols">"Simboliai"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Nepavyko apdoroti įkeliamos laikmenos, bandykite dar kartą."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Nepavyko įkelti laikmenos, pabandykite dar kartą."</string>
|
||||
<string name="screen_report_content_block_user">"Blokuoti vartotoją"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Kryptert av en ukjent eller slettet enhet."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Kryptert med en enhet som ikke er verifisert av eieren."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Kryptert av en ubekreftet bruker."</string>
|
||||
<string name="emoji_picker_category_activity">"Aktiviteter"</string>
|
||||
<string name="emoji_picker_category_flags">"Flagg"</string>
|
||||
<string name="emoji_picker_category_foods">"Mat og drikke"</string>
|
||||
<string name="emoji_picker_category_nature">"Dyr og natur"</string>
|
||||
<string name="emoji_picker_category_objects">"Gjenstander"</string>
|
||||
<string name="emoji_picker_category_people">"Smilefjes og mennesker"</string>
|
||||
<string name="emoji_picker_category_places">"Reising og steder"</string>
|
||||
<string name="emoji_picker_category_recent">"Nylige emojier"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboler"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Teksting er kanskje ikke synlig for personer som bruker eldre apper."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Trykk for å endre kvaliteten på videoopplastingen"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Filen kunne ikke lastes opp."</string>
|
||||
|
||||
@@ -6,14 +6,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Versleuteld door een onbekend of verwijderd apparaat."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Versleuteld door een apparaat dat niet is geverifieerd door de eigenaar."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Versleuteld door een niet-geverifieerde gebruiker."</string>
|
||||
<string name="emoji_picker_category_activity">"Activiteiten"</string>
|
||||
<string name="emoji_picker_category_flags">"Vlaggen"</string>
|
||||
<string name="emoji_picker_category_foods">"Eten & Drinken"</string>
|
||||
<string name="emoji_picker_category_nature">"Dieren & Natuur"</string>
|
||||
<string name="emoji_picker_category_objects">"Voorwerpen"</string>
|
||||
<string name="emoji_picker_category_people">"Smileys & Mensen"</string>
|
||||
<string name="emoji_picker_category_places">"Reizen & Locaties"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbolen"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Het verwerken van media voor uploaden is mislukt. Probeer het opnieuw."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Het uploaden van media is mislukt. Probeer het opnieuw."</string>
|
||||
<string name="screen_report_content_block_user">"Gebruiker blokkeren"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Zaszyfrowana przez nieznane lub usunięte urządzenie."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Zaszyfrowana przez urządzenie niezweryfikowane przez jego właściciela."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Zaszyfrowana przez niezweryfikowanego użytkownika."</string>
|
||||
<string name="emoji_picker_category_activity">"Aktywności"</string>
|
||||
<string name="emoji_picker_category_flags">"Flagi"</string>
|
||||
<string name="emoji_picker_category_foods">"Jedzenie i napoje"</string>
|
||||
<string name="emoji_picker_category_nature">"Zwierzęta i natura"</string>
|
||||
<string name="emoji_picker_category_objects">"Obiekty"</string>
|
||||
<string name="emoji_picker_category_people">"Buźki i osoby"</string>
|
||||
<string name="emoji_picker_category_places">"Podróż i miejsca"</string>
|
||||
<string name="emoji_picker_category_recent">"Ostatnie emoji"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbole"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Odwróć obraz w poziomie"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Odwrócono w poziomie"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Oryginalny"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Criptografado por um dispositivo desconhecido ou apagado."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Criptografado por um dispositivo que não foi verificado pelo seu dono."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Criptografado por um usuário não verificado."</string>
|
||||
<string name="emoji_picker_category_activity">"Atividades"</string>
|
||||
<string name="emoji_picker_category_flags">"Bandeiras"</string>
|
||||
<string name="emoji_picker_category_foods">"Comida & Bebida"</string>
|
||||
<string name="emoji_picker_category_nature">"Animais & Natureza"</string>
|
||||
<string name="emoji_picker_category_objects">"Objetos"</string>
|
||||
<string name="emoji_picker_category_people">"Sorrisos & Pessoas"</string>
|
||||
<string name="emoji_picker_category_places">"Viagens & Lugares"</string>
|
||||
<string name="emoji_picker_category_recent">"Emojis recentes"</string>
|
||||
<string name="emoji_picker_category_symbols">"Símbolos"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"As legendas podem não ser visíveis para pessoas que usam apps mais antigos."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Toque para alterar a qualidade do envio do vídeo"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"O arquivo não pôde ser enviado."</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Cifragem com origem num dispositivo eliminado ou desconhecido."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Cifragem com origem num dispositivo não verificado pelo seu dono."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Cifragem com origem num utilizador não verificado."</string>
|
||||
<string name="emoji_picker_category_activity">"Atividades"</string>
|
||||
<string name="emoji_picker_category_flags">"Bandeiras"</string>
|
||||
<string name="emoji_picker_category_foods">"Comidas e Bebidas"</string>
|
||||
<string name="emoji_picker_category_nature">"Animais e Natureza"</string>
|
||||
<string name="emoji_picker_category_objects">"Objetos"</string>
|
||||
<string name="emoji_picker_category_people">"Caras e Pessoas"</string>
|
||||
<string name="emoji_picker_category_places">"Viagens e Lugares"</string>
|
||||
<string name="emoji_picker_category_recent">"Emojis recentes"</string>
|
||||
<string name="emoji_picker_category_symbols">"Símbolos"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"As legendas poderão não ser visíveis em versões mais antigas da aplicação."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Toca para alterar a qualidade de carregamento do vídeo"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Não foi possível enviar o ficheiro"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Criptat de un dispozitiv necunoscut sau șters."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Criptat de un dispozitiv care nu este verificat de proprietarul său."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Criptat de un utilizator neverificat."</string>
|
||||
<string name="emoji_picker_category_activity">"Activități"</string>
|
||||
<string name="emoji_picker_category_flags">"Steaguri"</string>
|
||||
<string name="emoji_picker_category_foods">"Mâncare & Băutură"</string>
|
||||
<string name="emoji_picker_category_nature">"Animale și Natură"</string>
|
||||
<string name="emoji_picker_category_objects">"Obiecte"</string>
|
||||
<string name="emoji_picker_category_people">"Fețe zâmbitoare & Oameni"</string>
|
||||
<string name="emoji_picker_category_places">"Călătorii & Locuri"</string>
|
||||
<string name="emoji_picker_category_recent">"Emoticoane recente"</string>
|
||||
<string name="emoji_picker_category_symbols">"Simboluri"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Este posibil ca descrierile să nu fie vizibile pentru persoanele care folosesc aplicații mai vechi."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Atingeți pentru a modifica calitatea încărcării videoclipului"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Fișierul nu a putut fi încărcat."</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Зашифровано неизвестным или удаленным устройством."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Зашифровано устройством, не проверенным его владельцем."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Зашифровано непроверенным пользователем."</string>
|
||||
<string name="emoji_picker_category_activity">"Активности"</string>
|
||||
<string name="emoji_picker_category_flags">"Флаги"</string>
|
||||
<string name="emoji_picker_category_foods">"Еда и напитки"</string>
|
||||
<string name="emoji_picker_category_nature">"Животные и природа"</string>
|
||||
<string name="emoji_picker_category_objects">"Объекты"</string>
|
||||
<string name="emoji_picker_category_people">"Эмодзи и люди"</string>
|
||||
<string name="emoji_picker_category_places">"Путешествия и места"</string>
|
||||
<string name="emoji_picker_category_recent">"Недавние эмодзи"</string>
|
||||
<string name="emoji_picker_category_symbols">"Символы"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Перевернуть изображение по горизонтали"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Перевернуто по горизонтали"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Оригинал"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Zašifrované neznámym alebo odstráneným zariadením."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Šifrované zariadením, ktoré nie je overené jeho majiteľom."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Šifrované neovereným používateľom."</string>
|
||||
<string name="emoji_picker_category_activity">"Aktivity"</string>
|
||||
<string name="emoji_picker_category_flags">"Vlajky"</string>
|
||||
<string name="emoji_picker_category_foods">"Jedlo a nápoje"</string>
|
||||
<string name="emoji_picker_category_nature">"Zvieratá a príroda"</string>
|
||||
<string name="emoji_picker_category_objects">"Predmety"</string>
|
||||
<string name="emoji_picker_category_people">"Smajlíky a ľudia"</string>
|
||||
<string name="emoji_picker_category_places">"Cestovanie a miesta"</string>
|
||||
<string name="emoji_picker_category_recent">"Nedávne emotikony"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboly"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Titulky nemusia byť viditeľné pre ľudí používajúcich staršie aplikácie."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Ťuknutím zmeníte kvalitu nahratého videa"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Súbor sa nepodarilo nahrať."</string>
|
||||
|
||||
@@ -7,14 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Krypterad av en okänd eller raderad enhet."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Krypterad av en enhet som inte verifierats av ägaren."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Krypterad av en overifierad användare."</string>
|
||||
<string name="emoji_picker_category_activity">"Aktiviteter"</string>
|
||||
<string name="emoji_picker_category_flags">"Flaggor"</string>
|
||||
<string name="emoji_picker_category_foods">"Mat & dryck"</string>
|
||||
<string name="emoji_picker_category_nature">"Djur & natur"</string>
|
||||
<string name="emoji_picker_category_objects">"Föremål"</string>
|
||||
<string name="emoji_picker_category_people">"Smileys & personer"</string>
|
||||
<string name="emoji_picker_category_places">"Resor & platser"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symboler"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Bildtexter kanske inte är synliga för personer som använder äldre appar."</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"Tryck för att ändra videouppladdningskvaliteten"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"Filen kunde inte laddas upp."</string>
|
||||
|
||||
@@ -7,14 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Bilinmeyen veya silinmiş bir cihaz tarafından şifrelenmiştir."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Sahibi tarafından doğrulanmamış bir cihaz tarafından şifrelenmiştir."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Doğrulanmamış bir kullanıcı tarafından şifrelenmiştir."</string>
|
||||
<string name="emoji_picker_category_activity">"Aktiviteler"</string>
|
||||
<string name="emoji_picker_category_flags">"Bayraklar"</string>
|
||||
<string name="emoji_picker_category_foods">"Yiyecek ve İçecek"</string>
|
||||
<string name="emoji_picker_category_nature">"Hayvanlar ve Doğa"</string>
|
||||
<string name="emoji_picker_category_objects">"Nesneler"</string>
|
||||
<string name="emoji_picker_category_people">"İfadeler ve İnsanlar"</string>
|
||||
<string name="emoji_picker_category_places">"Seyahat ve Yerler"</string>
|
||||
<string name="emoji_picker_category_symbols">"Simgeler"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"Açıklamalar, eski uygulamaları kullanan kişiler tarafından görülemeyebilir."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Medya yüklenemedi, lütfen tekrar deneyin."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Medya yüklenemedi, lütfen tekrar deneyin."</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Зашифровано невідомим або видаленим пристроєм."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Зашифровано пристроєм, який не звірено його власником."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Зашифровано не звіреним користувачем."</string>
|
||||
<string name="emoji_picker_category_activity">"Дії"</string>
|
||||
<string name="emoji_picker_category_flags">"Прапори"</string>
|
||||
<string name="emoji_picker_category_foods">"Їжа та напої"</string>
|
||||
<string name="emoji_picker_category_nature">"Тварини та природа"</string>
|
||||
<string name="emoji_picker_category_objects">"Об\'єкти"</string>
|
||||
<string name="emoji_picker_category_people">"Смайлики та люди"</string>
|
||||
<string name="emoji_picker_category_places">"Подорожі та місця"</string>
|
||||
<string name="emoji_picker_category_recent">"Нещодавні емодзі"</string>
|
||||
<string name="emoji_picker_category_symbols">"Символи"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Повернути зображення по горизонталі"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Перевернуто по горизонталі"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Оригінал"</string>
|
||||
|
||||
@@ -6,14 +6,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"کسی نامعلوم یا حذف شدہ آلے کے ذریعے مرموز کردہ۔"</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"کسی ایسے آلے کے ذریعے مرموز کردہ جس کی توثیق اسکے مالک سے نہیں ہوئی۔"</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"ایک غیر توثیق شدہ صارف کے ذریعے مرموز کردہ۔"</string>
|
||||
<string name="emoji_picker_category_activity">"سرگرمیاں"</string>
|
||||
<string name="emoji_picker_category_flags">"جھنڈے"</string>
|
||||
<string name="emoji_picker_category_foods">"طعم و مشروب"</string>
|
||||
<string name="emoji_picker_category_nature">"حیوانات و فطرت"</string>
|
||||
<string name="emoji_picker_category_objects">"اشیاء"</string>
|
||||
<string name="emoji_picker_category_people">"مسکراہٹیں و لوگ"</string>
|
||||
<string name="emoji_picker_category_places">"سفر و مقامات"</string>
|
||||
<string name="emoji_picker_category_symbols">"علامتیں"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"وسائط کا معالجہ برائے ترفیع ناکام، برائے مہربانی دوبارہ کوشش کریں۔"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"وسائط رفع کرنے میں ناکام، برائے مہربانی دوبارہ کوشش کریں۔"</string>
|
||||
<string name="screen_report_content_block_user">"صارف کو مسدود کریں"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Nomaʼlum yoki oʻchirib tashlangan qurilma tomonidan shifrlangan."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Egasi tasdiqlamagan qurilma tomonidan shifrlangan."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Tasdiqlanmagan foydalanuvchi tomonidan shifrlangan."</string>
|
||||
<string name="emoji_picker_category_activity">"Faoliyatlar"</string>
|
||||
<string name="emoji_picker_category_flags">"Bayroqlar"</string>
|
||||
<string name="emoji_picker_category_foods">"Oziq-ovqat va ichimliklar"</string>
|
||||
<string name="emoji_picker_category_nature">"Hayvonlar va tabiat"</string>
|
||||
<string name="emoji_picker_category_objects">"Ob\'ektlar"</string>
|
||||
<string name="emoji_picker_category_people">"Smayllar va odamlar"</string>
|
||||
<string name="emoji_picker_category_places">"Sayohat va Joylar"</string>
|
||||
<string name="emoji_picker_category_recent">"Oxirgi kulgichlar"</string>
|
||||
<string name="emoji_picker_category_symbols">"Belgilar"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Rasmni gorizontal ravishda aylantiring"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Gorizontal ravishda aylantirildi"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Asl nusxasi"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Được mã hóa bởi một thiết bị không xác định hoặc đã bị xóa."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Được mã hóa bởi một thiết bị chưa được chủ sở hữu xác minh."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Được mã hóa bởi một người dùng chưa được xác minh."</string>
|
||||
<string name="emoji_picker_category_activity">"Hoạt động"</string>
|
||||
<string name="emoji_picker_category_flags">"Cờ"</string>
|
||||
<string name="emoji_picker_category_foods">"Thực phẩm và đồ uống"</string>
|
||||
<string name="emoji_picker_category_nature">"Động vật và thiên nhiên"</string>
|
||||
<string name="emoji_picker_category_objects">"Đồ vật"</string>
|
||||
<string name="emoji_picker_category_people">"Mặt cười & mọi người"</string>
|
||||
<string name="emoji_picker_category_places">"Du lịch và địa danh"</string>
|
||||
<string name="emoji_picker_category_recent">"Biểu tượng cảm xúc gần đây"</string>
|
||||
<string name="emoji_picker_category_symbols">"Biểu tượng"</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Xử lý phương tiện tải lên không thành công, vui lòng thử lại."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Không thể tải lên tệp phương tiện. Vui lòng thử lại."</string>
|
||||
<string name="screen_report_content_block_user">"Chặn người dùng"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"由未知或已刪除的裝置加密。"</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"由未經其擁有者驗證的裝置加密。"</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"由未經驗證的使用者加密。"</string>
|
||||
<string name="emoji_picker_category_activity">"活動"</string>
|
||||
<string name="emoji_picker_category_flags">"旗幟"</string>
|
||||
<string name="emoji_picker_category_foods">"食物與飲料"</string>
|
||||
<string name="emoji_picker_category_nature">"動物與大自然"</string>
|
||||
<string name="emoji_picker_category_objects">"物品"</string>
|
||||
<string name="emoji_picker_category_people">"表情與人物"</string>
|
||||
<string name="emoji_picker_category_places">"旅行與景點"</string>
|
||||
<string name="emoji_picker_category_recent">"最近使用的表情符號"</string>
|
||||
<string name="emoji_picker_category_symbols">"標誌"</string>
|
||||
<string name="screen_media_upload_preview_caption_warning">"使用舊應用程式的使用者可能看不到標題。"</string>
|
||||
<string name="screen_media_upload_preview_change_video_quality_prompt">"輕點即可變更影片上傳品質"</string>
|
||||
<string name="screen_media_upload_preview_error_could_not_be_uploaded">"無法上傳檔案。"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"由未知或已删除的设备加密。"</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"由未经其所有者验证的设备加密。"</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"由未经验证的用户加密。"</string>
|
||||
<string name="emoji_picker_category_activity">"节假日"</string>
|
||||
<string name="emoji_picker_category_flags">"旗帜"</string>
|
||||
<string name="emoji_picker_category_foods">"饮食"</string>
|
||||
<string name="emoji_picker_category_nature">"动物和自然"</string>
|
||||
<string name="emoji_picker_category_objects">"日常物品"</string>
|
||||
<string name="emoji_picker_category_people">"表情和人物"</string>
|
||||
<string name="emoji_picker_category_places">"文旅景点"</string>
|
||||
<string name="emoji_picker_category_recent">"最近的 Emoji"</string>
|
||||
<string name="emoji_picker_category_symbols">"符号"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"水平翻转图像"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"水平翻转"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"原始"</string>
|
||||
|
||||
@@ -7,15 +7,6 @@
|
||||
<string name="crypto_event_authenticity_unknown_device">"Encrypted by an unknown or deleted device."</string>
|
||||
<string name="crypto_event_authenticity_unsigned_device">"Encrypted by a device not verified by its owner."</string>
|
||||
<string name="crypto_event_authenticity_unverified_identity">"Encrypted by an unverified user."</string>
|
||||
<string name="emoji_picker_category_activity">"Activities"</string>
|
||||
<string name="emoji_picker_category_flags">"Flags"</string>
|
||||
<string name="emoji_picker_category_foods">"Food & Drink"</string>
|
||||
<string name="emoji_picker_category_nature">"Animals & Nature"</string>
|
||||
<string name="emoji_picker_category_objects">"Objects"</string>
|
||||
<string name="emoji_picker_category_people">"Smileys & People"</string>
|
||||
<string name="emoji_picker_category_places">"Travel & Places"</string>
|
||||
<string name="emoji_picker_category_recent">"Recent emojis"</string>
|
||||
<string name="emoji_picker_category_symbols">"Symbols"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally">"Flip image horizontally"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_flipped">"Flipped horizontally"</string>
|
||||
<string name="screen_image_edition_a11y_flip_image_horizontally_state_original">"Original"</string>
|
||||
|
||||
+1
-1
@@ -52,6 +52,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
import io.element.android.libraries.emoji.api.recentemojis.AddRecentEmoji
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -94,7 +95,6 @@ import io.element.android.libraries.matrix.test.room.threads.FakeThreadsListServ
|
||||
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
|
||||
import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo
|
||||
import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails
|
||||
import io.element.android.libraries.recentemojis.api.AddRecentEmoji
|
||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||
import io.element.android.libraries.textcomposer.model.TextEditorState
|
||||
import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown
|
||||
|
||||
+26
-15
@@ -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<ComponentActivity>.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<ComponentActivity>.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,
|
||||
)
|
||||
}
|
||||
|
||||
+1
-1
@@ -27,6 +27,7 @@ import io.element.android.features.messages.impl.timeline.model.event.aTimelineI
|
||||
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
|
||||
import io.element.android.features.poll.api.pollcontent.aPollAnswerItemList
|
||||
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
|
||||
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlags
|
||||
import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
|
||||
import io.element.android.libraries.matrix.api.notification.CallIntent
|
||||
@@ -42,7 +43,6 @@ import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.room.FakeBaseRoom
|
||||
import io.element.android.libraries.matrix.test.room.aRoomInfo
|
||||
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
|
||||
import io.element.android.libraries.recentemojis.api.GetRecentEmojis
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
+4
-8
@@ -11,11 +11,11 @@ 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.api.recentemojis.EmptyGetRecentEmojis
|
||||
import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.recentemojis.test.FakeEmojibaseProvider
|
||||
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 +25,8 @@ class CustomReactionPresenterTest {
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
private val presenter = CustomReactionPresenter(
|
||||
emojibaseProvider = FakeEmojibaseProvider(),
|
||||
getRecentEmojis = { Result.success(persistentListOf()) },
|
||||
emojiPickerPresenterFactory = fakeEmojiPickerPresenterFactory(),
|
||||
getRecentEmojis = EmptyGetRecentEmojis,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -38,8 +38,6 @@ class CustomReactionPresenterTest {
|
||||
|
||||
initialState.eventSink(CustomReactionEvent.ShowCustomReactionSheet(event))
|
||||
|
||||
assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.Loading(event))
|
||||
|
||||
val eventId = (awaitItem().target as? CustomReactionState.Target.Success)?.event?.eventId
|
||||
assertThat(eventId).isEqualTo(AN_EVENT_ID)
|
||||
|
||||
@@ -59,8 +57,6 @@ class CustomReactionPresenterTest {
|
||||
val key = reactions.reactions.first().key
|
||||
initialState.eventSink(CustomReactionEvent.ShowCustomReactionSheet(event))
|
||||
|
||||
assertThat(awaitItem().target).isEqualTo(CustomReactionState.Target.Loading(event))
|
||||
|
||||
val stateWithSelectedEmojis = awaitItem()
|
||||
val eventId = (stateWithSelectedEmojis.target as? CustomReactionState.Target.Success)?.event?.eventId
|
||||
assertThat(eventId).isEqualTo(AN_EVENT_ID)
|
||||
|
||||
-125
@@ -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<Pair<EmojibaseCategory, ImmutableList<Emoji>>> = persistentListOf(emojiCategory()),
|
||||
recentEmojis: ImmutableList<String> = persistentListOf(),
|
||||
) = EmojiPickerPresenter(
|
||||
emojibaseStore = EmojibaseStore(categories.toMap().toImmutableMap()),
|
||||
recentEmojis = recentEmojis,
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
)
|
||||
|
||||
private fun emojiCategory(
|
||||
category: EmojibaseCategory = EmojibaseCategory.Activity,
|
||||
emojis: ImmutableList<Emoji> = persistentListOf(
|
||||
Emoji("1F3C3", "Smile", persistentListOf("smile"), persistentListOf("smile"), "😊", skins = null)
|
||||
)
|
||||
) = category to emojis
|
||||
|
||||
@OptIn(InternalComposeApi::class)
|
||||
private suspend fun TestScope.testPresenter(
|
||||
presenter: EmojiPickerPresenter = createPresenter(),
|
||||
testBlock: suspend TurbineTestContext<EmojiPickerState>.() -> Unit,
|
||||
) {
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
// These are needed to load the history icon in the presenter
|
||||
currentComposer.startProviders(arrayOf(
|
||||
LocalContext provides InstrumentationRegistry.getInstrumentation().context,
|
||||
LocalConfiguration provides InstrumentationRegistry.getInstrumentation().context.resources.configuration,
|
||||
))
|
||||
val state = presenter.present()
|
||||
currentComposer.endProviders()
|
||||
state
|
||||
}.test {
|
||||
testBlock()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,7 @@ dependencies {
|
||||
implementation(projects.features.deactivation.api)
|
||||
implementation(projects.features.home.api)
|
||||
implementation(projects.features.invite.api)
|
||||
implementation(projects.libraries.emoji.api)
|
||||
implementation(projects.services.analytics.api)
|
||||
implementation(projects.services.analytics.compose)
|
||||
implementation(projects.services.appnavstate.api)
|
||||
@@ -98,6 +99,7 @@ dependencies {
|
||||
|
||||
testCommonDependencies(libs, true)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.emoji.test)
|
||||
testImplementation(projects.libraries.featureflag.test)
|
||||
testImplementation(projects.libraries.mediapickers.test)
|
||||
testImplementation(projects.libraries.mediaupload.test)
|
||||
|
||||
+3
@@ -24,6 +24,7 @@ import io.element.android.features.logout.api.direct.DirectLogoutView
|
||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@@ -33,6 +34,7 @@ class PreferencesRootNode(
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val presenter: PreferencesRootPresenter,
|
||||
private val directLogoutView: DirectLogoutView,
|
||||
private val emojiPickerRenderer: EmojiPickerRenderer,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun navigateToAddAccount()
|
||||
@@ -75,6 +77,7 @@ class PreferencesRootNode(
|
||||
val isDark = ElementTheme.isLightTheme.not()
|
||||
PreferencesRootView(
|
||||
state = state,
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
modifier = modifier,
|
||||
onBackClick = this::navigateUp,
|
||||
onAddAccountClick = callback::navigateToAddAccount,
|
||||
|
||||
+11
-1
@@ -41,6 +41,8 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
|
||||
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.core.DeviceId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||
@@ -49,6 +51,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@Composable
|
||||
fun PreferencesRootView(
|
||||
state: PreferencesRootState,
|
||||
emojiPickerRenderer: EmojiPickerRenderer,
|
||||
onBackClick: () -> Unit,
|
||||
onAddAccountClick: () -> Unit,
|
||||
onSecureBackupClick: () -> Unit,
|
||||
@@ -90,7 +93,11 @@ fun PreferencesRootView(
|
||||
)
|
||||
}
|
||||
if (state.userStatusState != null) {
|
||||
UserStatusSection(state.userStatusState, showTopDivider = !state.isMultiAccountEnabled)
|
||||
UserStatusSection(
|
||||
userStatusState = state.userStatusState,
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
showTopDivider = !state.isMultiAccountEnabled,
|
||||
)
|
||||
}
|
||||
// 'Account' section
|
||||
ManageAccountSection(
|
||||
@@ -135,6 +142,7 @@ fun PreferencesRootView(
|
||||
@Composable
|
||||
private fun ColumnScope.UserStatusSection(
|
||||
userStatusState: UserStatusState,
|
||||
emojiPickerRenderer: EmojiPickerRenderer,
|
||||
showTopDivider: Boolean,
|
||||
) {
|
||||
if (showTopDivider) {
|
||||
@@ -145,6 +153,7 @@ private fun ColumnScope.UserStatusSection(
|
||||
}
|
||||
UserStatusView(
|
||||
state = userStatusState,
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
HorizontalDivider(
|
||||
@@ -369,6 +378,7 @@ internal fun PreferencesRootViewDarkPreview(@PreviewParameter(PreferencesRootSta
|
||||
private fun ContentToPreview(state: PreferencesRootState) {
|
||||
PreferencesRootView(
|
||||
state = state,
|
||||
emojiPickerRenderer = NoOpEmojiPickerRenderer,
|
||||
onBackClick = {},
|
||||
onAddAccountClick = {},
|
||||
onOpenAnalytics = {},
|
||||
|
||||
+2
@@ -16,5 +16,7 @@ sealed interface UserStatusEvent {
|
||||
data object OpenCustomInput : UserStatusEvent
|
||||
data object CancelCustomInput : UserStatusEvent
|
||||
data class UpdateCustomEmoji(val emoji: String) : UserStatusEvent
|
||||
data object OpenEmojiPicker : UserStatusEvent
|
||||
data object DismissEmojiPicker : UserStatusEvent
|
||||
data object ClearStatus : UserStatusEvent
|
||||
}
|
||||
|
||||
+49
-14
@@ -19,36 +19,32 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
|
||||
import io.element.android.libraries.emoji.api.recentemojis.EmptyGetRecentEmojis
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@Inject
|
||||
class UserStatusPresenter(
|
||||
private val matrixClient: MatrixClient,
|
||||
emojiPickerPresenterFactory: EmojiPickerPresenter.Factory,
|
||||
) : Presenter<UserStatusState> {
|
||||
private val emojiPickerPresenter = emojiPickerPresenterFactory.create(EmptyGetRecentEmojis)
|
||||
|
||||
@Composable
|
||||
override fun present(): UserStatusState {
|
||||
val userProfile by matrixClient.userProfile.collectAsState()
|
||||
var pickerState by remember { mutableStateOf<UserStatusPickerState>(UserStatusPickerState.Hidden) }
|
||||
var isEmojiPickerVisible by remember { mutableStateOf(false) }
|
||||
val customTextFieldState = rememberTextFieldState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
fun handleEvent(event: UserStatusEvent) {
|
||||
when (event) {
|
||||
// Mode transitions
|
||||
UserStatusEvent.OpenPicker -> pickerState = UserStatusPickerState.ShowingPicker
|
||||
UserStatusEvent.DismissPicker -> pickerState = UserStatusPickerState.Hidden
|
||||
UserStatusEvent.OpenCustomInput -> {
|
||||
val raw = userProfile.rawStatus
|
||||
if (raw != null) {
|
||||
customTextFieldState.setTextAndPlaceCursorAtEnd(raw.text)
|
||||
} else {
|
||||
customTextFieldState.clearText()
|
||||
}
|
||||
pickerState = UserStatusPickerState.CustomInput(
|
||||
emoji = raw?.emoji ?: "😀",
|
||||
textFieldState = customTextFieldState,
|
||||
)
|
||||
}
|
||||
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
|
||||
is UserStatusEvent.SetStatus -> {
|
||||
pickerState = UserStatusPickerState.Hidden
|
||||
coroutineScope.launch { matrixClient.setUserStatus(event.status) }
|
||||
@@ -57,19 +53,58 @@ class UserStatusPresenter(
|
||||
pickerState = UserStatusPickerState.Hidden
|
||||
coroutineScope.launch { matrixClient.clearUserStatus() }
|
||||
}
|
||||
UserStatusEvent.CancelCustomInput -> pickerState = UserStatusPickerState.Hidden
|
||||
UserStatusEvent.OpenCustomInput -> {
|
||||
val raw = userProfile.rawStatus
|
||||
if (raw != null) {
|
||||
customTextFieldState.setTextAndPlaceCursorAtEnd(raw.text)
|
||||
} else {
|
||||
customTextFieldState.clearText()
|
||||
}
|
||||
isEmojiPickerVisible = false
|
||||
pickerState = UserStatusPickerState.CustomInput(
|
||||
emoji = raw?.emoji ?: "😀",
|
||||
textFieldState = customTextFieldState,
|
||||
emojiPickerSheetState = EmojiPickerSheetState.Hidden,
|
||||
)
|
||||
}
|
||||
// Custom-input mutations
|
||||
is UserStatusEvent.UpdateCustomEmoji -> {
|
||||
isEmojiPickerVisible = false
|
||||
val current = pickerState as? UserStatusPickerState.CustomInput ?: return
|
||||
pickerState = current.copy(emoji = event.emoji)
|
||||
}
|
||||
// Emoji-picker visibility (only meaningful within CustomInput mode)
|
||||
UserStatusEvent.OpenEmojiPicker -> isEmojiPickerVisible = true
|
||||
UserStatusEvent.DismissEmojiPicker -> isEmojiPickerVisible = false
|
||||
}
|
||||
}
|
||||
|
||||
// In CustomInput mode, override the picker state's `emojiPickerSheetState` with the freshly composed emoji-picker
|
||||
// sub-state so recompositions of the picker propagate up. Other modes pass through unchanged.
|
||||
val effectivePickerState = when (val current = pickerState) {
|
||||
is UserStatusPickerState.CustomInput -> {
|
||||
val emojiPickerState = presentEmojiPickerSheetState(isEmojiPickerVisible)
|
||||
current.copy(emojiPickerSheetState = emojiPickerState)
|
||||
}
|
||||
else -> current
|
||||
}
|
||||
|
||||
return UserStatusState(
|
||||
displayedStatus = userProfile.displayedStatus,
|
||||
rawStatus = userProfile.rawStatus,
|
||||
pickerState = pickerState,
|
||||
pickerState = effectivePickerState,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun presentEmojiPickerSheetState(isVisible: Boolean): EmojiPickerSheetState {
|
||||
if (!isVisible) return EmojiPickerSheetState.Hidden
|
||||
val emojiPickerState = emojiPickerPresenter.present()
|
||||
return if (emojiPickerState.isReady) {
|
||||
EmojiPickerSheetState.Shown(emojiPickerState)
|
||||
} else {
|
||||
EmojiPickerSheetState.Loading
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -11,6 +11,7 @@ import androidx.annotation.StringRes
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
|
||||
@@ -28,9 +29,17 @@ sealed interface UserStatusPickerState {
|
||||
data class CustomInput(
|
||||
val emoji: String,
|
||||
val textFieldState: TextFieldState,
|
||||
val emojiPickerSheetState: EmojiPickerSheetState,
|
||||
) : UserStatusPickerState
|
||||
}
|
||||
|
||||
@Immutable
|
||||
sealed interface EmojiPickerSheetState {
|
||||
data object Hidden : EmojiPickerSheetState
|
||||
data object Loading : EmojiPickerSheetState
|
||||
data class Shown(val state: EmojiPickerState) : EmojiPickerSheetState
|
||||
}
|
||||
|
||||
enum class PredefinedUserStatus(val emoji: String, @StringRes val labelRes: Int) {
|
||||
IN_A_MEETING("💬", R.string.common_user_status_in_a_meeting),
|
||||
FOCUS_TIME("💡", R.string.common_user_status_focus_time),
|
||||
|
||||
+14
-2
@@ -23,8 +23,20 @@ internal class UserStatusStateProvider : PreviewParameterProvider<UserStatusStat
|
||||
rawStatus = UserStatus("🌴", "Away"),
|
||||
pickerState = UserStatusPickerState.ShowingPicker,
|
||||
),
|
||||
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "😀", textFieldState = TextFieldState())),
|
||||
aUserStatusState(pickerState = UserStatusPickerState.CustomInput(emoji = "🚀", textFieldState = TextFieldState("Working on something"))),
|
||||
aUserStatusState(
|
||||
pickerState = UserStatusPickerState.CustomInput(
|
||||
emoji = "😀",
|
||||
textFieldState = TextFieldState(),
|
||||
emojiPickerSheetState = EmojiPickerSheetState.Hidden
|
||||
)
|
||||
),
|
||||
aUserStatusState(
|
||||
pickerState = UserStatusPickerState.CustomInput(
|
||||
emoji = "🚀",
|
||||
textFieldState = TextFieldState("Working on something"),
|
||||
emojiPickerSheetState = EmojiPickerSheetState.Hidden
|
||||
)
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+49
-2
@@ -10,6 +10,7 @@ package io.element.android.features.preferences.impl.userstatus
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@@ -58,13 +59,17 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||
import io.element.android.libraries.designsystem.theme.components.hide
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
|
||||
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
|
||||
@Composable
|
||||
fun UserStatusView(
|
||||
state: UserStatusState,
|
||||
emojiPickerRenderer: EmojiPickerRenderer,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
when (val pickerState = state.pickerState) {
|
||||
@@ -103,8 +108,18 @@ fun UserStatusView(
|
||||
)
|
||||
},
|
||||
onCancel = { state.eventSink(UserStatusEvent.CancelCustomInput) },
|
||||
onEmojiClick = { state.eventSink(UserStatusEvent.OpenEmojiPicker) },
|
||||
modifier = modifier,
|
||||
)
|
||||
when (val sheet = pickerState.emojiPickerSheetState) {
|
||||
EmojiPickerSheetState.Hidden, EmojiPickerSheetState.Loading -> Unit
|
||||
is EmojiPickerSheetState.Shown -> EmojiPickerBottomSheet(
|
||||
pickerState = sheet.state,
|
||||
emojiPickerRenderer = emojiPickerRenderer,
|
||||
onSelectEmoji = { emoji -> state.eventSink(UserStatusEvent.UpdateCustomEmoji(emoji.unicode)) },
|
||||
onDismiss = { state.eventSink(UserStatusEvent.DismissEmojiPicker) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -231,6 +246,7 @@ private fun CustomStatusInputRow(
|
||||
rawStatus: UserStatus?,
|
||||
onConfirm: () -> Unit,
|
||||
onCancel: () -> Unit,
|
||||
onEmojiClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val hasText by remember { derivedStateOf { textFieldState.text.isNotEmpty() } }
|
||||
@@ -297,7 +313,7 @@ private fun CustomStatusInputRow(
|
||||
modifier = Modifier
|
||||
.size(50.dp)
|
||||
.clip(CircleShape)
|
||||
.clickable { },
|
||||
.clickable(onClick = onEmojiClick),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
EmojiText(emoji)
|
||||
@@ -308,8 +324,39 @@ private fun CustomStatusInputRow(
|
||||
)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun EmojiPickerBottomSheet(
|
||||
pickerState: io.element.android.libraries.emoji.api.picker.EmojiPickerState,
|
||||
emojiPickerRenderer: EmojiPickerRenderer,
|
||||
onSelectEmoji: (io.element.android.emojibasebindings.Emoji) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val sheetState = rememberBottomSheetState(initialValue = SheetValue.Hidden)
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
sheetState = sheetState,
|
||||
scrollable = false,
|
||||
) {
|
||||
emojiPickerRenderer.Render(
|
||||
state = pickerState,
|
||||
onSelectEmoji = { emoji ->
|
||||
sheetState.hide(coroutineScope) {
|
||||
onSelectEmoji(emoji)
|
||||
}
|
||||
},
|
||||
selectedEmojis = persistentSetOf(),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun UserStatusViewPreview(@PreviewParameter(UserStatusStateProvider::class) state: UserStatusState) = ElementPreview {
|
||||
UserStatusView(state = state)
|
||||
UserStatusView(
|
||||
state = state,
|
||||
emojiPickerRenderer = NoOpEmojiPickerRenderer,
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -19,6 +19,7 @@ import androidx.compose.ui.test.performClick
|
||||
import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
@@ -466,6 +467,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
|
||||
setContent {
|
||||
PreferencesRootView(
|
||||
state = state,
|
||||
emojiPickerRenderer = NoOpEmojiPickerRenderer,
|
||||
onBackClick = onBackClick,
|
||||
onAddAccountClick = onAddAccountClick,
|
||||
onSecureBackupClick = onSecureBackupClick,
|
||||
|
||||
+5
-1
@@ -8,6 +8,7 @@
|
||||
package io.element.android.features.preferences.impl.userstatus
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.emoji.test.fakeEmojiPickerPresenterFactory
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.UserStatus
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
@@ -17,7 +18,10 @@ import org.junit.Test
|
||||
|
||||
class UserStatusPresenterTest {
|
||||
private fun createPresenter(client: FakeMatrixClient = FakeMatrixClient()) =
|
||||
UserStatusPresenter(matrixClient = client)
|
||||
UserStatusPresenter(
|
||||
matrixClient = client,
|
||||
emojiPickerPresenterFactory = fakeEmojiPickerPresenterFactory(),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `initial state - no status set`() = runTest {
|
||||
|
||||
Reference in New Issue
Block a user