From 7e2ea85defeabce8417431ab27ce728ac0489565 Mon Sep 17 00:00:00 2001
From: Janne Mareike Koschinski <janne@kuschku.de>
Date: Fri, 4 Mar 2022 19:36:54 +0100
Subject: [PATCH] feat: switch storage to room

---
 app/build.gradle.kts                          |  13 +-
 .../quasseldroid/messages/MessageBuffer.kt    |  12 --
 .../quasseldroid/messages/MessageStore.kt     | 110 ----------
 .../quasseldroid/persistence/AppDatabase.kt   | 198 ++++++++++++++++++
 .../service/ClientSessionWrapper.kt           |   9 -
 .../quasseldroid/service/QuasselBackend.kt    |   3 +-
 .../quasseldroid/service/QuasselBinder.kt     |   5 +-
 .../quasseldroid/service/QuasselRunner.kt     |  84 ++++----
 .../quasseldroid/service/QuasselService.kt    |  12 +-
 .../de/justjanne/quasseldroid/ui/Constants.kt |  10 +
 .../quasseldroid/ui/components/BuildNick.kt   |  23 ++
 .../ui/components/ConnectedClientCard.kt      |   7 +-
 .../ui/components/CoreInfoView.kt             |   9 +-
 .../quasseldroid/ui/components/MessageBase.kt | 114 ++--------
 .../ui/components/MessageBaseSmall.kt         |  78 +++++++
 .../ui/components/MessageDayChangeView.kt     |   7 +-
 .../quasseldroid/ui/components/MessageList.kt |  97 ---------
 .../ui/components/MessagePlaceholder.kt       |  96 +++++++++
 .../quasseldroid/ui/icons/AvatarIcon.kt       |   3 +-
 .../quasseldroid/ui/routes/CoreInfoRoute.kt   |   1 -
 .../quasseldroid/ui/routes/HomeRoute.kt       |   4 +-
 .../quasseldroid/ui/routes/MessageRoute.kt    | 169 ++++++++++-----
 .../util/format/IrcFormatRenderer.kt          |  10 +-
 .../justjanne.kotlin.android.gradle.kts       |   1 +
 .../main/kotlin/justjanne.kotlin.gradle.kts   |   1 +
 gradle/libs.versions.toml                     |  20 +-
 26 files changed, 653 insertions(+), 443 deletions(-)
 delete mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageBuffer.kt
 delete mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt
 create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/persistence/AppDatabase.kt
 delete mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt
 create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/ui/Constants.kt
 create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/BuildNick.kt
 create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBaseSmall.kt
 delete mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt
 create mode 100644 app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessagePlaceholder.kt

diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 00fbf423d..f58f94ada 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -25,7 +25,6 @@ plugins {
 android {
   defaultConfig {
     vectorDrawables.useSupportLibrary = true
-    testInstrumentationRunner = "de.justjanne.quasseldroid.util.TestRunner"
   }
 
   buildTypes {
@@ -72,8 +71,20 @@ dependencies {
   implementation(libs.androidx.compose.runtime)
   implementation(libs.androidx.compose.ui)
 
+  implementation(libs.androidx.collection.ktx)
+  implementation(libs.androidx.core.ktx)
+
   implementation(libs.androidx.navigation.compose)
 
+  implementation(libs.androidx.paging.runtime)
+  testImplementation(libs.androidx.paging.test)
+  implementation(libs.androidx.paging.compose)
+
+  implementation(libs.androidx.room.runtime)
+  ksp(libs.androidx.room.compiler)
+  implementation(libs.androidx.room.ktx)
+  implementation(libs.androidx.room.paging)
+
   implementation(libs.libquassel.client)
   implementation(libs.libquassel.irc)
 
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageBuffer.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageBuffer.kt
deleted file mode 100644
index 6df763cce..000000000
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageBuffer.kt
+++ /dev/null
@@ -1,12 +0,0 @@
-package de.justjanne.quasseldroid.messages
-
-import de.justjanne.libquassel.protocol.models.Message
-
-data class MessageBuffer(
-  /**
-   * Whether the chronologically latest message for a given buffer id is in the buffer.
-   * If yes, new messages that arrive for this buffer should be appened to the end.
-   */
-  val atEnd: Boolean,
-  val messages: List<Message>
-)
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt
deleted file mode 100644
index fe3faa8ba..000000000
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/messages/MessageStore.kt
+++ /dev/null
@@ -1,110 +0,0 @@
-package de.justjanne.quasseldroid.messages
-
-import androidx.collection.LruCache
-import de.justjanne.bitflags.none
-import de.justjanne.libquassel.client.syncables.ClientBacklogManager
-import de.justjanne.libquassel.protocol.models.Message
-import de.justjanne.libquassel.protocol.models.flags.MessageFlag
-import de.justjanne.libquassel.protocol.models.flags.MessageType
-import de.justjanne.libquassel.protocol.models.ids.BufferId
-import de.justjanne.libquassel.protocol.models.ids.MsgId
-import de.justjanne.libquassel.protocol.util.StateHolder
-import de.justjanne.libquassel.protocol.variant.into
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.async
-import kotlinx.coroutines.awaitAll
-import kotlinx.coroutines.cancelAndJoin
-import kotlinx.coroutines.flow.Flow
-import kotlinx.coroutines.flow.MutableStateFlow
-import kotlinx.coroutines.flow.launchIn
-import kotlinx.coroutines.flow.onEach
-import kotlinx.coroutines.flow.update
-import kotlinx.coroutines.launch
-import kotlinx.coroutines.runBlocking
-import java.io.Closeable
-
-class MessageStore(
-  incoming: Flow<Message>,
-  private val backlogManager: ClientBacklogManager
-) : Closeable, StateHolder<Map<BufferId, MessageBuffer>> {
-  private val state = MutableStateFlow(mapOf<BufferId, MessageBuffer>())
-  override fun state() = state.value
-  override fun flow() = state
-
-  private val scope = CoroutineScope(Dispatchers.IO)
-  private val disposable = incoming.onEach { message ->
-    val bufferId = message.bufferInfo.bufferId
-    state.update { messages ->
-      val buffer = messages[bufferId] ?: MessageBuffer(true, emptyList())
-      if (buffer.atEnd) {
-        messages + Pair(bufferId, buffer.copy(messages = buffer.messages + message))
-      } else {
-        messages
-      }
-    }
-  }.launchIn(scope)
-
-  fun loadAround(bufferId: BufferId, messageId: MsgId, limit: Int) {
-    scope.launch {
-      state.update { messages ->
-        val (before, after) = listOf(
-          backlogManager.backlog(bufferId, last = messageId, limit = limit)
-            .mapNotNull { it.into<Message>() },
-          backlogManager.backlogForward(bufferId, first = messageId, limit = limit - 1)
-            .mapNotNull { it.into<Message>() },
-        )
-
-        val updated = MessageBuffer(
-          atEnd = false,
-          messages = (before + after).distinct().sortedBy { it.messageId }
-        )
-        messages + Pair(bufferId, updated)
-      }
-    }
-  }
-
-  fun loadBefore(bufferId: BufferId, limit: Int) {
-    scope.launch {
-      state.update { messages ->
-        val buffer = messages[bufferId] ?: MessageBuffer(true, emptyList())
-        val messageId = buffer.messages.firstOrNull()?.messageId ?: MsgId(-1)
-        val data = backlogManager.backlog(bufferId, last = messageId, limit = limit)
-          .mapNotNull { it.into<Message>() }
-        val updated = buffer.copy(
-          messages = (buffer.messages + data).distinct().sortedBy { it.messageId }
-        )
-        messages + Pair(bufferId, updated)
-      }
-    }
-  }
-
-  fun loadAfter(bufferId: BufferId, limit: Int) {
-    scope.launch {
-      state.update { messages ->
-        val buffer = messages[bufferId] ?: MessageBuffer(true, emptyList())
-        val messageId = buffer.messages.lastOrNull()?.messageId ?: MsgId(-1)
-        val data = backlogManager.backlogForward(bufferId, first = messageId, limit = limit)
-          .mapNotNull { it.into<Message>() }
-        val updated = buffer.copy(
-          messages = (buffer.messages + data).distinct().sortedBy { it.messageId }
-        )
-        messages + Pair(bufferId, updated)
-      }
-    }
-  }
-
-  fun clear(bufferId: BufferId) {
-    scope.launch {
-      state.update { messages ->
-        messages - bufferId
-      }
-    }
-  }
-
-  override fun close() {
-    runBlocking {
-      disposable.cancelAndJoin()
-    }
-  }
-}
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/persistence/AppDatabase.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/persistence/AppDatabase.kt
new file mode 100644
index 000000000..8baadc9ee
--- /dev/null
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/persistence/AppDatabase.kt
@@ -0,0 +1,198 @@
+package de.justjanne.quasseldroid.persistence
+
+import androidx.paging.LoadType
+import androidx.paging.PagingSource
+import androidx.paging.PagingState
+import androidx.paging.RemoteMediator
+import androidx.room.ColumnInfo
+import androidx.room.Dao
+import androidx.room.Database
+import androidx.room.Entity
+import androidx.room.Insert
+import androidx.room.OnConflictStrategy
+import androidx.room.PrimaryKey
+import androidx.room.Query
+import androidx.room.RoomDatabase
+import androidx.room.TypeConverter
+import androidx.room.TypeConverters
+import androidx.room.withTransaction
+import de.justjanne.bitflags.of
+import de.justjanne.bitflags.toBits
+import de.justjanne.libquassel.client.syncables.ClientBacklogManager
+import de.justjanne.libquassel.protocol.models.BufferInfo
+import de.justjanne.libquassel.protocol.models.Message
+import de.justjanne.libquassel.protocol.models.flags.MessageFlag
+import de.justjanne.libquassel.protocol.models.flags.MessageType
+import de.justjanne.libquassel.protocol.models.ids.BufferId
+import de.justjanne.libquassel.protocol.models.ids.MsgId
+import de.justjanne.libquassel.protocol.models.ids.SignedId64Type
+import de.justjanne.libquassel.protocol.models.ids.SignedIdType
+import de.justjanne.libquassel.protocol.models.ids.isValid
+import de.justjanne.libquassel.protocol.variant.QVariant_
+import de.justjanne.libquassel.protocol.variant.into
+import org.intellij.lang.annotations.Language
+import org.threeten.bp.Instant
+
+class QuasselRemoteMediator<Key : Any>(
+  private val bufferId: BufferId,
+  private val database: AppDatabase,
+  private val backlogManager: ClientBacklogManager,
+  private val pageSize: Int = 50
+) : RemoteMediator<Key, MessageModel>() {
+
+  private suspend fun loadAround(bufferId: BufferId, messageId: MsgId): List<Message> =
+    loadBefore(bufferId, messageId) + loadAfter(bufferId, messageId)
+
+  private suspend fun loadBefore(bufferId: BufferId, messageId: MsgId) =
+    backlogManager.backlog(bufferId, last = messageId, limit = pageSize)
+      .mapNotNull<QVariant_, Message>(QVariant_::into)
+
+  private suspend fun loadAfter(bufferId: BufferId, messageId: MsgId) =
+    backlogManager.backlogForward(bufferId, first = messageId, limit = pageSize)
+      .mapNotNull<QVariant_, Message>(QVariant_::into)
+
+  override suspend fun load(
+    loadType: LoadType,
+    state: PagingState<Key, MessageModel>
+  ): MediatorResult {
+    val loadKey: MsgId = when (loadType) {
+      LoadType.REFRESH ->
+        state.anchorPosition?.let { anchorPosition ->
+          state.closestItemToPosition(anchorPosition)?.messageId?.let(::MsgId)
+        } ?: MsgId(-1)
+      LoadType.PREPEND ->
+        state.firstItemOrNull()?.messageId?.let(::MsgId)
+        ?: return MediatorResult.Success(endOfPaginationReached = true)
+      LoadType.APPEND ->
+        state.lastItemOrNull()?.messageId?.let(::MsgId)
+          ?: return MediatorResult.Success(endOfPaginationReached = true)
+    }
+
+    val newMessages: List<Message> = when (loadType) {
+      LoadType.REFRESH ->
+        if (loadKey.isValid()) loadAround(bufferId, loadKey)
+        else loadBefore(bufferId, loadKey)
+      LoadType.PREPEND -> loadBefore(bufferId, loadKey)
+      LoadType.APPEND -> loadAfter(bufferId, loadKey)
+    }
+
+    database.withTransaction {
+      if (loadType == LoadType.REFRESH) {
+        database.messageDao().delete(bufferId.id)
+      }
+
+      database.messageDao().insert(newMessages.map(::MessageModel))
+    }
+
+    return MediatorResult.Success(
+      endOfPaginationReached = newMessages.isEmpty()
+    )
+  }
+}
+
+@Database(entities = [MessageModel::class], version = 1)
+@TypeConverters(Converters::class)
+abstract class AppDatabase : RoomDatabase() {
+  abstract fun messageDao(): MessageDao
+}
+
+object Converters {
+  @TypeConverter
+  fun fromInstant(value: Instant): Long = value.toEpochMilli()
+
+  @TypeConverter
+  fun toInstant(value: Long): Instant = Instant.ofEpochMilli(value)
+}
+
+@Dao
+interface MessageDao {
+  @Insert(onConflict = OnConflictStrategy.REPLACE)
+  suspend fun insert(vararg models: MessageModel)
+
+  @Insert(onConflict = OnConflictStrategy.REPLACE)
+  suspend fun insert(models: Collection<MessageModel>)
+
+  @Query("SELECT * FROM message WHERE bufferId = :bufferId")
+  fun pagingSource(bufferId: SignedIdType): PagingSource<Int, MessageModel>
+
+  @Language("RoomSql")
+  @Query("DELETE FROM message WHERE bufferId = :bufferId")
+  suspend fun delete(bufferId: SignedIdType)
+
+  @Query("DELETE FROM message")
+  suspend fun delete()
+}
+
+@Entity(tableName = "message")
+data class MessageModel(
+  /**
+   * Id of the message
+   */
+  @PrimaryKey
+  val messageId: SignedId64Type,
+  /**
+   * Timestamp at which the message was sent
+   */
+  val time: Instant,
+  /**
+   * Message type
+   */
+  val type: Int,
+  /**
+   * Set flags on the message
+   */
+  val flag: Int,
+  /**
+   * Metadata of the buffer the message was received in
+   */
+  @ColumnInfo(index = true)
+  val bufferId: SignedIdType,
+  /**
+   * `nick!ident@host` of the sender
+   */
+  val sender: String,
+  /**
+   * Channel role prefixes of the sender
+   */
+  val senderPrefixes: String,
+  /**
+   * Realname of the sender
+   */
+  val realName: String,
+  /**
+   * Avatar of the sender
+   */
+  val avatarUrl: String,
+  /**
+   * Message content
+   */
+  val content: String
+) {
+  constructor(message: Message) : this(
+    messageId = message.messageId.id,
+    time = message.time,
+    type = message.type.toBits().toInt(),
+    flag = message.flag.toBits().toInt(),
+    bufferId = message.bufferInfo.bufferId.id,
+    sender = message.sender,
+    senderPrefixes = message.senderPrefixes,
+    realName = message.realName,
+    avatarUrl = message.avatarUrl,
+    content = message.content
+  )
+
+  fun toMessage() = Message(
+    messageId = MsgId(messageId),
+    time = time,
+    type = MessageType.of(type.toUInt()),
+    flag = MessageFlag.of(flag.toUInt()),
+    bufferInfo = BufferInfo(
+      bufferId = BufferId(bufferId)
+    ),
+    sender = sender,
+    senderPrefixes = senderPrefixes,
+    realName = realName,
+    avatarUrl = avatarUrl,
+    content = content
+  )
+}
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt
deleted file mode 100644
index 428e82c52..000000000
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/ClientSessionWrapper.kt
+++ /dev/null
@@ -1,9 +0,0 @@
-package de.justjanne.quasseldroid.service
-
-import de.justjanne.libquassel.client.session.ClientSession
-import de.justjanne.quasseldroid.messages.MessageStore
-
-data class ClientSessionWrapper(
-  val session: ClientSession,
-  val messages: MessageStore
-)
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt
index fc63885b5..5d8ebb5b0 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBackend.kt
@@ -5,6 +5,7 @@ import android.content.Context
 import android.content.ServiceConnection
 import android.os.IBinder
 import android.util.Log
+import de.justjanne.libquassel.client.session.ClientSession
 import de.justjanne.libquassel.protocol.util.StateHolder
 import de.justjanne.libquassel.protocol.util.flatMap
 import de.justjanne.quasseldroid.BuildConfig
@@ -14,7 +15,7 @@ import de.justjanne.quasseldroid.util.lifecycle.LifecycleStatus
 import kotlinx.coroutines.flow.MutableStateFlow
 
 class QuasselBackend : DefaultContextualLifecycleObserver(), ServiceConnection,
-  StateHolder<ClientSessionWrapper?> {
+  StateHolder<ClientSession?> {
   private var connectionData: ConnectionData? = null
 
   override fun flow() = state.flatMap()
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBinder.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBinder.kt
index d3cf4eb3e..db890aef0 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBinder.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselBinder.kt
@@ -1,12 +1,13 @@
 package de.justjanne.quasseldroid.service
 
 import android.os.Binder
+import de.justjanne.libquassel.client.session.ClientSession
 import de.justjanne.libquassel.protocol.util.StateHolder
 import kotlinx.coroutines.flow.StateFlow
 
 class QuasselBinder(
-  private val state: StateFlow<ClientSessionWrapper?>
-) : Binder(), StateHolder<ClientSessionWrapper?> {
+  private val state: StateFlow<ClientSession?>
+) : Binder(), StateHolder<ClientSession?> {
   constructor(runner: QuasselRunner) : this(runner.flow())
 
   override fun flow() = state
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselRunner.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselRunner.kt
index 93512a477..5cca4178b 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselRunner.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselRunner.kt
@@ -8,11 +8,20 @@ import de.justjanne.libquassel.protocol.connection.ProtocolVersion
 import de.justjanne.libquassel.protocol.features.FeatureSet
 import de.justjanne.libquassel.protocol.io.CoroutineChannel
 import de.justjanne.libquassel.protocol.util.StateHolder
-import de.justjanne.quasseldroid.messages.MessageStore
+import de.justjanne.quasseldroid.persistence.AppDatabase
+import de.justjanne.quasseldroid.persistence.MessageModel
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.DelicateCoroutinesApi
 import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.cancelAndJoin
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.flow.collectIndexed
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.newSingleThreadContext
 import kotlinx.coroutines.runBlocking
+import kotlinx.coroutines.runInterruptible
 import kotlinx.coroutines.withTimeout
 import java.io.Closeable
 import java.net.InetSocketAddress
@@ -20,55 +29,52 @@ import javax.net.ssl.SSLContext
 
 class QuasselRunner(
   private val address: InetSocketAddress,
-  private val auth: Pair<String, String>
-) : Thread("Quassel Runner"), Closeable, StateHolder<ClientSessionWrapper?> {
+  private val auth: Pair<String, String>,
+  private val database: AppDatabase
+) : Closeable, StateHolder<ClientSession?>, CoroutineScope {
   private val channel = CoroutineChannel()
 
-  override fun state(): ClientSessionWrapper? = state.value
-  override fun flow(): StateFlow<ClientSessionWrapper?> = state
+  override fun state(): ClientSession? = state.value
+  override fun flow(): StateFlow<ClientSession?> = state
 
-  private val state = MutableStateFlow<ClientSessionWrapper?>(null)
+  private val state = MutableStateFlow<ClientSession?>(null)
 
-  init {
-    start()
-  }
+  override val coroutineContext = newSingleThreadContext("Quassel Runner")
 
-  override fun run() {
-    runBlocking(Dispatchers.IO) {
-      Log.d("QuasselRunner", "Resolving URL")
-      val address = InetSocketAddress(address.hostString, address.port)
-      Log.d("QuasselRunner", "Connecting")
-      channel.connect(address)
-      Log.d("QuasselRunner", "Handshake")
-      val session = ClientSession(
-        channel,
-        ProtocolFeature.all,
-        listOf(
-          ProtocolMeta(
-            ProtocolVersion.Datastream,
-            0x0000u
-          )
-        ),
-        SSLContext.getDefault()
-      )
-      state.value = ClientSessionWrapper(
-        session,
-        messages = MessageStore(
-          session.rpcHandler.messages(),
-          session.backlogManager
+  private val job = launch {
+    Log.d("QuasselRunner", "Resolving URL")
+    val address = InetSocketAddress(address.hostString, address.port)
+    Log.d("QuasselRunner", "Connecting")
+    channel.connect(address)
+    Log.d("QuasselRunner", "Handshake")
+    val session = ClientSession(
+      channel,
+      ProtocolFeature.all,
+      listOf(
+        ProtocolMeta(
+          ProtocolVersion.Datastream,
+          0x0000u
         )
-      )
-      session.handshakeHandler.init(
+      ),
+      SSLContext.getDefault()
+    )
+    state.value = session
+
+    with(session) {
+      handshakeHandler.init(
         "Quasseltest v0.1",
         "2022-02-24",
         FeatureSet.all()
       )
       val (username, password) = auth
       Log.d("QuasselRunner", "Authenticating")
-      session.handshakeHandler.login(username, password)
+      handshakeHandler.login(username, password)
       Log.d("QuasselRunner", "Waiting for init")
-      session.baseInitHandler.waitForInitDone()
+      baseInitHandler.waitForInitDone()
       Log.d("QuasselRunner", "Init Done")
+      rpcHandler.messages().collectIndexed { _, message ->
+        database.messageDao().insert(MessageModel(message))
+      }
     }
   }
 
@@ -76,7 +82,11 @@ class QuasselRunner(
     Log.d("QuasselRunner", "Stopping Quassel Runner")
     runBlocking(Dispatchers.IO) {
       withTimeout(2000L) {
-        channel.close()
+        job.cancelAndJoin()
+        runInterruptible {
+          coroutineContext.cancel()
+          channel.close()
+        }
       }
     }
   }
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselService.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselService.kt
index 7d21c9148..7b7ce32cc 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselService.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/service/QuasselService.kt
@@ -5,11 +5,21 @@ import android.content.Context
 import android.content.Intent
 import android.os.IBinder
 import android.util.Log
+import androidx.room.Room
+import de.justjanne.quasseldroid.persistence.AppDatabase
 import java.net.InetSocketAddress
 
 class QuasselService : Service() {
   private var runner: QuasselRunner? = null
 
+  private val database: AppDatabase by lazy {
+    Room.databaseBuilder(
+      this.applicationContext,
+      AppDatabase::class.java,
+      "app"
+    ).build()
+  }
+
   private fun newRunner(intent: Intent): QuasselRunner {
     Log.w("QuasselService", "Creating new quassel runner")
     val address = InetSocketAddress.createUnresolved(
@@ -26,7 +36,7 @@ class QuasselService : Service() {
         "Required argument 'password' missing"
       },
     )
-    return QuasselRunner(address, auth)
+    return QuasselRunner(address, auth, database)
   }
 
   override fun onCreate() {
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/Constants.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/Constants.kt
new file mode 100644
index 000000000..77689815e
--- /dev/null
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/Constants.kt
@@ -0,0 +1,10 @@
+package de.justjanne.quasseldroid.ui
+
+import org.threeten.bp.format.DateTimeFormatter
+import org.threeten.bp.format.FormatStyle
+
+object Constants {
+  val timeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
+  val dateFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
+  val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
+}
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/BuildNick.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/BuildNick.kt
new file mode 100644
index 000000000..a6e5e97e3
--- /dev/null
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/BuildNick.kt
@@ -0,0 +1,23 @@
+package de.justjanne.quasseldroid.ui.components
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.text.font.FontWeight
+import de.justjanne.quasseldroid.ui.theme.QuasselTheme
+import de.justjanne.quasseldroid.util.irc.SenderColorUtil
+
+@Composable
+fun buildNick(nick: String, senderPrefixes: String): AnnotatedString {
+  val senderColor = QuasselTheme.sender.colors[SenderColorUtil.senderColor(nick)]
+
+  return buildAnnotatedString {
+      if (senderPrefixes.isNotEmpty()) {
+          append(senderPrefixes)
+      }
+      pushStyle(SpanStyle(color = senderColor, fontWeight = FontWeight.Bold))
+      append(nick)
+      pop()
+  }
+}
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/ConnectedClientCard.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/ConnectedClientCard.kt
index 4092eeee0..ac44730d0 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/ConnectedClientCard.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/ConnectedClientCard.kt
@@ -20,12 +20,9 @@ import de.charlex.compose.HtmlText
 import de.justjanne.libquassel.protocol.models.ConnectedClient
 import de.justjanne.quasseldroid.model.SecurityLevel
 import de.justjanne.quasseldroid.sample.SampleConnectedClientProvider
+import de.justjanne.quasseldroid.ui.Constants
 import de.justjanne.quasseldroid.ui.theme.Typography
 import org.threeten.bp.ZoneId
-import org.threeten.bp.format.DateTimeFormatter
-import org.threeten.bp.format.FormatStyle
-
-private val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
 
 @Preview(name = "Connected Client")
 @Composable
@@ -51,7 +48,7 @@ fun ConnectedClientCard(
           Text(
             client.connectedSince
               .atZone(ZoneId.systemDefault())
-              .format(formatter),
+              .format(Constants.dateTimeFormatter),
             style = Typography.body2
           )
         }
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/CoreInfoView.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/CoreInfoView.kt
index c25cdce54..f0c494704 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/CoreInfoView.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/CoreInfoView.kt
@@ -14,12 +14,9 @@ import de.charlex.compose.HtmlText
 import de.justjanne.libquassel.protocol.models.ConnectedClient
 import de.justjanne.libquassel.protocol.syncables.state.CoreInfoState
 import de.justjanne.quasseldroid.sample.SampleCoreInfoProvider
+import de.justjanne.quasseldroid.ui.Constants
 import de.justjanne.quasseldroid.ui.theme.Typography
 import org.threeten.bp.ZoneId
-import org.threeten.bp.format.DateTimeFormatter
-import org.threeten.bp.format.FormatStyle
-
-private val formatter = DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM)
 
 @Preview(name = "Core Info", showBackground = true)
 @Composable
@@ -36,14 +33,14 @@ fun CoreInfoView(
       Text(
         text = coreInfo.versionDate
           ?.atZone(ZoneId.systemDefault())
-          ?.format(formatter)
+          ?.format(Constants.dateTimeFormatter)
           ?: "Unknown",
         style = Typography.body2
       )
       Text(
         coreInfo.startTime
           .atZone(ZoneId.systemDefault())
-          .format(formatter),
+          .format(Constants.dateTimeFormatter),
         style = Typography.body2
       )
     }
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBase.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBase.kt
index 49fef6157..eaa81fc55 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBase.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBase.kt
@@ -1,6 +1,13 @@
 package de.justjanne.quasseldroid.ui.components
 
-import androidx.compose.foundation.layout.*
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.paddingFromBaseline
+import androidx.compose.foundation.layout.width
 import androidx.compose.material.ContentAlpha
 import androidx.compose.material.LocalContentAlpha
 import androidx.compose.material.MaterialTheme
@@ -9,12 +16,8 @@ import androidx.compose.runtime.Composable
 import androidx.compose.runtime.CompositionLocalProvider
 import androidx.compose.ui.Alignment
 import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
-import androidx.compose.ui.text.AnnotatedString
 import androidx.compose.ui.text.SpanStyle
 import androidx.compose.ui.text.buildAnnotatedString
-import androidx.compose.ui.text.font.FontWeight
-import androidx.compose.ui.text.style.TextOverflow
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.tooling.preview.PreviewParameter
 import androidx.compose.ui.unit.dp
@@ -22,29 +25,10 @@ import androidx.compose.ui.unit.sp
 import de.justjanne.libquassel.irc.HostmaskHelper
 import de.justjanne.libquassel.protocol.models.Message
 import de.justjanne.quasseldroid.sample.SampleMessageProvider
+import de.justjanne.quasseldroid.ui.Constants
 import de.justjanne.quasseldroid.ui.icons.AvatarIcon
-import de.justjanne.quasseldroid.ui.theme.QuasselTheme
 import de.justjanne.quasseldroid.ui.theme.Typography
-import de.justjanne.quasseldroid.util.irc.SenderColorUtil
 import org.threeten.bp.ZoneId
-import org.threeten.bp.format.DateTimeFormatter
-import org.threeten.bp.format.FormatStyle
-
-private val formatter = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT)
-
-@Composable
-fun buildNick(nick: String, senderPrefixes: String): AnnotatedString {
-  val senderColor = QuasselTheme.sender.colors[SenderColorUtil.senderColor(nick)]
-
-  return buildAnnotatedString {
-    if (senderPrefixes.isNotEmpty()) {
-      append(senderPrefixes)
-    }
-    pushStyle(SpanStyle(color = senderColor, fontWeight = FontWeight.Bold))
-    append(nick)
-    pop()
-  }
-}
 
 @Preview(name = "Message Base", showBackground = true)
 @Composable
@@ -53,7 +37,6 @@ fun MessageBase(
   message: Message,
   followUp: Boolean = false,
   // avatarSize: Dp = 32.dp
-  backgroundColor: Color = MaterialTheme.colors.surface,
   content: @Composable () -> Unit = { Text(message.content, style = Typography.body2) }
 ) {
   val avatarSize = 32.dp
@@ -79,20 +62,16 @@ fun MessageBase(
     }
     Column(modifier = Modifier.align(Alignment.CenterVertically)) {
       if (!followUp) {
-        Row {
-          Text(
-            buildAnnotatedString {
-              append(buildNick(nick, message.senderPrefixes))
-              append(' ')
-              pushStyle(SpanStyle(color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium)))
-              append(message.realName)
-              pop()
-            },
-            style = Typography.body2,
-            maxLines = 1,
-            overflow = TextOverflow.Ellipsis
-          )
-        }
+        Text(
+          buildAnnotatedString {
+            append(buildNick(nick, message.senderPrefixes))
+            append(' ')
+            pushStyle(SpanStyle(color = MaterialTheme.colors.onSurface.copy(alpha = ContentAlpha.medium)))
+            append(message.realName)
+            pop()
+          },
+          style = Typography.body2
+        )
       }
       Row {
         Box(modifier = Modifier.weight(1.0f)) {
@@ -102,7 +81,7 @@ fun MessageBase(
           Text(
             message.time
               .atZone(ZoneId.systemDefault())
-              .format(formatter),
+              .format(Constants.timeFormatter),
             style = Typography.body2,
             fontSize = 12.sp,
             modifier = Modifier.align(Alignment.Bottom)
@@ -112,56 +91,3 @@ fun MessageBase(
     }
   }
 }
-
-@Preview(name = "Message Small", showBackground = true)
-@Composable
-fun MessageBaseSmall(
-  @PreviewParameter(SampleMessageProvider::class)
-  message: Message,
-  followUp: Boolean = false,
-  // avatarSize: Dp = 32.dp,
-  backgroundColor: Color = MaterialTheme.colors.surface,
-  content: @Composable () -> Unit = {
-    val nick = HostmaskHelper.nick(message.sender)
-
-    Text(buildAnnotatedString {
-      append("— ")
-      append(buildNick(nick, message.senderPrefixes))
-      append(" ")
-      append(message.content)
-    }, style = Typography.body2)
-  }
-) {
-  val avatarSize = 16.dp
-  val nick = HostmaskHelper.nick(message.sender)
-
-  Row(
-    modifier = Modifier
-      .padding(2.dp)
-      .fillMaxWidth()
-  ) {
-    Spacer(Modifier.width(20.dp))
-    AvatarIcon(
-      nick,
-      modifier = Modifier
-        .align(Alignment.Top)
-        .paddingFromBaseline(top = 14.sp),
-      size = avatarSize
-    )
-    Spacer(Modifier.width(4.dp))
-    Box(modifier = Modifier.weight(1.0f)) {
-      content()
-    }
-    Spacer(Modifier.width(4.dp))
-    CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
-      Text(
-        message.time
-          .atZone(ZoneId.systemDefault())
-          .format(formatter),
-        style = Typography.body2,
-        fontSize = 12.sp,
-        modifier = Modifier.align(Alignment.Bottom)
-      )
-    }
-  }
-}
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBaseSmall.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBaseSmall.kt
new file mode 100644
index 000000000..5d1181e42
--- /dev/null
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageBaseSmall.kt
@@ -0,0 +1,78 @@
+package de.justjanne.quasseldroid.ui.components
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.paddingFromBaseline
+import androidx.compose.foundation.layout.width
+import androidx.compose.material.ContentAlpha
+import androidx.compose.material.LocalContentAlpha
+import androidx.compose.material.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import de.justjanne.libquassel.protocol.models.Message
+import de.justjanne.libquassel.irc.HostmaskHelper
+import de.justjanne.quasseldroid.sample.SampleMessageProvider
+import de.justjanne.quasseldroid.ui.Constants
+import de.justjanne.quasseldroid.ui.icons.AvatarIcon
+import de.justjanne.quasseldroid.ui.theme.Typography
+import org.threeten.bp.ZoneId
+
+@Preview(name = "Message Small", showBackground = true)
+@Composable
+fun MessageBaseSmall(
+  @PreviewParameter(SampleMessageProvider::class)
+  message: Message,
+  content: @Composable () -> Unit = {
+    val nick = HostmaskHelper.nick(message.sender)
+
+    Text(buildAnnotatedString {
+      append("— ")
+      append(buildNick(nick, message.senderPrefixes))
+      append(" ")
+      append(message.content)
+    }, style = Typography.body2)
+  }
+) {
+  val avatarSize = 16.dp
+  val nick = HostmaskHelper.nick(message.sender)
+
+  Row(
+    modifier = Modifier
+      .padding(2.dp)
+      .fillMaxWidth()
+  ) {
+    Spacer(Modifier.width(20.dp))
+    AvatarIcon(
+      nick,
+      modifier = Modifier
+        .align(Alignment.Top)
+        .paddingFromBaseline(top = 14.sp),
+      size = avatarSize
+    )
+    Spacer(Modifier.width(4.dp))
+    Box(modifier = Modifier.weight(1.0f)) {
+      content()
+    }
+    Spacer(Modifier.width(4.dp))
+    CompositionLocalProvider(LocalContentAlpha provides ContentAlpha.medium) {
+      Text(
+        message.time
+          .atZone(ZoneId.systemDefault())
+          .format(Constants.timeFormatter),
+        style = Typography.body2,
+        fontSize = 12.sp,
+        modifier = Modifier.align(Alignment.Bottom)
+      )
+    }
+  }
+}
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageDayChangeView.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageDayChangeView.kt
index 953f9e9f3..b2e270813 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageDayChangeView.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageDayChangeView.kt
@@ -17,13 +17,10 @@ import androidx.compose.ui.text.font.FontWeight
 import androidx.compose.ui.tooling.preview.Preview
 import androidx.compose.ui.unit.dp
 import androidx.compose.ui.unit.sp
+import de.justjanne.quasseldroid.ui.Constants
 import de.justjanne.quasseldroid.ui.theme.QuasselTheme
 import de.justjanne.quasseldroid.ui.theme.Typography
 import org.threeten.bp.LocalDate
-import org.threeten.bp.format.DateTimeFormatter
-import org.threeten.bp.format.FormatStyle
-
-private val formatter = DateTimeFormatter.ofLocalizedDate(FormatStyle.MEDIUM)
 
 @Preview(name = "Day Change", showBackground = true)
 @Composable
@@ -54,7 +51,7 @@ fun MessageDayChangeView(
     )
     Spacer(Modifier.width(4.dp))
     Text(
-      date.format(formatter),
+      date.format(Constants.dateFormatter),
       modifier = Modifier
         .align(Alignment.CenterVertically),
       style = Typography.body2,
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt
deleted file mode 100644
index fd6d5e7d2..000000000
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessageList.kt
+++ /dev/null
@@ -1,97 +0,0 @@
-package de.justjanne.quasseldroid.ui.components
-
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.foundation.lazy.LazyListState
-import androidx.compose.foundation.lazy.itemsIndexed
-import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.material.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.ui.res.stringResource
-import androidx.compose.ui.text.AnnotatedString
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.compose.ui.tooling.preview.PreviewParameter
-import de.justjanne.bitflags.of
-import de.justjanne.libquassel.irc.HostmaskHelper
-import de.justjanne.libquassel.irc.IrcFormat
-import de.justjanne.libquassel.irc.IrcFormatDeserializer
-import de.justjanne.libquassel.protocol.models.Message
-import de.justjanne.libquassel.protocol.models.flags.MessageType
-import de.justjanne.libquassel.protocol.models.ids.MsgId
-import de.justjanne.quasseldroid.R
-import de.justjanne.quasseldroid.sample.SampleMessagesProvider
-import de.justjanne.quasseldroid.ui.theme.QuasselTheme
-import de.justjanne.quasseldroid.ui.theme.Typography
-import de.justjanne.quasseldroid.util.extensions.OnBottomReached
-import de.justjanne.quasseldroid.util.extensions.OnTopReached
-import de.justjanne.quasseldroid.util.extensions.format
-import de.justjanne.quasseldroid.util.extensions.getPrevious
-import de.justjanne.quasseldroid.util.format.IrcFormatRenderer
-import de.justjanne.quasseldroid.util.format.TextFormatter
-import org.threeten.bp.ZoneId
-
-@Preview(name = "Messages", showBackground = true)
-@Composable
-fun MessageList(
-  @PreviewParameter(SampleMessagesProvider::class)
-  messages: List<Message>,
-  listState: LazyListState = rememberLazyListState(),
-  markerLine: MsgId = MsgId(-1),
-  buffer: Int = 0,
-  onLoadAtStart: () -> Unit = { },
-  onLoadAtEnd: () -> Unit = { },
-) {
-  LazyColumn(state = listState) {
-    itemsIndexed(messages, key = { _, item -> item.messageId }) { index, message ->
-      val prev = messages.getPrevious(index)
-      val prevDate = prev?.time?.atZone(ZoneId.systemDefault())?.toLocalDate()
-      val messageDate = message.time.atZone(ZoneId.systemDefault()).toLocalDate()
-
-      val followUp = prev != null &&
-        message.sender == prev.sender &&
-        message.senderPrefixes == prev.senderPrefixes &&
-        message.realName == prev.realName &&
-        message.avatarUrl == prev.avatarUrl
-
-      val isNew = (prev == null || prev.messageId <= markerLine) &&
-        message.messageId > markerLine
-
-      val parsed = IrcFormatDeserializer.parse(message.content)
-
-      if (prevDate == null || !messageDate.isEqual(prevDate)) {
-        MessageDayChangeView(messageDate, isNew)
-      } else if (isNew) {
-        NewMessageView()
-      }
-
-      when (message.type) {
-        MessageType.of(MessageType.Plain) -> {
-          MessageBase(message, followUp) {
-            Text(IrcFormatRenderer.render(parsed), style = Typography.body2)
-          }
-        }
-        MessageType.of(MessageType.Action) -> {
-          MessageBaseSmall(message, backgroundColor = QuasselTheme.chat.action) {
-            val nick = HostmaskHelper.nick(message.sender)
-
-            Text(
-              TextFormatter.format(
-                AnnotatedString(stringResource(R.string.message_format_action)),
-                buildNick(nick, message.senderPrefixes),
-                IrcFormatRenderer.render(
-                  data = parsed.map { it.copy(style = it.style.flipFlag(IrcFormat.Flag.ITALIC)) },
-                  textColor = QuasselTheme.chat.onAction,
-                  backgroundColor = QuasselTheme.chat.action
-                )
-              ),
-              style = Typography.body2,
-              color = QuasselTheme.chat.onAction
-            )
-          }
-        }
-      }
-    }
-  }
-
-  listState.OnTopReached(buffer = buffer, onLoadMore = onLoadAtStart)
-  listState.OnBottomReached(buffer = buffer, onLoadMore = onLoadAtEnd)
-}
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessagePlaceholder.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessagePlaceholder.kt
new file mode 100644
index 000000000..074a50541
--- /dev/null
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/components/MessagePlaceholder.kt
@@ -0,0 +1,96 @@
+package de.justjanne.quasseldroid.ui.components
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.Surface
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.TextUnit
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+
+@Preview(name = "Message Placeholder", showBackground = true)
+@Composable
+fun MessagePlaceholder() {
+  val density = LocalDensity.current
+  fun TextUnit.toDp() = with(density) { toPx().toDp() }
+
+  Row(
+    modifier = Modifier
+      .padding(2.dp)
+      .fillMaxWidth()
+  ) {
+    Spacer(Modifier.width(4.dp))
+    Surface(
+      shape = RoundedCornerShape(2.dp),
+      color = Color.Gray,
+      modifier = Modifier
+        .padding(2.dp)
+        .size(32.dp)
+    ) {}
+    Spacer(Modifier.width(4.dp))
+    Column(
+      modifier = Modifier
+        .align(Alignment.CenterVertically)
+        .padding(vertical = 2.dp)
+    ) {
+      Row(
+        modifier = Modifier
+          .height(12.sp.toDp())
+          .fillMaxWidth()
+      ) {
+        Surface(
+          modifier = Modifier
+            .width(62.sp.toDp())
+            .fillMaxHeight(),
+          color = Color.Gray,
+        ) {}
+        Spacer(modifier = Modifier.width(7.dp))
+        Surface(
+          modifier = Modifier
+            .width(163.sp.toDp())
+            .fillMaxHeight(),
+          color = Color.LightGray,
+        ) {}
+      }
+      Spacer(modifier = Modifier.height(4.dp))
+      Row {
+        Column {
+          Surface(
+            modifier = Modifier
+              .width(280.sp.toDp())
+              .height(14.sp.toDp()),
+            color = Color.Gray,
+          ) {}
+          Spacer(modifier = Modifier.height(2.dp))
+          Surface(
+            modifier = Modifier
+              .width(160.sp.toDp())
+              .height(14.sp.toDp()),
+            color = Color.Gray,
+          ) {}
+        }
+        Spacer(modifier = Modifier.weight(1.0f))
+        Surface(
+          modifier = Modifier
+            .size(width = 34.sp.toDp(), height = 12.sp.toDp())
+            .padding(end = 2.dp)
+            .align(Alignment.Bottom),
+          color = Color.LightGray
+        ) {}
+      }
+    }
+  }
+}
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/icons/AvatarIcon.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/icons/AvatarIcon.kt
index 6134f07b6..3df717983 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/icons/AvatarIcon.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/icons/AvatarIcon.kt
@@ -1,6 +1,5 @@
 package de.justjanne.quasseldroid.ui.icons
 
-import android.graphics.Bitmap
 import androidx.compose.foundation.layout.Box
 import androidx.compose.foundation.layout.size
 import androidx.compose.foundation.shape.RoundedCornerShape
@@ -29,7 +28,7 @@ fun AvatarIcon(
   @PreviewParameter(SampleNickProvider::class)
   nick: String,
   modifier: Modifier = Modifier,
-  avatar: Bitmap? = null,
+  //avatar: Bitmap? = null,
   size: Dp = 32.dp
 ) {
   val senderColor = QuasselTheme.sender.colors[SenderColorUtil.senderColor(nick)]
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/CoreInfoRoute.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/CoreInfoRoute.kt
index cc17d865f..825e2fa81 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/CoreInfoRoute.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/CoreInfoRoute.kt
@@ -18,7 +18,6 @@ import de.justjanne.quasseldroid.util.rememberFlow
 fun CoreInfoRoute(backend: QuasselBackend, navController: NavController) {
   val coreInfo = rememberFlow(null) {
     backend.flow()
-      .mapNullable { it.session }
       .flatMap()
       .mapNullable { it.coreInfo }
       .flatMap()
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/HomeRoute.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/HomeRoute.kt
index 940fd34f9..3fb32c4ae 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/HomeRoute.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/HomeRoute.kt
@@ -34,7 +34,7 @@ import kotlinx.coroutines.flow.map
 @Composable
 fun HomeRoute(backend: QuasselBackend, navController: NavController) {
   val side = rememberFlow(null) {
-    backend.flow().mapNullable { it.session.side }
+    backend.flow().mapNullable { it.side }
   }
 
   val (buffer, setBuffer) = rememberSaveable(stateSaver = TextFieldValueSaver) {
@@ -43,14 +43,12 @@ fun HomeRoute(backend: QuasselBackend, navController: NavController) {
 
   val initStatus = rememberFlow(null) {
     backend.flow()
-      .mapNullable { it.session }
       .mapNullable { it.baseInitHandler }
       .flatMap()
   }
 
   val buffers: List<Pair<NetworkInfo?, BufferInfo>> = rememberFlow(emptyList()) {
     val sessions = backend.flow()
-      .mapNullable { it.session }
       .flatMap()
 
     val networks: Flow<Map<NetworkId, Network>> = sessions
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/MessageRoute.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/MessageRoute.kt
index c05908e85..aa979e60e 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/MessageRoute.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/ui/routes/MessageRoute.kt
@@ -1,33 +1,50 @@
 package de.justjanne.quasseldroid.ui.routes
 
-import androidx.compose.foundation.horizontalScroll
 import androidx.compose.foundation.layout.Column
 import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.lazy.LazyColumn
 import androidx.compose.foundation.lazy.rememberLazyListState
-import androidx.compose.foundation.rememberScrollState
 import androidx.compose.material.Button
 import androidx.compose.material.Text
-import androidx.compose.material.TextField
 import androidx.compose.runtime.Composable
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.saveable.rememberSaveable
-import androidx.compose.ui.Modifier
+import androidx.compose.runtime.remember
 import androidx.compose.ui.platform.LocalContext
-import androidx.compose.ui.text.input.TextFieldValue
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.AnnotatedString
 import androidx.navigation.NavController
-import de.justjanne.libquassel.protocol.models.Message
+import androidx.paging.Pager
+import androidx.paging.PagingConfig
+import androidx.paging.compose.collectAsLazyPagingItems
+import androidx.paging.compose.itemsIndexed
+import androidx.room.Room
+import de.justjanne.bitflags.of
+import de.justjanne.libquassel.irc.HostmaskHelper
+import de.justjanne.libquassel.irc.IrcFormat
+import de.justjanne.libquassel.irc.IrcFormatDeserializer
+import de.justjanne.libquassel.protocol.models.flags.MessageType
 import de.justjanne.libquassel.protocol.models.ids.BufferId
 import de.justjanne.libquassel.protocol.models.ids.MsgId
 import de.justjanne.libquassel.protocol.util.flatMap
-import de.justjanne.quasseldroid.messages.MessageStore
+import de.justjanne.quasseldroid.R
+import de.justjanne.quasseldroid.persistence.AppDatabase
+import de.justjanne.quasseldroid.persistence.QuasselRemoteMediator
 import de.justjanne.quasseldroid.service.QuasselBackend
-import de.justjanne.quasseldroid.ui.components.MessageList
+import de.justjanne.quasseldroid.ui.components.MessageBase
+import de.justjanne.quasseldroid.ui.components.MessageBaseSmall
+import de.justjanne.quasseldroid.ui.components.MessageDayChangeView
+import de.justjanne.quasseldroid.ui.components.MessagePlaceholder
+import de.justjanne.quasseldroid.ui.components.NewMessageView
+import de.justjanne.quasseldroid.ui.components.buildNick
+import de.justjanne.quasseldroid.ui.theme.QuasselTheme
+import de.justjanne.quasseldroid.ui.theme.Typography
+import de.justjanne.quasseldroid.util.extensions.format
+import de.justjanne.quasseldroid.util.extensions.getPrevious
+import de.justjanne.quasseldroid.util.format.IrcFormatRenderer
+import de.justjanne.quasseldroid.util.format.TextFormatter
 import de.justjanne.quasseldroid.util.mapNullable
 import de.justjanne.quasseldroid.util.rememberFlow
-import de.justjanne.quasseldroid.util.saver.TextFieldValueSaver
-import kotlinx.coroutines.flow.map
-
-private const val limit = 20
+import kotlinx.coroutines.flow.flatMapLatest
+import org.threeten.bp.ZoneId
 
 @Composable
 fun MessageRoute(
@@ -37,22 +54,46 @@ fun MessageRoute(
 ) {
   val listState = rememberLazyListState()
 
-  val messageStore: MessageStore? = rememberFlow(null) {
-    backend.flow()
-      .mapNullable { it.messages }
+  val context = LocalContext.current.applicationContext
+
+  val database = remember {
+    Room.databaseBuilder(
+      context,
+      AppDatabase::class.java,
+      "app"
+    ).build()
   }
 
-  val messages: List<Message> = rememberFlow(emptyList()) {
+  val pageSize = 50
+  val limit = 200
+
+  val messages = remember {
     backend.flow()
-      .mapNullable { it.messages }
       .flatMap()
-      .mapNullable { it[buffer] }
-      .map { it?.messages.orEmpty() }
-  }
+      .mapNullable { it.backlogManager }
+      .flatMapLatest { backlogManager ->
+        Pager(
+          PagingConfig(
+            pageSize = pageSize,
+            enablePlaceholders = true,
+            maxSize = limit
+          ),
+          remoteMediator = backlogManager?.let {
+            QuasselRemoteMediator(
+              bufferId = buffer,
+              database = database,
+              backlogManager = it,
+              pageSize = pageSize
+            )
+          }
+        ) {
+          database.messageDao().pagingSource(buffer.id)
+        }.flow
+      }
+  }.collectAsLazyPagingItems()
 
   val markerLine: MsgId? = rememberFlow(null) {
     backend.flow()
-      .mapNullable { it.session }
       .flatMap()
       .mapNullable { it.bufferSyncer }
       .flatMap()
@@ -64,35 +105,63 @@ fun MessageRoute(
       Button(onClick = { navController.navigate("home") }) {
         Text("Back")
       }
-      Button(onClick = {
-        messageStore?.loadBefore(buffer, limit)
-      }) {
-        Text("↑")
-      }
-      Button(onClick = {
-        messageStore?.loadAfter(buffer, limit)
-      }) {
-        Text("↓")
-      }
-      Button(onClick = {
-        messageStore?.loadAround(buffer, markerLine ?: MsgId(-1), limit)
-      }) {
-        Text("N")
-      }
-      Button(onClick = {
-        messageStore?.clear(buffer)
-      }) {
-        Text("Clr")
+    }
+    LazyColumn(state = listState) {
+      itemsIndexed(messages, key = { _, item -> item.messageId }) { index, model ->
+        if (model == null) {
+          MessagePlaceholder()
+        } else {
+          val message = model.toMessage()
+          val prev = messages.itemSnapshotList.getPrevious(index)?.toMessage()
+          val prevDate = prev?.time?.atZone(ZoneId.systemDefault())?.toLocalDate()
+          val messageDate = message.time.atZone(ZoneId.systemDefault()).toLocalDate()
+
+          val followUp = prev != null &&
+            message.sender == prev.sender &&
+            message.senderPrefixes == prev.senderPrefixes &&
+            message.realName == prev.realName &&
+            message.avatarUrl == prev.avatarUrl
+
+          val isNew = (markerLine != null && (prev == null || prev.messageId <= markerLine)) &&
+            message.messageId > markerLine
+
+          val parsed = IrcFormatDeserializer.parse(message.content)
+
+          if (prevDate == null || !messageDate.isEqual(prevDate)) {
+            MessageDayChangeView(messageDate, isNew)
+          } else if (isNew) {
+            NewMessageView()
+          }
+
+          when (message.type) {
+            MessageType.of(MessageType.Plain) -> {
+              MessageBase(message, followUp) {
+                Text(IrcFormatRenderer.render(parsed), style = Typography.body2)
+              }
+            }
+            MessageType.of(MessageType.Action) -> {
+              MessageBaseSmall(message) {
+                val nick = HostmaskHelper.nick(message.sender)
+
+                Text(
+                  TextFormatter.format(
+                    AnnotatedString(stringResource(R.string.message_format_action)),
+                    buildNick(nick, message.senderPrefixes),
+                    IrcFormatRenderer.render(
+                      data = parsed.map {
+                        it.copy(style = it.style.flipFlag(IrcFormat.Flag.ITALIC))
+                      }
+                    )
+                  ),
+                  style = Typography.body2,
+                  color = QuasselTheme.chat.onAction
+                )
+              }
+            }
+          }
+        }
       }
     }
-    MessageList(
-      messages = messages,
-      listState = listState,
-      markerLine = markerLine ?: MsgId(-1),
-      buffer = 5,
-      onLoadAtStart = { messageStore?.loadBefore(buffer, limit) },
-      onLoadAtEnd = { messageStore?.loadAfter(buffer, limit) }
-    )
   }
 }
 
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/IrcFormatRenderer.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/IrcFormatRenderer.kt
index 87233d91f..71cba2216 100644
--- a/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/IrcFormatRenderer.kt
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/IrcFormatRenderer.kt
@@ -43,8 +43,8 @@ object IrcFormatRenderer {
     textColor: Color,
     backgroundColor: Color
   ): SpanStyle {
-    val foreground = toColor(style.foreground)
-    val background = toColor(style.background)
+    val foreground = toColor(style.foreground) ?: textColor
+    val background = toColor(style.background) ?: backgroundColor
 
     return SpanStyle(
       fontWeight = if (style.flags.contains(IrcFormat.Flag.BOLD)) FontWeight.Bold else FontWeight.Normal,
@@ -56,10 +56,8 @@ object IrcFormatRenderer {
         )
       ),
       fontFamily = if (style.flags.contains(IrcFormat.Flag.MONOSPACE)) FontFamily.Monospace else null,
-      color = if (style.flags.contains(IrcFormat.Flag.INVERSE)) background ?: backgroundColor
-      else foreground ?: Color.Unspecified,
-      background = if (style.flags.contains(IrcFormat.Flag.INVERSE)) foreground ?: textColor
-      else background ?: Color.Unspecified,
+      color = if (style.flags.contains(IrcFormat.Flag.INVERSE)) background else foreground,
+      background = if (style.flags.contains(IrcFormat.Flag.INVERSE)) foreground else background,
     )
   }
 }
diff --git a/gradle/convention/src/main/kotlin/justjanne.kotlin.android.gradle.kts b/gradle/convention/src/main/kotlin/justjanne.kotlin.android.gradle.kts
index 0739cbd60..3f663fe8b 100644
--- a/gradle/convention/src/main/kotlin/justjanne.kotlin.android.gradle.kts
+++ b/gradle/convention/src/main/kotlin/justjanne.kotlin.android.gradle.kts
@@ -30,6 +30,7 @@ tasks.withType<KotlinCompile> {
       "-opt-in=kotlin.ExperimentalUnsignedTypes",
       "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
       "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
+      "-opt-in=androidx.paging.ExperimentalPagingApi",
     )
     jvmTarget = "1.8"
   }
diff --git a/gradle/convention/src/main/kotlin/justjanne.kotlin.gradle.kts b/gradle/convention/src/main/kotlin/justjanne.kotlin.gradle.kts
index 888ec7207..2e1834c8f 100644
--- a/gradle/convention/src/main/kotlin/justjanne.kotlin.gradle.kts
+++ b/gradle/convention/src/main/kotlin/justjanne.kotlin.gradle.kts
@@ -22,6 +22,7 @@ tasks.withType<KotlinCompile> {
       "-opt-in=kotlin.ExperimentalUnsignedTypes",
       "-opt-in=kotlinx.coroutines.ExperimentalCoroutinesApi",
       "-opt-in=androidx.compose.ui.ExperimentalComposeUiApi",
+      "-opt-in=androidx.paging.ExperimentalPagingApi",
     )
     jvmTarget = "1.8"
   }
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index ff603593b..aacaa480a 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -1,9 +1,14 @@
 [versions]
 libquassel = "0.10.1"
+androidx-collection = "1.2.0"
+androidx-core = "1.7.0"
 androidx-activity = "1.4.0"
 androidx-appcompat = "1.4.1"
 androidx-compose = "1.1.1"
+androidx-material3 = "1.0.0-alpha05"
 androidx-navigation = "2.4.1"
+androidx-paging = "3.1.0"
+androidx-room = "2.5.0-alpha01"
 
 [libraries]
 libquassel-protocol = { module = "de.justjanne.libquassel:libquassel-protocol", version.ref = "libquassel" }
@@ -19,7 +24,8 @@ androidx-appcompat-resources = { module = "androidx.appcompat:appcompat-resource
 androidx-compose-animation = { module = "androidx.compose.animation:animation", version.ref = "androidx-compose" }
 androidx-compose-compiler = { module = "androidx.compose.compiler:compiler", version.ref = "androidx-compose" }
 androidx-compose-foundation = { module = "androidx.compose.foundation:foundation", version.ref = "androidx-compose" }
-androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose" }
+#androidx-compose-material = { module = "androidx.compose.material:material", version.ref = "androidx-compose" }
+androidx-compose-material = { module = "androidx.compose.material3:material3", version.ref = "androidx-material3" }
 androidx-compose-material-icons = { module = "androidx.compose.material:material-icons-extended", version.ref = "androidx-compose" }
 androidx-compose-runtime = { module = "androidx.compose.runtime:runtime", version.ref = "androidx-compose" }
 androidx-compose-ui = { module = "androidx.compose.ui:ui", version.ref = "androidx-compose" }
@@ -27,6 +33,18 @@ androidx-compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling", versi
 androidx-compose-ui-preview = { module = "androidx.compose.ui:ui-tooling-preview", version.ref = "androidx-compose" }
 androidx-compose-ui-test = { module = "androidx.compose.ui:ui-test", version.ref = "androidx-compose" }
 
+androidx-collection-ktx = { module = "androidx.collection:collection-ktx", version.ref = "androidx-collection" }
+androidx-core-ktx = { module = "androidx.core:core-ktx", version.ref = "androidx-core" }
+
 androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "androidx-navigation" }
 
+androidx-paging-runtime = { module = "androidx.paging:paging-runtime", version.ref = "androidx-paging" }
+androidx-paging-test = { module = "androidx.paging:paging-common", version.ref = "androidx-paging" }
+androidx-paging-compose = { module = "androidx.paging:paging-compose", version = "1.0.0-alpha14" }
+
+androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "androidx-room" }
+androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = "androidx-room" }
+androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "androidx-room" }
+androidx-room-paging = { module = "androidx.room:room-paging", version.ref = "androidx-room" }
+
 compose-htmltext = { module = "de.charlex.compose:html-text", version = "1.1.0" }
-- 
GitLab