Implement Emoji Autocompletion and Replacement

parent de3934ce
This diff is collapsed.
......@@ -27,7 +27,8 @@ data class AutoCompleteSettings(
val prefix: Boolean = true,
val nicks: Boolean = true,
val buffers: Boolean = true,
val aliases: Boolean = true
val aliases: Boolean = true,
val emoji: Boolean = true
) {
companion object {
val DEFAULT = AutoCompleteSettings()
......
......@@ -37,7 +37,8 @@ data class MessageSettings(
val showGravatarAvatars: Boolean = false,
val showMatrixAvatars: Boolean = false,
val largerEmoji: Boolean = false,
val highlightOwnMessages: Boolean = false
val highlightOwnMessages: Boolean = false,
val replaceEmoji: Boolean = true
) {
enum class ColorizeNicknamesMode {
......
......@@ -132,6 +132,10 @@ object Settings {
highlightOwnMessages = getBoolean(
context.getString(R.string.preference_highlight_own_messages_key),
MessageSettings.DEFAULT.highlightOwnMessages
),
replaceEmoji = getBoolean(
context.getString(R.string.preference_replace_emoji_key),
MessageSettings.DEFAULT.replaceEmoji
)
)
}
......@@ -216,6 +220,10 @@ object Settings {
aliases = getBoolean(
context.getString(R.string.preference_autocomplete_aliases_key),
AutoCompleteSettings.DEFAULT.aliases
),
emoji = getBoolean(
context.getString(R.string.preference_autocomplete_emoji_key),
AutoCompleteSettings.DEFAULT.emoji
)
)
}
......
......@@ -80,6 +80,11 @@ class AutoCompleteAdapter @Inject constructor(
.inflate(R.layout.widget_alias, parent, false),
clickListener = clickListener
)
VIEWTYPE_EMOJI -> AutoCompleteViewHolder.EmojiViewHolder(
LayoutInflater.from(parent.context)
.inflate(R.layout.widget_emoji, parent, false),
clickListener = clickListener
)
else -> throw IllegalArgumentException(
"Invoked with wrong item type"
)
......@@ -92,6 +97,7 @@ class AutoCompleteAdapter @Inject constructor(
when {
it is AutoCompleteItem.ChannelItem -> VIEWTYPE_CHANNEL
it is AutoCompleteItem.AliasItem -> VIEWTYPE_ALIAS
it is AutoCompleteItem.EmojiItem -> VIEWTYPE_EMOJI
it is AutoCompleteItem.UserItem && it.away -> VIEWTYPE_NICK_AWAY
else -> VIEWTYPE_NICK_ACTIVE
}
......@@ -105,6 +111,8 @@ class AutoCompleteAdapter @Inject constructor(
this.bindImpl(data, messageSettings)
data is AutoCompleteItem.AliasItem && this is AliasViewHolder ->
this.bindImpl(data, messageSettings)
data is AutoCompleteItem.EmojiItem && this is EmojiViewHolder ->
this.bindImpl(data, messageSettings)
else ->
throw IllegalArgumentException("Invoked with wrong item type")
}
......@@ -209,6 +217,35 @@ class AutoCompleteAdapter @Inject constructor(
expansion.text = data.expansion
}
}
class EmojiViewHolder(
itemView: View,
private val clickListener: ((String, String) -> Unit)? = null
) : AutoCompleteViewHolder(itemView) {
@BindView(R.id.emoji)
lateinit var emoji: TextView
@BindView(R.id.shortCode)
lateinit var shortCode: TextView
var value: AutoCompleteItem? = null
init {
ButterKnife.bind(this, itemView)
itemView.setOnClickListener {
val value = value
if (value != null)
clickListener?.invoke(value.name, value.suffix)
}
}
fun bindImpl(data: AutoCompleteItem.EmojiItem, messageSettings: MessageSettings) {
value = data
emoji.text = data.replacement
shortCode.text = data.shortCodes.joinToString(", ")
}
}
}
companion object {
......@@ -216,5 +253,6 @@ class AutoCompleteAdapter @Inject constructor(
const val VIEWTYPE_NICK_ACTIVE = 1
const val VIEWTYPE_NICK_AWAY = 2
const val VIEWTYPE_ALIAS = 3
const val VIEWTYPE_EMOJI = 4
}
}
......@@ -42,6 +42,7 @@ import de.kuschku.quasseldroid.settings.AutoCompleteSettings
import de.kuschku.quasseldroid.settings.MessageSettings
import de.kuschku.quasseldroid.util.ColorContext
import de.kuschku.quasseldroid.util.avatars.AvatarHelper
import de.kuschku.quasseldroid.util.emoji.EmojiData
import de.kuschku.quasseldroid.util.helper.styledAttributes
import de.kuschku.quasseldroid.util.helper.toLiveData
import de.kuschku.quasseldroid.util.irc.format.IrcFormatDeserializer
......@@ -92,12 +93,14 @@ class AutoCompleteHelper(
(autoCompleteSettings.auto && query.length >= 3) ||
(autoCompleteSettings.prefix && autoCompleteSettings.nicks && query.startsWith('@')) ||
(autoCompleteSettings.prefix && autoCompleteSettings.buffers && query.startsWith('#')) ||
(autoCompleteSettings.prefix && autoCompleteSettings.aliases && query.startsWith('/'))
(autoCompleteSettings.prefix && autoCompleteSettings.aliases && query.startsWith('/')) ||
(autoCompleteSettings.prefix && autoCompleteSettings.emoji && query.startsWith(':'))
val list = if (shouldShowResults) it?.second.orEmpty() else emptyList()
val data = list.filter {
it is AutoCompleteItem.AliasItem && autoCompleteSettings.aliases ||
it is AutoCompleteItem.UserItem && autoCompleteSettings.nicks ||
it is AutoCompleteItem.ChannelItem && autoCompleteSettings.buffers
it is AutoCompleteItem.ChannelItem && autoCompleteSettings.buffers ||
it is AutoCompleteItem.EmojiItem && autoCompleteSettings.emoji
}.map {
when (it) {
is AutoCompleteItem.UserItem -> {
......@@ -184,15 +187,25 @@ class AutoCompleteHelper(
network.ircChannel(bufferInfo.bufferName) ?: IrcChannel.NULL
} else IrcChannel.NULL
val users = ircChannel.ircUsers()
fun processResults(list: List<AutoCompleteItem>) = list.filter {
it.name.trimStart(*IGNORED_CHARS)
fun filterStart(name: String): Boolean {
return name.trimStart(*IGNORED_CHARS)
.startsWith(
lastWord.first.trimStart(*IGNORED_CHARS),
ignoreCase = true
)
}.sorted()
}
fun filter(name: String): Boolean {
return name.trim(*IGNORED_CHARS)
.contains(
lastWord.first.trim(*IGNORED_CHARS),
ignoreCase = true
)
}
fun getAliases() = aliases.map {
fun getAliases() = aliases.filter {
filterStart(it.name ?: "")
}.map {
AutoCompleteItem.AliasItem(
"/${it.name}",
it.expansion
......@@ -200,6 +213,8 @@ class AutoCompleteHelper(
}
fun getBuffers() = infos.filter {
filterStart(it.bufferName ?: "")
}.filter {
it.type.toInt() == Buffer_Type.ChannelBuffer.toInt()
}.mapNotNull { info ->
networks[info.networkId]?.let { info to it }
......@@ -230,7 +245,9 @@ class AutoCompleteHelper(
emptySet()
}
fun getNicks() = getUsers().map { user ->
fun getNicks() = getUsers().filter {
filterStart(it.nick())
}.map { user ->
val userModes = ircChannel.userModes(user)
val prefixModes = network.prefixModes()
......@@ -249,12 +266,19 @@ class AutoCompleteHelper(
)
}
when (lastWord.first.firstOrNull()) {
'/' -> processResults(getAliases())
'@' -> processResults(getNicks())
'#' -> processResults(getBuffers())
else -> processResults(getNicks())
fun getEmojis() = EmojiData.processedEmojiMap.filter {
it.shortCodes.any {
it.contains(lastWord.first.trim(':'))
}
}
when (lastWord.first.firstOrNull()) {
'/' -> getAliases()
'@' -> getNicks()
'#' -> getBuffers()
':' -> getEmojis()
else -> getNicks()
}.sorted()
} else {
emptyList()
}
......@@ -269,7 +293,8 @@ class AutoCompleteHelper(
}?.filter {
it is AutoCompleteItem.AliasItem && autoCompleteSettings.aliases ||
it is AutoCompleteItem.UserItem && autoCompleteSettings.nicks ||
it is AutoCompleteItem.ChannelItem && autoCompleteSettings.buffers
it is AutoCompleteItem.ChannelItem && autoCompleteSettings.buffers ||
it is AutoCompleteItem.EmojiItem && autoCompleteSettings.emoji
}.orEmpty()
if (previous != null && originalWord.first == previous.originalWord && originalWord.second.start == previous.range.start) {
......
......@@ -39,6 +39,7 @@ import de.kuschku.quasseldroid.R
import de.kuschku.quasseldroid.settings.AppearanceSettings
import de.kuschku.quasseldroid.settings.AutoCompleteSettings
import de.kuschku.quasseldroid.settings.MessageSettings
import de.kuschku.quasseldroid.util.emoji.EmojiData
import de.kuschku.quasseldroid.util.helper.*
import de.kuschku.quasseldroid.util.irc.format.IrcFormatDeserializer
import de.kuschku.quasseldroid.util.irc.format.IrcFormatSerializer
......@@ -168,8 +169,12 @@ class ChatlineFragment : ServiceBoundFragment() {
}
fun send() {
if (chatline.safeText.isNotEmpty()) {
val lines = chatline.safeText.lineSequence().map {
val safeText =
if (messageSettings.replaceEmoji) EmojiData.replaceShortCodes(chatline.safeText)
else chatline.safeText
if (safeText.isNotEmpty()) {
val lines = safeText.lineSequence().map {
SpannableString(it).apply {
for (span in getSpans(0, length, Any::class.java)) {
if (getSpanFlags(span) and Spanned.SPAN_COMPOSING != 0) {
......
......@@ -76,6 +76,8 @@
<androidx.appcompat.widget.AppCompatImageView
android:id="@+id/handle"
android:visibility="gone"
tools:visibility="visible"
android:layout_width="24dp"
android:layout_height="24dp"
android:layout_gravity="center_vertical"
......
<?xml version="1.0" encoding="utf-8"?><!--
Quasseldroid - Quassel client for Android
Copyright (c) 2019 Janne Mareike Koschinski
Copyright (c) 2019 The Quassel Project
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License version 3 as published
by the Free Software Foundation.
This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
-->
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?attr/backgroundMenuItem"
android:minHeight="48dp"
android:paddingLeft="16dp"
android:paddingTop="8dp"
android:paddingRight="16dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/emoji"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_gravity="center_vertical|start"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:fontFamily="sans-serif-medium"
android:gravity="center_vertical|start"
android:singleLine="true"
android:textColor="?attr/colorTextPrimary"
android:textSize="24dp"
tools:ignore="SpUsage"
tools:text="♥" />
<TextView
android:id="@+id/shortCode"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical|start"
android:singleLine="true"
android:textColor="?attr/colorTextSecondary"
android:textSize="12sp"
tools:text=":heart:" />
</LinearLayout>
......@@ -256,6 +256,10 @@
<string name="preference_highlight_own_messages_title">Highlight own messages</string>
<string name="preference_highlight_own_messages_summary">Shows your own messages more prominently</string>
<string name="preference_replace_emoji_key" translatable="false">replace_emoji</string>
<string name="preference_replace_emoji_title">Replace Emoji Shortcodes</string>
<string name="preference_replace_emoji_summary">Automatically replaces shortcodes such as :+1: or :like: with emoji</string>
<string name="preference_autocomplete_title">Autocomplete</string>
......@@ -288,6 +292,9 @@
<string name="preference_autocomplete_aliases_key" translatable="false">autocomplete_aliases</string>
<string name="preference_autocomplete_aliases_title">Autocomplete Commands</string>
<string name="preference_autocomplete_emoji_key" translatable="false">autocomplete_emoji</string>
<string name="preference_autocomplete_emoji_title">Autocomplete Emoji</string>
<string name="preference_backlog_title">Backlog</string>
......
......@@ -239,6 +239,11 @@
android:key="@string/preference_highlight_own_messages_key"
android:summary="@string/preference_highlight_own_messages_summary"
android:title="@string/preference_highlight_own_messages_title" />
<SwitchPreference
android:defaultValue="true"
android:key="@string/preference_replace_emoji_key"
android:summary="@string/preference_replace_emoji_summary"
android:title="@string/preference_replace_emoji_title" />
</PreferenceCategory>
<PreferenceCategory android:layout="@layout/widget_preference_divider" />
......@@ -289,6 +294,11 @@
android:defaultValue="true"
android:key="@string/preference_autocomplete_aliases_key"
android:title="@string/preference_autocomplete_aliases_title" />
<SwitchPreference
android:defaultValue="true"
android:key="@string/preference_autocomplete_emoji_key"
android:title="@string/preference_autocomplete_emoji_title" />
</PreferenceCategory>
<PreferenceCategory android:layout="@layout/widget_preference_divider" />
......
/*
* Quasseldroid - Quassel client for Android
*
* Copyright (c) 2019 Janne Mareike Koschinski
* Copyright (c) 2019 The Quassel Project
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 3 as published
* by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package de.kuschku.quasseldroid.testutil
object StringEscaper {
private fun escape(text: String): String {
val stringBuilder = StringBuilder()
escape(stringBuilder, text)
return stringBuilder.toString()
}
private fun escape(stringBuilder: StringBuilder, text: String) {
for (char in text) {
escape(stringBuilder, char)
}
}
private fun escape(stringBuilder: StringBuilder, text: Char) {
if (text > '\u007f') {
// write \udddd
stringBuilder.append("\\u")
val hex = StringBuffer(Integer.toHexString(text.toInt()))
hex.reverse()
val length = 4 - hex.length
for (j in 0 until length) {
hex.append('0')
}
for (j in 0..3) {
stringBuilder.append(hex[3 - j])
}
} else {
stringBuilder.append(Character.toString(text))
}
}
}
/*
* Quasseldroid - Quassel client for Android
*
* Copyright (c) 2019 Janne Mareike Koschinski
* Copyright (c) 2019 The Quassel Project
*
* This program is free software: you can redistribute it and/or modify it
* under the terms of the GNU General Public License version 3 as published
* by the Free Software Foundation.
*
* This program 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 this program. If not, see <http://www.gnu.org/licenses/>.
*/
package de.kuschku.quasseldroid.util.emoji
import android.text.SpannableStringBuilder
import de.kuschku.quasseldroid.QuasseldroidTest
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@Config(application = QuasseldroidTest::class)
@RunWith(RobolectricTestRunner::class)
class EmojiDataTest {
@Test
fun replaceShortCodes() {
assertEquals("\ud83d\udc4d", replaceShortCodes(":like:"))
assertEquals("this\ud83d\udc4disa\ud83d\udc1e\ud83d\udc4dtest",
replaceShortCodes("this:like:isa:beetle::+1:test"))
}
companion object {
private fun replaceShortCodes(text: String): String =
EmojiData.replaceShortCodes(SpannableStringBuilder(text)).toString()
}
}
......@@ -20,6 +20,8 @@
package de.kuschku.quasseldroid.util.emoji
import android.os.Build
import android.text.Editable
import de.kuschku.quasseldroid.viewmodel.data.AutoCompleteItem
object EmojiData {
val rawEmojiMap = mapOf(
......@@ -1637,7 +1639,7 @@ object EmojiData {
"secret" to "\u3299\uFE0F"
)
val emojiMap = rawEmojiMap + mapOf(
val emojiReplacementMap = mapOf(
// Aliases imported from IRCCloud and Slack
"like" to rawEmojiMap["+1"],
"doge" to rawEmojiMap["dog"],
......@@ -1646,20 +1648,9 @@ object EmojiData {
"party_popper" to rawEmojiMap["tada"],
"shock" to rawEmojiMap["scream"],
"atom" to rawEmojiMap["atom_symbol"],
"<3" to rawEmojiMap["heart"],
"</3" to rawEmojiMap["broken_heart"],
")" to rawEmojiMap["smiley"],
"')" to rawEmojiMap["smiley"],
"-)" to rawEmojiMap["disappointed"],
"(" to rawEmojiMap["cry"],
"_(" to rawEmojiMap["sob"],
"loudly_crying_face" to rawEmojiMap["sob"],
"sad_tears" to rawEmojiMap["sob"],
"bawl" to rawEmojiMap["sob"],
";)" to rawEmojiMap["wink"],
";p" to rawEmojiMap["stuck_out_tongue_winking_eye"],
"simple_smile" to ":)",
"slightly_smiling_face" to ":)",
"ufo" to rawEmojiMap["flying_saucer"],
"throwing_up" to rawEmojiMap["face_with_open_mouth_vomiting"],
"being_sick" to rawEmojiMap["face_with_open_mouth_vomiting"],
......@@ -1699,6 +1690,24 @@ object EmojiData {
"steam_train" to rawEmojiMap["steam_locomotive"]
)
val emojiAsciiMap = mapOf(
"<3" to rawEmojiMap["heart"],
"</3" to rawEmojiMap["broken_heart"],
")" to rawEmojiMap["smiley"],
"')" to rawEmojiMap["smiley"],
"-)" to rawEmojiMap["disappointed"],
"(" to rawEmojiMap["cry"],
"_(" to rawEmojiMap["sob"],
";)" to rawEmojiMap["wink"],
";p" to rawEmojiMap["stuck_out_tongue_winking_eye"]
)
val processedEmojiMap = (rawEmojiMap + emojiReplacementMap).toList()
.groupBy(Pair<String, String?>::second, Pair<String, String?>::first)
.map { (replacement, shortCodes) ->
AutoCompleteItem.EmojiItem(shortCodes.sorted(), replacement ?: "")
}
val conversionMap = mapOf(
"\u0030\u20E3" to "\uDBBA\uDC37", // ZERO
"\u0031\u20E3" to "\uDBBA\uDC2E", // ONE
......@@ -1741,5 +1750,22 @@ object EmojiData {
)
} else emptyMap()
val emojis = EmojiData.conversionMap.values.toSet() + EmojiData.rawEmojiMap.values.toSet() + "\u200d" + "\ufe0f"
val emojis = conversionMap.values.toSet() + rawEmojiMap.values.toSet() + "\u200d" + "\ufe0f"
fun replaceShortCodes(source: Editable): Editable {
var result = source
for (emoji in processedEmojiMap) {
for (rawShortCode in emoji.shortCodes) {
val shortCode = ":$rawShortCode:"
var index: Int
while (true) {
index = result.indexOf(shortCode)
if (index == -1) break
result = result.replace(index, index + shortCode.length, emoji.replacement)
}
}
}
return result
}
}
......@@ -131,4 +131,27 @@ sealed class AutoCompleteItem(open val name: String, val suffix: String, private
return result
}
}
data class EmojiItem(
val shortCodes: List<String>,
val replacement: String
) : AutoCompleteItem(replacement, " ", 3) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as EmojiItem
if (shortCodes != other.shortCodes) return false
if (replacement != other.replacement) return false
return true
}
override fun hashCode(): Int {
var result = shortCodes.hashCode()
result = 31 * result + replacement.hashCode()
return result
}
}
}
......@@ -32,6 +32,7 @@ import de.kuschku.libquassel.util.helper.combineLatest
import de.kuschku.libquassel.util.helper.mapNullable
import de.kuschku.libquassel.util.helper.nullIf
import de.kuschku.libquassel.util.helper.safeSwitchMap
import de.kuschku.quasseldroid.util.emoji.EmojiData
import de.kuschku.quasseldroid.viewmodel.ChatViewModel
import de.kuschku.quasseldroid.viewmodel.EditorViewModel
import de.kuschku.quasseldroid.viewmodel.QuasselViewModel
......@@ -47,14 +48,13 @@ open class EditorViewModelHelper @Inject constructor(
quassel: QuasselViewModel
) : ChatViewModelHelper(chat, quassel) {
val rawAutoCompleteData: Observable<Triple<Optional<ISession>, BufferId, Pair<String, IntRange>>> =
combineLatest(connectedSession, chat.bufferId, editor.lastWord)
.safeSwitchMap { (sessionOptional, id, lastWordWrapper) ->
lastWordWrapper
.distinctUntilChanged()
.map { lastWord ->
Triple(sessionOptional, id, lastWord)
}
}
combineLatest(
connectedSession,
chat.bufferId,
editor.lastWord.safeSwitchMap {
it
}.distinctUntilChanged()
)
val autoCompleteData: Observable<Pair<String, List<AutoCompleteItem>>> = rawAutoCompleteData
.distinctUntilChanged()
......@@ -72,51 +72,63 @@ open class EditorViewModelHelper @Inject constructor(
network.ircChannel(bufferInfo.bufferName) ?: IrcChannel.NULL
} else IrcChannel.NULL
ircChannel.liveIrcUsers().safeSwitchMap { users ->
fun filterStart(name: String): Boolean {
return name.trimStart(*IGNORED_CHARS)
.startsWith(
lastWord.first.trimStart(*IGNORED_CHARS),
ignoreCase = true
)
}
fun filter(name: String): Boolean {
return name.trim(*IGNORED_CHARS)
.contains(
lastWord.first.trim(*IGNORED_CHARS),
ignoreCase = true
)
}
fun processResults(results: List<Observable<out AutoCompleteItem>>) =
combineLatest<AutoCompleteItem>(results)
.map { list ->
val filtered = list.filter {
it.name.trimStart(*IGNORED_CHARS)
.startsWith(
lastWord.first.trimStart(*IGNORED_CHARS),
ignoreCase = true
)
}
Pair(
lastWord.first,
filtered.sorted()
list.sorted()
)
}
fun getAliases() = aliases.map {
fun getAliases() = aliases.filter {
filterStart(it.name ?: "")
}.map {
Observable.just(AutoCompleteItem.AliasItem(
"/${it.name}",
it.expansion
))
}
fun getBuffers() = infos.values
.filter {
it.type.toInt() == Buffer_Type.ChannelBuffer.toInt()
}.mapNotNull { info ->
networks[info.networkId]?.let { info to it }
}.map { (info, network) ->
network.liveIrcChannel(
info.bufferName
).safeSwitchMap { channel ->
channel.updates().mapNullable(IrcChannel.NULL) {
AutoCompleteItem.ChannelItem(
info = info,
network = network.networkInfo(),
bufferStatus = when (it) {
null -> BufferStatus.OFFLINE
else -> BufferStatus.ONLINE
},
description = it?.topic() ?: ""
)
}
fun getBuffers() = infos.values.filter {
filterStart(it.bufferName ?: "")
}.filter {
it.type.toInt() == Buffer_Type.ChannelBuffer.toInt()
}.mapNotNull { info ->
networks[info.networkId]?.let { info to it }