mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-05 04:15:12 +00:00
Merge branch 'develop' into feature/valere/timeline_active_call_rendering
# Conflicts: # tests/uitests/src/test/snapshots/images/features.messages.impl.timeline.components.customreaction_EmojiItemWithPopup_Night_0_en.png
This commit is contained in:
+1
-1
@@ -25,7 +25,7 @@ fun MatrixBadgeRowMolecule(
|
||||
) {
|
||||
FlowRow(
|
||||
modifier = modifier
|
||||
.padding(start = 16.dp, end = 16.dp, top = 8.dp),
|
||||
.padding(start = 16.dp, end = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
|
||||
@@ -1,17 +1,16 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.recentemojis.api"
|
||||
namespace = "io.element.android.libraries.emoji.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -19,5 +18,5 @@ dependencies {
|
||||
implementation(projects.libraries.matrix.api)
|
||||
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
api(libs.matrix.emojibase.bindings)
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.api.picker
|
||||
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
|
||||
|
||||
fun interface EmojiPickerPresenter : Presenter<EmojiPickerState> {
|
||||
fun interface Factory {
|
||||
fun create(getRecentEmojis: GetRecentEmojis): EmojiPickerPresenter
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.api.picker
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Modifier
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
@Immutable
|
||||
interface EmojiPickerRenderer {
|
||||
/**
|
||||
* @param state opaque state produced by [EmojiPickerPresenter.present].
|
||||
* @param onSelectEmoji invoked when the user taps an emoji (including a skin-tone variant).
|
||||
* @param selectedEmojis emojis to visually mark as already selected.
|
||||
* @param modifier layout modifier for the picker container.
|
||||
* @param contentDescription accessibility label for each emoji cell.
|
||||
*/
|
||||
@Composable
|
||||
fun Render(
|
||||
state: EmojiPickerState,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
selectedEmojis: ImmutableSet<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: @Composable (Emoji, Boolean) -> String = { emoji, _ -> emoji.unicode },
|
||||
)
|
||||
}
|
||||
|
||||
object NoOpEmojiPickerRenderer : EmojiPickerRenderer {
|
||||
@Composable
|
||||
override fun Render(
|
||||
state: EmojiPickerState,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
selectedEmojis: ImmutableSet<String>,
|
||||
modifier: Modifier,
|
||||
contentDescription: @Composable (Emoji, Boolean) -> String,
|
||||
) = Unit
|
||||
}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.api.picker
|
||||
|
||||
/**
|
||||
* Opaque state produced by an [EmojiPickerPresenter] and consumed by an [EmojiPickerRenderer].
|
||||
*/
|
||||
interface EmojiPickerState {
|
||||
/**
|
||||
* `true` once the picker has finished loading its data and is safe to display.
|
||||
* Callers should typically defer showing the picker (or its container, e.g. a bottom sheet)
|
||||
* until this flips to `true` to avoid flashing an empty picker.
|
||||
*/
|
||||
val isReady: Boolean
|
||||
}
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.recentemojis.api
|
||||
package io.element.android.libraries.emoji.api.recentemojis
|
||||
|
||||
fun interface AddRecentEmoji {
|
||||
suspend operator fun invoke(emoji: String): Result<Unit>
|
||||
+10
-1
@@ -6,9 +6,10 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.recentemojis.api
|
||||
package io.element.android.libraries.emoji.api.recentemojis
|
||||
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
/**
|
||||
* Returns the list of recently used emojis for reactions.
|
||||
@@ -16,3 +17,11 @@ import kotlinx.collections.immutable.ImmutableList
|
||||
fun interface GetRecentEmojis {
|
||||
suspend operator fun invoke(): Result<ImmutableList<String>>
|
||||
}
|
||||
|
||||
/**
|
||||
* A [GetRecentEmojis] that never returns any recent emojis. Handy for pickers that don't want
|
||||
* a "Recent" tab (e.g. the user-status picker).
|
||||
*/
|
||||
object EmptyGetRecentEmojis : GetRecentEmojis {
|
||||
override suspend fun invoke(): Result<ImmutableList<String>> = Result.success(persistentListOf())
|
||||
}
|
||||
+15
-12
@@ -1,37 +1,40 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import extension.setupDependencyInjection
|
||||
import extension.testCommonDependencies
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.recentemojis.impl"
|
||||
namespace = "io.element.android.libraries.emoji.impl"
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setupDependencyInjection()
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.recentemojis.api)
|
||||
api(projects.libraries.emoji.api)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.di)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
|
||||
testImplementation(projects.libraries.recentemojis.test)
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testCommonDependencies(libs)
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.impl
|
||||
|
||||
import android.content.Context
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.emojibasebindings.EmojibaseDatasource
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.annotations.AppCoroutineScope
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.async
|
||||
|
||||
@SingleIn(AppScope::class)
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultEmojibaseProvider(
|
||||
@ApplicationContext private val context: Context,
|
||||
dispatchers: CoroutineDispatchers,
|
||||
@AppCoroutineScope appScope: CoroutineScope,
|
||||
) : EmojibaseProvider {
|
||||
private val deferred = appScope.async(dispatchers.io, start = CoroutineStart.LAZY) {
|
||||
EmojibaseDatasource().load(context)
|
||||
}
|
||||
|
||||
override suspend fun getStore(): EmojibaseStore = deferred.await()
|
||||
}
|
||||
+3
-4
@@ -1,15 +1,14 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.recentemojis.api
|
||||
package io.element.android.libraries.emoji.impl
|
||||
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
|
||||
interface EmojibaseProvider {
|
||||
val emojibaseStore: EmojibaseStore
|
||||
suspend fun getStore(): EmojibaseStore
|
||||
}
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.impl.picker
|
||||
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
|
||||
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
|
||||
import io.element.android.libraries.emoji.impl.EmojibaseProvider
|
||||
import io.element.android.libraries.emoji.impl.R
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@AssistedInject
|
||||
class DefaultEmojiPickerPresenter(
|
||||
private val emojibaseProvider: EmojibaseProvider,
|
||||
@Assisted private val getRecentEmojis: GetRecentEmojis,
|
||||
private val coroutineDispatchers: CoroutineDispatchers,
|
||||
) : EmojiPickerPresenter {
|
||||
@AssistedFactory
|
||||
@ContributesBinding(SessionScope::class)
|
||||
interface Factory : EmojiPickerPresenter.Factory {
|
||||
override fun create(getRecentEmojis: GetRecentEmojis): DefaultEmojiPickerPresenter
|
||||
}
|
||||
|
||||
@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 data by produceState(EmojiPickerData.Empty) {
|
||||
val storeDeferred = async { emojibaseProvider.getStore() }
|
||||
val recentsDeferred = async { getRecentEmojis().getOrNull() ?: persistentListOf() }
|
||||
val store = storeDeferred.await()
|
||||
val recentEmojiUnicodes = recentsDeferred.await()
|
||||
value = withContext(coroutineDispatchers.computation) {
|
||||
val baseCategories = store.categories.map { (category, emojis) ->
|
||||
EmojiCategory(
|
||||
titleId = category.title,
|
||||
icon = IconSource.Vector(category.icon),
|
||||
emojis = emojis,
|
||||
)
|
||||
}
|
||||
val recentEmojis = recentEmojiUnicodes
|
||||
.mapNotNull { unicode -> store.allEmojis.find { it.unicode == unicode } }
|
||||
.toImmutableList()
|
||||
val categories = if (recentEmojis.isEmpty()) {
|
||||
baseCategories.toImmutableList()
|
||||
} else {
|
||||
val recentCategory = EmojiCategory(
|
||||
titleId = R.string.emoji_picker_category_recent,
|
||||
icon = IconSource.Resource(io.element.android.compound.R.drawable.ic_compound_history),
|
||||
emojis = recentEmojis,
|
||||
)
|
||||
(listOf(recentCategory) + baseCategories).toImmutableList()
|
||||
}
|
||||
EmojiPickerData(
|
||||
categories = categories,
|
||||
allEmojis = store.allEmojis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val searchQuery = queryState.text.toString()
|
||||
LaunchedEffect(searchQuery, data) {
|
||||
if (searchQuery.isEmpty() || data.allEmojis.isEmpty()) {
|
||||
// `SearchBarResultState.Initial` has no `equals` override, so reassigning a new instance
|
||||
// would trigger a redundant emission each time this effect re-runs while the query is empty.
|
||||
if (emojiResults !is SearchBarResultState.Initial) {
|
||||
emojiResults = SearchBarResultState.Initial()
|
||||
}
|
||||
return@LaunchedEffect
|
||||
}
|
||||
delay(100.milliseconds)
|
||||
val lowercaseQuery = searchQuery.lowercase()
|
||||
val results = withContext(coroutineDispatchers.computation) {
|
||||
data.allEmojis
|
||||
.asSequence()
|
||||
.filter { emoji ->
|
||||
emoji.tags.orEmpty().any { it.contains(lowercaseQuery) } ||
|
||||
emoji.shortcodes.any { it.contains(lowercaseQuery) }
|
||||
}
|
||||
.take(60)
|
||||
.toImmutableList()
|
||||
}
|
||||
emojiResults = SearchBarResultState.Results(results)
|
||||
}
|
||||
|
||||
val isInPreview = LocalInspectionMode.current
|
||||
fun handleEvent(event: EmojiPickerEvent) {
|
||||
when (event) {
|
||||
is EmojiPickerEvent.ToggleSearchActive -> if (!isInPreview) {
|
||||
isSearchActive = event.isActive
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return DefaultEmojiPickerState(
|
||||
categories = data.categories,
|
||||
searchQuery = queryState,
|
||||
isSearchActive = isSearchActive,
|
||||
searchResults = emojiResults,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class EmojiPickerData(
|
||||
val categories: ImmutableList<EmojiCategory>,
|
||||
val allEmojis: ImmutableList<Emoji>,
|
||||
) {
|
||||
companion object {
|
||||
val Empty = EmojiPickerData(persistentListOf(), persistentListOf())
|
||||
}
|
||||
}
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.impl.picker
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultEmojiPickerRenderer : EmojiPickerRenderer {
|
||||
@Composable
|
||||
override fun Render(
|
||||
state: EmojiPickerState,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
selectedEmojis: ImmutableSet<String>,
|
||||
modifier: Modifier,
|
||||
contentDescription: @Composable (Emoji, Boolean) -> String,
|
||||
) {
|
||||
if (state is DefaultEmojiPickerState) {
|
||||
EmojiPickerView(
|
||||
state = state,
|
||||
onSelectEmoji = onSelectEmoji,
|
||||
selectedEmojis = selectedEmojis,
|
||||
contentDescription = contentDescription,
|
||||
modifier = modifier,
|
||||
)
|
||||
} else {
|
||||
error("Unsupported state type: ${state::class.java}")
|
||||
}
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.impl.picker
|
||||
|
||||
import androidx.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 io.element.android.libraries.emoji.api.picker.EmojiPickerState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@Immutable
|
||||
internal data class DefaultEmojiPickerState(
|
||||
val categories: ImmutableList<EmojiCategory>,
|
||||
val searchQuery: TextFieldState,
|
||||
val isSearchActive: Boolean,
|
||||
val searchResults: SearchBarResultState<ImmutableList<Emoji>>,
|
||||
val eventSink: (EmojiPickerEvent) -> Unit,
|
||||
) : EmojiPickerState {
|
||||
override val isReady = categories.isNotEmpty()
|
||||
}
|
||||
|
||||
internal data class EmojiCategory(
|
||||
@StringRes val titleId: Int,
|
||||
val icon: IconSource,
|
||||
val emojis: ImmutableList<Emoji>,
|
||||
)
|
||||
+92
@@ -0,0 +1,92 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.impl.picker
|
||||
|
||||
import androidx.compose.foundation.text.input.TextFieldState
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojibaseCategory
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
|
||||
internal class DefaultEmojiPickerStateProvider : PreviewParameterProvider<DefaultEmojiPickerState> {
|
||||
override val values: Sequence<DefaultEmojiPickerState>
|
||||
get() = sequenceOf(
|
||||
aDefaultEmojiPickerState(),
|
||||
aDefaultEmojiPickerState(isSearchActive = true),
|
||||
aDefaultEmojiPickerState(isSearchActive = true, searchQuery = "smile"),
|
||||
aDefaultEmojiPickerState(
|
||||
isSearchActive = true,
|
||||
searchQuery = "smile",
|
||||
searchResults = SearchBarResultState.Results(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"),
|
||||
"🥲",
|
||||
null
|
||||
)
|
||||
)
|
||||
|
||||
internal fun aDefaultEmojiPickerState(
|
||||
categories: ImmutableList<EmojiCategory> = EmojibaseCategory.entries.map {
|
||||
EmojiCategory(
|
||||
titleId = it.title,
|
||||
icon = IconSource.Vector(it.icon),
|
||||
emojis = emojiList(),
|
||||
)
|
||||
}.toImmutableList(),
|
||||
searchQuery: String = "",
|
||||
isSearchActive: Boolean = false,
|
||||
searchResults: SearchBarResultState<ImmutableList<Emoji>> = SearchBarResultState.Initial(),
|
||||
eventSink: (EmojiPickerEvent) -> Unit = {},
|
||||
) = DefaultEmojiPickerState(
|
||||
categories = categories,
|
||||
searchQuery = TextFieldState(initialText = searchQuery),
|
||||
isSearchActive = isSearchActive,
|
||||
searchResults = searchResults,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
/**
|
||||
* Public helper for external tests that need an [EmojiPickerState] instance the impl's
|
||||
* [DefaultEmojiPickerRenderer] will accept. The no-arg overload returns a ready state
|
||||
* (see [EmojiPickerState.isReady]); use the [emojis] overload to inject specific categories.
|
||||
*/
|
||||
fun anEmojiPickerState(): EmojiPickerState = aDefaultEmojiPickerState()
|
||||
|
||||
fun anEmojiPickerState(
|
||||
emojis: Map<EmojibaseCategory, ImmutableList<Emoji>>,
|
||||
): EmojiPickerState = aDefaultEmojiPickerState(
|
||||
categories = emojis.map { (category, list) ->
|
||||
EmojiCategory(
|
||||
titleId = category.title,
|
||||
icon = IconSource.Vector(category.icon),
|
||||
emojis = list,
|
||||
)
|
||||
}.toImmutableList(),
|
||||
)
|
||||
+313
@@ -0,0 +1,313 @@
|
||||
/*
|
||||
* 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.libraries.emoji.impl.picker
|
||||
|
||||
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.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
|
||||
internal 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,
|
||||
contentDescription: @Composable (emoji: Emoji, isSelected: Boolean) -> String = { emoji, _ -> emoji.unicode },
|
||||
) {
|
||||
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 = contentDescription(item, isSelected)
|
||||
val hasSkinTones = !item.skins.isNullOrEmpty()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.onPlaced { coordinates ->
|
||||
val position = coordinates.positionInParent()
|
||||
val parentBounds = coordinates.parentLayoutCoordinates?.size ?: return@onPlaced
|
||||
// Calculate the offset of the item inside its parent as a percentage, to be able to set the initial offset
|
||||
// for the selection inside the skin tone picker relative to its size.
|
||||
itemOffsetPercent = if (parentBounds.width > 0) {
|
||||
position.x / parentBounds.width
|
||||
} else {
|
||||
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 {
|
||||
this.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("👍🏾"),
|
||||
)
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.impl.picker
|
||||
|
||||
internal sealed interface EmojiPickerEvent {
|
||||
data class ToggleSearchActive(val isActive: Boolean) : EmojiPickerEvent
|
||||
}
|
||||
+176
@@ -0,0 +1,176 @@
|
||||
/*
|
||||
* 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.libraries.emoji.impl.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.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.emoji.impl.R
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableSet
|
||||
import kotlinx.collections.immutable.persistentSetOf
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
internal fun EmojiPickerView(
|
||||
state: DefaultEmojiPickerState,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
selectedEmojis: ImmutableSet<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
contentDescription: @Composable (emoji: Emoji, isSelected: Boolean) -> String = { emoji, _ -> emoji.unicode },
|
||||
) {
|
||||
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(R.string.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,
|
||||
contentDescription = contentDescription,
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
contentDescription = contentDescription,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun EmojiResults(
|
||||
emojis: ImmutableList<Emoji>,
|
||||
isEmojiSelected: (Emoji) -> Boolean,
|
||||
onSelectEmoji: (Emoji) -> Unit,
|
||||
onLongPress: (Emoji) -> Unit,
|
||||
skinPickerEmoji: Emoji?,
|
||||
onDismissSkinPicker: () -> Unit,
|
||||
selectedEmojis: ImmutableSet<String>,
|
||||
contentDescription: @Composable (emoji: Emoji, isSelected: Boolean) -> 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,
|
||||
contentDescription = contentDescription,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun EmojiPickerViewPreview(@PreviewParameter(DefaultEmojiPickerStateProvider::class) state: DefaultEmojiPickerState) = ElementPreview {
|
||||
EmojiPickerView(
|
||||
state = state,
|
||||
onSelectEmoji = {},
|
||||
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
/*
|
||||
* 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.libraries.emoji.impl.picker
|
||||
|
||||
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.libraries.emoji.impl.R
|
||||
|
||||
@get:StringRes
|
||||
internal 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
|
||||
}
|
||||
|
||||
internal 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
|
||||
}
|
||||
+137
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.impl.picker
|
||||
|
||||
import androidx.compose.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
|
||||
|
||||
internal val SkinToneSlotSize = 48.dp
|
||||
internal val SkinToneSlotSpacing = 2.dp
|
||||
internal val SkinTonePadding = 4.dp
|
||||
|
||||
@Composable
|
||||
internal 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", "👍🏿"),
|
||||
)
|
||||
+2
-2
@@ -6,13 +6,13 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.recentemojis.impl
|
||||
package io.element.android.libraries.emoji.impl.recentemojis
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.emoji.api.recentemojis.AddRecentEmoji
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.recentemojis.api.AddRecentEmoji
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
@ContributesBinding(SessionScope::class)
|
||||
+4
-4
@@ -6,14 +6,14 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.recentemojis.impl
|
||||
package io.element.android.libraries.emoji.impl.recentemojis
|
||||
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
|
||||
import io.element.android.libraries.emoji.impl.EmojibaseProvider
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
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.toImmutableList
|
||||
import kotlinx.coroutines.withContext
|
||||
@@ -25,7 +25,7 @@ class DefaultGetRecentEmojis(
|
||||
private val emojibaseProvider: EmojibaseProvider,
|
||||
) : GetRecentEmojis {
|
||||
override suspend operator fun invoke(): Result<ImmutableList<String>> = withContext(dispatchers.io) {
|
||||
val allEmojis = emojibaseProvider.emojibaseStore.allEmojis
|
||||
val allEmojis = emojibaseProvider.getStore().allEmojis
|
||||
client.getRecentEmojis()
|
||||
.map { emojis ->
|
||||
// Remove any possible duplicates
|
||||
@@ -0,0 +1,11 @@
|
||||
<?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>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?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>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Cerca emoticones"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Hledat emotikony"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Chwilio emojis"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Søg emojis"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Emojis suchen"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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_recent">"Πρόσφατα emoji"</string>
|
||||
<string name="emoji_picker_category_symbols">"Σύμβολα"</string>
|
||||
<string name="emoji_picker_search_placeholder">"Αναζήτηση emoji"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Otsi emojisid"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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_recent">"شکلکهای اخیر"</string>
|
||||
<string name="emoji_picker_category_symbols">"نمادها"</string>
|
||||
<string name="emoji_picker_search_placeholder">"حستوجوی شکلکها"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Etsi emojeja"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Chercher des emojis"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Pretraživanje emotikona"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Emodzsik keresése"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Cari emoji"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Cerca emoji"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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_recent">"最近使用"</string>
|
||||
<string name="emoji_picker_category_symbols">"記号"</string>
|
||||
<string name="emoji_picker_search_placeholder">"絵文字を検索"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?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>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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_recent">"최근 이모지"</string>
|
||||
<string name="emoji_picker_category_symbols">"상징"</string>
|
||||
<string name="emoji_picker_search_placeholder">"이모지 검색"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?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>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Søk etter emojier"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Wyszukaj emoji"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Pesquisar emojis"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Pesquisar emojis"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Căutați emoticoane"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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_recent">"Недавние эмодзи"</string>
|
||||
<string name="emoji_picker_category_symbols">"Символы"</string>
|
||||
<string name="emoji_picker_search_placeholder">"Поиск эмодзи"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Hľadať emotikony"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Sök emojier"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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_recent">"Нещодавні емодзі"</string>
|
||||
<string name="emoji_picker_category_symbols">"Символи"</string>
|
||||
<string name="emoji_picker_search_placeholder">"Пошук емоджі"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,11 @@
|
||||
<?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>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Emojilarni qidiring"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Tìm kiếm biểu tượng cảm xúc"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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_recent">"最近使用的表情符號"</string>
|
||||
<string name="emoji_picker_category_symbols">"標誌"</string>
|
||||
<string name="emoji_picker_search_placeholder">"搜尋表情符號"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?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_recent">"最近的 Emoji"</string>
|
||||
<string name="emoji_picker_category_symbols">"符号"</string>
|
||||
<string name="emoji_picker_search_placeholder">"搜索 Emoji"</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<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="emoji_picker_search_placeholder">"Search emojis"</string>
|
||||
</resources>
|
||||
+6
-6
@@ -6,18 +6,18 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.recentemojis.test
|
||||
package io.element.android.libraries.emoji.impl.fixtures
|
||||
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojibaseCategory
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
import io.element.android.libraries.recentemojis.api.EmojibaseProvider
|
||||
import io.element.android.libraries.emoji.impl.EmojibaseProvider
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.toPersistentMap
|
||||
|
||||
class FakeEmojibaseProvider(
|
||||
val emojis: Map<EmojibaseCategory, ImmutableList<Emoji>> = mapOf(),
|
||||
internal class FakeEmojibaseProvider(
|
||||
emojis: Map<EmojibaseCategory, ImmutableList<Emoji>> = mapOf(),
|
||||
) : EmojibaseProvider {
|
||||
override val emojibaseStore: EmojibaseStore
|
||||
get() = EmojibaseStore(emojis.toPersistentMap())
|
||||
private val store = EmojibaseStore(emojis.toPersistentMap())
|
||||
override suspend fun getStore(): EmojibaseStore = store
|
||||
}
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.impl.picker
|
||||
|
||||
import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd
|
||||
import app.cash.turbine.TurbineTestContext
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojibaseCategory
|
||||
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
|
||||
import io.element.android.libraries.emoji.api.recentemojis.GetRecentEmojis
|
||||
import io.element.android.libraries.emoji.impl.fixtures.FakeEmojibaseProvider
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
|
||||
class EmojiPickerPresenterTest : RobolectricTest() {
|
||||
@get:Rule
|
||||
val warmUpRule = WarmUpRule()
|
||||
|
||||
@Test
|
||||
fun `updating search query loads new results`() = runTest {
|
||||
createPresenter().test {
|
||||
val initialState = awaitStoreLoaded()
|
||||
assertThat(initialState.searchQuery.text.toString()).isEmpty()
|
||||
assertThat(initialState.searchResults).isInstanceOf(SearchBarResultState.Initial::class.java)
|
||||
|
||||
initialState.searchQuery.setTextAndPlaceCursorAtEnd("smile")
|
||||
val stateAfterQuery = awaitItem() as DefaultEmojiPickerState
|
||||
assertThat(stateAfterQuery.searchQuery.text.toString()).isEqualTo("smile")
|
||||
|
||||
val stateWithResults = awaitItem() as DefaultEmojiPickerState
|
||||
assertThat(stateWithResults.searchQuery.text.toString()).isEqualTo("smile")
|
||||
assertThat(stateWithResults.searchResults).isInstanceOf(SearchBarResultState.Results::class.java)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `recent emojis are prepended as a category when the source returns any`() = runTest {
|
||||
createPresenter(
|
||||
categories = persistentListOf(emojiCategory(EmojibaseCategory.Activity)),
|
||||
getRecentEmojis = GetRecentEmojis { Result.success(persistentListOf("😊")) },
|
||||
).test {
|
||||
val state = awaitStoreLoaded()
|
||||
assertThat(state.categories.size).isEqualTo(2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ToggleSearchActive toggles the search state`() = runTest {
|
||||
createPresenter().test {
|
||||
val initialState = awaitStoreLoaded()
|
||||
assertThat(initialState.isSearchActive).isFalse()
|
||||
|
||||
initialState.eventSink(EmojiPickerEvent.ToggleSearchActive(true))
|
||||
assertThat((awaitItem() as DefaultEmojiPickerState).isSearchActive).isTrue()
|
||||
|
||||
initialState.eventSink(EmojiPickerEvent.ToggleSearchActive(false))
|
||||
assertThat((awaitItem() as DefaultEmojiPickerState).isSearchActive).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `presenter exposes categories from the emoji store when the recent source is empty`() = runTest {
|
||||
val providedCategories = persistentListOf(
|
||||
emojiCategory(EmojibaseCategory.Activity),
|
||||
emojiCategory(EmojibaseCategory.People),
|
||||
)
|
||||
createPresenter(categories = providedCategories).test {
|
||||
val state = awaitStoreLoaded()
|
||||
assertThat(state.categories.size).isEqualTo(providedCategories.size)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun TurbineTestContext<EmojiPickerState>.awaitStoreLoaded(): DefaultEmojiPickerState {
|
||||
var state = awaitItem() as DefaultEmojiPickerState
|
||||
while (state.categories.isEmpty()) {
|
||||
state = awaitItem() as DefaultEmojiPickerState
|
||||
}
|
||||
return state
|
||||
}
|
||||
|
||||
private fun TestScope.createPresenter(
|
||||
categories: ImmutableList<Pair<EmojibaseCategory, ImmutableList<Emoji>>> = persistentListOf(emojiCategory()),
|
||||
getRecentEmojis: GetRecentEmojis = GetRecentEmojis { Result.success(persistentListOf()) },
|
||||
) = DefaultEmojiPickerPresenter(
|
||||
emojibaseProvider = FakeEmojibaseProvider(emojis = categories.toMap()),
|
||||
getRecentEmojis = getRecentEmojis,
|
||||
coroutineDispatchers = testCoroutineDispatchers(),
|
||||
)
|
||||
|
||||
private fun emojiCategory(
|
||||
category: EmojibaseCategory = EmojibaseCategory.Activity,
|
||||
emojis: ImmutableList<Emoji> = persistentListOf(
|
||||
Emoji("1F3C3", "Smile", persistentListOf("smile"), persistentListOf("smile"), "😊", skins = null)
|
||||
)
|
||||
) = category to emojis
|
||||
}
|
||||
+2
-2
@@ -6,14 +6,14 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.recentemojis.impl
|
||||
package io.element.android.libraries.emoji.impl.recentemojis
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.emojibasebindings.Emoji
|
||||
import io.element.android.emojibasebindings.EmojibaseCategory
|
||||
import io.element.android.emojibasebindings.EmojibaseCategory.People
|
||||
import io.element.android.libraries.emoji.impl.fixtures.FakeEmojibaseProvider
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.recentemojis.test.FakeEmojibaseProvider
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.ImmutableMap
|
||||
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.emoji.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.emoji.api)
|
||||
implementation(projects.libraries.architecture)
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.test
|
||||
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerPresenter
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
|
||||
|
||||
fun fakeEmojiPickerPresenter(
|
||||
state: EmojiPickerState = FakeEmojiPickerState(isReady = true),
|
||||
): EmojiPickerPresenter = EmojiPickerPresenter { state }
|
||||
|
||||
fun fakeEmojiPickerPresenterFactory(
|
||||
presenter: EmojiPickerPresenter = fakeEmojiPickerPresenter(),
|
||||
): EmojiPickerPresenter.Factory = EmojiPickerPresenter.Factory { presenter }
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.emoji.test
|
||||
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerState
|
||||
|
||||
/**
|
||||
* Minimal [EmojiPickerState] for use in tests that don't render the picker (typically presenter
|
||||
* tests).
|
||||
*/
|
||||
data class FakeEmojiPickerState(
|
||||
override val isReady: Boolean,
|
||||
) : EmojiPickerState
|
||||
+2
@@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.api.room
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
data class RoomMember(
|
||||
@@ -23,6 +24,7 @@ data class RoomMember(
|
||||
val role: Role,
|
||||
val membershipChangeReason: String?,
|
||||
val isServiceMember: Boolean,
|
||||
val displayedStatus: DisplayedStatus?,
|
||||
) {
|
||||
/**
|
||||
* Role of the RoomMember, based on its [powerLevel].
|
||||
|
||||
+3
-1
@@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event
|
||||
|
||||
import androidx.compose.runtime.Immutable
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
|
||||
@Immutable
|
||||
sealed interface ProfileDetails {
|
||||
@@ -20,7 +21,8 @@ sealed interface ProfileDetails {
|
||||
data class Ready(
|
||||
val displayName: String?,
|
||||
val displayNameAmbiguous: Boolean,
|
||||
val avatarUrl: String?
|
||||
val avatarUrl: String?,
|
||||
val displayedStatus: DisplayedStatus?,
|
||||
) : ProfileDetails
|
||||
|
||||
data class Error(
|
||||
|
||||
+3
@@ -38,6 +38,7 @@ class ProfileTimelineDetailsTest {
|
||||
displayName = null,
|
||||
displayNameAmbiguous = false,
|
||||
avatarUrl = null,
|
||||
displayedStatus = null,
|
||||
).getDisambiguatedDisplayName(aUserId)
|
||||
).isEqualTo(A_USER_ID)
|
||||
}
|
||||
@@ -49,6 +50,7 @@ class ProfileTimelineDetailsTest {
|
||||
displayName = "Alice",
|
||||
displayNameAmbiguous = false,
|
||||
avatarUrl = null,
|
||||
displayedStatus = null,
|
||||
).getDisambiguatedDisplayName(aUserId)
|
||||
).isEqualTo("Alice")
|
||||
}
|
||||
@@ -60,6 +62,7 @@ class ProfileTimelineDetailsTest {
|
||||
displayName = "Alice",
|
||||
displayNameAmbiguous = true,
|
||||
avatarUrl = null,
|
||||
displayedStatus = null,
|
||||
).getDisambiguatedDisplayName(aUserId)
|
||||
).isEqualTo("Alice ($A_USER_ID)")
|
||||
}
|
||||
|
||||
+2
@@ -30,6 +30,8 @@ object RoomMemberMapper {
|
||||
role = mapRole(roomMember.suggestedRoleForPowerLevel, powerLevel),
|
||||
membershipChangeReason = roomMember.membershipChangeReason,
|
||||
isServiceMember = roomMember.isServiceMember,
|
||||
// TODO map this value when available
|
||||
displayedStatus = null,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+3
-1
@@ -74,7 +74,9 @@ fun RustProfileDetails.map(): ProfileDetails {
|
||||
is RustProfileDetails.Ready -> ProfileDetails.Ready(
|
||||
displayName = displayName,
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
avatarUrl = avatarUrl
|
||||
avatarUrl = avatarUrl,
|
||||
// TODO map this value when available
|
||||
displayedStatus = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.test.room
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
fun aRoomMember(
|
||||
@@ -24,6 +25,7 @@ fun aRoomMember(
|
||||
role: RoomMember.Role = RoomMember.Role.User,
|
||||
membershipChangeReason: String? = null,
|
||||
isServiceMember: Boolean = false,
|
||||
displayedStatus: DisplayedStatus? = null,
|
||||
) = RoomMember(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
@@ -35,6 +37,7 @@ fun aRoomMember(
|
||||
role = role,
|
||||
membershipChangeReason = membershipChangeReason,
|
||||
isServiceMember = isServiceMember,
|
||||
displayedStatus = displayedStatus,
|
||||
)
|
||||
|
||||
fun aRoomMemberList() = persistentListOf(
|
||||
|
||||
+4
-1
@@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.SendHandlePro
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.StickerContent
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemDebugInfoProvider
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
@@ -84,11 +85,13 @@ fun anEventTimelineItem(
|
||||
fun aProfileDetails(
|
||||
displayName: String? = A_USER_NAME,
|
||||
displayNameAmbiguous: Boolean = false,
|
||||
avatarUrl: String? = null
|
||||
avatarUrl: String? = null,
|
||||
displayedStatus: DisplayedStatus? = null,
|
||||
): ProfileDetails = ProfileDetails.Ready(
|
||||
displayName = displayName,
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
avatarUrl = avatarUrl,
|
||||
displayedStatus = displayedStatus,
|
||||
)
|
||||
|
||||
fun aProfileChangeMessageContent(
|
||||
|
||||
+2
-1
@@ -19,6 +19,7 @@ import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
@@ -50,7 +51,7 @@ fun CheckableUserRow(
|
||||
UserRow(
|
||||
modifier = rowModifier,
|
||||
avatarData = data.avatarData,
|
||||
name = data.name,
|
||||
name = AnnotatedString(data.name),
|
||||
subtext = data.subtext,
|
||||
enabled = enabled,
|
||||
)
|
||||
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.components
|
||||
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.ui.model.toEmojiText
|
||||
|
||||
/**
|
||||
* Component to display side by side a display name and an optional status.
|
||||
*/
|
||||
@Composable
|
||||
fun DisplayNameWithStatus(
|
||||
name: String,
|
||||
status: DisplayedStatus?,
|
||||
style: TextStyle,
|
||||
modifier: Modifier = Modifier,
|
||||
nameColor: Color = Color.Unspecified,
|
||||
nameFontStyle: FontStyle? = null,
|
||||
) {
|
||||
Row(
|
||||
modifier = modifier,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = name,
|
||||
modifier = Modifier
|
||||
.weight(1f, fill = false)
|
||||
.clipToBounds(),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
style = style,
|
||||
color = nameColor,
|
||||
fontStyle = nameFontStyle,
|
||||
)
|
||||
if (status != null) {
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
text = status.toEmojiText(),
|
||||
style = style,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+4
-7
@@ -18,7 +18,6 @@ import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -58,13 +57,11 @@ fun MatrixUserHeader(
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)
|
||||
) {
|
||||
// Name
|
||||
Text(
|
||||
modifier = Modifier.clipToBounds(),
|
||||
text = matrixUser.getBestName(),
|
||||
maxLines = 1,
|
||||
DisplayNameWithStatus(
|
||||
name = matrixUser.getBestName(),
|
||||
status = matrixUser.displayedStatus,
|
||||
nameColor = ElementTheme.colors.textPrimary,
|
||||
style = ElementTheme.typography.fontHeadingMdRegular,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = ElementTheme.colors.textPrimary,
|
||||
)
|
||||
// Id
|
||||
if (matrixUser.displayName.isNullOrEmpty().not()) {
|
||||
|
||||
+4
@@ -21,6 +21,7 @@ import io.element.android.libraries.designsystem.preview.USER_NAME_SUSIE
|
||||
import io.element.android.libraries.designsystem.preview.USER_NAME_VICTOR
|
||||
import io.element.android.libraries.designsystem.preview.USER_NAME_WALTER
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
|
||||
@@ -28,6 +29,7 @@ open class MatrixUserProvider : PreviewParameterProvider<MatrixUser> {
|
||||
get() = sequenceOf(
|
||||
aMatrixUser(),
|
||||
aMatrixUser(displayName = null),
|
||||
aMatrixUser(displayedStatus = DisplayedStatus.InCall(0L)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -43,10 +45,12 @@ fun aMatrixUser(
|
||||
id: String? = null,
|
||||
displayName: String? = USER_NAME_ALICE,
|
||||
avatarUrl: String? = null,
|
||||
displayedStatus: DisplayedStatus? = null,
|
||||
) = MatrixUser(
|
||||
userId = UserId(id ?: "@${displayName?.lowercase()?.replace(" ", "_") ?: "id"}:server.org"),
|
||||
displayName = displayName,
|
||||
avatarUrl = avatarUrl,
|
||||
displayedStatus = displayedStatus,
|
||||
)
|
||||
|
||||
fun aMatrixUserList() = listOf(
|
||||
|
||||
+9
-1
@@ -13,6 +13,7 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
@@ -29,7 +30,14 @@ fun MatrixUserRow(
|
||||
trailingContent: @Composable (() -> Unit)? = null,
|
||||
) = UserRow(
|
||||
avatarData = matrixUser.getAvatarData(avatarSize),
|
||||
name = matrixUser.getBestName(),
|
||||
nameContent = {
|
||||
DisplayNameWithStatus(
|
||||
name = matrixUser.getBestName(),
|
||||
status = matrixUser.displayedStatus,
|
||||
nameColor = ElementTheme.colors.textPrimary,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
},
|
||||
subtext = if (matrixUser.displayName.isNullOrEmpty()) null else matrixUser.userId.value,
|
||||
modifier = modifier,
|
||||
verticalSpaceWidth = verticalSpaceWidth,
|
||||
|
||||
+33
-9
@@ -18,6 +18,7 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -30,7 +31,37 @@ import io.element.android.libraries.designsystem.theme.components.Text
|
||||
@Composable
|
||||
internal fun UserRow(
|
||||
avatarData: AvatarData,
|
||||
name: String,
|
||||
name: AnnotatedString,
|
||||
subtext: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
verticalSpaceWidth: Dp = 12.dp,
|
||||
trailingContent: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
UserRow(
|
||||
avatarData = avatarData,
|
||||
nameContent = {
|
||||
Text(
|
||||
modifier = Modifier.clipToBounds(),
|
||||
text = name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (enabled) ElementTheme.colors.textPrimary else ElementTheme.colors.textDisabled,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
},
|
||||
subtext = subtext,
|
||||
modifier = modifier,
|
||||
enabled = enabled,
|
||||
verticalSpaceWidth = verticalSpaceWidth,
|
||||
trailingContent = trailingContent,
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
internal fun UserRow(
|
||||
avatarData: AvatarData,
|
||||
nameContent: @Composable () -> Unit,
|
||||
subtext: String?,
|
||||
modifier: Modifier = Modifier,
|
||||
enabled: Boolean = true,
|
||||
@@ -52,14 +83,7 @@ internal fun UserRow(
|
||||
modifier = Modifier.weight(1f),
|
||||
) {
|
||||
// Name
|
||||
Text(
|
||||
modifier = Modifier.clipToBounds(),
|
||||
text = name,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
color = if (enabled) ElementTheme.colors.textPrimary else ElementTheme.colors.textDisabled,
|
||||
style = ElementTheme.typography.fontBodyLgRegular,
|
||||
)
|
||||
nameContent()
|
||||
// Id
|
||||
subtext?.let {
|
||||
Text(
|
||||
|
||||
+5
@@ -13,6 +13,7 @@ import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import kotlinx.coroutines.coroutineScope
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.drop
|
||||
@@ -31,4 +32,8 @@ class RoomMemberProfilesCache {
|
||||
fun getDisplayName(userId: UserId): String? {
|
||||
return cache.value[userId]?.disambiguatedDisplayName
|
||||
}
|
||||
|
||||
fun getDisplayedStatus(userId: UserId): DisplayedStatus? {
|
||||
return cache.value[userId]?.displayedStatus
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -167,4 +167,5 @@ fun aProfileDetailsReady(
|
||||
displayName = displayName,
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
avatarUrl = avatarUrl,
|
||||
displayedStatus = null,
|
||||
)
|
||||
|
||||
+18
-1
@@ -24,6 +24,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||
import io.element.android.libraries.matrix.ui.model.toText
|
||||
|
||||
// https://www.figma.com/file/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?type=design&node-id=917-80169&mode=design&t=A0CJCBbMqR8NOwUQ-0
|
||||
@Composable
|
||||
@@ -54,6 +55,19 @@ fun SenderName(
|
||||
SecondaryText(text = senderId.value, mode = senderNameMode)
|
||||
}
|
||||
}
|
||||
val userStatus = senderProfile.displayedStatus
|
||||
if (senderNameMode is SenderNameMode.Timeline && userStatus != null) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clipToBounds()
|
||||
.alignByBaseline(),
|
||||
text = userStatus.toText(),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = ElementTheme.colors.textSecondary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,13 +88,16 @@ private fun RowScope.MainText(
|
||||
SenderNameMode.ActionList,
|
||||
SenderNameMode.Reply -> Modifier
|
||||
}
|
||||
|
||||
val color = when (mode) {
|
||||
is SenderNameMode.Timeline -> mode.mainColor
|
||||
SenderNameMode.ActionList,
|
||||
SenderNameMode.Reply -> ElementTheme.colors.textPrimary
|
||||
}
|
||||
Text(
|
||||
modifier = modifier.clipToBounds(),
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.weight(1f, fill = false),
|
||||
text = text,
|
||||
style = style,
|
||||
color = color,
|
||||
|
||||
+4
-1
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.ProfileDetails
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
|
||||
data class SenderNameData(
|
||||
val userId: UserId,
|
||||
@@ -47,12 +48,14 @@ open class SenderNameDataProvider : PreviewParameterProvider<SenderNameData> {
|
||||
private fun aSenderNameData(
|
||||
senderNameMode: SenderNameMode,
|
||||
displayNameAmbiguous: Boolean = false,
|
||||
displayedStatus: DisplayedStatus? = null,
|
||||
) = SenderNameData(
|
||||
userId = UserId("@alice:${senderNameMode.javaClass.simpleName.lowercase()}"),
|
||||
profileDetails = ProfileDetails.Ready(
|
||||
displayName = "Alice ${senderNameMode.javaClass.simpleName}",
|
||||
displayNameAmbiguous = displayNameAmbiguous,
|
||||
avatarUrl = null
|
||||
avatarUrl = null,
|
||||
displayedStatus = displayedStatus,
|
||||
),
|
||||
senderNameMode = senderNameMode,
|
||||
)
|
||||
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.ui.model
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
fun DisplayedStatus.toEmojiText(): String = when (this) {
|
||||
is DisplayedStatus.UserSet -> status.emoji
|
||||
is DisplayedStatus.InCall -> "🎧"
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun DisplayedStatus.toText(): String = when (this) {
|
||||
is DisplayedStatus.UserSet -> "${status.emoji} ${status.text}"
|
||||
is DisplayedStatus.InCall -> "🎧 ${stringResource(CommonStrings.common_on_a_call)}"
|
||||
}
|
||||
+12
@@ -13,6 +13,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomInfo
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
|
||||
fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData(
|
||||
id = id.value,
|
||||
@@ -45,3 +46,14 @@ fun RoomInfo.roleOf(userId: UserId): RoomMember.Role {
|
||||
val powerLevel = powerLevelOf(userId = userId)
|
||||
return RoomMember.Role.forPowerLevel(powerLevel)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the [DisplayedStatus] of the other member in a DM.
|
||||
*/
|
||||
fun RoomInfo.dmUserStatus(): DisplayedStatus? {
|
||||
return if (isDm) {
|
||||
heroes.firstOrNull()?.displayedStatus
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -21,6 +21,7 @@ import io.element.android.libraries.designsystem.preview.USER_NAME_WALTER
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMember
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipState
|
||||
import io.element.android.libraries.matrix.api.user.DisplayedStatus
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
fun aRoomMember(
|
||||
@@ -34,6 +35,7 @@ fun aRoomMember(
|
||||
role: RoomMember.Role = RoomMember.Role.User,
|
||||
membershipChangeReason: String? = null,
|
||||
isServiceMember: Boolean = false,
|
||||
displayedStatus: DisplayedStatus? = null,
|
||||
) = RoomMember(
|
||||
userId = userId,
|
||||
displayName = displayName,
|
||||
@@ -45,6 +47,7 @@ fun aRoomMember(
|
||||
role = role,
|
||||
membershipChangeReason = membershipChangeReason,
|
||||
isServiceMember = isServiceMember,
|
||||
displayedStatus = displayedStatus,
|
||||
)
|
||||
|
||||
fun aRoomMemberList() = persistentListOf(
|
||||
|
||||
-20
@@ -1,20 +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.libraries.recentemojis.impl
|
||||
|
||||
import android.content.Context
|
||||
import io.element.android.emojibasebindings.EmojibaseDatasource
|
||||
import io.element.android.emojibasebindings.EmojibaseStore
|
||||
import io.element.android.libraries.recentemojis.api.EmojibaseProvider
|
||||
|
||||
class DefaultEmojibaseProvider(val context: Context) : EmojibaseProvider {
|
||||
override val emojibaseStore: EmojibaseStore by lazy {
|
||||
EmojibaseDatasource().load(context)
|
||||
}
|
||||
}
|
||||
@@ -1,26 +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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.recentemojis.test"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(projects.libraries.matrix.api)
|
||||
api(libs.coroutines.core)
|
||||
|
||||
implementation(libs.kotlinx.collections.immutable)
|
||||
implementation(libs.coroutines.test)
|
||||
implementation(projects.tests.testutils)
|
||||
implementation(projects.libraries.recentemojis.api)
|
||||
implementation(libs.matrix.emojibase.bindings)
|
||||
}
|
||||
+19
-9
@@ -39,11 +39,15 @@ class MentionSpan(
|
||||
private var typeface: Typeface = Typeface.DEFAULT
|
||||
|
||||
private var measuredTextWidth = 0
|
||||
private var measuredTrailingWidth = 0
|
||||
|
||||
// The formatted display text, will be set by the formatter
|
||||
var displayText: CharSequence = ""
|
||||
private set
|
||||
|
||||
var trailingText: String? = null
|
||||
private set
|
||||
|
||||
/**
|
||||
* Updates the visual properties of this span.
|
||||
*/
|
||||
@@ -80,6 +84,10 @@ class MentionSpan(
|
||||
displayText = formatter.formatDisplayText(type)
|
||||
}
|
||||
|
||||
fun updateTrailingText(trailingText: String?) {
|
||||
this.trailingText = trailingText
|
||||
}
|
||||
|
||||
override fun getSize(
|
||||
paint: Paint,
|
||||
text: CharSequence?,
|
||||
@@ -91,7 +99,8 @@ class MentionSpan(
|
||||
textPaint.typeface = typeface
|
||||
// Measure the full text width without truncation
|
||||
measuredTextWidth = textPaint.measureText(displayText, 0, displayText.length).roundToInt()
|
||||
return measuredTextWidth + startPadding + endPadding
|
||||
measuredTrailingWidth = trailingText?.let { textPaint.measureText(" $it").roundToInt() } ?: 0
|
||||
return measuredTextWidth + measuredTrailingWidth + startPadding + endPadding
|
||||
}
|
||||
|
||||
override fun draw(
|
||||
@@ -106,7 +115,8 @@ class MentionSpan(
|
||||
paint: Paint
|
||||
) {
|
||||
val availableWidth = (canvas.width - x).coerceAtLeast(0f)
|
||||
val measuredWidth = measuredTextWidth + startPadding + endPadding
|
||||
|
||||
val measuredWidth = measuredTextWidth + measuredTrailingWidth + startPadding + endPadding
|
||||
val pillWidth = minOf(availableWidth, measuredWidth.toFloat())
|
||||
|
||||
backgroundPaint.color = backgroundColor
|
||||
@@ -118,18 +128,18 @@ class MentionSpan(
|
||||
textPaint.color = textColor
|
||||
textPaint.typeface = typeface
|
||||
|
||||
val availableWidthForText = availableWidth - startPadding - endPadding
|
||||
val availableWidthForText = availableWidth - startPadding - endPadding - measuredTrailingWidth
|
||||
val textToDraw = if (measuredTextWidth > availableWidthForText) {
|
||||
TextUtils.ellipsize(
|
||||
displayText,
|
||||
textPaint,
|
||||
availableWidthForText,
|
||||
TextUtils.TruncateAt.END
|
||||
)
|
||||
TextUtils.ellipsize(displayText, textPaint, availableWidthForText, TextUtils.TruncateAt.END)
|
||||
} else {
|
||||
displayText
|
||||
}
|
||||
canvas.drawText(textToDraw, 0, textToDraw.length, x + startPadding, y.toFloat(), textPaint)
|
||||
|
||||
trailingText?.let {
|
||||
val drawnTextWidth = minOf(availableWidthForText, measuredTextWidth.toFloat())
|
||||
canvas.drawText(" $it", x + startPadding + drawnTextWidth, y.toFloat(), textPaint)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -18,6 +18,7 @@ import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.libraries.di.RoomScope
|
||||
import io.element.android.libraries.matrix.ui.messages.RoomMemberProfilesCache
|
||||
import io.element.android.libraries.matrix.ui.messages.RoomNamesCache
|
||||
import io.element.android.libraries.matrix.ui.model.toEmojiText
|
||||
|
||||
interface MentionSpanUpdater {
|
||||
fun updateMentionSpans(text: CharSequence): CharSequence
|
||||
@@ -48,6 +49,13 @@ class DefaultMentionSpanUpdater(
|
||||
for (mentionSpan in text.getMentionSpans()) {
|
||||
mentionSpan.updateTheme(theme)
|
||||
mentionSpan.updateDisplayText(formatter)
|
||||
mentionSpan.updateTrailingText(
|
||||
if (mentionSpan.type is MentionType.User) {
|
||||
roomMemberProfilesCache.getDisplayedStatus(mentionSpan.type.userId)?.toEmojiText()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -321,7 +321,6 @@ Segur que vols continuar?"</string>
|
||||
<string name="dialog_unsaved_changes_description">"Hi ha canvis sense desar."</string>
|
||||
<string name="dialog_unsaved_changes_description_android">"Els canvis no s\'han desat. Segur que vols tornar enrere?"</string>
|
||||
<string name="dialog_unsaved_changes_title">"Desar canvis?"</string>
|
||||
<string name="emoji_picker_search_placeholder">"Cerca emoticones"</string>
|
||||
<string name="error_account_creation_not_possible">"El servidor utilitzat s\'ha d\'actualitzar per admetre el servei d\'autenticació de Matrix i la creació de comptes."</string>
|
||||
<string name="error_failed_creating_the_permalink">"No s\'ha pogut crear l\'enllaç permanent"</string>
|
||||
<string name="error_failed_loading_map">"%1$s no ha pogut carregar el mapa. Torna-ho a provar més tard."</string>
|
||||
|
||||
@@ -452,7 +452,6 @@ Opravdu chcete pokračovat?"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_file_size">"Maximální povolená velikost souboru je: %1$s"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Vyberte kvalitu videa, které chcete nahrát."</string>
|
||||
<string name="dialog_video_quality_selector_title">"Vyberte kvalitu nahrávání videa"</string>
|
||||
<string name="emoji_picker_search_placeholder">"Hledat emotikony"</string>
|
||||
<string name="error_account_already_logged_in">"Na tomto zařízení jste již přihlášeni jako %1$s."</string>
|
||||
<string name="error_account_creation_not_possible">"Váš domovský server je třeba upgradovat, aby podporoval službu Matrix Authentication Service a vytváření účtu."</string>
|
||||
<string name="error_failed_creating_the_permalink">"Vytvoření trvalého odkazu se nezdařilo"</string>
|
||||
|
||||
@@ -416,7 +416,6 @@ Ydych chi\'n siŵr eich bod am barhau?"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_file_size">"Y maint ffeil mwyaf sy\'n cael ei ganiatáu yw: %1$s"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Dewiswch ansawdd y fideo rydych chi am ei llwytho."</string>
|
||||
<string name="dialog_video_quality_selector_title">"Dewiswch ansawdd llwytho fideo"</string>
|
||||
<string name="emoji_picker_search_placeholder">"Chwilio emojis"</string>
|
||||
<string name="error_account_already_logged_in">"Rydych chi eisoes wedi mewngofnodi ar y ddyfais hon fel %1$s ."</string>
|
||||
<string name="error_account_creation_not_possible">"Mae angen uwchraddio eich gweinydd cartref i gefnogi Gwasanaeth Dilysu Matrix a chreu cyfrif."</string>
|
||||
<string name="error_failed_creating_the_permalink">"Wedi methu creu\'r ddolen barhaol"</string>
|
||||
|
||||
@@ -440,7 +440,6 @@ Er du sikker på, at du vil fortsætte?"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_file_size">"Den maksimalt tilladte filstørrelse er: %1$s"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Vælg den kvalitet, du ønsker at uploade videoen i."</string>
|
||||
<string name="dialog_video_quality_selector_title">"Vælg kvalitet for video-overførsel"</string>
|
||||
<string name="emoji_picker_search_placeholder">"Søg emojis"</string>
|
||||
<string name="error_account_already_logged_in">"Du er allerede logget ind på denne enhed som %1$s."</string>
|
||||
<string name="error_account_creation_not_possible">"Din hjemmeserver skal opgraderes for at understøtte Matrix Authentication Service og kontooprettelse."</string>
|
||||
<string name="error_failed_creating_the_permalink">"Oprettelse af permalink mislykkedes"</string>
|
||||
|
||||
@@ -433,7 +433,6 @@ Möchtest du wirklich fortfahren?"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_file_size">"Die maximal erlaubte Dateigröße ist: %1$s"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Wähle die Qualität des Videos, das du hochladen möchtest."</string>
|
||||
<string name="dialog_video_quality_selector_title">"Wähle die Video-Upload-Qualität"</string>
|
||||
<string name="emoji_picker_search_placeholder">"Emojis suchen"</string>
|
||||
<string name="error_account_already_logged_in">"Du bist auf diesem Gerät bereits als %1$s angemeldet."</string>
|
||||
<string name="error_account_creation_not_possible">"Dein Homeserver muss aktualisiert werden, um den Matrix Authentication Services und die Erstellung von Konten zu unterstützen."</string>
|
||||
<string name="error_failed_creating_the_permalink">"Fehler beim Erstellen des Permalinks"</string>
|
||||
|
||||
@@ -434,7 +434,6 @@
|
||||
<string name="dialog_video_quality_selector_subtitle_file_size">"Το μέγιστο επιτρεπόμενο μέγεθος αρχείου είναι: %1$s"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Επιλέξτε την ποιότητα του βίντεο που θέλετε να ανεβάσετε."</string>
|
||||
<string name="dialog_video_quality_selector_title">"Επιλέξτε την ποιότητα μεταφόρτωσης βίντεο"</string>
|
||||
<string name="emoji_picker_search_placeholder">"Αναζήτηση emoji"</string>
|
||||
<string name="error_account_already_logged_in">"Έχετε ήδη συνδεθεί σε αυτή τη συσκευή ως %1$s."</string>
|
||||
<string name="error_account_creation_not_possible">"Ο οικιακός διακομιστής σου πρέπει να αναβαθμιστεί για να υποστηρίζει το Matrix Authentication Service και τη δημιουργία λογαριασμού."</string>
|
||||
<string name="error_failed_creating_the_permalink">"Αποτυχία δημιουργίας του μόνιμου συνδέσμου"</string>
|
||||
|
||||
@@ -477,7 +477,6 @@ Kas sa oled kindel, et soovid jätkata?"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_file_size">"Suurim lubatud failisuurus on: %1$s"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Vali üleslaaditava video kvaliteet."</string>
|
||||
<string name="dialog_video_quality_selector_title">"Vali video kvaliteet."</string>
|
||||
<string name="emoji_picker_search_placeholder">"Otsi emojisid"</string>
|
||||
<string name="error_account_already_logged_in">"Sa juba oled sellesse seadmesse sisseloginud kasutajana %1$s."</string>
|
||||
<string name="error_account_creation_not_possible">"Selleks et koos kasutajakonto loomisega toimiks Matrix Authentication Service\'i tugi, vajab sinu koduserver uuendamist."</string>
|
||||
<string name="error_failed_creating_the_permalink">"Püsilingi loomine ei õnnestumud"</string>
|
||||
|
||||
@@ -348,7 +348,6 @@
|
||||
<string name="dialog_unsaved_changes_description">"تغییراتی ذخیره نشده دارید."</string>
|
||||
<string name="dialog_unsaved_changes_description_android">"تغییراتتان ذخیره نشدهاند. مطمئنید که میخواهید برگردید؟"</string>
|
||||
<string name="dialog_unsaved_changes_title">"ذخیرهٔ تغییرات؟"</string>
|
||||
<string name="emoji_picker_search_placeholder">"حستوجوی شکلکها"</string>
|
||||
<string name="error_failed_creating_the_permalink">"شکست در ایجاد پایاپیوند"</string>
|
||||
<string name="error_failed_loading_map">"%1$s نتوانست نقشه را بارگیری کند. لطفا بعدا دوباره امتحان کنید."</string>
|
||||
<string name="error_failed_loading_messages">"شکست در بار کردن پیامها"</string>
|
||||
|
||||
@@ -477,7 +477,6 @@ Haluatko varmasti jatkaa?"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_file_size">"Suurin sallittu tiedostokoko on: %1$s"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Valitse lähetettävän videon laatu."</string>
|
||||
<string name="dialog_video_quality_selector_title">"Valitse videon lähetyslaatu"</string>
|
||||
<string name="emoji_picker_search_placeholder">"Etsi emojeja"</string>
|
||||
<string name="error_account_already_logged_in">"Olet jo kirjautuneena tälle laitteelle käyttäjällä %1$s."</string>
|
||||
<string name="error_account_creation_not_possible">"Kotipalvelimesi on päivitettävä tukemaan Matrix Authentication Serviceä ja tilin luomista."</string>
|
||||
<string name="error_failed_creating_the_permalink">"Pysyvän linkin luominen epäonnistui"</string>
|
||||
|
||||
@@ -452,7 +452,6 @@ Raison : %1$s."</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_file_size">"La taille maximale de fichier autorisée est: %1$s"</string>
|
||||
<string name="dialog_video_quality_selector_subtitle_no_file_size">"Sélectionnez la qualité des vidéos que vous souhaitez envoyer."</string>
|
||||
<string name="dialog_video_quality_selector_title">"Sélectionnez la qualité d’envoi des vidéos"</string>
|
||||
<string name="emoji_picker_search_placeholder">"Chercher des emojis"</string>
|
||||
<string name="error_account_already_logged_in">"Vous êtes déjà connecté sur cet appareil en tant que %1$s."</string>
|
||||
<string name="error_account_creation_not_possible">"Votre serveur d’accueil doit être mis à jour pour prendre en charge le protocole MAS (Matrix Authentication Service) et la création de compte."</string>
|
||||
<string name="error_failed_creating_the_permalink">"Échec de la création du permalien"</string>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user