Skip to content

Commit

Permalink
Add sending freeform reactions
Browse files Browse the repository at this point in the history
Signed-off-by: Joe Groocock <[email protected]>
  • Loading branch information
frebib committed May 21, 2024
1 parent 6fa81b8 commit 7ae884b
Show file tree
Hide file tree
Showing 9 changed files with 118 additions and 59 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.api.core.EventId

sealed interface MessagesEvents {
data class HandleAction(val action: TimelineItemAction, val event: TimelineItem.Event) : MessagesEvents
data class ToggleReaction(val emoji: String, val eventId: EventId) : MessagesEvents
data class ToggleReaction(val reaction: String, val eventId: EventId) : MessagesEvents
data class InviteDialogDismissed(val action: InviteDialogAction) : MessagesEvents
data object Dismiss : MessagesEvents
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,7 +196,7 @@ class MessagesPresenter @AssistedInject constructor(
)
}
is MessagesEvents.ToggleReaction -> {
localCoroutineScope.toggleReaction(event.emoji, event.eventId)
localCoroutineScope.toggleReaction(event.reaction, event.eventId)
}
is MessagesEvents.InviteDialogDismissed -> {
hasDismissedInviteDialog = true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,9 @@ fun MessagesView(
state = state.customReactionState,
onEmojiSelected = { eventId, emoji ->
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
},
onReactionSelected = { eventId, reaction ->
state.eventSink(MessagesEvents.ToggleReaction(reaction, eventId))
}
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -111,12 +111,16 @@ fun MessagesReactionButton(

@Immutable
sealed interface MessagesReactionsButtonContent {
data class Text(val text: String) : MessagesReactionsButtonContent
data class Text(val text: String, val highlight: Boolean = false) : MessagesReactionsButtonContent
data class Icon(@DrawableRes val resourceId: Int) : MessagesReactionsButtonContent

data class Reaction(val reaction: AggregatedReaction) : MessagesReactionsButtonContent

val isHighlighted get() = this is Reaction && reaction.isHighlighted
val isHighlighted get() = when(this) {
is Reaction -> reaction.isHighlighted
is Text -> highlight
else -> false
}
}

internal val REACTION_EMOJI_LINE_HEIGHT = 20.sp
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import io.element.android.libraries.matrix.api.core.EventId
fun CustomReactionBottomSheet(
state: CustomReactionState,
onEmojiSelected: (EventId, Emoji) -> Unit,
onReactionSelected: (EventId, String) -> Unit,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = state.searchState.startActive)
Expand All @@ -62,6 +63,14 @@ fun CustomReactionBottomSheet(
}
}

fun onReactionSelectedDismiss(reaction: String) {
if (target?.event?.eventId == null) return
sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
onReactionSelected(target.event.eventId, reaction)
}
}

if (target?.emojibaseStore != null && target.event.eventId != null) {
ModalBottomSheet(
onDismissRequest = ::onDismiss,
Expand All @@ -80,6 +89,7 @@ fun CustomReactionBottomSheet(
) {
EmojiPicker(
onEmojiSelected = ::onEmojiSelectedDismiss,
onReactionSelected = ::onReactionSelectedDismiss,
emojibaseStore = target.emojibaseStore,
selectedEmojis = state.selectedEmoji,
state = state.searchState,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ class CustomReactionPresenter @Inject constructor(
return CustomReactionState(
target = target.value,
selectedEmoji = selectedEmoji,
eventSink = { handleEvents(it) },
eventSink = ::handleEvents,
searchState = searchState,
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.compose.foundation.interaction.collectIsFocusedAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxSize
Expand All @@ -45,6 +46,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
Expand All @@ -62,16 +64,20 @@ import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.emojibasebindings.EmojibaseDatasource
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.emojibasebindings.allEmojis
import io.element.android.features.messages.impl.timeline.components.MessagesReactionButton
import io.element.android.features.messages.impl.timeline.components.MessagesReactionsButtonContent
import io.element.android.features.messages.impl.timeline.model.MAX_REACTION_LENGTH_CHARS
import io.element.android.libraries.core.extensions.ellipsize
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.ElementSearchBarDefaults
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.SearchBarResultState
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch
Expand All @@ -80,6 +86,7 @@ import kotlinx.coroutines.launch
@Composable
fun EmojiPicker(
onEmojiSelected: (Emoji) -> Unit,
onReactionSelected: (String) -> Unit,
emojibaseStore: EmojibaseStore,
selectedEmojis: ImmutableSet<String>,
state: EmojiPickerState,
Expand Down Expand Up @@ -111,54 +118,56 @@ fun EmojiPicker(
}
}
) {
if (state.searchQuery.isEmpty()) {
SecondaryTabRow(
selectedTabIndex = pagerState.currentPage,
) {
EmojibaseCategory.entries.forEachIndexed { index, category ->
Tab(icon = {
Icon(
imageVector = category.icon, contentDescription = stringResource(id = category.title)
)
}, selected = pagerState.currentPage == index, onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
})
}
}

HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
) { index ->
val category = EmojibaseCategory.entries[index]
val emojis = categories[category] ?: listOf()
EmojiGrid(emojis = emojis, selectedEmojis = selectedEmojis, onEmojiSelected = onEmojiSelected)
}
} else {
when (state.searchResults) {
is SearchBarResultState.Results<ImmutableList<Emoji>> -> {
EmojiGrid(
emojis = state.searchResults.results,
selectedEmojis = selectedEmojis,
onEmojiSelected = onEmojiSelected,
)
when (state.searchResults) {
is SearchBarResultState.Initial -> {
SecondaryTabRow(
selectedTabIndex = pagerState.currentPage,
) {
EmojibaseCategory.entries.forEachIndexed { index, category ->
Tab(icon = {
Icon(
imageVector = category.icon, contentDescription = stringResource(id = category.title)
)
}, selected = pagerState.currentPage == index, onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
})
}
}

is SearchBarResultState.NoResultsFound<ImmutableList<Emoji>> -> {
// No results found, show a message
Spacer(Modifier.size(80.dp))

Text(
text = stringResource(CommonStrings.common_no_results),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
) { index ->
val category = EmojibaseCategory.entries[index]
val emojis = categories[category] ?: listOf()
EmojiGrid(emojis = emojis, selectedEmojis = selectedEmojis, onEmojiSelected = onEmojiSelected)
}
}
is SearchBarResultState.Results -> {
FreeformReaction(
searchQuery = state.searchQuery,
onReactionSelected = onReactionSelected
)
EmojiGrid(
emojis = state.searchResults.results,
selectedEmojis = selectedEmojis,
onEmojiSelected = onEmojiSelected,
)
}
is SearchBarResultState.NoResultsFound -> {
FreeformReaction(
searchQuery = state.searchQuery,
onReactionSelected = onReactionSelected
)
// No results found, show a message
Spacer(Modifier.size(80.dp))

else -> {
// Not searching - nothing to show.
}
Text(
text = stringResource(CommonStrings.common_no_results),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
}
}
}
Expand Down Expand Up @@ -224,9 +233,9 @@ private fun EmojiPickerSearchBar(
trailingIcon = when {
query.isNotEmpty() -> {
{
IconButton(onClick = {
onQueryChange("")
}) {
IconButton(
onClick = { onQueryChange("") },
) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.action_clear),
Expand Down Expand Up @@ -267,11 +276,41 @@ private fun EmojiPickerSearchBar(
}
}

@Composable
private fun FreeformReaction(
searchQuery: String,
onReactionSelected: (String) -> Unit
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalAlignment = Alignment.CenterVertically
) {
val reaction = searchQuery.trim()

Text(text = "Tap to react with ")
MessagesReactionButton(
content = MessagesReactionsButtonContent.Text(
text = reaction.ellipsize(MAX_REACTION_LENGTH_CHARS),
highlight = true,
),
onClick = { onReactionSelected(reaction) },
onLongClick = {},
)
}
HorizontalDivider(
modifier = Modifier
.padding(top = 12.dp, bottom = 4.dp)
.fillMaxWidth()
)
}

@PreviewsDayNight
@Composable
internal fun EmojiPickerPreview() = ElementPreview {
EmojiPicker(
onEmojiSelected = {},
onReactionSelected = {},
emojibaseStore = EmojibaseDatasource().load(LocalContext.current),
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
state = EmojiPickerState(
Expand All @@ -291,6 +330,7 @@ internal fun EmojiPickerSearchPreview() = ElementPreview {
val query = "grin"
EmojiPicker(
onEmojiSelected = {},
onReactionSelected = {},
emojibaseStore = emojibaseStore,
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
state = EmojiPickerState(
Expand All @@ -307,9 +347,11 @@ internal fun EmojiPickerSearchPreview() = ElementPreview {
@Composable
internal fun EmojiPickerSearchNoMatchPreview() = ElementPreview {
val emojibaseStore = EmojibaseDatasource().load(LocalContext.current)
val query = "this is a very long string that won't match anything"
// padded with whitespace to test that it's trimmed
val query = " this is a very long string that won't match anything "
EmojiPicker(
onEmojiSelected = {},
onReactionSelected = {},
emojibaseStore = emojibaseStore,
selectedEmojis = persistentSetOf("😀", "😄", "😃"),
state = EmojiPickerState(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -72,10 +72,10 @@ class EmojiPickerStatePresenter @Inject constructor(
}

fun searchEmojis(searchQuery: String, allEmojis: List<Emoji>): SearchBarResultState<List<Emoji>> {
if (searchQuery == "")
val query = searchQuery.trim()
if (query == "")
return SearchBarResultState.Initial()

val query = searchQuery.trim()
val matches = allEmojis.filter { emoji ->
emoji.unicode == query
|| emoji.label.contains(query, true)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import io.element.android.libraries.matrix.api.core.UserId
* Reactions can be free text, so we need to limit the length
* displayed on screen.
*/
private const val MAX_DISPLAY_CHARS = 16
internal const val MAX_REACTION_LENGTH_CHARS = 16

/**
* @property currentUserId the ID of the currently logged in user
Expand All @@ -40,10 +40,10 @@ data class AggregatedReaction(
/**
* The key to be displayed on screen.
*
* See [MAX_DISPLAY_CHARS].
* See [MAX_REACTION_LENGTH_CHARS].
*/
val displayKey: String by lazy {
key.ellipsize(MAX_DISPLAY_CHARS)
key.ellipsize(MAX_REACTION_LENGTH_CHARS)
}

/**
Expand Down

0 comments on commit 7ae884b

Please sign in to comment.