From 10fcee47985e4d7cf93efca380b27f400965bade Mon Sep 17 00:00:00 2001 From: autumnontape <40726037+autumnontape@users.noreply.github.com> Date: Mon, 4 Mar 2019 10:28:08 -0800 Subject: [PATCH] Add autocompletion for custom emoji (#1089) * Remove unnecessary //noinspection ConstantConditions * Add autocompletion for custom emoji * Update MentionTagTokenizer tests for emoji autocomplete support * Move 1) emoji list retrieval notifying and 2) setting of emojiList field into setEmojiList() method of ComposeActivity * Convert RelativeLayout in item_autocomplete_emoji.xml to LinearLayout * Rename MentionTag* to Compose* * Improve emoji autocomplete matching * Make hashtag autocomplete results bold * Use Context.getString()'s built-in formatting * Add a divider between emoji autocomplete results that *start with* the token and those that *contain* it --- .../keylesspalace/tusky/ComposeActivity.java | 62 +++++++-- ...r.java => ComposeAutoCompleteAdapter.java} | 119 +++++++++++++++--- ...ionTagTokenizer.kt => ComposeTokenizer.kt} | 6 +- .../drawable/autocomplete_divider_dark.xml | 6 + .../drawable/autocomplete_divider_light.xml | 6 + .../res/layout/item_autocomplete_divider.xml | 5 + .../res/layout/item_autocomplete_emoji.xml | 33 +++++ .../res/layout/item_autocomplete_hashtag.xml | 8 ++ app/src/main/res/values-night/styles.xml | 2 + app/src/main/res/values/attrs.xml | 1 + app/src/main/res/values/colors.xml | 1 + app/src/main/res/values/strings.xml | 1 + app/src/main/res/values/styles.xml | 2 + ...kenizerTest.kt => ComposeTokenizerTest.kt} | 18 ++- 14 files changed, 232 insertions(+), 38 deletions(-) rename app/src/main/java/com/keylesspalace/tusky/adapter/{MentionTagAutoCompleteAdapter.java => ComposeAutoCompleteAdapter.java} (62%) rename app/src/main/java/com/keylesspalace/tusky/util/{MentionTagTokenizer.kt => ComposeTokenizer.kt} (91%) create mode 100644 app/src/main/res/drawable/autocomplete_divider_dark.xml create mode 100644 app/src/main/res/drawable/autocomplete_divider_light.xml create mode 100644 app/src/main/res/layout/item_autocomplete_divider.xml create mode 100644 app/src/main/res/layout/item_autocomplete_emoji.xml create mode 100644 app/src/main/res/layout/item_autocomplete_hashtag.xml rename app/src/test/java/com/keylesspalace/tusky/{MentionTagTokenizerTest.kt => ComposeTokenizerTest.kt} (77%) diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index 8e0b8837..6f7cc4f7 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -66,7 +66,7 @@ import com.google.android.material.snackbar.Snackbar; import com.google.gson.Gson; import com.google.gson.reflect.TypeToken; import com.keylesspalace.tusky.adapter.EmojiAdapter; -import com.keylesspalace.tusky.adapter.MentionTagAutoCompleteAdapter; +import com.keylesspalace.tusky.adapter.ComposeAutoCompleteAdapter; import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener; import com.keylesspalace.tusky.db.AccountEntity; import com.keylesspalace.tusky.db.AppDatabase; @@ -84,7 +84,7 @@ import com.keylesspalace.tusky.service.SendTootService; import com.keylesspalace.tusky.util.CountUpDownLatch; import com.keylesspalace.tusky.util.DownsizeImageTask; import com.keylesspalace.tusky.util.ListUtils; -import com.keylesspalace.tusky.util.MentionTagTokenizer; +import com.keylesspalace.tusky.util.ComposeTokenizer; import com.keylesspalace.tusky.util.SaveTootHelper; import com.keylesspalace.tusky.util.SpanUtilsKt; import com.keylesspalace.tusky.util.StringUtils; @@ -111,6 +111,7 @@ import java.util.Date; import java.util.Iterator; import java.util.List; import java.util.Locale; +import java.util.concurrent.CountDownLatch; import javax.inject.Inject; @@ -157,7 +158,7 @@ import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvid public final class ComposeActivity extends BaseActivity implements ComposeOptionsListener, - MentionTagAutoCompleteAdapter.AutocompletionProvider, + ComposeAutoCompleteAdapter.AutocompletionProvider, OnEmojiSelectedListener, Injectable, InputConnectionCompat.OnCommitContentListener { @@ -226,6 +227,7 @@ public final class ComposeActivity private Uri photoUploadUri; private int savedTootUid = 0; private List emojiList; + private CountDownLatch emojiListRetrievalLatch = new CountDownLatch(1); private int maximumTootCharacters = STATUS_CHARACTER_LIMIT; private @Px int thumbnailViewSize; @@ -313,7 +315,7 @@ public final class ComposeActivity mastodonApi.getCustomEmojis().enqueue(new Callback>() { @Override public void onResponse(@NonNull Call> call, @NonNull Response> response) { - emojiList = response.body(); + List emojiList = response.body(); if(emojiList == null) { emojiList = Collections.emptyList(); } @@ -519,8 +521,8 @@ public final class ComposeActivity }); textEditor.setAdapter( - new MentionTagAutoCompleteAdapter(this)); - textEditor.setTokenizer(new MentionTagTokenizer()); + new ComposeAutoCompleteAdapter(this)); + textEditor.setTokenizer(new ComposeTokenizer()); // Add any mentions to the text field when a reply is first composed. if (mentionedUsernames != null) { @@ -1545,7 +1547,7 @@ public final class ComposeActivity } @Override - public List search(String token) { + public List search(String token) { try { switch (token.charAt(0)) { case '@': @@ -1557,18 +1559,53 @@ public final class ComposeActivity if (accountList != null) { resultList.addAll(accountList); } - return CollectionsKt.map(resultList, MentionTagAutoCompleteAdapter.AccountResult::new); + return CollectionsKt.map(resultList, ComposeAutoCompleteAdapter.AccountResult::new); case '#': Response response = mastodonApi.search(token, false).execute(); if (response.isSuccessful() && response.body() != null) { return CollectionsKt.map( response.body().getHashtags(), - MentionTagAutoCompleteAdapter.HashtagResult::new + ComposeAutoCompleteAdapter.HashtagResult::new ); } else { Log.e(TAG, String.format("Autocomplete search for %s failed.", token)); return Collections.emptyList(); } + case ':': + try { + emojiListRetrievalLatch.await(); + } catch (InterruptedException e) { + Log.e(TAG, String.format("Autocomplete search for %s was interrupted.", token)); + return Collections.emptyList(); + } + if (emojiList != null) { + String incomplete = token.substring(1).toLowerCase(); + + List results = + new ArrayList<>(); + List resultsInside = + new ArrayList<>(); + + for (Emoji emoji : emojiList) { + String shortcode = emoji.getShortcode().toLowerCase(); + + if (shortcode.startsWith(incomplete)) { + results.add(new ComposeAutoCompleteAdapter.EmojiResult(emoji)); + } else if (shortcode.indexOf(incomplete, 1) != -1) { + resultsInside.add(new ComposeAutoCompleteAdapter.EmojiResult(emoji)); + } + } + + if (!results.isEmpty() && !resultsInside.isEmpty()) { + // both lists have results. include a separator between them. + results.add(new ComposeAutoCompleteAdapter.ResultSeparator()); + } + + results.addAll(resultsInside); + return results; + } else { + return Collections.emptyList(); + } default: Log.w(TAG, "Unexpected autocompletion token: " + token); return Collections.emptyList(); @@ -1591,13 +1628,16 @@ public final class ComposeActivity if(instanceEntity != null) { Integer max = instanceEntity.getMaximumTootCharacters(); maximumTootCharacters = (max == null ? STATUS_CHARACTER_LIMIT : max); - emojiList = instanceEntity.getEmojiList(); - setEmojiList(emojiList); + setEmojiList(instanceEntity.getEmojiList()); updateVisibleCharactersLeft(); } } private void setEmojiList(@Nullable List emojiList) { + this.emojiList = emojiList; + + emojiListRetrievalLatch.countDown(); + if (emojiList != null) { emojiView.setAdapter(new EmojiAdapter(emojiList, ComposeActivity.this)); enableButton(emojiButton, true, emojiList.size() > 0); diff --git a/app/src/main/java/com/keylesspalace/tusky/adapter/MentionTagAutoCompleteAdapter.java b/app/src/main/java/com/keylesspalace/tusky/adapter/ComposeAutoCompleteAdapter.java similarity index 62% rename from app/src/main/java/com/keylesspalace/tusky/adapter/MentionTagAutoCompleteAdapter.java rename to app/src/main/java/com/keylesspalace/tusky/adapter/ComposeAutoCompleteAdapter.java index 84bef0cc..1a786c4b 100644 --- a/app/src/main/java/com/keylesspalace/tusky/adapter/MentionTagAutoCompleteAdapter.java +++ b/app/src/main/java/com/keylesspalace/tusky/adapter/ComposeAutoCompleteAdapter.java @@ -27,6 +27,7 @@ import android.widget.TextView; import com.keylesspalace.tusky.R; import com.keylesspalace.tusky.entity.Account; +import com.keylesspalace.tusky.entity.Emoji; import com.keylesspalace.tusky.util.CustomEmojiHelper; import com.squareup.picasso.Picasso; @@ -40,15 +41,17 @@ import androidx.annotation.Nullable; * Created by charlag on 12/11/17. */ -public class MentionTagAutoCompleteAdapter extends BaseAdapter +public class ComposeAutoCompleteAdapter extends BaseAdapter implements Filterable { - private static final int ACCOUNT_VIEW_TYPE = 0; - private static final int HASHTAG_VIEW_TYPE = 1; + private static final int ACCOUNT_VIEW_TYPE = 1; + private static final int HASHTAG_VIEW_TYPE = 2; + private static final int EMOJI_VIEW_TYPE = 3; + private static final int SEPARATOR_VIEW_TYPE = 0; private final ArrayList resultList; private final AutocompletionProvider autocompletionProvider; - public MentionTagAutoCompleteAdapter(AutocompletionProvider autocompletionProvider) { + public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider) { super(); resultList = new ArrayList<>(); this.autocompletionProvider = autocompletionProvider; @@ -77,8 +80,12 @@ public class MentionTagAutoCompleteAdapter extends BaseAdapter public CharSequence convertResultToString(Object resultValue) { if (resultValue instanceof AccountResult) { return formatUsername(((AccountResult) resultValue)); - } else { + } else if (resultValue instanceof HashtagResult) { return formatHashtag((HashtagResult) resultValue); + } else if (resultValue instanceof EmojiResult) { + return formatEmoji((EmojiResult) resultValue); + } else { + return ""; } } @@ -117,9 +124,8 @@ public class MentionTagAutoCompleteAdapter extends BaseAdapter switch (getItemViewType(position)) { case ACCOUNT_VIEW_TYPE: - AccountViewHolder holder; + AccountViewHolder accountViewHolder; if (convertView == null) { - //noinspection ConstantConditions view = ((LayoutInflater) context .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) .inflate(R.layout.item_autocomplete_account, parent, false); @@ -127,22 +133,24 @@ public class MentionTagAutoCompleteAdapter extends BaseAdapter if (view.getTag() == null) { view.setTag(new AccountViewHolder(view)); } - holder = (AccountViewHolder) view.getTag(); + accountViewHolder = (AccountViewHolder) view.getTag(); AccountResult accountResult = ((AccountResult) getItem(position)); if (accountResult != null) { Account account = accountResult.account; - String format = context.getString(R.string.status_username_format); - String formattedUsername = String.format(format, account.getUsername()); - holder.username.setText(formattedUsername); + String formattedUsername = context.getString( + R.string.status_username_format, + account.getUsername() + ); + accountViewHolder.username.setText(formattedUsername); CharSequence emojifiedName = CustomEmojiHelper.emojifyString(account.getName(), - account.getEmojis(), holder.displayName); - holder.displayName.setText(emojifiedName); + account.getEmojis(), accountViewHolder.displayName); + accountViewHolder.displayName.setText(emojifiedName); if (!account.getAvatar().isEmpty()) { Picasso.with(context) .load(account.getAvatar()) .placeholder(R.drawable.avatar_default) - .into(holder.avatar); + .into(accountViewHolder.avatar); } } break; @@ -151,7 +159,7 @@ public class MentionTagAutoCompleteAdapter extends BaseAdapter if (convertView == null) { view = ((LayoutInflater) context .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) - .inflate(R.layout.item_hashtag, parent, false); + .inflate(R.layout.item_autocomplete_hashtag, parent, false); } HashtagResult result = (HashtagResult) getItem(position); @@ -159,6 +167,40 @@ public class MentionTagAutoCompleteAdapter extends BaseAdapter ((TextView) view).setText(formatHashtag(result)); } break; + + case EMOJI_VIEW_TYPE: + EmojiViewHolder emojiViewHolder; + if (convertView == null) { + view = ((LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) + .inflate(R.layout.item_autocomplete_emoji, parent, false); + } + if (view.getTag() == null) { + view.setTag(new EmojiViewHolder(view)); + } + emojiViewHolder = (EmojiViewHolder) view.getTag(); + + EmojiResult emojiResult = ((EmojiResult) getItem(position)); + if (emojiResult != null) { + Emoji emoji = emojiResult.emoji; + String formattedShortcode = context.getString( + R.string.emoji_shortcode_format, + emoji.getShortcode() + ); + emojiViewHolder.shortcode.setText(formattedShortcode); + Picasso.with(context) + .load(emoji.getUrl()) + .into(emojiViewHolder.preview); + } + break; + + case SEPARATOR_VIEW_TYPE: + if (convertView == null) { + view = ((LayoutInflater) context + .getSystemService(Context.LAYOUT_INFLATER_SERVICE)) + .inflate(R.layout.item_autocomplete_divider, parent, false); + } + break; default: throw new AssertionError("unknown view type"); } @@ -174,20 +216,41 @@ public class MentionTagAutoCompleteAdapter extends BaseAdapter return String.format("#%s", result.hashtag); } + private String formatEmoji(EmojiResult result) { + return String.format(":%s:", result.emoji.getShortcode()); + } + @Override public int getViewTypeCount() { - return 2; + return 4; } @Override public int getItemViewType(int position) { - if (getItem(position) instanceof AccountResult) { + AutocompleteResult item = getItem(position); + + if (item instanceof AccountResult) { return ACCOUNT_VIEW_TYPE; - } else { + } else if (item instanceof HashtagResult) { return HASHTAG_VIEW_TYPE; + } else if (item instanceof EmojiResult) { + return EMOJI_VIEW_TYPE; + } else { + return SEPARATOR_VIEW_TYPE; } } + @Override + public boolean areAllItemsEnabled() { + // there may be separators + return false; + } + + @Override + public boolean isEnabled(int position) { + return !(getItem(position) instanceof ResultSeparator); + } + public abstract static class AutocompleteResult { AutocompleteResult() { } @@ -209,6 +272,16 @@ public class MentionTagAutoCompleteAdapter extends BaseAdapter } } + public final static class EmojiResult extends AutocompleteResult { + private final Emoji emoji; + + public EmojiResult(Emoji emoji) { + this.emoji = emoji; + } + } + + public final static class ResultSeparator extends AutocompleteResult {} + public interface AutocompletionProvider { List search(String mention); } @@ -224,4 +297,14 @@ public class MentionTagAutoCompleteAdapter extends BaseAdapter avatar = view.findViewById(R.id.avatar); } } + + private class EmojiViewHolder { + final TextView shortcode; + final ImageView preview; + + private EmojiViewHolder(View view) { + shortcode = view.findViewById(R.id.shortcode); + preview = view.findViewById(R.id.preview); + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MentionTagTokenizer.kt b/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt similarity index 91% rename from app/src/main/java/com/keylesspalace/tusky/util/MentionTagTokenizer.kt rename to app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt index 76adc3a4..a1294333 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/MentionTagTokenizer.kt +++ b/app/src/main/java/com/keylesspalace/tusky/util/ComposeTokenizer.kt @@ -20,14 +20,14 @@ import android.text.Spanned import android.text.TextUtils import android.widget.MultiAutoCompleteTextView -class MentionTagTokenizer : MultiAutoCompleteTextView.Tokenizer { +class ComposeTokenizer : MultiAutoCompleteTextView.Tokenizer { override fun findTokenStart(text: CharSequence, cursor: Int): Int { if (cursor == 0) { return cursor } var i = cursor var character = text[i - 1] - while (i > 0 && character != '@' && character != '#') { + while (i > 0 && character != '@' && character != '#' && character != ':') { // See SpanUtils.MENTION_REGEX if (!Character.isLetterOrDigit(character) && character != '_') { return cursor @@ -36,7 +36,7 @@ class MentionTagTokenizer : MultiAutoCompleteTextView.Tokenizer { character = if (i == 0) ' ' else text[i - 1] } if (i < 1 - || (character != '@' && character != '#') + || (character != '@' && character != '#' && character != ':') || i > 1 && !Character.isWhitespace(text[i - 2])) { return cursor } diff --git a/app/src/main/res/drawable/autocomplete_divider_dark.xml b/app/src/main/res/drawable/autocomplete_divider_dark.xml new file mode 100644 index 00000000..fea67422 --- /dev/null +++ b/app/src/main/res/drawable/autocomplete_divider_dark.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/autocomplete_divider_light.xml b/app/src/main/res/drawable/autocomplete_divider_light.xml new file mode 100644 index 00000000..86694405 --- /dev/null +++ b/app/src/main/res/drawable/autocomplete_divider_light.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_autocomplete_divider.xml b/app/src/main/res/layout/item_autocomplete_divider.xml new file mode 100644 index 00000000..38ff1b88 --- /dev/null +++ b/app/src/main/res/layout/item_autocomplete_divider.xml @@ -0,0 +1,5 @@ + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_autocomplete_emoji.xml b/app/src/main/res/layout/item_autocomplete_emoji.xml new file mode 100644 index 00000000..2f910040 --- /dev/null +++ b/app/src/main/res/layout/item_autocomplete_emoji.xml @@ -0,0 +1,33 @@ + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/item_autocomplete_hashtag.xml b/app/src/main/res/layout/item_autocomplete_hashtag.xml new file mode 100644 index 00000000..33247421 --- /dev/null +++ b/app/src/main/res/layout/item_autocomplete_hashtag.xml @@ -0,0 +1,8 @@ + + diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index 44f6a85b..d86235c5 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -72,6 +72,8 @@ @color/compound_button_color_dark + @drawable/autocomplete_divider_dark + @style/PreferenceThemeOverlay 32dp diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index 1ba57ba9..5db4de02 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -38,6 +38,7 @@ + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index a8f9e72f..3cd5fd7d 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -28,6 +28,7 @@ #586173 #313543 #373c4b + #424a5b #000000 #111111 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 168a8d05..48b04f8e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -355,6 +355,7 @@ Your device\'s default emoji set The Blob emojis known from Android 4.4–7.1 Mastodon\'s standard emoji set + :%s: Download failed diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index df9fc98f..67dc25ef 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -120,6 +120,8 @@ @color/compound_button_color_light + @drawable/autocomplete_divider_light + @style/PreferenceThemeOverlay 32dp diff --git a/app/src/test/java/com/keylesspalace/tusky/MentionTagTokenizerTest.kt b/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt similarity index 77% rename from app/src/test/java/com/keylesspalace/tusky/MentionTagTokenizerTest.kt rename to app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt index 7e41819f..73a00670 100644 --- a/app/src/test/java/com/keylesspalace/tusky/MentionTagTokenizerTest.kt +++ b/app/src/test/java/com/keylesspalace/tusky/ComposeTokenizerTest.kt @@ -15,16 +15,16 @@ package com.keylesspalace.tusky -import com.keylesspalace.tusky.util.MentionTagTokenizer +import com.keylesspalace.tusky.util.ComposeTokenizer import org.junit.Assert import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized @RunWith(Parameterized::class) -class MentionTagTokenizerTest(private val text: CharSequence, - private val expectedStartIndex: Int, - private val expectedEndIndex: Int) { +class ComposeTokenizerTest(private val text: CharSequence, + private val expectedStartIndex: Int, + private val expectedEndIndex: Int) { companion object { @Parameterized.Parameters(name = "{0}") @@ -50,12 +50,18 @@ class MentionTagTokenizerTest(private val text: CharSequence, arrayOf("#tusky", 0, 6), arrayOf("#@tusky", 7, 7), arrayOf("@#tusky", 7, 7), - arrayOf(" @#tusky", 8, 8) + arrayOf(" @#tusky", 8, 8), + arrayOf(":mastodon", 0, 9), + arrayOf(":@mastodon", 10, 10), + arrayOf("@:mastodon", 10, 10), + arrayOf(" @:mastodon", 11, 11), + arrayOf("#@:mastodon", 11, 11), + arrayOf(" #@:mastodon", 12, 12) ) } } - private val tokenizer = MentionTagTokenizer() + private val tokenizer = ComposeTokenizer() @Test fun tokenIndices_matchExpectations() {