Compose: auto-complete mentions, hashtags, and emojis
This commit is contained in:
parent
098128bcd4
commit
7186b6387f
|
@ -10,7 +10,7 @@ android {
|
|||
applicationId "org.joinmastodon.android"
|
||||
minSdk 23
|
||||
targetSdk 31
|
||||
versionCode 13
|
||||
versionCode 14
|
||||
versionName "0.1"
|
||||
}
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -3,7 +3,6 @@ package org.joinmastodon.android.fragments;
|
|||
import android.animation.ObjectAnimator;
|
||||
import android.annotation.SuppressLint;
|
||||
import android.app.Activity;
|
||||
import android.app.ProgressDialog;
|
||||
import android.content.ClipData;
|
||||
import android.content.Intent;
|
||||
import android.content.res.ColorStateList;
|
||||
|
@ -19,17 +18,17 @@ import android.os.Build;
|
|||
import android.os.Bundle;
|
||||
import android.os.Parcelable;
|
||||
import android.text.Editable;
|
||||
import android.text.Layout;
|
||||
import android.text.SpannableStringBuilder;
|
||||
import android.text.Spanned;
|
||||
import android.text.TextUtils;
|
||||
import android.text.TextWatcher;
|
||||
import android.text.style.ImageSpan;
|
||||
import android.util.Log;
|
||||
import android.view.Gravity;
|
||||
import android.view.LayoutInflater;
|
||||
import android.view.Menu;
|
||||
import android.view.MenuInflater;
|
||||
import android.view.MenuItem;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
import android.view.ViewGroup;
|
||||
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.Status;
|
||||
import org.joinmastodon.android.model.StatusPrivacy;
|
||||
import org.joinmastodon.android.ui.ComposeAutocompleteViewController;
|
||||
import org.joinmastodon.android.ui.CustomEmojiPopupKeyboard;
|
||||
import org.joinmastodon.android.ui.M3AlertDialogBuilder;
|
||||
import org.joinmastodon.android.ui.PopupKeyboard;
|
||||
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.SpacerSpan;
|
||||
import org.joinmastodon.android.ui.utils.SimpleTextWatcher;
|
||||
import org.joinmastodon.android.ui.utils.UiUtils;
|
||||
import org.joinmastodon.android.ui.views.ComposeMediaLayout;
|
||||
import org.joinmastodon.android.ui.views.ReorderableLinearLayout;
|
||||
import org.joinmastodon.android.ui.views.SelectionListenerEditText;
|
||||
import org.joinmastodon.android.ui.views.SizeListenerLinearLayout;
|
||||
import org.parceler.Parcel;
|
||||
import org.parceler.Parcels;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
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.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 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);
|
||||
|
||||
// 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 =
|
||||
"(" + // $1 total match
|
||||
"(" + Regex.URL_VALID_PRECEDING_CHARS + ")" + // $2 Preceding character
|
||||
|
@ -130,7 +137,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
|||
private Account self;
|
||||
private String instanceDomain;
|
||||
|
||||
private EditText mainEditText;
|
||||
private SelectionListenerEditText mainEditText;
|
||||
private TextView charCounter;
|
||||
private String accountID;
|
||||
private int charCount, charLimit, trimmedCharCount;
|
||||
|
@ -163,6 +170,9 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
|||
private View sendingOverlay;
|
||||
private WindowManager wm;
|
||||
private StatusPrivacy statusVisibility=StatusPrivacy.PUBLIC;
|
||||
private ComposeAutocompleteSpan currentAutocompleteSpan;
|
||||
private FrameLayout mainEditTextWrap;
|
||||
private ComposeAutocompleteViewController autocompleteViewController;
|
||||
|
||||
@Override
|
||||
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);
|
||||
mainEditText=view.findViewById(R.id.toot_text);
|
||||
mainEditTextWrap=view.findViewById(R.id.toot_text_wrap);
|
||||
charCounter=view.findViewById(R.id.char_counter);
|
||||
charCounter.setText(String.valueOf(charLimit));
|
||||
|
||||
|
@ -295,6 +306,12 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
|||
}
|
||||
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;
|
||||
}
|
||||
|
||||
|
@ -336,6 +353,7 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
|||
imm.showSoftInput(mainEditText, 0);
|
||||
}, 100);
|
||||
|
||||
mainEditText.setSelectionListener(this);
|
||||
mainEditText.addTextChangedListener(new TextWatcher(){
|
||||
@Override
|
||||
public void beforeTextChanged(CharSequence s, int start, int count, int after){
|
||||
|
@ -344,7 +362,49 @@ public class ComposeFragment extends ToolbarFragment implements OnBackPressedLis
|
|||
|
||||
@Override
|
||||
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
|
||||
|
@ -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
|
||||
static class DraftMediaAttachment{
|
||||
public Attachment serverAttachment;
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
}
|
|
@ -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){
|
||||
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -1,5 +1,6 @@
|
|||
package org.joinmastodon.android.ui.utils;
|
||||
|
||||
import android.graphics.drawable.Animatable;
|
||||
import android.graphics.drawable.Drawable;
|
||||
import android.text.Spanned;
|
||||
|
||||
|
@ -40,5 +41,7 @@ public class CustomEmojiHelper{
|
|||
for(CustomEmojiSpan span:spans.get(image)){
|
||||
span.setDrawable(drawable);
|
||||
}
|
||||
if(drawable instanceof Animatable && !((Animatable) drawable).isRunning())
|
||||
((Animatable) drawable).start();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -82,11 +82,16 @@
|
|||
|
||||
</RelativeLayout>
|
||||
|
||||
<EditText
|
||||
<FrameLayout
|
||||
android:id="@+id/toot_text_wrap"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp">
|
||||
|
||||
<org.joinmastodon.android.ui.views.SelectionListenerEditText
|
||||
android:id="@+id/toot_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:paddingLeft="16dp"
|
||||
android:paddingRight="16dp"
|
||||
android:paddingBottom="16dp"
|
||||
|
@ -94,8 +99,11 @@
|
|||
android:gravity="top"
|
||||
android:background="@null"
|
||||
android:hint="@string/compose_hint"
|
||||
android:elevation="0dp"
|
||||
android:inputType="textMultiLine|textCapSentences"/>
|
||||
|
||||
</FrameLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/poll_wrap"
|
||||
android:layout_width="match_parent"
|
||||
|
|
|
@ -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>
|
Loading…
Reference in New Issue