Skip to content

Commit

Permalink
Suggest and track frequently used emoji reactions
Browse files Browse the repository at this point in the history
Change-Id: Ia5500017459bbd254f069ba0adb69aecfd3e67f8
  • Loading branch information
SpiritCroc committed Mar 30, 2024
1 parent 01eefc9 commit 2287bb6
Show file tree
Hide file tree
Showing 18 changed files with 320 additions and 48 deletions.
2 changes: 2 additions & 0 deletions FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,8 @@ Note that following list of changes compared to Element X is likely incomplete,
- Bigger stickers
- Differentiate notices from normal text messages by adding some transparency
- Render collapsible `<details>` tags in messages
- Suggest and record frequently used emoji reactions (synced with desktop clients via `io.element.recent_emoji` account data)

- Allow sending freeform reactions


Expand Down
1 change: 1 addition & 0 deletions features/messages/impl/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ dependencies {
implementation(projects.schildi.lib)
implementation(projects.schildi.components)
implementation(projects.schildi.theme)
implementation(projects.schildi.matrixsdk)
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
api(projects.features.messages.api)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.emojis.RecentEmojiDataSource
import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
Expand All @@ -52,6 +53,7 @@ class MessagesNode @AssistedInject constructor(
presenterFactory: MessagesPresenter.Factory,
private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
private val mediaPlayer: MediaPlayer,
private val recentEmojiDataSource: RecentEmojiDataSource,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
private val callback = plugins<Callback>().firstOrNull()
Expand Down Expand Up @@ -136,6 +138,7 @@ class MessagesNode @AssistedInject constructor(
val state = presenter.present()
MessagesView(
state = state,
recentEmojiDataSource = recentEmojiDataSource,
onBackPressed = this::navigateUp,
onRoomDetailsClicked = this::onRoomDetailsClicked,
onEventClicked = this::onEventClicked,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.emojis.RecentEmojiDataSource
import io.element.android.features.messages.impl.mentions.MentionSuggestionsPickerView
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
Expand Down Expand Up @@ -127,6 +128,7 @@ fun MessagesView(
onCreatePollClicked: () -> Unit,
onJoinCallClicked: () -> Unit,
modifier: Modifier = Modifier,
recentEmojiDataSource: RecentEmojiDataSource? = null,
forceJumpToBottomVisibility: Boolean = false
) {
OnLifecycleEvent { _, event ->
Expand Down Expand Up @@ -257,6 +259,7 @@ fun MessagesView(

CustomReactionBottomSheet(
state = state.customReactionState,
recentEmojiDataSource = recentEmojiDataSource,
onEmojiSelected = { eventId, emoji ->
state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId))
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.emojis.RecentEmojiDataSource
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
Expand All @@ -41,6 +42,7 @@ import javax.inject.Inject

class ActionListPresenter @Inject constructor(
private val appPreferencesStore: AppPreferencesStore,
private val recentEmojiDataSource: RecentEmojiDataSource,
) : Presenter<ActionListState> {
@Composable
override fun present(): ActionListState {
Expand Down Expand Up @@ -194,6 +196,7 @@ class ActionListPresenter @Inject constructor(
target.value = ActionListState.Target.Success(
event = timelineItem,
displayEmojiReactions = displayEmojiReactions,
recentEmojis = recentEmojiDataSource.getRecentEmojisSorted(),
actions = actions.toImmutableList()
)
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ data class ActionListState(
data class Success(
val event: TimelineItem.Event,
val displayEmojiReactions: Boolean,
val recentEmojis: List<String> = emptyList(),
val actions: ImmutableList<TimelineItemAction>,
) : Target
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ private fun SheetContent(
highlightedEmojis = target.event.reactionsState.highlightedKeys,
onEmojiReactionClicked = onEmojiReactionClicked,
onCustomReactionClicked = onCustomReactionClicked,
recentEmojis = target.recentEmojis,
modifier = Modifier.fillMaxWidth(),
)
HorizontalDivider()
Expand Down Expand Up @@ -296,20 +297,21 @@ private fun EmojiReactionsRow(
highlightedEmojis: ImmutableList<String>,
onEmojiReactionClicked: (String) -> Unit,
onCustomReactionClicked: () -> Unit,
recentEmojis: List<String>,
modifier: Modifier = Modifier,
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier.padding(horizontal = 24.dp, vertical = 16.dp)
) {
// TODO use most recently used emojis here when available from the Rust SDK
val defaultEmojis = sequenceOf(
val defaultEmojis = (recentEmojis.take(EMOJI_COUNT_QUICK_PICKER) + sequenceOf(
"👍️",
"👎️",
"🔥",
"❤️",
"👏"
)
)).take(EMOJI_COUNT_QUICK_PICKER)
for (emoji in defaultEmojis) {
val isHighlighted = highlightedEmojis.contains(emoji)
EmojiButton(emoji, isHighlighted, onEmojiReactionClicked)
Expand All @@ -333,7 +335,6 @@ private fun EmojiReactionsRow(
)
)
}
// TODO SC extension for quicker freeform reaction
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package io.element.android.features.messages.impl.actionlist

internal const val EMOJI_COUNT_QUICK_PICKER = 6
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package io.element.android.features.messages.impl.emojis

import chat.schildi.matrixsdk.ACCOUNT_DATA_RECENT_EMOJI
import chat.schildi.matrixsdk.RecentEmojiItem
import chat.schildi.matrixsdk.RecentEmojiSerializer
import chat.schildi.matrixsdk.isValidRecentEmoji
import chat.schildi.matrixsdk.recordSelection
import io.element.android.libraries.matrix.api.MatrixClient
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject

class RecentEmojiDataSource @Inject constructor(
private val client: MatrixClient,
) {

private suspend fun getCurrentRecentEmojis(): List<RecentEmojiItem> {
val result = client.getAccountData(ACCOUNT_DATA_RECENT_EMOJI)?.let { RecentEmojiSerializer.deserializeContent(it) }
if (result?.isFailure != false) {
Timber.w("Failed to load recent emojis: ${result?.exceptionOrNull()}")
return emptyList()
}
return result.getOrNull().orEmpty()
}

suspend fun getRecentEmojisSorted(): List<String> {
return getCurrentRecentEmojis().filter { isValidRecentEmoji(it.emoji) }.sortedByDescending { it.recurrences ?: 1L }.map { it.emoji }
}

suspend fun recordEmoji(emoji: String) {
if (!isValidRecentEmoji(emoji)) {
return
}
val data = getCurrentRecentEmojis().toMutableList()
data.recordSelection(emoji)
client.setAccountData(ACCOUNT_DATA_RECENT_EMOJI, RecentEmojiSerializer.serializeContent(data))
}

private val _recentEmojis = MutableStateFlow<ImmutableList<String>>(persistentListOf())
val recentEmojis: StateFlow<ImmutableList<String>> = _recentEmojis

fun refresh(coroutineScope: CoroutineScope) {
coroutineScope.launch {
_recentEmojis.emit(getRecentEmojisSorted().toImmutableList())
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import androidx.compose.ui.Modifier
import chat.schildi.lib.preferences.ScPrefs
import chat.schildi.lib.preferences.value
import io.element.android.emojibasebindings.Emoji
import io.element.android.features.messages.impl.emojis.RecentEmojiDataSource
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet
import io.element.android.libraries.designsystem.theme.components.hide
import io.element.android.libraries.matrix.api.core.EventId
Expand All @@ -35,6 +36,7 @@ fun CustomReactionBottomSheet(
state: CustomReactionState,
onEmojiSelected: (EventId, Emoji) -> Unit,
onCustomEmojiSelected: (EventId, String) -> Unit,
recentEmojiDataSource: RecentEmojiDataSource?,
modifier: Modifier = Modifier,
) {
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = ScPrefs.PREFER_FULLSCREEN_REACTION_SHEET.value())
Expand All @@ -47,17 +49,27 @@ fun CustomReactionBottomSheet(

fun onEmojiSelectedDismiss(emoji: Emoji) {
if (target?.event?.eventId == null) return

val wasSelected = state.selectedEmoji.contains(emoji.unicode)

sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
onEmojiSelected(target.event.eventId, emoji)

if (!wasSelected) recentEmojiDataSource?.recordEmoji(emoji.unicode)
}
}

fun onCustomEmojiSelectedDismiss(emoji: String) {
if (target?.event?.eventId == null) return

val wasSelected = state.selectedEmoji.contains(emoji)

sheetState.hide(coroutineScope) {
state.eventSink(CustomReactionEvents.DismissCustomReactionSheet)
onCustomEmojiSelected(target.event.eventId, emoji)

if (!wasSelected) recentEmojiDataSource?.recordEmoji(emoji)
}
}

Expand All @@ -72,6 +84,7 @@ fun CustomReactionBottomSheet(
onCustomEmojiSelected = ::onCustomEmojiSelectedDismiss,
emojibaseStore = target.emojibaseStore,
selectedEmojis = state.selectedEmoji,
recentEmojiDataSource = recentEmojiDataSource,
modifier = Modifier.fillMaxSize(),
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import io.element.android.emojibasebindings.Emoji
import io.element.android.emojibasebindings.EmojibaseCategory
import io.element.android.emojibasebindings.EmojibaseDatasource
import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.features.messages.impl.emojis.RecentEmojiDataSource
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toSp
Expand All @@ -59,6 +60,7 @@ fun EmojiPicker(
onCustomEmojiSelected: (String) -> Unit,
emojibaseStore: EmojibaseStore,
selectedEmojis: ImmutableSet<String>,
recentEmojiDataSource: RecentEmojiDataSource? = null,
modifier: Modifier = Modifier,
) {
val coroutineScope = rememberCoroutineScope()
Expand All @@ -71,6 +73,7 @@ fun EmojiPicker(
SecondaryTabRow(
selectedTabIndex = pagerState.currentPage,
) {
ScEmojiPickerTabsStart(pagerState)
EmojibaseCategory.entries.forEachIndexed { index, category ->
Tab(
icon = {
Expand All @@ -79,20 +82,21 @@ fun EmojiPicker(
contentDescription = stringResource(id = category.title)
)
},
selected = pagerState.currentPage == index,
selected = pagerState.currentPage.removeScPickerOffset() == index,
onClick = {
coroutineScope.launch { pagerState.animateScrollToPage(index) }
coroutineScope.launch { pagerState.animateScrollToPage(index.addScPickerOffset()) }
}
)
}
ScEmojiPickerTabs(pagerState)
ScEmojiPickerTabsEnd(pagerState)
}

HorizontalPager(
state = pagerState,
modifier = Modifier.fillMaxWidth(),
) { index ->
if (scEmojiPickerPage(index, pagerState.currentPage, onCustomEmojiSelected)) {
) { scIndex ->
val index = scIndex.removeScPickerOffset()
if (scEmojiPickerPage(scIndex, pagerState.currentPage, selectedEmojis, recentEmojiDataSource, onCustomEmojiSelected)) {
return@HorizontalPager
}
val category = EmojibaseCategory.entries[index]
Expand Down
Loading

0 comments on commit 2287bb6

Please sign in to comment.