Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions app/src/main/assets/emoji_keywords.json

Large diffs are not rendered by default.

2 changes: 2 additions & 0 deletions app/src/main/assets/emoji_keywords.json.license
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
SPDX-License-Identifier: GPL-3.0-or-later
101 changes: 62 additions & 39 deletions app/src/main/java/com/nextcloud/talk/chat/MessageInputFragment.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ package com.nextcloud.talk.chat

import android.content.Context
import android.content.res.Resources
import android.icu.text.BreakIterator
import android.os.Build
import android.os.Bundle
import android.os.CountDownTimer
Expand Down Expand Up @@ -43,7 +44,6 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toDrawable
import androidx.core.view.isVisible
import androidx.core.widget.doAfterTextChanged
import androidx.emoji2.emojipicker.RecentEmojiProvider
import androidx.emoji2.widget.EmojiTextView
import androidx.fragment.app.Fragment
import androidx.fragment.app.activityViewModels
Expand All @@ -65,6 +65,7 @@ import com.nextcloud.talk.chat.viewmodels.ChatViewModel
import com.nextcloud.talk.chat.viewmodels.MessageInputViewModel
import com.nextcloud.talk.data.network.NetworkMonitor
import com.nextcloud.talk.databinding.FragmentMessageInputBinding
import com.nextcloud.talk.emojipicker.SharedPreferencesRecentEmojiProvider
import com.nextcloud.talk.jobs.UploadAndShareFilesWorker
import com.nextcloud.talk.models.json.capabilities.SpreedCapability
import com.nextcloud.talk.models.json.chat.ChatUtils
Expand Down Expand Up @@ -128,6 +129,7 @@ class MessageInputFragment : Fragment() {
private var hasScheduledMessages = false
private lateinit var spreedCapabilities: SpreedCapability
private var hasSharedText = false
private lateinit var recentEmojiProvider: SharedPreferencesRecentEmojiProvider

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
Expand Down Expand Up @@ -466,6 +468,7 @@ class MessageInputFragment : Fragment() {
val text = binding.fragmentMessageInputView.messageInput.text.toString()
chatActivity.chatViewModel.messageDraft.messageCursor = cursor
chatActivity.chatViewModel.messageDraft.messageText = text
binding.emojiPickerPanel.backspaceActionAvailable = s.isNotEmpty()
handleButtonsVisibility()
}
})
Expand Down Expand Up @@ -782,7 +785,7 @@ class MessageInputFragment : Fragment() {
private fun showRecordAudioUi(show: Boolean) {
if (show) {
restoreKeyboardOnEmojiDismiss = false
binding.emojiPicker.isVisible = false
hideEmojiKeyboardArea()
val animation: Animation = AlphaAnimation(FULLY_OPAQUE, FULLY_TRANSPARENT)
animation.duration = ANIMATION_DURATION
animation.interpolator = LinearInterpolator()
Expand Down Expand Up @@ -815,7 +818,7 @@ class MessageInputFragment : Fragment() {
}

private fun updateSmileyButtonIcon() {
val icon = if (binding.emojiPicker.isVisible) {
val icon = if (isEmojiAreaVisible()) {
R.drawable.ic_baseline_keyboard_24
} else {
R.drawable.ic_insert_emoticon_black_24dp
Expand All @@ -824,6 +827,47 @@ class MessageInputFragment : Fragment() {
binding.fragmentMessageInputView.smileyButton.setImageDrawable(drawable)
}

private fun isEmojiAreaVisible(): Boolean = binding.emojiPickerPanel.isVisible

private fun showEmojiKeyboardArea() {
binding.emojiPickerPanel.isVisible = true
binding.emojiPickerPanel.reset()
}

private fun hideEmojiKeyboardArea() {
binding.emojiPickerPanel.isVisible = false
}

private fun insertEmoji(emoji: String) {
val inputEditText = binding.fragmentMessageInputView.inputEditText
val start = inputEditText.selectionStart
val end = inputEditText.selectionEnd
inputEditText.editableText.replace(start, end, emoji)
}

private fun deleteEmojiOrCharacterBeforeCursor() {
val inputEditText = binding.fragmentMessageInputView.inputEditText
val text = inputEditText.text ?: return

fun rangeToDelete(): Pair<Int, Int>? {
val start = inputEditText.selectionStart
val end = inputEditText.selectionEnd
return when {
start < 0 || end < 0 -> null
start != end -> minOf(start, end) to maxOf(start, end)
start == 0 -> null
else -> {
val breakIterator = BreakIterator.getCharacterInstance()
breakIterator.setText(text.toString())
val deleteStart = breakIterator.preceding(start)
if (deleteStart == BreakIterator.DONE) null else deleteStart to start
}
}
}

rangeToDelete()?.let { (deleteStart, deleteEnd) -> text.delete(deleteStart, deleteEnd) }
}

private fun showKeyboard() {
val editText = binding.fragmentMessageInputView.inputEditText
editText.requestFocus()
Expand All @@ -842,60 +886,38 @@ class MessageInputFragment : Fragment() {

private fun initSmileyKeyboardToggler() {
val inputEditText = binding.fragmentMessageInputView.inputEditText
val emojiPicker = binding.emojiPicker
val emojiPickerPanel = binding.emojiPickerPanel

emojiPicker.setRecentEmojiProvider(MessageInputRecentEmojiProvider(requireContext()))
emojiPicker.setOnEmojiPickedListener { item ->
val start = inputEditText.selectionStart
val end = inputEditText.selectionEnd
inputEditText.editableText.replace(start, end, item.emoji)
}
recentEmojiProvider = SharedPreferencesRecentEmojiProvider(requireContext(), RECENT_EMOJIS_PREFS)
emojiPickerPanel.searchEnabled = true
emojiPickerPanel.backspaceEnabled = true
emojiPickerPanel.setRecentEmojiProvider(recentEmojiProvider)
emojiPickerPanel.onEmojiPicked = { emoji -> insertEmoji(emoji) }
emojiPickerPanel.onBackspaceClicked = { deleteEmojiOrCharacterBeforeCursor() }
emojiPickerPanel.backspaceActionAvailable = inputEditText.text?.isNotEmpty() == true

viewThemeUtils.talk.themeEmojiPicker(emojiPicker)
viewThemeUtils.talk.themeEmojiPicker(emojiPickerPanel)
updateSmileyButtonIcon()

binding.fragmentMessageInputView.smileyButton.setOnClickListener {
if (emojiPicker.isVisible) {
if (isEmojiAreaVisible()) {
showKeyboard()
emojiPicker.isVisible = false
hideEmojiKeyboardArea()
} else {
hideKeyboard()
emojiPicker.isVisible = true
showEmojiKeyboardArea()
}
updateSmileyButtonIcon()
}

inputEditText.setOnClickListener {
if (emojiPicker.isVisible) {
emojiPicker.isVisible = false
if (isEmojiAreaVisible()) {
hideEmojiKeyboardArea()
updateSmileyButtonIcon()
}
}
}

private class MessageInputRecentEmojiProvider(context: Context) : RecentEmojiProvider {
private val prefs = context.getSharedPreferences("recent_emojis", Context.MODE_PRIVATE)

override fun recordSelection(emoji: String) {
val updated = listOf(emoji) + getStoredList().filterNot { it == emoji }
prefs.edit()
.putString("recent", updated.take(MAX_STORED_RECENT_EMOJIS).joinToString(","))
.apply()
}

override suspend fun getRecentEmojiList(): List<String> = getStoredList()

private fun getStoredList(): List<String> =
prefs.getString("recent", null)
?.split(",")
?.filter { it.isNotBlank() }
?: emptyList()

companion object {
private const val MAX_STORED_RECENT_EMOJIS = 20
}
}

private fun replyToMessage(quotedMessageText: String?, quotedActorDisplayName: String?, quotedImageUrl: String?) {
Log.d(TAG, "Reply")
val view = binding.fragmentMessageInputView
Expand Down Expand Up @@ -1280,5 +1302,6 @@ class MessageInputFragment : Fragment() {
private const val FULLY_OPAQUE: Float = 1.0f
private const val FULLY_TRANSPARENT: Float = 0.0f
private const val OPACITY_DISABLED = 0.7f
private const val RECENT_EMOJIS_PREFS = "recent_emojis"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@

package com.nextcloud.talk.chat.ui

import android.content.Context
import android.content.res.Configuration
import android.view.ContextThemeWrapper
import androidx.activity.compose.BackHandler
Expand Down Expand Up @@ -61,20 +60,17 @@ import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.util.Consumer
import androidx.emoji2.emojipicker.EmojiPickerView
import androidx.emoji2.emojipicker.RecentEmojiProvider
import com.nextcloud.talk.R
import com.nextcloud.talk.chat.data.model.ChatMessage
import com.nextcloud.talk.data.user.model.User
import com.nextcloud.talk.emojipicker.EmojiPickerPanel
import com.nextcloud.talk.emojipicker.SharedPreferencesRecentEmojiProvider
import com.nextcloud.talk.models.domain.ConversationModel
import com.nextcloud.talk.models.json.capabilities.SpreedCapability
import com.nextcloud.talk.models.json.conversations.ConversationEnums
import com.nextcloud.talk.ui.theme.protectEmojiPickerScrollGesture
import com.nextcloud.talk.ui.theme.themeEmojiPickerCategoryTabs
import com.nextcloud.talk.utils.CapabilitiesUtil
import com.nextcloud.talk.utils.CapabilitiesUtil.hasSpreedFeatureCapability
import com.nextcloud.talk.utils.ConversationUtils
Expand All @@ -90,30 +86,8 @@ private const val AGE_THRESHOLD_FOR_DELETE_MESSAGE = 21600000L
private const val MAX_RECENTS = 8
private const val ACTOR_BOTS = "bots"
private const val RECENT_REACTIONS_PREFS = "recent_reaction_emojis"
private const val RECENT_REACTIONS_KEY = "recent"
private const val MAX_STORED_RECENT_REACTIONS = 20

private val defaultReactionEmojis = listOf("πŸ‘", "πŸ‘Ž", "❀️", "πŸ˜‚", "πŸ˜•", "😒", "πŸ™", "πŸ”₯")
private val emojiPickerHeight = 360.dp

private class ReactionRecentEmojiProvider(context: Context) : RecentEmojiProvider {
private val prefs = context.getSharedPreferences(RECENT_REACTIONS_PREFS, Context.MODE_PRIVATE)

override fun recordSelection(emoji: String) {
val updated = listOf(emoji) + getStoredList().filterNot { it == emoji }
prefs.edit()
.putString(RECENT_REACTIONS_KEY, updated.take(MAX_STORED_RECENT_REACTIONS).joinToString(","))
.apply()
}

override suspend fun getRecentEmojiList(): List<String> = getStoredList()

private fun getStoredList(): List<String> =
prefs.getString(RECENT_REACTIONS_KEY, null)
?.split(",")
?.filter { it.isNotBlank() }
?: emptyList()
}

data class MessageActionsState(
val showEmojiBar: Boolean,
Expand Down Expand Up @@ -300,7 +274,9 @@ fun MessageActionsBottomSheet(
onDismiss: () -> Unit
) {
val context = LocalContext.current
val recentEmojiProvider = remember(context) { ReactionRecentEmojiProvider(context) }
val recentEmojiProvider = remember(context) {
SharedPreferencesRecentEmojiProvider(context, RECENT_REACTIONS_PREFS)
}
var showEmojiPicker by rememberSaveable { mutableStateOf(false) }
val scope = rememberCoroutineScope()

Expand Down Expand Up @@ -692,9 +668,11 @@ private fun EmojiPickerSheetContent(
onEmojiSelected: (String) -> Unit,
onBack: () -> Unit
) {
val backgroundColor = MaterialTheme.colorScheme.surfaceContainerLow
val backgroundColor = MaterialTheme.colorScheme.surfaceContainerLow.toArgb()
val selectedTabColor = MaterialTheme.colorScheme.primary.toArgb()
val unselectedTabColor = MaterialTheme.colorScheme.onSurfaceVariant.toArgb()
val hintTextColor = MaterialTheme.colorScheme.onSurfaceVariant.toArgb()
val textColor = MaterialTheme.colorScheme.onSurface.toArgb()

Column(
modifier = Modifier
Expand All @@ -705,16 +683,14 @@ private fun EmojiPickerSheetContent(
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back_button))
}
AndroidView(
modifier = Modifier
.fillMaxWidth()
.height(emojiPickerHeight),
modifier = Modifier.fillMaxWidth(),
factory = { ctx ->
EmojiPickerView(ContextThemeWrapper(ctx, R.style.ThemeOverlay_App_EmojiPicker)).apply {
setBackgroundColor(backgroundColor.toArgb())
EmojiPickerPanel(ContextThemeWrapper(ctx, R.style.ThemeOverlay_App_EmojiPicker)).apply {
searchEnabled = true
backspaceEnabled = false
setRecentEmojiProvider(recentEmojiProvider)
setOnEmojiPickedListener(Consumer { item -> onEmojiSelected(item.emoji) })
themeEmojiPickerCategoryTabs(this, selectedTabColor, unselectedTabColor)
protectEmojiPickerScrollGesture(this)
onEmojiPicked = onEmojiSelected
applyTheme(backgroundColor, selectedTabColor, unselectedTabColor, hintTextColor, textColor)
}
}
)
Expand Down Expand Up @@ -809,7 +785,10 @@ private fun PreviewEmojiBar() {
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) {
EmojiBar(
selfReactions = setOf("πŸ‘", "❀️"),
recentEmojiProvider = ReactionRecentEmojiProvider(LocalContext.current),
recentEmojiProvider = SharedPreferencesRecentEmojiProvider(
LocalContext.current,
RECENT_REACTIONS_PREFS
),
onEmojiClick = {},
onMoreEmojiClick = {}
)
Expand Down Expand Up @@ -851,7 +830,10 @@ private fun PreviewMessageActionsSheetContent() {
Surface(color = MaterialTheme.colorScheme.surfaceContainerLow) {
MessageActionsSheetContent(
actionsState = previewState,
recentEmojiProvider = ReactionRecentEmojiProvider(LocalContext.current),
recentEmojiProvider = SharedPreferencesRecentEmojiProvider(
LocalContext.current,
RECENT_REACTIONS_PREFS
),
onEmojiClick = {},
onMoreEmojiClick = {},
onReply = {},
Expand Down Expand Up @@ -907,7 +889,10 @@ private fun PreviewMessageActionsSheetPinned() {
showOpenInFiles = false,
showDelete = false
),
recentEmojiProvider = ReactionRecentEmojiProvider(LocalContext.current),
recentEmojiProvider = SharedPreferencesRecentEmojiProvider(
LocalContext.current,
RECENT_REACTIONS_PREFS
),
onEmojiClick = {},
onMoreEmojiClick = {},
onReply = {},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,16 +41,11 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.util.Consumer
import androidx.emoji2.emojipicker.EmojiPickerView
import com.nextcloud.talk.R
import com.nextcloud.talk.chooseaccount.viewmodel.StatusMessageViewModel
import com.nextcloud.talk.emojipicker.EmojiPickerPanel
import com.nextcloud.talk.models.json.status.Status
import com.nextcloud.talk.models.json.status.predefined.PredefinedStatus
import com.nextcloud.talk.ui.theme.protectEmojiPickerScrollGesture
import com.nextcloud.talk.ui.theme.themeEmojiPickerCategoryTabs

private val emojiPickerHeight = 360.dp

@OptIn(ExperimentalMaterial3Api::class)
@Composable
Expand Down Expand Up @@ -94,9 +89,11 @@ fun StatusMessageModalBottomSheet(currentStatus: Status, viewModel: StatusMessag

@Composable
private fun EmojiPickerSheetContent(onEmojiSelected: (String) -> Unit, onBack: () -> Unit) {
val backgroundColor = MaterialTheme.colorScheme.surfaceContainerLow
val backgroundColor = MaterialTheme.colorScheme.surfaceContainerLow.toArgb()
val selectedTabColor = MaterialTheme.colorScheme.primary.toArgb()
val unselectedTabColor = MaterialTheme.colorScheme.onSurfaceVariant.toArgb()
val hintTextColor = MaterialTheme.colorScheme.onSurfaceVariant.toArgb()
val textColor = MaterialTheme.colorScheme.onSurface.toArgb()

Column(
modifier = Modifier
Expand All @@ -107,15 +104,13 @@ private fun EmojiPickerSheetContent(onEmojiSelected: (String) -> Unit, onBack: (
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(R.string.back_button))
}
AndroidView(
modifier = Modifier
.fillMaxWidth()
.height(emojiPickerHeight),
modifier = Modifier.fillMaxWidth(),
factory = { ctx ->
EmojiPickerView(ContextThemeWrapper(ctx, R.style.ThemeOverlay_App_EmojiPicker)).apply {
setBackgroundColor(backgroundColor.toArgb())
setOnEmojiPickedListener(Consumer { item -> onEmojiSelected(item.emoji) })
themeEmojiPickerCategoryTabs(this, selectedTabColor, unselectedTabColor)
protectEmojiPickerScrollGesture(this)
EmojiPickerPanel(ContextThemeWrapper(ctx, R.style.ThemeOverlay_App_EmojiPicker)).apply {
searchEnabled = true
backspaceEnabled = false
onEmojiPicked = onEmojiSelected
applyTheme(backgroundColor, selectedTabColor, unselectedTabColor, hintTextColor, textColor)
}
}
)
Expand Down
Loading
Loading