* Add hashtag autocompletion, closes #769 * Apply review feedbackmain
parent
b3a8d00093
commit
c5dcc639a4
@ -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); |
||||
} |
||||
} |
@ -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); |
||||
} |
||||
} |
||||
} |
@ -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)) |
||||
} |
||||
} |
@ -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)) |
||||
} |
||||
} |
Loading…
Reference in new issue