Hashtag following

closes #684, closes #233
This commit is contained in:
Grishka 2023-09-12 07:49:14 +03:00
parent 209e603f2c
commit 6d84f28600
15 changed files with 332 additions and 16 deletions

View File

@ -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<Hashtag>{
public GetTag(String tag){
super(HttpMethod.GET, "/tags/"+tag, Hashtag.class);
}
}

View File

@ -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<Hashtag>{
public SetTagFollowed(String tag, boolean followed){
super(HttpMethod.POST, "/tags/"+tag+(followed ? "/follow" : "/unfollow"), Hashtag.class);
setRequestBody(new Object());
}
}

View File

@ -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<Hashtag
@Override
public void onItemClick(String id){
UiUtils.openHashtagTimeline(getActivity(), accountID, id);
UiUtils.openHashtagTimeline(getActivity(), accountID, Objects.requireNonNull(findItemOfType(id, HashtagStatusDisplayItem.class)).tag);
}
@Override

View File

@ -1,24 +1,52 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.content.res.TypedArray;
import android.os.Bundle;
import android.text.SpannableStringBuilder;
import android.view.Menu;
import android.view.MenuInflater;
import android.view.MenuItem;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageButton;
import android.widget.ProgressBar;
import android.widget.TextView;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.MastodonErrorResponse;
import org.joinmastodon.android.api.requests.tags.GetTag;
import org.joinmastodon.android.api.requests.tags.SetTagFollowed;
import org.joinmastodon.android.api.requests.timelines.GetHashtagTimeline;
import org.joinmastodon.android.model.Hashtag;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.text.SpacerSpan;
import org.joinmastodon.android.ui.views.ProgressBarButton;
import org.parceler.Parcels;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.Callback;
import me.grishka.appkit.api.ErrorResponse;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.CubicBezierInterpolator;
import me.grishka.appkit.utils.MergeRecyclerAdapter;
import me.grishka.appkit.utils.SingleViewRecyclerAdapter;
import me.grishka.appkit.utils.V;
public class HashtagTimelineFragment extends StatusListFragment{
private String hashtag;
private Hashtag hashtag;
private String hashtagName;
private ImageButton fab;
private TextView headerTitle, headerSubtitle;
private ProgressBarButton followButton;
private ProgressBar followProgress;
private MenuItem followMenuItem;
private boolean followRequestRunning;
private boolean toolbarContentVisible;
public HashtagTimelineFragment(){
setListLayoutId(R.layout.recycler_fragment_with_fab);
@ -27,13 +55,19 @@ public class HashtagTimelineFragment extends StatusListFragment{
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
hashtag=getArguments().getString("hashtag");
setTitle('#'+hashtag);
if(getArguments().containsKey("hashtag")){
hashtag=Parcels.unwrap(getArguments().getParcelable("hashtag"));
hashtagName=hashtag.name;
}else{
hashtagName=getArguments().getString("hashtagName");
}
setTitle('#'+hashtagName);
setHasOptionsMenu(true);
}
@Override
protected void doLoadData(int offset, int count){
currentRequest=new GetHashtagTimeline(hashtag, offset==0 ? null : getMaxID(), null, count)
currentRequest=new GetHashtagTimeline(hashtagName, offset==0 ? null : getMaxID(), null, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Status> 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);
}
}

View File

@ -93,7 +93,7 @@ public class ProfileFeaturedFragment extends BaseStatusListFragment<SearchResult
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();

View File

@ -94,7 +94,7 @@ public class SearchFragment extends BaseStatusListFragment<SearchResult>{
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();

View File

@ -377,7 +377,7 @@ public class SearchQueryFragment extends MastodonRecyclerFragment<SearchResultVi
}
private void openHashtag(SearchResult res){
UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag.name);
UiUtils.openHashtagTimeline(getActivity(), accountID, res.hashtag);
AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putRecentSearch(res);
}

View File

@ -105,7 +105,7 @@ public class TrendingHashtagsFragment extends BaseRecyclerFragment<Hashtag> impl
@Override
public void onClick(){
UiUtils.openHashtagTimeline(getActivity(), accountID, item.name);
UiUtils.openHashtagTimeline(getActivity(), accountID, item);
}
}
}

View File

@ -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);
}
}

View File

@ -13,6 +13,7 @@ public class Hashtag extends BaseModel implements DisplayItemsParent{
public String url;
public List<History> 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+
'}';
}

View File

@ -84,6 +84,7 @@ public class HtmlParser{
Map<String, String> 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<String, String> tagsByUrl=tags.stream().collect(Collectors.toMap(t->t.url, t->t.name));
Map<String, Hashtag> 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;
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -0,0 +1,62 @@
<?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:paddingHorizontal="16dp"
android:paddingBottom="8dp">
<TextView
android:id="@+id/title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_toStartOf="@id/follow_btn_wrap"
android:layout_marginEnd="8dp"
android:textAppearance="@style/m3_headline_small"
android:textColor="?colorM3OnSurface"
android:maxLines="4"
android:ellipsize="end"
android:minHeight="36dp"
android:gravity="center_vertical"
tools:text="#CatsOfMastodonButLong"/>
<FrameLayout
android:id="@+id/follow_btn_wrap"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignTop="@id/title"
android:layout_alignBottom="@id/title"
android:layout_alignParentEnd="true">
<org.joinmastodon.android.ui.views.ProgressBarButton
android:id="@+id/profile_action_btn"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:layout_gravity="center"
style="@style/Widget.Mastodon.M3.Button.Filled"
android:paddingHorizontal="16dp"
tools:text="@string/button_follow" />
<ProgressBar
android:id="@+id/action_progress"
style="?android:progressBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:elevation="10dp"
android:indeterminate="true"
android:outlineProvider="none"
android:visibility="gone" />
</FrameLayout>
<TextView
android:id="@+id/subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_below="@id/title"
android:layout_marginTop="8dp"
android:textAppearance="@style/m3_label_large"
android:textColor="?colorM3OnSurfaceVariant"
tools:text="123 posts"/>
</RelativeLayout>

View File

@ -596,4 +596,13 @@
<string name="post_translated">Translated from %1$s using %2$s</string>
<string name="translation_show_original">Show original</string>
<string name="translation_failed">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.</string>
<plurals name="x_participants">
<item quantity="one">%,d participant</item>
<item quantity="other">%,d participants</item>
</plurals>
<plurals name="x_posts_today">
<item quantity="one">%,d post today</item>
<item quantity="other">%,d posts today</item>
</plurals>
</resources>