Autocomplete @ mentions in the composer. Closes #103

This commit is contained in:
Vavassor 2017-06-18 22:10:50 -04:00
parent 8994d81c66
commit 74aa866647
5 changed files with 168 additions and 8 deletions

View file

@ -18,6 +18,7 @@ package com.keylesspalace.tusky;
import android.Manifest; import android.Manifest;
import android.app.ProgressDialog; import android.app.ProgressDialog;
import android.content.ContentResolver; import android.content.ContentResolver;
import android.content.Context;
import android.content.DialogInterface; import android.content.DialogInterface;
import android.content.Intent; import android.content.Intent;
import android.content.SharedPreferences; import android.content.SharedPreferences;
@ -39,6 +40,7 @@ import android.os.Parcel;
import android.os.Parcelable; import android.os.Parcelable;
import android.provider.MediaStore; import android.provider.MediaStore;
import android.support.annotation.AttrRes; import android.support.annotation.AttrRes;
import android.support.annotation.LayoutRes;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.annotation.StringRes; import android.support.annotation.StringRes;
import android.support.design.widget.Snackbar; import android.support.design.widget.Snackbar;
@ -60,14 +62,18 @@ import android.util.Log;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.View; import android.view.View;
import android.webkit.MimeTypeMap; import android.webkit.MimeTypeMap;
import android.widget.ArrayAdapter;
import android.widget.Button; import android.widget.Button;
import android.widget.EditText; import android.widget.EditText;
import android.widget.Filter;
import android.widget.Filterable;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.ImageView; import android.widget.ImageView;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.ProgressBar; import android.widget.ProgressBar;
import android.widget.TextView; import android.widget.TextView;
import com.keylesspalace.tusky.entity.Account;
import com.keylesspalace.tusky.entity.Media; import com.keylesspalace.tusky.entity.Media;
import com.keylesspalace.tusky.entity.Status; import com.keylesspalace.tusky.entity.Status;
import com.keylesspalace.tusky.fragment.ComposeOptionsFragment; 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.DownsizeImageTask;
import com.keylesspalace.tusky.util.IOUtils; import com.keylesspalace.tusky.util.IOUtils;
import com.keylesspalace.tusky.util.MediaUtils; import com.keylesspalace.tusky.util.MediaUtils;
import com.keylesspalace.tusky.util.MentionTokenizer;
import com.keylesspalace.tusky.util.ParserUtils; import com.keylesspalace.tusky.util.ParserUtils;
import com.keylesspalace.tusky.util.SpanUtils; import com.keylesspalace.tusky.util.SpanUtils;
import com.keylesspalace.tusky.util.ThemeUtils; 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. // Add any mentions to the text field when a reply is first composed.
if (mentionedUsernames != null) { if (mentionedUsernames != null) {
StringBuilder builder = new StringBuilder(); StringBuilder builder = new StringBuilder();
@ -1220,7 +1231,6 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
} }
} }
// remove the precedent paste from the edit text // remove the precedent paste from the edit text
private void cleanBaseUrl(ParserUtils.HeaderInfo headerInfo) { private void cleanBaseUrl(ParserUtils.HeaderInfo headerInfo) {
int lengthBaseUrl = headerInfo.baseUrl.length(); int lengthBaseUrl = headerInfo.baseUrl.length();
@ -1236,6 +1246,28 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
displayTransientError(R.string.error_generic); displayTransientError(R.string.error_generic);
} }
/**
* Does a synchronous search request for usernames fulfilling the given partial mention text.
*/
private ArrayList<String> autocompleteMention(String mention) {
ArrayList<String> resultList = new ArrayList<>();
try {
List<Account> 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 { private static class QueuedMedia {
Type type; Type type;
ImageView preview; ImageView preview;
@ -1311,4 +1343,47 @@ public class ComposeActivity extends BaseActivity implements ComposeOptionsFragm
dest.writeLong(mediaSize); dest.writeLong(mediaSize);
} }
} }
private class MentionAutoCompleteAdapter extends ArrayAdapter<String> implements Filterable {
private ArrayList<String> 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();
}
}
};
}
}
} }

View file

@ -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 <http://www.gnu.org/licenses>. */
package com.keylesspalace.tusky.util; package com.keylesspalace.tusky.util;
import android.content.ContentResolver; import android.content.ContentResolver;

View file

@ -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 <http://www.gnu.org/licenses>. */
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 + " ";
}
}
}

View file

@ -18,18 +18,18 @@ package com.keylesspalace.tusky.view;
import android.content.Context; import android.content.Context;
import android.support.v13.view.inputmethod.EditorInfoCompat; import android.support.v13.view.inputmethod.EditorInfoCompat;
import android.support.v13.view.inputmethod.InputConnectionCompat; 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.util.AttributeSet;
import android.view.inputmethod.EditorInfo; import android.view.inputmethod.EditorInfo;
import android.view.inputmethod.InputConnection; import android.view.inputmethod.InputConnection;
import com.keylesspalace.tusky.util.Assert; import com.keylesspalace.tusky.util.Assert;
public class EditTextTyped extends AppCompatEditText { public class EditTextTyped extends AppCompatMultiAutoCompleteTextView {
InputConnectionCompat.OnCommitContentListener onCommitContentListener; InputConnectionCompat.OnCommitContentListener onCommitContentListener;
String[] mimeTypes; String[] mimeTypes;
private OnPasteListener mOnPasteListener; private OnPasteListener onPasteListener;
public EditTextTyped(Context context) { public EditTextTyped(Context context) {
super(context); super(context);
@ -40,7 +40,7 @@ public class EditTextTyped extends AppCompatEditText {
} }
public void addOnPasteListener(OnPasteListener mOnPasteListener) { public void addOnPasteListener(OnPasteListener mOnPasteListener) {
this.mOnPasteListener = mOnPasteListener; this.onPasteListener = mOnPasteListener;
} }
public void setMimeTypes(String[] types, public void setMimeTypes(String[] types,
@ -68,6 +68,7 @@ public class EditTextTyped extends AppCompatEditText {
switch (id) { switch (id) {
case android.R.id.paste: case android.R.id.paste:
onPaste(); onPaste();
break;
} }
return consumed; return consumed;
} }
@ -76,8 +77,9 @@ public class EditTextTyped extends AppCompatEditText {
* Text was pasted into the EditText. * Text was pasted into the EditText.
*/ */
public void onPaste() { public void onPaste() {
if (mOnPasteListener != null) if (onPasteListener != null) {
mOnPasteListener.onPaste(); onPasteListener.onPaste();
}
} }
public interface OnPasteListener { public interface OnPasteListener {

View file

@ -62,7 +62,9 @@
android:ems="10" android:ems="10"
android:gravity="start|top" android:gravity="start|top"
android:hint="@string/hint_compose" android:hint="@string/hint_compose"
android:inputType="text|textMultiLine|textCapSentences" /> android:inputType="text|textMultiLine|textCapSentences"
android:dropDownWidth="wrap_content"
android:completionThreshold="2" />
<HorizontalScrollView <HorizontalScrollView
android:layout_width="match_parent" android:layout_width="match_parent"