diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java index 3b58fac14..9c5d23450 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java +++ b/mastodon/src/main/java/org/joinmastodon/android/api/CacheController.java @@ -12,6 +12,7 @@ import android.util.Log; import org.joinmastodon.android.BuildConfig; import org.joinmastodon.android.MastodonApp; import org.joinmastodon.android.api.requests.notifications.GetNotifications; +import org.joinmastodon.android.api.requests.timelines.GetConversationsTimeline; import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.model.CacheablePaginatedResponse; @@ -126,6 +127,79 @@ public class CacheController{ }); } + public void getConversationsTimeline(String maxID, int count, boolean forceReload, Callback>> callback){ + cancelDelayedClose(); + databaseThread.postRunnable(()->{ + try{ + List filters=AccountSessionManager.getInstance().getAccount(accountID).wordFilters.stream().filter(f->f.context.contains(Filter.FilterContext.HOME)).collect(Collectors.toList()); + if(!forceReload){ + SQLiteDatabase db=getOrOpenDatabase(); + try(Cursor cursor=db.query("conversations_timeline", new String[]{"json", "flags"}, maxID==null ? null : "`id` result=new ArrayList<>(); + cursor.moveToFirst(); + String newMaxID; + outer: + do{ + Status status=MastodonAPIController.gson.fromJson(cursor.getString(0), Status.class); + status.postprocess(); + int flags=cursor.getInt(1); + status.hasGapAfter=((flags & POST_FLAG_GAP_AFTER)!=0); + newMaxID=status.id; + for(Filter filter:filters){ + if(filter.matches(status)) + continue outer; + } + result.add(status); + }while(cursor.moveToNext()); + String _newMaxID=newMaxID; + uiHandler.post(()->callback.onSuccess(new CacheablePaginatedResponse<>(result, _newMaxID, true))); + return; + } + }catch(IOException x){ + Log.w(TAG, "getHomeTimeline: corrupted status object in database", x); + } + } + new GetConversationsTimeline(maxID, null, count, null) + .setCallback(new Callback<>(){ + @Override + public void onSuccess(List result){ + callback.onSuccess(new CacheablePaginatedResponse<>(result.stream().filter(new StatusFilterPredicate(filters)).collect(Collectors.toList()), result.isEmpty() ? null : result.get(result.size()-1).id, false)); + putConversationsTimeline(result, maxID==null); + } + + @Override + public void onError(ErrorResponse error){ + callback.onError(error); + } + }) + .exec(accountID); + }catch(SQLiteException x){ + Log.w(TAG, x); + uiHandler.post(()->callback.onError(new MastodonErrorResponse(x.getLocalizedMessage(), 500, x))); + }finally{ + closeDelayed(); + } + }, 0); + } + + public void putConversationsTimeline(List posts, boolean clear){ + runOnDbThread((db)->{ + if(clear) + db.delete("conversations_timeline", null, null); + ContentValues values=new ContentValues(3); + for(Status s:posts){ + values.put("id", s.id); + values.put("json", MastodonAPIController.gson.toJson(s)); + int flags=0; + if(s.hasGapAfter) + flags|=POST_FLAG_GAP_AFTER; + values.put("flags", flags); + db.insertWithOnConflict("conversations_timeline", null, values, SQLiteDatabase.CONFLICT_REPLACE); + } + }); + } + public void getNotifications(String maxID, int count, boolean onlyMentions, boolean onlyPosts, boolean forceReload, Callback>> callback){ cancelDelayedClose(); databaseThread.postRunnable(()->{ diff --git a/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetConversationsTimeline.java b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetConversationsTimeline.java new file mode 100644 index 000000000..7bd223a2e --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/api/requests/timelines/GetConversationsTimeline.java @@ -0,0 +1,22 @@ +package org.joinmastodon.android.api.requests.timelines; + +import com.google.gson.reflect.TypeToken; + +import org.joinmastodon.android.api.MastodonAPIRequest; +import org.joinmastodon.android.model.Status; + +import java.util.List; + +public class GetConversationsTimeline extends MastodonAPIRequest>{ + public GetConversationsTimeline(String maxID, String minID, int limit, String sinceID){ + super(HttpMethod.GET, "/conversations", new TypeToken<>(){}); + if(maxID!=null) + addQueryParameter("max_id", maxID); + if(minID!=null) + addQueryParameter("min_id", minID); + if(sinceID!=null) + addQueryParameter("since_id", sinceID); + if(limit>0) + addQueryParameter("limit", ""+limit); + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ConversationsTimelineFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ConversationsTimelineFragment.java new file mode 100644 index 000000000..0e446b7a3 --- /dev/null +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ConversationsTimelineFragment.java @@ -0,0 +1,253 @@ +package org.joinmastodon.android.fragments; + +import android.app.Activity; +import android.os.Bundle; +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.joinmastodon.android.GlobalUserPreferences; +import org.joinmastodon.android.api.requests.timelines.GetConversationsTimeline; +import org.joinmastodon.android.api.requests.timelines.GetHomeTimeline; +import org.joinmastodon.android.api.session.AccountSessionManager; +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.utils.StatusFilterPredicate; + +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; + +import me.grishka.appkit.api.Callback; +import me.grishka.appkit.api.ErrorResponse; +import me.grishka.appkit.api.SimpleCallback; +import me.grishka.appkit.utils.V; + +public class ConversationsTimelineFragment extends FabStatusListFragment { + private HomeTabFragment parent; + private String maxID; + + @Override + public void onAttach(Activity activity){ + super.onAttach(activity); + if (getParentFragment() instanceof HomeTabFragment home) parent = home; + loadData(); + } + + private List filterPosts(List items) { +// Disabling this for DMs, because there are no boosts on DMs, and most of them are replies +// return items.stream().filter(i -> +// (GlobalUserPreferences.showReplies || i.inReplyToId == null) && +// (GlobalUserPreferences.showBoosts || i.reblog == null) +// ).collect(Collectors.toList()); + return items; + } + + @Override + protected void doLoadData(int offset, int count){ + AccountSessionManager.getInstance() + .getAccount(accountID).getCacheController() + .getConversationsTimeline(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); + + list.addOnScrollListener(new RecyclerView.OnScrollListener(){ + @Override + public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy){ + if(parent != null && parent.isNewPostsBtnShown() && list.getChildAdapterPosition(list.getChildAt(0))<=getMainAdapterOffset()){ + parent.hideNewPostsButton(); + } + } + }); + } + + @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 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 GetConversationsTimeline(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; + } + StatusFilterPredicate filterPredicate=new StatusFilterPredicate(accountID, Filter.FilterContext.HOME); + toAdd=toAdd.stream().filter(filterPredicate).collect(Collectors.toList()); + if(!toAdd.isEmpty()){ + prependItems(toAdd, true); + if (parent != null) parent.showNewPostsButton(); + AccountSessionManager.getInstance().getAccount(accountID).getCacheController().putConversationsTimeline(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 GetConversationsTimeline(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().putConversationsTimeline(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().putConversationsTimeline(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); + StatusFilterPredicate filterPredicate=new StatusFilterPredicate(accountID, Filter.FilterContext.HOME); + for(Status s:result){ + if(idsBelowGap.contains(s.id)) + break; + if(filterPredicate.test(s)){ + 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().putConversationsTimeline(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; + } + if (parent != null) parent.hideNewPostsButton(); + super.onRefresh(); + } + + @Override + protected boolean shouldRemoveAccountPostsWhenUnfollowing(){ + return true; + } +} diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java index a8f4a537d..40eabd5ab 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/NotificationsFragment.java @@ -20,6 +20,8 @@ import org.joinmastodon.android.api.requests.accounts.GetFollowRequests; import org.joinmastodon.android.events.FollowRequestHandledEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.HeaderPaginationList; +import org.joinmastodon.android.model.Notification; +import org.joinmastodon.android.model.Token; import org.joinmastodon.android.ui.SimpleViewHolder; import org.joinmastodon.android.ui.tabs.TabLayout; import org.joinmastodon.android.ui.tabs.TabLayoutMediator; @@ -44,7 +46,9 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc private FrameLayout[] tabViews; private TabLayoutMediator tabLayoutMediator; - private NotificationsListFragment allNotificationsFragment, mentionsFragment, postsFragment; + private NotificationsListFragment allNotificationsFragment, mentionsFragment; + + private ConversationsTimelineFragment conversationsTimelineFragment; private String accountID; @@ -152,13 +156,13 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc args=new Bundle(args); args.putBoolean("onlyPosts", true); - postsFragment=new NotificationsListFragment(); - postsFragment.setArguments(args); + conversationsTimelineFragment=new ConversationsTimelineFragment(); + conversationsTimelineFragment.setArguments(args); getChildFragmentManager().beginTransaction() .add(R.id.notifications_all, allNotificationsFragment) .add(R.id.notifications_mentions, mentionsFragment) - .add(R.id.notifications_posts, postsFragment) + .add(R.id.notifications_posts, conversationsTimelineFragment) .commit(); } @@ -168,7 +172,7 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc tab.setText(switch(position){ case 0 -> R.string.all_notifications; case 1 -> R.string.mentions; - case 2 -> R.string.posts; + case 2 -> R.string.sk_conversations; default -> throw new IllegalStateException("Unexpected value: "+position); }); tab.view.textView.setAllCaps(true); @@ -213,11 +217,11 @@ public class NotificationsFragment extends MastodonToolbarFragment implements Sc getToolbar().setOutlineProvider(null); } - private NotificationsListFragment getFragmentForPage(int page){ + private Fragment getFragmentForPage(int page){ return switch(page){ case 0 -> allNotificationsFragment; case 1 -> mentionsFragment; - case 2 -> postsFragment; + case 2 -> conversationsTimelineFragment; default -> throw new IllegalStateException("Unexpected value: "+page); }; } diff --git a/mastodon/src/main/res/values/strings_sk.xml b/mastodon/src/main/res/values/strings_sk.xml index 86ec76cad..d5739af6a 100644 --- a/mastodon/src/main/res/values/strings_sk.xml +++ b/mastodon/src/main/res/values/strings_sk.xml @@ -163,6 +163,7 @@ Remove %s as a follower by blocking and immediately unblocking them? Remove Successfully removed follower + Conversations Add new poll option