From 9ce95d6de74ed85235b87af43ded5ebf06701b7b Mon Sep 17 00:00:00 2001
From: Torsten Grote <t@grobox.de>
Date: Mon, 10 Oct 2016 10:04:28 -0300
Subject: [PATCH] Refactor Forum Activity and adapters to be re-used for
 private groups

---
 briar-android/res/layout/activity_forum.xml   |   6 +-
 .../android/forum/ForumActivity.java          | 233 +++-------
 .../android/forum/ForumEntry.java             |  90 +---
 .../android/forum/NestedForumAdapter.java     | 436 +-----------------
 .../android/forum/NestedForumHolder.java      |  13 +
 .../conversation/GroupMessageItem.java        |  22 +
 .../{util => threaded}/NestedTreeList.java    |   2 +-
 .../android/threaded/ThreadItem.java          |  95 ++++
 .../android/threaded/ThreadItemAdapter.java   | 298 ++++++++++++
 .../threaded/ThreadItemViewHolder.java        | 182 ++++++++
 .../android/threaded/ThreadListActivity.java  | 205 ++++++++
 .../android/forum/ForumActivityTest.java      |   8 +-
 .../android/forum/TestForumActivity.java      |   2 +-
 13 files changed, 894 insertions(+), 698 deletions(-)
 create mode 100644 briar-android/src/org/briarproject/android/forum/NestedForumHolder.java
 create mode 100644 briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageItem.java
 rename briar-android/src/org/briarproject/android/{util => threaded}/NestedTreeList.java (96%)
 create mode 100644 briar-android/src/org/briarproject/android/threaded/ThreadItem.java
 create mode 100644 briar-android/src/org/briarproject/android/threaded/ThreadItemAdapter.java
 create mode 100644 briar-android/src/org/briarproject/android/threaded/ThreadItemViewHolder.java
 create mode 100644 briar-android/src/org/briarproject/android/threaded/ThreadListActivity.java

diff --git a/briar-android/res/layout/activity_forum.xml b/briar-android/res/layout/activity_forum.xml
index b796da899f..81c7e4a2bb 100644
--- a/briar-android/res/layout/activity_forum.xml
+++ b/briar-android/res/layout/activity_forum.xml
@@ -7,12 +7,12 @@
 	android:orientation="vertical">
 
 	<org.briarproject.android.view.BriarRecyclerView
-		android:id="@+id/forum_discussion_list"
+		android:id="@+id/list"
 		android:layout_width="match_parent"
 		android:layout_height="0dp"
 		android:layout_weight="1"
-		app:scrollToEnd="false"
-		app:emptyText="@string/no_forum_posts"/>
+		app:emptyText="@string/no_forum_posts"
+		app:scrollToEnd="false"/>
 
 	<org.briarproject.android.view.TextInputView
 		android:id="@+id/text_input_container"
diff --git a/briar-android/src/org/briarproject/android/forum/ForumActivity.java b/briar-android/src/org/briarproject/android/forum/ForumActivity.java
index 1cb6662fec..0d74863a87 100644
--- a/briar-android/src/org/briarproject/android/forum/ForumActivity.java
+++ b/briar-android/src/org/briarproject/android/forum/ForumActivity.java
@@ -3,36 +3,28 @@ package org.briarproject.android.forum;
 import android.content.DialogInterface;
 import android.content.Intent;
 import android.os.Bundle;
+import android.support.annotation.LayoutRes;
 import android.support.annotation.Nullable;
-import android.support.design.widget.Snackbar;
 import android.support.v4.app.ActivityCompat;
 import android.support.v4.app.ActivityOptionsCompat;
-import android.support.v4.content.ContextCompat;
 import android.support.v7.app.AlertDialog;
 import android.support.v7.widget.LinearLayoutManager;
 import android.view.Menu;
 import android.view.MenuInflater;
 import android.view.MenuItem;
-import android.view.View;
 import android.widget.Toast;
 
 import org.briarproject.R;
 import org.briarproject.android.ActivityComponent;
-import org.briarproject.android.BriarActivity;
-import org.briarproject.android.api.AndroidNotificationManager;
 import org.briarproject.android.controller.handler.UiResultExceptionHandler;
 import org.briarproject.android.controller.handler.UiResultHandler;
 import org.briarproject.android.forum.ForumController.ForumPostListener;
-import org.briarproject.android.forum.NestedForumAdapter.OnNestedForumListener;
 import org.briarproject.android.sharing.ShareForumActivity;
 import org.briarproject.android.sharing.SharingStatusForumActivity;
-import org.briarproject.android.view.BriarRecyclerView;
-import org.briarproject.android.view.TextInputView;
-import org.briarproject.android.view.TextInputView.TextInputListener;
+import org.briarproject.android.threaded.ThreadListActivity;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.forum.Forum;
 import org.briarproject.api.forum.ForumPostHeader;
-import org.briarproject.api.sync.GroupId;
 import org.briarproject.util.StringUtils;
 
 import java.util.ArrayList;
@@ -42,56 +34,32 @@ import javax.inject.Inject;
 
 import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP;
 import static android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP;
-import static android.view.View.GONE;
-import static android.view.View.VISIBLE;
+import static android.support.v4.app.ActivityOptionsCompat.makeCustomAnimation;
 import static android.widget.Toast.LENGTH_SHORT;
 
-public class ForumActivity extends BriarActivity implements
-		ForumPostListener, TextInputListener, OnNestedForumListener {
+public class ForumActivity extends ThreadListActivity<ForumEntry, NestedForumAdapter>
+		implements ForumPostListener {
 
 	static final String FORUM_NAME = "briar.FORUM_NAME";
 
 	private static final int REQUEST_FORUM_SHARED = 3;
-	private static final String KEY_INPUT_VISIBILITY = "inputVisibility";
-	private static final String KEY_REPLY_ID = "replyId";
-
-	@Inject
-	AndroidNotificationManager notificationManager;
 
 	@Inject
 	protected ForumController forumController;
 
-	// Protected access for testing
-	protected NestedForumAdapter forumAdapter;
-
-	private BriarRecyclerView recyclerView;
-	private TextInputView textInput;
-
-	private volatile GroupId groupId = null;
+	@Override
+	public void injectActivity(ActivityComponent component) {
+		component.inject(this);
+	}
 
 	@Override
 	public void onCreate(final Bundle state) {
 		super.onCreate(state);
 
-		setContentView(R.layout.activity_forum);
-
 		Intent i = getIntent();
-		byte[] b = i.getByteArrayExtra(GROUP_ID);
-		if (b == null) throw new IllegalStateException();
-		groupId = new GroupId(b);
 		String forumName = i.getStringExtra(FORUM_NAME);
 		if (forumName != null) setTitle(forumName);
 
-		textInput = (TextInputView) findViewById(R.id.text_input_container);
-		textInput.setVisibility(GONE);
-		textInput.setListener(this);
-		recyclerView =
-				(BriarRecyclerView) findViewById(R.id.forum_discussion_list);
-		LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
-		recyclerView.setLayoutManager(linearLayoutManager);
-		forumAdapter = new NestedForumAdapter(this, this, linearLayoutManager);
-		recyclerView.setAdapter(forumAdapter);
-
 		forumController.loadForum(groupId,
 				new UiResultExceptionHandler<List<ForumEntry>, DbException>(
 						this) {
@@ -101,14 +69,15 @@ public class ForumActivity extends BriarActivity implements
 						if (forum != null) setTitle(forum.getName());
 						List<ForumEntry> entries = new ArrayList<>(result);
 						if (entries.isEmpty()) {
-							recyclerView.showData();
+							list.showData();
 						} else {
-							forumAdapter.setEntries(entries);
+							adapter.setItems(entries);
+							list.showData();
 							if (state != null) {
 								byte[] replyId =
 										state.getByteArray(KEY_REPLY_ID);
 								if (replyId != null)
-									forumAdapter.setReplyEntryById(replyId);
+									adapter.setReplyItemById(replyId);
 							}
 						}
 					}
@@ -122,36 +91,20 @@ public class ForumActivity extends BriarActivity implements
 	}
 
 	@Override
-	protected void onRestoreInstanceState(Bundle savedInstanceState) {
-		super.onRestoreInstanceState(savedInstanceState);
-		textInput.setVisibility(
-				savedInstanceState.getBoolean(KEY_INPUT_VISIBILITY) ?
-						VISIBLE : GONE);
+	protected @LayoutRes int getLayout() {
+		return R.layout.activity_forum;
 	}
 
-
 	@Override
-	protected void onSaveInstanceState(Bundle outState) {
-		super.onSaveInstanceState(outState);
-		outState.putBoolean(KEY_INPUT_VISIBILITY,
-				textInput.getVisibility() == VISIBLE);
-		ForumEntry replyEntry = forumAdapter.getReplyEntry();
-		if (replyEntry != null) {
-			outState.putByteArray(KEY_REPLY_ID,
-					replyEntry.getMessageId().getBytes());
-		}
+	protected NestedForumAdapter createAdapter(
+			LinearLayoutManager layoutManager) {
+		return new NestedForumAdapter(this, layoutManager);
 	}
 
 	@Override
-	public void injectActivity(ActivityComponent component) {
-		component.inject(this);
-	}
-
-	private void displaySnackbarShort(int stringId) {
-		Snackbar snackbar =
-				Snackbar.make(recyclerView, stringId, Snackbar.LENGTH_SHORT);
-		snackbar.getView().setBackgroundResource(R.color.briar_primary);
-		snackbar.show();
+	public void onResume() {
+		super.onResume();
+		notificationManager.clearForumPostNotification(groupId);
 	}
 
 	@Override
@@ -172,34 +125,10 @@ public class ForumActivity extends BriarActivity implements
 		return super.onCreateOptionsMenu(menu);
 	}
 
-	@Override
-	public void onBackPressed() {
-		if (textInput.getVisibility() == VISIBLE) {
-			textInput.setVisibility(GONE);
-			forumAdapter.setReplyEntry(null);
-		} else {
-			super.onBackPressed();
-		}
-	}
-
-	private void showTextInput(@Nullable ForumEntry replyEntry) {
-		// An animation here would be an overkill because of the keyboard
-		// popping up.
-		// only clear the text when the input container was not visible
-		if (textInput.getVisibility() != VISIBLE) {
-			textInput.setVisibility(VISIBLE);
-			textInput.setText("");
-		}
-		textInput.showSoftKeyboard();
-		textInput.setHint(replyEntry == null ? R.string.forum_new_message_hint :
-				R.string.forum_message_reply_hint);
-		forumAdapter.setReplyEntry(replyEntry);
-	}
-
 	@Override
 	public boolean onOptionsItemSelected(final MenuItem item) {
-		ActivityOptionsCompat options = ActivityOptionsCompat
-				.makeCustomAnimation(this, android.R.anim.slide_in_left,
+		ActivityOptionsCompat options =
+				makeCustomAnimation(this, android.R.anim.slide_in_left,
 						android.R.anim.slide_out_right);
 		// Handle presses on the action bar items
 		switch (item.getItemId()) {
@@ -228,31 +157,33 @@ public class ForumActivity extends BriarActivity implements
 		}
 	}
 
-	@Override
-	public void onResume() {
-		super.onResume();
-		notificationManager.blockNotification(groupId);
-		notificationManager.clearForumPostNotification(groupId);
-		recyclerView.startPeriodicUpdate();
+	protected void markItemRead(ForumEntry entry) {
+		forumController.entryRead(entry);
 	}
 
 	@Override
-	public void onPause() {
-		super.onPause();
-		notificationManager.unblockNotification(groupId);
-		recyclerView.stopPeriodicUpdate();
+	public void onForumPostReceived(ForumPostHeader header) {
+		forumController.loadPost(header,
+				new UiResultExceptionHandler<ForumEntry, DbException>(this) {
+					@Override
+					public void onResultUi(final ForumEntry result) {
+						addItem(result, false);
+					}
+
+					@Override
+					public void onExceptionUi(DbException exception) {
+						// TODO add proper exception handling
+					}
+				});
 	}
 
 	@Override
-	public void onSendClick(String text) {
-		if (text.trim().length() == 0)
-			return;
-		ForumEntry replyEntry = forumAdapter.getReplyEntry();
-		UiResultExceptionHandler<ForumEntry, DbException> resultHandler =
+	protected void sendItem(String text, @Nullable ForumEntry replyItem) {
+		UiResultExceptionHandler<ForumEntry, DbException> handler =
 				new UiResultExceptionHandler<ForumEntry, DbException>(this) {
 					@Override
 					public void onResultUi(ForumEntry result) {
-						onForumEntryAdded(result, true);
+						addItem(result, true);
 					}
 
 					@Override
@@ -261,17 +192,28 @@ public class ForumActivity extends BriarActivity implements
 						finish();
 					}
 				};
-
-		if (replyEntry == null) {
+		if (replyItem == null) {
 			// root post
-			forumController.createPost(StringUtils.toUtf8(text), resultHandler);
+			forumController.createPost(StringUtils.toUtf8(text), handler);
 		} else {
 			forumController.createPost(StringUtils.toUtf8(text),
-					replyEntry.getId(), resultHandler);
+					replyItem.getId(), handler);
 		}
-		textInput.hideSoftKeyboard();
-		textInput.setVisibility(GONE);
-		forumAdapter.setReplyEntry(null);
+	}
+
+	@Override
+	public void onForumRemoved() {
+		supportFinishAfterTransition();
+	}
+
+	@Override
+	protected int getItemPostedString() {
+		return R.string.forum_new_entry_posted;
+	}
+
+	@Override
+	protected int getItemReceivedString() {
+		return R.string.forum_new_entry_received;
 	}
 
 	private void showUnsubscribeDialog() {
@@ -304,61 +246,4 @@ public class ForumActivity extends BriarActivity implements
 		builder.show();
 	}
 
-	@Override
-	public void onEntryVisible(ForumEntry forumEntry) {
-		if (!forumEntry.isRead()) {
-			forumEntry.setRead(true);
-			forumController.entryRead(forumEntry);
-		}
-	}
-
-	@Override
-	public void onReplyClick(ForumEntry forumEntry) {
-		showTextInput(forumEntry);
-	}
-
-	private void onForumEntryAdded(final ForumEntry entry, boolean isLocal) {
-		forumAdapter.addEntry(entry);
-		if (isLocal && forumAdapter.isVisible(entry)) {
-			displaySnackbarShort(R.string.forum_new_entry_posted);
-		} else {
-			Snackbar snackbar = Snackbar.make(recyclerView,
-					isLocal ? R.string.forum_new_entry_posted :
-							R.string.forum_new_entry_received,
-					Snackbar.LENGTH_LONG);
-			snackbar.getView().setBackgroundResource(R.color.briar_primary);
-			snackbar.setActionTextColor(ContextCompat
-					.getColor(ForumActivity.this,
-							R.color.briar_button_positive));
-			snackbar.setAction(R.string.show, new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					forumAdapter.scrollToEntry(entry);
-				}
-			});
-			snackbar.getView().setBackgroundResource(R.color.briar_primary);
-			snackbar.show();
-		}
-	}
-
-	@Override
-	public void onForumPostReceived(ForumPostHeader header) {
-		forumController.loadPost(header,
-				new UiResultExceptionHandler<ForumEntry, DbException>(this) {
-					@Override
-					public void onResultUi(final ForumEntry result) {
-						onForumEntryAdded(result, false);
-					}
-
-					@Override
-					public void onExceptionUi(DbException exception) {
-						// TODO add proper exception handling
-					}
-				});
-	}
-
-	@Override
-	public void onForumRemoved() {
-		finish();
-	}
 }
diff --git a/briar-android/src/org/briarproject/android/forum/ForumEntry.java b/briar-android/src/org/briarproject/android/forum/ForumEntry.java
index 49e1ee32f5..02817e1ab9 100644
--- a/briar-android/src/org/briarproject/android/forum/ForumEntry.java
+++ b/briar-android/src/org/briarproject/android/forum/ForumEntry.java
@@ -1,102 +1,22 @@
 package org.briarproject.android.forum;
 
-import org.briarproject.api.clients.MessageTree;
+import org.briarproject.android.threaded.ThreadItem;
 import org.briarproject.api.forum.ForumPostHeader;
 import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.Author.Status;
 import org.briarproject.api.sync.MessageId;
 
 /* This class is not thread safe */
-public class ForumEntry implements MessageTree.MessageNode {
-
-	public final static int LEVEL_UNDEFINED = -1;
-
-	private final MessageId messageId;
-	private final MessageId parentId;
-	private final String text;
-	private final long timestamp;
-	private final Author author;
-	private Status status;
-	private int level = LEVEL_UNDEFINED;
-	private boolean isShowingDescendants = true;
-	private int descendantCount = 0;
-	private boolean isRead = true;
+public class ForumEntry extends ThreadItem {
 
 	ForumEntry(ForumPostHeader h, String text) {
-		this(h.getId(), h.getParentId(), text, h.getTimestamp(), h.getAuthor(),
-				h.getAuthorStatus());
-		this.isRead = h.isRead();
+		super(h.getId(), h.getParentId(), text, h.getTimestamp(), h.getAuthor(),
+				h.getAuthorStatus(), h.isRead());
 	}
 
 	public ForumEntry(MessageId messageId, MessageId parentId, String text,
 			long timestamp, Author author, Status status) {
-		this.messageId = messageId;
-		this.parentId = parentId;
-		this.text = text;
-		this.timestamp = timestamp;
-		this.author = author;
-		this.status = status;
-	}
-
-	public String getText() {
-		return text;
-	}
-
-	public int getLevel() {
-		return level;
-	}
-
-	@Override
-	public MessageId getId() {
-		return messageId;
-	}
-
-	@Override
-	public MessageId getParentId() {
-		return parentId;
-	}
-
-	public long getTimestamp() {
-		return timestamp;
-	}
-
-	public Author getAuthor() {
-		return author;
-	}
-
-	public Status getStatus() {
-		return status;
+		super(messageId, parentId, text, timestamp, author, status, true);
 	}
 
-	boolean isShowingDescendants() {
-		return isShowingDescendants;
-	}
-
-	public void setLevel(int level) {
-		this.level = level;
-	}
-
-	void setShowingDescendants(boolean showingDescendants) {
-		this.isShowingDescendants = showingDescendants;
-	}
-
-	MessageId getMessageId() {
-		return messageId;
-	}
-
-	public boolean isRead() {
-		return isRead;
-	}
-
-	void setRead(boolean read) {
-		isRead = read;
-	}
-
-	public boolean hasDescendants() {
-		return descendantCount > 0;
-	}
-
-	public void setDescendantCount(int descendantCount) {
-		this.descendantCount = descendantCount;
-	}
 }
diff --git a/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java b/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java
index f1e2f5de26..d1cb240083 100644
--- a/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java
+++ b/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java
@@ -1,289 +1,20 @@
 package org.briarproject.android.forum;
 
-import android.animation.Animator;
-import android.animation.ArgbEvaluator;
-import android.animation.ValueAnimator;
-import android.content.Context;
-import android.graphics.drawable.ColorDrawable;
-import android.support.annotation.Nullable;
-import android.support.v4.content.ContextCompat;
+import android.support.annotation.UiThread;
 import android.support.v7.widget.LinearLayoutManager;
-import android.support.v7.widget.RecyclerView;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
-import android.widget.TextView;
 
 import org.briarproject.R;
-import org.briarproject.android.util.NestedTreeList;
-import org.briarproject.android.view.AuthorView;
-import org.briarproject.api.sync.MessageId;
-import org.briarproject.util.StringUtils;
+import org.briarproject.android.threaded.ThreadItemAdapter;
 
-import java.util.ArrayList;
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
+@UiThread
+public class NestedForumAdapter extends ThreadItemAdapter<ForumEntry> {
 
-import static android.support.v7.widget.RecyclerView.NO_POSITION;
-import static android.view.View.GONE;
-import static android.view.View.INVISIBLE;
-import static android.view.View.VISIBLE;
-
-public class NestedForumAdapter
-		extends RecyclerView.Adapter<NestedForumAdapter.NestedForumHolder> {
-
-	private static final int UNDEFINED = -1;
-
-	private final NestedTreeList<ForumEntry> forumEntries =
-			new NestedTreeList<>();
-	private final Map<ForumEntry, ValueAnimator> animatingEntries =
-			new HashMap<>();
-	// highlight not dependant on time
-	private ForumEntry replyEntry;
-	// temporary highlight
-	private ForumEntry addedEntry;
-	private final Context ctx;
-	private final OnNestedForumListener listener;
-	private final LinearLayoutManager layoutManager;
-
-	public NestedForumAdapter(Context ctx, OnNestedForumListener listener,
+	public NestedForumAdapter(ThreadItemListener<ForumEntry> listener,
 			LinearLayoutManager layoutManager) {
-		this.ctx = ctx;
-		this.listener = listener;
-		this.layoutManager = layoutManager;
-	}
-
-	ForumEntry getReplyEntry() {
-		return replyEntry;
-	}
-
-	void setEntries(List<ForumEntry> entries) {
-		forumEntries.clear();
-		forumEntries.addAll(entries);
-		notifyItemRangeInserted(0, entries.size());
-	}
-
-	void addEntry(ForumEntry entry) {
-		forumEntries.add(entry);
-		addedEntry = entry;
-		if (entry.getParentId() == null) {
-			notifyItemInserted(getVisiblePos(entry));
-		} else {
-			// Try to find the entry's parent and perform the proper ui update if
-			// it's present and visible.
-			for (int i = forumEntries.indexOf(entry) - 1; i >= 0; i--) {
-				ForumEntry higherEntry = forumEntries.get(i);
-				if (higherEntry.getLevel() < entry.getLevel()) {
-					// parent found
-					if (higherEntry.isShowingDescendants()) {
-						int parentVisiblePos = getVisiblePos(higherEntry);
-						if (parentVisiblePos != NO_POSITION) {
-							// parent is visible, we need to update its ui
-							notifyItemChanged(parentVisiblePos);
-							// new entry insert ui
-							int visiblePos = getVisiblePos(entry);
-							notifyItemInserted(visiblePos);
-							break;
-						}
-					} else {
-						// do not show the new entry if its parent is not showing
-						// descendants (this can be overridden by the user by
-						// pressing the snack bar)
-						break;
-					}
-				}
-			}
-		}
-	}
-
-	void scrollToEntry(ForumEntry entry) {
-		int visiblePos = getVisiblePos(entry);
-		if (visiblePos == NO_POSITION && entry.getParentId() != null) {
-			// The entry is not visible due to being hidden by its parent entry.
-			// Find the parent and make it visible and traverse up the parent
-			// chain if necessary to make the entry visible
-			MessageId parentId = entry.getParentId();
-			for (int i = forumEntries.indexOf(entry) - 1; i >= 0; i--) {
-				ForumEntry higherEntry = forumEntries.get(i);
-				if (higherEntry.getId().equals(parentId)) {
-					// parent found
-					showDescendants(higherEntry);
-					int parentPos = getVisiblePos(higherEntry);
-					if (parentPos != NO_POSITION) {
-						// parent or ancestor is visible, entry's visibility
-						// is ensured
-						notifyItemChanged(parentPos);
-						visiblePos = parentPos;
-						break;
-					}
-					// parent or ancestor is hidden, we need to continue up the
-					// dependency chain
-					parentId = higherEntry.getParentId();
-				}
-			}
-		}
-		if (visiblePos != NO_POSITION)
-			layoutManager.scrollToPositionWithOffset(visiblePos, 0);
-	}
-
-	private int getReplyCount(ForumEntry entry) {
-		int counter = 0;
-		int pos = forumEntries.indexOf(entry);
-		if (pos >= 0) {
-			int ancestorLvl = entry.getLevel();
-			for (int i = pos + 1; i < forumEntries.size(); i++) {
-				int descendantLvl = forumEntries.get(i).getLevel();
-				if (descendantLvl <= ancestorLvl)
-					break;
-				if (descendantLvl == ancestorLvl + 1)
-					counter++;
-			}
-		}
-		return counter;
-	}
-
-	void setReplyEntryById(byte[] id) {
-		MessageId messageId = new MessageId(id);
-		for (ForumEntry entry : forumEntries) {
-			if (entry.getId().equals(messageId)) {
-				setReplyEntry(entry);
-				break;
-			}
-		}
-	}
-
-	void setReplyEntry(@Nullable ForumEntry entry) {
-		if (replyEntry != null) {
-			notifyItemChanged(getVisiblePos(replyEntry));
-		}
-		replyEntry = entry;
-		if (replyEntry != null) {
-			notifyItemChanged(getVisiblePos(replyEntry));
-		}
-	}
-
-	private List<Integer> getSubTreeIndexes(int pos, int levelLimit) {
-		List<Integer> indexList = new ArrayList<>();
-
-		for (int i = pos + 1; i < getItemCount(); i++) {
-			ForumEntry entry = getVisibleEntry(i);
-			if (entry != null && entry.getLevel() > levelLimit) {
-				indexList.add(i);
-			} else {
-				break;
-			}
-		}
-		return indexList;
-	}
-
-	void showDescendants(ForumEntry forumEntry) {
-		forumEntry.setShowingDescendants(true);
-		int visiblePos = getVisiblePos(forumEntry);
-		List<Integer> indexList =
-				getSubTreeIndexes(visiblePos, forumEntry.getLevel());
-		if (!indexList.isEmpty()) {
-			if (indexList.size() == 1) {
-				notifyItemInserted(indexList.get(0));
-			} else {
-				notifyItemRangeInserted(indexList.get(0),
-						indexList.size());
-			}
-		}
-	}
-
-	void hideDescendants(ForumEntry forumEntry) {
-		int visiblePos = getVisiblePos(forumEntry);
-		List<Integer> indexList =
-				getSubTreeIndexes(visiblePos, forumEntry.getLevel());
-		if (!indexList.isEmpty()) {
-			// stop animating children
-			for (int index : indexList) {
-				ValueAnimator anim =
-						animatingEntries.get(forumEntries.get(index));
-				if (anim != null && anim.isRunning()) {
-					anim.cancel();
-				}
-			}
-			if (indexList.size() == 1) {
-				notifyItemRemoved(indexList.get(0));
-			} else {
-				notifyItemRangeRemoved(indexList.get(0),
-						indexList.size());
-			}
-		}
-		forumEntry.setShowingDescendants(false);
-	}
-
-
-	/**
-	 *
-	 * @param position is visible entry index
-	 * @return the visible entry at index position from an ordered list of visible
-	 * entries, or null if position is larger then the number of visible entries.
-	 */
-	@Nullable
-	ForumEntry getVisibleEntry(int position) {
-		int levelLimit = UNDEFINED;
-		for (ForumEntry forumEntry : forumEntries) {
-			if (levelLimit >= 0) {
-				// skip hidden entries that their parent is hiding
-				if (forumEntry.getLevel() > levelLimit) {
-					continue;
-				}
-				levelLimit = UNDEFINED;
-			}
-			if (!forumEntry.isShowingDescendants()) {
-				levelLimit = forumEntry.getLevel();
-			}
-			if (position-- == 0) {
-				return forumEntry;
-			}
-		}
-		return null;
-	}
-
-	private void animateFadeOut(final NestedForumHolder ui,
-			final ForumEntry addedEntry) {
-		ui.setIsRecyclable(false);
-		ValueAnimator anim = new ValueAnimator();
-		animatingEntries.put(addedEntry, anim);
-		ColorDrawable viewColor = (ColorDrawable) ui.cell.getBackground();
-		anim.setIntValues(viewColor.getColor(), ContextCompat
-				.getColor(ctx, R.color.window_background));
-		anim.setEvaluator(new ArgbEvaluator());
-		anim.addListener(new Animator.AnimatorListener() {
-			@Override
-			public void onAnimationStart(Animator animation) {
-
-			}
-
-			@Override
-			public void onAnimationEnd(Animator animation) {
-				ui.setIsRecyclable(true);
-				animatingEntries.remove(addedEntry);
-			}
-
-			@Override
-			public void onAnimationCancel(Animator animation) {
-				ui.setIsRecyclable(true);
-				animatingEntries.remove(addedEntry);
-			}
-
-			@Override
-			public void onAnimationRepeat(Animator animation) {
-
-			}
-		});
-		anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
-			@Override
-			public void onAnimationUpdate(ValueAnimator valueAnimator) {
-				ui.cell.setBackgroundColor(
-						(Integer) valueAnimator.getAnimatedValue());
-			}
-		});
-		anim.setDuration(5000);
-		anim.start();
+		super(listener, layoutManager);
 	}
 
 	@Override
@@ -294,159 +25,4 @@ public class NestedForumAdapter
 		return new NestedForumHolder(v);
 	}
 
-	@Override
-	public void onBindViewHolder(
-			final NestedForumHolder ui, final int position) {
-		final ForumEntry entry = getVisibleEntry(position);
-		if (entry == null) return;
-		listener.onEntryVisible(entry);
-
-		ui.textView.setText(StringUtils.trim(entry.getText()));
-
-		if (position == 0) {
-			ui.topDivider.setVisibility(View.INVISIBLE);
-		} else {
-			ui.topDivider.setVisibility(View.VISIBLE);
-		}
-
-		for (int i = 0; i < ui.lvls.length; i++) {
-			ui.lvls[i].setVisibility(i < entry.getLevel() ? VISIBLE : GONE);
-		}
-		if (entry.getLevel() > 5) {
-			ui.lvlText.setVisibility(VISIBLE);
-			ui.lvlText.setText("" + entry.getLevel());
-		} else {
-			ui.lvlText.setVisibility(GONE);
-		}
-		ui.author.setAuthor(entry.getAuthor());
-		ui.author.setDate(entry.getTimestamp());
-		ui.author.setAuthorStatus(entry.getStatus());
-
-		int replies = getReplyCount(entry);
-		if (replies == 0) {
-			ui.repliesText.setText("");
-		} else {
-			ui.repliesText.setText(
-					ctx.getResources()
-							.getQuantityString(R.plurals.message_replies,
-									replies, replies));
-		}
-
-		if (entry.hasDescendants()) {
-			ui.chevron.setVisibility(VISIBLE);
-			if (entry.isShowingDescendants()) {
-				ui.chevron.setSelected(false);
-			} else {
-				ui.chevron.setSelected(true);
-			}
-			ui.chevron.setOnClickListener(new View.OnClickListener() {
-				@Override
-				public void onClick(View v) {
-					ui.chevron.setSelected(!ui.chevron.isSelected());
-					if (ui.chevron.isSelected()) {
-						hideDescendants(entry);
-					} else {
-						showDescendants(entry);
-					}
-				}
-			});
-		} else {
-			ui.chevron.setVisibility(INVISIBLE);
-		}
-		if (entry.equals(replyEntry)) {
-			ui.cell.setBackgroundColor(ContextCompat
-					.getColor(ctx, R.color.forum_cell_highlight));
-		} else if (entry.equals(addedEntry)) {
-
-			ui.cell.setBackgroundColor(ContextCompat
-					.getColor(ctx, R.color.forum_cell_highlight));
-			animateFadeOut(ui, addedEntry);
-			addedEntry = null;
-		} else {
-			ui.cell.setBackgroundColor(ContextCompat
-					.getColor(ctx, R.color.window_background));
-		}
-		ui.replyButton.setOnClickListener(new View.OnClickListener() {
-			@Override
-			public void onClick(View v) {
-				listener.onReplyClick(entry);
-				scrollToEntry(entry);
-			}
-		});
-	}
-
-	public boolean isVisible(ForumEntry entry) {
-		return getVisiblePos(entry) != NO_POSITION;
-	}
-
-	/**
-	 * @param sEntry the ForumEntry to find the visible positoin of, or null to
-	 *               return the total cound of visible elements
-	 * @return the visible position of sEntry, or the total number of visible
-	 * elements if sEntry is null. If sEntry is not visible a NO_POSITION is
-	 * returned.
-	 */
-	private int getVisiblePos(@Nullable ForumEntry sEntry) {
-		int visibleCounter = 0;
-		int levelLimit = UNDEFINED;
-		for (ForumEntry fEntry : forumEntries) {
-			if (levelLimit >= 0) {
-				if (fEntry.getLevel() > levelLimit) {
-					// skip all the entries below a non visible branch
-					continue;
-				}
-				levelLimit = UNDEFINED;
-			}
-			if (sEntry != null && sEntry.equals(fEntry)) {
-				return visibleCounter;
-			} else if (!fEntry.isShowingDescendants()) {
-				levelLimit = fEntry.getLevel();
-			}
-			visibleCounter++;
-		}
-		return sEntry == null ? visibleCounter : NO_POSITION;
-	}
-
-	@Override
-	public int getItemCount() {
-		return getVisiblePos(null);
-	}
-
-	static class NestedForumHolder extends RecyclerView.ViewHolder {
-
-		private final TextView textView, lvlText, repliesText;
-		private final AuthorView author;
-		private final View[] lvls;
-		private final View chevron, replyButton;
-		private final ViewGroup cell;
-		private final View topDivider;
-
-		private NestedForumHolder(View v) {
-			super(v);
-
-			textView = (TextView) v.findViewById(R.id.text);
-			lvlText = (TextView) v.findViewById(R.id.nested_line_text);
-			author = (AuthorView) v.findViewById(R.id.author);
-			repliesText = (TextView) v.findViewById(R.id.replies);
-			int[] nestedLineIds = {
-					R.id.nested_line_1, R.id.nested_line_2, R.id.nested_line_3,
-					R.id.nested_line_4, R.id.nested_line_5
-			};
-			lvls = new View[nestedLineIds.length];
-			for (int i = 0; i < lvls.length; i++) {
-				lvls[i] = v.findViewById(nestedLineIds[i]);
-			}
-			chevron = v.findViewById(R.id.chevron);
-			replyButton = v.findViewById(R.id.btn_reply);
-			cell = (ViewGroup) v.findViewById(R.id.forum_cell);
-			topDivider = v.findViewById(R.id.top_divider);
-		}
-
-	}
-
-	interface OnNestedForumListener {
-		void onEntryVisible(ForumEntry forumEntry);
-
-		void onReplyClick(ForumEntry forumEntry);
-	}
 }
diff --git a/briar-android/src/org/briarproject/android/forum/NestedForumHolder.java b/briar-android/src/org/briarproject/android/forum/NestedForumHolder.java
new file mode 100644
index 0000000000..8263ccb4aa
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/forum/NestedForumHolder.java
@@ -0,0 +1,13 @@
+package org.briarproject.android.forum;
+
+import android.view.View;
+
+import org.briarproject.android.threaded.ThreadItemViewHolder;
+
+public class NestedForumHolder extends ThreadItemViewHolder<ForumEntry> {
+
+	public NestedForumHolder(View v) {
+		super(v);
+	}
+
+}
diff --git a/briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageItem.java b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageItem.java
new file mode 100644
index 0000000000..ee84c1fb00
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageItem.java
@@ -0,0 +1,22 @@
+package org.briarproject.android.privategroup.conversation;
+
+import org.briarproject.android.threaded.ThreadItem;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.Author.Status;
+import org.briarproject.api.privategroup.GroupMessageHeader;
+import org.briarproject.api.sync.MessageId;
+
+class GroupMessageItem extends ThreadItem {
+
+	public GroupMessageItem(MessageId messageId, MessageId parentId,
+			String text, long timestamp, Author author, Status status,
+			boolean isRead) {
+		super(messageId, parentId, text, timestamp, author, status, isRead);
+	}
+
+	public GroupMessageItem(GroupMessageHeader h, String text) {
+		this(h.getId(), h.getParentId(), text, h.getTimestamp(), h.getAuthor(),
+				h.getAuthorStatus(), h.isRead());
+	}
+
+}
diff --git a/briar-android/src/org/briarproject/android/util/NestedTreeList.java b/briar-android/src/org/briarproject/android/threaded/NestedTreeList.java
similarity index 96%
rename from briar-android/src/org/briarproject/android/util/NestedTreeList.java
rename to briar-android/src/org/briarproject/android/threaded/NestedTreeList.java
index d190a9f6de..aec3c58beb 100644
--- a/briar-android/src/org/briarproject/android/util/NestedTreeList.java
+++ b/briar-android/src/org/briarproject/android/threaded/NestedTreeList.java
@@ -1,4 +1,4 @@
-package org.briarproject.android.util;
+package org.briarproject.android.threaded;
 
 import android.support.annotation.UiThread;
 
diff --git a/briar-android/src/org/briarproject/android/threaded/ThreadItem.java b/briar-android/src/org/briarproject/android/threaded/ThreadItem.java
new file mode 100644
index 0000000000..a5a2207599
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/threaded/ThreadItem.java
@@ -0,0 +1,95 @@
+package org.briarproject.android.threaded;
+
+import org.briarproject.api.clients.MessageTree.MessageNode;
+import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.Author.Status;
+import org.briarproject.api.sync.MessageId;
+
+import static org.briarproject.android.threaded.ThreadItemAdapter.UNDEFINED;
+
+/* This class is not thread safe */
+public abstract class ThreadItem implements MessageNode {
+
+	private final MessageId messageId;
+	private final MessageId parentId;
+	private final String text;
+	private final long timestamp;
+	private final Author author;
+	private final Status status;
+	private int level = UNDEFINED;
+	private boolean isShowingDescendants = true;
+	private int descendantCount = 0;
+	private boolean isRead;
+
+	public ThreadItem(MessageId messageId, MessageId parentId, String text,
+			long timestamp, Author author, Status status, boolean isRead) {
+		this.messageId = messageId;
+		this.parentId = parentId;
+		this.text = text;
+		this.timestamp = timestamp;
+		this.author = author;
+		this.status = status;
+		this.isRead = isRead;
+	}
+
+	public String getText() {
+		return text;
+	}
+
+	public int getLevel() {
+		return level;
+	}
+
+	@Override
+	public MessageId getId() {
+		return messageId;
+	}
+
+	@Override
+	public MessageId getParentId() {
+		return parentId;
+	}
+
+	@Override
+	public long getTimestamp() {
+		return timestamp;
+	}
+
+	public Author getAuthor() {
+		return author;
+	}
+
+	public Status getStatus() {
+		return status;
+	}
+
+	public boolean isShowingDescendants() {
+		return isShowingDescendants;
+	}
+
+	@Override
+	public void setLevel(int level) {
+		this.level = level;
+	}
+
+	public void setShowingDescendants(boolean showingDescendants) {
+		this.isShowingDescendants = showingDescendants;
+	}
+
+	public boolean isRead() {
+		return isRead;
+	}
+
+	public void setRead(boolean read) {
+		isRead = read;
+	}
+
+	public boolean hasDescendants() {
+		return descendantCount > 0;
+	}
+
+	@Override
+	public void setDescendantCount(int descendantCount) {
+		this.descendantCount = descendantCount;
+	}
+}
diff --git a/briar-android/src/org/briarproject/android/threaded/ThreadItemAdapter.java b/briar-android/src/org/briarproject/android/threaded/ThreadItemAdapter.java
new file mode 100644
index 0000000000..82e1fca18f
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/threaded/ThreadItemAdapter.java
@@ -0,0 +1,298 @@
+package org.briarproject.android.threaded;
+
+import android.animation.ValueAnimator;
+import android.support.annotation.Nullable;
+import android.support.annotation.UiThread;
+import android.support.v7.widget.LinearLayoutManager;
+import android.support.v7.widget.RecyclerView;
+
+import org.briarproject.api.sync.MessageId;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import static android.support.v7.widget.RecyclerView.NO_POSITION;
+
+@UiThread
+public abstract class ThreadItemAdapter<I extends ThreadItem>
+		extends RecyclerView.Adapter<ThreadItemViewHolder<I>> {
+
+	static final int UNDEFINED = -1;
+
+	private final NestedTreeList<I> items =
+			new NestedTreeList<>();
+	private final Map<I, ValueAnimator> animatingItems =
+			new HashMap<>();
+	// highlight not dependant on time
+	private I replyItem;
+	// temporary highlight
+	private I addedEntry;
+
+	private final ThreadItemListener<I> listener;
+	private final LinearLayoutManager layoutManager;
+
+	public ThreadItemAdapter(ThreadItemListener<I> listener,
+			LinearLayoutManager layoutManager) {
+		this.listener = listener;
+		this.layoutManager = layoutManager;
+	}
+
+	@Override
+	public void onBindViewHolder(ThreadItemViewHolder<I> ui, int position) {
+		final I item = getVisibleItem(position);
+		if (item == null) return;
+		listener.onItemVisible(item);
+		ui.bind(this, listener, item, position);
+	}
+
+	@Override
+	public int getItemCount() {
+		return getVisiblePos(null);
+	}
+
+	public I getReplyItem() {
+		return replyItem;
+	}
+
+	public void setItems(List<I> items) {
+		this.items.clear();
+		this.items.addAll(items);
+		notifyDataSetChanged();
+	}
+
+	public void add(I item) {
+		items.add(item);
+		addedEntry = item;
+		if (item.getParentId() == null) {
+			notifyItemInserted(getVisiblePos(item));
+		} else {
+			// Try to find the item's parent and perform the proper ui update if
+			// it's present and visible.
+			for (int i = items.indexOf(item) - 1; i >= 0; i--) {
+				I higherItem = items.get(i);
+				if (higherItem.getLevel() < item.getLevel()) {
+					// parent found
+					if (higherItem.isShowingDescendants()) {
+						int parentVisiblePos = getVisiblePos(higherItem);
+						if (parentVisiblePos != NO_POSITION) {
+							// parent is visible, we need to update its ui
+							notifyItemChanged(parentVisiblePos);
+							// new item insert ui
+							int visiblePos = getVisiblePos(item);
+							notifyItemInserted(visiblePos);
+							break;
+						}
+					} else {
+						// do not show the new item if its parent is not showing
+						// descendants (this can be overridden by the user by
+						// pressing the snack bar)
+						break;
+					}
+				}
+			}
+		}
+	}
+
+	public void scrollTo(I item) {
+		int visiblePos = getVisiblePos(item);
+		if (visiblePos == NO_POSITION && item.getParentId() != null) {
+			// The item is not visible due to being hidden by its parent item.
+			// Find the parent and make it visible and traverse up the parent
+			// chain if necessary to make the item visible
+			MessageId parentId = item.getParentId();
+			for (int i = items.indexOf(item) - 1; i >= 0; i--) {
+				I higherItem = items.get(i);
+				if (higherItem.getId().equals(parentId)) {
+					// parent found
+					showDescendants(higherItem);
+					int parentPos = getVisiblePos(higherItem);
+					if (parentPos != NO_POSITION) {
+						// parent or ancestor is visible, entry's visibility
+						// is ensured
+						notifyItemChanged(parentPos);
+						visiblePos = parentPos;
+						break;
+					}
+					// parent or ancestor is hidden, we need to continue up the
+					// dependency chain
+					parentId = higherItem.getParentId();
+				}
+			}
+		}
+		if (visiblePos != NO_POSITION)
+			layoutManager.scrollToPositionWithOffset(visiblePos, 0);
+	}
+
+	int getReplyCount(I item) {
+		int counter = 0;
+		int pos = items.indexOf(item);
+		if (pos >= 0) {
+			int ancestorLvl = item.getLevel();
+			for (int i = pos + 1; i < items.size(); i++) {
+				int descendantLvl = items.get(i).getLevel();
+				if (descendantLvl <= ancestorLvl)
+					break;
+				if (descendantLvl == ancestorLvl + 1)
+					counter++;
+			}
+		}
+		return counter;
+	}
+
+	public void setReplyItem(@Nullable I entry) {
+		if (replyItem != null) {
+			notifyItemChanged(getVisiblePos(replyItem));
+		}
+		replyItem = entry;
+		if (replyItem != null) {
+			notifyItemChanged(getVisiblePos(replyItem));
+		}
+	}
+
+	public void setReplyItemById(byte[] id) {
+		MessageId messageId = new MessageId(id);
+		for (I item : items) {
+			if (item.getId().equals(messageId)) {
+				setReplyItem(item);
+				break;
+			}
+		}
+	}
+
+	private List<Integer> getSubTreeIndexes(int pos, int levelLimit) {
+		List<Integer> indexList = new ArrayList<>();
+
+		for (int i = pos + 1; i < getItemCount(); i++) {
+			I item = getVisibleItem(i);
+			if (item != null && item.getLevel() > levelLimit) {
+				indexList.add(i);
+			} else {
+				break;
+			}
+		}
+		return indexList;
+	}
+
+	public void showDescendants(I item) {
+		item.setShowingDescendants(true);
+		int visiblePos = getVisiblePos(item);
+		List<Integer> indexList =
+				getSubTreeIndexes(visiblePos, item.getLevel());
+		if (!indexList.isEmpty()) {
+			if (indexList.size() == 1) {
+				notifyItemInserted(indexList.get(0));
+			} else {
+				notifyItemRangeInserted(indexList.get(0),
+						indexList.size());
+			}
+		}
+	}
+
+	public void hideDescendants(I item) {
+		int visiblePos = getVisiblePos(item);
+		List<Integer> indexList =
+				getSubTreeIndexes(visiblePos, item.getLevel());
+		if (!indexList.isEmpty()) {
+			// stop animating children
+			for (int index : indexList) {
+				ValueAnimator anim = animatingItems.get(items.get(index));
+				if (anim != null && anim.isRunning()) {
+					anim.cancel();
+				}
+			}
+			if (indexList.size() == 1) {
+				notifyItemRemoved(indexList.get(0));
+			} else {
+				notifyItemRangeRemoved(indexList.get(0),
+						indexList.size());
+			}
+		}
+		item.setShowingDescendants(false);
+	}
+
+
+	/**
+	 * Returns the visible item at the given position
+	 * @param position is visible entry index
+	 * @return the visible entry at index position from an ordered list of
+	 * visible entries, or null if position is larger than
+	 * the number of visible entries.
+	 */
+	@Nullable
+	public I getVisibleItem(int position) {
+		int levelLimit = UNDEFINED;
+		for (I item : items) {
+			if (levelLimit >= 0) {
+				// skip hidden entries that their parent is hiding
+				if (item.getLevel() > levelLimit) {
+					continue;
+				}
+				levelLimit = UNDEFINED;
+			}
+			if (!item.isShowingDescendants()) {
+				levelLimit = item.getLevel();
+			}
+			if (position-- == 0) {
+				return item;
+			}
+		}
+		return null;
+	}
+
+	public boolean isVisible(I item) {
+		return getVisiblePos(item) != NO_POSITION;
+	}
+
+	/**
+	 * Returns the visible position of the given ThreadItem
+	 * @param item the ThreadItem to find the visible position of, or null to
+	 *               return the total count of visible elements
+	 * @return the visible position of item, or the total number of visible
+	 * elements if sEntry is null. If item is not visible NO_POSITION is
+	 * returned.
+	 */
+	private int getVisiblePos(@Nullable I item) {
+		int visibleCounter = 0;
+		int levelLimit = UNDEFINED;
+		for (I iItem : items) {
+			if (levelLimit >= 0) {
+				if (iItem.getLevel() > levelLimit) {
+					// skip all the entries below a non visible branch
+					continue;
+				}
+				levelLimit = UNDEFINED;
+			}
+			if (item != null && item.equals(iItem)) {
+				return visibleCounter;
+			} else if (!iItem.isShowingDescendants()) {
+				levelLimit = iItem.getLevel();
+			}
+			visibleCounter++;
+		}
+		return item == null ? visibleCounter : NO_POSITION;
+	}
+
+	I getAddedItem() {
+		return addedEntry;
+	}
+
+	void clearAddedItem() {
+		addedEntry = null;
+	}
+
+	void addAnimatingItem(I item, ValueAnimator anim) {
+		animatingItems.put(item, anim);
+	}
+
+	void removeAnimatingItem(I item) {
+		animatingItems.remove(item);
+	}
+
+	public interface ThreadItemListener<I> {
+		void onItemVisible(I item);
+
+		void onReplyClick(I item);
+	}
+}
diff --git a/briar-android/src/org/briarproject/android/threaded/ThreadItemViewHolder.java b/briar-android/src/org/briarproject/android/threaded/ThreadItemViewHolder.java
new file mode 100644
index 0000000000..27ca9d80dc
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/threaded/ThreadItemViewHolder.java
@@ -0,0 +1,182 @@
+package org.briarproject.android.threaded;
+
+import android.animation.Animator;
+import android.animation.ArgbEvaluator;
+import android.animation.ValueAnimator;
+import android.content.Context;
+import android.graphics.drawable.ColorDrawable;
+import android.support.annotation.UiThread;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.widget.RecyclerView;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+import org.briarproject.R;
+import org.briarproject.android.threaded.ThreadItemAdapter.ThreadItemListener;
+import org.briarproject.android.view.AuthorView;
+import org.briarproject.util.StringUtils;
+
+import static android.view.View.GONE;
+import static android.view.View.INVISIBLE;
+import static android.view.View.VISIBLE;
+
+@UiThread
+public abstract class ThreadItemViewHolder<I extends ThreadItem>
+		extends RecyclerView.ViewHolder {
+
+	private final static int ANIMATION_DURATION = 5000;
+
+	private final TextView textView, lvlText, repliesText;
+	private final AuthorView author;
+	private final View[] lvls;
+	private final View chevron, replyButton;
+	private final ViewGroup cell;
+	private final View topDivider;
+
+	public ThreadItemViewHolder(View v) {
+		super(v);
+
+		textView = (TextView) v.findViewById(R.id.text);
+		lvlText = (TextView) v.findViewById(R.id.nested_line_text);
+		author = (AuthorView) v.findViewById(R.id.author);
+		repliesText = (TextView) v.findViewById(R.id.replies);
+		int[] nestedLineIds = {
+				R.id.nested_line_1, R.id.nested_line_2, R.id.nested_line_3,
+				R.id.nested_line_4, R.id.nested_line_5
+		};
+		lvls = new View[nestedLineIds.length];
+		for (int i = 0; i < lvls.length; i++) {
+			lvls[i] = v.findViewById(nestedLineIds[i]);
+		}
+		chevron = v.findViewById(R.id.chevron);
+		replyButton = v.findViewById(R.id.btn_reply);
+		cell = (ViewGroup) v.findViewById(R.id.forum_cell);
+		topDivider = v.findViewById(R.id.top_divider);
+	}
+
+	// TODO improve encapsulation, so we don't need to pass the adapter here
+	public void bind(final ThreadItemAdapter<I> adapter,
+			final ThreadItemListener listener, final I item, int pos) {
+
+		textView.setText(StringUtils.trim(item.getText()));
+
+		if (pos == 0) {
+			topDivider.setVisibility(View.INVISIBLE);
+		} else {
+			topDivider.setVisibility(View.VISIBLE);
+		}
+
+		for (int i = 0; i < lvls.length; i++) {
+			lvls[i].setVisibility(i < item.getLevel() ? VISIBLE : GONE);
+		}
+		if (item.getLevel() > 5) {
+			lvlText.setVisibility(VISIBLE);
+			lvlText.setText("" + item.getLevel());
+		} else {
+			lvlText.setVisibility(GONE);
+		}
+		author.setAuthor(item.getAuthor());
+		author.setDate(item.getTimestamp());
+		author.setAuthorStatus(item.getStatus());
+
+		int replies = adapter.getReplyCount(item);
+		if (replies == 0) {
+			repliesText.setText("");
+		} else {
+			repliesText.setText(getContext().getResources()
+					.getQuantityString(R.plurals.message_replies, replies,
+							replies));
+		}
+
+		if (item.hasDescendants()) {
+			chevron.setVisibility(VISIBLE);
+			if (item.isShowingDescendants()) {
+				chevron.setSelected(false);
+			} else {
+				chevron.setSelected(true);
+			}
+			chevron.setOnClickListener(new View.OnClickListener() {
+				@Override
+				public void onClick(View v) {
+					chevron.setSelected(!chevron.isSelected());
+					if (chevron.isSelected()) {
+						adapter.hideDescendants(item);
+					} else {
+						adapter.showDescendants(item);
+					}
+				}
+			});
+		} else {
+			chevron.setVisibility(INVISIBLE);
+		}
+		if (item.equals(adapter.getReplyItem())) {
+			cell.setBackgroundColor(ContextCompat
+					.getColor(getContext(), R.color.forum_cell_highlight));
+		} else if (item.equals(adapter.getAddedItem())) {
+			cell.setBackgroundColor(ContextCompat
+					.getColor(getContext(), R.color.forum_cell_highlight));
+			animateFadeOut(adapter, adapter.getAddedItem());
+			adapter.clearAddedItem();
+		} else {
+			cell.setBackgroundColor(ContextCompat
+					.getColor(getContext(), R.color.window_background));
+		}
+		replyButton.setOnClickListener(new View.OnClickListener() {
+			@Override
+			public void onClick(View v) {
+				listener.onReplyClick(item);
+				adapter.scrollTo(item);
+			}
+		});
+	}
+
+	private void animateFadeOut(final ThreadItemAdapter<I> adapter,
+			final I addedItem) {
+
+		setIsRecyclable(false);
+		ValueAnimator anim = new ValueAnimator();
+		adapter.addAnimatingItem(addedItem, anim);
+		ColorDrawable viewColor = (ColorDrawable) cell.getBackground();
+		anim.setIntValues(viewColor.getColor(), ContextCompat
+				.getColor(getContext(), R.color.window_background));
+		anim.setEvaluator(new ArgbEvaluator());
+		anim.addListener(new Animator.AnimatorListener() {
+			@Override
+			public void onAnimationStart(Animator animation) {
+
+			}
+
+			@Override
+			public void onAnimationEnd(Animator animation) {
+				setIsRecyclable(true);
+				adapter.removeAnimatingItem(addedItem);
+			}
+
+			@Override
+			public void onAnimationCancel(Animator animation) {
+				setIsRecyclable(true);
+				adapter.removeAnimatingItem(addedItem);
+			}
+
+			@Override
+			public void onAnimationRepeat(Animator animation) {
+
+			}
+		});
+		anim.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
+			@Override
+			public void onAnimationUpdate(ValueAnimator valueAnimator) {
+				cell.setBackgroundColor(
+						(Integer) valueAnimator.getAnimatedValue());
+			}
+		});
+		anim.setDuration(ANIMATION_DURATION);
+		anim.start();
+	}
+
+	private Context getContext() {
+		return textView.getContext();
+	}
+
+}
diff --git a/briar-android/src/org/briarproject/android/threaded/ThreadListActivity.java b/briar-android/src/org/briarproject/android/threaded/ThreadListActivity.java
new file mode 100644
index 0000000000..ea18e720f5
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/threaded/ThreadListActivity.java
@@ -0,0 +1,205 @@
+package org.briarproject.android.threaded;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.annotation.CallSuper;
+import android.support.annotation.LayoutRes;
+import android.support.annotation.Nullable;
+import android.support.annotation.StringRes;
+import android.support.design.widget.Snackbar;
+import android.support.v4.content.ContextCompat;
+import android.support.v7.widget.LinearLayoutManager;
+import android.view.MenuItem;
+import android.view.View;
+
+import org.briarproject.R;
+import org.briarproject.android.BriarActivity;
+import org.briarproject.android.api.AndroidNotificationManager;
+import org.briarproject.android.threaded.ThreadItemAdapter.ThreadItemListener;
+import org.briarproject.android.view.BriarRecyclerView;
+import org.briarproject.android.view.TextInputView;
+import org.briarproject.android.view.TextInputView.TextInputListener;
+import org.briarproject.api.sync.GroupId;
+
+import javax.inject.Inject;
+
+import static android.support.design.widget.Snackbar.make;
+import static android.view.View.GONE;
+import static android.view.View.VISIBLE;
+
+public abstract class ThreadListActivity<I extends ThreadItem, A extends ThreadItemAdapter<I>>
+		extends BriarActivity
+		implements TextInputListener, ThreadItemListener<I> {
+
+	protected static final String KEY_INPUT_VISIBILITY = "inputVisibility";
+	protected static final String KEY_REPLY_ID = "replyId";
+
+	@Inject
+	protected AndroidNotificationManager notificationManager;
+
+	protected A adapter;
+	protected BriarRecyclerView list;
+	protected TextInputView textInput;
+	protected GroupId groupId;
+
+	@CallSuper
+	@Override
+	public void onCreate(final Bundle state) {
+		super.onCreate(state);
+
+		setContentView(getLayout());
+
+		Intent i = getIntent();
+		byte[] b = i.getByteArrayExtra(GROUP_ID);
+		if (b == null) throw new IllegalStateException("No GroupId in intent.");
+		groupId = new GroupId(b);
+
+		textInput = (TextInputView) findViewById(R.id.text_input_container);
+		textInput.setVisibility(GONE);
+		textInput.setListener(this);
+		list = (BriarRecyclerView) findViewById(R.id.list);
+		LinearLayoutManager linearLayoutManager = new LinearLayoutManager(this);
+		list.setLayoutManager(linearLayoutManager);
+		adapter = createAdapter(linearLayoutManager);
+		list.setAdapter(adapter);
+	}
+
+	protected abstract @LayoutRes int getLayout();
+
+	protected abstract A createAdapter(LinearLayoutManager layoutManager);
+
+	@CallSuper
+	@Override
+	public void onResume() {
+		super.onResume();
+		notificationManager.blockNotification(groupId);
+		list.startPeriodicUpdate();
+	}
+
+	@CallSuper
+	@Override
+	public void onPause() {
+		super.onPause();
+		notificationManager.unblockNotification(groupId);
+		list.stopPeriodicUpdate();
+	}
+
+	@Override
+	protected void onRestoreInstanceState(Bundle savedInstanceState) {
+		super.onRestoreInstanceState(savedInstanceState);
+		textInput.setVisibility(
+				savedInstanceState.getBoolean(KEY_INPUT_VISIBILITY) ?
+						VISIBLE : GONE);
+	}
+
+	@Override
+	protected void onSaveInstanceState(Bundle outState) {
+		super.onSaveInstanceState(outState);
+		outState.putBoolean(KEY_INPUT_VISIBILITY,
+				textInput.getVisibility() == VISIBLE);
+		ThreadItem replyItem = adapter.getReplyItem();
+		if (replyItem != null) {
+			outState.putByteArray(KEY_REPLY_ID,
+					replyItem.getId().getBytes());
+		}
+	}
+
+	@Override
+	public boolean onOptionsItemSelected(MenuItem item) {
+		switch (item.getItemId()) {
+			case android.R.id.home:
+				if (textInput.isKeyboardOpen()) textInput.hideSoftKeyboard();
+				supportFinishAfterTransition();
+				return true;
+			default:
+				return super.onOptionsItemSelected(item);
+		}
+	}
+
+	@Override
+	public void onBackPressed() {
+		if (textInput.getVisibility() == VISIBLE) {
+			textInput.setVisibility(GONE);
+			adapter.setReplyItem(null);
+		} else {
+			super.onBackPressed();
+		}
+	}
+
+	@Override
+	public void onItemVisible(I item) {
+		if (!item.isRead()) {
+			item.setRead(true);
+			markItemRead(item);
+		}
+	}
+
+	protected abstract void markItemRead(I item);
+
+	@Override
+	public void onReplyClick(I item) {
+		showTextInput(item);
+	}
+
+	protected void displaySnackbarShort(int stringId) {
+		Snackbar snackbar = make(list, stringId, Snackbar.LENGTH_SHORT);
+		snackbar.getView().setBackgroundResource(R.color.briar_primary);
+		snackbar.show();
+	}
+
+	protected void showTextInput(@Nullable I replyItem) {
+		// An animation here would be an overkill because of the keyboard
+		// popping up.
+		// only clear the text when the input container was not visible
+		if (textInput.getVisibility() != VISIBLE) {
+			textInput.setVisibility(VISIBLE);
+			textInput.setText("");
+		}
+		textInput.requestFocus();
+		textInput.showSoftKeyboard();
+		textInput.setHint(replyItem == null ? R.string.forum_new_message_hint :
+				R.string.forum_message_reply_hint);
+		adapter.setReplyItem(replyItem);
+	}
+
+	@Override
+	public void onSendClick(String text) {
+		if (text.trim().length() == 0)
+			return;
+		I replyItem = adapter.getReplyItem();
+		sendItem(text, replyItem);
+		textInput.hideSoftKeyboard();
+		textInput.setVisibility(GONE);
+		adapter.setReplyItem(null);
+	}
+
+	protected abstract void sendItem(String text, I replyToItem);
+
+	protected void addItem(final I item, boolean isLocal) {
+		adapter.add(item);
+		if (isLocal && adapter.isVisible(item)) {
+			displaySnackbarShort(getItemPostedString());
+		} else {
+			Snackbar snackbar = Snackbar.make(list,
+					isLocal ? getItemPostedString() : getItemReceivedString(),
+					Snackbar.LENGTH_LONG);
+			snackbar.getView().setBackgroundResource(R.color.briar_primary);
+			snackbar.setActionTextColor(ContextCompat
+					.getColor(ThreadListActivity.this,
+							R.color.briar_button_positive));
+			snackbar.setAction(R.string.show, new View.OnClickListener() {
+				@Override
+				public void onClick(View v) {
+					adapter.scrollTo(item);
+				}
+			});
+			snackbar.getView().setBackgroundResource(R.color.briar_primary);
+			snackbar.show();
+		}
+	}
+
+	protected abstract @StringRes int getItemPostedString();
+
+	protected abstract @StringRes int getItemReceivedString();
+
+}
diff --git a/briar-android/test/java/org/briarproject/android/forum/ForumActivityTest.java b/briar-android/test/java/org/briarproject/android/forum/ForumActivityTest.java
index 3e5431ac94..7842c11e2a 100644
--- a/briar-android/test/java/org/briarproject/android/forum/ForumActivityTest.java
+++ b/briar-android/test/java/org/briarproject/android/forum/ForumActivityTest.java
@@ -126,9 +126,9 @@ public class ForumActivityTest {
 		adapter.hideDescendants(dummyData.get(0));
 		assertEquals(2, adapter.getItemCount());
 		assertTrue(dummyData.get(0).getText()
-				.equals(adapter.getVisibleEntry(0).getText()));
+				.equals(adapter.getVisibleItem(0).getText()));
 		assertTrue(dummyData.get(5).getText()
-				.equals(adapter.getVisibleEntry(1).getText()));
+				.equals(adapter.getVisibleItem(1).getText()));
 		// Cascade re-open
 		adapter.showDescendants(dummyData.get(0));
 		assertEquals(4, adapter.getItemCount());
@@ -137,8 +137,8 @@ public class ForumActivityTest {
 		adapter.showDescendants(dummyData.get(2));
 		assertEquals(6, adapter.getItemCount());
 		assertTrue(dummyData.get(2).getText()
-				.equals(adapter.getVisibleEntry(2).getText()));
+				.equals(adapter.getVisibleItem(2).getText()));
 		assertTrue(dummyData.get(4).getText()
-				.equals(adapter.getVisibleEntry(4).getText()));
+				.equals(adapter.getVisibleItem(4).getText()));
 	}
 }
diff --git a/briar-android/test/java/org/briarproject/android/forum/TestForumActivity.java b/briar-android/test/java/org/briarproject/android/forum/TestForumActivity.java
index 8acbf3b8df..806fee299b 100644
--- a/briar-android/test/java/org/briarproject/android/forum/TestForumActivity.java
+++ b/briar-android/test/java/org/briarproject/android/forum/TestForumActivity.java
@@ -16,7 +16,7 @@ public class TestForumActivity extends ForumActivity {
 	}
 
 	public NestedForumAdapter getAdapter() {
-		return forumAdapter;
+		return adapter;
 	}
 
 	@Override
-- 
GitLab