modernize autocomplete (#2510)
* modernize autocomplete * use @WorkerThread annotation
This commit is contained in:
parent
4c9cd4084b
commit
cec8f6dd65
12 changed files with 314 additions and 412 deletions
|
@ -78,7 +78,6 @@ import com.keylesspalace.tusky.entity.Emoji
|
||||||
import com.keylesspalace.tusky.entity.NewPoll
|
import com.keylesspalace.tusky.entity.NewPoll
|
||||||
import com.keylesspalace.tusky.entity.Status
|
import com.keylesspalace.tusky.entity.Status
|
||||||
import com.keylesspalace.tusky.settings.PrefKeys
|
import com.keylesspalace.tusky.settings.PrefKeys
|
||||||
import com.keylesspalace.tusky.util.ComposeTokenizer
|
|
||||||
import com.keylesspalace.tusky.util.PickMediaFiles
|
import com.keylesspalace.tusky.util.PickMediaFiles
|
||||||
import com.keylesspalace.tusky.util.ThemeUtils
|
import com.keylesspalace.tusky.util.ThemeUtils
|
||||||
import com.keylesspalace.tusky.util.afterTextChanged
|
import com.keylesspalace.tusky.util.afterTextChanged
|
||||||
|
@ -307,7 +306,8 @@ class ComposeActivity :
|
||||||
ComposeAutoCompleteAdapter(
|
ComposeAutoCompleteAdapter(
|
||||||
this,
|
this,
|
||||||
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
preferences.getBoolean(PrefKeys.ANIMATE_GIF_AVATARS, false),
|
||||||
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false)
|
preferences.getBoolean(PrefKeys.ANIMATE_CUSTOM_EMOJIS, false),
|
||||||
|
preferences.getBoolean(PrefKeys.SHOW_BOT_OVERLAY, true)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
binding.composeEditField.setTokenizer(ComposeTokenizer())
|
binding.composeEditField.setTokenizer(ComposeTokenizer())
|
||||||
|
|
|
@ -1,320 +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.components.compose;
|
|
||||||
|
|
||||||
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.bumptech.glide.Glide;
|
|
||||||
import com.keylesspalace.tusky.R;
|
|
||||||
import com.keylesspalace.tusky.entity.Emoji;
|
|
||||||
import com.keylesspalace.tusky.entity.HashTag;
|
|
||||||
import com.keylesspalace.tusky.entity.TimelineAccount;
|
|
||||||
import com.keylesspalace.tusky.util.CustomEmojiHelper;
|
|
||||||
import com.keylesspalace.tusky.util.ImageLoadingHelper;
|
|
||||||
|
|
||||||
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 ComposeAutoCompleteAdapter extends BaseAdapter
|
|
||||||
implements Filterable {
|
|
||||||
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<AutocompleteResult> resultList;
|
|
||||||
private final AutocompletionProvider autocompletionProvider;
|
|
||||||
private final boolean animateAvatar;
|
|
||||||
private final boolean animateEmojis;
|
|
||||||
|
|
||||||
public ComposeAutoCompleteAdapter(AutocompletionProvider autocompletionProvider, boolean animateAvatar, boolean animateEmojis) {
|
|
||||||
super();
|
|
||||||
resultList = new ArrayList<>();
|
|
||||||
this.autocompletionProvider = autocompletionProvider;
|
|
||||||
this.animateAvatar = animateAvatar;
|
|
||||||
this.animateEmojis = animateEmojis;
|
|
||||||
}
|
|
||||||
|
|
||||||
@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 formatUsername(((AccountResult) resultValue));
|
|
||||||
} else if (resultValue instanceof HashtagResult) {
|
|
||||||
return formatHashtag((HashtagResult) resultValue);
|
|
||||||
} else if (resultValue instanceof EmojiResult) {
|
|
||||||
return formatEmoji((EmojiResult) resultValue);
|
|
||||||
} else {
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 accountViewHolder;
|
|
||||||
if (convertView == null) {
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
accountViewHolder = (AccountViewHolder) view.getTag();
|
|
||||||
|
|
||||||
AccountResult accountResult = ((AccountResult) getItem(position));
|
|
||||||
if (accountResult != null) {
|
|
||||||
TimelineAccount account = accountResult.account;
|
|
||||||
String formattedUsername = context.getString(
|
|
||||||
R.string.post_username_format,
|
|
||||||
account.getUsername()
|
|
||||||
);
|
|
||||||
accountViewHolder.username.setText(formattedUsername);
|
|
||||||
CharSequence emojifiedName = CustomEmojiHelper.emojify(account.getName(),
|
|
||||||
account.getEmojis(), accountViewHolder.displayName, animateEmojis);
|
|
||||||
accountViewHolder.displayName.setText(emojifiedName);
|
|
||||||
|
|
||||||
int avatarRadius = accountViewHolder.avatar.getContext().getResources()
|
|
||||||
.getDimensionPixelSize(R.dimen.avatar_radius_42dp);
|
|
||||||
|
|
||||||
ImageLoadingHelper.loadAvatar(
|
|
||||||
account.getAvatar(),
|
|
||||||
accountViewHolder.avatar,
|
|
||||||
avatarRadius,
|
|
||||||
animateAvatar
|
|
||||||
);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
case HASHTAG_VIEW_TYPE:
|
|
||||||
if (convertView == null) {
|
|
||||||
view = ((LayoutInflater) context
|
|
||||||
.getSystemService(Context.LAYOUT_INFLATER_SERVICE))
|
|
||||||
.inflate(R.layout.item_autocomplete_hashtag, parent, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
HashtagResult result = (HashtagResult) getItem(position);
|
|
||||||
if (result != null) {
|
|
||||||
((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);
|
|
||||||
Glide.with(emojiViewHolder.preview)
|
|
||||||
.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");
|
|
||||||
}
|
|
||||||
|
|
||||||
return view;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String formatUsername(AccountResult result) {
|
|
||||||
return String.format("@%s", result.account.getUsername());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String formatHashtag(HashtagResult result) {
|
|
||||||
return String.format("#%s", result.hashtag);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static String formatEmoji(EmojiResult result) {
|
|
||||||
return String.format(":%s:", result.emoji.getShortcode());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getViewTypeCount() {
|
|
||||||
return 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getItemViewType(int position) {
|
|
||||||
AutocompleteResult item = getItem(position);
|
|
||||||
|
|
||||||
if (item instanceof AccountResult) {
|
|
||||||
return ACCOUNT_VIEW_TYPE;
|
|
||||||
} 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() {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public final static class AccountResult extends AutocompleteResult {
|
|
||||||
private final TimelineAccount account;
|
|
||||||
|
|
||||||
public AccountResult(TimelineAccount account) {
|
|
||||||
this.account = account;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public final static class HashtagResult extends AutocompleteResult {
|
|
||||||
private final String hashtag;
|
|
||||||
|
|
||||||
public HashtagResult(HashTag hashtag) {
|
|
||||||
this.hashtag = hashtag.getName();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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<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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,175 @@
|
||||||
|
/* Copyright 2022 Tusky Contributors
|
||||||
|
*
|
||||||
|
* 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.components.compose
|
||||||
|
|
||||||
|
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 androidx.annotation.WorkerThread
|
||||||
|
import com.bumptech.glide.Glide
|
||||||
|
import com.keylesspalace.tusky.R
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemAutocompleteAccountBinding
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemAutocompleteEmojiBinding
|
||||||
|
import com.keylesspalace.tusky.databinding.ItemAutocompleteHashtagBinding
|
||||||
|
import com.keylesspalace.tusky.entity.Emoji
|
||||||
|
import com.keylesspalace.tusky.entity.TimelineAccount
|
||||||
|
import com.keylesspalace.tusky.util.emojify
|
||||||
|
import com.keylesspalace.tusky.util.loadAvatar
|
||||||
|
import com.keylesspalace.tusky.util.visible
|
||||||
|
|
||||||
|
class ComposeAutoCompleteAdapter(
|
||||||
|
private val autocompletionProvider: AutocompletionProvider,
|
||||||
|
private val animateAvatar: Boolean,
|
||||||
|
private val animateEmojis: Boolean,
|
||||||
|
private val showBotBadge: Boolean
|
||||||
|
) : BaseAdapter(), Filterable {
|
||||||
|
|
||||||
|
private var resultList: List<AutocompleteResult> = emptyList()
|
||||||
|
|
||||||
|
override fun getCount() = resultList.size
|
||||||
|
|
||||||
|
override fun getItem(index: Int): AutocompleteResult {
|
||||||
|
return resultList[index]
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getItemId(position: Int): Long {
|
||||||
|
return position.toLong()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getFilter(): Filter {
|
||||||
|
return object : Filter() {
|
||||||
|
|
||||||
|
override fun convertResultToString(resultValue: Any): CharSequence {
|
||||||
|
return when (resultValue) {
|
||||||
|
is AutocompleteResult.AccountResult -> formatUsername(resultValue)
|
||||||
|
is AutocompleteResult.HashtagResult -> formatHashtag(resultValue)
|
||||||
|
is AutocompleteResult.EmojiResult -> formatEmoji(resultValue)
|
||||||
|
else -> ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@WorkerThread
|
||||||
|
override fun performFiltering(constraint: CharSequence?): FilterResults {
|
||||||
|
val filterResults = FilterResults()
|
||||||
|
if (constraint != null) {
|
||||||
|
val results = autocompletionProvider.search(constraint.toString())
|
||||||
|
filterResults.values = results
|
||||||
|
filterResults.count = results.size
|
||||||
|
}
|
||||||
|
return filterResults
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun publishResults(constraint: CharSequence?, results: FilterResults) {
|
||||||
|
if (results.count > 0) {
|
||||||
|
resultList = results.values as List<AutocompleteResult>
|
||||||
|
notifyDataSetChanged()
|
||||||
|
} else {
|
||||||
|
notifyDataSetInvalidated()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||||
|
val itemViewType = getItemViewType(position)
|
||||||
|
val context = parent.context
|
||||||
|
|
||||||
|
val view: View = convertView ?: run {
|
||||||
|
val layoutInflater = LayoutInflater.from(context)
|
||||||
|
val binding = when (itemViewType) {
|
||||||
|
ACCOUNT_VIEW_TYPE -> ItemAutocompleteAccountBinding.inflate(layoutInflater)
|
||||||
|
HASHTAG_VIEW_TYPE -> ItemAutocompleteHashtagBinding.inflate(layoutInflater)
|
||||||
|
EMOJI_VIEW_TYPE -> ItemAutocompleteEmojiBinding.inflate(layoutInflater)
|
||||||
|
else -> throw AssertionError("unknown view type")
|
||||||
|
}
|
||||||
|
binding.root.tag = binding
|
||||||
|
binding.root
|
||||||
|
}
|
||||||
|
|
||||||
|
when (val binding = view.tag) {
|
||||||
|
is ItemAutocompleteAccountBinding -> {
|
||||||
|
val accountResult = getItem(position) as AutocompleteResult.AccountResult
|
||||||
|
val account = accountResult.account
|
||||||
|
binding.username.text = context.getString(R.string.post_username_format, account.username)
|
||||||
|
binding.displayName.text = account.name.emojify(account.emojis, binding.displayName, animateEmojis)
|
||||||
|
val avatarRadius = context.resources.getDimensionPixelSize(R.dimen.avatar_radius_42dp)
|
||||||
|
loadAvatar(
|
||||||
|
account.avatar,
|
||||||
|
binding.avatar,
|
||||||
|
avatarRadius,
|
||||||
|
animateAvatar
|
||||||
|
)
|
||||||
|
binding.avatarBadge.visible(showBotBadge && account.bot)
|
||||||
|
}
|
||||||
|
is ItemAutocompleteHashtagBinding -> {
|
||||||
|
val result = getItem(position) as AutocompleteResult.HashtagResult
|
||||||
|
binding.root.text = formatHashtag(result)
|
||||||
|
}
|
||||||
|
is ItemAutocompleteEmojiBinding -> {
|
||||||
|
val emojiResult = getItem(position) as AutocompleteResult.EmojiResult
|
||||||
|
val (shortcode, url) = emojiResult.emoji
|
||||||
|
binding.shortcode.text = context.getString(R.string.emoji_shortcode_format, shortcode)
|
||||||
|
Glide.with(binding.preview)
|
||||||
|
.load(url)
|
||||||
|
.into(binding.preview)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun getViewTypeCount() = 3
|
||||||
|
|
||||||
|
override fun getItemViewType(position: Int): Int {
|
||||||
|
return when (getItem(position)) {
|
||||||
|
is AutocompleteResult.AccountResult -> ACCOUNT_VIEW_TYPE
|
||||||
|
is AutocompleteResult.HashtagResult -> HASHTAG_VIEW_TYPE
|
||||||
|
is AutocompleteResult.EmojiResult -> EMOJI_VIEW_TYPE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class AutocompleteResult {
|
||||||
|
class AccountResult(val account: TimelineAccount) : AutocompleteResult()
|
||||||
|
|
||||||
|
class HashtagResult(val hashtag: String) : AutocompleteResult()
|
||||||
|
|
||||||
|
class EmojiResult(val emoji: Emoji) : AutocompleteResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AutocompletionProvider {
|
||||||
|
fun search(token: String): List<AutocompleteResult>
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ACCOUNT_VIEW_TYPE = 0
|
||||||
|
private const val HASHTAG_VIEW_TYPE = 1
|
||||||
|
private const val EMOJI_VIEW_TYPE = 2
|
||||||
|
|
||||||
|
private fun formatUsername(result: AutocompleteResult.AccountResult): String {
|
||||||
|
return String.format("@%s", result.account.username)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatHashtag(result: AutocompleteResult.HashtagResult): String {
|
||||||
|
return String.format("#%s", result.hashtag)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun formatEmoji(result: AutocompleteResult.EmojiResult): String {
|
||||||
|
return String.format(":%s:", result.emoji.shortcode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -13,7 +13,7 @@
|
||||||
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
* You should have received a copy of the GNU General Public License along with Tusky; if not,
|
||||||
* see <http://www.gnu.org/licenses>. */
|
* see <http://www.gnu.org/licenses>. */
|
||||||
|
|
||||||
package com.keylesspalace.tusky.util
|
package com.keylesspalace.tusky.components.compose
|
||||||
|
|
||||||
import android.text.SpannableString
|
import android.text.SpannableString
|
||||||
import android.text.Spanned
|
import android.text.Spanned
|
|
@ -24,6 +24,7 @@ import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.asLiveData
|
import androidx.lifecycle.asLiveData
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
import com.keylesspalace.tusky.components.compose.ComposeActivity.QueuedMedia
|
||||||
|
import com.keylesspalace.tusky.components.compose.ComposeAutoCompleteAdapter.AutocompleteResult
|
||||||
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
import com.keylesspalace.tusky.components.drafts.DraftHelper
|
||||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
|
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfo
|
||||||
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
import com.keylesspalace.tusky.components.instanceinfo.InstanceInfoRepository
|
||||||
|
@ -38,6 +39,7 @@ import com.keylesspalace.tusky.service.ServiceClient
|
||||||
import com.keylesspalace.tusky.service.StatusToSend
|
import com.keylesspalace.tusky.service.StatusToSend
|
||||||
import com.keylesspalace.tusky.util.combineLiveData
|
import com.keylesspalace.tusky.util.combineLiveData
|
||||||
import com.keylesspalace.tusky.util.randomAlphanumericString
|
import com.keylesspalace.tusky.util.randomAlphanumericString
|
||||||
|
import com.keylesspalace.tusky.util.result
|
||||||
import com.keylesspalace.tusky.util.toLiveData
|
import com.keylesspalace.tusky.util.toLiveData
|
||||||
import io.reactivex.rxjava3.core.Observable
|
import io.reactivex.rxjava3.core.Observable
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
@ -51,7 +53,6 @@ import kotlinx.coroutines.flow.updateAndGet
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.rx3.rxSingle
|
import kotlinx.coroutines.rx3.rxSingle
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import java.util.Locale
|
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class ComposeViewModel @Inject constructor(
|
class ComposeViewModel @Inject constructor(
|
||||||
|
@ -330,49 +331,40 @@ class ComposeViewModel @Inject constructor(
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun searchAutocompleteSuggestions(token: String): List<ComposeAutoCompleteAdapter.AutocompleteResult> {
|
fun searchAutocompleteSuggestions(token: String): List<AutocompleteResult> {
|
||||||
when (token[0]) {
|
when (token[0]) {
|
||||||
'@' -> {
|
'@' -> {
|
||||||
return try {
|
return api.searchAccountsCall(query = token.substring(1), limit = 10)
|
||||||
api.searchAccounts(query = token.substring(1), limit = 10)
|
.result()
|
||||||
.blockingGet()
|
.fold({ accounts ->
|
||||||
.map { ComposeAutoCompleteAdapter.AccountResult(it) }
|
accounts.map { AutocompleteResult.AccountResult(it) }
|
||||||
} catch (e: Throwable) {
|
}, { e ->
|
||||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
Log.e(TAG, "Autocomplete search for $token failed.", e)
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
'#' -> {
|
'#' -> {
|
||||||
return try {
|
return api.searchCall(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
||||||
api.searchObservable(query = token, type = SearchType.Hashtag.apiParameter, limit = 10)
|
.result()
|
||||||
.blockingGet()
|
.fold({ searchResult ->
|
||||||
.hashtags
|
searchResult.hashtags.map { AutocompleteResult.HashtagResult(it.name) }
|
||||||
.map { ComposeAutoCompleteAdapter.HashtagResult(it) }
|
}, { e ->
|
||||||
} catch (e: Throwable) {
|
Log.e(TAG, "Autocomplete search for $token failed.", e)
|
||||||
Log.e(TAG, String.format("Autocomplete search for %s failed.", token), e)
|
|
||||||
emptyList()
|
emptyList()
|
||||||
}
|
})
|
||||||
}
|
}
|
||||||
':' -> {
|
':' -> {
|
||||||
val emojiList = emoji.value ?: return emptyList()
|
val emojiList = emoji.value ?: return emptyList()
|
||||||
|
val incomplete = token.substring(1)
|
||||||
|
|
||||||
val incomplete = token.substring(1).lowercase(Locale.ROOT)
|
return emojiList.filter { emoji ->
|
||||||
val results = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
emoji.shortcode.contains(incomplete, ignoreCase = true)
|
||||||
val resultsInside = ArrayList<ComposeAutoCompleteAdapter.AutocompleteResult>()
|
}.sortedBy { emoji ->
|
||||||
for (emoji in emojiList) {
|
emoji.shortcode.indexOf(incomplete, ignoreCase = true)
|
||||||
val shortcode = emoji.shortcode.lowercase(Locale.ROOT)
|
}.map { emoji ->
|
||||||
if (shortcode.startsWith(incomplete)) {
|
AutocompleteResult.EmojiResult(emoji)
|
||||||
results.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
|
||||||
} else if (shortcode.indexOf(incomplete, 1) != -1) {
|
|
||||||
resultsInside.add(ComposeAutoCompleteAdapter.EmojiResult(emoji))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (results.isNotEmpty() && resultsInside.isNotEmpty()) {
|
|
||||||
results.add(ComposeAutoCompleteAdapter.ResultSeparator())
|
|
||||||
}
|
|
||||||
results.addAll(resultsInside)
|
|
||||||
return results
|
|
||||||
}
|
|
||||||
else -> {
|
else -> {
|
||||||
Log.w(TAG, "Unexpected autocompletion token: $token")
|
Log.w(TAG, "Unexpected autocompletion token: $token")
|
||||||
return emptyList()
|
return emptyList()
|
||||||
|
|
|
@ -288,6 +288,14 @@ interface MastodonApi {
|
||||||
@Query("following") following: Boolean? = null
|
@Query("following") following: Boolean? = null
|
||||||
): Single<List<TimelineAccount>>
|
): Single<List<TimelineAccount>>
|
||||||
|
|
||||||
|
@GET("api/v1/accounts/search")
|
||||||
|
fun searchAccountsCall(
|
||||||
|
@Query("q") query: String,
|
||||||
|
@Query("resolve") resolve: Boolean? = null,
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
@Query("following") following: Boolean? = null
|
||||||
|
): Call<List<TimelineAccount>>
|
||||||
|
|
||||||
@GET("api/v1/accounts/{id}")
|
@GET("api/v1/accounts/{id}")
|
||||||
fun account(
|
fun account(
|
||||||
@Path("id") accountId: String
|
@Path("id") accountId: String
|
||||||
|
@ -593,6 +601,16 @@ interface MastodonApi {
|
||||||
@Query("following") following: Boolean? = null
|
@Query("following") following: Boolean? = null
|
||||||
): Single<SearchResult>
|
): Single<SearchResult>
|
||||||
|
|
||||||
|
@GET("api/v2/search")
|
||||||
|
fun searchCall(
|
||||||
|
@Query("q") query: String?,
|
||||||
|
@Query("type") type: String? = null,
|
||||||
|
@Query("resolve") resolve: Boolean? = null,
|
||||||
|
@Query("limit") limit: Int? = null,
|
||||||
|
@Query("offset") offset: Int? = null,
|
||||||
|
@Query("following") following: Boolean? = null
|
||||||
|
): Call<SearchResult>
|
||||||
|
|
||||||
@FormUrlEncoded
|
@FormUrlEncoded
|
||||||
@POST("api/v1/accounts/{id}/note")
|
@POST("api/v1/accounts/{id}/note")
|
||||||
fun updateAccountNote(
|
fun updateAccountNote(
|
||||||
|
|
|
@ -0,0 +1,23 @@
|
||||||
|
package com.keylesspalace.tusky.util
|
||||||
|
|
||||||
|
import retrofit2.Call
|
||||||
|
import retrofit2.HttpException
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Synchronously executes the call and returns the response encapsulated in a kotlin.Result.
|
||||||
|
* Since Result is an inline class it is not possible to do this with a Retrofit adapter unfortunately.
|
||||||
|
* More efficient then calling a suspending method with runBlocking
|
||||||
|
*/
|
||||||
|
fun <T> Call<T>.result(): Result<T> {
|
||||||
|
return try {
|
||||||
|
val response = execute()
|
||||||
|
val responseBody = response.body()
|
||||||
|
if (response.isSuccessful && responseBody != null) {
|
||||||
|
Result.success(responseBody)
|
||||||
|
} else {
|
||||||
|
Result.failure(HttpException(response))
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
Result.failure(e)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,48 +1,65 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||||
xmlns:tools="http://schemas.android.com/tools"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_vertical"
|
android:paddingStart="16dp"
|
||||||
android:padding="8dp">
|
android:paddingTop="8dp"
|
||||||
|
android:paddingEnd="16dp"
|
||||||
|
android:paddingBottom="8dp">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/avatar"
|
android:id="@+id/avatar"
|
||||||
android:layout_width="42dp"
|
android:layout_width="48dp"
|
||||||
android:layout_height="42dp"
|
android:layout_height="48dp"
|
||||||
android:layout_centerVertical="true"
|
android:layout_marginEnd="24dp"
|
||||||
android:layout_marginEnd="8dp"
|
android:foregroundGravity="center_vertical"
|
||||||
android:contentDescription="@null"
|
android:importantForAccessibility="no"
|
||||||
|
app:layout_constraintBottom_toBottomOf="parent"
|
||||||
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
|
app:layout_constraintTop_toTopOf="parent"
|
||||||
tools:src="@drawable/avatar_default" />
|
tools:src="@drawable/avatar_default" />
|
||||||
|
|
||||||
<LinearLayout
|
<ImageView
|
||||||
android:layout_width="wrap_content"
|
android:id="@+id/avatarBadge"
|
||||||
android:layout_height="match_parent"
|
android:layout_width="24dp"
|
||||||
android:layout_toEndOf="@id/avatar"
|
android:layout_height="24dp"
|
||||||
android:gravity="center_vertical"
|
android:contentDescription="@string/profile_badge_bot_text"
|
||||||
android:orientation="vertical">
|
android:src="@drawable/bot_badge"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@id/avatar"
|
||||||
|
app:layout_constraintEnd_toEndOf="@id/avatar" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/display_name"
|
android:id="@+id/displayName"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="14dp"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:textColor="?android:textColorPrimary"
|
android:textColor="?android:textColorPrimary"
|
||||||
android:textSize="?attr/status_text_large"
|
android:textSize="?attr/status_text_large"
|
||||||
android:textStyle="normal|bold"
|
android:textStyle="normal|bold"
|
||||||
tools:text="Conny Duck" />
|
app:layout_constraintBottom_toTopOf="@id/username"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/avatar"
|
||||||
|
app:layout_constraintTop_toTopOf="@id/avatar"
|
||||||
|
app:layout_constraintVertical_chainStyle="packed"
|
||||||
|
tools:text="Display name" />
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/username"
|
android:id="@+id/username"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginStart="14dp"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:textColor="?android:textColorSecondary"
|
android:textColor="?android:textColorSecondary"
|
||||||
android:textSize="?attr/status_text_medium"
|
android:textSize="?attr/status_text_medium"
|
||||||
tools:text="\@ConnyDuck" />
|
app:layout_constraintBottom_toBottomOf="@id/avatar"
|
||||||
|
app:layout_constraintEnd_toEndOf="parent"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/avatar"
|
||||||
|
app:layout_constraintTop_toBottomOf="@id/displayName"
|
||||||
|
tools:text="\@username" />
|
||||||
|
|
||||||
</LinearLayout>
|
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||||
|
|
||||||
</RelativeLayout>
|
|
||||||
|
|
|
@ -1,5 +0,0 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<View xmlns:android="http://schemas.android.com/apk/res/android"
|
|
||||||
android:layout_width="match_parent"
|
|
||||||
android:layout_height="wrap_content"
|
|
||||||
android:background="@drawable/status_divider" />
|
|
|
@ -5,24 +5,24 @@
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:gravity="center_vertical"
|
android:gravity="center_vertical"
|
||||||
android:orientation="horizontal"
|
android:orientation="horizontal"
|
||||||
android:padding="8dp">
|
tools:ignore="UseCompoundDrawables">
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/preview"
|
android:id="@+id/preview"
|
||||||
android:layout_width="40dp"
|
android:layout_width="40dp"
|
||||||
android:layout_height="40dp"
|
android:layout_height="40dp"
|
||||||
android:layout_marginStart="4dp"
|
android:layout_marginStart="16dp"
|
||||||
android:layout_marginTop="4dp"
|
android:layout_marginTop="8dp"
|
||||||
android:layout_marginEnd="4dp"
|
android:layout_marginBottom="8dp"
|
||||||
android:layout_marginBottom="4dp"
|
android:importantForAccessibility="no" />
|
||||||
android:contentDescription="@null"
|
|
||||||
android:padding="4dp" />
|
|
||||||
|
|
||||||
<TextView
|
<TextView
|
||||||
android:id="@+id/shortcode"
|
android:id="@+id/shortcode"
|
||||||
android:layout_width="wrap_content"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:layout_marginStart="8dp"
|
android:layout_marginStart="16dp"
|
||||||
|
android:layout_marginEnd="16dp"
|
||||||
|
android:layout_weight="1"
|
||||||
android:ellipsize="end"
|
android:ellipsize="end"
|
||||||
android:maxLines="1"
|
android:maxLines="1"
|
||||||
android:textColor="?android:textColorPrimary"
|
android:textColor="?android:textColorPrimary"
|
||||||
|
|
|
@ -1,11 +1,13 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
<TextView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
xmlns:tools="http://schemas.android.com/tools"
|
||||||
android:id="@+id/hashtag"
|
android:id="@+id/hashtag"
|
||||||
android:layout_width="match_parent"
|
android:layout_width="match_parent"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:padding="16dp"
|
android:ellipsize="end"
|
||||||
|
android:maxLines="1"
|
||||||
|
android:paddingHorizontal="16dp"
|
||||||
|
android:paddingVertical="12dp"
|
||||||
android:textSize="?attr/status_text_medium"
|
android:textSize="?attr/status_text_medium"
|
||||||
android:textStyle="normal|bold"
|
android:textStyle="normal|bold"
|
||||||
app:drawableStartCompat="@drawable/ic_list"
|
tools:text="#Tusky" />
|
||||||
app:drawableTint="?attr/iconColor" />
|
|
||||||
|
|
|
@ -15,7 +15,7 @@
|
||||||
|
|
||||||
package com.keylesspalace.tusky
|
package com.keylesspalace.tusky
|
||||||
|
|
||||||
import com.keylesspalace.tusky.util.ComposeTokenizer
|
import com.keylesspalace.tusky.components.compose.ComposeTokenizer
|
||||||
import org.junit.Assert
|
import org.junit.Assert
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
Loading…
Reference in a new issue