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:
ganfra
2026-07-23 16:24:11 +02:00
347 changed files with 2066 additions and 1252 deletions
@@ -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)
}
@@ -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
}
}
@@ -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
}
@@ -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
}
@@ -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>
@@ -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())
}
@@ -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)
}
@@ -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()
}
@@ -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
}
@@ -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())
}
}
@@ -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}")
}
}
}
@@ -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>,
)
@@ -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(),
)
@@ -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("👍🏾"),
)
}
}
@@ -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
}
@@ -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(),
)
}
@@ -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
}
@@ -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", "👍🏿"),
)
@@ -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)
@@ -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">"Ежа &amp; Напоі"</string>
<string name="emoji_picker_category_nature">"Жывёлы &amp; Прырода"</string>
<string name="emoji_picker_category_objects">"Аб\'екты"</string>
<string name="emoji_picker_category_people">"Усмешкі &amp; Удзельнікі"</string>
<string name="emoji_picker_category_places">"Падарожжы &amp; Месцы"</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">"Храна &amp; Напитки"</string>
<string name="emoji_picker_category_nature">"Животни &amp; Природа"</string>
<string name="emoji_picker_category_objects">"Обекти"</string>
<string name="emoji_picker_category_people">"Усмивки &amp; Хора"</string>
<string name="emoji_picker_category_places">"Пътуване &amp; Места"</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 &amp; 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 &amp; Trinken"</string>
<string name="emoji_picker_category_nature">"Tiere &amp; Natur"</string>
<string name="emoji_picker_category_objects">"Objekte"</string>
<string name="emoji_picker_category_people">"Smileys &amp; Menschen"</string>
<string name="emoji_picker_category_places">"Reisen &amp; 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">"Φαγητό &amp; Ποτό"</string>
<string name="emoji_picker_category_nature">"Ζώα &amp; Φύση"</string>
<string name="emoji_picker_category_objects">"Αντικείμενα"</string>
<string name="emoji_picker_category_people">"Φατσούλες &amp; Άνθρωποι"</string>
<string name="emoji_picker_category_places">"Ταξίδια &amp; Μέρη"</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 &amp; 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 &amp; Minuman"</string>
<string name="emoji_picker_category_nature">"Hewan &amp; Alam"</string>
<string name="emoji_picker_category_objects">"Objek"</string>
<string name="emoji_picker_category_people">"Senyuman &amp; Orang"</string>
<string name="emoji_picker_category_places">"Wisata &amp; 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 &amp; Bevande"</string>
<string name="emoji_picker_category_nature">"Animali &amp; Natura"</string>
<string name="emoji_picker_category_objects">"Oggetti"</string>
<string name="emoji_picker_category_people">"Faccine &amp; Persone"</string>
<string name="emoji_picker_category_places">"Viaggi &amp; 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">"ცხოველები &amp; ბუნება"</string>
<string name="emoji_picker_category_objects">"ობიექტები"</string>
<string name="emoji_picker_category_people">"ღიმილები &amp; ხალხი"</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">"음식 &amp; 음료"</string>
<string name="emoji_picker_category_nature">"동물 &amp; 자연"</string>
<string name="emoji_picker_category_objects">"사물"</string>
<string name="emoji_picker_category_people">"표정 &amp; 사람"</string>
<string name="emoji_picker_category_places">"여행 &amp; 장소"</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 &amp; Drinken"</string>
<string name="emoji_picker_category_nature">"Dieren &amp; Natuur"</string>
<string name="emoji_picker_category_objects">"Voorwerpen"</string>
<string name="emoji_picker_category_people">"Smileys &amp; Mensen"</string>
<string name="emoji_picker_category_places">"Reizen &amp; 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 &amp; Bebida"</string>
<string name="emoji_picker_category_nature">"Animais &amp; Natureza"</string>
<string name="emoji_picker_category_objects">"Objetos"</string>
<string name="emoji_picker_category_people">"Sorrisos &amp; Pessoas"</string>
<string name="emoji_picker_category_places">"Viagens &amp; 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 &amp; 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 &amp; Oameni"</string>
<string name="emoji_picker_category_places">"Călătorii &amp; 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 &amp; dryck"</string>
<string name="emoji_picker_category_nature">"Djur &amp; natur"</string>
<string name="emoji_picker_category_objects">"Föremål"</string>
<string name="emoji_picker_category_people">"Smileys &amp; personer"</string>
<string name="emoji_picker_category_places">"Resor &amp; 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 &amp; 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 &amp; Drink"</string>
<string name="emoji_picker_category_nature">"Animals &amp; Nature"</string>
<string name="emoji_picker_category_objects">"Objects"</string>
<string name="emoji_picker_category_people">"Smileys &amp; People"</string>
<string name="emoji_picker_category_places">"Travel &amp; 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,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
}
@@ -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
}
@@ -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
+19
View File
@@ -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)
}
@@ -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 }
@@ -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
@@ -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].
@@ -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(
@@ -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)")
}
@@ -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,
)
}
@@ -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,
)
}
}
@@ -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(
@@ -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(
@@ -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,
)
@@ -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,
)
}
}
}
@@ -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()) {
@@ -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(
@@ -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,
@@ -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(
@@ -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
}
}
@@ -167,4 +167,5 @@ fun aProfileDetailsReady(
displayName = displayName,
displayNameAmbiguous = displayNameAmbiguous,
avatarUrl = avatarUrl,
displayedStatus = null,
)
@@ -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,
@@ -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,
)
@@ -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)}"
}
@@ -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
}
}
@@ -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(
@@ -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)
}
@@ -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)
}
}
}
@@ -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