feat(reactions): grid emoji picker, configurable quick reactions, open animation

- Fix swipe-to-react: enable stable ids on MessagesAdapter so the swipe
  callback resolves the swiped message (itemId was NO_ID -> lookup failed),
  and anchor the reaction popup independent of swipe-recovery translation so
  it no longer opens off-screen.
- Replace the typed-text reaction "+" with a themed, scrollable emoji grid
  (EmojiPicker) generated from colour-emoji codepoint ranges plus a curated
  common set. No third-party picker, so no Guava and it follows light/dark.
- Make the six quick reactions user-configurable and reorderable via a new
  Settings -> Quick reactions editor; the double-tap default reaction now uses
  the same grid picker instead of a raw text dialog.
- Pulse visible reaction badges (1 -> 1.4 -> 0.9 -> 1, cascaded 0.25s) when a
  conversation opens with a reaction as its newest message.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
octoshrimpy
2026-06-14 18:58:13 -04:00
parent dba337ee0e
commit 2ca015a0ae
10 changed files with 338 additions and 24 deletions
@@ -93,6 +93,10 @@ class Preferences @Inject constructor(
const val MESSAGE_SWIPE_ACTION_DELETE = 5
const val DEFAULT_REACTION_EMOJI = "👍"
// Quick-reaction emojis shown in the reaction popup, comma separated (user reorderable)
const val DEFAULT_QUICK_REACTIONS = "❤️,👍,👎,😂,😮,😢"
const val QUICK_REACTIONS_COUNT = 6
}
// Internal
@@ -133,6 +137,8 @@ class Preferences @Inject constructor(
val swipeRight = rxPrefs.getInteger("swipeRight", SWIPE_ACTION_ARCHIVE)
val swipeLeft = rxPrefs.getInteger("swipeLeft", SWIPE_ACTION_ARCHIVE)
val defaultReactionEmoji = rxPrefs.getString("defaultReactionEmoji", DEFAULT_REACTION_EMOJI)
val quickReactions = rxPrefs.getString("quickReactions", DEFAULT_QUICK_REACTIONS)
val messageSwipeRight = rxPrefs.getInteger("messageSwipeRight", MESSAGE_SWIPE_ACTION_REACT)
val messageSwipeLeft = rxPrefs.getInteger("messageSwipeLeft", MESSAGE_SWIPE_ACTION_REPLY)
val autoEmoji = rxPrefs.getBoolean("autoEmoji", true)
@@ -0,0 +1,119 @@
/*
* Copyright (C) 2017 Moez Bhatti <moez.bhatti@gmail.com>
*
* This file is part of QKSMS.
*
* QKSMS is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* QKSMS is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with QKSMS. If not, see <http://www.gnu.org/licenses/>.
*/
package dev.octoshrimpy.quik.common.util
import android.content.Context
import android.graphics.Paint
import android.view.Gravity
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.google.android.material.bottomsheet.BottomSheetDialog
import dev.octoshrimpy.quik.R
import dev.octoshrimpy.quik.common.util.extensions.dpToPx
import dev.octoshrimpy.quik.common.util.extensions.resolveThemeColor
/**
* A lightweight, dependency-free emoji picker. Renders a themed, scrollable grid of emoji in a
* bottom sheet so it follows Quik's light/dark/black themes (unlike androidx emoji2-emojipicker,
* which pulled in Guava and ignored the app theme — see project memory).
*/
object EmojiPicker {
// Popular reactions shown first. These include emoji-presentation sequences (base char +
// U+FE0F) that must be spelled out to render in colour — plain codepoint iteration would
// show them as black-and-white text glyphs.
private val commonEmojis = listOf(
"❤️", "👍", "👎", "😂", "😮", "😢", "🙏", "🔥", "🎉", "👏",
"💯", "😍", "🥰", "😊", "🤔", "😎", "😭", "😅", "😡", "🤣",
"😘", "👀", "✨", "⭐", "✅", "❌", "✌️", "🤝", "🙌", "💪"
)
// Unicode blocks that default to emoji (colour) presentation without a variation selector,
// so they enumerate cleanly. Sequences/flags/skin-tones aren't covered by codepoint walking.
private val emojiRanges = listOf(
0x1F300..0x1F5FF, // Misc Symbols and Pictographs
0x1F600..0x1F64F, // Emoticons
0x1F680..0x1F6FF, // Transport and Map
0x1F900..0x1F9FF, // Supplemental Symbols and Pictographs
0x1FA70..0x1FAFF // Symbols and Pictographs Extended-A
)
/** Common emoji first, then every renderable codepoint from the colour-emoji ranges. */
val emojis: List<String> by lazy {
val paint = Paint()
val generated = emojiRanges.asSequence()
.flatMap { it.asSequence() }
.mapNotNull { cp ->
val s = String(Character.toChars(cp))
if (paint.hasGlyph(s)) s else null
}
(commonEmojis.asSequence() + generated).distinct().toList()
}
/**
* Show the emoji grid in a bottom sheet. [onPicked] receives the chosen emoji and the sheet
* dismisses. [context] must be a themed (activity) context.
*/
fun show(context: Context, onPicked: (String) -> Unit) {
val backgroundColor = context.resolveThemeColor(android.R.attr.windowBackground)
val textColor = context.resolveThemeColor(android.R.attr.textColorPrimary)
val cell = 48.dpToPx(context)
val span = (context.resources.displayMetrics.widthPixels / cell).coerceAtLeast(6)
val sheet = BottomSheetDialog(context)
val recycler = RecyclerView(context).apply {
setBackgroundColor(backgroundColor)
layoutManager = GridLayoutManager(context, span)
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
(context.resources.displayMetrics.heightPixels * 0.5f).toInt()
)
adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
object : RecyclerView.ViewHolder(TextView(context).apply {
textSize = 22f
gravity = Gravity.CENTER
includeFontPadding = false
setTextColor(textColor)
setBackgroundResource(R.drawable.ripple)
layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, cell
)
}) {}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val emoji = emojis[position]
(holder.itemView as TextView).text = emoji
holder.itemView.setOnClickListener {
onPicked(emoji)
sheet.dismiss()
}
}
override fun getItemCount() = emojis.size
}
}
sheet.setContentView(recycler)
sheet.show()
}
}
@@ -45,7 +45,6 @@ import android.view.MenuItem
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.PopupWindow
import android.widget.SeekBar
@@ -75,6 +74,7 @@ import dev.octoshrimpy.quik.common.util.DateFormatter
import dev.octoshrimpy.quik.common.util.extensions.autoScrollToStart
import dev.octoshrimpy.quik.common.util.extensions.dpToPx
import dev.octoshrimpy.quik.common.util.extensions.hideKeyboard
import dev.octoshrimpy.quik.common.util.EmojiPicker
import dev.octoshrimpy.quik.common.util.extensions.makeToast
import dev.octoshrimpy.quik.common.util.extensions.scrapViews
import dev.octoshrimpy.quik.common.util.extensions.setBackgroundTint
@@ -87,6 +87,7 @@ import dev.octoshrimpy.quik.feature.compose.editing.ChipsAdapter
import dev.octoshrimpy.quik.feature.contacts.ContactsActivity
import dev.octoshrimpy.quik.model.Attachment
import dev.octoshrimpy.quik.model.Recipient
import dev.octoshrimpy.quik.util.Preferences
import io.reactivex.Observable
import io.reactivex.android.schedulers.AndroidSchedulers
import io.reactivex.disposables.Disposable
@@ -732,7 +733,12 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
}
override fun showReactionPicker(messageId: Long) {
val defaultEmojis = listOf("❤️", "👍", "👎", "😂", "😮", "😢")
// User-configurable quick reactions (Settings → Quick reactions); fall back to defaults
val defaultEmojis = prefs.quickReactions.get()
.split(",")
.map { it.trim() }
.filter { it.isNotEmpty() }
.ifEmpty { Preferences.DEFAULT_QUICK_REACTIONS.split(",") }
val theme = colors.theme()
val vPad = 8.dpToPx(this)
@@ -774,18 +780,9 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
row.addView(emojiView(emoji) { sendReactionIntent.onNext(Pair(messageId, emoji)) })
}
// "+" opens a simple input so the user can type or paste any emoji
// "+" opens the full scrollable emoji grid so any emoji can be chosen
row.addView(emojiView("+") {
val input = EditText(this).apply { hint = getString(R.string.compose_reaction_custom_hint) }
AlertDialog.Builder(this)
.setTitle(R.string.compose_reaction_custom_title)
.setView(input)
.setPositiveButton(R.string.button_send) { _, _ ->
val emoji = input.text.toString().trim()
if (emoji.isNotEmpty()) sendReactionIntent.onNext(Pair(messageId, emoji))
}
.setNegativeButton(R.string.button_cancel, null)
.show()
EmojiPicker.show(this) { emoji -> sendReactionIntent.onNext(Pair(messageId, emoji)) }
})
// Anchor above the long-pressed message if its view is on screen; else center on the list
@@ -794,17 +791,28 @@ class ComposeActivity : QkThemedActivity(), ComposeView {
.takeIf { it != -1 }
?.let { binding.messageList.layoutManager?.findViewByPosition(it) }
row.measure(
View.MeasureSpec.makeMeasureSpec(popupWidth, View.MeasureSpec.EXACTLY),
View.MeasureSpec.UNSPECIFIED
)
if (messageView != null) {
row.measure(
View.MeasureSpec.makeMeasureSpec(popupWidth, View.MeasureSpec.EXACTLY),
View.MeasureSpec.UNSPECIFIED
)
popup.showAsDropDown(messageView, margin, -(messageView.height + row.measuredHeight))
val loc = IntArray(2)
messageView.getLocationInWindow(loc)
// Neutralize any in-progress swipe-recovery translation so the popup is not
// placed off-screen. The popup is ~full width, so anchor x at a fixed margin
// rather than to the (possibly still translated) swiped row.
val top = loc[1] - messageView.translationY.toInt()
val y = (top - row.measuredHeight).let { above ->
if (above >= 0) above else top + messageView.height // flip below if no room above
}
popup.showAtLocation(binding.messageList, Gravity.NO_GRAVITY, margin, y)
} else {
popup.showAtLocation(binding.messageList, Gravity.CENTER, 0, 0)
}
}
override fun onCreateOptionsMenu(menu: Menu?): Boolean {
menuInflater.inflate(R.menu.compose, menu)
return super.onCreateOptionsMenu(menu)
@@ -23,6 +23,8 @@ import android.content.Context
import android.graphics.Typeface
import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.text.Layout
import android.text.Spannable
import android.text.SpannableString
@@ -35,6 +37,8 @@ import android.text.util.Linkify
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.animation.DecelerateInterpolator
import android.view.animation.OvershootInterpolator
import android.widget.ImageView
import android.widget.ProgressBar
import android.widget.TextView
@@ -103,6 +107,9 @@ class MessagesAdapter @Inject constructor(
private const val MAX_MESSAGE_DISPLAY_LENGTH = 5000
private const val DOUBLE_TAP_TIMEOUT_MS = 300L
// delay between each reaction badge's pulse when a conversation opens on a reaction
private const val REACTION_PULSE_STAGGER_MS = 250L
}
// tracks the previous tap so we can detect a double-tap without an OnTouchListener
@@ -110,6 +117,13 @@ class MessagesAdapter @Inject constructor(
private var lastTapMessageId = -1L
private var lastTapTime = 0L
// When the conversation opens with a reaction as the newest message, the visible reaction
// badges pulse once, cascaded by REACTION_PULSE_STAGGER_MS. pulsedReactionIds keeps each
// badge to a single pulse despite RecyclerView rebinding.
private var pendingReactionPulse = false
private var reactionPulseOrder = 0
private val pulsedReactionIds = mutableSetOf<Long>()
// click events passed back to compose view model
val partClicks: Subject<Long> = PublishSubject.create()
val messageLinkClicks: Subject<Uri> = PublishSubject.create()
@@ -128,6 +142,15 @@ class MessagesAdapter @Inject constructor(
field = value
contactCache.clear()
// Arm the reaction pulse if the newest message in the thread is a reaction. Binds for
// the initially-visible badges run synchronously on the next layout pass, so they all
// see the flag; the delayed reset only stops badges scrolled into view later.
if (value?.first?.lastMessage?.isEmojiReaction == true && !pendingReactionPulse) {
pendingReactionPulse = true
reactionPulseOrder = 0
Handler(Looper.getMainLooper()).postDelayed({ pendingReactionPulse = false }, 1500)
}
updateData(value?.second)
}
@@ -145,6 +168,13 @@ class MessagesAdapter @Inject constructor(
private val audioState = AudioState()
init {
// Stable ids so RecyclerView populates viewHolder.itemId from getItemId(); the swipe
// callback (MessageItemTouchCallback) reads itemId to identify the swiped message.
// Without this, itemId is NO_ID (-1) and swipe-to-react can't resolve the message.
setHasStableIds(true)
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): QkViewHolder {
// Use the parent's context to inflate the layout, otherwise link clicks will crash the app
val inflater = LayoutInflater.from(parent.context)
@@ -469,12 +499,48 @@ class MessagesAdapter @Inject constructor(
reactionTextView.text = reactionText
reactionTextView.setOnClickListener { reactionClicks.onNext(message.id) }
reactionsContainer.setVisible(true)
// clear any leftover scale from a recycled view
reactionTextView.animate().cancel()
reactionTextView.scaleX = 1f
reactionTextView.scaleY = 1f
// pulse once, cascaded, when the conversation opened on a reaction
if (pendingReactionPulse && pulsedReactionIds.add(message.id)) {
bounceReaction(reactionTextView, reactionPulseOrder++ * REACTION_PULSE_STAGGER_MS)
}
} else {
reactionsContainer.setVisible(false)
reactionTextView.setOnClickListener(null)
}
}
/** Grow-and-shrink bounce: 1 → 1.4 → 0.9 → 1, after [startDelay] ms. */
private fun bounceReaction(view: View, startDelay: Long) {
view.scaleX = 1f
view.scaleY = 1f
view.animate()
.scaleX(1.4f).scaleY(1.4f)
.setStartDelay(startDelay)
.setDuration(150)
.setInterpolator(DecelerateInterpolator())
.withEndAction {
view.animate()
.scaleX(0.9f).scaleY(0.9f)
.setStartDelay(0)
.setDuration(120)
.withEndAction {
view.animate()
.scaleX(1f).scaleY(1f)
.setDuration(120)
.setInterpolator(OvershootInterpolator())
.start()
}
.start()
}
.start()
}
private fun bindStatus(
statusView: TextView,
@@ -24,9 +24,15 @@ import android.content.Context
import android.os.Build
import android.text.format.DateFormat
import android.view.LayoutInflater
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.app.AlertDialog
import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.ItemTouchHelper
import androidx.recyclerview.widget.RecyclerView
import com.bluelinelabs.conductor.RouterTransaction
import com.google.android.material.snackbar.Snackbar
import com.jakewharton.rxbinding2.view.clicks
@@ -40,7 +46,10 @@ import dev.octoshrimpy.quik.common.QkChangeHandler
import dev.octoshrimpy.quik.common.QkDialog
import dev.octoshrimpy.quik.common.base.QkController
import dev.octoshrimpy.quik.common.util.Colors
import dev.octoshrimpy.quik.common.util.EmojiPicker
import dev.octoshrimpy.quik.common.util.extensions.animateLayoutChanges
import dev.octoshrimpy.quik.common.util.extensions.dpToPx
import dev.octoshrimpy.quik.common.util.extensions.resolveThemeColor
import dev.octoshrimpy.quik.common.util.extensions.setBackgroundTint
import dev.octoshrimpy.quik.common.util.extensions.setVisible
import dev.octoshrimpy.quik.common.widget.PreferenceView
@@ -56,6 +65,7 @@ import dev.octoshrimpy.quik.util.Preferences
import io.reactivex.Observable
import io.reactivex.subjects.PublishSubject
import io.reactivex.subjects.Subject
import java.util.Collections
import javax.inject.Inject
class SettingsController : QkController<SettingsControllerBinding, SettingsView, SettingsState, SettingsPresenter>(), SettingsView {
@@ -77,16 +87,12 @@ class SettingsController : QkController<SettingsControllerBinding, SettingsView,
TextInputDialog(activity!!, context.getString(R.string.settings_signature_title), signatureSubject::onNext)
}
private val defaultReactionDialog: TextInputDialog by lazy {
TextInputDialog(activity!!, context.getString(R.string.settings_default_reaction_title),
defaultReactionSubject::onNext)
}
private val viewQksmsPlusSubject: Subject<Unit> = PublishSubject.create()
private val startTimeSelectedSubject: Subject<Pair<Int, Int>> = PublishSubject.create()
private val endTimeSelectedSubject: Subject<Pair<Int, Int>> = PublishSubject.create()
private val signatureSubject: Subject<String> = PublishSubject.create()
private val defaultReactionSubject: Subject<String> = PublishSubject.create()
private val quickReactionsSubject: Subject<String> = PublishSubject.create()
private val progressAnimator by lazy { ObjectAnimator.ofInt(binding.syncingProgress, "progress", 0, 0) }
@@ -147,6 +153,8 @@ class SettingsController : QkController<SettingsControllerBinding, SettingsView,
override fun defaultReactionChanged(): Observable<String> = defaultReactionSubject.hide()
override fun quickReactionsChanged(): Observable<String> = quickReactionsSubject.hide()
override fun mmsSizeSelected(): Observable<Int> = mmsSizeDialog.adapter.menuItemClicks
override fun messageLinkHandlingSelected(): Observable<Int> = messageLinkHandlingDialog.adapter.menuItemClicks
@@ -177,6 +185,8 @@ class SettingsController : QkController<SettingsControllerBinding, SettingsView,
binding.defaultReaction.summary = state.defaultReactionEmoji
binding.quickReactions.summary = state.quickReactions.replace(",", " ")
binding.textSize.summary = state.textSizeSummary
textSizeDialog.adapter.selectedItem = state.textSizeId
@@ -249,7 +259,87 @@ class SettingsController : QkController<SettingsControllerBinding, SettingsView,
override fun showSignatureDialog(signature: String) = signatureDialog.setText(signature).show()
override fun showDefaultReactionDialog(current: String) = defaultReactionDialog.setText(current).show()
override fun showDefaultReactionDialog(current: String) {
EmojiPicker.show(activity!!) { emoji -> defaultReactionSubject.onNext(emoji) }
}
override fun showQuickReactionsDialog(current: List<String>) {
val ctx = activity!!
val count = Preferences.QUICK_REACTIONS_COUNT
val items = current.filter { it.isNotEmpty() }.toMutableList()
while (items.size < count) items.add("➕")
while (items.size > count) items.removeAt(items.lastIndex)
val textColor = ctx.resolveThemeColor(android.R.attr.textColorPrimary)
val cell = 48.dpToPx(ctx)
lateinit var slotAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>
slotAdapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
object : RecyclerView.ViewHolder(TextView(ctx).apply {
textSize = 24f
gravity = Gravity.CENTER
includeFontPadding = false
setTextColor(textColor)
setBackgroundResource(R.drawable.ripple)
// each slot fills its grid column so all of them fit the dialog width
// (evenly spaced) without horizontal scrolling
layoutParams = RecyclerView.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT, cell
)
}) {}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
(holder.itemView as TextView).text = items[position]
// tap a slot to replace its emoji via the full picker
holder.itemView.setOnClickListener {
EmojiPicker.show(ctx) { emoji ->
items[holder.adapterPosition] = emoji
slotAdapter.notifyItemChanged(holder.adapterPosition)
}
}
}
override fun getItemCount() = items.size
}
val recycler = RecyclerView(ctx).apply {
// one row of equal-width columns -> all slots fit, evenly spaced, no scrolling
layoutManager = GridLayoutManager(ctx, count)
adapter = slotAdapter
setPadding(8.dpToPx(ctx), 16.dpToPx(ctx), 8.dpToPx(ctx), 16.dpToPx(ctx))
clipToPadding = false
}
// long-press drag to reorder the slots
ItemTouchHelper(object : ItemTouchHelper.SimpleCallback(
ItemTouchHelper.LEFT or ItemTouchHelper.RIGHT, 0
) {
override fun onMove(
recyclerView: RecyclerView,
viewHolder: RecyclerView.ViewHolder,
target: RecyclerView.ViewHolder
): Boolean {
val from = viewHolder.adapterPosition
val to = target.adapterPosition
Collections.swap(items, from, to)
slotAdapter.notifyItemMoved(from, to)
return true
}
override fun onSwiped(viewHolder: RecyclerView.ViewHolder, direction: Int) = Unit
}).attachToRecyclerView(recycler)
AlertDialog.Builder(ctx)
.setTitle(R.string.settings_quick_reactions_title)
.setMessage(R.string.settings_quick_reactions_dialog_hint)
.setView(recycler)
.setPositiveButton(R.string.button_save) { _, _ ->
quickReactionsSubject.onNext(items.joinToString(",") { it.ifEmpty { "➕" } })
}
.setNegativeButton(R.string.button_cancel, null)
.show()
}
override fun showMmsSizePicker() = mmsSizeDialog.show(activity!!)
@@ -99,6 +99,9 @@ class SettingsPresenter @Inject constructor(
disposables += prefs.defaultReactionEmoji.asObservable()
.subscribe { emoji -> newState { copy(defaultReactionEmoji = emoji) } }
disposables += prefs.quickReactions.asObservable()
.subscribe { quick -> newState { copy(quickReactions = quick) } }
val textSizeLabels = context.resources.getStringArray(R.array.text_sizes)
disposables += prefs.textSize.asObservable()
.subscribe { textSize ->
@@ -189,6 +192,9 @@ class SettingsPresenter @Inject constructor(
R.id.defaultReaction -> view.showDefaultReactionDialog(prefs.defaultReactionEmoji.get())
R.id.quickReactions -> view.showQuickReactionsDialog(
prefs.quickReactions.get().split(",").map { it.trim() }.filter { it.isNotEmpty() })
R.id.delayed -> view.showDelayDurationDialog()
R.id.delivery -> prefs.delivery.set(!prefs.delivery.get())
@@ -288,6 +294,11 @@ class SettingsPresenter @Inject constructor(
.autoDisposable(view.scope())
.subscribe()
view.quickReactionsChanged()
.doOnNext(prefs.quickReactions::set)
.autoDisposable(view.scope())
.subscribe()
view.mmsSizeSelected()
.autoDisposable(view.scope())
.subscribe(prefs.mmsSize::set)
@@ -37,6 +37,7 @@ data class SettingsState(
val unreadAtTopEnabled: Boolean = false,
val signature: String = "",
val defaultReactionEmoji: String = Preferences.DEFAULT_REACTION_EMOJI,
val quickReactions: String = Preferences.DEFAULT_QUICK_REACTIONS,
val textSizeSummary: String = "",
val textSizeId: Int = Preferences.TEXT_SIZE_NORMAL,
val systemFontEnabled: Boolean = false,
@@ -33,6 +33,7 @@ interface SettingsView : QkViewContract<SettingsState> {
fun sendDelaySelected(): Observable<Int>
fun signatureChanged(): Observable<String>
fun defaultReactionChanged(): Observable<String>
fun quickReactionsChanged(): Observable<String>
fun mmsSizeSelected(): Observable<Int>
fun messageLinkHandlingSelected(): Observable<Int>
@@ -44,6 +45,7 @@ interface SettingsView : QkViewContract<SettingsState> {
fun showDelayDurationDialog()
fun showSignatureDialog(signature: String)
fun showDefaultReactionDialog(current: String)
fun showQuickReactionsDialog(current: List<String>)
fun showMmsSizePicker()
fun showMessageLinkHandlingDialogPicker()
fun showSwipeActions()
@@ -166,6 +166,14 @@
app:icon="@drawable/ic_favorite_black_24dp"
app:title="@string/settings_default_reaction_title" />
<dev.octoshrimpy.quik.common.widget.PreferenceView
android:id="@+id/quickReactions"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:icon="@drawable/ic_favorite_black_24dp"
app:summary="@string/settings_quick_reactions_summary"
app:title="@string/settings_quick_reactions_title" />
<dev.octoshrimpy.quik.common.widget.PreferenceView
android:id="@+id/delivery"
android:layout_width="match_parent"
@@ -312,6 +312,9 @@
<item>Speak</item>
</string-array>
<string name="settings_default_reaction_title">Default reaction</string>
<string name="settings_quick_reactions_title">Quick reactions</string>
<string name="settings_quick_reactions_summary">Emoji shown in the reaction popup</string>
<string name="settings_quick_reactions_dialog_hint">Tap to change, drag to reorder</string>
<string name="settings_message_swipe_actions">Message swipe actions</string>
<string name="settings_message_swipe_actions_summary">Configure swipe actions for messages</string>
<string-array name="settings_message_swipe_actions">