diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/util/AnnotatedStringAppender.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/util/AnnotatedStringAppender.kt
new file mode 100644
index 0000000000000000000000000000000000000000..e7036641b33b3f3dc6936e90739c14a6a848f529
--- /dev/null
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/util/AnnotatedStringAppender.kt
@@ -0,0 +1,61 @@
+package de.justjanne.quasseldroid.util
+
+import android.text.SpannableStringBuilder
+import androidx.compose.ui.text.AnnotatedString
+import java.lang.IllegalArgumentException
+
+class AnnotatedStringAppender(
+  private val builder: AnnotatedString.Builder
+) : Appendable {
+  override fun append(text: CharSequence): Appendable = this.apply {
+    when (text) {
+      is String -> builder.append(text)
+      is AnnotatedString -> builder.append(text)
+      else -> throw IllegalArgumentException(
+          "Unsupported type of text for annotated string: ${text.javaClass.canonicalName}"
+      )
+    }
+  }
+
+  override fun append(text: CharSequence, start: Int, end: Int): Appendable = this.apply {
+    when (text) {
+      is String -> builder.append(text.substring(start, end))
+      is AnnotatedString -> builder.append(text.subSequence(start, end))
+      else -> throw IllegalArgumentException(
+          "Unsupported type of text for annotated string: ${text.javaClass.canonicalName}"
+      )
+    }
+  }
+
+  override fun append(text: Char): Appendable = this.apply {
+    builder.append(text)
+  }
+}
+
+class SpannableStringAppender(
+  private val builder: SpannableStringBuilder
+) : Appendable {
+  override fun append(text: CharSequence): Appendable = this.apply {
+    when (text) {
+      is String -> builder.append(text)
+      is AnnotatedString -> builder.append(text)
+      else -> throw IllegalArgumentException(
+          "Unsupported type of text for annotated string: ${text.javaClass.canonicalName}"
+      )
+    }
+  }
+
+  override fun append(text: CharSequence, start: Int, end: Int): Appendable = this.apply {
+    when (text) {
+      is String -> builder.append(text.substring(start, end))
+      is AnnotatedString -> builder.append(text.subSequence(start, end))
+      else -> throw IllegalArgumentException(
+          "Unsupported type of text for annotated string: ${text.javaClass.canonicalName}"
+      )
+    }
+  }
+
+  override fun append(text: Char): Appendable = this.apply {
+    builder.append(text)
+  }
+}
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/FormatString.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/FormatString.kt
new file mode 100644
index 0000000000000000000000000000000000000000..a8c8ea9ff65ef66a67f2c8a9612f5ee9180208c7
--- /dev/null
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/FormatString.kt
@@ -0,0 +1,51 @@
+package de.justjanne.quasseldroid.util.format
+
+sealed class FormatString {
+  data class FixedValue(
+    val content: CharSequence
+  ) : FormatString() {
+    override fun toString(): String {
+      return "FixedValue($content)"
+    }
+  }
+
+  data class FormatSpecifier(
+    val index: Int?,
+    val flags: String?,
+    val width: Int?,
+    val precision: Int?,
+    val time: Boolean,
+    val conversion: Char
+  ) : FormatString() {
+    override fun toString(): String = listOfNotNull(
+      index?.let { "index=$index" },
+      flags?.let { "flags='$flags'" },
+      width?.let { "width=$width" },
+      precision?.let { "precision=$precision" },
+      "time=$time",
+      "conversion='$conversion'"
+    ).joinToString(", ", prefix = "FormatSpecifier(", postfix = ")")
+
+    fun toFormatSpecifier(ignoreFlags: Set<Char> = emptySet()) = buildString {
+      append("%")
+      if (index != null) {
+        append(index)
+        append("$")
+      }
+      if (flags != null) {
+        append(flags.filterNot(ignoreFlags::contains))
+      }
+      if (width != null) {
+        append(width)
+      }
+      if (precision != null) {
+        append('.')
+        append(precision)
+      }
+      if (time) {
+        append("t")
+      }
+      append(conversion)
+    }
+  }
+}
diff --git a/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/TextFormatter.kt b/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/TextFormatter.kt
new file mode 100644
index 0000000000000000000000000000000000000000..0278fc4f9c38507de68b08d3668638e95b148cee
--- /dev/null
+++ b/app/src/main/kotlin/de/justjanne/quasseldroid/util/format/TextFormatter.kt
@@ -0,0 +1,152 @@
+/*
+ * Copyright © 2014 George T. Steel
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package de.justjanne.quasseldroid.util.format
+
+import android.text.Spanned
+import androidx.compose.ui.text.AnnotatedString
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.core.text.buildSpannedString
+import de.justjanne.quasseldroid.util.AnnotatedStringAppender
+import java.util.*
+import java.util.regex.Pattern
+
+/**
+ * Provides [String.format] style functions that work with [Spanned] strings and preserve formatting.
+ *
+ * @author George T. Steel
+ */
+object TextFormatter {
+  fun format(
+    template: AnnotatedString,
+    vararg args: Any?,
+    locale: Locale = Locale.getDefault()
+  ): AnnotatedString = buildAnnotatedString {
+    formatBlocks(AnnotatedStringAppender(this), parseBlocks(template), args, locale)
+  }
+
+  fun format(
+    template: Spanned,
+    vararg args: Any?,
+    locale: Locale = Locale.getDefault()
+  ): Spanned = buildSpannedString {
+    formatBlocks(this, parseBlocks(template), args, locale)
+  }
+
+  fun format(
+    template: String,
+    vararg args: Any?,
+    locale: Locale = Locale.getDefault()
+  ): String = buildString {
+    formatBlocks(this, parseBlocks(template), args, locale)
+  }
+
+  internal fun formatBlocks(
+    target: Appendable,
+    blocks: Sequence<FormatString>,
+    args: Array<out Any?>,
+    locale: Locale
+  ) {
+    var argIndex = 0
+    for (block in blocks) {
+      when (block) {
+        is FormatString.FixedValue -> target.append(block.content)
+        is FormatString.FormatSpecifier -> {
+          val arg = when {
+              block.index != null -> args[block.index - 1]
+              block.flags.orEmpty().contains(FLAG_REUSE_ARGUMENT) -> args[argIndex]
+              else -> args[argIndex++]
+          }
+
+          if (block.conversion.lowercaseChar() == TYPE_STRING) {
+            if (arg == null) {
+              target.append(null)
+            }
+
+            fun justify(data: CharSequence): CharSequence = when {
+              block.width == null -> data
+              block.flags.orEmpty().contains(FLAG_JUSTIFY_LEFT) -> data.padEnd(block.width)
+              else -> data.padStart(block.width)
+            }
+
+            fun uppercase(data: String): String = when {
+              block.flags.orEmpty().contains(FLAG_UPPERCASE) -> data.uppercase(locale)
+              else -> data
+            }
+
+            when (arg) {
+              null -> target.append(justify(uppercase("null")))
+              is String -> target.append(justify(uppercase(arg)))
+              is CharSequence -> target.append(justify(arg))
+              else -> target.append(justify(uppercase(arg.toString())))
+            }
+          } else {
+            target.append(
+              String.format(
+                locale,
+                block.toFormatSpecifier(ignoreFlags = setOf(FLAG_REUSE_ARGUMENT)),
+                arg
+              )
+            )
+          }
+        }
+      }
+    }
+  }
+
+  internal fun parseBlocks(template: CharSequence) = sequence {
+    var index = 0
+    while (index < template.length) {
+      val match = FORMAT_SEQUENCE.toRegex().find(template, index)
+      if (match == null) {
+        yield(FormatString.FixedValue(template.subSequence(index, template.length)))
+        break
+      }
+      if (match.range.first != index) {
+        yield(FormatString.FixedValue(template.subSequence(index, match.range.first)))
+      }
+      index = match.range.last + 1
+
+      val conversionGroup = match.groups["conversion"]?.value
+      require(conversionGroup != null) {
+        "Invalid format string '$match', missing conversion"
+      }
+      require(conversionGroup.length == 1) {
+        "Invalid format string '$match', conversion too long"
+      }
+      val conversion = conversionGroup.first()
+
+      yield(
+        FormatString.FormatSpecifier(
+          index = match.groups["index"]?.value?.toIntOrNull(),
+          flags = match.groups["flags"]?.value,
+          width = match.groups["width"]?.value?.toIntOrNull(),
+          precision = match.groups["precision"]?.value?.toIntOrNull(),
+          time = match.groups["time"] != null,
+          conversion = conversion
+        )
+      )
+    }
+  }
+
+  private const val TYPE_STRING = 's'
+  private const val FLAG_JUSTIFY_LEFT = '-'
+  private const val FLAG_UPPERCASE = '^'
+  private const val FLAG_REUSE_ARGUMENT = '<'
+
+  private val FORMAT_SEQUENCE =
+    Pattern.compile("%(?:(?<index>[0-9]+)\\$)?(?<flags>[,\\-(+# 0<]+)?(?<width>[0-9]+)?(?:\\.(?<precision>[0-9]+))?(?<time>[tT])?(?<conversion>[a-zA-Z])")
+}
diff --git a/app/src/main/res/values/strings_messages.xml b/app/src/main/res/values/strings_messages.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d6c1be59ac4ff04eace35d245b7819a7f454c904
--- /dev/null
+++ b/app/src/main/res/values/strings_messages.xml
@@ -0,0 +1,17 @@
+<?xml version="1.0" encoding="utf-8" ?>
+<resources>
+  <string name="message_format_action">— %1$s%2$s %3$s</string>
+  <string name="message_format_notice">[%1$s%2$s] %3$s</string>
+  <string name="message_format_nick">%1$s%2$s is now known as %3$s%4$s</string>
+  <string name="message_format_nick_self">You are now known as %1$s%2$s</string>
+  <string name="message_format_mode">Mode %1$s by %2$s%3$s</string>
+  <string name="message_format_join">%1$s%2$s joined %3$s</string>
+  <string name="message_format_part_1">%1$s%2$s left</string>
+  <string name="message_format_part_2">%1$s%2$s left (%3$s)</string>
+  <string name="message_format_quit_1">%1$s%2$s quit</string>
+  <string name="message_format_quit_2">%1$s%2$s quit (%3$s)</string>
+  <string name="message_format_kick_1">%1$s was kicked by %2$s%3$s</string>
+  <string name="message_format_kick_2">%1$s was kicked by %2$s%3$s (%4$s)</string>
+  <string name="message_format_kill_1">%1$s was killed by %2$s%3$s</string>
+  <string name="message_format_kill_2">%1$s was killed by %2$s%3$s (%4$s)</string>
+</resources>
diff --git a/app/src/test/kotlin/de/kuschku/justjanne/quasseldroid/util/irc/TextFormatterTest.kt b/app/src/test/kotlin/de/kuschku/justjanne/quasseldroid/util/irc/TextFormatterTest.kt
new file mode 100644
index 0000000000000000000000000000000000000000..3a844e9dec080cbdb18ab4838b2d4b7423a481cf
--- /dev/null
+++ b/app/src/test/kotlin/de/kuschku/justjanne/quasseldroid/util/irc/TextFormatterTest.kt
@@ -0,0 +1,102 @@
+package de.kuschku.justjanne.quasseldroid.util.irc
+
+import android.text.Spanned
+import android.text.style.ForegroundColorSpan
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.SpanStyle
+import androidx.compose.ui.text.buildAnnotatedString
+import androidx.core.text.buildSpannedString
+import de.justjanne.quasseldroid.util.format.TextFormatter
+import org.junit.jupiter.api.Test
+import java.util.*
+import kotlin.test.assertEquals
+
+class TextFormatterTest {
+  @Test
+  fun testBasicFormatting() {
+    val calendar = GregorianCalendar(1995, Calendar.MAY, 23, 13, 34, 18)
+
+    assertEquals(
+      " d  c  b  a",
+      TextFormatter.format(
+        "%4\$2s %3\$2s %2\$2s %1\$2s", "a", "b", "c", "d",
+        locale = Locale.ENGLISH
+      )
+    )
+    assertEquals(
+      "e =    +2,7183",
+      TextFormatter.format("e = %+10.4f", Math.E, locale = Locale.FRANCE)
+    )
+    assertEquals(
+      "Amount gained or lost since last statement: $ (6,217.58)",
+      TextFormatter.format(
+        "Amount gained or lost since last statement: \$ %(,.2f", -6217.58,
+        locale = Locale.ENGLISH
+      )
+    )
+    assertEquals(
+      "Local time: 13:34:18",
+      TextFormatter.format(
+        "Local time: %tT",
+        calendar,
+        locale = Locale.ENGLISH
+      )
+    )
+    assertEquals(
+      "Unable to open file 'food': No such file or directory",
+      TextFormatter.format(
+        "Unable to open file '%1\$s': %2\$s",
+        "food",
+        "No such file or directory",
+        locale = Locale.ENGLISH
+      )
+    )
+    assertEquals(
+      "Duke's Birthday: May 23, 1995",
+      TextFormatter.format(
+        "Duke's Birthday: %1\$tb %1\$te, %1\$tY",
+        calendar,
+        locale = Locale.ENGLISH
+      )
+    )
+    assertEquals(
+      "Duke's Birthday: May 23, 1995",
+      TextFormatter.format(
+        "Duke's Birthday: %1\$tb %<te, %<tY",
+        calendar,
+        locale = Locale.ENGLISH,
+      )
+    )
+  }
+
+  @Test
+  fun testAnnotatedStrings() {
+    assertEquals(
+      buildAnnotatedString {
+        append("Hello ")
+        pushStyle(SpanStyle(color = Color.Red))
+        append("World")
+        pop()
+        append(", ")
+        pushStyle(SpanStyle(color = Color.Blue))
+        append("I love you")
+        pop()
+        append('!')
+      },
+      TextFormatter.format(
+        buildAnnotatedString {
+          append("Hello %1\$s, ")
+          pushStyle(SpanStyle(color = Color.Blue))
+          append("I love you")
+          pop()
+          append('!')
+        },
+        buildAnnotatedString {
+          pushStyle(SpanStyle(color = Color.Red))
+          append("World")
+          pop()
+        }
+      )
+    )
+  }
+}