From 74aa866647c1b18407442a830a11d879d4746e07 Mon Sep 17 00:00:00 2001 From: Vavassor Date: Sun, 18 Jun 2017 22:10:50 -0400 Subject: [PATCH] Autocomplete @ mentions in the composer. Closes #103 --- .../keylesspalace/tusky/ComposeActivity.java | 77 ++++++++++++++++++- .../keylesspalace/tusky/util/MediaUtils.java | 15 ++++ .../tusky/util/MentionTokenizer.java | 66 ++++++++++++++++ .../tusky/view/EditTextTyped.java | 14 ++-- app/src/main/res/layout/activity_compose.xml | 4 +- 5 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/keylesspalace/tusky/util/MentionTokenizer.java diff --git a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java index c4c99b64..90d9a388 100644 --- a/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java +++ b/app/src/main/java/com/keylesspalace/tusky/ComposeActivity.java @@ -18,6 +18,7 @@ package com.keylesspalace.tusky; import android.Manifest; import android.app.ProgressDialog; import android.content.ContentResolver; +import android.content.Context; import android.content.DialogInterface; import android.content.Intent; import android.content.SharedPreferences; @@ -39,6 +40,7 @@ import android.os.Parcel; import android.os.Parcelable; import android.provider.MediaStore; import android.support.annotation.AttrRes; +import android.support.annotation.LayoutRes; import android.support.annotation.NonNull; import android.support.annotation.StringRes; import android.support.design.widget.Snackbar; @@ -60,14 +62,18 @@ import android.util.Log; import android.view.MenuItem; import android.view.View; import android.webkit.MimeTypeMap; +import android.widget.ArrayAdapter; import android.widget.Button; import android.widget.EditText; +import android.widget.Filter; +import android.widget.Filterable; import android.widget.ImageButton; import android.widget.ImageView; import android.widget.LinearLayout; import android.widget.ProgressBar; import android.widget.TextView; +import com.keylesspalace.tusky.entity.Account; import com.keylesspalace.tusky.entity.Media; import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.fragment.ComposeOptionsFragment; @@ -75,6 +81,7 @@ import com.keylesspalace.tusky.util.CountUpDownLatch; import com.keylesspalace.tusky.util.DownsizeImageTask; import com.keylesspalace.tusky.util.IOUtils; import com.keylesspalace.tusky.util.MediaUtils; +import com.keylesspalace.tusky.util.MentionTokenizer; import com.keylesspalace.tusky.util.ParserUtils; import com.keylesspalace.tusky.util.SpanUtils; import com.keylesspalace.tusky.util.ThemeUtils; @@ -313,6 +320,10 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm } }); + textEditor.setAdapter(new MentionAutoCompleteAdapter(this, + android.R.layout.simple_dropdown_item_1line)); + textEditor.setTokenizer(new MentionTokenizer()); + // Add any mentions to the text field when a reply is first composed. if (mentionedUsernames != null) { StringBuilder builder = new StringBuilder(); @@ -1220,7 +1231,6 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm } } - // remove the precedent paste from the edit text private void cleanBaseUrl(ParserUtils.HeaderInfo headerInfo) { int lengthBaseUrl = headerInfo.baseUrl.length(); @@ -1236,6 +1246,28 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm displayTransientError(R.string.error_generic); } + /** + * Does a synchronous search request for usernames fulfilling the given partial mention text. + */ + private ArrayList autocompleteMention(String mention) { + ArrayList resultList = new ArrayList<>(); + try { + List accountList = mastodonAPI.searchAccounts(mention, false, 5) + .execute() + .body(); + /* Match only accounts whose username contains the partial mention text, because + searches also return matches for display names, which aren't relevant here. */ + for (Account account : accountList) { + if (account.username.toLowerCase().contains(mention.toLowerCase())) { + resultList.add(account.username); + } + } + } catch (IOException e) { + Log.e(TAG, String.format("Autocomplete search for %s failed.", mention)); + } + return resultList; + } + private static class QueuedMedia { Type type; ImageView preview; @@ -1311,4 +1343,47 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm dest.writeLong(mediaSize); } } + + private class MentionAutoCompleteAdapter extends ArrayAdapter implements Filterable { + private ArrayList resultList; + + MentionAutoCompleteAdapter(Context context, @LayoutRes int resource) { + super(context, resource); + } + + @Override + public int getCount() { + return resultList.size(); + } + + @Override + public String getItem(int index) { + return resultList.get(index); + } + + @Override @NonNull + public Filter getFilter() { + return new Filter() { + @Override + protected FilterResults performFiltering(CharSequence constraint) { + FilterResults filterResults = new FilterResults(); + if (constraint != null) { + resultList = autocompleteMention(constraint.toString()); + filterResults.values = resultList; + filterResults.count = resultList.size(); + } + return filterResults; + } + + @Override + protected void publishResults(CharSequence constraint, FilterResults results) { + if (results != null && results.count > 0) { + notifyDataSetChanged(); + } else { + notifyDataSetInvalidated(); + } + } + }; + } + } } diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.java b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.java index 76d18bde..9265aadf 100644 --- a/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.java +++ b/app/src/main/java/com/keylesspalace/tusky/util/MediaUtils.java @@ -1,3 +1,18 @@ +/* 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 . */ + package com.keylesspalace.tusky.util; import android.content.ContentResolver; diff --git a/app/src/main/java/com/keylesspalace/tusky/util/MentionTokenizer.java b/app/src/main/java/com/keylesspalace/tusky/util/MentionTokenizer.java new file mode 100644 index 00000000..f7bab03a --- /dev/null +++ b/app/src/main/java/com/keylesspalace/tusky/util/MentionTokenizer.java @@ -0,0 +1,66 @@ +/* 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 . */ + +package com.keylesspalace.tusky.util; + +import android.text.SpannableString; +import android.text.Spanned; +import android.text.TextUtils; +import android.widget.MultiAutoCompleteTextView; + +public class MentionTokenizer implements MultiAutoCompleteTextView.Tokenizer { + @Override + public int findTokenStart(CharSequence text, int cursor) { + int i = cursor; + while (i > 0 && text.charAt(i - 1) != '@') { + i--; + } + if (i < 1 || text.charAt(i - 1) != '@') { + return cursor; + } + return i; + } + + @Override + public int findTokenEnd(CharSequence text, int cursor) { + int i = cursor; + int length = text.length(); + while (i < length) { + if (text.charAt(i) == ' ') { + return i; + } else { + i++; + } + } + return length; + } + + @Override + public CharSequence terminateToken(CharSequence text) { + int i = text.length(); + while (i > 0 && text.charAt(i - 1) == ' ') { + i--; + } + if (i > 0 && text.charAt(i - 1) == ' ') { + return text; + } else if (text instanceof Spanned) { + SpannableString s = new SpannableString(text + " "); + TextUtils.copySpansFrom((Spanned) text, 0, text.length(), Object.class, s, 0); + return s; + } else { + return text + " "; + } + } +} diff --git a/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.java b/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.java index 4693ee35..0cf73eef 100644 --- a/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.java +++ b/app/src/main/java/com/keylesspalace/tusky/view/EditTextTyped.java @@ -18,18 +18,18 @@ package com.keylesspalace.tusky.view; import android.content.Context; import android.support.v13.view.inputmethod.EditorInfoCompat; import android.support.v13.view.inputmethod.InputConnectionCompat; -import android.support.v7.widget.AppCompatEditText; +import android.support.v7.widget.AppCompatMultiAutoCompleteTextView; import android.util.AttributeSet; import android.view.inputmethod.EditorInfo; import android.view.inputmethod.InputConnection; import com.keylesspalace.tusky.util.Assert; -public class EditTextTyped extends AppCompatEditText { +public class EditTextTyped extends AppCompatMultiAutoCompleteTextView { InputConnectionCompat.OnCommitContentListener onCommitContentListener; String[] mimeTypes; - private OnPasteListener mOnPasteListener; + private OnPasteListener onPasteListener; public EditTextTyped(Context context) { super(context); @@ -40,7 +40,7 @@ public class EditTextTyped extends AppCompatEditText { } public void addOnPasteListener(OnPasteListener mOnPasteListener) { - this.mOnPasteListener = mOnPasteListener; + this.onPasteListener = mOnPasteListener; } public void setMimeTypes(String[] types, @@ -68,6 +68,7 @@ public class EditTextTyped extends AppCompatEditText { switch (id) { case android.R.id.paste: onPaste(); + break; } return consumed; } @@ -76,8 +77,9 @@ public class EditTextTyped extends AppCompatEditText { * Text was pasted into the EditText. */ public void onPaste() { - if (mOnPasteListener != null) - mOnPasteListener.onPaste(); + if (onPasteListener != null) { + onPasteListener.onPaste(); + } } public interface OnPasteListener { diff --git a/app/src/main/res/layout/activity_compose.xml b/app/src/main/res/layout/activity_compose.xml index 1f5b14f1..0737da5a 100644 --- a/app/src/main/res/layout/activity_compose.xml +++ b/app/src/main/res/layout/activity_compose.xml @@ -62,7 +62,9 @@ android:ems="10" android:gravity="start|top" android:hint="@string/hint_compose" - android:inputType="text|textMultiLine|textCapSentences" /> + android:inputType="text|textMultiLine|textCapSentences" + android:dropDownWidth="wrap_content" + android:completionThreshold="2" />