diff --git a/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java b/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java new file mode 100644 index 000000000..911a46273 --- /dev/null +++ b/mastodon/src/androidTest/java/org/joinmastodon/android/fragments/ThreadFragmentTest.java @@ -0,0 +1,58 @@ +package org.joinmastodon.android.fragments; + +import static org.junit.Assert.*; + +import android.util.Pair; + +import org.joinmastodon.android.model.Status; +import org.joinmastodon.android.model.StatusContext; +import org.junit.Test; + +import java.util.List; +import java.util.stream.Collectors; + +public class ThreadFragmentTest { + + private Status fakeStatus(String id, String inReplyTo) { + Status status = Status.ofFake(id, null, null); + status.inReplyToId = inReplyTo; + return status; + } + + @Test + public void countAncestryLevels() { + StatusContext context = new StatusContext(); + context.ancestors = List.of( + fakeStatus("oldest ancestor", null), + fakeStatus("younger ancestor", "oldest ancestor") + ); + context.descendants = List.of( + fakeStatus("first reply", "main"), + fakeStatus("reply to first reply", "first reply"), + fakeStatus("third level reply", "reply to first reply"), + fakeStatus("another reply", "main") + ); + List> actual = + ThreadFragment.countAncestryLevels("main status", context); + + List> expected = List.of( + Pair.create("oldest ancestor", -2), + Pair.create("younger ancestor", -1), + Pair.create("main status", 0), + Pair.create("first reply", 1), + Pair.create("reply to first reply", 2), + Pair.create("third level reply", 3), + Pair.create("another reply", 1) + ); + assertEquals( + "status ids are in the right order", + expected.stream().map(p -> p.first).collect(Collectors.toList()), + actual.stream().map(p -> p.first).collect(Collectors.toList()) + ); + assertEquals( + "counted levels match", + expected.stream().map(p -> p.second).collect(Collectors.toList()), + actual.stream().map(p -> p.second).collect(Collectors.toList()) + ); + } +} \ No newline at end of file diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java index 173ae6f82..fb0e495f2 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/BaseStatusListFragment.java @@ -35,6 +35,7 @@ import org.joinmastodon.android.model.Relationship; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.ui.BetterItemAnimator; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.GapStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.HeaderStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.MediaGridStatusDisplayItem; @@ -132,7 +133,7 @@ public abstract class BaseStatusListFragment exten displayItems.clear(); } - protected void prependItems(List items, boolean notify){ + protected int prependItems(List items, boolean notify){ data.addAll(0, items); int offset=0; for(T s:items){ @@ -145,6 +146,7 @@ public abstract class BaseStatusListFragment exten } if(notify) adapter.notifyItemRangeInserted(0, offset); + return offset; } protected String getMaxID(){ @@ -355,7 +357,12 @@ public abstract class BaseStatusListFragment exten 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); + int bottom = tmpRect.bottom; + if (holder instanceof FooterStatusDisplayItem.Holder fh + && fh.getItem().hasDescendantSibling) { + bottom += V.dp(8); + } + outRect.bottom=Math.max(outRect.bottom, bottom); } } } @@ -772,6 +779,7 @@ public abstract class BaseStatusListFragment exten RecyclerView.ViewHolder siblingHolder=parent.getChildViewHolder(bottomSibling); if(holder instanceof StatusDisplayItem.Holder ih && siblingHolder instanceof StatusDisplayItem.Holder sh && (!ih.getItemID().equals(sh.getItemID()) || sh instanceof ExtendedFooterStatusDisplayItem.Holder) && ih.getItem().getType()!=StatusDisplayItem.Type.GAP){ + if (ih.getItem().descendantLevel != 0 && ih.getItem().hasDescendantSibling) continue; drawDivider(child, bottomSibling, holder, siblingHolder, parent, c, dividerPaint); } } diff --git a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java index 0b6de10e8..f70d17ed0 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java +++ b/mastodon/src/main/java/org/joinmastodon/android/fragments/ThreadFragment.java @@ -2,20 +2,19 @@ package org.joinmastodon.android.fragments; import android.net.Uri; import android.os.Bundle; +import android.util.Pair; import android.view.View; import org.joinmastodon.android.R; import org.joinmastodon.android.api.requests.statuses.GetStatusContext; -import org.joinmastodon.android.api.session.AccountSession; -import org.joinmastodon.android.api.session.AccountSessionManager; import org.joinmastodon.android.events.StatusCreatedEvent; import org.joinmastodon.android.model.Account; import org.joinmastodon.android.model.Filter; -import org.joinmastodon.android.model.Instance; import org.joinmastodon.android.model.Status; import org.joinmastodon.android.model.StatusContext; import org.joinmastodon.android.ui.displayitems.ExtendedFooterStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.FooterStatusDisplayItem; +import org.joinmastodon.android.ui.displayitems.ReblogOrReplyLineStatusDisplayItem; import org.joinmastodon.android.ui.displayitems.StatusDisplayItem; import org.joinmastodon.android.ui.displayitems.TextStatusDisplayItem; import org.joinmastodon.android.ui.text.HtmlParser; @@ -24,9 +23,14 @@ import org.joinmastodon.android.utils.ProvidesAssistContent; import org.joinmastodon.android.utils.StatusFilterPredicate; import org.parceler.Parcels; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Collections; +import java.util.Deque; +import java.util.HashMap; import java.util.List; +import java.util.Map; +import java.util.Optional; import java.util.stream.Collectors; import me.grishka.appkit.api.SimpleCallback; @@ -34,6 +38,24 @@ import me.grishka.appkit.api.SimpleCallback; public class ThreadFragment extends StatusListFragment implements ProvidesAssistContent { protected Status mainStatus; + /** + * lists the hierarchy of ancestors and descendants in a thread. level 0 = the main status. + * e.g. + *
+	 * [0] ancestor:   -2 ↰
+	 * [1] ancestor:     -1 ↰
+	 * [2] main status:     0 ↰
+	 * [3] descendant:        1 ↰
+	 * [4] descendant:          2 ↰
+	 * [5] descendant:            3
+	 * [6] descendant:        1
+	 * [7] descendant:        1 ↰
+	 * [8] descendant:          2
+	 * 
+ * confused? good. /j + */ + private final List> levels = new ArrayList<>(); + @Override public void onCreate(Bundle savedInstanceState){ super.onCreate(savedInstanceState); @@ -49,13 +71,38 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist @Override protected List buildDisplayItems(Status s){ List items=super.buildDisplayItems(s); - if(s.id.equals(mainStatus.id)){ - for(StatusDisplayItem item:items){ + // "what the fuck is a deque"? yes + // (it's just so the last-added item automatically comes first when looping over it) + Deque deleteTheseItems = new ArrayDeque<>(); + for(int i = 0; i < items.size(); i++){ + StatusDisplayItem item = items.get(i); + + Optional> levelForStatus = + levels.stream().filter(p -> p.first.equals(s.id)).findAny(); + item.descendantLevel = levelForStatus.map(p -> p.second).orElse(0); + if (levelForStatus.isPresent()) { + int idx = levels.indexOf(levelForStatus.get()); + item.hasDescendantSibling = (levels.size() > idx + 1) + && levels.get(idx + 1).second > levelForStatus.get().second; + item.isDescendantSibling = (idx - 1 >= 0) + && levels.get(idx - 1).second < levelForStatus.get().second; + } + + if (item instanceof ReblogOrReplyLineStatusDisplayItem + && item.isDescendantSibling + && item.descendantLevel != 1) { + deleteTheseItems.add(i); + } + + if(s.id.equals(mainStatus.id)){ if(item instanceof TextStatusDisplayItem text) text.textSelectable=true; else if(item instanceof FooterStatusDisplayItem footer) footer.hideCounts=true; } + } + for (int deleteThisItem : deleteTheseItems) items.remove(deleteThisItem); + if(s.id.equals(mainStatus.id)) { items.add(new ExtendedFooterStatusDisplayItem(s.id, this, s.getContentStatus())); } return items; @@ -70,6 +117,7 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist if (getActivity() == null) return; if(refreshing){ data.clear(); + levels.clear(); displayItems.clear(); data.add(mainStatus); onAppendItems(Collections.singletonList(mainStatus)); @@ -98,6 +146,9 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist } result.descendants=filterStatuses(result.descendants); result.ancestors=filterStatuses(result.ancestors); + + levels.addAll(countAncestryLevels(mainStatus.id, result)); + if(footerProgress!=null) footerProgress.setVisibility(View.GONE); data.addAll(result.descendants); @@ -106,7 +157,12 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist int count=displayItems.size(); if(!refreshing) adapter.notifyItemRangeInserted(prevCount, count-prevCount); - prependItems(result.ancestors, !refreshing); + int prependedCount = prependItems(result.ancestors, !refreshing); + if (prependedCount > 0 && displayItems.get(prependedCount) instanceof ReblogOrReplyLineStatusDisplayItem) { + displayItems.remove(prependedCount); + adapter.notifyItemRemoved(prependedCount); + count--; + } dataLoaded(); if(refreshing){ refreshDone(); @@ -118,6 +174,28 @@ public class ThreadFragment extends StatusListFragment implements ProvidesAssist .exec(accountID); } + public static List> countAncestryLevels(String mainStatusID, StatusContext context) { + List> levels = new ArrayList<>(); + + for (int i = 0; i < context.ancestors.size(); i++) { + levels.add(Pair.create( + context.ancestors.get(i).id, + -context.ancestors.size() + i // -3, -2, -1 + )); + } + + levels.add(Pair.create(mainStatusID, 0)); + Map levelPerStatus = new HashMap<>(); + + // sum up the amounts of descendants per status + context.descendants.forEach(s -> levelPerStatus.put(s.id, + levelPerStatus.getOrDefault(s.inReplyToId, 0) + 1)); + context.descendants.forEach(s -> + levels.add(Pair.create(s.id, levelPerStatus.get(s.id)))); + + return levels; + } + private List getDescendantsOrdered(String id, List statuses){ List out=new ArrayList<>(); for(Status s:getDirectDescendants(id, statuses)){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java index 9a5a2f3eb..d4f84da2e 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/FooterStatusDisplayItem.java @@ -134,12 +134,20 @@ public class FooterStatusDisplayItem extends StatusDisplayItem{ bindButton(reply, item.status.repliesCount); bindButton(boost, item.status.reblogsCount); bindButton(favorite, item.status.favouritesCount); - reply.setSelected(item.status.repliesCount > 0); + // in thread view, direct descendant posts display one direct reply to themselves, + // hence in that case displaying whether there is another reply + reply.setSelected(item.status.repliesCount > (item.descendantLevel > 0 ? 1 : 0)); boost.setSelected(item.status.reblogged); favorite.setSelected(item.status.favourited); bookmark.setSelected(item.status.bookmarked); boost.setEnabled(item.status.visibility==StatusPrivacy.PUBLIC || item.status.visibility==StatusPrivacy.UNLISTED || item.status.visibility==StatusPrivacy.LOCAL || (item.status.visibility==StatusPrivacy.PRIVATE && item.status.account.id.equals(AccountSessionManager.getInstance().getAccount(item.accountID).self.id))); + + ViewGroup.MarginLayoutParams params = (ViewGroup.MarginLayoutParams) itemView.getLayoutParams(); + params.setMargins(params.leftMargin, params.topMargin, params.rightMargin, + item.descendantLevel != 0 && item.hasDescendantSibling ? V.dp(-6) : 0); + + itemView.requestLayout(); } private void bindButton(TextView btn, long count){ diff --git a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java index be3b0a779..edc3f6861 100644 --- a/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java +++ b/mastodon/src/main/java/org/joinmastodon/android/ui/displayitems/StatusDisplayItem.java @@ -49,6 +49,8 @@ public abstract class StatusDisplayItem{ public final BaseStatusListFragment parentFragment; public boolean inset; public int index; + public int descendantLevel; + public boolean hasDescendantSibling, isDescendantSibling; public StatusDisplayItem(String parentID, BaseStatusListFragment parentFragment){ this.parentID=parentID;