package org.joinmastodon.android.fragments; import android.animation.Animator; import android.animation.AnimatorListenerAdapter; import android.animation.AnimatorSet; import android.animation.ObjectAnimator; import android.app.Activity; import android.content.res.Configuration; import android.os.Build; import android.os.Bundle; import android.view.Gravity; import android.view.Menu; import android.view.MenuInflater; import android.view.MenuItem; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.FrameLayout; import android.widget.ImageButton; import android.widget.TextView; import android.widget.Toolbar; import com.squareup.otto.Subscribe; import org.joinmastodon.android.E; import org.joinmastodon.android.GlobalUserPreferences; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.SelfUpdateStateChangedEvent; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.model.CacheablePaginatedResponse; import org.joinmastodon.android.model.Filter; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.utils.UiUtils; import org.joinmastodon.android.updater.GithubSelfUpdater; import org.joinmastodon.android.utils.StatusFilterPredicate; import java.util.Collections; import java.util.HashSet; import java.util.List; import java.util.Locale; 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.Callback; import me.grishka.appkit.api.ErrorResponse; import me.grishka.appkit.api.SimpleCallback; import me.grishka.appkit.utils.CubicBezierInterpolator; import me.grishka.appkit.utils.V; public class HomeTimelineFragment extends StatusListFragment{ private ImageButton fab; private TextView toolbarLogo; private Button toolbarShowNewPostsBtn; private boolean newPostsBtnShown; private AnimatorSet currentNewPostsAnim; private String maxID; public HomeTimelineFragment(){ setListLayoutId(R.layout.recycler_fragment_with_fab); } @Override public void onAttach(Activity activity){ super.onAttach(activity); setHasOptionsMenu(true); loadData(); } private List filterPosts(List items) { return items.stream().filter(i -> (GlobalUserPreferences.showReplies || i.inReplyToId == null) && (GlobalUserPreferences.showBoosts || i.reblog == null) ).collect(Collectors.toList()); } @Override protected void doLoadData(int offset, int count){ AccountSessionManager.getInstance() .getAccount(accountID).getCacheController() .getHomeTimeline(offset>0 ? maxID : null, count, refreshing, new SimpleCallback<>(this){ @Override public void onSuccess(CacheablePaginatedResponse> result){ if(getActivity()==null) return; List filteredItems = filterPosts(result.items); onDataLoaded(filteredItems, !result.items.isEmpty()); maxID=result.maxID; if(result.isFromCache()) loadNewPosts(); } }); } @Override public void onViewCreated(View view, Bundle savedInstanceState){ super.onViewCreated(view, savedInstanceState); fab=view.findViewById(R.id.fab); fab.setOnClickListener(this::onFabClick); updateToolbarLogo(); list.addOnScrollListener(new RecyclerView.OnScrollListener(){ @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ if(newPostsBtnShown && list.getChildAdapterPosition(list.getChildAt(0))<=getMainAdapterOffset()){ hideNewPostsButton(); } } }); if(GithubSelfUpdater.needSelfUpdating()){ E.register(this); updateUpdateState(GithubSelfUpdater.getInstance().getState()); } } @Override public void onCreateOptionsMenu(Menu menu, MenuInflater inflater){ inflater.inflate(R.menu.home, menu); } @Override public boolean onOptionsItemSelected(MenuItem item){ Bundle args=new Bundle(); args.putString("account", accountID); Nav.go(getActivity(), SettingsFragment.class, args); return true; } @Override public void onConfigurationChanged(Configuration newConfig){ super.onConfigurationChanged(newConfig); updateToolbarLogo(); } @Override protected void onShown(){ super.onShown(); if(!getArguments().getBoolean("noAutoLoad")){ if(!loaded && !dataLoading){ loadData(); }else if(!dataLoading){ loadNewPosts(); } } } public void onStatusCreated(StatusCreatedEvent ev){ prependItems(Collections.singletonList(ev.status), true); } private void onFabClick(View v){ Bundle args=new Bundle(); args.putString("account", accountID); Nav.go(getActivity(), ComposeFragment.class, args); } private void loadNewPosts(){ if (!GlobalUserPreferences.loadNewPosts) return; dataLoading=true; // The idea here is that we request the timeline such that if there are fewer than `limit` posts, // we'll get the currently topmost post as last in the response. This way we know there's no gap // between the existing and newly loaded parts of the timeline. String sinceID=data.size()>1 ? data.get(1).id : "1"; currentRequest=new GetHomeTimeline(null, null, 20, sinceID) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ currentRequest=null; dataLoading=false; result = filterPosts(result); if(result.isEmpty() || getActivity()==null) return; Status last=result.get(result.size()-1); List toAdd; if(!data.isEmpty() && last.id.equals(data.get(0).id)){ // This part intersects with the existing one toAdd=result.subList(0, result.size()-1); // Remove the already known last post }else{ result.get(result.size()-1).hasGapAfter=true; toAdd=result; } List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList()); if(!filters.isEmpty()){ toAdd=toAdd.stream().filter(new StatusFilterPredicate(filters)).collect(Collectors.toList()); } if(!toAdd.isEmpty()){ prependItems(toAdd, true); showNewPostsButton(); AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(toAdd, false); } } @Override public void onError(ErrorResponse error){ currentRequest=null; dataLoading=false; } }) .exec(accountID); } @Override public void onGapClick(GapStatusDisplayItem.Holder item){ if(dataLoading) return; item.getItem().loading=true; V.setVisibilityAnimated(item.progress, View.VISIBLE); V.setVisibilityAnimated(item.text, View.GONE); GapStatusDisplayItem gap=item.getItem(); dataLoading=true; currentRequest=new GetHomeTimeline(item.getItemID(), null, 20, null) .setCallback(new Callback<>(){ @Override public void onSuccess(List result){ currentRequest=null; dataLoading=false; if(getActivity()==null) return; int gapPos=displayItems.indexOf(gap); if(gapPos==-1) return; if(result.isEmpty()){ displayItems.remove(gapPos); adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos); Status gapStatus=getStatusByID(gap.parentID); if(gapStatus!=null){ gapStatus.hasGapAfter=false; AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(gapStatus), false); } }else{ Set idsBelowGap=new HashSet<>(); boolean belowGap=false; int gapPostIndex=0; for(Status s:data){ if(belowGap){ idsBelowGap.add(s.id); }else if(s.id.equals(gap.parentID)){ belowGap=true; s.hasGapAfter=false; AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(Collections.singletonList(s), false); }else{ gapPostIndex++; } } int endIndex=0; for(Status s:result){ endIndex++; if(idsBelowGap.contains(s.id)) break; } if(endIndex==result.size()){ result.get(result.size()-1).hasGapAfter=true; }else{ result=result.subList(0, endIndex); } List targetList=displayItems.subList(gapPos, gapPos+1); targetList.clear(); List insertedPosts=data.subList(gapPostIndex+1, gapPostIndex+1); List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList()); outer: for(Status s:result){ if(idsBelowGap.contains(s.id)) break; for(Filter filter:filters){ if(filter.matches(s)){ continue outer; } } targetList.addAll(buildDisplayItems(s)); insertedPosts.add(s); } if(targetList.isEmpty()){ // oops. We didn't add new posts, but at least we know there are none. adapter.notifyItemRemoved(getMainAdapterOffset()+gapPos); }else{ adapter.notifyItemChanged(getMainAdapterOffset()+gapPos); adapter.notifyItemRangeInserted(getMainAdapterOffset()+gapPos+1, targetList.size()-1); } AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putHomeTimeline(insertedPosts, false); } } @Override public void onError(ErrorResponse error){ currentRequest=null; dataLoading=false; gap.loading=false; Activity a=getActivity(); if(a!=null){ error.showToast(a); int gapPos=displayItems.indexOf(gap); if(gapPos>=0) adapter.notifyItemChanged(gapPos); } } }) .exec(accountID); } @Override public void onRefresh(){ if(currentRequest!=null){ currentRequest.cancel(); currentRequest=null; dataLoading=false; } super.onRefresh(); } private void updateToolbarLogo(){ toolbarLogo =new TextView(getActivity()); toolbarLogo.setText(getString(R.string.app_name).toLowerCase(Locale.getDefault())); toolbarLogo.setTextAppearance(R.style.app_title); toolbarShowNewPostsBtn=new Button(getActivity()); toolbarShowNewPostsBtn.setTextAppearance(R.style.m3_title_medium); toolbarShowNewPostsBtn.setTextColor(0xffffffff); toolbarShowNewPostsBtn.setStateListAnimator(null); toolbarShowNewPostsBtn.setBackgroundResource(R.drawable.bg_button_new_posts); toolbarShowNewPostsBtn.setText(R.string.see_new_posts); toolbarShowNewPostsBtn.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_fluent_arrow_up_16_filled, 0, 0, 0); toolbarShowNewPostsBtn.setCompoundDrawableTintList(toolbarShowNewPostsBtn.getTextColors()); toolbarShowNewPostsBtn.setCompoundDrawablePadding(V.dp(8)); if(Build.VERSION.SDK_INT