Add hashtag autocompletion, closes #769 (#1001)

* Add hashtag autocompletion, closes #769

* Apply review feedback
This commit is contained in:
Ivan Kupalov 2019-01-28 11:04:05 +01:00 committed by Konrad Pozniak
parent 96162ab544
commit a3ee13d767
7 changed files with 352 additions and 244 deletions

View file

@ -18,7 +18,6 @@ package com.keylesspalace.tusky;
import android.Manifest;
import android.annotation.SuppressLint;
import android.app.ProgressDialog;
import androidx.lifecycle.Lifecycle;
import android.content.ContentResolver;
import android.content.Context;
import android.content.DialogInterface;
@ -39,25 +38,6 @@ import android.os.Parcel;
import android.os.Parcelable;
import android.preference.PreferenceManager;
import android.provider.MediaStore;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.StringRes;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
import com.google.android.material.snackbar.Snackbar;
import androidx.transition.TransitionManager;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.appcompat.widget.Toolbar;
import android.text.Editable;
import android.text.InputFilter;
import android.text.InputType;
@ -81,10 +61,12 @@ import android.widget.PopupMenu;
import android.widget.TextView;
import android.widget.Toast;
import com.google.android.material.bottomsheet.BottomSheetBehavior;
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.MentionAutoCompleteAdapter;
import com.keylesspalace.tusky.adapter.MentionTagAutoCompleteAdapter;
import com.keylesspalace.tusky.adapter.OnEmojiSelectedListener;
import com.keylesspalace.tusky.db.AccountEntity;
import com.keylesspalace.tusky.db.AppDatabase;
@ -94,6 +76,7 @@ import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Attachment;
import com.keylesspalace.tusky.entity.Emoji;
import com.keylesspalace.tusky.entity.Instance;
import com.keylesspalace.tusky.entity.SearchResults;
import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.network.MastodonApi;
import com.keylesspalace.tusky.network.ProgressRequestBody;
@ -101,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.MentionTokenizer;
import com.keylesspalace.tusky.util.MentionTagTokenizer;
import com.keylesspalace.tusky.util.SaveTootHelper;
import com.keylesspalace.tusky.util.SpanUtilsKt;
import com.keylesspalace.tusky.util.StringUtils;
@ -131,12 +114,31 @@ import java.util.Locale;
import javax.inject.Inject;
import androidx.annotation.ColorInt;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.Px;
import androidx.annotation.StringRes;
import androidx.appcompat.app.ActionBar;
import androidx.appcompat.app.AlertDialog;
import androidx.appcompat.content.res.AppCompatResources;
import androidx.appcompat.widget.Toolbar;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
import androidx.core.content.FileProvider;
import androidx.core.view.inputmethod.InputConnectionCompat;
import androidx.core.view.inputmethod.InputContentInfoCompat;
import androidx.lifecycle.Lifecycle;
import androidx.recyclerview.widget.GridLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import androidx.transition.TransitionManager;
import at.connyduck.sparkbutton.helpers.Utils;
import io.reactivex.Single;
import io.reactivex.SingleObserver;
import io.reactivex.android.schedulers.AndroidSchedulers;
import io.reactivex.disposables.Disposable;
import io.reactivex.schedulers.Schedulers;
import kotlin.collections.CollectionsKt;
import okhttp3.MediaType;
import okhttp3.MultipartBody;
import retrofit2.Call;
@ -155,7 +157,7 @@ import static com.uber.autodispose.android.lifecycle.AndroidLifecycleScopeProvid
public final class ComposeActivity
extends BaseActivity
implements ComposeOptionsListener,
MentionAutoCompleteAdapter.AccountSearchProvider,
MentionTagAutoCompleteAdapter.AutocompletionProvider,
OnEmojiSelectedListener,
Injectable, InputConnectionCompat.OnCommitContentListener {
@ -225,7 +227,8 @@ public final class ComposeActivity
private int savedTootUid = 0;
private List<Emoji> emojiList;
private int maximumTootCharacters = STATUS_CHARACTER_LIMIT;
private @Px int thumbnailViewSize;
private @Px
int thumbnailViewSize;
private SaveTootHelper saveTootHelper;
private Gson gson = new Gson();
@ -516,8 +519,8 @@ public final class ComposeActivity
});
textEditor.setAdapter(
new MentionAutoCompleteAdapter(this, R.layout.item_autocomplete, this));
textEditor.setTokenizer(new MentionTokenizer());
new MentionTagAutoCompleteAdapter(this));
textEditor.setTokenizer(new MentionTagTokenizer());
// Add any mentions to the text field when a reply is first composed.
if (mentionedUsernames != null) {
@ -983,7 +986,7 @@ public final class ComposeActivity
spoilerText = contentWarningEditor.getText().toString();
}
int characterCount = calculateTextLength();
if (characterCount <= 0 && mediaQueued.size()==0) {
if (characterCount <= 0 && mediaQueued.size() == 0) {
textEditor.setError(getString(R.string.error_empty));
enableButtons();
} else if (characterCount <= maximumTootCharacters) {
@ -1532,19 +1535,38 @@ public final class ComposeActivity
}
@Override
public List<Account> searchAccounts(String mention) {
ArrayList<Account> resultList = new ArrayList<>();
public List<MentionTagAutoCompleteAdapter.AutocompleteResult> search(String token) {
try {
List<Account> accountList = mastodonApi.searchAccounts(mention, false, 40)
.execute()
.body();
if (accountList != null) {
resultList.addAll(accountList);
switch (token.charAt(0)) {
case '@':
ArrayList<Account> resultList = new ArrayList<>();
List<Account> accountList = mastodonApi
.searchAccounts(token.substring(1), false, 20)
.execute()
.body();
if (accountList != null) {
resultList.addAll(accountList);
}
return CollectionsKt.map(resultList, MentionTagAutoCompleteAdapter.AccountResult::new);
case '#':
Response<SearchResults> response = mastodonApi.search(token, false).execute();
if (response.isSuccessful() && response.body() != null) {
return CollectionsKt.map(
response.body().getHashtags(),
MentionTagAutoCompleteAdapter.HashtagResult::new
);
} else {
Log.e(TAG, String.format("Autocomplete search for %s failed.", token));
return Collections.emptyList();
}
default:
Log.w(TAG, "Unexpected autocompletion token: " + token);
return Collections.emptyList();
}
} catch (IOException e) {
Log.e(TAG, String.format("Autocomplete search for %s failed.", mention));
Log.e(TAG, String.format("Autocomplete search for %s failed.", token));
return Collections.emptyList();
}
return resultList;
}
@Override

View file

@ -1,143 +0,0 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter;
import android.content.Context;
import androidx.annotation.LayoutRes;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ArrayAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.TextView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.squareup.picasso.Picasso;
import java.util.ArrayList;
import java.util.List;
/**
* Created by charlag on 12/11/17.
*/
public class MentionAutoCompleteAdapter extends ArrayAdapter<Account>
implements Filterable {
private ArrayList<Account> resultList;
@LayoutRes
private int layoutId;
private final AccountSearchProvider accountSearchProvider;
public MentionAutoCompleteAdapter(Context context, @LayoutRes int resource,
AccountSearchProvider accountSearchProvider) {
super(context, resource);
layoutId = resource;
resultList = new ArrayList<>();
this.accountSearchProvider = accountSearchProvider;
}
@Override
public int getCount() {
return resultList.size();
}
@Override
public Account getItem(int index) {
return resultList.get(index);
}
@Override
@NonNull
public Filter getFilter() {
return new Filter() {
@Override
public CharSequence convertResultToString(Object resultValue) {
return ((Account) resultValue).getUsername();
}
// This method is invoked in a worker thread.
@Override
protected FilterResults performFiltering(CharSequence constraint) {
FilterResults filterResults = new FilterResults();
if (constraint != null) {
List<Account> accounts =
accountSearchProvider.searchAccounts(constraint.toString());
filterResults.values = accounts;
filterResults.count = accounts.size();
}
return filterResults;
}
@SuppressWarnings("unchecked")
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
if (results != null && results.count > 0) {
resultList.clear();
ArrayList<Account> newResults = (ArrayList<Account>) results.values;
resultList.addAll(newResults);
notifyDataSetChanged();
} else {
notifyDataSetInvalidated();
}
}
};
}
@Override
@NonNull
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = convertView;
Context context = getContext();
if (convertView == null) {
LayoutInflater layoutInflater =
(LayoutInflater) context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
//noinspection ConstantConditions
view = layoutInflater.inflate(layoutId, parent, false);
}
Account account = getItem(position);
if (account != null) {
TextView username = view.findViewById(R.id.username);
TextView displayName = view.findViewById(R.id.display_name);
ImageView avatar = view.findViewById(R.id.avatar);
String format = getContext().getString(R.string.status_username_format);
String formattedUsername = String.format(format, account.getUsername());
username.setText(formattedUsername);
CharSequence emojifiedName = CustomEmojiHelper.emojifyString(account.getName(), account.getEmojis(), displayName);
displayName.setText(emojifiedName);
if (!account.getAvatar().isEmpty()) {
Picasso.with(context)
.load(account.getAvatar())
.placeholder(R.drawable.avatar_default)
.into(avatar);
}
}
return view;
}
public interface AccountSearchProvider {
List<Account> searchAccounts(String mention);
}
}

View file

@ -0,0 +1,223 @@
/* Copyright 2017 Andrew Dawson
*
* This file is a part of Tusky.
*
* This program 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.adapter;
import android.content.Context;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.BaseAdapter;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageView;
import android.widget.TextView;
import com.keylesspalace.tusky.R;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.util.CustomEmojiHelper;
import com.squareup.picasso.Picasso;
import java.util.ArrayList;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
/**
* Created by charlag on 12/11/17.
*/
public class MentionTagAutoCompleteAdapter extends BaseAdapter
implements Filterable {
private static final int ACCOUNT_VIEW_TYPE = 0;
private static final int HASHTAG_VIEW_TYPE = 1;
private final ArrayList<AutocompleteResult> resultList;
private final AutocompletionProvider autocompletionProvider;
public MentionTagAutoCompleteAdapter(AutocompletionProvider autocompletionProvider) {
super();
resultList = new ArrayList<>();
this.autocompletionProvider = autocompletionProvider;
}
@Override
public int getCount() {
return resultList.size();
}
@Override
public AutocompleteResult getItem(int index) {
return resultList.get(index);
}
@Override
public long getItemId(int position) {
return position;
}
@Override
@NonNull
public Filter getFilter() {
return new Filter() {
@Override
public CharSequence convertResultToString(Object resultValue) {
if (resultValue instanceof AccountResult) {
return ((AccountResult) resultValue).account.getUsername();
} else {
return formatHashtag((HashtagResult) resultValue);
}
}
// This method is invoked in a worker thread.
@Override
protected FilterResults performFiltering(CharSequence constraint) {
FilterResults filterResults = new FilterResults();
if (constraint != null) {
List<AutocompleteResult> results =
autocompletionProvider.search(constraint.toString());
filterResults.values = results;
filterResults.count = results.size();
}
return filterResults;
}
@SuppressWarnings("unchecked")
@Override
protected void publishResults(CharSequence constraint, FilterResults results) {
if (results != null && results.count > 0) {
resultList.clear();
resultList.addAll((List<AutocompleteResult>) results.values);
notifyDataSetChanged();
} else {
notifyDataSetInvalidated();
}
}
};
}
@Override
@NonNull
public View getView(int position, @Nullable View convertView, @NonNull ViewGroup parent) {
View view = convertView;
final Context context = parent.getContext();
switch (getItemViewType(position)) {
case ACCOUNT_VIEW_TYPE:
AccountViewHolder holder;
if (convertView == null) {
//noinspection ConstantConditions
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_autocomplete_account, parent, false);
}
if (view.getTag() == null) {
view.setTag(new AccountViewHolder(view));
}
holder = (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);
CharSequence emojifiedName = CustomEmojiHelper.emojifyString(account.getName(),
account.getEmojis(), holder.displayName);
holder.displayName.setText(emojifiedName);
if (!account.getAvatar().isEmpty()) {
Picasso.with(context)
.load(account.getAvatar())
.placeholder(R.drawable.avatar_default)
.into(holder.avatar);
}
}
break;
case HASHTAG_VIEW_TYPE:
if (convertView == null) {
view = ((LayoutInflater) context
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
.inflate(R.layout.item_hashtag, parent, false);
}
HashtagResult result = (HashtagResult) getItem(position);
if (result != null) {
((TextView) view).setText(formatHashtag(result));
}
break;
default:
throw new AssertionError("unknown view type");
}
return view;
}
private String formatHashtag(HashtagResult result) {
return String.format("#%s", result.hashtag);
}
@Override
public int getViewTypeCount() {
return 2;
}
@Override
public int getItemViewType(int position) {
if (getItem(position) instanceof AccountResult) {
return ACCOUNT_VIEW_TYPE;
} else {
return HASHTAG_VIEW_TYPE;
}
}
public abstract static class AutocompleteResult {
AutocompleteResult() {
}
}
public final static class AccountResult extends AutocompleteResult {
private final Account account;
public AccountResult(Account account) {
this.account = account;
}
}
public final static class HashtagResult extends AutocompleteResult {
private final String hashtag;
public HashtagResult(String hashtag) {
this.hashtag = hashtag;
}
}
public interface AutocompletionProvider {
List<AutocompleteResult> search(String mention);
}
private class AccountViewHolder {
final TextView username;
final TextView displayName;
final ImageView avatar;
private AccountViewHolder(View view) {
username = view.findViewById(R.id.username);
displayName = view.findViewById(R.id.display_name);
avatar = view.findViewById(R.id.avatar);
}
}
}

View file

@ -20,14 +20,14 @@ import android.text.Spanned
import android.text.TextUtils
import android.widget.MultiAutoCompleteTextView
class MentionTokenizer : MultiAutoCompleteTextView.Tokenizer {
class MentionTagTokenizer : 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 != '@') {
while (i > 0 && character != '@' && character != '#') {
// See SpanUtils.MENTION_REGEX
if (!Character.isLetterOrDigit(character) && character != '_') {
return cursor
@ -35,10 +35,12 @@ class MentionTokenizer : MultiAutoCompleteTextView.Tokenizer {
i--
character = if (i == 0) ' ' else text[i - 1]
}
if (i < 1 || character != '@') {
if (i < 1
|| (character != '@' && character != '#')
|| i > 1 && !Character.isWhitespace(text[i - 2])) {
return cursor
}
return i
return i - 1
}
override fun findTokenEnd(text: CharSequence, cursor: Int): Int {

View file

@ -0,0 +1,65 @@
/* Copyright 2018 Levi Bard
*
* This file is a part of Tusky.
*
* This program 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import com.keylesspalace.tusky.util.MentionTagTokenizer
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) {
companion object {
@Parameterized.Parameters(name = "{0}")
@JvmStatic
fun data(): Iterable<Any> {
return listOf(
arrayOf("@mention", 0, 8),
arrayOf("@ment10n", 0, 8),
arrayOf("@ment10n_", 0, 9),
arrayOf("@ment10n_n", 0, 10),
arrayOf("@ment10n_9", 0, 10),
arrayOf(" @mention", 1, 9),
arrayOf(" @ment10n", 1, 9),
arrayOf(" @ment10n_", 1, 10),
arrayOf(" @ment10n_ @", 11, 12),
arrayOf(" @ment10n_ @ment20n", 11, 19),
arrayOf(" @ment10n_ @ment20n_", 11, 20),
arrayOf(" @ment10n_ @ment20n_n", 11, 21),
arrayOf(" @ment10n_ @ment20n_9", 11, 21),
arrayOf("mention", 7, 7),
arrayOf("ment10n", 7, 7),
arrayOf("mentio_", 7, 7),
arrayOf("#tusky", 0, 6),
arrayOf("#@tusky", 7, 7),
arrayOf("@#tusky", 7, 7),
arrayOf(" @#tusky", 8, 8)
)
}
}
private val tokenizer = MentionTagTokenizer()
@Test
fun tokenIndices_matchExpectations() {
Assert.assertEquals(expectedStartIndex, tokenizer.findTokenStart(text, text.length))
Assert.assertEquals(expectedEndIndex, tokenizer.findTokenEnd(text, text.length))
}
}

View file

@ -1,61 +0,0 @@
/* Copyright 2018 Levi Bard
*
* This file is a part of Tusky.
*
* This program 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.
*
* Tusky 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 Tusky; if not,
* see <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky
import com.keylesspalace.tusky.util.MentionTokenizer
import org.junit.Assert
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.runners.Parameterized
@RunWith(Parameterized::class)
class MentionTokenizerTest(private val text: CharSequence,
private val expectedStartIndex: Int,
private val expectedEndIndex: Int) {
companion object {
@Parameterized.Parameters(name = "{0}")
@JvmStatic
fun data(): Iterable<Any> {
return listOf(
arrayOf("@mention", 1, 8),
arrayOf("@ment10n", 1, 8),
arrayOf("@ment10n_", 1, 9),
arrayOf("@ment10n_n", 1, 10),
arrayOf("@ment10n_9", 1, 10),
arrayOf(" @mention", 2, 9),
arrayOf(" @ment10n", 2, 9),
arrayOf(" @ment10n_", 2, 10),
arrayOf(" @ment10n_ @", 12, 12),
arrayOf(" @ment10n_ @ment20n", 12, 19),
arrayOf(" @ment10n_ @ment20n_", 12, 20),
arrayOf(" @ment10n_ @ment20n_n", 12, 21),
arrayOf(" @ment10n_ @ment20n_9", 12, 21),
arrayOf("mention", 7, 7),
arrayOf("ment10n", 7, 7),
arrayOf("mentio_", 7, 7)
)
}
}
private val tokenizer = MentionTokenizer()
@Test
fun tokenIndices_matchExpectations() {
Assert.assertEquals(expectedStartIndex, tokenizer.findTokenStart(text, text.length))
Assert.assertEquals(expectedEndIndex, tokenizer.findTokenEnd(text, text.length))
}
}