diff --git a/briar-android-tests/src/test/java/org/briarproject/ForumSharingIntegrationTest.java b/briar-android-tests/src/test/java/org/briarproject/ForumSharingIntegrationTest.java
index 4c4ba9b965365bce29cad3c7f88e03d5faeb3fe6..ae3e770a4e944e48fc201490cee7e8965c0876e0 100644
--- a/briar-android-tests/src/test/java/org/briarproject/ForumSharingIntegrationTest.java
+++ b/briar-android-tests/src/test/java/org/briarproject/ForumSharingIntegrationTest.java
@@ -3,16 +3,14 @@ package org.briarproject;
 import net.jodah.concurrentunit.Waiter;
 
 import org.briarproject.api.Bytes;
-import org.briarproject.api.clients.MessageQueueManager;
 import org.briarproject.api.clients.ContactGroupFactory;
+import org.briarproject.api.clients.MessageQueueManager;
 import org.briarproject.api.clients.SessionId;
 import org.briarproject.api.contact.Contact;
 import org.briarproject.api.contact.ContactId;
 import org.briarproject.api.contact.ContactManager;
 import org.briarproject.api.crypto.CryptoComponent;
 import org.briarproject.api.crypto.KeyPair;
-import org.briarproject.api.crypto.KeyParser;
-import org.briarproject.api.crypto.PrivateKey;
 import org.briarproject.api.crypto.SecretKey;
 import org.briarproject.api.data.BdfList;
 import org.briarproject.api.db.DatabaseComponent;
@@ -820,12 +818,10 @@ public class ForumSharingIntegrationTest extends BriarTestCase {
 
 			// sharer posts into the forum
 			long time = clock.currentTimeMillis();
-			byte[] body = TestUtils.getRandomBytes(42);
-			KeyParser keyParser = cryptoComponent.getSignatureKeyParser();
-			PrivateKey key = keyParser.parsePrivateKey(author0.getPrivateKey());
+			String body = TestUtils.getRandomString(42);
 			ForumPost p = forumPostFactory
 					.createPseudonymousPost(forum0.getId(), time, null, author0,
-							"text/plain", body, key);
+							body);
 			forumManager0.addLocalPost(p);
 
 			// sync forum post
@@ -841,11 +837,10 @@ public class ForumSharingIntegrationTest extends BriarTestCase {
 
 			// now invitee creates a post
 			time = clock.currentTimeMillis();
-			body = TestUtils.getRandomBytes(42);
-			key = keyParser.parsePrivateKey(author1.getPrivateKey());
+			body = TestUtils.getRandomString(42);
 			p = forumPostFactory
 					.createPseudonymousPost(forum0.getId(), time, null, author1,
-							"text/plain", body, key);
+							body);
 			forumManager1.addLocalPost(p);
 
 			// sync forum post
@@ -886,11 +881,10 @@ public class ForumSharingIntegrationTest extends BriarTestCase {
 
 			// now invitee creates a post
 			time = clock.currentTimeMillis();
-			body = TestUtils.getRandomBytes(42);
-			key = keyParser.parsePrivateKey(author1.getPrivateKey());
+			body = TestUtils.getRandomString(42);
 			p = forumPostFactory
 					.createPseudonymousPost(forum0.getId(), time, null, author1,
-							"text/plain", body, key);
+							body);
 			forumManager1.addLocalPost(p);
 
 			// sync forum post
diff --git a/briar-android-tests/src/test/java/org/briarproject/MessageSizeIntegrationTest.java b/briar-android-tests/src/test/java/org/briarproject/MessageSizeIntegrationTest.java
index 5e5b877cc86364d8fefdffcefac2c0fd6647426d..45fb9d7237bd38f3dcb8b575d5783c8fb736773c 100644
--- a/briar-android-tests/src/test/java/org/briarproject/MessageSizeIntegrationTest.java
+++ b/briar-android-tests/src/test/java/org/briarproject/MessageSizeIntegrationTest.java
@@ -6,8 +6,8 @@ import org.briarproject.api.crypto.PrivateKey;
 import org.briarproject.api.forum.ForumConstants;
 import org.briarproject.api.forum.ForumPost;
 import org.briarproject.api.forum.ForumPostFactory;
-import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.AuthorFactory;
+import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.messaging.MessagingConstants;
 import org.briarproject.api.messaging.PrivateMessage;
 import org.briarproject.api.messaging.PrivateMessageFactory;
@@ -68,17 +68,17 @@ public class MessageSizeIntegrationTest extends BriarTestCase {
 		String authorName = TestUtils.getRandomString(
 				MAX_AUTHOR_NAME_LENGTH);
 		byte[] authorPublic = new byte[MAX_PUBLIC_KEY_LENGTH];
-		Author author = authorFactory.createAuthor(authorName, authorPublic);
+		PrivateKey privateKey = crypto.generateSignatureKeyPair().getPrivate();
+		LocalAuthor author = authorFactory
+				.createLocalAuthor(authorName, authorPublic,
+						privateKey.getEncoded());
 		// Create a maximum-length forum post
 		GroupId groupId = new GroupId(TestUtils.getRandomId());
 		long timestamp = Long.MAX_VALUE;
 		MessageId parent = new MessageId(TestUtils.getRandomId());
-		String contentType = TestUtils.getRandomString(
-				ForumConstants.MAX_CONTENT_TYPE_LENGTH);
-		byte[] body = new byte[MAX_FORUM_POST_BODY_LENGTH];
-		PrivateKey privateKey = crypto.generateSignatureKeyPair().getPrivate();
+		String body = TestUtils.getRandomString(MAX_FORUM_POST_BODY_LENGTH);
 		ForumPost post = forumPostFactory.createPseudonymousPost(groupId,
-				timestamp, parent, author, contentType, body, privateKey);
+				timestamp, parent, author, body);
 		// Check the size of the serialised message
 		int length = post.getMessage().getRaw().length;
 		assertTrue(length > UniqueId.LENGTH + 8 + UniqueId.LENGTH
diff --git a/briar-android/AndroidManifest.xml b/briar-android/AndroidManifest.xml
index 86c5239e549e81befe949a9b2b32ce3a7e4c0db8..1bb12bfff447a50c72d83e6b30d5323d9a464d08 100644
--- a/briar-android/AndroidManifest.xml
+++ b/briar-android/AndroidManifest.xml
@@ -100,6 +100,17 @@
 				/>
 		</activity>
 
+		<activity
+			android:name=".android.privategroup.conversation.GroupActivity"
+			android:label="@string/app_name"
+			android:parentActivityName=".android.NavDrawerActivity"
+			android:windowSoftInputMode="adjustResize|stateHidden">
+			<meta-data
+				android:name="android.support.PARENT_ACTIVITY"
+				android:value=".android.NavDrawerActivity"
+				/>
+		</activity>
+
 		<activity
 			android:name=".android.sharing.InvitationsForumActivity"
 			android:label="@string/forum_invitations_title"
diff --git a/briar-android/res/drawable/ic_group_white.xml b/briar-android/res/drawable/ic_group_white.xml
new file mode 100644
index 0000000000000000000000000000000000000000..f6e32475bd91839f3d140ff1afc791f660edaacb
--- /dev/null
+++ b/briar-android/res/drawable/ic_group_white.xml
@@ -0,0 +1,9 @@
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+        android:width="24dp"
+        android:height="24dp"
+        android:viewportHeight="24.0"
+        android:viewportWidth="24.0">
+	<path
+		android:fillColor="#FFFFFFFF"
+		android:pathData="M16,11c1.66,0 2.99,-1.34 2.99,-3S17.66,5 16,5c-1.66,0 -3,1.34 -3,3s1.34,3 3,3zM8,11c1.66,0 2.99,-1.34 2.99,-3S9.66,5 8,5C6.34,5 5,6.34 5,8s1.34,3 3,3zM8,13c-2.33,0 -7,1.17 -7,3.5L1,19h14v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5zM16,13c-0.29,0 -0.62,0.02 -0.97,0.05 1.16,0.84 1.97,1.97 1.97,3.45L17,19h6v-2.5c0,-2.33 -4.67,-3.5 -7,-3.5z"/>
+</vector>
diff --git a/briar-android/res/layout/activity_forum.xml b/briar-android/res/layout/activity_forum.xml
index b796da899f8744256b77dbdc225f18689970cf83..81c7e4a2bb220efd271238321ef1a84d91e4b8b2 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/res/menu/forum_actions.xml b/briar-android/res/menu/forum_actions.xml
index 2a262cbfa3b53e43ffcd04dab63b7887c7bddbf5..cd4e4573c50e29dddeb025f5c7980800665ca4bb 100644
--- a/briar-android/res/menu/forum_actions.xml
+++ b/briar-android/res/menu/forum_actions.xml
@@ -7,7 +7,7 @@
 		android:id="@+id/action_forum_compose_post"
 		android:icon="@drawable/forum_item_create_white"
 		android:title="@string/forum_compose_post"
-		app:showAsAction="ifRoom"/>
+		app:showAsAction="always"/>
 
 	<item
 		android:id="@+id/action_forum_share"
diff --git a/briar-android/res/menu/group_actions.xml b/briar-android/res/menu/group_actions.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8ebe58a1b345f9c4b68b9be15f2c98dc467aba40
--- /dev/null
+++ b/briar-android/res/menu/group_actions.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu
+	xmlns:android="http://schemas.android.com/apk/res/android"
+	xmlns:app="http://schemas.android.com/apk/res-auto">
+
+	<item
+		android:id="@+id/action_group_compose_message"
+		android:icon="@drawable/forum_item_create_white"
+		android:title="@string/groups_compose_message"
+		app:showAsAction="always"/>
+
+	<item
+		android:id="@+id/action_group_member_list"
+		android:enabled="false"
+		android:icon="@drawable/ic_group_white"
+		android:title="@string/groups_member_list"
+		app:showAsAction="ifRoom"/>
+
+	<item
+		android:id="@+id/action_group_invite"
+		android:enabled="false"
+		android:icon="@drawable/ic_add_white"
+		android:title="@string/groups_invite_members"
+		app:showAsAction="ifRoom"/>
+
+	<item
+		android:id="@+id/action_group_leave"
+		android:enabled="false"
+		android:icon="@drawable/action_delete_white"
+		android:title="@string/groups_leave"
+		android:visible="false"
+		app:showAsAction="never"/>
+
+	<item
+		android:id="@+id/action_group_dissolve"
+		android:enabled="false"
+		android:icon="@drawable/action_delete_white"
+		android:title="@string/groups_dissolve"
+		app:showAsAction="never"/>
+
+</menu>
\ No newline at end of file
diff --git a/briar-android/res/values/strings.xml b/briar-android/res/values/strings.xml
index 883dceee683a5640a16a3c49887b1589278b7aa5..b1652f5fb7c1ea51c54367e5d0137ac4bdc99d83 100644
--- a/briar-android/res/values/strings.xml
+++ b/briar-android/res/values/strings.xml
@@ -75,6 +75,7 @@
 	<string name="send">Send</string>
 	<string name="no_data">No data</string>
 	<string name="ellipsis">…</string>
+	<string name="text_too_long">The entered text is too long</string>
 
 	<!-- Contacts and Private Conversations-->
 	<string name="no_contacts">It seems that you are new here and have no contacts yet.\n\nTap the + icon at the top and follow the instructions to add some friends to your list.\n\nPlease remember: You can only add new contacts face-to-face to prevent anyone from impersonating you or reading your messages in the future.</string>
@@ -155,6 +156,14 @@
 	<string name="groups_group_is_dissolved">This group is dissolved</string>
 	<string name="groups_remove">Remove</string>
 	<string name="groups_add_group_title">Add Private Group</string>
+	<string name="groups_no_messages">This group is empty.\n\nYou can use the pen icon at the top to compose the first message.</string>
+	<string name="groups_compose_message">Compose Message</string>
+	<string name="groups_message_sent">Message sent</string>
+	<string name="groups_message_received">Message received</string>
+	<string name="groups_member_list">Member List</string>
+	<string name="groups_invite_members">Invite Members</string>
+	<string name="groups_leave">Leave Group</string>
+	<string name="groups_dissolve">Dissolve Group</string>
 
 	<!-- Forums -->
 	<string name="no_forums">You don\'t have any forums yet.\n\nWhy don\'t you create a new one yourself by tapping the + icon at the top?\n\nYou can also ask your contacts to share forums with you.</string>
diff --git a/briar-android/src/org/briarproject/android/ActivityComponent.java b/briar-android/src/org/briarproject/android/ActivityComponent.java
index d7cab786be65373e4c5efec208e210b539fdabb5..4e66c87aba6e1fbb891dc9e669d55348a8df974a 100644
--- a/briar-android/src/org/briarproject/android/ActivityComponent.java
+++ b/briar-android/src/org/briarproject/android/ActivityComponent.java
@@ -28,6 +28,7 @@ import org.briarproject.android.keyagreement.KeyAgreementActivity;
 import org.briarproject.android.keyagreement.ShowQrCodeFragment;
 import org.briarproject.android.panic.PanicPreferencesActivity;
 import org.briarproject.android.panic.PanicResponderActivity;
+import org.briarproject.android.privategroup.conversation.GroupActivity;
 import org.briarproject.android.privategroup.list.GroupListFragment;
 import org.briarproject.android.sharing.ContactSelectorFragment;
 import org.briarproject.android.sharing.InvitationsBlogActivity;
@@ -72,6 +73,8 @@ public interface ActivityComponent {
 
 	void inject(InvitationsBlogActivity activity);
 
+	void inject(GroupActivity activity);
+
 	void inject(CreateForumActivity activity);
 
 	void inject(ShareForumActivity activity);
diff --git a/briar-android/src/org/briarproject/android/ActivityModule.java b/briar-android/src/org/briarproject/android/ActivityModule.java
index 3a81fc60ffcd42e5e3a47ef2b6e8592222bf2f78..07cace566205890ee8a101bd04e8a9f651baa94b 100644
--- a/briar-android/src/org/briarproject/android/ActivityModule.java
+++ b/briar-android/src/org/briarproject/android/ActivityModule.java
@@ -21,6 +21,8 @@ import org.briarproject.android.controller.SetupController;
 import org.briarproject.android.controller.SetupControllerImpl;
 import org.briarproject.android.forum.ForumController;
 import org.briarproject.android.forum.ForumControllerImpl;
+import org.briarproject.android.privategroup.conversation.GroupController;
+import org.briarproject.android.privategroup.conversation.GroupControllerImpl;
 import org.briarproject.android.privategroup.list.GroupListController;
 import org.briarproject.android.privategroup.list.GroupListControllerImpl;
 
@@ -99,6 +101,13 @@ public class ActivityModule {
 		return groupListController;
 	}
 
+	@ActivityScope
+	@Provides
+	protected GroupController provideGroupController(
+			GroupControllerImpl groupController) {
+		return groupController;
+	}
+
 	@ActivityScope
 	@Provides
 	protected ForumController provideForumController(
diff --git a/briar-android/src/org/briarproject/android/AndroidComponent.java b/briar-android/src/org/briarproject/android/AndroidComponent.java
index 9b7e6edbaf0b64aecdf3ab00eab40732aeb3d419..ceb3cd9e0b94be42eaa86acd8e2e24df8658b982 100644
--- a/briar-android/src/org/briarproject/android/AndroidComponent.java
+++ b/briar-android/src/org/briarproject/android/AndroidComponent.java
@@ -19,7 +19,6 @@ import org.briarproject.api.db.DatabaseExecutor;
 import org.briarproject.api.event.EventBus;
 import org.briarproject.api.feed.FeedManager;
 import org.briarproject.api.forum.ForumManager;
-import org.briarproject.api.forum.ForumPostFactory;
 import org.briarproject.api.forum.ForumSharingManager;
 import org.briarproject.api.identity.AuthorFactory;
 import org.briarproject.api.identity.IdentityManager;
@@ -37,6 +36,7 @@ import org.briarproject.api.plugins.ConnectionRegistry;
 import org.briarproject.api.plugins.PluginManager;
 import org.briarproject.api.privategroup.PrivateGroupManager;
 import org.briarproject.api.settings.SettingsManager;
+import org.briarproject.api.system.Clock;
 import org.briarproject.plugins.AndroidPluginsModule;
 import org.briarproject.system.AndroidSystemModule;
 
@@ -102,8 +102,6 @@ public interface AndroidComponent extends CoreEagerSingletons {
 
 	BlogSharingManager blogSharingManager();
 
-	ForumPostFactory forumPostFactory();
-
 	BlogManager blogManager();
 
 	BlogPostFactory blogPostFactory();
@@ -124,6 +122,8 @@ public interface AndroidComponent extends CoreEagerSingletons {
 
 	FeedManager feedManager();
 
+	Clock clock();
+
 	@IoExecutor
 	Executor ioExecutor();
 
diff --git a/briar-android/src/org/briarproject/android/BriarActivity.java b/briar-android/src/org/briarproject/android/BriarActivity.java
index 49dc574a858a2590b120bba32c74ae28a6291294..4c01808af7cba45255b7d091657471145c1d3112 100644
--- a/briar-android/src/org/briarproject/android/BriarActivity.java
+++ b/briar-android/src/org/briarproject/android/BriarActivity.java
@@ -26,6 +26,7 @@ public abstract class BriarActivity extends BaseActivity {
 			"briar.LOCAL_AUTHOR_HANDLE";
 	public static final String KEY_STARTUP_FAILED = "briar.STARTUP_FAILED";
 	public static final String GROUP_ID = "briar.GROUP_ID";
+	public static final String GROUP_NAME = "briar.GROUP_NAME";
 
 	public static final int REQUEST_PASSWORD = 1;
 
diff --git a/briar-android/src/org/briarproject/android/forum/CreateForumActivity.java b/briar-android/src/org/briarproject/android/forum/CreateForumActivity.java
index ff1644b1c95052d0d28d3849d7bf3be891d6ee94..4c902459998fd68ce540933aa72f1295869b2c34 100644
--- a/briar-android/src/org/briarproject/android/forum/CreateForumActivity.java
+++ b/briar-android/src/org/briarproject/android/forum/CreateForumActivity.java
@@ -31,7 +31,6 @@ import static android.view.View.VISIBLE;
 import static android.widget.Toast.LENGTH_LONG;
 import static java.util.logging.Level.INFO;
 import static java.util.logging.Level.WARNING;
-import static org.briarproject.android.forum.ForumActivity.FORUM_NAME;
 import static org.briarproject.api.forum.ForumConstants.MAX_FORUM_NAME_LENGTH;
 
 public class CreateForumActivity extends BriarActivity
@@ -150,7 +149,7 @@ public class CreateForumActivity extends BriarActivity
 				Intent i = new Intent(CreateForumActivity.this,
 						ForumActivity.class);
 				i.putExtra(GROUP_ID, f.getId().getBytes());
-				i.putExtra(FORUM_NAME, f.getName());
+				i.putExtra(GROUP_NAME, f.getName());
 				startActivity(i);
 				Toast.makeText(CreateForumActivity.this,
 						R.string.forum_created_toast, LENGTH_LONG).show();
diff --git a/briar-android/src/org/briarproject/android/forum/ForumActivity.java b/briar-android/src/org/briarproject/android/forum/ForumActivity.java
index 1cb6662fecc991a625582037eb89810b6c3d2eaa..81b41f99fed3dbc19467fd6ce6e56dc7c6067ee4 100644
--- a/briar-android/src/org/briarproject/android/forum/ForumActivity.java
+++ b/briar-android/src/org/briarproject/android/forum/ForumActivity.java
@@ -3,155 +3,79 @@ package org.briarproject.android.forum;
 import android.content.DialogInterface;
 import android.content.Intent;
 import android.os.Bundle;
-import android.support.annotation.Nullable;
-import android.support.design.widget.Snackbar;
+import android.support.annotation.LayoutRes;
+import android.support.annotation.StringRes;
 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.android.threaded.ThreadListController;
 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;
-import java.util.List;
 
 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;
+import static org.briarproject.api.forum.ForumConstants.MAX_FORUM_POST_BODY_LENGTH;
 
-public class ForumActivity extends BriarActivity implements
-		ForumPostListener, TextInputListener, OnNestedForumListener {
-
-	static final String FORUM_NAME = "briar.FORUM_NAME";
+public class ForumActivity extends
+		ThreadListActivity<Forum, ForumItem, ForumPostHeader, NestedForumAdapter> {
 
 	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;
+	ForumController forumController;
 
-	// Protected access for testing
-	protected NestedForumAdapter forumAdapter;
-
-	private BriarRecyclerView recyclerView;
-	private TextInputView textInput;
+	@Override
+	public void injectActivity(ActivityComponent component) {
+		component.inject(this);
+	}
 
-	private volatile GroupId groupId = null;
+	@Override
+	protected ThreadListController<Forum, ForumItem, ForumPostHeader> getController() {
+		return forumController;
+	}
 
 	@Override
-	public void onCreate(final Bundle state) {
+	public void onCreate(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) {
-					@Override
-					public void onResultUi(List<ForumEntry> result) {
-						Forum forum = forumController.getForum();
-						if (forum != null) setTitle(forum.getName());
-						List<ForumEntry> entries = new ArrayList<>(result);
-						if (entries.isEmpty()) {
-							recyclerView.showData();
-						} else {
-							forumAdapter.setEntries(entries);
-							if (state != null) {
-								byte[] replyId =
-										state.getByteArray(KEY_REPLY_ID);
-								if (replyId != null)
-									forumAdapter.setReplyEntryById(replyId);
-							}
-						}
-					}
-
-					@Override
-					public void onExceptionUi(DbException exception) {
-						// TODO Improve UX ?
-						finish();
-					}
-				});
+		String groupName = i.getStringExtra(GROUP_NAME);
+		if (groupName != null) setTitle(groupName);
+		else loadNamedGroup();
 	}
 
 	@Override
-	protected void onRestoreInstanceState(Bundle savedInstanceState) {
-		super.onRestoreInstanceState(savedInstanceState);
-		textInput.setVisibility(
-				savedInstanceState.getBoolean(KEY_INPUT_VISIBILITY) ?
-						VISIBLE : GONE);
+	protected void onNamedGroupLoaded(Forum forum) {
+		setTitle(forum.getName());
 	}
 
-
 	@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());
-		}
+	@LayoutRes
+	protected int getLayout() {
+		return R.layout.activity_forum;
 	}
 
 	@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();
+	protected NestedForumAdapter createAdapter(
+			LinearLayoutManager layoutManager) {
+		return new NestedForumAdapter(this, layoutManager);
 	}
 
 	@Override
@@ -172,34 +96,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()) {
@@ -229,69 +129,29 @@ public class ForumActivity extends BriarActivity implements
 	}
 
 	@Override
-	public void onResume() {
-		super.onResume();
-		notificationManager.blockNotification(groupId);
-		notificationManager.clearForumPostNotification(groupId);
-		recyclerView.startPeriodicUpdate();
+	protected int getMaxBodyLength() {
+		return MAX_FORUM_POST_BODY_LENGTH;
 	}
 
 	@Override
-	public void onPause() {
-		super.onPause();
-		notificationManager.unblockNotification(groupId);
-		recyclerView.stopPeriodicUpdate();
+	@StringRes
+	protected int getItemPostedString() {
+		return R.string.forum_new_entry_posted;
 	}
 
 	@Override
-	public void onSendClick(String text) {
-		if (text.trim().length() == 0)
-			return;
-		ForumEntry replyEntry = forumAdapter.getReplyEntry();
-		UiResultExceptionHandler<ForumEntry, DbException> resultHandler =
-				new UiResultExceptionHandler<ForumEntry, DbException>(this) {
-					@Override
-					public void onResultUi(ForumEntry result) {
-						onForumEntryAdded(result, true);
-					}
-
-					@Override
-					public void onExceptionUi(DbException exception) {
-						// TODO Improve UX ?
-						finish();
-					}
-				};
-
-		if (replyEntry == null) {
-			// root post
-			forumController.createPost(StringUtils.toUtf8(text), resultHandler);
-		} else {
-			forumController.createPost(StringUtils.toUtf8(text),
-					replyEntry.getId(), resultHandler);
-		}
-		textInput.hideSoftKeyboard();
-		textInput.setVisibility(GONE);
-		forumAdapter.setReplyEntry(null);
+	@StringRes
+	protected int getItemReceivedString() {
+		return R.string.forum_new_entry_received;
 	}
 
 	private void showUnsubscribeDialog() {
 		DialogInterface.OnClickListener okListener =
 				new DialogInterface.OnClickListener() {
 					@Override
-					public void onClick(DialogInterface dialog, int which) {
-						forumController.unsubscribe(
-								new UiResultHandler<Boolean>(
-										ForumActivity.this) {
-									@Override
-									public void onResultUi(Boolean result) {
-										if (result) {
-											Toast.makeText(ForumActivity.this,
-													R.string.forum_left_toast,
-													LENGTH_SHORT)
-													.show();
-										}
-									}
-								});
+					public void onClick(final DialogInterface dialog,
+							int which) {
+						deleteNamedGroup();
 					}
 				};
 		AlertDialog.Builder builder =
@@ -299,66 +159,30 @@ public class ForumActivity extends BriarActivity implements
 						R.style.BriarDialogTheme);
 		builder.setTitle(getString(R.string.dialog_title_leave_forum));
 		builder.setMessage(getString(R.string.dialog_message_leave_forum));
-		builder.setPositiveButton(R.string.dialog_button_leave, okListener);
-		builder.setNegativeButton(android.R.string.cancel, null);
+		builder.setNegativeButton(R.string.dialog_button_leave, okListener);
+		builder.setPositiveButton(R.string.cancel, null);
 		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) {
+	private void deleteNamedGroup() {
+		forumController.deleteNamedGroup(
+				new UiResultExceptionHandler<Void, DbException>(
+						ForumActivity.this) {
 					@Override
-					public void onResultUi(final ForumEntry result) {
-						onForumEntryAdded(result, false);
+					public void onResultUi(Void v) {
+						Toast.makeText(ForumActivity.this,
+								R.string.forum_left_toast,
+								LENGTH_SHORT)
+								.show();
 					}
 
 					@Override
-					public void onExceptionUi(DbException exception) {
-						// TODO add proper exception handling
+					public void onExceptionUi(
+							DbException exception) {
+						// TODO proper error handling
+						finish();
 					}
 				});
 	}
 
-	@Override
-	public void onForumRemoved() {
-		finish();
-	}
 }
diff --git a/briar-android/src/org/briarproject/android/forum/ForumController.java b/briar-android/src/org/briarproject/android/forum/ForumController.java
index ad08dbc4dd534a918736c43486fcfc6fa44917ce..9b1fba5641b032c862b4fb9ba1ffcf254b773fd6 100644
--- a/briar-android/src/org/briarproject/android/forum/ForumController.java
+++ b/briar-android/src/org/briarproject/android/forum/ForumController.java
@@ -1,51 +1,10 @@
 package org.briarproject.android.forum;
 
-import android.support.annotation.Nullable;
-import android.support.annotation.UiThread;
-
-import org.briarproject.android.DestroyableContext;
-import org.briarproject.android.controller.ActivityLifecycleController;
-import org.briarproject.android.controller.handler.ResultExceptionHandler;
-import org.briarproject.android.controller.handler.ResultHandler;
-import org.briarproject.api.db.DbException;
+import org.briarproject.android.threaded.ThreadListController;
 import org.briarproject.api.forum.Forum;
 import org.briarproject.api.forum.ForumPostHeader;
-import org.briarproject.api.sync.GroupId;
-import org.briarproject.api.sync.MessageId;
-
-import java.util.Collection;
-import java.util.List;
-
-public interface ForumController extends ActivityLifecycleController {
-
-	void loadForum(GroupId groupId,
-			ResultExceptionHandler<List<ForumEntry>, DbException> resultHandler);
-
-	@Nullable
-	Forum getForum();
-
-	void loadPost(ForumPostHeader header,
-			ResultExceptionHandler<ForumEntry, DbException> resultHandler);
-
-	void unsubscribe(ResultHandler<Boolean> resultHandler);
-
-	void entryRead(ForumEntry forumEntry);
-
-	void entriesRead(Collection<ForumEntry> messageIds);
-
-	void createPost(byte[] body,
-			ResultExceptionHandler<ForumEntry, DbException> resultHandler);
-
-	void createPost(byte[] body, MessageId parentId,
-			ResultExceptionHandler<ForumEntry, DbException> resultHandler);
-
-	interface ForumPostListener extends DestroyableContext {
-
-		@UiThread
-		void onForumPostReceived(ForumPostHeader header);
 
-		@UiThread
-		void onForumRemoved();
-	}
+public interface ForumController
+		extends ThreadListController<Forum, ForumItem, ForumPostHeader> {
 
 }
diff --git a/briar-android/src/org/briarproject/android/forum/ForumControllerImpl.java b/briar-android/src/org/briarproject/android/forum/ForumControllerImpl.java
index b6a8ddce527efa65d6ff3585fb59e1716b8e5ef8..fa62f1b292740855de64345b1b2e9866c0404dca 100644
--- a/briar-android/src/org/briarproject/android/forum/ForumControllerImpl.java
+++ b/briar-android/src/org/briarproject/android/forum/ForumControllerImpl.java
@@ -1,388 +1,126 @@
 package org.briarproject.android.forum;
 
-import android.app.Activity;
 import android.support.annotation.Nullable;
 
-import org.briarproject.android.controller.DbControllerImpl;
-import org.briarproject.android.controller.handler.ResultExceptionHandler;
-import org.briarproject.android.controller.handler.ResultHandler;
-import org.briarproject.api.FormatException;
-import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.android.api.AndroidNotificationManager;
+import org.briarproject.android.threaded.ThreadListControllerImpl;
+import org.briarproject.api.clients.MessageTracker.GroupCount;
 import org.briarproject.api.crypto.CryptoExecutor;
-import org.briarproject.api.crypto.KeyParser;
-import org.briarproject.api.crypto.PrivateKey;
 import org.briarproject.api.db.DatabaseExecutor;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.event.Event;
 import org.briarproject.api.event.EventBus;
-import org.briarproject.api.event.EventListener;
 import org.briarproject.api.event.ForumPostReceivedEvent;
-import org.briarproject.api.event.GroupRemovedEvent;
 import org.briarproject.api.forum.Forum;
 import org.briarproject.api.forum.ForumManager;
 import org.briarproject.api.forum.ForumPost;
-import org.briarproject.api.forum.ForumPostFactory;
 import org.briarproject.api.forum.ForumPostHeader;
 import org.briarproject.api.identity.IdentityManager;
 import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.lifecycle.LifecycleManager;
-import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.system.Clock;
 import org.briarproject.util.StringUtils;
 
-import java.security.GeneralSecurityException;
-import java.util.ArrayList;
 import java.util.Collection;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import java.util.concurrent.ConcurrentHashMap;
 import java.util.concurrent.Executor;
-import java.util.concurrent.atomic.AtomicLong;
 import java.util.logging.Logger;
 
 import javax.inject.Inject;
 
-import static java.util.logging.Level.INFO;
-import static java.util.logging.Level.WARNING;
-import static org.briarproject.api.identity.Author.Status.OURSELVES;
-
-public class ForumControllerImpl extends DbControllerImpl
-		implements ForumController, EventListener {
+public class ForumControllerImpl
+		extends ThreadListControllerImpl<Forum, ForumItem, ForumPostHeader, ForumPost>
+		implements ForumController {
 
 	private static final Logger LOG =
 			Logger.getLogger(ForumControllerImpl.class.getName());
 
-	private final Executor cryptoExecutor;
-	private final ForumPostFactory forumPostFactory;
-	private final CryptoComponent crypto;
 	private final ForumManager forumManager;
-	private final EventBus eventBus;
-	private final IdentityManager identityManager;
-
-	private final Map<MessageId, byte[]> bodyCache = new ConcurrentHashMap<>();
-	private final AtomicLong newestTimeStamp = new AtomicLong();
-
-	private volatile LocalAuthor localAuthor = null;
-	private volatile Forum forum = null;
-	private volatile ForumPostListener listener;
 
 	@Inject
 	ForumControllerImpl(@DatabaseExecutor Executor dbExecutor,
-			LifecycleManager lifecycleManager,
+			LifecycleManager lifecycleManager, IdentityManager identityManager,
 			@CryptoExecutor Executor cryptoExecutor,
-			ForumPostFactory forumPostFactory, CryptoComponent crypto,
 			ForumManager forumManager, EventBus eventBus,
-			IdentityManager identityManager) {
-		super(dbExecutor, lifecycleManager);
-		this.cryptoExecutor = cryptoExecutor;
-		this.forumPostFactory = forumPostFactory;
-		this.crypto = crypto;
+			AndroidNotificationManager notificationManager, Clock clock) {
+		super(dbExecutor, lifecycleManager, identityManager, cryptoExecutor,
+				eventBus, notificationManager, clock);
 		this.forumManager = forumManager;
-		this.eventBus = eventBus;
-		this.identityManager = identityManager;
-	}
-
-	@Override
-	public void onActivityCreate(Activity activity) {
-		if (activity instanceof ForumPostListener) {
-			listener = (ForumPostListener) activity;
-		} else {
-			throw new IllegalStateException(
-					"An activity that injects the ForumController must " +
-							"implement the ForumPostListener");
-		}
 	}
 
 	@Override
 	public void onActivityResume() {
-		eventBus.addListener(this);
-	}
-
-	@Override
-	public void onActivityPause() {
-		eventBus.removeListener(this);
-	}
-
-	@Override
-	public void onActivityDestroy() {
+		super.onActivityResume();
+		notificationManager.clearForumPostNotification(getGroupId());
 	}
 
 	@Override
 	public void eventOccurred(Event e) {
-		if (forum == null) return;
+		super.eventOccurred(e);
+
 		if (e instanceof ForumPostReceivedEvent) {
 			final ForumPostReceivedEvent pe = (ForumPostReceivedEvent) e;
-			if (pe.getGroupId().equals(forum.getId())) {
+			if (pe.getGroupId().equals(getGroupId())) {
 				LOG.info("Forum post received, adding...");
 				final ForumPostHeader fph = pe.getForumPostHeader();
-				updateNewestTimestamp(fph.getTimestamp());
-				listener.runOnUiThreadUnlessDestroyed(new Runnable() {
-					@Override
-					public void run() {
-						listener.onForumPostReceived(fph);
-					}
-				});
-			}
-		} else if (e instanceof GroupRemovedEvent) {
-			GroupRemovedEvent s = (GroupRemovedEvent) e;
-			if (s.getGroup().getId().equals(forum.getId())) {
-				LOG.info("Forum removed");
 				listener.runOnUiThreadUnlessDestroyed(new Runnable() {
 					@Override
 					public void run() {
-						listener.onForumRemoved();
+						listener.onHeaderReceived(fph);
 					}
 				});
 			}
 		}
 	}
 
-	/**
-	 * This should only be run from the DbThread.
-	 *
-	 * @throws DbException
-	 */
-	private void loadForum(GroupId groupId) throws DbException {
-		// Get Forum
-		long now = System.currentTimeMillis();
-		forum = forumManager.getForum(groupId);
-		long duration = System.currentTimeMillis() - now;
-		if (LOG.isLoggable(INFO))
-			LOG.info("Loading forum took " + duration + " ms");
-
-		// Get First Identity
-		now = System.currentTimeMillis();
-		localAuthor = identityManager.getLocalAuthor();
-		duration = System.currentTimeMillis() - now;
-		if (LOG.isLoggable(INFO))
-			LOG.info("Loading author took " + duration + " ms");
-	}
-
-	/**
-	 * This should only be run from the DbThread.
-	 *
-	 * @throws DbException
-	 */
-	private Collection<ForumPostHeader> loadHeaders() throws DbException {
-		if (forum == null)
-			throw new RuntimeException("Forum has not been initialized");
-
-		// Get Headers
-		long now = System.currentTimeMillis();
-		Collection<ForumPostHeader> headers =
-				forumManager.getPostHeaders(forum.getId());
-		long duration = System.currentTimeMillis() - now;
-		if (LOG.isLoggable(INFO))
-			LOG.info("Loading headers took " + duration + " ms");
-		return headers;
-	}
-
-	/**
-	 * This should only be run from the DbThread.
-	 *
-	 * @throws DbException
-	 */
-	private void loadBodies(Collection<ForumPostHeader> headers)
-			throws DbException {
-		// Get Bodies
-		long now = System.currentTimeMillis();
-		for (ForumPostHeader header : headers) {
-			if (!bodyCache.containsKey(header.getId())) {
-				byte[] body = forumManager.getPostBody(header.getId());
-				bodyCache.put(header.getId(), body);
-			}
-		}
-		long duration = System.currentTimeMillis() - now;
-		if (LOG.isLoggable(INFO))
-			LOG.info("Loading bodies took " + duration + " ms");
-	}
-
-	private List<ForumEntry> buildForumEntries(
-			Collection<ForumPostHeader> headers) {
-		List<ForumEntry> entries = new ArrayList<>();
-		for (ForumPostHeader h : headers) {
-			byte[] body = bodyCache.get(h.getId());
-			entries.add(new ForumEntry(h, StringUtils.fromUtf8(body)));
-		}
-		return entries;
-	}
-
-	private void updateNewestTimeStamp(Collection<ForumPostHeader> headers) {
-		for (ForumPostHeader h : headers) {
-			updateNewestTimestamp(h.getTimestamp());
-		}
-	}
-
 	@Override
-	public void loadForum(final GroupId groupId,
-			final ResultExceptionHandler<List<ForumEntry>, DbException> resultHandler) {
-		runOnDbThread(new Runnable() {
-			@Override
-			public void run() {
-				LOG.info("Loading forum...");
-				try {
-					if (forum == null) {
-						loadForum(groupId);
-					}
-					// Get Forum Posts and Bodies
-					Collection<ForumPostHeader> headers = loadHeaders();
-					updateNewestTimeStamp(headers);
-					loadBodies(headers);
-					resultHandler.onResult(buildForumEntries(headers));
-				} catch (DbException e) {
-					if (LOG.isLoggable(WARNING))
-						LOG.log(WARNING, e.toString(), e);
-					resultHandler.onException(e);
-				}
-			}
-		});
+	protected Forum loadNamedGroup() throws DbException {
+		return forumManager.getForum(getGroupId());
 	}
 
 	@Override
-	@Nullable
-	public Forum getForum() {
-		return forum;
+	protected Collection<ForumPostHeader> loadHeaders() throws DbException {
+		return forumManager.getPostHeaders(getGroupId());
 	}
 
 	@Override
-	public void loadPost(final ForumPostHeader header,
-			final ResultExceptionHandler<ForumEntry, DbException> resultHandler) {
-		runOnDbThread(new Runnable() {
-			@Override
-			public void run() {
-				LOG.info("Loading post...");
-				try {
-					loadBodies(Collections.singletonList(header));
-					resultHandler.onResult(new ForumEntry(header, StringUtils
-							.fromUtf8(bodyCache.get(header.getId()))));
-				} catch (DbException e) {
-					if (LOG.isLoggable(WARNING))
-						LOG.log(WARNING, e.toString(), e);
-					resultHandler.onException(e);
-				}
-			}
-		});
+	protected String loadMessageBody(MessageId id) throws DbException {
+		return StringUtils.fromUtf8(forumManager.getPostBody(id));
 	}
 
 	@Override
-	public void unsubscribe(final ResultHandler<Boolean> resultHandler) {
-		if (forum == null) return;
-		runOnDbThread(new Runnable() {
-			@Override
-			public void run() {
-				try {
-					long now = System.currentTimeMillis();
-					forumManager.removeForum(forum);
-					long duration = System.currentTimeMillis() - now;
-					if (LOG.isLoggable(INFO))
-						LOG.info("Removing forum took " + duration + " ms");
-					resultHandler.onResult(true);
-				} catch (DbException e) {
-					if (LOG.isLoggable(WARNING))
-						LOG.log(WARNING, e.toString(), e);
-					resultHandler.onResult(false);
-				}
-			}
-		});
+	protected void markRead(MessageId id) throws DbException {
+		forumManager.setReadFlag(getGroupId(), id, true);
 	}
 
 	@Override
-	public void entryRead(ForumEntry forumEntry) {
-		entriesRead(Collections.singletonList(forumEntry));
+	protected long getLatestTimestamp() throws DbException {
+		GroupCount count = forumManager.getGroupCount(getGroupId());
+		return count.getLatestMsgTime();
 	}
 
 	@Override
-	public void entriesRead(final Collection<ForumEntry> forumEntries) {
-		if (forum == null) return;
-		runOnDbThread(new Runnable() {
-			@Override
-			public void run() {
-				try {
-					long now = System.currentTimeMillis();
-					for (ForumEntry fe : forumEntries) {
-						forumManager
-								.setReadFlag(forum.getId(), fe.getId(), true);
-					}
-					long duration = System.currentTimeMillis() - now;
-					if (LOG.isLoggable(INFO))
-						LOG.info("Marking read took " + duration + " ms");
-				} catch (DbException e) {
-					if (LOG.isLoggable(WARNING))
-						LOG.log(WARNING, e.toString(), e);
-				}
-			}
-		});
+	protected ForumPost createLocalMessage(String body, long timestamp,
+			@Nullable MessageId parentId, LocalAuthor author) {
+		return forumManager
+				.createLocalPost(getGroupId(), body, timestamp, parentId,
+						author);
 	}
 
 	@Override
-	public void createPost(byte[] body,
-			ResultExceptionHandler<ForumEntry, DbException> resultHandler) {
-		createPost(body, null, resultHandler);
+	protected ForumPostHeader addLocalMessage(ForumPost p)
+			throws DbException {
+		return forumManager.addLocalPost(p);
 	}
 
 	@Override
-	public void createPost(final byte[] body, final MessageId parentId,
-			final ResultExceptionHandler<ForumEntry, DbException> resultHandler) {
-		cryptoExecutor.execute(new Runnable() {
-			@Override
-			public void run() {
-				LOG.info("Create post...");
-				long timestamp = System.currentTimeMillis();
-				timestamp = Math.max(timestamp, newestTimeStamp.get());
-				ForumPost p;
-				try {
-					KeyParser keyParser = crypto.getSignatureKeyParser();
-					byte[] b = localAuthor.getPrivateKey();
-					PrivateKey authorKey = keyParser.parsePrivateKey(b);
-					p = forumPostFactory.createPseudonymousPost(
-							forum.getId(), timestamp, parentId, localAuthor,
-							"text/plain", body, authorKey);
-				} catch (GeneralSecurityException | FormatException e) {
-					throw new RuntimeException(e);
-				}
-				bodyCache.put(p.getMessage().getId(), body);
-				storePost(p, resultHandler);
-			}
-		});
+	protected void deleteNamedGroup(Forum forum) throws DbException {
+		forumManager.removeForum(forum);
 	}
 
-	private void storePost(final ForumPost p,
-			final ResultExceptionHandler<ForumEntry, DbException> resultHandler) {
-		runOnDbThread(new Runnable() {
-			@Override
-			public void run() {
-				try {
-					LOG.info("Store post...");
-					long now = System.currentTimeMillis();
-					forumManager.addLocalPost(p);
-					long duration = System.currentTimeMillis() - now;
-					if (LOG.isLoggable(INFO))
-						LOG.info("Storing message took " + duration + " ms");
-
-					ForumPostHeader h =
-							new ForumPostHeader(p.getMessage().getId(),
-									p.getParent(),
-									p.getMessage().getTimestamp(),
-									p.getAuthor(), OURSELVES, true);
-
-					resultHandler.onResult(new ForumEntry(h, StringUtils
-							.fromUtf8(bodyCache.get(p.getMessage().getId()))));
-
-				} catch (DbException e) {
-					if (LOG.isLoggable(WARNING))
-						LOG.log(WARNING, e.toString(), e);
-					resultHandler.onException(e);
-				}
-			}
-		});
+	@Override
+	protected ForumItem buildItem(ForumPostHeader header, String body) {
+		return new ForumItem(header, body);
 	}
 
-	private void updateNewestTimestamp(long update) {
-		long newest = newestTimeStamp.get();
-		while (newest < update) {
-			if (newestTimeStamp.compareAndSet(newest, update)) return;
-			newest = newestTimeStamp.get();
-		}
-	}
 }
diff --git a/briar-android/src/org/briarproject/android/forum/ForumItem.java b/briar-android/src/org/briarproject/android/forum/ForumItem.java
new file mode 100644
index 0000000000000000000000000000000000000000..80f28e2d3aaf508e8d0581db1e23cb3d3051efbc
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/forum/ForumItem.java
@@ -0,0 +1,24 @@
+package org.briarproject.android.forum;
+
+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;
+
+import javax.annotation.concurrent.NotThreadSafe;
+
+@NotThreadSafe
+public class ForumItem extends ThreadItem {
+
+	ForumItem(ForumPostHeader h, String body) {
+		super(h.getId(), h.getParentId(), body, h.getTimestamp(), h.getAuthor(),
+				h.getAuthorStatus(), h.isRead());
+	}
+
+	public ForumItem(MessageId messageId, MessageId parentId, String text,
+			long timestamp, Author author, Status status) {
+		super(messageId, parentId, text, timestamp, author, status, true);
+	}
+
+}
diff --git a/briar-android/src/org/briarproject/android/forum/ForumListAdapter.java b/briar-android/src/org/briarproject/android/forum/ForumListAdapter.java
index ca237eb35e8847002c3aec56dd3894a0cd285911..040b81662b884d6394d67fdcdc8b16ea31ea489d 100644
--- a/briar-android/src/org/briarproject/android/forum/ForumListAdapter.java
+++ b/briar-android/src/org/briarproject/android/forum/ForumListAdapter.java
@@ -2,7 +2,6 @@ package org.briarproject.android.forum;
 
 import android.content.Context;
 import android.content.Intent;
-import android.support.annotation.Nullable;
 import android.support.v4.content.ContextCompat;
 import android.support.v7.widget.RecyclerView;
 import android.view.LayoutInflater;
@@ -21,7 +20,7 @@ import static android.support.v7.util.SortedList.INVALID_POSITION;
 import static android.view.View.GONE;
 import static android.view.View.VISIBLE;
 import static org.briarproject.android.BriarActivity.GROUP_ID;
-import static org.briarproject.android.forum.ForumActivity.FORUM_NAME;
+import static org.briarproject.android.BriarActivity.GROUP_NAME;
 
 class ForumListAdapter
 		extends BriarAdapter<ForumListItem, ForumListAdapter.ForumViewHolder> {
@@ -84,7 +83,7 @@ class ForumListAdapter
 				Intent i = new Intent(ctx, ForumActivity.class);
 				Forum f = item.getForum();
 				i.putExtra(GROUP_ID, f.getId().getBytes());
-				i.putExtra(FORUM_NAME, f.getName());
+				i.putExtra(GROUP_NAME, f.getName());
 				ctx.startActivity(i);
 			}
 		});
@@ -115,17 +114,6 @@ class ForumListAdapter
 		return a.getForum().equals(b.getForum());
 	}
 
-	@Nullable
-	public ForumListItem findItem(GroupId g) {
-		for (int i = 0; i < items.size(); i++) {
-			ForumListItem item = items.get(i);
-			if (item.getForum().getGroup().getId().equals(g)) {
-				return item;
-			}
-		}
-		return null;
-	}
-
 	int findItemPosition(GroupId g) {
 		int count = getItemCount();
 		for (int i = 0; i < count; i++) {
diff --git a/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java b/briar-android/src/org/briarproject/android/forum/NestedForumAdapter.java
index f1e2f5de2637a7f53bea3913b603aade60769df9..f690df90afea7c5222b737d08afd3cfd9372f349 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<ForumItem> {
 
-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<ForumItem> 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 0000000000000000000000000000000000000000..b73558ff5d668c1adf37f0e8e69a9f971411967c
--- /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<ForumItem> {
+
+	public NestedForumHolder(View v) {
+		super(v);
+	}
+
+}
diff --git a/briar-android/src/org/briarproject/android/privategroup/conversation/GroupActivity.java b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..71a4865b516bf1e14e8b5d1ef9c6317179092c2a
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupActivity.java
@@ -0,0 +1,112 @@
+package org.briarproject.android.privategroup.conversation;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.support.annotation.LayoutRes;
+import android.support.annotation.StringRes;
+import android.support.v7.app.ActionBar;
+import android.support.v7.widget.LinearLayoutManager;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+
+import org.briarproject.R;
+import org.briarproject.android.ActivityComponent;
+import org.briarproject.android.threaded.ThreadListActivity;
+import org.briarproject.android.threaded.ThreadListController;
+import org.briarproject.api.privategroup.GroupMessageHeader;
+import org.briarproject.api.privategroup.PrivateGroup;
+
+import javax.inject.Inject;
+
+import static org.briarproject.api.privategroup.PrivateGroupConstants.MAX_GROUP_POST_BODY_LENGTH;
+
+public class GroupActivity extends
+		ThreadListActivity<PrivateGroup, GroupMessageItem, GroupMessageHeader, GroupMessageAdapter> {
+
+	@Inject
+	GroupController controller;
+
+	@Override
+	public void injectActivity(ActivityComponent component) {
+		component.inject(this);
+	}
+
+	@Override
+	protected ThreadListController<PrivateGroup, GroupMessageItem, GroupMessageHeader> getController() {
+		return controller;
+	}
+
+	@Override
+	public void onCreate(Bundle state) {
+		super.onCreate(state);
+
+		Intent i = getIntent();
+		String groupName = i.getStringExtra(GROUP_NAME);
+		if (groupName != null) setTitle(groupName);
+		loadNamedGroup();
+
+		list.setEmptyText(R.string.groups_no_messages);
+	}
+
+	@Override
+	protected void onNamedGroupLoaded(PrivateGroup group) {
+		setTitle(group.getName());
+		// Created by
+		ActionBar actionBar = getSupportActionBar();
+		if (actionBar != null) {
+			actionBar.setSubtitle(getString(R.string.groups_created_by,
+					group.getAuthor().getName()));
+		}
+	}
+
+	@Override
+	@LayoutRes
+	protected int getLayout() {
+		return R.layout.activity_forum;
+	}
+
+	@Override
+	protected GroupMessageAdapter createAdapter(
+			LinearLayoutManager layoutManager) {
+		return new GroupMessageAdapter(this, layoutManager);
+	}
+
+	@Override
+	public boolean onCreateOptionsMenu(Menu menu) {
+		// Inflate the menu items for use in the action bar
+		MenuInflater inflater = getMenuInflater();
+		inflater.inflate(R.menu.group_actions, menu);
+
+		return super.onCreateOptionsMenu(menu);
+	}
+
+	@Override
+	public boolean onOptionsItemSelected(final MenuItem item) {
+		switch (item.getItemId()) {
+			case R.id.action_group_compose_message:
+				showTextInput(null);
+				return true;
+			default:
+				return super.onOptionsItemSelected(item);
+		}
+	}
+
+	@Override
+	protected int getMaxBodyLength() {
+		return MAX_GROUP_POST_BODY_LENGTH;
+	}
+
+	@Override
+	@StringRes
+	protected int getItemPostedString() {
+		return R.string.groups_message_sent;
+	}
+
+	@Override
+	@StringRes
+	protected int getItemReceivedString() {
+		return R.string.groups_message_received;
+	}
+
+}
diff --git a/briar-android/src/org/briarproject/android/privategroup/conversation/GroupController.java b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupController.java
new file mode 100644
index 0000000000000000000000000000000000000000..0292f22787bba631e676d6eed41945ca4bbf865e
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupController.java
@@ -0,0 +1,11 @@
+package org.briarproject.android.privategroup.conversation;
+
+import org.briarproject.android.threaded.ThreadListController;
+import org.briarproject.api.privategroup.GroupMessageHeader;
+import org.briarproject.api.privategroup.PrivateGroup;
+
+public interface GroupController
+		extends
+		ThreadListController<PrivateGroup, GroupMessageItem, GroupMessageHeader> {
+
+}
diff --git a/briar-android/src/org/briarproject/android/privategroup/conversation/GroupControllerImpl.java b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupControllerImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..fb0cd8ac59136aa84e4fc01910f5c5ecffb5072a
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupControllerImpl.java
@@ -0,0 +1,126 @@
+package org.briarproject.android.privategroup.conversation;
+
+import android.support.annotation.Nullable;
+
+import org.briarproject.android.api.AndroidNotificationManager;
+import org.briarproject.android.threaded.ThreadListControllerImpl;
+import org.briarproject.api.clients.MessageTracker.GroupCount;
+import org.briarproject.api.crypto.CryptoExecutor;
+import org.briarproject.api.db.DatabaseExecutor;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.event.Event;
+import org.briarproject.api.event.EventBus;
+import org.briarproject.api.event.GroupMessageAddedEvent;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.identity.LocalAuthor;
+import org.briarproject.api.lifecycle.LifecycleManager;
+import org.briarproject.api.privategroup.GroupMessage;
+import org.briarproject.api.privategroup.GroupMessageHeader;
+import org.briarproject.api.privategroup.PrivateGroup;
+import org.briarproject.api.privategroup.PrivateGroupManager;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.system.Clock;
+
+import java.util.Collection;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+public class GroupControllerImpl
+		extends ThreadListControllerImpl<PrivateGroup, GroupMessageItem, GroupMessageHeader, GroupMessage>
+		implements GroupController {
+
+	private static final Logger LOG =
+			Logger.getLogger(GroupControllerImpl.class.getName());
+
+	private final PrivateGroupManager privateGroupManager;
+
+	@Inject
+	GroupControllerImpl(@DatabaseExecutor Executor dbExecutor,
+			LifecycleManager lifecycleManager, IdentityManager identityManager,
+			@CryptoExecutor Executor cryptoExecutor,
+			PrivateGroupManager privateGroupManager, EventBus eventBus,
+			AndroidNotificationManager notificationManager, Clock clock) {
+		super(dbExecutor, lifecycleManager, identityManager, cryptoExecutor,
+				eventBus, notificationManager, clock);
+		this.privateGroupManager = privateGroupManager;
+	}
+
+	@Override
+	public void onActivityResume() {
+		super.onActivityResume();
+		// TODO: Add new notification manager methods for private groups
+	}
+
+	@Override
+	public void eventOccurred(Event e) {
+		super.eventOccurred(e);
+
+		if (e instanceof GroupMessageAddedEvent) {
+			GroupMessageAddedEvent gmae = (GroupMessageAddedEvent) e;
+			if (!gmae.isLocal() && gmae.getGroupId().equals(getGroupId())) {
+				LOG.info("Group message received, adding...");
+				final GroupMessageHeader h = gmae.getHeader();
+				listener.runOnUiThreadUnlessDestroyed(new Runnable() {
+					@Override
+					public void run() {
+						listener.onHeaderReceived(h);
+					}
+				});
+			}
+		}
+	}
+
+	@Override
+	protected PrivateGroup loadNamedGroup() throws DbException {
+		return privateGroupManager.getPrivateGroup(getGroupId());
+	}
+
+	@Override
+	protected Collection<GroupMessageHeader> loadHeaders() throws DbException {
+		return privateGroupManager.getHeaders(getGroupId());
+	}
+
+	@Override
+	protected String loadMessageBody(MessageId id) throws DbException {
+		return privateGroupManager.getMessageBody(id);
+	}
+
+	@Override
+	protected void markRead(MessageId id) throws DbException {
+		privateGroupManager.setReadFlag(getGroupId(), id, true);
+	}
+
+	@Override
+	protected long getLatestTimestamp() throws DbException {
+		GroupCount count = privateGroupManager.getGroupCount(getGroupId());
+		return count.getLatestMsgTime();
+	}
+
+	@Override
+	protected GroupMessage createLocalMessage(String body, long timestamp,
+			@Nullable MessageId parentId, LocalAuthor author) {
+		return privateGroupManager
+				.createLocalMessage(getGroupId(), body, timestamp, parentId,
+						author);
+	}
+
+	@Override
+	protected GroupMessageHeader addLocalMessage(GroupMessage message)
+			throws DbException {
+		return privateGroupManager.addLocalMessage(message);
+	}
+
+	@Override
+	protected void deleteNamedGroup(PrivateGroup group) throws DbException {
+		privateGroupManager.removePrivateGroup(group.getId());
+	}
+
+	@Override
+	protected GroupMessageItem buildItem(GroupMessageHeader header,
+			String body) {
+		return new GroupMessageItem(header, body);
+	}
+
+}
diff --git a/briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageAdapter.java b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageAdapter.java
new file mode 100644
index 0000000000000000000000000000000000000000..19ee14adce62158808c7f795e9eb505ec889cd2f
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageAdapter.java
@@ -0,0 +1,28 @@
+package org.briarproject.android.privategroup.conversation;
+
+import android.support.annotation.UiThread;
+import android.support.v7.widget.LinearLayoutManager;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+import org.briarproject.R;
+import org.briarproject.android.threaded.ThreadItemAdapter;
+
+@UiThread
+public class GroupMessageAdapter extends ThreadItemAdapter<GroupMessageItem> {
+
+	public GroupMessageAdapter(ThreadItemListener<GroupMessageItem> listener,
+			LinearLayoutManager layoutManager) {
+		super(listener, layoutManager);
+	}
+
+	@Override
+	public GroupMessageViewHolder onCreateViewHolder(ViewGroup parent,
+			int viewType) {
+		View v = LayoutInflater.from(parent.getContext())
+				.inflate(R.layout.list_item_forum_post, parent, false);
+		return new GroupMessageViewHolder(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 0000000000000000000000000000000000000000..ee84c1fb00b756fc07098cf0b1fefcaab9399f41
--- /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/privategroup/conversation/GroupMessageViewHolder.java b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageViewHolder.java
new file mode 100644
index 0000000000000000000000000000000000000000..11825b8056e6ee3002338956a6599c2a4b949b21
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/privategroup/conversation/GroupMessageViewHolder.java
@@ -0,0 +1,14 @@
+package org.briarproject.android.privategroup.conversation;
+
+import android.view.View;
+
+import org.briarproject.android.threaded.ThreadItemViewHolder;
+
+public class GroupMessageViewHolder
+		extends ThreadItemViewHolder<GroupMessageItem> {
+
+	public GroupMessageViewHolder(View v) {
+		super(v);
+	}
+
+}
diff --git a/briar-android/src/org/briarproject/android/privategroup/list/GroupListAdapter.java b/briar-android/src/org/briarproject/android/privategroup/list/GroupListAdapter.java
index 20210a486c9a903cd63d1f686d92a390a0d89716..bae48022a7e8599cf4bbeef00f069ee88ed7651c 100644
--- a/briar-android/src/org/briarproject/android/privategroup/list/GroupListAdapter.java
+++ b/briar-android/src/org/briarproject/android/privategroup/list/GroupListAdapter.java
@@ -1,7 +1,6 @@
 package org.briarproject.android.privategroup.list;
 
 import android.content.Context;
-import android.support.annotation.Nullable;
 import android.view.LayoutInflater;
 import android.view.View;
 import android.view.ViewGroup;
@@ -32,7 +31,7 @@ class GroupListAdapter extends BriarAdapter<GroupItem, GroupViewHolder> {
 
 	@Override
 	public void onBindViewHolder(GroupViewHolder ui, int position) {
-		ui.bindView(ctx, getItemAt(position), listener);
+		ui.bindView(ctx, items.get(position), listener);
 	}
 
 	@Override
diff --git a/briar-android/src/org/briarproject/android/privategroup/list/GroupListControllerImpl.java b/briar-android/src/org/briarproject/android/privategroup/list/GroupListControllerImpl.java
index 9ad7536eb70f6544a47282797050577ae4be63b7..9db84cdcadfa143748c975dce863815075ba0d1c 100644
--- a/briar-android/src/org/briarproject/android/privategroup/list/GroupListControllerImpl.java
+++ b/briar-android/src/org/briarproject/android/privategroup/list/GroupListControllerImpl.java
@@ -68,6 +68,7 @@ public class GroupListControllerImpl extends DbControllerImpl
 			throw new IllegalStateException(
 					"GroupListListener needs to be attached");
 		eventBus.addListener(this);
+		// TODO: Add new notification manager methods for private groups
 	}
 
 	@Override
diff --git a/briar-android/src/org/briarproject/android/privategroup/list/GroupViewHolder.java b/briar-android/src/org/briarproject/android/privategroup/list/GroupViewHolder.java
index 12b956df4f688597947569e31fb25df1b5f0302a..c7d59dd1ff12611509016cd1d77a99d17dffa4dd 100644
--- a/briar-android/src/org/briarproject/android/privategroup/list/GroupViewHolder.java
+++ b/briar-android/src/org/briarproject/android/privategroup/list/GroupViewHolder.java
@@ -1,7 +1,8 @@
 package org.briarproject.android.privategroup.list;
 
 import android.content.Context;
-import android.support.annotation.Nullable;
+import android.content.Intent;
+import android.support.v4.app.ActivityOptionsCompat;
 import android.support.v7.widget.RecyclerView;
 import android.view.View;
 import android.view.View.OnClickListener;
@@ -10,13 +11,18 @@ import android.widget.Button;
 import android.widget.TextView;
 
 import org.briarproject.R;
+import org.briarproject.android.privategroup.conversation.GroupActivity;
 import org.briarproject.android.util.AndroidUtils;
 import org.briarproject.android.view.TextAvatarView;
+import org.briarproject.api.sync.GroupId;
 import org.jetbrains.annotations.NotNull;
 
 import static android.support.v4.content.ContextCompat.getColor;
+import static android.support.v4.content.ContextCompat.startActivities;
 import static android.view.View.GONE;
 import static android.view.View.VISIBLE;
+import static org.briarproject.android.BriarActivity.GROUP_ID;
+import static org.briarproject.android.BriarActivity.GROUP_NAME;
 
 class GroupViewHolder extends RecyclerView.ViewHolder {
 
@@ -44,10 +50,8 @@ class GroupViewHolder extends RecyclerView.ViewHolder {
 		remove = (Button) v.findViewById(R.id.removeButton);
 	}
 
-	void bindView(Context ctx, @Nullable final GroupItem group,
+	void bindView(final Context ctx, final GroupItem group,
 			@NotNull final OnGroupRemoveClickListener listener) {
-		if (group == null) return;
-
 		// Avatar
 		avatar.setText(group.getName().substring(0, 1));
 		avatar.setBackgroundBytes(group.getId().getBytes());
@@ -115,15 +119,15 @@ class GroupViewHolder extends RecyclerView.ViewHolder {
 		layout.setOnClickListener(new OnClickListener() {
 			@Override
 			public void onClick(View v) {
-/*
 				Intent i = new Intent(ctx, GroupActivity.class);
-				GroupId id = item.getId();
+				GroupId id = group.getId();
 				i.putExtra(GROUP_ID, id.getBytes());
+				i.putExtra(GROUP_NAME, group.getName());
 				ActivityOptionsCompat options = ActivityOptionsCompat
 						.makeCustomAnimation(ctx, android.R.anim.fade_in,
 								android.R.anim.fade_out);
-				ActivityCompat.startActivity(ctx, i, options.toBundle());
-*/
+				Intent[] intents = {i};
+				startActivities(ctx, intents, options.toBundle());
 			}
 		});
 	}
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 d190a9f6dec35496cdd6dae2b9dd71a8ae80bf96..aec3c58beb0f33dae8a671c55755821cfc6aa6cd 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/forum/ForumEntry.java b/briar-android/src/org/briarproject/android/threaded/ThreadItem.java
similarity index 62%
rename from briar-android/src/org/briarproject/android/forum/ForumEntry.java
rename to briar-android/src/org/briarproject/android/threaded/ThreadItem.java
index 49e1ee32f59cb3d0e95c6e0bc3622213e60afcbb..a5a22075995246e5859671bc7b64995324bb75ea 100644
--- a/briar-android/src/org/briarproject/android/forum/ForumEntry.java
+++ b/briar-android/src/org/briarproject/android/threaded/ThreadItem.java
@@ -1,41 +1,35 @@
-package org.briarproject.android.forum;
+package org.briarproject.android.threaded;
 
-import org.briarproject.api.clients.MessageTree;
-import org.briarproject.api.forum.ForumPostHeader;
+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;
 
-/* This class is not thread safe */
-public class ForumEntry implements MessageTree.MessageNode {
+import static org.briarproject.android.threaded.ThreadItemAdapter.UNDEFINED;
 
-	public final static int LEVEL_UNDEFINED = -1;
+/* 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 Status status;
-	private int level = LEVEL_UNDEFINED;
+	private final Status status;
+	private int level = UNDEFINED;
 	private boolean isShowingDescendants = true;
 	private int descendantCount = 0;
-	private boolean isRead = true;
+	private boolean isRead;
 
-	ForumEntry(ForumPostHeader h, String text) {
-		this(h.getId(), h.getParentId(), text, h.getTimestamp(), h.getAuthor(),
-				h.getAuthorStatus());
-		this.isRead = h.isRead();
-	}
-
-	public ForumEntry(MessageId messageId, MessageId parentId, String text,
-			long timestamp, Author author, Status status) {
+	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() {
@@ -56,6 +50,7 @@ public class ForumEntry implements MessageTree.MessageNode {
 		return parentId;
 	}
 
+	@Override
 	public long getTimestamp() {
 		return timestamp;
 	}
@@ -68,27 +63,24 @@ public class ForumEntry implements MessageTree.MessageNode {
 		return status;
 	}
 
-	boolean isShowingDescendants() {
+	public boolean isShowingDescendants() {
 		return isShowingDescendants;
 	}
 
+	@Override
 	public void setLevel(int level) {
 		this.level = level;
 	}
 
-	void setShowingDescendants(boolean showingDescendants) {
+	public void setShowingDescendants(boolean showingDescendants) {
 		this.isShowingDescendants = showingDescendants;
 	}
 
-	MessageId getMessageId() {
-		return messageId;
-	}
-
 	public boolean isRead() {
 		return isRead;
 	}
 
-	void setRead(boolean read) {
+	public void setRead(boolean read) {
 		isRead = read;
 	}
 
@@ -96,6 +88,7 @@ public class ForumEntry implements MessageTree.MessageNode {
 		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 0000000000000000000000000000000000000000..129e5348593003ac3e4783b2b43324455ea4ad58
--- /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.Collection;
+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) {
+		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(Collection<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(MessageId id) {
+		for (I item : items) {
+			if (item.getId().equals(id)) {
+				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 0000000000000000000000000000000000000000..0b5a5ddc5b74cc34bee5bb6d63b2534ce2aa0227
--- /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<I> 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 0000000000000000000000000000000000000000..889399525de6ab9a880511dd43789533d65b835d
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/threaded/ThreadListActivity.java
@@ -0,0 +1,304 @@
+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.annotation.UiThread;
+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.controller.handler.UiResultExceptionHandler;
+import org.briarproject.android.threaded.ThreadItemAdapter.ThreadItemListener;
+import org.briarproject.android.threaded.ThreadListController.ThreadListListener;
+import org.briarproject.android.view.BriarRecyclerView;
+import org.briarproject.android.view.TextInputView;
+import org.briarproject.android.view.TextInputView.TextInputListener;
+import org.briarproject.api.clients.NamedGroup;
+import org.briarproject.api.clients.PostHeader;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.util.StringUtils;
+
+import java.util.Collection;
+
+import static android.support.design.widget.Snackbar.make;
+import static android.view.View.GONE;
+import static android.view.View.VISIBLE;
+
+public abstract class ThreadListActivity<G extends NamedGroup, I extends ThreadItem, H extends PostHeader, A extends ThreadItemAdapter<I>>
+		extends BriarActivity
+		implements ThreadListListener<H>, TextInputListener,
+		ThreadItemListener<I> {
+
+	protected static final String KEY_INPUT_VISIBILITY = "inputVisibility";
+	protected static final String KEY_REPLY_ID = "replyId";
+
+	protected A adapter;
+	protected BriarRecyclerView list;
+	protected TextInputView textInput;
+	protected GroupId groupId;
+	private MessageId replyId;
+
+	protected abstract ThreadListController<G, I, H> getController();
+
+	@CallSuper
+	@Override
+	@SuppressWarnings("ConstantConditions")
+	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);
+		getController().setGroupId(groupId);
+
+		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);
+
+		if (state != null) {
+			byte[] replyIdBytes = state.getByteArray(KEY_REPLY_ID);
+			if(replyIdBytes != null) replyId = new MessageId(replyIdBytes);
+		}
+
+		loadItems();
+	}
+
+	@LayoutRes
+	protected abstract int getLayout();
+
+	protected abstract A createAdapter(LinearLayoutManager layoutManager);
+
+	protected void loadNamedGroup() {
+		getController().loadNamedGroup(
+				new UiResultExceptionHandler<G, DbException>(this) {
+					@Override
+					public void onResultUi(G groupItem) {
+						onNamedGroupLoaded(groupItem);
+					}
+
+					@Override
+					public void onExceptionUi(DbException exception) {
+						// TODO Proper error handling
+						finish();
+					}
+				});
+	}
+
+	@UiThread
+	protected abstract void onNamedGroupLoaded(G groupItem);
+
+	private void loadItems() {
+		getController().loadItems(
+				new UiResultExceptionHandler<Collection<I>, DbException>(
+						this) {
+					@Override
+					public void onResultUi(Collection<I> items) {
+						if (items.isEmpty()) {
+							list.showData();
+						} else {
+							adapter.setItems(items);
+							list.showData();
+							if (replyId != null)
+								adapter.setReplyItemById(replyId);
+						}
+					}
+
+					@Override
+					public void onExceptionUi(DbException exception) {
+						// TODO Proper error handling
+						finish();
+					}
+				});
+	}
+
+	@CallSuper
+	@Override
+	public void onResume() {
+		super.onResume();
+		list.startPeriodicUpdate();
+	}
+
+	@CallSuper
+	@Override
+	public void onPause() {
+		super.onPause();
+		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);
+			getController().markItemRead(item);
+		}
+	}
+
+	@Override
+	public void onReplyClick(I item) {
+		showTextInput(item);
+	}
+
+	protected void displaySnackbarShort(@StringRes 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;
+		if (StringUtils.isTooLong(text, getMaxBodyLength())) {
+			displaySnackbarShort(R.string.text_too_long);
+			return;
+		}
+		I replyItem = adapter.getReplyItem();
+		UiResultExceptionHandler<I, DbException> handler =
+				new UiResultExceptionHandler<I, DbException>(this) {
+					@Override
+					public void onResultUi(I result) {
+						addItem(result, true);
+					}
+
+					@Override
+					public void onExceptionUi(DbException exception) {
+						// TODO add proper exception handling
+						finish();
+					}
+				};
+		getController().createAndStoreMessage(text,
+				replyItem != null ? replyItem.getId() : null, handler);
+		textInput.hideSoftKeyboard();
+		textInput.setVisibility(GONE);
+		adapter.setReplyItem(null);
+	}
+
+	protected abstract int getMaxBodyLength();
+
+	@Override
+	public void onHeaderReceived(H header) {
+		getController().loadItem(header,
+				new UiResultExceptionHandler<I, DbException>(this) {
+					@Override
+					public void onResultUi(final I result) {
+						addItem(result, false);
+					}
+
+					@Override
+					public void onExceptionUi(DbException exception) {
+						// TODO add proper exception handling
+						finish();
+					}
+				});
+	}
+
+	@Override
+	public void onGroupRemoved() {
+		supportFinishAfterTransition();
+	}
+
+	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();
+		}
+	}
+
+	@StringRes
+	protected abstract int getItemPostedString();
+
+	@StringRes
+	protected abstract int getItemReceivedString();
+
+}
diff --git a/briar-android/src/org/briarproject/android/threaded/ThreadListController.java b/briar-android/src/org/briarproject/android/threaded/ThreadListController.java
new file mode 100644
index 0000000000000000000000000000000000000000..a7731d8d48ed2046f39c962e8781b86c4c9c396d
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/threaded/ThreadListController.java
@@ -0,0 +1,45 @@
+package org.briarproject.android.threaded;
+
+import android.support.annotation.Nullable;
+import android.support.annotation.UiThread;
+
+import org.briarproject.android.DestroyableContext;
+import org.briarproject.android.controller.ActivityLifecycleController;
+import org.briarproject.android.controller.handler.ResultExceptionHandler;
+import org.briarproject.api.clients.NamedGroup;
+import org.briarproject.api.clients.PostHeader;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+
+import java.util.Collection;
+
+public interface ThreadListController<G extends NamedGroup, I extends ThreadItem, H extends PostHeader>
+		extends ActivityLifecycleController {
+
+	void setGroupId(GroupId groupId);
+
+	void loadNamedGroup(ResultExceptionHandler<G, DbException> handler);
+
+	void loadItem(H header, ResultExceptionHandler<I, DbException> handler);
+
+	void loadItems(ResultExceptionHandler<Collection<I>, DbException> handler);
+
+	void markItemRead(I item);
+
+	void markItemsRead(Collection<I> items);
+
+	void createAndStoreMessage(String body, @Nullable MessageId parentId,
+			ResultExceptionHandler<I, DbException> handler);
+
+	void deleteNamedGroup(ResultExceptionHandler<Void, DbException> handler);
+
+	interface ThreadListListener<H> extends DestroyableContext {
+		@UiThread
+		void onHeaderReceived(H header);
+
+		@UiThread
+		void onGroupRemoved();
+	}
+
+}
diff --git a/briar-android/src/org/briarproject/android/threaded/ThreadListControllerImpl.java b/briar-android/src/org/briarproject/android/threaded/ThreadListControllerImpl.java
new file mode 100644
index 0000000000000000000000000000000000000000..ac968758fc0dd462eeb2770d6b5bf932a227787c
--- /dev/null
+++ b/briar-android/src/org/briarproject/android/threaded/ThreadListControllerImpl.java
@@ -0,0 +1,363 @@
+package org.briarproject.android.threaded;
+
+import android.app.Activity;
+import android.support.annotation.CallSuper;
+import android.support.annotation.Nullable;
+
+import org.briarproject.android.api.AndroidNotificationManager;
+import org.briarproject.android.controller.DbControllerImpl;
+import org.briarproject.android.controller.handler.ResultExceptionHandler;
+import org.briarproject.api.clients.BaseMessage;
+import org.briarproject.api.clients.NamedGroup;
+import org.briarproject.api.clients.PostHeader;
+import org.briarproject.api.crypto.CryptoExecutor;
+import org.briarproject.api.db.DatabaseExecutor;
+import org.briarproject.api.db.DbException;
+import org.briarproject.api.event.Event;
+import org.briarproject.api.event.EventBus;
+import org.briarproject.api.event.EventListener;
+import org.briarproject.api.event.GroupRemovedEvent;
+import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.identity.LocalAuthor;
+import org.briarproject.api.lifecycle.LifecycleManager;
+import org.briarproject.api.sync.GroupId;
+import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.system.Clock;
+
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Level.WARNING;
+
+public abstract class ThreadListControllerImpl<G extends NamedGroup, I extends ThreadItem, H extends PostHeader, M extends BaseMessage>
+		extends DbControllerImpl
+		implements ThreadListController<G, I, H>, EventListener {
+
+	private static final Logger LOG =
+			Logger.getLogger(ThreadListControllerImpl.class.getName());
+
+	private final IdentityManager identityManager;
+	private final Executor cryptoExecutor;
+	protected final AndroidNotificationManager notificationManager;
+	private final EventBus eventBus;
+	private final Clock clock;
+
+	private final Map<MessageId, String> bodyCache =
+			new ConcurrentHashMap<>();
+
+	private volatile GroupId groupId;
+
+	protected ThreadListListener<H> listener;
+
+	protected ThreadListControllerImpl(@DatabaseExecutor Executor dbExecutor,
+			LifecycleManager lifecycleManager, IdentityManager identityManager,
+			@CryptoExecutor Executor cryptoExecutor, EventBus eventBus,
+			AndroidNotificationManager notificationManager, Clock clock) {
+		super(dbExecutor, lifecycleManager);
+		this.identityManager = identityManager;
+		this.cryptoExecutor = cryptoExecutor;
+		this.eventBus = eventBus;
+		this.notificationManager = notificationManager;
+		this.clock = clock;
+	}
+
+	@Override
+	public void setGroupId(GroupId groupId) {
+		this.groupId = groupId;
+	}
+
+	@CallSuper
+	@SuppressWarnings("unchecked")
+	@Override
+	public void onActivityCreate(Activity activity) {
+		listener = (ThreadListListener<H>) activity;
+	}
+
+	@CallSuper
+	@Override
+	public void onActivityResume() {
+		notificationManager.blockNotification(getGroupId());
+		eventBus.addListener(this);
+	}
+
+	@CallSuper
+	@Override
+	public void onActivityPause() {
+		notificationManager.unblockNotification(getGroupId());
+		eventBus.removeListener(this);
+	}
+
+	@Override
+	public void onActivityDestroy() {
+	}
+
+	@CallSuper
+	@Override
+	public void eventOccurred(Event e) {
+		if (e instanceof GroupRemovedEvent) {
+			GroupRemovedEvent s = (GroupRemovedEvent) e;
+			if (s.getGroup().getId().equals(getGroupId())) {
+				LOG.info("Group removed");
+				listener.runOnUiThreadUnlessDestroyed(new Runnable() {
+					@Override
+					public void run() {
+						listener.onGroupRemoved();
+					}
+				});
+			}
+		}
+	}
+
+	@Override
+	public void loadNamedGroup(
+			final ResultExceptionHandler<G, DbException> handler) {
+		checkGroupId();
+		runOnDbThread(new Runnable() {
+			@Override
+			public void run() {
+				try {
+					long now = System.currentTimeMillis();
+					G groupItem = loadNamedGroup();
+					long duration = System.currentTimeMillis() - now;
+					if (LOG.isLoggable(INFO))
+						LOG.info(
+								"Loading named group took " + duration + " ms");
+					handler.onResult(groupItem);
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+					handler.onException(e);
+				}
+			}
+		});
+	}
+
+	@DatabaseExecutor
+	protected abstract G loadNamedGroup() throws DbException;
+
+	@Override
+	public void loadItems(
+			final ResultExceptionHandler<Collection<I>, DbException> handler) {
+		checkGroupId();
+		runOnDbThread(new Runnable() {
+			@Override
+			public void run() {
+				LOG.info("Loading items...");
+				try {
+					// Load headers
+					long now = System.currentTimeMillis();
+					Collection<H> headers = loadHeaders();
+					long duration = System.currentTimeMillis() - now;
+					if (LOG.isLoggable(INFO))
+						LOG.info("Loading headers took " + duration + " ms");
+
+					// Load bodies into cache
+					now = System.currentTimeMillis();
+					for (H header : headers) {
+						if (!bodyCache.containsKey(header.getId())) {
+							bodyCache.put(header.getId(),
+									loadMessageBody(header.getId()));
+						}
+					}
+					duration = System.currentTimeMillis() - now;
+					if (LOG.isLoggable(INFO))
+						LOG.info("Loading bodies took " + duration + " ms");
+
+					// Build and hand over items
+					handler.onResult(buildItems(headers));
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+					handler.onException(e);
+				}
+			}
+		});
+	}
+
+	@DatabaseExecutor
+	protected abstract Collection<H> loadHeaders() throws DbException;
+
+	@DatabaseExecutor
+	protected abstract String loadMessageBody(MessageId id) throws DbException;
+
+	@Override
+	public void loadItem(final H header,
+			final ResultExceptionHandler<I, DbException> handler) {
+		runOnDbThread(new Runnable() {
+			@Override
+			public void run() {
+				LOG.info("Loading item...");
+				try {
+					String body;
+					if (!bodyCache.containsKey(header.getId())) {
+						body = loadMessageBody(header.getId());
+						bodyCache.put(header.getId(), body);
+					} else {
+						body = bodyCache.get(header.getId());
+					}
+					I item = buildItem(header, body);
+					handler.onResult(item);
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+					handler.onException(e);
+				}
+			}
+		});
+	}
+
+	@Override
+	public void markItemRead(I item) {
+		markItemsRead(Collections.singletonList(item));
+	}
+
+	@Override
+	public void markItemsRead(final Collection<I> items) {
+		runOnDbThread(new Runnable() {
+			@Override
+			public void run() {
+				try {
+					long now = System.currentTimeMillis();
+					for (I i : items) {
+						markRead(i.getId());
+					}
+					long duration = System.currentTimeMillis() - now;
+					if (LOG.isLoggable(INFO))
+						LOG.info("Marking read took " + duration + " ms");
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+				}
+			}
+		});
+	}
+
+	@DatabaseExecutor
+	protected abstract void markRead(MessageId id) throws DbException;
+
+	@Override
+	public void createAndStoreMessage(final String body,
+			@Nullable final MessageId parentId,
+			final ResultExceptionHandler<I, DbException> handler) {
+		runOnDbThread(new Runnable() {
+			@Override
+			public void run() {
+				try {
+					LocalAuthor author = identityManager.getLocalAuthor();
+					long timestamp = getLatestTimestamp();
+					timestamp =
+							Math.max(timestamp, clock.currentTimeMillis());
+					createMessage(body, timestamp, parentId, author,
+							handler);
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+					handler.onException(e);
+				}
+			}
+		});
+	}
+
+	@DatabaseExecutor
+	protected abstract long getLatestTimestamp() throws DbException;
+
+	private void createMessage(final String body, final long timestamp,
+			final @Nullable MessageId parentId, final LocalAuthor author,
+			final ResultExceptionHandler<I, DbException> handler) {
+		cryptoExecutor.execute(new Runnable() {
+			@Override
+			public void run() {
+				LOG.info("Creating message...");
+				M msg = createLocalMessage(body, timestamp, parentId, author);
+				storePost(msg, body, handler);
+			}
+		});
+	}
+
+	@CryptoExecutor
+	protected abstract M createLocalMessage(String body, long timestamp,
+			@Nullable MessageId parentId, LocalAuthor author);
+
+	private void storePost(final M msg, final String body,
+			final ResultExceptionHandler<I, DbException> resultHandler) {
+		runOnDbThread(new Runnable() {
+			@Override
+			public void run() {
+				try {
+					LOG.info("Store message...");
+					long now = System.currentTimeMillis();
+					H header = addLocalMessage(msg);
+					bodyCache.put(msg.getMessage().getId(), body);
+					long duration = System.currentTimeMillis() - now;
+					if (LOG.isLoggable(INFO))
+						LOG.info("Storing message took " + duration + " ms");
+					resultHandler.onResult(buildItem(header, body));
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+					resultHandler.onException(e);
+				}
+			}
+		});
+	}
+
+	@DatabaseExecutor
+	protected abstract H addLocalMessage(M message) throws DbException;
+
+	@Override
+	public void deleteNamedGroup(
+			final ResultExceptionHandler<Void, DbException> handler) {
+		runOnDbThread(new Runnable() {
+			@Override
+			public void run() {
+				try {
+					long now = System.currentTimeMillis();
+					G groupItem = loadNamedGroup();
+					deleteNamedGroup(groupItem);
+					long duration = System.currentTimeMillis() - now;
+					if (LOG.isLoggable(INFO))
+						LOG.info("Removing group took " + duration + " ms");
+					//noinspection ConstantConditions
+					handler.onResult(null);
+				} catch (DbException e) {
+					if (LOG.isLoggable(WARNING))
+						LOG.log(WARNING, e.toString(), e);
+					handler.onException(e);
+				}
+			}
+		});
+	}
+
+	@DatabaseExecutor
+	protected abstract void deleteNamedGroup(G groupItem) throws DbException;
+
+	private List<I> buildItems(Collection<H> headers) {
+		List<I> entries = new ArrayList<>();
+		for (H h : headers) {
+			entries.add(buildItem(h, bodyCache.get(h.getId())));
+		}
+		return entries;
+	}
+
+	protected abstract I buildItem(H header, String body);
+
+	protected GroupId getGroupId() {
+		checkGroupId();
+		return groupId;
+	}
+
+	private void checkGroupId() {
+		if (groupId == null) {
+			throw new IllegalStateException(
+					"You must set the GroupId before the controller is started.");
+		}
+	}
+
+}
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 3e5431ac9483ee409f930e150327a8957b422f4a..e6290e9716af40673203e0cfba28027469a8b249 100644
--- a/briar-android/test/java/org/briarproject/android/forum/ForumActivityTest.java
+++ b/briar-android/test/java/org/briarproject/android/forum/ForumActivityTest.java
@@ -11,14 +11,12 @@ import org.briarproject.android.controller.handler.UiResultExceptionHandler;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.AuthorId;
-import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.MessageId;
 import org.junit.Before;
 import org.junit.Test;
 import org.junit.runner.RunWith;
 import org.mockito.ArgumentCaptor;
 import org.mockito.Captor;
-import org.mockito.Mockito;
 import org.mockito.MockitoAnnotations;
 import org.robolectric.Robolectric;
 import org.robolectric.RobolectricGradleTestRunner;
@@ -26,6 +24,7 @@ import org.robolectric.annotation.Config;
 
 import java.util.ArrayList;
 import java.util.Arrays;
+import java.util.Collection;
 import java.util.List;
 
 import static junit.framework.Assert.assertEquals;
@@ -81,7 +80,7 @@ public class ForumActivityTest {
 
 	private TestForumActivity forumActivity;
 	@Captor
-	private ArgumentCaptor<UiResultExceptionHandler<List<ForumEntry>, DbException>>
+	private ArgumentCaptor<UiResultExceptionHandler<Collection<ForumItem>, DbException>>
 			rc;
 
 	@Before
@@ -93,14 +92,14 @@ public class ForumActivityTest {
 				.withIntent(intent).create().resume().get();
 	}
 
-	private List<ForumEntry> getDummyData() {
-		ForumEntry[] forumEntries = new ForumEntry[6];
+	private List<ForumItem> getDummyData() {
+		ForumItem[] forumEntries = new ForumItem[6];
 		for (int i = 0; i < forumEntries.length; i++) {
 			AuthorId authorId = new AuthorId(TestUtils.getRandomId());
 			byte[] publicKey = TestUtils.getRandomBytes(MAX_PUBLIC_KEY_LENGTH);
 			Author author = new Author(authorId, AUTHORS[i], publicKey);
 			forumEntries[i] =
-					new ForumEntry(AUTHOR_IDS[i], PARENT_AUTHOR_IDS[i],
+					new ForumItem(AUTHOR_IDS[i], PARENT_AUTHOR_IDS[i],
 							AUTHORS[i], System.currentTimeMillis(), author,
 							UNKNOWN);
 			forumEntries[i].setLevel(LEVELS[i]);
@@ -111,9 +110,8 @@ public class ForumActivityTest {
 	@Test
 	public void testNestedEntries() {
 		ForumController mc = forumActivity.getController();
-		List<ForumEntry> dummyData = getDummyData();
-		verify(mc, times(1))
-				.loadForum(Mockito.any(GroupId.class), rc.capture());
+		List<ForumItem> dummyData = getDummyData();
+		verify(mc, times(1)).loadItems(rc.capture());
 		rc.getValue().onResult(dummyData);
 		NestedForumAdapter adapter = forumActivity.getAdapter();
 		Assert.assertNotNull(adapter);
@@ -126,9 +124,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 +135,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 8acbf3b8df67f46659570d38ffb60ff5daf697c8..806fee299bf73a5bb23a034dfd8541765b1b29d3 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
diff --git a/briar-api/src/org/briarproject/api/blogs/Blog.java b/briar-api/src/org/briarproject/api/blogs/Blog.java
index 5de781e7de8875d3ee305466b59432594fb7cd6f..153eb6e27ccef2ccf6ca0d73d6aae7935f9287df 100644
--- a/briar-api/src/org/briarproject/api/blogs/Blog.java
+++ b/briar-api/src/org/briarproject/api/blogs/Blog.java
@@ -2,19 +2,22 @@ package org.briarproject.api.blogs;
 
 import org.briarproject.api.clients.BaseGroup;
 import org.briarproject.api.identity.Author;
+import org.briarproject.api.nullsafety.NotNullByDefault;
 import org.briarproject.api.sharing.Shareable;
 import org.briarproject.api.sync.Group;
 import org.jetbrains.annotations.NotNull;
 
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
 public class Blog extends BaseGroup implements Shareable {
 
 	private final String description;
 	private final Author author;
 
-	public Blog(@NotNull Group group, @NotNull String name,
-			@NotNull String description, @NotNull Author author) {
-		super(group, name, null);
-
+	public Blog(Group group, String name, String description, Author author) {
+		super(group, name);
 		this.description = description;
 		this.author = author;
 	}
diff --git a/briar-api/src/org/briarproject/api/clients/BaseGroup.java b/briar-api/src/org/briarproject/api/clients/BaseGroup.java
index fe8a282f5b7fb50770be89e170dd3bf889d6171d..f101106e38c1d836e3f1c0d1366e867bdcebb924 100644
--- a/briar-api/src/org/briarproject/api/clients/BaseGroup.java
+++ b/briar-api/src/org/briarproject/api/clients/BaseGroup.java
@@ -1,19 +1,22 @@
 package org.briarproject.api.clients;
 
+import org.briarproject.api.nullsafety.NotNullByDefault;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 import org.jetbrains.annotations.NotNull;
 
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
 public abstract class BaseGroup {
 
 	private final Group group;
 	private final String name;
-	private final byte[] salt;
 
-	public BaseGroup(@NotNull Group group, @NotNull String name, byte[] salt) {
+	public BaseGroup(Group group, String name) {
 		this.group = group;
 		this.name = name;
-		this.salt = salt;
 	}
 
 	@NotNull
@@ -31,10 +34,6 @@ public abstract class BaseGroup {
 		return name;
 	}
 
-	public byte[] getSalt() {
-		return salt;
-	}
-
 	@Override
 	public int hashCode() {
 		return group.hashCode();
diff --git a/briar-api/src/org/briarproject/api/clients/NamedGroup.java b/briar-api/src/org/briarproject/api/clients/NamedGroup.java
new file mode 100644
index 0000000000000000000000000000000000000000..f4b95fdc5cd5a8ced6eb8f18fe9f1b2540d4ce12
--- /dev/null
+++ b/briar-api/src/org/briarproject/api/clients/NamedGroup.java
@@ -0,0 +1,29 @@
+package org.briarproject.api.clients;
+
+import org.briarproject.api.nullsafety.NotNullByDefault;
+import org.briarproject.api.sync.Group;
+import org.jetbrains.annotations.NotNull;
+
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+public abstract class NamedGroup extends BaseGroup {
+
+	private final byte[] salt;
+
+	public NamedGroup(@NotNull Group group, @NotNull String name, byte[] salt) {
+		super(group, name);
+		this.salt = salt;
+	}
+
+	public byte[] getSalt() {
+		return salt;
+	}
+
+	@Override
+	public boolean equals(Object o) {
+		return o instanceof NamedGroup && super.equals(o);
+	}
+
+}
diff --git a/briar-api/src/org/briarproject/api/forum/Forum.java b/briar-api/src/org/briarproject/api/forum/Forum.java
index cdaa091b98960d65cce7d150e0994a785ab8e28a..f73cd049afff86ea54c15bb905b9dd9bb4ccdd0f 100644
--- a/briar-api/src/org/briarproject/api/forum/Forum.java
+++ b/briar-api/src/org/briarproject/api/forum/Forum.java
@@ -1,10 +1,15 @@
 package org.briarproject.api.forum;
 
-import org.briarproject.api.clients.BaseGroup;
+import org.briarproject.api.clients.NamedGroup;
+import org.briarproject.api.nullsafety.NotNullByDefault;
 import org.briarproject.api.sharing.Shareable;
 import org.briarproject.api.sync.Group;
 
-public class Forum extends BaseGroup implements Shareable {
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+public class Forum extends NamedGroup implements Shareable {
 
 	public Forum(Group group, String name, byte[] salt) {
 		super(group, name, salt);
diff --git a/briar-api/src/org/briarproject/api/forum/ForumManager.java b/briar-api/src/org/briarproject/api/forum/ForumManager.java
index 8c35d05e368420f38e6356b77728dc3d3e2f39a6..7561fc9636ab1a0544ee8d4212c00a47d3067c50 100644
--- a/briar-api/src/org/briarproject/api/forum/ForumManager.java
+++ b/briar-api/src/org/briarproject/api/forum/ForumManager.java
@@ -1,11 +1,14 @@
 package org.briarproject.api.forum;
 
 import org.briarproject.api.clients.MessageTracker;
+import org.briarproject.api.crypto.CryptoExecutor;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.db.Transaction;
+import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.MessageId;
+import org.jetbrains.annotations.Nullable;
 
 import java.util.Collection;
 
@@ -20,8 +23,13 @@ public interface ForumManager extends MessageTracker {
 	/** Unsubscribes from a forum. */
 	void removeForum(Forum f) throws DbException;
 
+	/** Creates a local forum post. */
+	@CryptoExecutor
+	ForumPost createLocalPost(GroupId groupId, String body, long timestamp,
+			@Nullable MessageId parentId, LocalAuthor author);
+
 	/** Stores a local forum post. */
-	void addLocalPost(ForumPost p) throws DbException;
+	ForumPostHeader addLocalPost(ForumPost p) throws DbException;
 
 	/** Returns the forum with the given ID. */
 	Forum getForum(GroupId g) throws DbException;
diff --git a/briar-api/src/org/briarproject/api/forum/ForumPost.java b/briar-api/src/org/briarproject/api/forum/ForumPost.java
index aef48c78c4e695b14ff4a8fe0e4ea8af5fafc8a5..bbc23e9db045b12a25ae05cfaf5527c89a756e8a 100644
--- a/briar-api/src/org/briarproject/api/forum/ForumPost.java
+++ b/briar-api/src/org/briarproject/api/forum/ForumPost.java
@@ -9,6 +9,7 @@ import org.jetbrains.annotations.Nullable;
 
 public class ForumPost extends BaseMessage {
 
+	@Nullable
 	private final Author author;
 
 	public ForumPost(@NotNull Message message, @Nullable MessageId parent,
diff --git a/briar-api/src/org/briarproject/api/forum/ForumPostFactory.java b/briar-api/src/org/briarproject/api/forum/ForumPostFactory.java
index e05edc8831b3538458c44175e8a005d59d56d878..1d6aa9e76d9379b1aaa78ba28f34a849153d25dc 100644
--- a/briar-api/src/org/briarproject/api/forum/ForumPostFactory.java
+++ b/briar-api/src/org/briarproject/api/forum/ForumPostFactory.java
@@ -1,8 +1,7 @@
 package org.briarproject.api.forum;
 
 import org.briarproject.api.FormatException;
-import org.briarproject.api.crypto.PrivateKey;
-import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.MessageId;
 
@@ -15,7 +14,6 @@ public interface ForumPostFactory {
 			throws FormatException;
 
 	ForumPost createPseudonymousPost(GroupId groupId, long timestamp,
-			MessageId parent, Author author, String contentType, byte[] body,
-			PrivateKey privateKey) throws FormatException,
-			GeneralSecurityException;
+			MessageId parent, LocalAuthor author, String body)
+			throws FormatException, GeneralSecurityException;
 }
diff --git a/briar-api/src/org/briarproject/api/privategroup/GroupMessage.java b/briar-api/src/org/briarproject/api/privategroup/GroupMessage.java
index 8ffe6c6a46c753ca4feea98c1c9541319151059e..22457de2eba5053e7fc189637bf1f22360061bf5 100644
--- a/briar-api/src/org/briarproject/api/privategroup/GroupMessage.java
+++ b/briar-api/src/org/briarproject/api/privategroup/GroupMessage.java
@@ -1,17 +1,24 @@
 package org.briarproject.api.privategroup;
 
-import org.briarproject.api.forum.ForumPost;
+import org.briarproject.api.clients.BaseMessage;
 import org.briarproject.api.identity.Author;
 import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageId;
 import org.jetbrains.annotations.NotNull;
 import org.jetbrains.annotations.Nullable;
 
-public class GroupMessage extends ForumPost {
+public class GroupMessage extends BaseMessage {
+
+	private final Author author;
 
 	public GroupMessage(@NotNull Message message, @Nullable MessageId parent,
 			@NotNull Author author) {
-		super(message, parent, author);
+		super(message, parent);
+		this.author = author;
+	}
+
+	public Author getAuthor() {
+		return author;
 	}
 
 }
diff --git a/briar-api/src/org/briarproject/api/privategroup/PrivateGroup.java b/briar-api/src/org/briarproject/api/privategroup/PrivateGroup.java
index f6d5862c78da7a15f9f81adaf41d62c501d6b551..310611361b960acc44ef6b5e9f6d30207f488360 100644
--- a/briar-api/src/org/briarproject/api/privategroup/PrivateGroup.java
+++ b/briar-api/src/org/briarproject/api/privategroup/PrivateGroup.java
@@ -1,11 +1,16 @@
 package org.briarproject.api.privategroup;
 
-import org.briarproject.api.clients.BaseGroup;
+import org.briarproject.api.clients.NamedGroup;
 import org.briarproject.api.identity.Author;
+import org.briarproject.api.nullsafety.NotNullByDefault;
 import org.briarproject.api.sync.Group;
 import org.jetbrains.annotations.NotNull;
 
-public class PrivateGroup extends BaseGroup {
+import javax.annotation.concurrent.Immutable;
+
+@Immutable
+@NotNullByDefault
+public class PrivateGroup extends NamedGroup {
 
 	private final Author author;
 
diff --git a/briar-api/src/org/briarproject/api/privategroup/PrivateGroupFactory.java b/briar-api/src/org/briarproject/api/privategroup/PrivateGroupFactory.java
index 492eabc866df02bc8be162aa02c79165ab6f717f..6a27552fa286c13c9c7e12c683c7af44707e18db 100644
--- a/briar-api/src/org/briarproject/api/privategroup/PrivateGroupFactory.java
+++ b/briar-api/src/org/briarproject/api/privategroup/PrivateGroupFactory.java
@@ -1,6 +1,8 @@
 package org.briarproject.api.privategroup;
 
+import org.briarproject.api.FormatException;
 import org.briarproject.api.identity.Author;
+import org.briarproject.api.sync.Group;
 import org.jetbrains.annotations.NotNull;
 
 public interface PrivateGroupFactory {
@@ -17,4 +19,9 @@ public interface PrivateGroupFactory {
 	@NotNull
 	PrivateGroup createPrivateGroup(String name, Author author, byte[] salt);
 
+	/**
+	 * Parses a group and returns the corresponding PrivateGroup.
+	 */
+	PrivateGroup parsePrivateGroup(Group group) throws FormatException;
+
 }
diff --git a/briar-api/src/org/briarproject/api/privategroup/PrivateGroupManager.java b/briar-api/src/org/briarproject/api/privategroup/PrivateGroupManager.java
index a7fbd0cb12a5421e04d3c1c37eacebb09bf43c19..956a42344649b2d88cdbf0924d4c5ab1cda0986d 100644
--- a/briar-api/src/org/briarproject/api/privategroup/PrivateGroupManager.java
+++ b/briar-api/src/org/briarproject/api/privategroup/PrivateGroupManager.java
@@ -3,10 +3,12 @@ package org.briarproject.api.privategroup;
 import org.briarproject.api.clients.MessageTracker;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.db.Transaction;
+import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.MessageId;
 import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
 
 import java.util.Collection;
 
@@ -19,8 +21,12 @@ public interface PrivateGroupManager extends MessageTracker {
 	/** Removes a dissolved private group. */
 	void removePrivateGroup(GroupId g) throws DbException;
 
+	/** Creates a local group message. */
+	GroupMessage createLocalMessage(GroupId groupId, String body,
+			long timestamp, @Nullable MessageId parentId, LocalAuthor author);
+
 	/** Stores (and sends) a local group message. */
-	void addLocalMessage(GroupMessage p) throws DbException;
+	GroupMessageHeader addLocalMessage(GroupMessage p) throws DbException;
 
 	/** Returns the private group with the given ID. */
 	@NotNull
diff --git a/briar-core/src/org/briarproject/forum/ForumManagerImpl.java b/briar-core/src/org/briarproject/forum/ForumManagerImpl.java
index 77a7712e57ae0b4769e0a4ccee6c560981a08cce..8d1511f7d8a8e2665fe4c5781d9c1e95b91a4542 100644
--- a/briar-core/src/org/briarproject/forum/ForumManagerImpl.java
+++ b/briar-core/src/org/briarproject/forum/ForumManagerImpl.java
@@ -13,11 +13,13 @@ import org.briarproject.api.forum.Forum;
 import org.briarproject.api.forum.ForumFactory;
 import org.briarproject.api.forum.ForumManager;
 import org.briarproject.api.forum.ForumPost;
+import org.briarproject.api.forum.ForumPostFactory;
 import org.briarproject.api.forum.ForumPostHeader;
 import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.Author.Status;
 import org.briarproject.api.identity.AuthorId;
 import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.sync.ClientId;
 import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
@@ -25,7 +27,9 @@ import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageId;
 import org.briarproject.clients.BdfIncomingMessageHook;
 import org.briarproject.util.StringUtils;
+import org.jetbrains.annotations.Nullable;
 
+import java.security.GeneralSecurityException;
 import java.util.ArrayList;
 import java.util.Collection;
 import java.util.Collections;
@@ -47,6 +51,7 @@ import static org.briarproject.api.forum.ForumConstants.KEY_PARENT;
 import static org.briarproject.api.forum.ForumConstants.KEY_PUBLIC_NAME;
 import static org.briarproject.api.forum.ForumConstants.KEY_TIMESTAMP;
 import static org.briarproject.api.identity.Author.Status.ANONYMOUS;
+import static org.briarproject.api.identity.Author.Status.OURSELVES;
 import static org.briarproject.clients.BdfConstants.MSG_KEY_READ;
 
 class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
@@ -57,16 +62,18 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
 
 	private final IdentityManager identityManager;
 	private final ForumFactory forumFactory;
+	private final ForumPostFactory forumPostFactory;
 	private final List<RemoveForumHook> removeHooks;
 
 	@Inject
 	ForumManagerImpl(DatabaseComponent db, IdentityManager identityManager,
 			ClientHelper clientHelper, MetadataParser metadataParser,
-			ForumFactory forumFactory) {
+			ForumFactory forumFactory, ForumPostFactory forumPostFactory) {
 		super(db, clientHelper, metadataParser);
 
 		this.identityManager = identityManager;
 		this.forumFactory = forumFactory;
+		this.forumPostFactory = forumPostFactory;
 		removeHooks = new CopyOnWriteArrayList<RemoveForumHook>();
 	}
 
@@ -118,7 +125,24 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
 	}
 
 	@Override
-	public void addLocalPost(ForumPost p) throws DbException {
+	public ForumPost createLocalPost(final GroupId groupId, final String body,
+			final long timestamp, final @Nullable MessageId parentId,
+			final LocalAuthor author) {
+		ForumPost p;
+		try {
+			p = forumPostFactory
+					.createPseudonymousPost(groupId, timestamp, parentId,
+							author, body);
+		} catch (GeneralSecurityException e) {
+			throw new RuntimeException(e);
+		} catch (FormatException e) {
+			throw new RuntimeException(e);
+		}
+		return p;
+	}
+
+	@Override
+	public ForumPostHeader addLocalPost(ForumPost p) throws DbException {
 		Transaction txn = db.startTransaction(false);
 		try {
 			BdfDictionary meta = new BdfDictionary();
@@ -142,6 +166,8 @@ class ForumManagerImpl extends BdfIncomingMessageHook implements ForumManager {
 		} finally {
 			db.endTransaction(txn);
 		}
+		return new ForumPostHeader(p.getMessage().getId(), p.getParent(),
+				p.getMessage().getTimestamp(), p.getAuthor(), OURSELVES, true);
 	}
 
 	@Override
diff --git a/briar-core/src/org/briarproject/forum/ForumPostFactoryImpl.java b/briar-core/src/org/briarproject/forum/ForumPostFactoryImpl.java
index a6c0b226aea4109724a72ebb36ae02ca6299a937..7c0d1e46ddc88a7fefb999340f96185cc99d22be 100644
--- a/briar-core/src/org/briarproject/forum/ForumPostFactoryImpl.java
+++ b/briar-core/src/org/briarproject/forum/ForumPostFactoryImpl.java
@@ -3,12 +3,13 @@ package org.briarproject.forum;
 import org.briarproject.api.FormatException;
 import org.briarproject.api.clients.ClientHelper;
 import org.briarproject.api.crypto.CryptoComponent;
+import org.briarproject.api.crypto.KeyParser;
 import org.briarproject.api.crypto.PrivateKey;
 import org.briarproject.api.crypto.Signature;
 import org.briarproject.api.data.BdfList;
 import org.briarproject.api.forum.ForumPost;
 import org.briarproject.api.forum.ForumPostFactory;
-import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageId;
@@ -49,9 +50,10 @@ class ForumPostFactoryImpl implements ForumPostFactory {
 
 	@Override
 	public ForumPost createPseudonymousPost(GroupId groupId, long timestamp,
-			MessageId parent, Author author, String contentType, byte[] body,
-			PrivateKey privateKey) throws FormatException,
-			GeneralSecurityException {
+			MessageId parent, LocalAuthor author, String bodyStr)
+			throws FormatException, GeneralSecurityException {
+		String contentType = "text/plain";
+		byte[] body = StringUtils.toUtf8(bodyStr);
 		// Validate the arguments
 		if (StringUtils.toUtf8(contentType).length > MAX_CONTENT_TYPE_LENGTH)
 			throw new IllegalArgumentException();
@@ -62,6 +64,10 @@ class ForumPostFactoryImpl implements ForumPostFactory {
 				author.getPublicKey());
 		BdfList signed = BdfList.of(groupId, timestamp, parent, authorList,
 				contentType, body);
+		// Get private key
+		KeyParser keyParser = crypto.getSignatureKeyParser();
+		byte[] k = author.getPrivateKey();
+		PrivateKey privateKey = keyParser.parsePrivateKey(k);
 		// Generate the signature
 		Signature signature = crypto.getSignature();
 		signature.initSign(privateKey);
diff --git a/briar-core/src/org/briarproject/privategroup/PrivateGroupFactoryImpl.java b/briar-core/src/org/briarproject/privategroup/PrivateGroupFactoryImpl.java
index 429fd56b5f34383f80ae8f308543581cbdc46566..7389f2fd691718ee4c84ae88b8f505df97d79b98 100644
--- a/briar-core/src/org/briarproject/privategroup/PrivateGroupFactoryImpl.java
+++ b/briar-core/src/org/briarproject/privategroup/PrivateGroupFactoryImpl.java
@@ -4,6 +4,7 @@ import org.briarproject.api.FormatException;
 import org.briarproject.api.clients.ClientHelper;
 import org.briarproject.api.data.BdfList;
 import org.briarproject.api.identity.Author;
+import org.briarproject.api.identity.AuthorFactory;
 import org.briarproject.api.privategroup.PrivateGroup;
 import org.briarproject.api.privategroup.PrivateGroupFactory;
 import org.briarproject.api.sync.Group;
@@ -22,14 +23,17 @@ class PrivateGroupFactoryImpl implements PrivateGroupFactory {
 
 	private final GroupFactory groupFactory;
 	private final ClientHelper clientHelper;
+	private final AuthorFactory authorFactory;
 	private final SecureRandom random;
 
 	@Inject
 	PrivateGroupFactoryImpl(GroupFactory groupFactory,
-			ClientHelper clientHelper, SecureRandom random) {
+			ClientHelper clientHelper, AuthorFactory authorFactory,
+			SecureRandom random) {
 
 		this.groupFactory = groupFactory;
 		this.clientHelper = clientHelper;
+		this.authorFactory = authorFactory;
 		this.random = random;
 	}
 
@@ -66,4 +70,13 @@ class PrivateGroupFactoryImpl implements PrivateGroupFactory {
 		}
 	}
 
+	@Override
+	public PrivateGroup parsePrivateGroup(Group group) throws FormatException {
+		byte[] descriptor = group.getDescriptor();
+		BdfList list = clientHelper.toList(descriptor);
+		Author a =
+				authorFactory.createAuthor(list.getString(1), list.getRaw(2));
+		return new PrivateGroup(group, list.getString(0), a, list.getRaw(3));
+	}
+
 }
diff --git a/briar-core/src/org/briarproject/privategroup/PrivateGroupManagerImpl.java b/briar-core/src/org/briarproject/privategroup/PrivateGroupManagerImpl.java
index 3c9f5814bc580c50a3bb1822855000993d613e82..6f40bd9c6c9c069c4e9de80d9cbfa7c887626b53 100644
--- a/briar-core/src/org/briarproject/privategroup/PrivateGroupManagerImpl.java
+++ b/briar-core/src/org/briarproject/privategroup/PrivateGroupManagerImpl.java
@@ -8,27 +8,34 @@ import org.briarproject.api.data.MetadataParser;
 import org.briarproject.api.db.DatabaseComponent;
 import org.briarproject.api.db.DbException;
 import org.briarproject.api.db.Transaction;
-import org.briarproject.api.identity.Author;
 import org.briarproject.api.identity.IdentityManager;
+import org.briarproject.api.identity.LocalAuthor;
 import org.briarproject.api.privategroup.GroupMessage;
+import org.briarproject.api.privategroup.GroupMessageFactory;
 import org.briarproject.api.privategroup.GroupMessageHeader;
 import org.briarproject.api.privategroup.PrivateGroup;
 import org.briarproject.api.privategroup.PrivateGroupFactory;
 import org.briarproject.api.privategroup.PrivateGroupManager;
 import org.briarproject.api.sync.ClientId;
+import org.briarproject.api.sync.Group;
 import org.briarproject.api.sync.GroupId;
 import org.briarproject.api.sync.Message;
 import org.briarproject.api.sync.MessageId;
+import org.briarproject.api.system.Clock;
 import org.briarproject.clients.BdfIncomingMessageHook;
 import org.briarproject.util.StringUtils;
 import org.jetbrains.annotations.NotNull;
+import org.jetbrains.annotations.Nullable;
 
+import java.security.GeneralSecurityException;
 import java.util.Collection;
 import java.util.Collections;
 import java.util.logging.Logger;
 
 import javax.inject.Inject;
 
+import static org.briarproject.api.identity.Author.Status.OURSELVES;
+
 public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 		PrivateGroupManager {
 
@@ -40,16 +47,21 @@ public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 
 	private final IdentityManager identityManager;
 	private final PrivateGroupFactory privateGroupFactory;
+	private final GroupMessageFactory groupMessageFactory;
+	private final Clock clock;
 
 	@Inject
 	PrivateGroupManagerImpl(ClientHelper clientHelper,
 			MetadataParser metadataParser, DatabaseComponent db,
 			IdentityManager identityManager,
-			PrivateGroupFactory privateGroupFactory) {
+			PrivateGroupFactory privateGroupFactory,
+			GroupMessageFactory groupMessageFactory, Clock clock) {
 		super(db, clientHelper, metadataParser);
 
 		this.identityManager = identityManager;
 		this.privateGroupFactory = privateGroupFactory;
+		this.groupMessageFactory = groupMessageFactory;
+		this.clock = clock;
 	}
 
 	@NotNull
@@ -64,7 +76,22 @@ public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 	}
 
 	@Override
-	public void addLocalMessage(GroupMessage m) throws DbException {
+	public GroupMessage createLocalMessage(GroupId groupId, String body,
+			long timestamp, @Nullable MessageId parentId, LocalAuthor author) {
+		try {
+			return groupMessageFactory
+					.createGroupMessage(groupId, timestamp, parentId, author,
+							body);
+		} catch (FormatException e) {
+			throw new RuntimeException(e);
+		} catch (GeneralSecurityException e) {
+			throw new RuntimeException(e);
+		}
+	}
+
+	@Override
+	public GroupMessageHeader addLocalMessage(GroupMessage m)
+			throws DbException {
 		Transaction txn = db.startTransaction(false);
 		try {
 			BdfDictionary meta = new BdfDictionary();
@@ -76,21 +103,35 @@ public class PrivateGroupManagerImpl extends BdfIncomingMessageHook implements
 		} finally {
 			db.endTransaction(txn);
 		}
+		return new GroupMessageHeader(m.getMessage().getGroupId(),
+				m.getMessage().getId(), m.getParent(),
+				m.getMessage().getTimestamp(), m.getAuthor(), OURSELVES, true);
 	}
 
 	@NotNull
 	@Override
 	public PrivateGroup getPrivateGroup(GroupId g) throws DbException {
-		Author a = identityManager.getLocalAuthor();
-		return privateGroupFactory.createPrivateGroup("todo", a);
+		PrivateGroup privateGroup;
+		Transaction txn = db.startTransaction(true);
+		try {
+			privateGroup = getPrivateGroup(txn, g);
+			txn.setComplete();
+		} finally {
+			db.endTransaction(txn);
+		}
+		return privateGroup;
 	}
 
 	@NotNull
 	@Override
 	public PrivateGroup getPrivateGroup(Transaction txn, GroupId g)
 			throws DbException {
-		Author a = identityManager.getLocalAuthor(txn);
-		return privateGroupFactory.createPrivateGroup("todo", a);
+		try {
+			Group group = db.getGroup(txn, g);
+			return privateGroupFactory.parsePrivateGroup(group);
+		} catch (FormatException e) {
+			throw new DbException(e);
+		}
 	}
 
 	@NotNull
diff --git a/briar-core/src/org/briarproject/util/StringUtils.java b/briar-core/src/org/briarproject/util/StringUtils.java
index f8a97a6d635bd403c59eb9ea659decdde3c68f5a..ec43eebf75e42e3d8bb480eee52f55b2e3b098dd 100644
--- a/briar-core/src/org/briarproject/util/StringUtils.java
+++ b/briar-core/src/org/briarproject/util/StringUtils.java
@@ -86,4 +86,11 @@ public class StringUtils {
 	public static String trim(String s) {
 		return s.trim();
 	}
+
+	/**
+	 * Returns true if the string is longer than maxLength
+	 */
+	public static boolean isTooLong(String s, int maxLength) {
+		return toUtf8(s).length > maxLength;
+	}
 }