visually connect descendant replies in threads

closes sk22#256
closes sk22#510
This commit is contained in:
sk
2023-06-02 00:55:42 +02:00
parent e175a721d4
commit 3985de5b14
5 changed files with 163 additions and 9 deletions

View File

@@ -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<Pair<String, Integer>> actual =
ThreadFragment.countAncestryLevels("main status", context);
List<Pair<String, Integer>> 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())
);
}
}

View File

@@ -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<T extends DisplayItemsParent> exten
displayItems.clear();
}
protected void prependItems(List<T> items, boolean notify){
protected int prependItems(List<T> items, boolean notify){
data.addAll(0, items);
int offset=0;
for(T s:items){
@@ -145,6 +146,7 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> exten
}
if(notify)
adapter.notifyItemRangeInserted(0, offset);
return offset;
}
protected String getMaxID(){
@@ -355,7 +357,12 @@ public abstract class BaseStatusListFragment<T extends DisplayItemsParent> 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<T extends DisplayItemsParent> 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);
}
}

View File

@@ -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.
* <pre>
* [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
* </pre>
* confused? good. /j
*/
private final List<Pair<String, Integer>> 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<StatusDisplayItem> buildDisplayItems(Status s){
List<StatusDisplayItem> items=super.buildDisplayItems(s);
// "what the fuck is a deque"? yes
// (it's just so the last-added item automatically comes first when looping over it)
Deque<Integer> deleteTheseItems = new ArrayDeque<>();
for(int i = 0; i < items.size(); i++){
StatusDisplayItem item = items.get(i);
Optional<Pair<String, Integer>> 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)){
for(StatusDisplayItem item:items){
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<Pair<String, Integer>> countAncestryLevels(String mainStatusID, StatusContext context) {
List<Pair<String, Integer>> 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<String, Integer> 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<Status> getDescendantsOrdered(String id, List<Status> statuses){
List<Status> out=new ArrayList<>();
for(Status s:getDirectDescendants(id, statuses)){

View File

@@ -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){

View File

@@ -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;