Notifications

This commit is contained in:
Grishka
2022-03-05 12:59:27 +03:00
parent b437f6f3a3
commit 37bef85f6a
18 changed files with 738 additions and 119 deletions

View File

@@ -16,10 +16,12 @@ import android.view.ViewTreeObserver;
import android.widget.Toolbar;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.accounts.GetAccountRelationships;
import org.joinmastodon.android.api.requests.polls.SubmitPollVote;
import org.joinmastodon.android.model.Account;
import org.joinmastodon.android.model.DisplayItemsParent;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Relationship;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.BetterItemAnimator;
import org.joinmastodon.android.ui.PhotoLayoutHelper;
@@ -41,6 +43,7 @@ import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
@@ -63,6 +66,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
protected String accountID;
protected PhotoViewer currentPhotoViewer;
protected HashMap<String, Account> knownAccounts=new HashMap<>();
protected HashMap<String, Relationship> relationships=new HashMap<>();
public BaseStatusListFragment(){
super(20);
@@ -255,6 +259,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
});
list.addItemDecoration(new RecyclerView.ItemDecoration(){
private Rect tmpRect=new Rect();
private Paint paint=new Paint();
{
paint.setColor(UiUtils.getThemeColor(getActivity(), R.attr.colorPollVoted));
@@ -264,13 +269,18 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
for(int i=0;i<parent.getChildCount();i++){
for(int i=0;i<parent.getChildCount()-1;i++){
View child=parent.getChildAt(i);
View bottomSibling=parent.getChildAt(i+1);
RecyclerView.ViewHolder holder=parent.getChildViewHolder(child);
if(holder instanceof FooterStatusDisplayItem.Holder){
float y=child.getY()+child.getHeight()-V.dp(.5f);
RecyclerView.ViewHolder siblingHolder=parent.getChildViewHolder(bottomSibling);
if(holder instanceof StatusDisplayItem.Holder && siblingHolder instanceof StatusDisplayItem.Holder
&& !((StatusDisplayItem.Holder<?>) holder).getItemID().equals(((StatusDisplayItem.Holder<?>) siblingHolder).getItemID())){
parent.getDecoratedBoundsWithMargins(child, tmpRect);
tmpRect.offset(0, Math.round(child.getTranslationY()));
float y=tmpRect.bottom-V.dp(.5f);
paint.setAlpha(Math.round(255*child.getAlpha()));
c.drawLine(child.getX(), y, child.getX()+child.getWidth(), y, paint);
c.drawLine(0, y, parent.getWidth(), y, paint);
}
}
}
@@ -316,9 +326,10 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
});
((UsableRecyclerView)list).setSelectorBoundsProvider(new UsableRecyclerView.SelectorBoundsProvider(){
private Rect tmpRect=new Rect();
@Override
public void getSelectorBounds(View view, Rect outRect){
outRect.set(view.getLeft(), view.getTop(), view.getRight(), view.getBottom());
list.getDecoratedBoundsWithMargins(view, outRect);
RecyclerView.ViewHolder holder=list.getChildViewHolder(view);
if(holder instanceof StatusDisplayItem.Holder){
String id=((StatusDisplayItem.Holder<?>) holder).getItemID();
@@ -328,10 +339,11 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
if(holder instanceof StatusDisplayItem.Holder){
String otherID=((StatusDisplayItem.Holder<?>) holder).getItemID();
if(otherID.equals(id)){
outRect.left=Math.min(outRect.left, child.getLeft());
outRect.top=Math.min(outRect.top, child.getTop());
outRect.right=Math.max(outRect.right, child.getRight());
outRect.bottom=Math.max(outRect.bottom, child.getBottom());
list.getDecoratedBoundsWithMargins(child, tmpRect);
outRect.left=Math.min(outRect.left, tmpRect.left);
outRect.top=Math.min(outRect.top, tmpRect.top);
outRect.right=Math.max(outRect.right, tmpRect.right);
outRect.bottom=Math.max(outRect.bottom, tmpRect.bottom);
}
}
}
@@ -509,6 +521,37 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
return accountID;
}
public Relationship getRelationship(String id){
return relationships.get(id);
}
public void putRelationship(String id, Relationship rel){
relationships.put(id, rel);
}
protected void loadRelationships(Set<String> ids){
if(ids.isEmpty())
return;
// TODO somehow manage these and cancel outstanding requests on refresh
new GetAccountRelationships(ids)
.setCallback(new Callback<>(){
@Override
public void onSuccess(List<Relationship> result){
for(Relationship r:result)
relationships.put(r.id, r);
onRelationshipsLoaded();
}
@Override
public void onError(ErrorResponse error){
}
})
.exec(accountID);
}
protected void onRelationshipsLoaded(){}
@Nullable
protected <I extends StatusDisplayItem> I findItemOfType(String id, Class<I> type){
for(StatusDisplayItem item:displayItems){

View File

@@ -170,6 +170,9 @@ public class HomeFragment extends AppKitFragment implements OnBackPressedListene
lf.loadData();
}else if(newFragment instanceof DiscoverFragment){
((DiscoverFragment) newFragment).loadData();
}else if(newFragment instanceof NotificationsFragment){
((NotificationsFragment) newFragment).loadData();
// TODO make an interface?
}
currentTab=tab;
((FragmentStackActivity)getActivity()).invalidateSystemBarColors(this);

View File

@@ -1,25 +1,49 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.app.Fragment;
import android.content.res.Configuration;
import android.os.Build;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.FrameLayout;
import android.widget.LinearLayout;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.notifications.GetNotifications;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.parceler.Parcels;
import org.joinmastodon.android.ui.SimpleViewHolder;
import org.joinmastodon.android.ui.tabs.TabLayout;
import org.joinmastodon.android.ui.tabs.TabLayoutMediator;
import org.joinmastodon.android.ui.utils.UiUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import androidx.viewpager2.widget.ViewPager2;
import me.grishka.appkit.fragments.BaseRecyclerFragment;
import me.grishka.appkit.fragments.ToolbarFragment;
import me.grishka.appkit.utils.V;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
public class NotificationsFragment extends ToolbarFragment implements ScrollableToTop{
private TabLayout tabLayout;
private ViewPager2 pager;
private FrameLayout[] tabViews;
private TabLayoutMediator tabLayoutMediator;
private NotificationsListFragment allNotificationsFragment, mentionsFragment;
private String accountID;
@Override
public void onCreate(Bundle savedInstanceState){
super.onCreate(savedInstanceState);
if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.N)
setRetainInstance(true);
accountID=getArguments().getString("account");
}
public class NotificationsFragment extends BaseStatusListFragment<Notification>{
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
@@ -27,80 +51,135 @@ public class NotificationsFragment extends BaseStatusListFragment<Notification>{
}
@Override
protected List<StatusDisplayItem> buildDisplayItems(Notification n){
ReblogOrReplyLineStatusDisplayItem titleItem=new ReblogOrReplyLineStatusDisplayItem(n.id, this, switch(n.type){
case FOLLOW -> getString(R.string.user_followed_you, n.account.displayName);
case FOLLOW_REQUEST -> getString(R.string.user_sent_follow_request, n.account.displayName);
case MENTION -> getString(R.string.user_mentioned_you, n.account.displayName);
case REBLOG -> getString(R.string.user_boosted, n.account.displayName);
case FAVORITE -> getString(R.string.user_favorited, n.account.displayName);
case POLL -> getString(R.string.poll_ended);
case STATUS -> getString(R.string.user_posted, n.account.displayName);
}, n.account.emojis, R.drawable.ic_fluent_arrow_reply_20_filled);
if(n.status!=null){
ArrayList<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts);
items.add(0, titleItem);
return items;
}else{
return Collections.singletonList(titleItem);
public View onCreateContentView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState){
LinearLayout view=(LinearLayout) inflater.inflate(R.layout.fragment_notifications, container, false);
tabLayout=view.findViewById(R.id.tabbar);
pager=view.findViewById(R.id.pager);
tabViews=new FrameLayout[2];
for(int i=0;i<tabViews.length;i++){
FrameLayout tabView=new FrameLayout(getActivity());
tabView.setId(switch(i){
case 0 -> R.id.notifications_all;
case 1 -> R.id.notifications_mentions;
default -> throw new IllegalStateException("Unexpected value: "+i);
});
tabView.setVisibility(View.GONE);
view.addView(tabView); // needed so the fragment manager will have somewhere to restore the tab fragment
tabViews[i]=tabView;
}
}
@Override
protected void addAccountToKnown(Notification s){
if(!knownAccounts.containsKey(s.account.id))
knownAccounts.put(s.account.id, s.account);
if(s.status!=null && !knownAccounts.containsKey(s.status.account.id))
knownAccounts.put(s.status.account.id, s.status.account);
}
tabLayout.setTabTextSize(V.dp(16));
tabLayout.setTabTextColors(UiUtils.getThemeColor(getActivity(), R.attr.colorTabInactive), UiUtils.getThemeColor(getActivity(), android.R.attr.textColorPrimary));
@Override
protected void doLoadData(int offset, int count){
new GetNotifications(offset>0 ? getMaxID() : null, count)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Notification> result){
onDataLoaded(result, !result.isEmpty());
}
})
.exec(accountID);
}
pager.setOffscreenPageLimit(4);
pager.setAdapter(new DiscoverPagerAdapter());
pager.registerOnPageChangeCallback(new ViewPager2.OnPageChangeCallback(){
@Override
public void onPageSelected(int position){
if(position==0)
return;
Fragment _page=getFragmentForPage(position);
if(_page instanceof BaseRecyclerFragment){
BaseRecyclerFragment page=(BaseRecyclerFragment) _page;
if(!page.loaded && !page.isDataLoading())
page.loadData();
}
}
});
@Override
protected void onShown(){
super.onShown();
if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
loadData();
}
@Override
public void onItemClick(String id){
Notification n=getNotificationByID(id);
if(n.status!=null){
Status status=n.status;
if(allNotificationsFragment==null){
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("status", Parcels.wrap(status));
if(status.inReplyToAccountId!=null && knownAccounts.containsKey(status.inReplyToAccountId))
args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status.inReplyToAccountId)));
Nav.go(getActivity(), ThreadFragment.class, args);
args.putBoolean("__is_tab", true);
allNotificationsFragment=new NotificationsListFragment();
allNotificationsFragment.setArguments(args);
args=new Bundle(args);
args.putBoolean("onlyMentions", true);
mentionsFragment=new NotificationsListFragment();
mentionsFragment.setArguments(args);
getChildFragmentManager().beginTransaction()
.add(R.id.notifications_all, allNotificationsFragment)
.add(R.id.notifications_mentions, mentionsFragment)
.commit();
}
tabLayoutMediator=new TabLayoutMediator(tabLayout, pager, new TabLayoutMediator.TabConfigurationStrategy(){
@Override
public void onConfigureTab(@NonNull TabLayout.Tab tab, int position){
tab.setText(switch(position){
case 0 -> R.string.all_notifications;
case 1 -> R.string.mentions;
default -> throw new IllegalStateException("Unexpected value: "+position);
});
tab.view.textView.setAllCaps(true);
}
});
tabLayoutMediator.attach();
return view;
}
@Override
protected void updatePoll(String itemID, Poll poll){
Notification notification=getNotificationByID(itemID);
if(notification==null || notification.status==null)
return;
notification.status.poll=poll;
super.updatePoll(itemID, poll);
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
updateToolbar();
}
private Notification getNotificationByID(String id){
for(Notification n:data){
if(n.id.equals(id))
return n;
@Override
public void onConfigurationChanged(Configuration newConfig){
super.onConfigurationChanged(newConfig);
updateToolbar();
}
@Override
public void scrollToTop(){
getFragmentForPage(pager.getCurrentItem()).scrollToTop();
}
public void loadData(){
if(allNotificationsFragment!=null && !allNotificationsFragment.loaded && !allNotificationsFragment.dataLoading)
allNotificationsFragment.loadData();
}
private void updateToolbar(){
getToolbar().setOutlineProvider(null);
}
private NotificationsListFragment getFragmentForPage(int page){
return switch(page){
case 0 -> allNotificationsFragment;
case 1 -> mentionsFragment;
default -> throw new IllegalStateException("Unexpected value: "+page);
};
}
private class DiscoverPagerAdapter extends RecyclerView.Adapter<SimpleViewHolder>{
@NonNull
@Override
public SimpleViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType){
FrameLayout view=tabViews[viewType];
((ViewGroup)view.getParent()).removeView(view);
view.setVisibility(View.VISIBLE);
view.setLayoutParams(new RecyclerView.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.MATCH_PARENT));
return new SimpleViewHolder(view);
}
@Override
public void onBindViewHolder(@NonNull SimpleViewHolder holder, int position){}
@Override
public int getItemCount(){
return 2;
}
@Override
public int getItemViewType(int position){
return position;
}
return null;
}
}

View File

@@ -0,0 +1,222 @@
package org.joinmastodon.android.fragments;
import android.app.Activity;
import android.graphics.Canvas;
import android.graphics.Paint;
import android.graphics.Rect;
import android.graphics.RectF;
import android.os.Bundle;
import android.view.View;
import org.joinmastodon.android.R;
import org.joinmastodon.android.api.requests.notifications.GetNotifications;
import org.joinmastodon.android.model.Notification;
import org.joinmastodon.android.model.Poll;
import org.joinmastodon.android.model.Status;
import org.joinmastodon.android.ui.displayitems.AccountCardStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.ImageStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.LinkCardStatusDisplayItem;
import org.joinmastodon.android.ui.displayitems.StatusDisplayItem;
import org.joinmastodon.android.ui.utils.UiUtils;
import org.parceler.Parcels;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import androidx.annotation.NonNull;
import androidx.recyclerview.widget.RecyclerView;
import me.grishka.appkit.Nav;
import me.grishka.appkit.api.SimpleCallback;
import me.grishka.appkit.utils.V;
public class NotificationsListFragment extends BaseStatusListFragment<Notification>{
private EnumSet<Notification.Type> types;
@Override
public void onAttach(Activity activity){
super.onAttach(activity);
setTitle(R.string.notifications);
if(getArguments().getBoolean("onlyMentions", false)){
types=EnumSet.complementOf(EnumSet.of(Notification.Type.MENTION));
}
}
@Override
protected List<StatusDisplayItem> buildDisplayItems(Notification n){
String extraText=switch(n.type){
case FOLLOW -> getString(R.string.user_followed_you);
case FOLLOW_REQUEST -> getString(R.string.user_sent_follow_request);
case MENTION, STATUS -> null;
case REBLOG -> getString(R.string.user_boosted);
case FAVORITE -> getString(R.string.user_favorited);
case POLL -> getString(R.string.poll_ended);
};
HeaderStatusDisplayItem titleItem=extraText!=null ? new HeaderStatusDisplayItem(n.id, n.account, n.createdAt, this, accountID, null, extraText) : null;
if(n.status!=null){
ArrayList<StatusDisplayItem> items=StatusDisplayItem.buildItems(this, n.status, accountID, n, knownAccounts, titleItem!=null, titleItem==null);
if(titleItem!=null)
items.add(0, titleItem);
return items;
}else{
AccountCardStatusDisplayItem card=new AccountCardStatusDisplayItem(n.id, this, n.account);
return Arrays.asList(titleItem, card);
}
}
@Override
protected void addAccountToKnown(Notification s){
if(!knownAccounts.containsKey(s.account.id))
knownAccounts.put(s.account.id, s.account);
if(s.status!=null && !knownAccounts.containsKey(s.status.account.id))
knownAccounts.put(s.status.account.id, s.status.account);
}
@Override
protected void doLoadData(int offset, int count){
new GetNotifications(offset>0 ? getMaxID() : null, count, types)
.setCallback(new SimpleCallback<>(this){
@Override
public void onSuccess(List<Notification> result){
if(refreshing)
relationships.clear();
onDataLoaded(result, !result.isEmpty());
Set<String> needRelationships=result.stream()
.filter(ntf->ntf.status==null && !relationships.containsKey(ntf.account.id))
.map(ntf->ntf.account.id)
.collect(Collectors.toSet());
loadRelationships(needRelationships);
}
})
.exec(accountID);
}
@Override
protected void onRelationshipsLoaded(){
if(getActivity()==null)
return;
for(int i=0;i<list.getChildCount();i++){
RecyclerView.ViewHolder holder=list.getChildViewHolder(list.getChildAt(i));
if(holder instanceof AccountCardStatusDisplayItem.Holder)
((AccountCardStatusDisplayItem.Holder) holder).rebind();
}
}
@Override
protected void onShown(){
super.onShown();
// if(!getArguments().getBoolean("noAutoLoad") && !loaded && !dataLoading)
// loadData();
}
@Override
public void onItemClick(String id){
Notification n=getNotificationByID(id);
if(n.status!=null){
Status status=n.status;
Bundle args=new Bundle();
args.putString("account", accountID);
args.putParcelable("status", Parcels.wrap(status));
if(status.inReplyToAccountId!=null && knownAccounts.containsKey(status.inReplyToAccountId))
args.putParcelable("inReplyToAccount", Parcels.wrap(knownAccounts.get(status.inReplyToAccountId)));
Nav.go(getActivity(), ThreadFragment.class, args);
}
}
@Override
protected void updatePoll(String itemID, Poll poll){
Notification notification=getNotificationByID(itemID);
if(notification==null || notification.status==null)
return;
notification.status.poll=poll;
super.updatePoll(itemID, poll);
}
@Override
public void onViewCreated(View view, Bundle savedInstanceState){
super.onViewCreated(view, savedInstanceState);
list.addItemDecoration(new RecyclerView.ItemDecoration(){
private Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG);
private int bgColor=UiUtils.getThemeColor(getActivity(), android.R.attr.colorBackground);
private int borderColor=UiUtils.getThemeColor(getActivity(), R.attr.colorPollVoted);
private RectF rect=new RectF();
@Override
public void onDraw(@NonNull Canvas c, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
int pos=0;
for(int i=0;i<parent.getChildCount();i++){
View child=parent.getChildAt(i);
RecyclerView.ViewHolder holder=parent.getChildViewHolder(child);
pos=holder.getAbsoluteAdapterPosition();
boolean inset=(holder instanceof StatusDisplayItem.Holder) && ((StatusDisplayItem.Holder<?>) holder).getItem().inset;
if(inset){
if(rect.isEmpty()){
rect.set(child.getX(), i==0 && pos>0 && displayItems.get(pos-1).inset ? V.dp(-10) : child.getY(), child.getX()+child.getWidth(), child.getY()+child.getHeight());
}else{
rect.bottom=Math.max(rect.bottom, child.getY()+child.getHeight());
rect.right=Math.max(rect.right, child.getX()+child.getHeight());
}
}else if(!rect.isEmpty()){
drawInsetBackground(c);
rect.setEmpty();
}
}
if(!rect.isEmpty()){
if(pos<displayItems.size()-1 && displayItems.get(pos+1).inset){
rect.bottom=parent.getHeight()+V.dp(10);
}
drawInsetBackground(c);
rect.setEmpty();
}
}
private void drawInsetBackground(Canvas c){
paint.setStyle(Paint.Style.FILL);
paint.setColor(bgColor);
rect.inset(V.dp(4), V.dp(4));
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(V.dp(1));
paint.setColor(borderColor);
rect.inset(paint.getStrokeWidth()/2f, paint.getStrokeWidth()/2f);
c.drawRoundRect(rect, V.dp(4), V.dp(4), paint);
}
@Override
public void getItemOffsets(@NonNull Rect outRect, @NonNull View view, @NonNull RecyclerView parent, @NonNull RecyclerView.State state){
RecyclerView.ViewHolder holder=parent.getChildViewHolder(view);
if(holder instanceof StatusDisplayItem.Holder){
boolean inset=((StatusDisplayItem.Holder<?>) holder).getItem().inset;
int pos=holder.getAbsoluteAdapterPosition();
if(inset){
boolean topSiblingInset=pos>0 && displayItems.get(pos-1).inset;
boolean bottomSiblingInset=pos<displayItems.size()-1 && displayItems.get(pos+1).inset;
int pad;
if(holder instanceof ImageStatusDisplayItem.Holder || holder instanceof LinkCardStatusDisplayItem.Holder)
pad=V.dp(16);
else
pad=V.dp(12);
outRect.left=outRect.right=pad;
if(!topSiblingInset)
outRect.top=pad;
if(!bottomSiblingInset)
outRect.bottom=pad;
}
}
}
});
}
private Notification getNotificationByID(String id){
for(Notification n:data){
if(n.id.equals(id))
return n;
}
return null;
}
}

View File

@@ -21,7 +21,7 @@ import me.grishka.appkit.Nav;
public abstract class StatusListFragment extends BaseStatusListFragment<Status>{
protected List<StatusDisplayItem> buildDisplayItems(Status s){
return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts);
return StatusDisplayItem.buildItems(this, s, accountID, s, knownAccounts, false, true);
}
@Override