Skip to content

Commit bdb9acf

Browse files
Add emoji search to the reaction emoji picker (#5255)
* Add emoji search to the reaction emoji picker * Update screenshots * Fix tests and lint issues. Fixing the tests required addressing some underlying issues in `SearchBar` --------- Co-authored-by: ElementBot <[email protected]>
1 parent a2dd455 commit bdb9acf

18 files changed

+395
-121
lines changed

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,12 @@ import androidx.compose.foundation.layout.fillMaxSize
1111
import androidx.compose.material3.ExperimentalMaterial3Api
1212
import androidx.compose.material3.rememberModalBottomSheetState
1313
import androidx.compose.runtime.Composable
14+
import androidx.compose.runtime.remember
1415
import androidx.compose.runtime.rememberCoroutineScope
1516
import androidx.compose.ui.Modifier
1617
import io.element.android.emojibasebindings.Emoji
18+
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPicker
19+
import io.element.android.features.messages.impl.timeline.components.customreaction.picker.EmojiPickerPresenter
1720
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
1821
import io.element.android.libraries.designsystem.theme.components.hide
1922
import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId
@@ -47,9 +50,10 @@ fun CustomReactionBottomSheet(
4750
sheetState = sheetState,
4851
modifier = modifier
4952
) {
53+
val presenter = remember { EmojiPickerPresenter(target.emojibaseStore) }
5054
EmojiPicker(
5155
onSelectEmoji = ::onEmojiSelectedDismiss,
52-
emojibaseStore = target.emojibaseStore,
56+
state = presenter.present(),
5357
selectedEmojis = state.selectedEmoji,
5458
modifier = Modifier.fillMaxSize(),
5559
)

features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt

Lines changed: 0 additions & 110 deletions
This file was deleted.
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
9+
10+
import androidx.compose.foundation.layout.Arrangement
11+
import androidx.compose.foundation.layout.Column
12+
import androidx.compose.foundation.layout.PaddingValues
13+
import androidx.compose.foundation.layout.WindowInsets
14+
import androidx.compose.foundation.layout.aspectRatio
15+
import androidx.compose.foundation.layout.fillMaxSize
16+
import androidx.compose.foundation.layout.fillMaxWidth
17+
import androidx.compose.foundation.layout.padding
18+
import androidx.compose.foundation.lazy.grid.GridCells
19+
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
20+
import androidx.compose.foundation.lazy.grid.items
21+
import androidx.compose.foundation.pager.HorizontalPager
22+
import androidx.compose.foundation.pager.rememberPagerState
23+
import androidx.compose.material3.ExperimentalMaterial3Api
24+
import androidx.compose.material3.SecondaryTabRow
25+
import androidx.compose.material3.Tab
26+
import androidx.compose.runtime.Composable
27+
import androidx.compose.runtime.rememberCoroutineScope
28+
import androidx.compose.ui.Modifier
29+
import androidx.compose.ui.res.stringResource
30+
import androidx.compose.ui.tooling.preview.PreviewParameter
31+
import androidx.compose.ui.unit.dp
32+
import io.element.android.emojibasebindings.Emoji
33+
import io.element.android.emojibasebindings.EmojibaseCategory
34+
import io.element.android.features.messages.impl.timeline.components.customreaction.EmojiItem
35+
import io.element.android.features.messages.impl.timeline.components.customreaction.icon
36+
import io.element.android.features.messages.impl.timeline.components.customreaction.title
37+
import io.element.android.libraries.designsystem.preview.ElementPreview
38+
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
39+
import io.element.android.libraries.designsystem.text.toSp
40+
import io.element.android.libraries.designsystem.theme.components.Icon
41+
import io.element.android.libraries.designsystem.theme.components.SearchBar
42+
import io.element.android.libraries.ui.strings.CommonStrings
43+
import kotlinx.collections.immutable.ImmutableSet
44+
import kotlinx.collections.immutable.persistentSetOf
45+
import kotlinx.coroutines.launch
46+
47+
@OptIn(ExperimentalMaterial3Api::class)
48+
@Composable
49+
fun EmojiPicker(
50+
onSelectEmoji: (Emoji) -> Unit,
51+
state: EmojiPickerState,
52+
selectedEmojis: ImmutableSet<String>,
53+
modifier: Modifier = Modifier,
54+
) {
55+
val coroutineScope = rememberCoroutineScope()
56+
val categories = state.categories
57+
val pagerState = rememberPagerState(pageCount = { EmojibaseCategory.entries.size })
58+
59+
Column(modifier) {
60+
SearchBar(
61+
modifier = Modifier.padding(bottom = 10.dp),
62+
query = state.searchQuery,
63+
onQueryChange = { state.eventSink(EmojiPickerEvents.UpdateSearchQuery(it)) },
64+
resultState = state.searchResults,
65+
active = state.isSearchActive,
66+
onActiveChange = { state.eventSink(EmojiPickerEvents.ToggleSearchActive(it)) },
67+
windowInsets = WindowInsets(0, 0, 0, 0),
68+
placeHolderTitle = stringResource(CommonStrings.emoji_picker_search_placeholder),
69+
) { results ->
70+
val emojis = results
71+
LazyVerticalGrid(
72+
modifier = Modifier.fillMaxSize(),
73+
columns = GridCells.Adaptive(minSize = 48.dp),
74+
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
75+
horizontalArrangement = Arrangement.spacedBy(8.dp),
76+
verticalArrangement = Arrangement.spacedBy(2.dp)
77+
) {
78+
items(emojis, key = { it.unicode }) { item ->
79+
SelectableEmojiItem(
80+
item = item,
81+
selectedEmojis = selectedEmojis,
82+
onSelectEmoji = onSelectEmoji,
83+
)
84+
}
85+
}
86+
}
87+
88+
if (!state.isSearchActive) {
89+
SecondaryTabRow(
90+
selectedTabIndex = pagerState.currentPage,
91+
) {
92+
EmojibaseCategory.entries.forEachIndexed { index, category ->
93+
Tab(
94+
icon = {
95+
Icon(
96+
imageVector = category.icon,
97+
contentDescription = stringResource(id = category.title)
98+
)
99+
},
100+
selected = pagerState.currentPage == index,
101+
onClick = {
102+
coroutineScope.launch { pagerState.animateScrollToPage(index) }
103+
}
104+
)
105+
}
106+
}
107+
108+
HorizontalPager(
109+
state = pagerState,
110+
modifier = Modifier.fillMaxWidth(),
111+
) { index ->
112+
val category = EmojibaseCategory.entries[index]
113+
val emojis = categories[category] ?: listOf()
114+
LazyVerticalGrid(
115+
modifier = Modifier.fillMaxSize(),
116+
columns = GridCells.Adaptive(minSize = 48.dp),
117+
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
118+
horizontalArrangement = Arrangement.spacedBy(8.dp),
119+
verticalArrangement = Arrangement.spacedBy(2.dp)
120+
) {
121+
items(emojis, key = { it.unicode }) { item ->
122+
SelectableEmojiItem(
123+
item = item,
124+
selectedEmojis = selectedEmojis,
125+
onSelectEmoji = onSelectEmoji,
126+
)
127+
}
128+
}
129+
}
130+
}
131+
}
132+
}
133+
134+
@Composable
135+
private fun SelectableEmojiItem(
136+
item: Emoji,
137+
selectedEmojis: ImmutableSet<String>,
138+
onSelectEmoji: (Emoji) -> Unit,
139+
) {
140+
EmojiItem(
141+
modifier = Modifier.aspectRatio(1f),
142+
item = item,
143+
isSelected = selectedEmojis.contains(item.unicode),
144+
onSelectEmoji = onSelectEmoji,
145+
emojiSize = 32.dp.toSp(),
146+
)
147+
}
148+
149+
@PreviewsDayNight
150+
@Composable
151+
internal fun EmojiPickerPreview(@PreviewParameter(EmojiPickerStateProvider::class) state: EmojiPickerState) = ElementPreview {
152+
EmojiPicker(
153+
onSelectEmoji = {},
154+
state = state,
155+
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
156+
modifier = Modifier.fillMaxWidth(),
157+
)
158+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
9+
10+
sealed interface EmojiPickerEvents {
11+
data class ToggleSearchActive(val isActive: Boolean) : EmojiPickerEvents
12+
data class UpdateSearchQuery(val query: String) : EmojiPickerEvents
13+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
/*
2+
* Copyright 2025 New Vector Ltd.
3+
*
4+
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5+
* Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
package io.element.android.features.messages.impl.timeline.components.customreaction.picker
9+
10+
import androidx.compose.runtime.Composable
11+
import androidx.compose.runtime.LaunchedEffect
12+
import androidx.compose.runtime.getValue
13+
import androidx.compose.runtime.mutableStateOf
14+
import androidx.compose.runtime.remember
15+
import androidx.compose.runtime.setValue
16+
import androidx.compose.ui.platform.LocalInspectionMode
17+
import io.element.android.emojibasebindings.Emoji
18+
import io.element.android.emojibasebindings.EmojibaseStore
19+
import io.element.android.libraries.architecture.Presenter
20+
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
21+
import kotlinx.collections.immutable.ImmutableList
22+
import kotlinx.collections.immutable.toImmutableList
23+
import kotlinx.coroutines.Dispatchers
24+
import kotlinx.coroutines.delay
25+
import kotlinx.coroutines.withContext
26+
import kotlin.time.Duration.Companion.milliseconds
27+
28+
class EmojiPickerPresenter(
29+
private val emojibaseStore: EmojibaseStore,
30+
) : Presenter<EmojiPickerState> {
31+
@Composable
32+
override fun present(): EmojiPickerState {
33+
var searchQuery by remember { mutableStateOf("") }
34+
var isSearchActive by remember { mutableStateOf(false) }
35+
var emojiResults by remember { mutableStateOf<SearchBarResultState<ImmutableList<Emoji>>>(SearchBarResultState.Initial()) }
36+
val categories = remember { emojibaseStore.categories }
37+
38+
LaunchedEffect(searchQuery) {
39+
emojiResults = if (searchQuery.isEmpty()) {
40+
SearchBarResultState.Initial()
41+
} else {
42+
// Add a small delay to avoid doing too many computations when the user is typing quickly
43+
delay(100.milliseconds)
44+
45+
val lowercaseQuery = searchQuery.lowercase()
46+
val results = withContext(Dispatchers.Default) {
47+
emojibaseStore.allEmojis
48+
.asSequence()
49+
.filter { emoji ->
50+
emoji.tags.orEmpty().any { it.contains(lowercaseQuery) } ||
51+
emoji.shortcodes.any { it.contains(lowercaseQuery) }
52+
}
53+
.take(60)
54+
.toImmutableList()
55+
}
56+
57+
SearchBarResultState.Results(results)
58+
}
59+
}
60+
61+
val isInPreview = LocalInspectionMode.current
62+
fun handleEvents(event: EmojiPickerEvents) {
63+
when (event) {
64+
// For some reason, in preview mode the SearchBar emits this event with an `isActive = true` value automatically
65+
is EmojiPickerEvents.ToggleSearchActive -> if (!isInPreview) {
66+
isSearchActive = event.isActive
67+
}
68+
is EmojiPickerEvents.UpdateSearchQuery -> searchQuery = event.query
69+
}
70+
}
71+
72+
return EmojiPickerState(
73+
categories = categories,
74+
searchQuery = searchQuery,
75+
isSearchActive = isSearchActive,
76+
searchResults = emojiResults,
77+
eventSink = ::handleEvents,
78+
)
79+
}
80+
}

0 commit comments

Comments
 (0)