From 02a1f2ef8c32363ddad6dc64943fb11348ca5ace Mon Sep 17 00:00:00 2001 From: Grishka Date: Mon, 2 May 2022 05:45:51 +0300 Subject: [PATCH] Add following/followers lists closes #25 --- mastodon/build.gradle | 2 +- .../android/api/MastodonAPIController.java | 8 +- .../android/api/MastodonAPIRequest.java | 3 +- .../api/requests/HeaderPaginationRequest.java | 57 +++ .../accounts/GetAccountFollowers.java | 16 + .../accounts/GetAccountFollowing.java | 16 + .../fragments/BaseAccountListFragment.java | 381 ++++++++++++++++++ .../fragments/FollowerListFragment.java | 49 +++ .../fragments/FollowingListFragment.java | 49 +++ .../android/fragments/ProfileFragment.java | 17 + .../android/model/HeaderPaginationList.java | 24 ++ .../android/ui/AccountSwitcherSheet.java | 4 +- .../android/ui/photoviewer/PhotoViewer.java | 4 +- .../src/main/res/layout/fragment_profile.xml | 151 +++---- .../src/main/res/layout/item_account_list.xml | 62 +++ mastodon/src/main/res/values/strings.xml | 28 +- 16 files changed, 786 insertions(+), 85 deletions(-) create mode 100644 mastodon/src/main/java/org/joinmastodon/android/api/requests/HeaderPaginationRequest.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFollowers.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFollowing.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/fragments/BaseAccountListFragment.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/fragments/FollowerListFragment.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/fragments/FollowingListFragment.java create mode 100644 mastodon/src/main/java/org/joinmastodon/android/model/HeaderPaginationList.java create mode 100644 mastodon/src/main/res/layout/item_account_list.xml diff --git a/mastodon/build.gradle b/mastodon/build.gradle index 449a6f6b..8e1fec48 100644 --- a/mastodon/build.gradle +++ b/mastodon/build.gradle @@ -58,7 +58,7 @@ dependencies { implementation 'me.grishka.litex:dynamicanimation:1.1.0-alpha03' implementation 'me.grishka.litex:viewpager:1.0.0' implementation 'me.grishka.litex:viewpager2:1.0.0' - implementation 'me.grishka.appkit:appkit:1.2.4' + implementation 'me.grishka.appkit:appkit:1.2.6' implementation 'com.google.code.gson:gson:2.8.9' implementation 'org.jsoup:jsoup:1.14.3' implementation 'com.squareup:otto:1.3.8' diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java index 63fe2346..1f30e737 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIController.java @@ -1,14 +1,10 @@ package org.joinmastodon.android.api; -import android.content.pm.ApplicationInfo; -import android.content.pm.PackageInfo; -import android.content.pm.PackageManager; import android.util.Log; import com.google.gson.FieldNamingPolicy; import com.google.gson.Gson; import com.google.gson.GsonBuilder; -import com.google.gson.JsonArray; import com.google.gson.JsonElement; import com.google.gson.JsonIOException; import com.google.gson.JsonObject; @@ -16,11 +12,9 @@ import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import org.joinmastodon.android.BuildConfig; -import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.gson.IsoInstantTypeAdapter; import org.joinmastodon.android.api.gson.IsoLocalDateTypeAdapter; import org.joinmastodon.android.api.session.AccountSession; -import org.joinmastodon.android.model.BaseModel; import java.io.IOException; import java.io.Reader; @@ -144,7 +138,7 @@ public class MastodonAPIController{ } try{ - req.validateAndPostprocessResponse(respObj); + req.validateAndPostprocessResponse(respObj, response); }catch(IOException x){ if(BuildConfig.DEBUG) Log.w(TAG, "["+(session==null ? "no-auth" : session.getID())+"] "+response+" error post-processing or validating response", x); diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java index 0dc12ea9..3d8adffd 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/MastodonAPIRequest.java @@ -28,6 +28,7 @@ import me.grishka.appkit.api.Callback; import me.grishka.appkit.api.ErrorResponse; import okhttp3.Call; import okhttp3.RequestBody; +import okhttp3.Response; public abstract class MastodonAPIRequest extends APIRequest{ private static final String TAG="MastodonAPIRequest"; @@ -158,7 +159,7 @@ public abstract class MastodonAPIRequest extends APIRequest{ } @CallSuper - public void validateAndPostprocessResponse(T respObj) throws IOException{ + public void validateAndPostprocessResponse(T respObj, Response httpResponse) throws IOException{ if(respObj instanceof BaseModel){ ((BaseModel) respObj).postprocess(); }else if(respObj instanceof List){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/HeaderPaginationRequest.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/HeaderPaginationRequest.java new file mode 100644 index 00000000..b268b3e6 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/HeaderPaginationRequest.java @@ -0,0 +1,57 @@ +package org.joinmastodon.android.api.requests; + +import android.net.Uri; +import android.text.TextUtils; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.HeaderPaginationList; + +import java.io.IOException; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import okhttp3.Response; + +public abstract class HeaderPaginationRequest extends MastodonAPIRequest>{ + private static final Pattern LINK_HEADER_PATTERN=Pattern.compile("(?:(?:,\\s*)?<([^>]+)>|;\\s*(\\w+)=['\"](\\w+)['\"])"); + + public HeaderPaginationRequest(HttpMethod method, String path, Class> respClass){ + super(method, path, respClass); + } + + public HeaderPaginationRequest(HttpMethod method, String path, TypeToken> respTypeToken){ + super(method, path, respTypeToken); + } + + @Override + public void validateAndPostprocessResponse(HeaderPaginationList respObj, Response httpResponse) throws IOException{ + super.validateAndPostprocessResponse(respObj, httpResponse); + String link=httpResponse.header("Link"); + if(!TextUtils.isEmpty(link)){ + Matcher matcher=LINK_HEADER_PATTERN.matcher(link); + String url=null; + while(matcher.find()){ + if(url==null){ + String _url=matcher.group(1); + if(_url==null) + continue; + url=_url; + }else{ + String paramName=matcher.group(2); + String paramValue=matcher.group(3); + if(paramName==null || paramValue==null) + return; + if("rel".equals(paramName)){ + switch(paramValue){ + case "next" -> respObj.nextPageUri=Uri.parse(url); + case "prev" -> respObj.prevPageUri=Uri.parse(url); + } + url=null; + } + } + } + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFollowers.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFollowers.java new file mode 100644 index 00000000..72153341 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFollowers.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.Account; + +public class GetAccountFollowers extends HeaderPaginationRequest{ + public GetAccountFollowers(String id, String maxID, int limit){ + super(HttpMethod.GET, "/accounts/"+id+"/followers", new TypeToken<>(){}); + if(maxID!=null) + addQueryParameter("max_id", maxID); + if(limit>0) + addQueryParameter("limit", limit+""); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFollowing.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFollowing.java new file mode 100644 index 00000000..3aea0b3e --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/accounts/GetAccountFollowing.java @@ -0,0 +1,16 @@ +package org.joinmastodon.android.api.requests.accounts; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.requests.HeaderPaginationRequest; +import org.joinmastodon.android.model.Account; + +public class GetAccountFollowing extends HeaderPaginationRequest{ + public GetAccountFollowing(String id, String maxID, int limit){ + super(HttpMethod.GET, "/accounts/"+id+"/following", new TypeToken<>(){}); + if(maxID!=null) + addQueryParameter("max_id", maxID); + if(limit>0) + addQueryParameter("limit", limit+""); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseAccountListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseAccountListFragment.java new file mode 100644 index 00000000..b9d5e58d --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseAccountListFragment.java @@ -0,0 +1,381 @@ +package org.joinmastodon.android.fragments; + +import android.app.ProgressDialog; +import android.content.Intent; +import android.content.res.Configuration; +import android.graphics.drawable.Animatable; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.Bundle; +import android.view.ContextMenu; +import android.view.Menu; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.view.WindowInsets; +import android.widget.Button; +import android.widget.ImageView; +import android.widget.PopupMenu; +import android.widget.TextView; +import android.widget.Toolbar; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships; +import org.joinmastodon.android.api.requests.accounts.SetAccountFollowed; +import org.joinmastodon.android.fragments.report.ReportReasonChoiceFragment; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.Relationship; +import org.joinmastodon.android.ui.DividerItemDecoration; +import org.joinmastodon.android.ui.OutlineProviders; +import org.joinmastodon.android.ui.text.HtmlParser; +import org.joinmastodon.android.ui.utils.CustomEmojiHelper; +import org.joinmastodon.android.ui.utils.UiUtils; +import org.parceler.Parcels; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import androidx.annotation.CallSuper; +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; +import me.grishka.appkit.Nav; +import me.grishka.appkit.api.APIRequest; +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.fragments.BaseRecyclerFragment; +import me.grishka.appkit.imageloader.ImageLoaderRecyclerAdapter; +import me.grishka.appkit.imageloader.ImageLoaderViewHolder; +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 abstract class BaseAccountListFragment extends BaseRecyclerFragment{ + protected HashMap relationships=new HashMap<>(); + protected String accountID; + protected ArrayList> relationshipsRequests=new ArrayList<>(); + + public BaseAccountListFragment(){ + super(40); + } + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + accountID=getArguments().getString("account"); + } + + @Override + protected void onDataLoaded(List d, boolean more){ + if(refreshing){ + relationships.clear(); + } + loadRelationships(d); + super.onDataLoaded(d, more); + } + + @Override + public void onRefresh(){ + for(APIRequest req:relationshipsRequests){ + req.cancel(); + } + relationshipsRequests.clear(); + super.onRefresh(); + } + + protected void loadRelationships(List accounts){ + Set ids=accounts.stream().map(ai->ai.account.id).collect(Collectors.toSet()); + GetAccountRelationships req=new GetAccountRelationships(ids); + relationshipsRequests.add(req); + req.setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + relationshipsRequests.remove(req); + for(Relationship rel:result){ + relationships.put(rel.id, rel); + } + if(list==null) + return; + for(int i=0;i=29 && insets.getTappableElementInsets().bottom==0){ + list.setPadding(0, V.dp(16), 0, V.dp(16)+insets.getSystemWindowInsetBottom()); + insets=insets.inset(0, 0, 0, insets.getSystemWindowInsetBottom()); + }else{ + list.setPadding(0, V.dp(16), 0, V.dp(16)); + } + super.onApplyWindowInsets(insets); + } + + protected class AccountsAdapter extends UsableRecyclerView.Adapter implements ImageLoaderRecyclerAdapter{ + public AccountsAdapter(){ + super(imgLoader); + } + + @NonNull + @Override + public AccountViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){ + return new AccountViewHolder(); + } + + @Override + public void onBindViewHolder(AccountViewHolder holder, int position){ + holder.bind(data.get(position)); + super.onBindViewHolder(holder, position); + } + + @Override + public int getItemCount(){ + return data.size(); + } + + @Override + public int getImageCountForItem(int position){ + return data.get(position).emojiHelper.getImageCount()+1; + } + + @Override + public ImageLoaderRequest getImageRequest(int position, int image){ + AccountItem item=data.get(position); + return image==0 ? item.avaRequest : item.emojiHelper.getImageRequest(image-1); + } + } + + protected class AccountViewHolder extends BindableViewHolder implements ImageLoaderViewHolder, UsableRecyclerView.Clickable, UsableRecyclerView.LongClickable{ + private final TextView name, username; + private final ImageView avatar; + private final Button button; + private final PopupMenu contextMenu; + private final View menuAnchor; + + public AccountViewHolder(){ + super(getActivity(), R.layout.item_account_list, list); + name=findViewById(R.id.name); + username=findViewById(R.id.username); + avatar=findViewById(R.id.avatar); + button=findViewById(R.id.button); + menuAnchor=findViewById(R.id.menu_anchor); + + avatar.setOutlineProvider(OutlineProviders.roundedRect(12)); + avatar.setClipToOutline(true); + + button.setOnClickListener(this::onButtonClick); + + contextMenu=new PopupMenu(getActivity(), menuAnchor); + contextMenu.inflate(R.menu.profile); + contextMenu.setOnMenuItemClickListener(this::onContextMenuItemSelected); + } + + @Override + public void onBind(AccountItem item){ + name.setText(item.parsedName); + username.setText("@"+item.account.acct); + bindRelationship(); + } + + public void bindRelationship(){ + Relationship rel=relationships.get(item.account.id); + if(rel==null){ + button.setVisibility(View.GONE); + }else{ + button.setVisibility(View.VISIBLE); + UiUtils.setRelationshipToActionButton(rel, button); + } + } + + @Override + public void setImage(int index, Drawable image){ + if(index==0){ + avatar.setImageDrawable(image); + }else{ + item.emojiHelper.setImageDrawable(index-1, image); + name.invalidate(); + } + + if(image instanceof Animatable a && !a.isRunning()) + a.start(); + } + + @Override + public void clearImage(int index){ + setImage(index, null); + } + + @Override + public void onClick(){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("profileAccount", Parcels.wrap(item.account)); + Nav.go(getActivity(), ProfileFragment.class, args); + } + + @Override + public boolean onLongClick(){ + return false; + } + + @Override + public boolean onLongClick(float x, float y){ + Relationship relationship=relationships.get(item.account.id); + if(relationship==null) + return false; + Menu menu=contextMenu.getMenu(); + Account account=item.account; + + menu.findItem(R.id.share).setTitle(getString(R.string.share_user, account.getDisplayUsername())); + menu.findItem(R.id.mute).setTitle(getString(relationship.muting ? R.string.unmute_user : R.string.mute_user, account.getDisplayUsername())); + menu.findItem(R.id.block).setTitle(getString(relationship.blocking ? R.string.unblock_user : R.string.block_user, account.getDisplayUsername())); + menu.findItem(R.id.report).setTitle(getString(R.string.report_user, account.getDisplayUsername())); + if(relationship.following) + menu.findItem(R.id.hide_boosts).setTitle(getString(relationship.showingReblogs ? R.string.hide_boosts_from_user : R.string.show_boosts_from_user, account.getDisplayUsername())); + else + menu.findItem(R.id.hide_boosts).setVisible(false); + if(!account.isLocal()) + menu.findItem(R.id.block_domain).setTitle(getString(relationship.domainBlocking ? R.string.unblock_domain : R.string.block_domain, account.getDomain())); + else + menu.findItem(R.id.block_domain).setVisible(false); + + menuAnchor.setTranslationX(x); + menuAnchor.setTranslationY(y); + contextMenu.show(); + + return true; + } + + private void onButtonClick(View v){ + ProgressDialog progress=new ProgressDialog(getActivity()); + progress.setMessage(getString(R.string.loading)); + progress.setCancelable(false); + UiUtils.performAccountAction(getActivity(), item.account, accountID, relationships.get(item.account.id), button, progressShown->{ + itemView.setHasTransientState(progressShown); + if(progressShown) + progress.show(); + else + progress.dismiss(); + }, result->{ + relationships.put(item.account.id, result); + bindRelationship(); + }); + } + + private boolean onContextMenuItemSelected(MenuItem item){ + Relationship relationship=relationships.get(this.item.account.id); + if(relationship==null) + return false; + Account account=this.item.account; + + int id=item.getItemId(); + if(id==R.id.share){ + Intent intent=new Intent(Intent.ACTION_SEND); + intent.setType("text/plain"); + intent.putExtra(Intent.EXTRA_TEXT, account.url); + startActivity(Intent.createChooser(intent, item.getTitle())); + }else if(id==R.id.mute){ + UiUtils.confirmToggleMuteUser(getActivity(), accountID, account, relationship.muting, this::updateRelationship); + }else if(id==R.id.block){ + UiUtils.confirmToggleBlockUser(getActivity(), accountID, account, relationship.blocking, this::updateRelationship); + }else if(id==R.id.report){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("reportAccount", Parcels.wrap(account)); + Nav.go(getActivity(), ReportReasonChoiceFragment.class, args); + }else if(id==R.id.open_in_browser){ + UiUtils.launchWebBrowser(getActivity(), account.url); + }else if(id==R.id.block_domain){ + UiUtils.confirmToggleBlockDomain(getActivity(), accountID, account.getDomain(), relationship.domainBlocking, ()->{ + relationship.domainBlocking=!relationship.domainBlocking; + bindRelationship(); + }); + }else if(id==R.id.hide_boosts){ + new SetAccountFollowed(account.id, true, !relationship.showingReblogs) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(Relationship result){ + relationships.put(AccountViewHolder.this.item.account.id, result); + bindRelationship(); + } + + @Override + public void onError(ErrorResponse error){ + error.showToast(getActivity()); + } + }) + .wrapProgress(getActivity(), R.string.loading, false) + .exec(accountID); + } + return true; + } + + private void updateRelationship(Relationship r){ + relationships.put(item.account.id, r); + bindRelationship(); + } + } + + protected static class AccountItem{ + public final Account account; + public final ImageLoaderRequest avaRequest; + public final CustomEmojiHelper emojiHelper; + public final CharSequence parsedName; + + public AccountItem(Account account){ + this.account=account; + avaRequest=new UrlImageLoaderRequest(GlobalUserPreferences.playGifs ? account.avatar : account.avatarStatic, V.dp(50), V.dp(50)); + emojiHelper=new CustomEmojiHelper(); + emojiHelper.setText(parsedName=HtmlParser.parseCustomEmoji(account.displayName, account.emojis)); + } + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowerListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowerListFragment.java new file mode 100644 index 00000000..3ba63614 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowerListFragment.java @@ -0,0 +1,49 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.GetAccountFollowers; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.parceler.Parcels; + +import java.util.stream.Collectors; + +import me.grishka.appkit.api.SimpleCallback; + +public class FollowerListFragment extends BaseAccountListFragment{ + private Account account; + private String nextMaxID; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + account=Parcels.unwrap(getArguments().getParcelable("targetAccount")); + setTitle("@"+account.acct); + setSubtitle(getResources().getQuantityString(R.plurals.x_followers, account.followersCount, account.followersCount)); + } + + @Override + public void onResume(){ + super.onResume(); + if(!loaded && !dataLoading) + loadData(); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetAccountFollowers(account.id, offset==0 ? null : nextMaxID, count) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + if(result.nextPageUri!=null) + nextMaxID=result.nextPageUri.getQueryParameter("max_id"); + else + nextMaxID=null; + onDataLoaded(result.stream().map(AccountItem::new).collect(Collectors.toList()), nextMaxID!=null); + } + }) + .exec(accountID); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowingListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowingListFragment.java new file mode 100644 index 00000000..d11ca1b6 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/FollowingListFragment.java @@ -0,0 +1,49 @@ +package org.joinmastodon.android.fragments; + +import android.os.Bundle; + +import org.joinmastodon.android.R; +import org.joinmastodon.android.api.requests.accounts.GetAccountFollowing; +import org.joinmastodon.android.model.Account; +import org.joinmastodon.android.model.HeaderPaginationList; +import org.parceler.Parcels; + +import java.util.stream.Collectors; + +import me.grishka.appkit.api.SimpleCallback; + +public class FollowingListFragment extends BaseAccountListFragment{ + private Account account; + private String nextMaxID; + + @Override + public void onCreate(Bundle savedInstanceState){ + super.onCreate(savedInstanceState); + account=Parcels.unwrap(getArguments().getParcelable("targetAccount")); + setTitle("@"+account.acct); + setSubtitle(getResources().getQuantityString(R.plurals.x_following, account.followingCount, account.followingCount)); + } + + @Override + public void onResume(){ + super.onResume(); + if(!loaded && !dataLoading) + loadData(); + } + + @Override + protected void doLoadData(int offset, int count){ + currentRequest=new GetAccountFollowing(account.id, offset==0 ? null : nextMaxID, count) + .setCallback(new SimpleCallback<>(this){ + @Override + public void onSuccess(HeaderPaginationList result){ + if(result.nextPageUri!=null) + nextMaxID=result.nextPageUri.getQueryParameter("max_id"); + else + nextMaxID=null; + onDataLoaded(result.stream().map(AccountItem::new).collect(Collectors.toList()), nextMaxID!=null); + } + }) + .exec(accountID); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java index da6d464c..4d050596 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ProfileFragment.java @@ -261,6 +261,9 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList fab.setVisibility(View.GONE); } + followersBtn.setOnClickListener(this::onFollowersOrFollowingClick); + followingBtn.setOnClickListener(this::onFollowersOrFollowingClick); + return sizeWrapper; } @@ -848,6 +851,20 @@ public class ProfileFragment extends LoaderFragment implements OnBackPressedList scrollView.smoothScrollTo(0, 0); } + private void onFollowersOrFollowingClick(View v){ + Bundle args=new Bundle(); + args.putString("account", accountID); + args.putParcelable("targetAccount", Parcels.wrap(account)); + Class cls; + if(v.getId()==R.id.followers_btn) + cls=FollowerListFragment.class; + else if(v.getId()==R.id.following_btn) + cls=FollowingListFragment.class; + else + return; + Nav.go(getActivity(), cls, args); + } + private class ProfilePagerAdapter extends RecyclerView.Adapter{ @NonNull @Override diff --git a/mastodon/src/main/java/org/joinmastodon/android/model/HeaderPaginationList.java b/mastodon/src/main/java/org/joinmastodon/android/model/HeaderPaginationList.java new file mode 100644 index 00000000..10390e31 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/model/HeaderPaginationList.java @@ -0,0 +1,24 @@ +package org.joinmastodon.android.model; + +import android.net.Uri; + +import java.util.ArrayList; +import java.util.Collection; + +import androidx.annotation.NonNull; + +public class HeaderPaginationList extends ArrayList{ + public Uri nextPageUri, prevPageUri; + + public HeaderPaginationList(int initialCapacity){ + super(initialCapacity); + } + + public HeaderPaginationList(){ + super(); + } + + public HeaderPaginationList(@NonNull Collection c){ + super(c); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java b/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java index 1ed4125c..87fb0457 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/AccountSwitcherSheet.java @@ -136,8 +136,10 @@ public class AccountSwitcherSheet extends BottomSheet{ if(tappableBottom==0 && insetBottom>0){ list.setPadding(0, 0, 0, V.dp(48)-insetBottom); }else{ - list.setPadding(0, 0, 0, 0); + list.setPadding(0, 0, 0, V.dp(24)); } + }else{ + list.setPadding(0, 0, 0, V.dp(24)); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java index cc4944aa..d9c0c833 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/photoviewer/PhotoViewer.java @@ -133,18 +133,18 @@ public class PhotoViewer implements ZoomPanView.Listener{ public WindowInsets dispatchApplyWindowInsets(WindowInsets insets){ if(Build.VERSION.SDK_INT>=29){ DisplayCutout cutout=insets.getDisplayCutout(); + Insets tappable=insets.getTappableElementInsets(); if(cutout!=null){ // Make controls extend beneath the cutout, and replace insets to avoid cutout insets being filled with "navigation bar color" - Insets tappable=insets.getTappableElementInsets(); int leftInset=Math.max(0, cutout.getSafeInsetLeft()-tappable.left); int rightInset=Math.max(0, cutout.getSafeInsetRight()-tappable.right); - insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, tappable.bottom); toolbarWrap.setPadding(leftInset, 0, rightInset, 0); videoControls.setPadding(leftInset, 0, rightInset, 0); }else{ toolbarWrap.setPadding(0, 0, 0, 0); videoControls.setPadding(0, 0, 0, 0); } + insets=insets.replaceSystemWindowInsets(tappable.left, tappable.top, tappable.right, tappable.bottom); } uiOverlay.dispatchApplyWindowInsets(insets); int bottomInset=insets.getSystemWindowInsetBottom(); diff --git a/mastodon/src/main/res/layout/fragment_profile.xml b/mastodon/src/main/res/layout/fragment_profile.xml index 620e6f99..6cc49dfc 100644 --- a/mastodon/src/main/res/layout/fragment_profile.xml +++ b/mastodon/src/main/res/layout/fragment_profile.xml @@ -75,80 +75,95 @@ tools:src="#0f0" /> - - - + android:layout_toEndOf="@id/avatar_border" + android:gravity="end"> - - - - + android:layout_height="56dp" + android:layout_marginTop="12dp" + android:layout_marginEnd="12dp" + android:gravity="center_horizontal" + android:orientation="vertical" + android:padding="4dp"> + + + - - - + android:layout_height="56dp" + android:layout_marginTop="12dp" + android:layout_marginEnd="16dp" + android:padding="4dp" + android:orientation="vertical" + android:background="?android:selectableItemBackgroundBorderless" + android:gravity="center_horizontal"> + + + - + android:layout_height="56dp" + android:layout_marginTop="12dp" + android:layout_marginEnd="12dp" + android:padding="4dp" + android:orientation="vertical" + android:background="?android:selectableItemBackgroundBorderless" + android:gravity="center_horizontal"> + + + + + + + +