From 7186b6387f9e3eb3ffb8f233730f58c686df54ea Mon Sep 17 00:00:00 2001 From: Grishka Date: Thu, 17 Mar 2022 06:28:36 +0300 Subject: [PATCH] Compose: auto-complete mentions, hashtags, and emojis --- mastodon/build.gradle | 2 +- .../api/requests/search/GetSearchResults.java | 24 + .../android/fragments/ComposeFragment.java | 134 ++++- .../android/model/SearchResults.java | 28 ++ .../ui/ComposeAutocompleteViewController.java | 471 ++++++++++++++++++ ...ComposeAutocompleteBackgroundDrawable.java | 84 ++++ .../ui/text/ComposeAutocompleteSpan.java | 11 + .../ui/text/ComposeHashtagOrMentionSpan.java | 14 + .../android/ui/utils/CustomEmojiHelper.java | 3 + .../ui/views/SelectionListenerEditText.java | 40 ++ .../src/main/res/layout/fragment_compose.xml | 30 +- .../res/layout/item_autocomplete_user.xml | 41 ++ 12 files changed, 863 insertions(+), 19 deletions(-) create mode 100644 mastodon/src/main/java/org/joinmastodon/android/api/requests/search/GetSearchResults.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/model/SearchResults.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/ComposeAutocompleteViewController.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/drawables/ComposeAutocompleteBackgroundDrawable.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/text/ComposeAutocompleteSpan.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/text/ComposeHashtagOrMentionSpan.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/ui/views/SelectionListenerEditText.java create mode 100644 mastodon/src/main/res/layout/item_autocomplete_user.xml diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 6ea68821..a88da3ec 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -10,7 +10,7 @@ android { applicationId "org.joinmastodon.android" minSdk 23 targetSdk 31 - versionCode 13 + versionCode 14 versionName "0.1" } diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/search/GetSearchResults.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/search/GetSearchResults.java new file mode 100644 index 00000000..edec0727 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/search/GetSearchResults.java @@ -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{ + 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 + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java index 7e7f16da..6bcd0fd2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ComposeFragment.java @@ -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()+spanStart0){ + 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; diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/SearchResults.java b/mastodon/src/main/java/org/joinmastodon/android/model/SearchResults.java new file mode 100644 index 00000000..1c6d3f8a --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/SearchResults.java @@ -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 accounts; + public List statuses; + public List 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(); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/ComposeAutocompleteViewController.java b/mastodon/src/main/java/org/joinmastodon/android/ui/ComposeAutocompleteViewController.java new file mode 100644 index 00000000..90fadde6 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/ComposeAutocompleteViewController.java @@ -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 users=Collections.emptyList(); + private List hashtags=Collections.emptyList(); + private List 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 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 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 completionSelectedListener){ + this.completionSelectedListener=completionSelectedListener; + } + + public void setArrowOffset(int offset){ + background.setArrowOffset(offset); + } + + public View getView(){ + return contentView; + } + + private void updateList(List oldList, List newList, RecyclerView.Adapter adapter, BiPredicate 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 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 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 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 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{ + + @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 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 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 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 + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/ComposeAutocompleteBackgroundDrawable.java b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/ComposeAutocompleteBackgroundDrawable.java new file mode 100644 index 00000000..2e26af4d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/drawables/ComposeAutocompleteBackgroundDrawable.java @@ -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(); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/ComposeAutocompleteSpan.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/ComposeAutocompleteSpan.java new file mode 100644 index 00000000..760811c4 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/ComposeAutocompleteSpan.java @@ -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){ + + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/ComposeHashtagOrMentionSpan.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/ComposeHashtagOrMentionSpan.java new file mode 100644 index 00000000..91152e00 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/ComposeHashtagOrMentionSpan.java @@ -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); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/CustomEmojiHelper.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/CustomEmojiHelper.java index a9a20892..c150114e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/CustomEmojiHelper.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/CustomEmojiHelper.java @@ -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(); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/views/SelectionListenerEditText.java b/mastodon/src/main/java/org/joinmastodon/android/ui/views/SelectionListenerEditText.java new file mode 100644 index 00000000..36eec7f5 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/views/SelectionListenerEditText.java @@ -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); + } +} diff --git a/mastodon/src/main/res/layout/fragment_compose.xml b/mastodon/src/main/res/layout/fragment_compose.xml index 81778e5a..b76889a9 100644 --- a/mastodon/src/main/res/layout/fragment_compose.xml +++ b/mastodon/src/main/res/layout/fragment_compose.xml @@ -82,19 +82,27 @@ - + android:layout_marginTop="10dp"> + + + + + + + + + + + + + \ No newline at end of file