diff --git a/app/src/main/java/app/fedilab/android/client/entities/api/Conversation.java b/app/src/main/java/app/fedilab/android/client/entities/api/Conversation.java index 7e70175b..d8cd609f 100644 --- a/app/src/main/java/app/fedilab/android/client/entities/api/Conversation.java +++ b/app/src/main/java/app/fedilab/android/client/entities/api/Conversation.java @@ -1,5 +1,7 @@ package app.fedilab.android.client.entities.api; +import androidx.annotation.Nullable; + import com.google.gson.annotations.SerializedName; import java.util.List; @@ -27,4 +29,25 @@ public class Conversation { public List accounts; @SerializedName("last_status") public Status last_status; + public boolean isFetchMore = false; + @SerializedName("cached") + public boolean cached = false; + + + public PositionFetchMore positionFetchMore = PositionFetchMore.BOTTOM; + + @Override + public boolean equals(@Nullable Object obj) { + boolean same = false; + if (obj instanceof Conversation) { + same = this.id.equals(((Conversation) obj).id); + } + return same; + } + + public enum PositionFetchMore { + TOP, + BOTTOM + } + } diff --git a/app/src/main/java/app/fedilab/android/client/entities/api/Notification.java b/app/src/main/java/app/fedilab/android/client/entities/api/Notification.java index e17c1cfd..4db1ae7f 100644 --- a/app/src/main/java/app/fedilab/android/client/entities/api/Notification.java +++ b/app/src/main/java/app/fedilab/android/client/entities/api/Notification.java @@ -58,7 +58,7 @@ public class Notification { public transient List relatedNotifications; public boolean isFetchMore; - public boolean isFetchMoreHidden = false; + /** * Serialized a list of Notification class diff --git a/app/src/main/java/app/fedilab/android/client/entities/app/StatusCache.java b/app/src/main/java/app/fedilab/android/client/entities/app/StatusCache.java index 3d28f2b1..77b59d32 100644 --- a/app/src/main/java/app/fedilab/android/client/entities/app/StatusCache.java +++ b/app/src/main/java/app/fedilab/android/client/entities/app/StatusCache.java @@ -28,6 +28,8 @@ import java.util.Date; import java.util.List; import app.fedilab.android.activities.MainActivity; +import app.fedilab.android.client.entities.api.Conversation; +import app.fedilab.android.client.entities.api.Conversations; import app.fedilab.android.client.entities.api.Notification; import app.fedilab.android.client.entities.api.Notifications; import app.fedilab.android.client.entities.api.Pagination; @@ -57,6 +59,8 @@ public class StatusCache { public Status status; @SerializedName("notification") public Notification notification; + @SerializedName("conversation") + public Conversation conversation; @SerializedName("created_at") public Date created_at; @SerializedName("updated_at") @@ -103,6 +107,21 @@ public class StatusCache { } } + /** + * Serialized a Conversation class + * + * @param mastodon_conversation {@link Conversation} to serialize + * @return String serialized Conversation + */ + public static String mastodonConversationToStringStorage(Conversation mastodon_conversation) { + Gson gson = new Gson(); + try { + return gson.toJson(mastodon_conversation); + } catch (Exception e) { + return null; + } + } + /** * Unserialized a Mastodon Status * @@ -135,6 +154,23 @@ public class StatusCache { } } + + /** + * Unserialized a Mastodon Conversation + * + * @param serializedConversation String serialized Conversation + * @return {@link Conversation} + */ + public static Conversation restoreConversationFromString(String serializedConversation) { + Gson gson = new Gson(); + try { + return gson.fromJson(serializedConversation, Conversation.class); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + /** * Insert or update a status * @@ -238,6 +274,9 @@ public class StatusCache { if (statusCache.notification != null) { values.put(Sqlite.COL_STATUS, mastodonNotificationToStringStorage(statusCache.notification)); } + if (statusCache.conversation != null) { + values.put(Sqlite.COL_STATUS, mastodonConversationToStringStorage(statusCache.conversation)); + } values.put(Sqlite.COL_CREATED_AT, Helper.dateToString(new Date())); //Inserts token try { @@ -268,6 +307,9 @@ public class StatusCache { if (statusCache.notification != null) { values.put(Sqlite.COL_STATUS, mastodonNotificationToStringStorage(statusCache.notification)); } + if (statusCache.conversation != null) { + values.put(Sqlite.COL_STATUS, mastodonConversationToStringStorage(statusCache.conversation)); + } values.put(Sqlite.COL_UPDATED_AT, Helper.dateToString(new Date())); //Inserts token try { @@ -388,6 +430,42 @@ public class StatusCache { } + /** + * Get paginated conversations from db + * + * @param instance String - instance + * @param user_id String - us + * @param max_id String - status having max id + * @param min_id String - status having min id + * @return Statuses + * @throws DBException - throws a db exception + */ + public Conversations getConversations(String instance, String user_id, String max_id, String min_id, String since_id) throws DBException { + if (db == null) { + throw new DBException("db is null. Wrong initialization."); + } + String order = " DESC"; + String selection = Sqlite.COL_INSTANCE + "='" + instance + "' AND " + Sqlite.COL_USER_ID + "= '" + user_id + "' AND " + Sqlite.COL_TYPE + "= '" + Timeline.TimeLineEnum.CONVERSATION.getValue() + "' "; + String limit = String.valueOf(MastodonHelper.statusesPerCall(context)); + if (min_id != null) { + selection += "AND " + Sqlite.COL_STATUS_ID + " > '" + min_id + "' "; + order = " ASC"; + } else if (max_id != null) { + selection += "AND " + Sqlite.COL_STATUS_ID + " < '" + max_id + "' "; + } else if (since_id != null) { + selection += "AND " + Sqlite.COL_STATUS_ID + " > '" + since_id + "' "; + limit = null; + } + + try { + Cursor c = db.query(Sqlite.TABLE_STATUS_CACHE, null, selection, null, null, null, Sqlite.COL_STATUS_ID + order, limit); + return createConversationReply(cursorToListOfConversations(c)); + } catch (Exception e) { + e.printStackTrace(); + return null; + } + } + /** * Get paginated statuses from db * @@ -495,7 +573,7 @@ public class StatusCache { * Convert a cursor to list of notifications * * @param c Cursor - * @return List + * @return List */ private List cursorToListOfNotifications(Cursor c) { //No element found @@ -514,6 +592,28 @@ public class StatusCache { } + /** + * Convert a cursor to list of Conversation + * + * @param c Cursor + * @return List + */ + private List cursorToListOfConversations(Cursor c) { + //No element found + if (c.getCount() == 0) { + c.close(); + return null; + } + List conversationList = new ArrayList<>(); + while (c.moveToNext()) { + Conversation conversation = convertCursorToConversation(c); + conversationList.add(conversation); + } + //Close the cursor + c.close(); + return conversationList; + } + /** * Create a reply from db in the same way than API call * @@ -537,6 +637,30 @@ public class StatusCache { return notifications; } + + /** + * Create a reply from db in the same way than API call + * + * @param conversationList List + * @return Conversations (with pagination) + */ + private Conversations createConversationReply(List conversationList) { + Conversations conversations = new Conversations(); + conversations.conversations = conversationList; + Pagination pagination = new Pagination(); + if (conversationList != null && conversationList.size() > 0) { + //Status list is inverted, it happens for min_id due to ASC ordering + if (conversationList.get(0).id.compareTo(conversationList.get(conversationList.size() - 1).id) < 0) { + Collections.reverse(conversationList); + conversations.conversations = conversationList; + } + pagination.max_id = conversationList.get(0).id; + pagination.min_id = conversationList.get(conversationList.size() - 1).id; + } + conversations.pagination = pagination; + return conversations; + } + /** * Create a reply from db in the same way than API call * @@ -583,6 +707,17 @@ public class StatusCache { } + /** + * Read cursor and hydrate without closing it + * + * @param c - Cursor + * @return Conversation + */ + private Conversation convertCursorToConversation(Cursor c) { + String serializedNotification = c.getString(c.getColumnIndexOrThrow(Sqlite.COL_STATUS)); + return restoreConversationFromString(serializedNotification); + } + public enum order { @SerializedName("ASC") ASC("ASC"), diff --git a/app/src/main/java/app/fedilab/android/client/entities/app/Timeline.java b/app/src/main/java/app/fedilab/android/client/entities/app/Timeline.java index cc9c61a7..100455e5 100644 --- a/app/src/main/java/app/fedilab/android/client/entities/app/Timeline.java +++ b/app/src/main/java/app/fedilab/android/client/entities/app/Timeline.java @@ -358,6 +358,8 @@ public class Timeline { DIRECT("DIRECT"), @SerializedName("NOTIFICATION") NOTIFICATION("NOTIFICATION"), + @SerializedName("CONVERSATION") + CONVERSATION("CONVERSATION"), @SerializedName("LOCAL") LOCAL("LOCAL"), @SerializedName("PUBLIC") diff --git a/app/src/main/java/app/fedilab/android/ui/drawer/ConversationAdapter.java b/app/src/main/java/app/fedilab/android/ui/drawer/ConversationAdapter.java index 94e1d748..6b351a29 100644 --- a/app/src/main/java/app/fedilab/android/ui/drawer/ConversationAdapter.java +++ b/app/src/main/java/app/fedilab/android/ui/drawer/ConversationAdapter.java @@ -58,6 +58,7 @@ public class ConversationAdapter extends RecyclerView.Adapter conversationList; private Context context; private boolean isExpended = false; + public FetchMoreCallBack fetchMoreCallBack; public ConversationAdapter(List conversations) { if (conversations == null) { @@ -127,6 +128,42 @@ public class ConversationAdapter extends RecyclerView.Adapter { + conversation.isFetchMore = false; + if (holder.getBindingAdapterPosition() < conversationList.size() - 1) { + String fromId; + if (conversation.positionFetchMore == Conversation.PositionFetchMore.TOP) { + fromId = conversationList.get(position + 1).id; + } else { + fromId = conversation.id; + } + fetchMoreCallBack.onClickMinId(fromId, conversation); + notifyItemChanged(position); + } + + }); + holder.binding.layoutFetchMore.fetchMoreMax.setOnClickListener(v -> { + //We hide the button + conversation.isFetchMore = false; + String fromId; + if (conversation.positionFetchMore == Conversation.PositionFetchMore.TOP) { + fromId = conversationList.get(position).id; + } else { + fromId = conversationList.get(position - 1).id; + } + notifyItemChanged(position); + fetchMoreCallBack.onClickMaxId(fromId, conversation); + }); + } else { + holder.binding.layoutFetchMore.fetchMoreContainer.setVisibility(View.GONE); + } + if (conversation.cached) { + holder.binding.cacheIndicator.setVisibility(View.VISIBLE); + } else { + holder.binding.cacheIndicator.setVisibility(View.GONE); + } //---- SPOILER TEXT ----- boolean expand_cw = sharedpreferences.getBoolean(context.getString(R.string.SET_EXPAND_CW), false); if (conversation.last_status.spoiler_text != null && !conversation.last_status.spoiler_text.trim().isEmpty()) { @@ -257,5 +294,9 @@ public class ConversationAdapter extends RecyclerView.Adapter. */ +import android.content.SharedPreferences; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -24,6 +25,7 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; +import androidx.preference.PreferenceManager; import androidx.recyclerview.widget.LinearLayoutManager; import androidx.recyclerview.widget.RecyclerView; @@ -34,33 +36,149 @@ import app.fedilab.android.BaseMainActivity; import app.fedilab.android.R; import app.fedilab.android.client.entities.api.Conversation; import app.fedilab.android.client.entities.api.Conversations; +import app.fedilab.android.client.entities.app.StatusCache; +import app.fedilab.android.client.entities.app.Timeline; import app.fedilab.android.databinding.FragmentPaginationBinding; +import app.fedilab.android.exception.DBException; import app.fedilab.android.helper.MastodonHelper; import app.fedilab.android.helper.ThemeHelper; import app.fedilab.android.ui.drawer.ConversationAdapter; import app.fedilab.android.viewmodel.mastodon.TimelinesVM; -public class FragmentMastodonConversation extends Fragment { +public class FragmentMastodonConversation extends Fragment implements ConversationAdapter.FetchMoreCallBack { private FragmentPaginationBinding binding; private TimelinesVM timelinesVM; - private FragmentMastodonConversation currentFragment; private boolean flagLoading; - private List conversations; - private String max_id; + private List conversationList; + private String max_id, min_id, min_id_fetch_more, max_id_fetch_more; private ConversationAdapter conversationAdapter; + private LinearLayoutManager mLayoutManager; public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { - currentFragment = this; flagLoading = false; binding = FragmentPaginationBinding.inflate(inflater, container, false); binding.getRoot().setBackgroundColor(ThemeHelper.getBackgroundColor(requireActivity())); return binding.getRoot(); } + /** + * Router for timelines + * + * @param direction - DIRECTION null if first call, then is set to TOP or BOTTOM depending of scroll + */ + private void route(FragmentMastodonTimeline.DIRECTION direction, boolean fetchingMissing) { + route(direction, fetchingMissing, null); + } + + /** + * Router for timelines + * + * @param direction - DIRECTION null if first call, then is set to TOP or BOTTOM depending of scroll + */ + private void route(FragmentMastodonTimeline.DIRECTION direction, boolean fetchingMissing, Conversation conversationToUpdate) { + if (binding == null || !isAdded() || getActivity() == null) { + return; + } + if (!isAdded()) { + return; + } + + SharedPreferences sharedpreferences = PreferenceManager.getDefaultSharedPreferences(requireActivity()); + boolean useCache = sharedpreferences.getBoolean(getString(R.string.SET_USE_CACHE), true); + + TimelinesVM.TimelineParams timelineParams = new TimelinesVM.TimelineParams(Timeline.TimeLineEnum.NOTIFICATION, direction, null); + timelineParams.limit = MastodonHelper.notificationsPerCall(requireActivity()); + if (direction == FragmentMastodonTimeline.DIRECTION.REFRESH || direction == FragmentMastodonTimeline.DIRECTION.SCROLL_TOP) { + timelineParams.maxId = null; + timelineParams.minId = null; + } else if (direction == FragmentMastodonTimeline.DIRECTION.BOTTOM) { + timelineParams.maxId = fetchingMissing ? max_id_fetch_more : max_id; + timelineParams.minId = null; + } else if (direction == FragmentMastodonTimeline.DIRECTION.TOP) { + timelineParams.minId = fetchingMissing ? min_id_fetch_more : min_id; + timelineParams.maxId = null; + } else { + timelineParams.maxId = max_id; + } + + timelineParams.fetchingMissing = fetchingMissing; + + if (useCache) { + getCachedConversations(direction, fetchingMissing, timelineParams); + } else { + getLiveConversations(direction, fetchingMissing, timelineParams, conversationToUpdate); + } + } + + private void getCachedConversations(FragmentMastodonTimeline.DIRECTION direction, boolean fetchingMissing, TimelinesVM.TimelineParams timelineParams) { + + if (direction == null) { + timelinesVM.getConversations(conversationList, timelineParams) + .observe(getViewLifecycleOwner(), conversationsCached -> { + if (conversationsCached == null || conversationsCached.conversations == null || conversationsCached.conversations.size() == 0) { + getLiveConversations(null, fetchingMissing, timelineParams, null); + } else { + initializeConversationCommonView(conversationsCached); + } + }); + } else if (direction == FragmentMastodonTimeline.DIRECTION.BOTTOM) { + timelinesVM.getConversationsCache(conversationList, timelineParams) + .observe(getViewLifecycleOwner(), conversationsBottom -> { + if (conversationsBottom == null || conversationsBottom.conversations == null || conversationsBottom.conversations.size() == 0) { + getLiveConversations(FragmentMastodonTimeline.DIRECTION.BOTTOM, fetchingMissing, timelineParams, null); + } else { + dealWithPagination(conversationsBottom, FragmentMastodonTimeline.DIRECTION.BOTTOM, fetchingMissing, null); + } + + }); + } else if (direction == FragmentMastodonTimeline.DIRECTION.TOP) { + timelinesVM.getConversationsCache(conversationList, timelineParams) + .observe(getViewLifecycleOwner(), conversationsTop -> { + if (conversationsTop == null || conversationsTop.conversations == null || conversationsTop.conversations.size() == 0) { + getLiveConversations(FragmentMastodonTimeline.DIRECTION.TOP, fetchingMissing, timelineParams, null); + } else { + dealWithPagination(conversationsTop, FragmentMastodonTimeline.DIRECTION.TOP, fetchingMissing, null); + } + }); + } else if (direction == FragmentMastodonTimeline.DIRECTION.REFRESH) { + timelinesVM.getConversations(conversationList, timelineParams) + .observe(getViewLifecycleOwner(), notificationsRefresh -> { + if (conversationAdapter != null) { + dealWithPagination(notificationsRefresh, FragmentMastodonTimeline.DIRECTION.REFRESH, true, null); + } else { + initializeConversationCommonView(notificationsRefresh); + } + }); + } + } + + private void getLiveConversations(FragmentMastodonTimeline.DIRECTION direction, boolean fetchingMissing, TimelinesVM.TimelineParams timelineParams, Conversation conversationToUpdate) { + if (direction == null) { + timelinesVM.getConversations(conversationList, timelineParams) + .observe(getViewLifecycleOwner(), this::initializeConversationCommonView); + } else if (direction == FragmentMastodonTimeline.DIRECTION.BOTTOM) { + timelinesVM.getConversations(conversationList, timelineParams) + .observe(getViewLifecycleOwner(), conversationsBottom -> dealWithPagination(conversationsBottom, FragmentMastodonTimeline.DIRECTION.BOTTOM, fetchingMissing, conversationToUpdate)); + } else if (direction == FragmentMastodonTimeline.DIRECTION.TOP) { + timelinesVM.getConversations(conversationList, timelineParams) + .observe(getViewLifecycleOwner(), conversationsTop -> dealWithPagination(conversationsTop, FragmentMastodonTimeline.DIRECTION.TOP, fetchingMissing, conversationToUpdate)); + } else if (direction == FragmentMastodonTimeline.DIRECTION.REFRESH) { + timelinesVM.getConversations(conversationList, timelineParams) + .observe(getViewLifecycleOwner(), conversationsRefresh -> { + if (conversationAdapter != null) { + dealWithPagination(conversationsRefresh, FragmentMastodonTimeline.DIRECTION.REFRESH, true, conversationToUpdate); + } else { + initializeConversationCommonView(conversationsRefresh); + } + }); + } + } + + @Override public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { super.onViewCreated(view, savedInstanceState); @@ -74,8 +192,7 @@ public class FragmentMastodonConversation extends Fragment { binding.recyclerView.setVisibility(View.GONE); timelinesVM = new ViewModelProvider(FragmentMastodonConversation.this).get(TimelinesVM.class); max_id = null; - timelinesVM.getConversations(BaseMainActivity.currentInstance, BaseMainActivity.currentToken, null, null, null, MastodonHelper.statusesPerCall(requireActivity())) - .observe(getViewLifecycleOwner(), this::initializeConversationCommonView); + route(null, false); } /** @@ -85,71 +202,214 @@ public class FragmentMastodonConversation extends Fragment { */ private void initializeConversationCommonView(final Conversations conversations) { flagLoading = false; + if (binding == null || !isAdded() || getActivity() == null) { + return; + } binding.loader.setVisibility(View.GONE); binding.noAction.setVisibility(View.GONE); - if (conversationAdapter != null && this.conversations != null) { - int size = this.conversations.size(); - this.conversations.clear(); - this.conversations = new ArrayList<>(); + binding.swipeContainer.setRefreshing(false); + binding.swipeContainer.setOnRefreshListener(() -> { + binding.swipeContainer.setRefreshing(true); + flagLoading = false; + route(FragmentMastodonTimeline.DIRECTION.REFRESH, true); + }); + if (conversations == null || conversations.conversations == null || conversations.conversations.size() == 0) { + binding.noAction.setVisibility(View.VISIBLE); + binding.recyclerView.setVisibility(View.GONE); + return; + } else { + binding.noAction.setVisibility(View.GONE); + binding.recyclerView.setVisibility(View.VISIBLE); + } + flagLoading = conversations.pagination.max_id == null; + + if (conversationAdapter != null && conversationList != null) { + int size = conversationList.size(); + conversationList.clear(); + conversationList = new ArrayList<>(); conversationAdapter.notifyItemRangeRemoved(0, size); } - binding.recyclerView.setVisibility(View.VISIBLE); - this.conversations = conversations.conversations; - conversationAdapter = new ConversationAdapter(this.conversations); - //conversationAdapter.itemListener = currentFragment; - binding.swipeContainer.setRefreshing(false); - LinearLayoutManager mLayoutManager = new LinearLayoutManager(requireActivity()); + if (conversationList == null) { + conversationList = new ArrayList<>(); + } + conversationList.addAll(conversations.conversations); + + if (max_id == null || (conversations.pagination.max_id != null && conversations.pagination.max_id.compareTo(max_id) < 0)) { + max_id = conversations.pagination.max_id; + } + if (min_id == null || (conversations.pagination.min_id != null && conversations.pagination.min_id.compareTo(min_id) > 0)) { + min_id = conversations.pagination.min_id; + } + + conversationAdapter = new ConversationAdapter(conversationList); + conversationAdapter.fetchMoreCallBack = this; + mLayoutManager = new LinearLayoutManager(requireActivity()); binding.recyclerView.setLayoutManager(mLayoutManager); binding.recyclerView.setAdapter(conversationAdapter); - max_id = conversations.pagination.max_id; + binding.recyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() { @Override public void onScrolled(@NonNull RecyclerView recyclerView, int dx, int dy) { + if (requireActivity() instanceof BaseMainActivity) { + if (dy < 0 && !((BaseMainActivity) requireActivity()).getFloatingVisibility()) + ((BaseMainActivity) requireActivity()).manageFloatingButton(true); + if (dy > 0 && ((BaseMainActivity) requireActivity()).getFloatingVisibility()) + ((BaseMainActivity) requireActivity()).manageFloatingButton(false); + } int firstVisibleItem = mLayoutManager.findFirstVisibleItemPosition(); if (dy > 0) { int visibleItemCount = mLayoutManager.getChildCount(); int totalItemCount = mLayoutManager.getItemCount(); + if (firstVisibleItem + visibleItemCount == totalItemCount) { if (!flagLoading) { flagLoading = true; binding.loadingNextElements.setVisibility(View.VISIBLE); - timelinesVM.getConversations(BaseMainActivity.currentInstance, BaseMainActivity.currentToken, max_id, null, null, MastodonHelper.statusesPerCall(requireActivity())) - .observe(FragmentMastodonConversation.this, fetched_conversations -> { - binding.loadingNextElements.setVisibility(View.GONE); - if (currentFragment.conversations != null && fetched_conversations != null) { - int startId = 0; - flagLoading = fetched_conversations.pagination.max_id == null; - //There are some statuses present in the timeline - if (currentFragment.conversations.size() > 0) { - startId = currentFragment.conversations.size(); - } - currentFragment.conversations.addAll(fetched_conversations.conversations); - max_id = fetched_conversations.pagination.max_id; - conversationAdapter.notifyItemRangeInserted(startId, fetched_conversations.conversations.size()); - } - }); + route(FragmentMastodonTimeline.DIRECTION.BOTTOM, false); } } else { binding.loadingNextElements.setVisibility(View.GONE); } + } else if (firstVisibleItem == 0) { //Scroll top and item is zero + if (!flagLoading) { + flagLoading = true; + binding.loadingNextElements.setVisibility(View.VISIBLE); + route(FragmentMastodonTimeline.DIRECTION.TOP, false); + } } - - } - }); - binding.swipeContainer.setOnRefreshListener(() -> { - if (this.conversations != null && this.conversations.size() > 0) { - binding.swipeContainer.setRefreshing(true); - max_id = null; - flagLoading = false; - timelinesVM.getConversations(BaseMainActivity.currentInstance, BaseMainActivity.currentToken, null, null, null, MastodonHelper.statusesPerCall(requireActivity())) - .observe(FragmentMastodonConversation.this, this::initializeConversationCommonView); } }); } + + /** + * Update view and pagination when scrolling down + * + * @param fetched_conversations Conversations + */ + private synchronized void dealWithPagination(Conversations fetched_conversations, FragmentMastodonTimeline.DIRECTION direction, boolean fetchingMissing, Conversation conversationToUpdate) { + if (binding == null || !isAdded() || getActivity() == null) { + return; + } + binding.swipeContainer.setRefreshing(false); + binding.loadingNextElements.setVisibility(View.GONE); + flagLoading = false; + if (conversationList != null && fetched_conversations != null && fetched_conversations.conversations != null && fetched_conversations.conversations.size() > 0) { + try { + if (conversationToUpdate != null) { + new Thread(() -> { + StatusCache statusCache = new StatusCache(); + statusCache.instance = BaseMainActivity.currentInstance; + statusCache.user_id = BaseMainActivity.currentUserID; + conversationToUpdate.isFetchMore = false; + statusCache.conversation = conversationToUpdate; + statusCache.status_id = conversationToUpdate.id; + try { + new StatusCache(requireActivity()).updateIfExists(statusCache); + } catch (DBException e) { + e.printStackTrace(); + } + }).start(); + } + } catch (Exception ignored) { + } + + flagLoading = fetched_conversations.pagination.max_id == null; + binding.noAction.setVisibility(View.GONE); + //Update the timeline with new statuses + updateConversationListWith(fetched_conversations.conversations); + if (direction == FragmentMastodonTimeline.DIRECTION.TOP && fetchingMissing) { + binding.recyclerView.scrollToPosition(getPosition(fetched_conversations.conversations.get(fetched_conversations.conversations.size() - 1)) + 1); + } + if (!fetchingMissing) { + if (fetched_conversations.pagination.max_id == null) { + flagLoading = true; + } else if (max_id == null || fetched_conversations.pagination.max_id.compareTo(max_id) < 0) { + max_id = fetched_conversations.pagination.max_id; + } + if (min_id == null || (fetched_conversations.pagination.min_id != null && fetched_conversations.pagination.min_id.compareTo(min_id) > 0)) { + min_id = fetched_conversations.pagination.min_id; + } + } + } else if (direction == FragmentMastodonTimeline.DIRECTION.BOTTOM) { + flagLoading = true; + } + if (direction == FragmentMastodonTimeline.DIRECTION.SCROLL_TOP) { + binding.recyclerView.scrollToPosition(0); + } + } + + + /** + * Return the position of the convnersation in the ArrayList + * + * @param conversation - Conversation to fetch + * @return position or -1 if not found + */ + private int getPosition(Conversation conversation) { + int position = 0; + boolean found = false; + for (Conversation _conversation : conversationList) { + if (conversation != null && _conversation.id.compareTo(conversation.id) == 0) { + found = true; + break; + } + position++; + } + return found ? position : -1; + } + + /** + * Update the timeline with received Conversations + * + * @param conversationsReceived - List Conversation received + */ + private void updateConversationListWith(List conversationsReceived) { + if (conversationsReceived != null && conversationsReceived.size() > 0) { + for (Conversation conversationReceived : conversationsReceived) { + int position = 0; + //We loop through messages already in the timeline + if (conversationList != null) { + conversationAdapter.notifyItemRangeChanged(0, conversationList.size()); + for (Conversation conversationsAlreadyPresent : conversationList) { + //We compare the date of each status and we only add status having a date greater than the another, it is inserted at this position + //Pinned messages are ignored because their date can be older + if (conversationReceived.id.compareTo(conversationsAlreadyPresent.id) > 0) { + if (!conversationList.contains(conversationReceived)) { + conversationList.add(position, conversationReceived); + conversationAdapter.notifyItemInserted(position); + } + break; + } + position++; + } + //Statuses added at the bottom, we flag them by position = -2 for not dealing with them and fetch more + if (position == conversationList.size() && !conversationList.contains(conversationReceived)) { + conversationList.add(position, conversationReceived); + conversationAdapter.notifyItemInserted(position); + } + } + } + } + } + + public void scrollToTop() { binding.recyclerView.scrollToPosition(0); } + @Override + public void onClickMinId(String min_id, Conversation conversationToUpdate) { + min_id_fetch_more = min_id; + route(FragmentMastodonTimeline.DIRECTION.TOP, true, conversationToUpdate); + } + + @Override + public void onClickMaxId(String max_id, Conversation conversationToUpdate) { + max_id_fetch_more = max_id; + route(FragmentMastodonTimeline.DIRECTION.BOTTOM, true, conversationToUpdate); + } + + } \ No newline at end of file diff --git a/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonNotification.java b/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonNotification.java index 33690c2c..07ade85f 100644 --- a/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonNotification.java +++ b/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonNotification.java @@ -88,7 +88,6 @@ public class FragmentMastodonNotification extends Fragment implements Notificati }; private String max_id, min_id, min_id_fetch_more, max_id_fetch_more; private LinearLayoutManager mLayoutManager; - private ArrayList idOfAddedNotifications; private NotificationTypeEnum notificationType; private boolean aggregateNotification; @@ -121,7 +120,7 @@ public class FragmentMastodonNotification extends Fragment implements Notificati int position = 0; boolean found = false; for (Notification _notification : notificationList) { - if (_notification.status != null && _notification.id.compareTo(notification.id) == 0) { + if (_notification != null && _notification.id.compareTo(notification.id) == 0) { found = true; break; } @@ -134,7 +133,6 @@ public class FragmentMastodonNotification extends Fragment implements Notificati ViewGroup container, Bundle savedInstanceState) { flagLoading = false; - idOfAddedNotifications = new ArrayList<>(); binding = FragmentPaginationBinding.inflate(inflater, container, false); View root = binding.getRoot(); if (getArguments() != null) { @@ -142,8 +140,6 @@ public class FragmentMastodonNotification extends Fragment implements Notificati } aggregateNotification = false; binding.getRoot().setBackgroundColor(ThemeHelper.getBackgroundColor(requireActivity())); - SharedPreferences sharedpreferences = PreferenceManager.getDefaultSharedPreferences(requireActivity()); - String excludedCategories = sharedpreferences.getString(getString(R.string.SET_EXCLUDED_NOTIFICATIONS_TYPE) + BaseMainActivity.currentUserID + BaseMainActivity.currentInstance, null); int c1 = getResources().getColor(R.color.cyanea_accent_reference); binding.swipeContainer.setProgressBackgroundColorSchemeColor(getResources().getColor(R.color.cyanea_primary_reference)); binding.swipeContainer.setColorSchemeColors( @@ -222,9 +218,7 @@ public class FragmentMastodonNotification extends Fragment implements Notificati binding.noAction.setVisibility(View.GONE); binding.recyclerView.setVisibility(View.VISIBLE); } - for (Notification notification : notifications.notifications) { - idOfAddedNotifications.add(notification.id); - } + flagLoading = notifications.pagination.max_id == null; if (aggregateNotification) { notifications.notifications = aggregateNotifications(notifications.notifications); @@ -455,6 +449,7 @@ public class FragmentMastodonNotification extends Fragment implements Notificati StatusCache statusCache = new StatusCache(); statusCache.instance = BaseMainActivity.currentInstance; statusCache.user_id = BaseMainActivity.currentUserID; + notificationToUpdate.isFetchMore = false; statusCache.notification = notificationToUpdate; statusCache.status_id = notificationToUpdate.id; try { diff --git a/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonTimeline.java b/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonTimeline.java index 0dcb1b36..a910b9a6 100644 --- a/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonTimeline.java +++ b/app/src/main/java/app/fedilab/android/ui/fragment/timeline/FragmentMastodonTimeline.java @@ -396,6 +396,7 @@ public class FragmentMastodonTimeline extends Fragment implements StatusAdapter. StatusCache statusCache = new StatusCache(); statusCache.instance = BaseMainActivity.currentInstance; statusCache.user_id = BaseMainActivity.currentUserID; + statusToUpdate.isFetchMore = false; statusCache.status = statusToUpdate; statusCache.status_id = statusToUpdate.id; try { diff --git a/app/src/main/java/app/fedilab/android/viewmodel/mastodon/NotificationsVM.java b/app/src/main/java/app/fedilab/android/viewmodel/mastodon/NotificationsVM.java index df85ae43..fcdd9e57 100644 --- a/app/src/main/java/app/fedilab/android/viewmodel/mastodon/NotificationsVM.java +++ b/app/src/main/java/app/fedilab/android/viewmodel/mastodon/NotificationsVM.java @@ -115,6 +115,7 @@ public class NotificationsVM extends AndroidViewModel { notifications.pagination = MastodonHelper.getPagination(notificationsResponse.headers()); if (notifications.notifications != null && notifications.notifications.size() > 0) { + addFetchMoreNotifications(notifications.notifications, notificationList, timelineParams); for (Notification notification : notifications.notifications) { StatusCache statusCacheDAO = new StatusCache(getApplication().getApplicationContext()); StatusCache statusCache = new StatusCache(); @@ -130,7 +131,6 @@ public class NotificationsVM extends AndroidViewModel { e.printStackTrace(); } } - addFetchMoreNotifications(notifications.notifications, notificationList, timelineParams); } } } catch (Exception e) { diff --git a/app/src/main/java/app/fedilab/android/viewmodel/mastodon/TimelinesVM.java b/app/src/main/java/app/fedilab/android/viewmodel/mastodon/TimelinesVM.java index c2c45270..4cdc7aab 100644 --- a/app/src/main/java/app/fedilab/android/viewmodel/mastodon/TimelinesVM.java +++ b/app/src/main/java/app/fedilab/android/viewmodel/mastodon/TimelinesVM.java @@ -363,6 +363,27 @@ public class TimelinesVM extends AndroidViewModel { } } + private static void addFetchMoreConversation(List conversationList, List timelineConversations, TimelineParams timelineParams) throws DBException { + if (conversationList != null && conversationList.size() > 0 && timelineConversations != null && timelineConversations.size() > 0) { + if (timelineParams.direction == FragmentMastodonTimeline.DIRECTION.REFRESH || timelineParams.direction == FragmentMastodonTimeline.DIRECTION.SCROLL_TOP) { + //When refreshing/scrolling to TOP, if last statuses fetched has a greater id from newest in cache, there is potential hole + if (conversationList.get(conversationList.size() - 1).id.compareToIgnoreCase(timelineConversations.get(0).id) > 0) { + conversationList.get(conversationList.size() - 1).isFetchMore = true; + conversationList.get(conversationList.size() - 1).positionFetchMore = Conversation.PositionFetchMore.TOP; + } + } else if (timelineParams.direction == FragmentMastodonTimeline.DIRECTION.TOP && timelineParams.fetchingMissing) { + if (!timelineConversations.contains(conversationList.get(0))) { + conversationList.get(0).isFetchMore = true; + conversationList.get(0).positionFetchMore = Conversation.PositionFetchMore.BOTTOM; + } + } else if (timelineParams.direction == FragmentMastodonTimeline.DIRECTION.BOTTOM && timelineParams.fetchingMissing) { + if (!timelineConversations.contains(conversationList.get(conversationList.size() - 1))) { + conversationList.get(conversationList.size() - 1).isFetchMore = true; + conversationList.get(conversationList.size() - 1).positionFetchMore = Conversation.PositionFetchMore.TOP; + } + } + } + } public LiveData getTimeline(List timelineStatuses, TimelineParams timelineParams) { @@ -397,6 +418,7 @@ public class TimelinesVM extends AndroidViewModel { statuses.statuses = TimelineHelper.filterStatus(getApplication().getApplicationContext(), statusList, timelineParams.type); statuses.pagination = MastodonHelper.getPagination(timelineResponse.headers()); if (statusList != null && statusList.size() > 0) { + addFetchMore(statusList, timelineStatuses, timelineParams); for (Status status : statuses.statuses) { StatusCache statusCacheDAO = new StatusCache(getApplication().getApplicationContext()); StatusCache statusCache = new StatusCache(); @@ -411,7 +433,6 @@ public class TimelinesVM extends AndroidViewModel { e.printStackTrace(); } } - addFetchMore(statusList, timelineStatuses, timelineParams); } } } catch (Exception e) { @@ -476,28 +497,46 @@ public class TimelinesVM extends AndroidViewModel { return statusDraftListMutableLiveData; } + /** * Show conversations * * @return {@link LiveData} containing a {@link Conversations} */ - public LiveData getConversations(@NonNull String instance, String token, - String maxId, - String sinceId, - String minId, - int limit) { + public LiveData getConversations(List conversationsTimeline, TimelineParams timelineParams) { conversationListMutableLiveData = new MutableLiveData<>(); - MastodonTimelinesService mastodonTimelinesService = init(instance); + MastodonTimelinesService mastodonTimelinesService = init(timelineParams.instance); new Thread(() -> { Conversations conversations = null; - Call> conversationsCall = mastodonTimelinesService.getConversations(token, maxId, sinceId, minId, limit); + Call> conversationsCall = mastodonTimelinesService.getConversations(timelineParams.token, timelineParams.maxId, timelineParams.sinceId, timelineParams.minId, timelineParams.limit); if (conversationsCall != null) { conversations = new Conversations(); try { Response> conversationsResponse = conversationsCall.execute(); if (conversationsResponse.isSuccessful()) { - conversations.conversations = conversationsResponse.body(); + List conversationList = conversationsResponse.body(); + conversations.conversations = conversationList; conversations.pagination = MastodonHelper.getPagination(conversationsResponse.headers()); + + if (conversationList != null && conversationList.size() > 0) { + + addFetchMoreConversation(conversationList, conversationsTimeline, timelineParams); + + for (Conversation conversation : conversations.conversations) { + StatusCache statusCacheDAO = new StatusCache(getApplication().getApplicationContext()); + StatusCache statusCache = new StatusCache(); + statusCache.instance = timelineParams.instance; + statusCache.user_id = timelineParams.userId; + statusCache.conversation = conversation; + statusCache.type = Timeline.TimeLineEnum.CONVERSATION; + statusCache.status_id = conversation.id; + try { + statusCacheDAO.insertOrUpdate(statusCache, timelineParams.slug); + } catch (DBException e) { + e.printStackTrace(); + } + } + } } } catch (Exception e) { e.printStackTrace(); @@ -512,6 +551,36 @@ public class TimelinesVM extends AndroidViewModel { return conversationListMutableLiveData; } + + public LiveData getConversationsCache(List timelineConversations, TimelineParams timelineParams) { + conversationListMutableLiveData = new MutableLiveData<>(); + new Thread(() -> { + StatusCache statusCacheDAO = new StatusCache(getApplication().getApplicationContext()); + Conversations conversations = null; + try { + conversations = statusCacheDAO.getConversations(timelineParams.instance, timelineParams.userId, timelineParams.maxId, timelineParams.minId, timelineParams.sinceId); + if (conversations != null) { + if (conversations.conversations != null && conversations.conversations.size() > 0) { + for (Conversation conversation : conversations.conversations) { + conversation.cached = true; + } + addFetchMoreConversation(conversations.conversations, timelineConversations, timelineParams); + conversations.pagination = new Pagination(); + conversations.pagination.min_id = conversations.conversations.get(0).id; + conversations.pagination.max_id = conversations.conversations.get(conversations.conversations.size() - 1).id; + } + } + } catch (DBException e) { + e.printStackTrace(); + } + Handler mainHandler = new Handler(Looper.getMainLooper()); + Conversations finalConversations = conversations; + Runnable myRunnable = () -> conversationListMutableLiveData.setValue(finalConversations); + mainHandler.post(myRunnable); + }).start(); + return conversationListMutableLiveData; + } + /** * Remove conversation * diff --git a/app/src/main/res/layout/drawer_conversation.xml b/app/src/main/res/layout/drawer_conversation.xml index 1c903cd3..722c67dd 100644 --- a/app/src/main/res/layout/drawer_conversation.xml +++ b/app/src/main/res/layout/drawer_conversation.xml @@ -32,8 +32,20 @@ + + + +