diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetTag.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetTag.java new file mode 100644 index 00000000..06b90b18 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/GetTag.java @@ -0,0 +1,10 @@ +package org.joinmastodon.android.api.requests.tags; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Hashtag; + +public class GetTag extends MastodonAPIRequest{ + public GetTag(String tag){ + super(HttpMethod.GET, "/tags/"+tag, Hashtag.class); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/SetTagFollowed.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/SetTagFollowed.java new file mode 100644 index 00000000..4ecf6e73 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/tags/SetTagFollowed.java @@ -0,0 +1,11 @@ +package org.joinmastodon.android.api.requests.tags; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Hashtag; + +public class SetTagFollowed extends MastodonAPIRequest{ + public SetTagFollowed(String tag, boolean followed){ + super(HttpMethod.POST, "/tags/"+tag+(followed ? "/follow" : "/unfollow"), Hashtag.class); + setRequestBody(new Object()); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java index 61915a3f..8851aa34 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FeaturedHashtagsListFragment.java @@ -15,6 +15,7 @@ import org.parceler.Parcels; import java.util.Collections; import java.util.List; +import java.util.Objects; import java.util.stream.Collectors; import androidx.recyclerview.widget.RecyclerView; @@ -44,7 +45,7 @@ public class FeaturedHashtagsListFragment extends BaseStatusListFragment(this){ @Override public void onSuccess(List result){ @@ -50,17 +84,39 @@ public class HashtagTimelineFragment extends StatusListFragment{ loadData(); } + @Override + public void loadData(){ + reloadTag(); + super.loadData(); + } + @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); fab=view.findViewById(R.id.fab); fab.setOnClickListener(this::onFabClick); + + list.addOnScrollListener(new RecyclerView.OnScrollListener(){ + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ + View topChild=recyclerView.getChildAt(0); + int firstChildPos=recyclerView.getChildAdapterPosition(topChild); + float newAlpha=firstChildPos>0 ? 1f : Math.min(1f, -topChild.getTop()/(float)headerTitle.getHeight()); + toolbarTitleView.setAlpha(newAlpha); + boolean newToolbarVisibility=newAlpha>0.5f; + if(newToolbarVisibility!=toolbarContentVisible){ + toolbarContentVisible=newToolbarVisibility; + if(followMenuItem!=null) + followMenuItem.setVisible(toolbarContentVisible); + } + } + }); } private void onFabClick(View v){ Bundle args=new Bundle(); args.putString("account", accountID); - args.putString("prefilledText", '#'+hashtag+' '); + args.putString("prefilledText", '#'+hashtagName+' '); Nav.go(getActivity(), ComposeFragment.class, args); } @@ -68,4 +124,150 @@ public class HashtagTimelineFragment extends StatusListFragment{ protected void onSetFabBottomInset(int inset){ ((ViewGroup.MarginLayoutParams) fab.getLayoutParams()).bottomMargin=V.dp(16)+inset; } + + @Override + protected RecyclerView.Adapter getAdapter(){ + View header=getActivity().getLayoutInflater().inflate(R.layout.header_hashtag_timeline, list, false); + headerTitle=header.findViewById(R.id.title); + headerSubtitle=header.findViewById(R.id.subtitle); + followButton=header.findViewById(R.id.profile_action_btn); + followProgress=header.findViewById(R.id.action_progress); + + headerTitle.setText("#"+hashtagName); + followButton.setVisibility(View.GONE); + followButton.setOnClickListener(v->{ + if(hashtag==null) + return; + setFollowed(!hashtag.following); + }); + updateHeader(); + + MergeRecyclerAdapter mergeAdapter=new MergeRecyclerAdapter(); + mergeAdapter.addAdapter(new SingleViewRecyclerAdapter(header)); + mergeAdapter.addAdapter(super.getAdapter()); + return mergeAdapter; + } + + @Override + protected int getMainAdapterOffset(){ + return 1; + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ + followMenuItem=menu.add(getString(hashtag!=null && hashtag.following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName)); + followMenuItem.setVisible(toolbarContentVisible); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item){ + if(hashtag!=null){ + setFollowed(!hashtag.following); + } + return true; + } + + @Override + protected void onUpdateToolbar(){ + super.onUpdateToolbar(); + toolbarTitleView.setAlpha(toolbarContentVisible ? 1f : 0f); + if(followMenuItem!=null) + followMenuItem.setVisible(toolbarContentVisible); + } + + private void updateHeader(){ + if(hashtag==null) + return; + + if(hashtag.history!=null && !hashtag.history.isEmpty()){ + int weekPosts=hashtag.history.stream().mapToInt(h->h.uses).sum(); + int todayPosts=hashtag.history.get(0).uses; + int numAccounts=hashtag.history.stream().mapToInt(h->h.accounts).sum(); + int hSpace=V.dp(8); + SpannableStringBuilder ssb=new SpannableStringBuilder(); + ssb.append(getResources().getQuantityString(R.plurals.x_posts, weekPosts, weekPosts)); + ssb.append(" ", new SpacerSpan(hSpace, 0), 0); + ssb.append('·'); + ssb.append(" ", new SpacerSpan(hSpace, 0), 0); + ssb.append(getResources().getQuantityString(R.plurals.x_participants, numAccounts, numAccounts)); + ssb.append(" ", new SpacerSpan(hSpace, 0), 0); + ssb.append('·'); + ssb.append(" ", new SpacerSpan(hSpace, 0), 0); + ssb.append(getResources().getQuantityString(R.plurals.x_posts_today, todayPosts, todayPosts)); + headerSubtitle.setText(ssb); + } + + int styleRes; + followButton.setVisibility(View.VISIBLE); + if(hashtag.following){ + followButton.setText(R.string.button_following); + styleRes=R.style.Widget_Mastodon_M3_Button_Tonal; + }else{ + followButton.setText(R.string.button_follow); + styleRes=R.style.Widget_Mastodon_M3_Button_Filled; + } + TypedArray ta=followButton.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.background}); + followButton.setBackground(ta.getDrawable(0)); + ta.recycle(); + ta=followButton.getContext().obtainStyledAttributes(styleRes, new int[]{android.R.attr.textColor}); + followButton.setTextColor(ta.getColorStateList(0)); + followProgress.setIndeterminateTintList(ta.getColorStateList(0)); + ta.recycle(); + + followButton.setTextVisible(true); + followProgress.setVisibility(View.GONE); + if(followMenuItem!=null){ + followMenuItem.setTitle(getString(hashtag.following ? R.string.unfollow_user : R.string.follow_user, "#"+hashtagName)); + } + } + + private void reloadTag(){ + new GetTag(hashtagName) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Hashtag result){ + hashtag=result; + updateHeader(); + } + + @Override + public void onError(ErrorResponse error){ + + } + }) + .exec(accountID); + } + + private void setFollowed(boolean followed){ + if(followRequestRunning) + return; + followButton.setTextVisible(false); + followProgress.setVisibility(View.VISIBLE); + followRequestRunning=true; + new SetTagFollowed(hashtagName, followed) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Hashtag result){ + if(getActivity()==null) + return; + hashtag=result; + updateHeader(); + followRequestRunning=false; + } + + @Override + public void onError(ErrorResponse error){ + if(getActivity()==null) + return; + if(error instanceof MastodonErrorResponse er && "Duplicate record".equals(er.error)){ + hashtag.following=true; + }else{ + error.showToast(getActivity()); + } + updateHeader(); + followRequestRunning=false; + } + }) + .exec(accountID); + } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFeaturedFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFeaturedFragment.java index 706777b8..5f915057 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFeaturedFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFeaturedFragment.java @@ -93,7 +93,7 @@ public class ProfileFeaturedFragment extends BaseStatusListFragment UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag.name); + case HASHTAG -> UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag); case STATUS -> { Status status=res.status.getContentStatus(); Bundle args=new Bundle(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java index d51078af..62deef4a 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchFragment.java @@ -94,7 +94,7 @@ public class SearchFragment extends BaseStatusListFragment{ args.putParcelable("profileAccount", Parcels.wrap(res.account)); Nav.go(getActivity(), ProfileFragment.class, args); } - case HASHTAG -> UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag.name); + case HASHTAG -> UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag); case STATUS -> { Status status=res.status.getContentStatus(); Bundle args=new Bundle(); diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java index d455ee47..ac8940ea 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/discover/SearchQueryFragment.java @@ -377,7 +377,7 @@ public class SearchQueryFragment extends MastodonRecyclerFragment impl @Override public void onClick(){ - UiUtils.openHashtagTimeline(getActivity(), accountID, item.name); + UiUtils.openHashtagTimeline(getActivity(), accountID, item); } } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/SignupFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/SignupFragment.java index 8455100c..b0bf6d21 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/SignupFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/onboarding/SignupFragment.java @@ -271,7 +271,7 @@ public class SignupFragment extends ToolbarFragment{ @Override public void tail(Node node, int depth){ if(node instanceof Element){ - ssb.setSpan(new LinkSpan("", SignupFragment.this::onGoBackLinkClick, LinkSpan.Type.CUSTOM, null), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + ssb.setSpan(new LinkSpan("", SignupFragment.this::onGoBackLinkClick, LinkSpan.Type.CUSTOM, null, null), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); ssb.setSpan(new TypefaceSpan("sans-serif-medium"), spanStart, ssb.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java b/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java index 0143f852..392a6a29 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java +++ b/mastodon/src/main/java/org/joinmastodon/android/model/Hashtag.java @@ -13,6 +13,7 @@ public class Hashtag extends BaseModel implements DisplayItemsParent{ public String url; public List history; public int statusesCount; + public boolean following; @Override public String toString(){ @@ -21,6 +22,7 @@ public class Hashtag extends BaseModel implements DisplayItemsParent{ ", url='"+url+'\''+ ", history="+history+ ", statusesCount="+statusesCount+ + ", following="+following+ '}'; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java index 4929d611..29db5699 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/HtmlParser.java @@ -84,6 +84,7 @@ public class HtmlParser{ Map idsByUrl=mentions.stream().collect(Collectors.toMap(m->m.url, m->m.id)); // Hashtags in remote posts have remote URLs, these have local URLs so they don't match. // Map tagsByUrl=tags.stream().collect(Collectors.toMap(t->t.url, t->t.name)); + Map tagsByTag=tags.stream().collect(Collectors.toMap(t->t.name.toLowerCase(), Function.identity())); final SpannableStringBuilder ssb=new SpannableStringBuilder(); Jsoup.parseBodyFragment(source).body().traverse(new NodeVisitor(){ @@ -96,6 +97,7 @@ public class HtmlParser{ }else if(node instanceof Element el){ switch(el.nodeName()){ case "a" -> { + Object linkObject=null; String href=el.attr("href"); LinkSpan.Type linkType; if(el.hasClass("hashtag")){ @@ -103,6 +105,7 @@ public class HtmlParser{ if(text.startsWith("#")){ linkType=LinkSpan.Type.HASHTAG; href=text.substring(1); + linkObject=tagsByTag.get(text.substring(1).toLowerCase()); }else{ linkType=LinkSpan.Type.URL; } @@ -117,7 +120,7 @@ public class HtmlParser{ }else{ linkType=LinkSpan.Type.URL; } - openSpans.add(new SpanInfo(new LinkSpan(href, null, linkType, accountID), ssb.length(), el)); + openSpans.add(new SpanInfo(new LinkSpan(href, null, linkType, accountID, linkObject), ssb.length(), el)); } case "br" -> ssb.append('\n'); case "span" -> { @@ -213,7 +216,7 @@ public class HtmlParser{ String url=matcher.group(3); if(TextUtils.isEmpty(matcher.group(4))) url="http://"+url; - ssb.setSpan(new LinkSpan(url, null, LinkSpan.Type.URL, null), matcher.start(3), matcher.end(3), 0); + ssb.setSpan(new LinkSpan(url, null, LinkSpan.Type.URL, null, null), matcher.start(3), matcher.end(3), 0); }while(matcher.find()); // Find more URLs return ssb; } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java b/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java index 1dad4082..22137bd3 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/text/LinkSpan.java @@ -4,6 +4,7 @@ import android.content.Context; import android.text.TextPaint; import android.text.style.CharacterStyle; +import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.ui.utils.UiUtils; public class LinkSpan extends CharacterStyle { @@ -13,12 +14,14 @@ public class LinkSpan extends CharacterStyle { private String link; private Type type; private String accountID; + private Object linkObject; - public LinkSpan(String link, OnLinkClickListener listener, Type type, String accountID){ + public LinkSpan(String link, OnLinkClickListener listener, Type type, String accountID, Object linkObject){ this.listener=listener; this.link=link; this.type=type; this.accountID=accountID; + this.linkObject=linkObject; } public int getColor(){ @@ -35,7 +38,12 @@ public class LinkSpan extends CharacterStyle { switch(getType()){ case URL -> UiUtils.openURL(context, accountID, link); case MENTION -> UiUtils.openProfileByID(context, accountID, link); - case HASHTAG -> UiUtils.openHashtagTimeline(context, accountID, link); + case HASHTAG -> { + if(linkObject instanceof Hashtag ht) + UiUtils.openHashtagTimeline(context, accountID, ht); + else + UiUtils.openHashtagTimeline(context, accountID, link); + } case CUSTOM -> listener.onLinkClick(this); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java index 4aec84b6..c42a3a49 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/utils/UiUtils.java @@ -61,6 +61,7 @@ import org.joinmastodon.android.fragments.ProfileFragment; import org.joinmastodon.android.fragments.ThreadFragment; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Emoji; +import org.joinmastodon.android.model.Hashtag; import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.M3AlertDialogBuilder; @@ -342,10 +343,17 @@ public class UiUtils{ Nav.go((Activity)context, ProfileFragment.class, args); } + public static void openHashtagTimeline(Context context, String accountID, Hashtag hashtag){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("hashtag", Parcels.wrap(hashtag)); + Nav.go((Activity)context, HashtagTimelineFragment.class, args); + } + public static void openHashtagTimeline(Context context, String accountID, String hashtag){ Bundle args=new Bundle(); args.putString("account", accountID); - args.putString("hashtag", hashtag); + args.putString("hashtagName", hashtag); Nav.go((Activity)context, HashtagTimelineFragment.class, args); } diff --git a/mastodon/src/main/res/layout/header_hashtag_timeline.xml b/mastodon/src/main/res/layout/header_hashtag_timeline.xml new file mode 100644 index 00000000..750a1638 --- /dev/null +++ b/mastodon/src/main/res/layout/header_hashtag_timeline.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/mastodon/src/main/res/values/strings.xml b/mastodon/src/main/res/values/strings.xml index 0659200c..20b8593b 100644 --- a/mastodon/src/main/res/values/strings.xml +++ b/mastodon/src/main/res/values/strings.xml @@ -596,4 +596,13 @@ Translated from %1$s using %2$s Show original Translation failed. Maybe the administrator has not enabled translations on this server or this server is running an older version of Mastodon where translations are not yet supported. + + + %,d participant + %,d participants + + + %,d post today + %,d posts today + \ No newline at end of file