Compose: auto-complete mentions, hashtags, and emojis

This commit is contained in:
Grishka 2022-03-17 06:28:36 +03:00
parent 098128bcd4
commit 7186b6387f
12 changed files with 863 additions and 19 deletions

View File

@ -10,7 +10,7 @@ android {
applicationId "org.joinmastodon.android" applicationId "org.joinmastodon.android"
minSdk 23 minSdk 23
targetSdk 31 targetSdk 31
versionCode 13 versionCode 14
versionName "0.1" versionName "0.1"
} }

View File

@ -0,0 +1,24 @@
package org.joinmastodon.android.api.requests.search;
import org.joinmastodon.android.api.MastodonAPIRequest;
import org.joinmastodon.android.model.SearchResults;
public class GetSearchResults extends MastodonAPIRequest<SearchResults>{
public GetSearchResults(String query, Type type){
super(HttpMethod.GET, "/search", SearchResults.class);
addQueryParameter("q", query);
if(type!=null)
addQueryParameter("type", type.name().toLowerCase());
}
@Override
protected String getPathPrefix(){
return "/api/v2";
}
public enum Type{
ACCOUNTS,
HASHTAGS,
STATUSES
}
}

View File

@ -3,7 +3,6 @@ package org.joinmastodon.android.fragments;
import android.animation.ObjectAnimator; import android.animation.ObjectAnimator;
import android.annotation.SuppressLint; import android.annotation.SuppressLint;
import android.app.Activity; import android.app.Activity;
import android.app.ProgressDialog;
import android.content.ClipData; import android.content.ClipData;
import android.content.Intent; import android.content.Intent;
import android.content.res.ColorStateList; import android.content.res.ColorStateList;
@ -19,17 +18,17 @@ import android.os.Build;
import android.os.Bundle; import android.os.Bundle;
import android.os.Parcelable; import android.os.Parcelable;
import android.text.Editable; import android.text.Editable;
import android.text.Layout;
import android.text.SpannableStringBuilder; import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextUtils; import android.text.TextUtils;
import android.text.TextWatcher; import android.text.TextWatcher;
import android.text.style.ImageSpan;
import android.util.Log; import android.util.Log;
import android.view.Gravity; import android.view.Gravity;
import android.view.LayoutInflater; import android.view.LayoutInflater;
import android.view.Menu; import android.view.Menu;
import android.view.MenuInflater; import android.view.MenuInflater;
import android.view.MenuItem; import android.view.MenuItem;
import android.view.MotionEvent;
import android.view.View; import android.view.View;
import android.view.ViewGroup; import android.view.ViewGroup;
import android.view.ViewOutlineProvider; import android.view.ViewOutlineProvider;
@ -66,25 +65,29 @@ import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.model.Mention; import org.joinmastodon.android.model.Mention;
import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.model.StatusPrivacy; import org.joinmastodon.android.model.StatusPrivacy;
import org.joinmastodon.android.ui.ComposeAutocompleteViewController;
import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard; import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard;
import org.joinmastodon.android.ui.M3AlertDialogBuilder; import org.joinmastodon.android.ui.M3AlertDialogBuilder;
import org.joinmastodon.android.ui.PopupKeyboard; import org.joinmastodon.android.ui.PopupKeyboard;
import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable; import org.joinmastodon.android.ui.drawables.SpoilerStripesDrawable;
import org.joinmastodon.android.ui.text.ComposeAutocompleteSpan;
import org.joinmastodon.android.ui.text.ComposeHashtagOrMentionSpan;
import org.joinmastodon.android.ui.text.HtmlParser; import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.text.SpacerSpan; import org.joinmastodon.android.ui.text.SpacerSpan;
import org.joinmastodon.android.ui.utils.SimpleTextWatcher; import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.ui.utils.UiUtils;
import org.joinmastodon.android.ui.views.ComposeMediaLayout; import org.joinmastodon.android.ui.views.ComposeMediaLayout;
import org.joinmastodon.android.ui.views.ReorderableLinearLayout; import org.joinmastodon.android.ui.views.ReorderableLinearLayout;
import org.joinmastodon.android.ui.views.SelectionListenerEditText;
import org.joinmastodon.android.ui.views.SizeListenerLinearLayout; import org.joinmastodon.android.ui.views.SizeListenerLinearLayout;
import org.parceler.Parcel; import org.parceler.Parcel;
import org.parceler.Parcels; import org.parceler.Parcels;
import java.lang.reflect.Field;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.UUID; import java.util.UUID;
import java.util.regex.Matcher;
import java.util.regex.Pattern; import java.util.regex.Pattern;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -97,7 +100,7 @@ import me.grishka.appkit.imageloader.ViewImageLoader;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest; import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.V; import me.grishka.appkit.utils.V;
public class ComposeFragment extends ToolbarFragment implements OnBackPressedListener{ public class ComposeFragment extends ToolbarFragment implements OnBackPressedListener, SelectionListenerEditText.SelectionListener{
private static final int MEDIA_RESULT=717; private static final int MEDIA_RESULT=717;
private static final int IMAGE_DESCRIPTION_RESULT=363; private static final int IMAGE_DESCRIPTION_RESULT=363;
@ -106,6 +109,10 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE); private static final Pattern MENTION_PATTERN=Pattern.compile("(^|[^\\/\\w])@(([a-z0-9_]+)@[a-z0-9\\.\\-]+[a-z0-9]+)", Pattern.CASE_INSENSITIVE);
// from https://github.com/mastodon/mastodon-ios/blob/main/Mastodon/Helper/MastodonRegex.swift
private static final Pattern AUTO_COMPLETE_PATTERN=Pattern.compile("(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))|(^\\B:|\\s:)([a-zA-Z0-9_]+)");
private static final Pattern HIGHLIGHT_PATTERN=Pattern.compile("(?:@([a-zA-Z0-9_]+)(@[a-zA-Z0-9_.-]+)?|#([^\\s.]+))");
private static final String VALID_URL_PATTERN_STRING = private static final String VALID_URL_PATTERN_STRING =
"(" + // $1 total match "(" + // $1 total match
"(" + Regex.URL_VALID_PRECEDING_CHARS + ")" + // $2 Preceding character "(" + Regex.URL_VALID_PRECEDING_CHARS + ")" + // $2 Preceding character
@ -130,7 +137,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
private Account self; private Account self;
private String instanceDomain; private String instanceDomain;
private EditText mainEditText; private SelectionListenerEditText mainEditText;
private TextView charCounter; private TextView charCounter;
private String accountID; private String accountID;
private int charCount, charLimit, trimmedCharCount; private int charCount, charLimit, trimmedCharCount;
@ -163,6 +170,9 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
private View sendingOverlay; private View sendingOverlay;
private WindowManager wm; private WindowManager wm;
private StatusPrivacy statusVisibility=StatusPrivacy.PUBLIC; private StatusPrivacy statusVisibility=StatusPrivacy.PUBLIC;
private ComposeAutocompleteSpan currentAutocompleteSpan;
private FrameLayout mainEditTextWrap;
private ComposeAutocompleteViewController autocompleteViewController;
@Override @Override
public void onCreate(Bundle savedInstanceState){ public void onCreate(Bundle savedInstanceState){
@ -200,6 +210,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
View view=inflater.inflate(R.layout.fragment_compose, container, false); View view=inflater.inflate(R.layout.fragment_compose, container, false);
mainEditText=view.findViewById(R.id.toot_text); mainEditText=view.findViewById(R.id.toot_text);
mainEditTextWrap=view.findViewById(R.id.toot_text_wrap);
charCounter=view.findViewById(R.id.char_counter); charCounter=view.findViewById(R.id.char_counter);
charCounter.setText(String.valueOf(charLimit)); charCounter.setText(String.valueOf(charLimit));
@ -295,6 +306,12 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
} }
updateVisibilityIcon(); updateVisibilityIcon();
autocompleteViewController=new ComposeAutocompleteViewController(getActivity(), accountID);
autocompleteViewController.setCompletionSelectedListener(this::onAutocompleteOptionSelected);
View autocompleteView=autocompleteViewController.getView();
autocompleteView.setVisibility(View.GONE);
mainEditTextWrap.addView(autocompleteView, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(178), Gravity.TOP));
return view; return view;
} }
@ -336,6 +353,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
imm.showSoftInput(mainEditText, 0); imm.showSoftInput(mainEditText, 0);
}, 100); }, 100);
mainEditText.setSelectionListener(this);
mainEditText.addTextChangedListener(new TextWatcher(){ mainEditText.addTextChangedListener(new TextWatcher(){
@Override @Override
public void beforeTextChanged(CharSequence s, int start, int count, int after){ public void beforeTextChanged(CharSequence s, int start, int count, int after){
@ -344,7 +362,49 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
@Override @Override
public void onTextChanged(CharSequence s, int start, int before, int count){ public void onTextChanged(CharSequence s, int start, int before, int count){
// offset one char back to catch an already typed '@' or '#' or ':'
int realStart=start;
start=Math.max(0, start-1);
CharSequence changedText=s.subSequence(start, realStart+count);
String raw=changedText.toString();
Editable editable=(Editable) s;
// 1. find mentions, hashtags, and emoji shortcodes in any freshly inserted text, and put spans over them
if(raw.contains("@") || raw.contains("#") || raw.contains(":")){
Matcher matcher=AUTO_COMPLETE_PATTERN.matcher(changedText);
while(matcher.find()){
if(editable.getSpans(start+matcher.start(), start+matcher.end(), ComposeAutocompleteSpan.class).length>0)
continue;
Log.w("11", "found: "+matcher);
ComposeAutocompleteSpan span;
if(TextUtils.isEmpty(matcher.group(4))){ // not an emoji
span=new ComposeHashtagOrMentionSpan();
}else{
span=new ComposeAutocompleteSpan();
}
editable.setSpan(span, start+matcher.start(), start+matcher.end(), Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
}
}
// 2. go over existing spans in the affected range, adjust end offsets and remove no longer valid spans
ComposeAutocompleteSpan[] spans=editable.getSpans(realStart, realStart+count, ComposeAutocompleteSpan.class);
for(ComposeAutocompleteSpan span:spans){
int spanStart=editable.getSpanStart(span);
int spanEnd=editable.getSpanEnd(span);
if(spanStart==spanEnd){ // empty, remove
editable.removeSpan(span);
continue;
}
char firstChar=editable.charAt(spanStart);
String spanText=s.subSequence(spanStart, spanEnd).toString();
if(firstChar=='@' || firstChar=='#'){
Matcher matcher=HIGHLIGHT_PATTERN.matcher(spanText);
if(!matcher.find()){ // invalid mention, remove
editable.removeSpan(span);
continue;
}else if(matcher.end()+spanStart<spanEnd){ // mention with something at the end, move the end offset
editable.setSpan(span, spanStart, spanStart+matcher.end(), Spanned.SPAN_EXCLUSIVE_INCLUSIVE);
}
}
}
} }
@Override @Override
@ -933,6 +993,66 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
}); });
} }
@Override
public void onSelectionChanged(int start, int end){
if(start==end){
ComposeAutocompleteSpan[] spans=mainEditText.getText().getSpans(start, end, ComposeAutocompleteSpan.class);
if(spans.length>0){
assert spans.length==1;
ComposeAutocompleteSpan span=spans[0];
if(currentAutocompleteSpan==null && end==mainEditText.getText().getSpanEnd(span)){
startAutocomplete(span);
}else if(currentAutocompleteSpan!=null){
Editable e=mainEditText.getText();
String spanText=e.toString().substring(e.getSpanStart(span), e.getSpanEnd(span));
autocompleteViewController.setText(spanText);
}
View autocompleteView=autocompleteViewController.getView();
Layout layout=mainEditText.getLayout();
int line=layout.getLineForOffset(start);
int offsetY=layout.getLineBottom(line);
FrameLayout.LayoutParams lp=(FrameLayout.LayoutParams) autocompleteView.getLayoutParams();
if(lp.topMargin!=offsetY){
lp.topMargin=offsetY;
mainEditTextWrap.requestLayout();
}
int offsetX=Math.round(layout.getPrimaryHorizontal(start))+mainEditText.getPaddingLeft();
autocompleteViewController.setArrowOffset(offsetX);
}else if(currentAutocompleteSpan!=null){
finishAutocomplete();
}
}else if(currentAutocompleteSpan!=null){
finishAutocomplete();
}
}
private void startAutocomplete(ComposeAutocompleteSpan span){
currentAutocompleteSpan=span;
Editable e=mainEditText.getText();
String spanText=e.toString().substring(e.getSpanStart(span), e.getSpanEnd(span));
autocompleteViewController.setText(spanText);
View autocompleteView=autocompleteViewController.getView();
autocompleteView.setVisibility(View.VISIBLE);
}
private void finishAutocomplete(){
if(currentAutocompleteSpan==null)
return;
autocompleteViewController.setText(null);
currentAutocompleteSpan=null;
autocompleteViewController.getView().setVisibility(View.GONE);
}
private void onAutocompleteOptionSelected(String text){
Editable e=mainEditText.getText();
int start=e.getSpanStart(currentAutocompleteSpan);
int end=e.getSpanEnd(currentAutocompleteSpan);
e.replace(start, end, text+" ");
mainEditText.setSelection(start+text.length()+1);
finishAutocomplete();
}
@Parcel @Parcel
static class DraftMediaAttachment{ static class DraftMediaAttachment{
public Attachment serverAttachment; public Attachment serverAttachment;

View File

@ -0,0 +1,28 @@
package org.joinmastodon.android.model;
import org.joinmastodon.android.api.ObjectValidationException;
import java.util.List;
public class SearchResults extends BaseModel{
public List<Account> accounts;
public List<Status> statuses;
public List<Hashtag> hashtags;
@Override
public void postprocess() throws ObjectValidationException{
super.postprocess();
if(accounts!=null){
for(Account acc:accounts)
acc.postprocess();
}
if(statuses!=null){
for(Status s:statuses)
s.postprocess();
}
if(hashtags!=null){
for(Hashtag t:hashtags)
t.postprocess();
}
}
}

View File

@ -0,0 +1,471 @@
package org.joinmastodon.android.ui;
import android.app.Activity;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.text.TextUtils;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.ProgressBar;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.search.GetSearchResults;
import org.joinmastodon.android.api.session.AccountSessionManager;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.Emoji;
import org.joinmastodon.android.model.EmojiCategory;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.SearchResults;
import org.joinmastodon.android.ui.drawables.ComposeAutocompleteBackgroundDrawable;
import org.joinmastodon.android.ui.text.HtmlParser;
import org.joinmastodon.android.ui.utils.CustomEmojiHelper;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.Collections;
import java.util.List;
import java.util.function.BiPredicate;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.DiffUtil;
import androidx.recyclerview.widget.LinearLayoutManager;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.api.APIRequest;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter;
import me.grishka.appkit.imageloader.ImageLoaderViewHolder;
import me.grishka.appkit.imageloader.ListImageLoaderWrapper;
import me.grishka.appkit.imageloader.RecyclerViewDelegate;
import me.grishka.appkit.imageloader.requests.ImageLoaderRequest;
import me.grishka.appkit.imageloader.requests.UrlImageLoaderRequest;
import me.grishka.appkit.utils.BindableViewHolder;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.views.UsableRecyclerView;
public class ComposeAutocompleteViewController{
private Activity activity;
private String accountID;
private FrameLayout contentView;
private UsableRecyclerView list;
private ListImageLoaderWrapper imgLoader;
private ProgressBar progress;
private List<WrappedAccount> users=Collections.emptyList();
private List<Hashtag> hashtags=Collections.emptyList();
private List<WrappedEmoji> emojis=Collections.emptyList();
private Mode mode;
private APIRequest currentRequest;
private Runnable usersDebouncer=this::doSearchUsers, hashtagsDebouncer=this::doSearchHashtags;
private String lastText;
private ComposeAutocompleteBackgroundDrawable background;
private boolean listIsHidden=true;
private UsersAdapter usersAdapter;
private HashtagsAdapter hashtagsAdapter;
private EmojisAdapter emojisAdapter;
private Consumer<String> completionSelectedListener;
private DividerItemDecoration usersDividers, hashtagsDividers;
public ComposeAutocompleteViewController(Activity activity, String accountID){
this.activity=activity;
this.accountID=accountID;
background=new ComposeAutocompleteBackgroundDrawable(UiUtils.getThemeColor(activity, android.R.attr.colorBackground));
contentView=new FrameLayout(activity);
contentView.setBackground(background);
list=new UsableRecyclerView(activity);
list.setLayoutManager(new LinearLayoutManager(activity));
list.setItemAnimator(new BetterItemAnimator());
list.setVisibility(View.GONE);
contentView.addView(list, new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
progress=new ProgressBar(activity);
FrameLayout.LayoutParams progressLP=new FrameLayout.LayoutParams(V.dp(48), V.dp(48), Gravity.CENTER_HORIZONTAL|Gravity.TOP);
progressLP.topMargin=V.dp(16);
contentView.addView(progress, progressLP);
usersDividers=new DividerItemDecoration(activity, R.attr.colorPollVoted, 1, 72, 16);
hashtagsDividers=new DividerItemDecoration(activity, R.attr.colorPollVoted, 1, 16, 16);
imgLoader=new ListImageLoaderWrapper(activity, list, new RecyclerViewDelegate(list), null);
}
public void setText(String text){
if(mode==Mode.USERS){
list.removeCallbacks(usersDebouncer);
}else if(mode==Mode.HASHTAGS){
list.removeCallbacks(hashtagsDebouncer);
}
if(text==null)
return;
Mode prevMode=mode;
if(currentRequest!=null){
currentRequest.cancel();
currentRequest=null;
}
mode=switch(text.charAt(0)){
case '@' -> Mode.USERS;
case '#' -> Mode.HASHTAGS;
case ':' -> Mode.EMOJIS;
default -> throw new IllegalStateException("Unexpected value: "+text.charAt(0));
};
if(prevMode!=mode){
list.setAdapter(switch(mode){
case USERS -> {
if(usersAdapter==null)
usersAdapter=new UsersAdapter();
yield usersAdapter;
}
case EMOJIS -> {
if(emojisAdapter==null)
emojisAdapter=new EmojisAdapter();
yield emojisAdapter;
}
case HASHTAGS -> {
if(hashtagsAdapter==null)
hashtagsAdapter=new HashtagsAdapter();
yield hashtagsAdapter;
}
});
if(mode!=Mode.EMOJIS){
list.setVisibility(View.GONE);
progress.setVisibility(View.VISIBLE);
listIsHidden=true;
}else if(listIsHidden){
list.setVisibility(View.VISIBLE);
progress.setVisibility(View.GONE);
listIsHidden=false;
}
if((prevMode==Mode.HASHTAGS)!=(mode==Mode.HASHTAGS) || prevMode==null){
if(prevMode!=null)
list.removeItemDecoration(prevMode==Mode.HASHTAGS ? hashtagsDividers : usersDividers);
list.addItemDecoration(mode==Mode.HASHTAGS ? hashtagsDividers : usersDividers);
}
}
lastText=text;
if(mode==Mode.USERS){
list.postDelayed(usersDebouncer, 300);
}else if(mode==Mode.HASHTAGS){
list.postDelayed(hashtagsDebouncer, 300);
}else if(mode==Mode.EMOJIS){
String _text=text.substring(1); // remove ':'
List<WrappedEmoji> oldList=emojis;
emojis=AccountSessionManager.getInstance()
.getCustomEmojis(AccountSessionManager.getInstance().getAccount(accountID).domain)
.stream()
.flatMap(ec->ec.emojis.stream())
.filter(e->e.visibleInPicker && e.shortcode.startsWith(_text))
.map(WrappedEmoji::new)
.collect(Collectors.toList());
updateList(oldList, emojis, emojisAdapter, (e1, e2)->e1.emoji.shortcode.equals(e2.emoji.shortcode));
}
}
public void setCompletionSelectedListener(Consumer<String> completionSelectedListener){
this.completionSelectedListener=completionSelectedListener;
}
public void setArrowOffset(int offset){
background.setArrowOffset(offset);
}
public View getView(){
return contentView;
}
private <T> void updateList(List<T> oldList, List<T> newList, RecyclerView.Adapter<?> adapter, BiPredicate<T, T> areItemsSame){
DiffUtil.calculateDiff(new DiffUtil.Callback(){
@Override
public int getOldListSize(){
return oldList.size();
}
@Override
public int getNewListSize(){
return newList.size();
}
@Override
public boolean areItemsTheSame(int oldItemPosition, int newItemPosition){
return areItemsSame.test(oldList.get(oldItemPosition), newList.get(newItemPosition));
}
@Override
public boolean areContentsTheSame(int oldItemPosition, int newItemPosition){
return true;
}
}).dispatchUpdatesTo(adapter);
}
private void doSearchUsers(){
currentRequest=new GetSearchResults(lastText, GetSearchResults.Type.ACCOUNTS)
.setCallback(new Callback<>(){
@Override
public void onSuccess(SearchResults result){
currentRequest=null;
List<WrappedAccount> oldList=users;
users=result.accounts.stream().map(WrappedAccount::new).collect(Collectors.toList());
updateList(oldList, users, usersAdapter, (a1, a2)->a1.account.id.equals(a2.account.id));
if(listIsHidden){
listIsHidden=false;
V.setVisibilityAnimated(list, View.VISIBLE);
V.setVisibilityAnimated(progress, View.GONE);
}
}
@Override
public void onError(ErrorResponse error){
currentRequest=null;
}
})
.exec(accountID);
}
private void doSearchHashtags(){
currentRequest=new GetSearchResults(lastText, GetSearchResults.Type.HASHTAGS)
.setCallback(new Callback<>(){
@Override
public void onSuccess(SearchResults result){
currentRequest=null;
List<Hashtag> oldList=hashtags;
hashtags=result.hashtags;
updateList(oldList, hashtags, hashtagsAdapter, (t1, t2)->t1.name.equals(t2.name));
if(listIsHidden){
listIsHidden=false;
V.setVisibilityAnimated(list, View.VISIBLE);
V.setVisibilityAnimated(progress, View.GONE);
}
}
@Override
public void onError(ErrorResponse error){
currentRequest=null;
}
})
.exec(accountID);
}
private class UsersAdapter extends UsableRecyclerView.Adapter<UserViewHolder> implements ImageLoaderRecyclerAdapter{
public UsersAdapter(){
super(imgLoader);
}
@NonNull
@Override
public UserViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new UserViewHolder();
}
@Override
public int getItemCount(){
return users.size();
}
@Override
public void onBindViewHolder(UserViewHolder holder, int position){
holder.bind(users.get(position));
super.onBindViewHolder(holder, position);
}
@Override
public int getImageCountForItem(int position){
return 1+users.get(position).emojiHelper.getImageCount();
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
WrappedAccount a=users.get(position);
if(image==0)
return a.avaRequest;
return a.emojiHelper.getImageRequest(image-1);
}
}
private class UserViewHolder extends BindableViewHolder<WrappedAccount> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
private final ImageView ava;
private final TextView name, username;
private UserViewHolder(){
super(activity, R.layout.item_autocomplete_user, list);
ava=findViewById(R.id.photo);
name=findViewById(R.id.name);
username=findViewById(R.id.username);
ava.setOutlineProvider(OutlineProviders.roundedRect(12));
ava.setClipToOutline(true);
}
@Override
public void onBind(WrappedAccount item){
name.setText(item.parsedName);
username.setText("@"+item.account.acct);
}
@Override
public void onClick(){
completionSelectedListener.accept("@"+item.account.acct);
}
@Override
public void setImage(int index, Drawable image){
if(index==0){
ava.setImageDrawable(image);
}else{
item.emojiHelper.setImageDrawable(index-1, image);
name.invalidate();
}
}
@Override
public void clearImage(int index){
setImage(index, null);
}
}
private class HashtagsAdapter extends RecyclerView.Adapter<HashtagViewHolder>{
@NonNull
@Override
public HashtagViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new HashtagViewHolder();
}
@Override
public void onBindViewHolder(@NonNull HashtagViewHolder holder, int position){
holder.bind(hashtags.get(position));
}
@Override
public int getItemCount(){
return hashtags.size();
}
}
private class HashtagViewHolder extends BindableViewHolder<Hashtag> implements UsableRecyclerView.Clickable{
private final TextView text;
private HashtagViewHolder(){
super(new TextView(activity));
text=(TextView) itemView;
text.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, V.dp(48)));
text.setTextAppearance(R.style.m3_title_medium);
text.setTypeface(Typeface.DEFAULT);
text.setSingleLine();
text.setEllipsize(TextUtils.TruncateAt.END);
text.setGravity(Gravity.CENTER_VERTICAL);
text.setPadding(V.dp(16), 0, V.dp(16), 0);
}
@Override
public void onBind(Hashtag item){
text.setText("#"+item.name);
}
@Override
public void onClick(){
completionSelectedListener.accept("#"+item.name);
}
}
private class EmojisAdapter extends UsableRecyclerView.Adapter<EmojiViewHolder> implements ImageLoaderRecyclerAdapter{
public EmojisAdapter(){
super(imgLoader);
}
@NonNull
@Override
public EmojiViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
return new EmojiViewHolder();
}
@Override
public int getItemCount(){
return emojis.size();
}
@Override
public void onBindViewHolder(EmojiViewHolder holder, int position){
holder.bind(emojis.get(position));
super.onBindViewHolder(holder, position);
}
@Override
public int getImageCountForItem(int position){
return 1;
}
@Override
public ImageLoaderRequest getImageRequest(int position, int image){
return emojis.get(position).request;
}
}
private class EmojiViewHolder extends BindableViewHolder<WrappedEmoji> implements ImageLoaderViewHolder, UsableRecyclerView.Clickable{
private final ImageView ava;
private final TextView name;
private EmojiViewHolder(){
super(activity, R.layout.item_autocomplete_user, list);
ava=findViewById(R.id.photo);
name=findViewById(R.id.name);
}
@Override
public void setImage(int index, Drawable image){
ava.setImageDrawable(image);
}
@Override
public void clearImage(int index){
ava.setImageDrawable(null);
}
@Override
public void onBind(WrappedEmoji item){
name.setText(":"+item.emoji.shortcode+":");
}
@Override
public void onClick(){
completionSelectedListener.accept(":"+item.emoji.shortcode+":");
}
}
private static class WrappedAccount{
private Account account;
private CharSequence parsedName;
private CustomEmojiHelper emojiHelper;
private ImageLoaderRequest avaRequest;
public WrappedAccount(Account account){
this.account=account;
parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis);
emojiHelper=new CustomEmojiHelper();
emojiHelper.setText(parsedName);
avaRequest=new UrlImageLoaderRequest(account.avatar, V.dp(50), V.dp(50));
}
}
private static class WrappedEmoji{
private Emoji emoji;
private ImageLoaderRequest request;
public WrappedEmoji(Emoji emoji){
this.emoji=emoji;
request=new UrlImageLoaderRequest(emoji.url, V.dp(44), V.dp(44));
}
}
private enum Mode{
USERS,
HASHTAGS,
EMOJIS
}
}

View File

@ -0,0 +1,84 @@
package org.joinmastodon.android.ui.drawables;
import android.graphics.Canvas;
import android.graphics.ColorFilter;
import android.graphics.Paint;
import android.graphics.Path;
import android.graphics.PixelFormat;
import android.graphics.Rect;
import android.graphics.drawable.Drawable;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import me.grishka.appkit.utils.V;
public class ComposeAutocompleteBackgroundDrawable extends Drawable{
private Path path=new Path();
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private int fillColor, arrowOffset;
public ComposeAutocompleteBackgroundDrawable(int fillColor){
this.fillColor=fillColor;
}
@Override
public void draw(@NonNull Canvas canvas){
Rect bounds=getBounds();
canvas.save();
canvas.translate(bounds.left, bounds.top);
paint.setColor(0x80000000);
canvas.drawPath(path, paint);
canvas.translate(0, V.dp(1));
paint.setColor(fillColor);
canvas.drawPath(path, paint);
int arrowSize=V.dp(10);
canvas.drawRect(0, arrowSize, bounds.width(), bounds.height(), paint);
canvas.restore();
}
@Override
public void setAlpha(int alpha){
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter){
}
@Override
public int getOpacity(){
return PixelFormat.TRANSLUCENT;
}
public void setArrowOffset(int offset){
arrowOffset=offset;
updatePath();
invalidateSelf();
}
@Override
protected void onBoundsChange(Rect bounds){
super.onBoundsChange(bounds);
updatePath();
}
@Override
public boolean getPadding(@NonNull Rect padding){
padding.top=V.dp(11);
return true;
}
private void updatePath(){
path.rewind();
int arrowSize=V.dp(10);
path.moveTo(0, arrowSize*2);
path.lineTo(0, arrowSize);
path.lineTo(arrowOffset-arrowSize, arrowSize);
path.lineTo(arrowOffset, 0);
path.lineTo(arrowOffset+arrowSize, arrowSize);
path.lineTo(getBounds().width(), arrowSize);
path.lineTo(getBounds().width(), arrowSize*2);
path.close();
}
}

View File

@ -0,0 +1,11 @@
package org.joinmastodon.android.ui.text;
import android.text.TextPaint;
import android.text.style.CharacterStyle;
public class ComposeAutocompleteSpan extends CharacterStyle{
@Override
public void updateDrawState(TextPaint tp){
}
}

View File

@ -0,0 +1,14 @@
package org.joinmastodon.android.ui.text;
import android.graphics.Typeface;
import android.text.TextPaint;
public class ComposeHashtagOrMentionSpan extends ComposeAutocompleteSpan{
private static final Typeface MEDIUM_TYPEFACE=Typeface.create("sans-serif-medium", 0);
@Override
public void updateDrawState(TextPaint tp){
tp.setColor(tp.linkColor);
tp.setTypeface(MEDIUM_TYPEFACE);
}
}

View File

@ -1,5 +1,6 @@
package org.joinmastodon.android.ui.utils; package org.joinmastodon.android.ui.utils;
import android.graphics.drawable.Animatable;
import android.graphics.drawable.Drawable; import android.graphics.drawable.Drawable;
import android.text.Spanned; import android.text.Spanned;
@ -40,5 +41,7 @@ public class CustomEmojiHelper{
for(CustomEmojiSpan span:spans.get(image)){ for(CustomEmojiSpan span:spans.get(image)){
span.setDrawable(drawable); span.setDrawable(drawable);
} }
if(drawable instanceof Animatable && !((Animatable) drawable).isRunning())
((Animatable) drawable).start();
} }
} }

View File

@ -0,0 +1,40 @@
package org.joinmastodon.android.ui.views;
import android.content.Context;
import android.util.AttributeSet;
import android.widget.EditText;
public class SelectionListenerEditText extends EditText{
private SelectionListener selectionListener;
public SelectionListenerEditText(Context context){
super(context);
}
public SelectionListenerEditText(Context context, AttributeSet attrs){
super(context, attrs);
}
public SelectionListenerEditText(Context context, AttributeSet attrs, int defStyleAttr){
super(context, attrs, defStyleAttr);
}
public SelectionListenerEditText(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes){
super(context, attrs, defStyleAttr, defStyleRes);
}
@Override
protected void onSelectionChanged(int selStart, int selEnd){
super.onSelectionChanged(selStart, selEnd);
if(selectionListener!=null)
selectionListener.onSelectionChanged(selStart, selEnd);
}
public void setSelectionListener(SelectionListener selectionListener){
this.selectionListener=selectionListener;
}
public interface SelectionListener{
void onSelectionChanged(int start, int end);
}
}

View File

@ -82,19 +82,27 @@
</RelativeLayout> </RelativeLayout>
<EditText <FrameLayout
android:id="@+id/toot_text" android:id="@+id/toot_text_wrap"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="10dp" android:layout_marginTop="10dp">
android:paddingLeft="16dp"
android:paddingRight="16dp" <org.joinmastodon.android.ui.views.SelectionListenerEditText
android:paddingBottom="16dp" android:id="@+id/toot_text"
android:textAppearance="@style/m3_body_large" android:layout_width="match_parent"
android:gravity="top" android:layout_height="wrap_content"
android:background="@null" android:paddingLeft="16dp"
android:hint="@string/compose_hint" android:paddingRight="16dp"
android:inputType="textMultiLine|textCapSentences"/> android:paddingBottom="16dp"
android:textAppearance="@style/m3_body_large"
android:gravity="top"
android:background="@null"
android:hint="@string/compose_hint"
android:elevation="0dp"
android:inputType="textMultiLine|textCapSentences"/>
</FrameLayout>
<LinearLayout <LinearLayout
android:id="@+id/poll_wrap" android:id="@+id/poll_wrap"

View File

@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingTop="8dp"
android:paddingBottom="8dp"
android:paddingLeft="16dp"
android:paddingRight="16dp">
<ImageView
android:id="@+id/photo"
android:layout_width="40dp"
android:layout_height="40dp"
android:layout_marginEnd="12dp"
tools:src="#0f0"/>
<TextView
android:id="@+id/name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_toEndOf="@id/photo"
android:textAppearance="@style/m3_title_medium"
android:fontFamily="sans-serif"
android:singleLine="true"
android:ellipsize="end"
tools:text="User Name"/>
<TextView
android:id="@+id/username"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_toEndOf="@id/photo"
android:layout_alignBottom="@id/photo"
android:textAppearance="@style/m3_body_medium"
android:textColor="?android:textColorSecondary"
android:singleLine="true"
android:ellipsize="end"
tools:text="\@user@domain"/>
</RelativeLayout>