diff --git a/gajim/chat_control.py b/gajim/chat_control.py
index af4abff009669c16e809e523a58fd4e51944096a..bb753bff48be466a615c5c52be5e1912561e2e51 100644
--- a/gajim/chat_control.py
+++ b/gajim/chat_control.py
@@ -209,8 +209,6 @@ def __init__(self, parent_win, jid, acct, session, resource=None):
             gui_menu_builder.get_encryption_menu(
                 self.control_id, self._type, self.account == 'Local'))
         self.set_encryption_menu_icon()
-        # restore previous conversation
-        self.restore_conversation()
         self.msg_textview.grab_focus()
 
         # PluginSystem: adding GUI extension point for this ChatControl
@@ -562,7 +560,7 @@ def _on_message_received(self, event):
                          additional_data=event.additional_data)
 
     def _on_message_error(self, event):
-        self.conv_textview.show_error(event.message_id, event.error)
+        self.conversation_view.show_error(event.message_id, event.error)
 
     def _on_message_sent(self, event):
         if not event.message:
@@ -589,10 +587,10 @@ def _on_message_sent(self, event):
                          additional_data=event.additional_data)
 
     def _on_receipt_received(self, event):
-        self.conv_textview.show_receipt(event.receipt_id)
+        self.conversation_view.show_receipt(event.receipt_id)
 
     def _on_displayed_received(self, event):
-        self.conv_textview.show_displayed(event.marker_id)
+        self.conversation_view.set_read_marker(event.marker_id)
 
     def _nec_ping(self, event):
         if self.contact != event.contact:
@@ -1177,62 +1175,6 @@ def _on_drag_data_received(self, widget, context, x, y, selection,
                                        [self.contact.jid],
                                        [dropped_jid])
 
-    def restore_conversation(self):
-        jid = self.contact.jid
-        # number of messages that are in queue and are already logged, we want
-        # to avoid duplication
-        pending = len(app.events.get_events(self.account, jid, ['chat', 'pm']))
-        if self.resource:
-            pending += len(app.events.get_events(self.account,
-                                                 self.contact.get_full_jid(),
-                                                 ['chat', 'pm']))
-
-        rows = app.storage.archive.get_last_conversation_lines(
-            self.account, jid, pending)
-
-        local_old_kind = None
-        self.conv_textview.just_cleared = True
-        for row in rows: # time, kind, message, subject, additional_data
-            msg = row.message
-            additional_data = row.additional_data
-            if not msg: # message is empty, we don't print it
-                continue
-            if row.kind in (KindConstant.CHAT_MSG_SENT,
-                            KindConstant.SINGLE_MSG_SENT):
-                kind = 'outgoing'
-                name = self.get_our_nick()
-            elif row.kind in (KindConstant.SINGLE_MSG_RECV,
-                              KindConstant.CHAT_MSG_RECV):
-                kind = 'incoming'
-                name = self.contact.name
-            elif row.kind == KindConstant.ERROR:
-                kind = 'status'
-                name = self.contact.name
-
-            tim = float(row.time)
-
-            if row.subject:
-                msg = _('Subject: %(subject)s\n%(message)s') % \
-                    {'subject': row.subject, 'message': msg}
-            ChatControlBase.add_message(self,
-                                        msg,
-                                        kind,
-                                        name,
-                                        tim,
-                                        restored=True,
-                                        old_kind=local_old_kind,
-                                        additional_data=additional_data,
-                                        message_id=row.message_id,
-                                        marker=row.marker,
-                                        error=row.error)
-            if (row.message.startswith('/me ') or
-                    row.message.startswith('/me\n')):
-                local_old_kind = None
-            else:
-                local_old_kind = kind
-        if rows:
-            self.conv_textview.print_empty_line()
-
     def _on_convert_to_gc_menuitem_activate(self, _widget):
         """
         User wants to invite some friends to chat
diff --git a/gajim/chat_control_base.py b/gajim/chat_control_base.py
index c2daa9b55171e6fa7afb6eb20b03a566aca6fb16..3941224ea8b537f7e5a94ce068772a6c20c427be 100644
--- a/gajim/chat_control_base.py
+++ b/gajim/chat_control_base.py
@@ -25,6 +25,7 @@
 
 import os
 import sys
+import datetime
 import time
 import uuid
 import tempfile
@@ -43,12 +44,12 @@
 from gajim.common.i18n import _
 from gajim.common.nec import EventHelper
 from gajim.common.helpers import AdditionalDataDict
+from gajim.common.const import KindConstant
 from gajim.common.structs import OutgoingMessage
 
 from gajim import gtkgui_helpers
 
-from gajim.conversation_textview import ConversationTextview
-
+from gajim.gui.conversation_view import ConversationView
 from gajim.gui.dialogs import DialogButton
 from gajim.gui.dialogs import ConfirmationDialog
 from gajim.gui.dialogs import PastePreviewDialog
@@ -93,7 +94,7 @@
 ################################################################################
 class ChatControlBase(ChatCommandProcessor, CommandTools, EventHelper):
     """
-    A base class containing a banner, ConversationTextview, MessageInputTextView
+    A base class containing a banner, ConversationView, MessageInputTextView
     """
 
     _type = None  # type: ControlType
@@ -164,14 +165,16 @@ def __init__(self, parent_win, widget_name, jid, acct,
             Gdk.DragAction.COPY | Gdk.DragAction.MOVE)
         self.xml.overlay.drag_dest_set_target_list(dst_targets)
 
-        # Create textviews and connect signals
-        self.conv_textview = ConversationTextview(self.account)
-
-        id_ = self.conv_textview.connect('quote', self.on_quote)
-        self.handlers[id_] = self.conv_textview
-
-        self.conv_textview.tv.connect('key-press-event',
-                                      self._on_conv_textview_key_press_event)
+        # Create ConversationView and connect signals
+        self.conversation_view = ConversationView(self.account, self.contact)
+        id_ = self.conversation_view.connect('quote', self.on_quote)
+        self.handlers[id_] = self.conversation_view
+        id_ = self.conversation_view.connect(
+            'load-history', self._on_load_history)
+        self.handlers[id_] = self.conversation_view
+        id_ = self.conversation_view.connect(
+            'key-press-event', self._on_conversation_view_key_press)
+        self.handlers[id_] = self.conversation_view
 
         # This is a workaround: as soon as a line break occurs in Gtk.TextView
         # with word-char wrapping enabled, a hyphen character is automatically
@@ -183,10 +186,18 @@ def __init__(self, parent_win, widget_name, jid, acct,
         hscrollbar = self.xml.conversation_scrolledwindow.get_hscrollbar()
         hscrollbar.hide()
 
-        self.xml.conversation_scrolledwindow.add(self.conv_textview.tv)
-        widget = self.xml.conversation_scrolledwindow.get_vadjustment()
-        widget.connect('changed', self.on_conversation_vadjustment_changed)
+        self.xml.conversation_scrolledwindow.add(self.conversation_view)
+        self._scrolled_old_upper = None
+        self._scrolled_old_value = None
+        self._scrolled_previous_value = 0
+
+        self._fetching_history = False
+        self._initial_fetch_done = False
 
+        vadjustment = self.xml.conversation_scrolledwindow.get_vadjustment()
+        vadjustment.connect(
+            'changed', self._on_conversation_vadjustment_changed)
+        vadjustment.connect('value-changed', self._on_scroll_value_changed)
         vscrollbar = self.xml.conversation_scrolledwindow.get_vscrollbar()
         vscrollbar.connect('button-release-event',
                            self._on_scrollbar_button_release)
@@ -237,7 +248,6 @@ def __init__(self, parent_win, widget_name, jid, acct,
 
         # Attach speller
         self.set_speller()
-        self.conv_textview.tv.show()
 
         # For XEP-0172
         self.user_nick = None
@@ -255,7 +265,7 @@ def __init__(self, parent_win, widget_name, jid, acct,
             self.handlers[id_] = parent_win.window
 
         self.encryption = self.get_encryption_state()
-        self.conv_textview.encryption_enabled = self.encryption is not None
+        self.conversation_view.encryption_enabled = self.encryption is not None
 
         # PluginSystem: adding GUI extension point for ChatControlBase
         # instance object (also subclasses, eg. ChatControl or GroupchatControl)
@@ -284,7 +294,7 @@ def process_event(self, event):
         method_name = f'_on_{method_name}'
         getattr(self, method_name)(event)
 
-    def _on_conv_textview_key_press_event(self, textview, event):
+    def _on_conversation_view_key_press(self, _listbox, event):
         if event.get_state() & Gdk.ModifierType.SHIFT_MASK:
             if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up):
                 return Gdk.EVENT_PROPAGATE
@@ -296,12 +306,13 @@ def _on_conv_textview_key_press_event(self, textview, event):
             # focused).
             return Gdk.EVENT_PROPAGATE
 
-        if event.get_state() & COPY_MODIFIER:
-            # Don’t reroute the event if it is META + c and the
-            # textview has a selection
-            if event.hardware_keycode in KEYCODES_KEY_C:
-                if textview.get_buffer().props.has_selection:
-                    return Gdk.EVENT_PROPAGATE
+        # if event.get_state() & COPY_MODIFIER:
+        # TODO
+        #     # Don’t reroute the event if it is META + c and the
+        #     # textview has a selection
+        #     if event.hardware_keycode in KEYCODES_KEY_C:
+        #         if textview.get_buffer().props.has_selection:
+        #             return Gdk.EVENT_PROPAGATE
 
         if not self.msg_textview.get_sensitive():
             # If the input textview is not sensitive it can’t get the focus.
@@ -517,7 +528,7 @@ def delegate_action(self, action):
             return Gdk.EVENT_STOP
 
         if action == 'clear-chat':
-            self.conv_textview.clear()
+            self.conversation_view.clear()
             return Gdk.EVENT_STOP
 
         if action == 'delete-line':
@@ -617,7 +628,7 @@ def _on_authentication_button_clicked(self, _button):
 
     def set_encryption_state(self, encryption):
         self.encryption = encryption
-        self.conv_textview.encryption_enabled = encryption is not None
+        self.conversation_view.encryption_enabled = encryption is not None
         self.contact.settings.set('encryption', self.encryption or '')
 
     def get_encryption_state(self):
@@ -702,8 +713,7 @@ def shutdown(self):
                 self.handlers[i].disconnect(i)
         self.handlers.clear()
 
-        self.conv_textview.del_handlers()
-        del self.conv_textview
+        del self.conversation_view
         del self.msg_textview
         del self.msg_scrolledwindow
 
@@ -856,7 +866,7 @@ def _on_message_textview_key_press_event(self, textview, event):
                 return True
 
             if event.keyval in (Gdk.KEY_Page_Down, Gdk.KEY_Page_Up):
-                self.conv_textview.tv.event(event)
+                self.conversation_view.event(event)
                 self._on_scroll(None, event.keyval)
                 return True
 
@@ -1098,11 +1108,11 @@ def save_message(self, message, msg_type):
             self.received_history_pos = pos
 
     def add_info_message(self, text, message_id=None):
-        self.conv_textview.print_conversation_line(
+        self.conversation_view.add_message(
             text, 'info', '', None, message_id=message_id, graphics=False)
 
     def add_status_message(self, text):
-        self.conv_textview.print_conversation_line(
+        self.conversation_view.add_message(
             text, 'status', '', None)
 
     def add_message(self,
@@ -1130,9 +1140,8 @@ def add_message(self,
         """
         jid = self.contact.jid
         full_jid = self.get_full_jid()
-        textview = self.conv_textview
         end = False
-        if self.conv_textview.autoscroll or kind == 'outgoing':
+        if self.conversation_view.autoscroll or kind == 'outgoing':
             end = True
 
         if other_tags_for_name is None:
@@ -1144,21 +1153,18 @@ def add_message(self,
         if additional_data is None:
             additional_data = AdditionalDataDict()
 
-        textview.print_conversation_line(text,
-                                         kind,
-                                         name,
-                                         tim,
-                                         other_tags_for_name,
-                                         other_tags_for_time,
-                                         other_tags_for_text,
-                                         subject,
-                                         old_kind,
-                                         displaymarking=displaymarking,
-                                         message_id=message_id,
-                                         correct_id=correct_id,
-                                         additional_data=additional_data,
-                                         marker=marker,
-                                         error=error)
+        self.conversation_view.add_message(
+            text,
+            kind,
+            name,
+            tim,
+            other_text_tags=other_tags_for_text,
+            display_marking=displaymarking,
+            message_id=message_id,
+            correct_id=correct_id,
+            additional_data=additional_data,
+            marker=marker,
+            error=error)
 
         if restored:
             return
@@ -1301,7 +1307,7 @@ def _style_changed(self, *args):
         self.update_tags()
 
     def update_tags(self):
-        self.conv_textview.update_tags()
+        self.conversation_view.update_text_tags()
 
     @staticmethod
     def clear(tv):
@@ -1372,7 +1378,7 @@ def set_control_active(self, state):
         if state:
             self.set_emoticon_popover()
             jid = self.contact.jid
-            if self.conv_textview.autoscroll:
+            if self.conversation_view.autoscroll:
                 # we are at the end
                 type_ = [f'printed_{self._type}']
                 if self._type.is_groupchat:
@@ -1401,14 +1407,73 @@ def set_control_active(self, state):
                 self.contact, Chatstate.INACTIVE)
 
     def scroll_to_end(self, force=False):
-        self.conv_textview.scroll_to_end(force)
+        self.conversation_view.scroll_to_end(force)
+
+    def _on_load_history(self, _button, lines):
+        self.fetch_n_lines_history(lines)
+
+    def fetch_n_lines_history(self, n_lines):
+        if self._fetching_history:
+            return
+
+        timestamp_end = self.conversation_view.first_message_timestamp
+        if not timestamp_end:
+            timestamp_end = datetime.datetime.now()
+        self._fetching_history = True
+        adjustment = self.xml.conversation_scrolledwindow.get_vadjustment()
+
+        # Store these values so we can restore scroll position later
+        self._scrolled_old_upper = adjustment.get_upper()
+        self._scrolled_old_value = adjustment.get_value()
+
+        if self.is_groupchat:
+            messages = app.storage.archive.get_conversation_muc_before(
+                self.account,
+                self.contact.jid,
+                timestamp_end,
+                n_lines)
+        else:
+            messages = app.storage.archive.get_conversation_before(
+                self.account,
+                self.contact.jid,
+                timestamp_end,
+                n_lines)
+
+        for msg in messages:
+            if not msg:
+                continue
+            kind = 'status'
+            contact_name = msg.contact_name
+            if msg.kind in (
+                    KindConstant.SINGLE_MSG_RECV, KindConstant.CHAT_MSG_RECV):
+                kind = 'incoming'
+                contact_name = self.contact.name
+            elif msg.kind == KindConstant.GC_MSG:
+                kind = 'incoming'
+            elif msg.kind in (
+                    KindConstant.SINGLE_MSG_SENT, KindConstant.CHAT_MSG_SENT):
+                kind = 'outgoing'
+                contact_name = self.get_our_nick()
+            if not msg.message:
+                continue
+            self.conversation_view.add_message(
+                msg.message,
+                kind,
+                contact_name,
+                msg.time,
+                subject=msg.subject,
+                additional_data=msg.additional_data,
+                message_id=msg.message_id,
+                marker=msg.marker,
+                error=msg.error,
+                history=True)
 
     def _on_edge_reached(self, _scrolledwindow, pos):
         if pos != Gtk.PositionType.BOTTOM:
             return
         # Remove all events and set autoscroll True
         app.log('autoscroll').info('Autoscroll enabled')
-        self.conv_textview.autoscroll = True
+        self.conversation_view.autoscroll = True
         if self.resource:
             jid = self.contact.get_full_jid()
         else:
@@ -1437,13 +1502,44 @@ def _on_edge_reached(self, _scrolledwindow, pos):
                     self._type)
                 self.last_msg_id = None
 
+    def _on_edge_overshot(self, _scrolledwindow, pos):
+        # Fetch messages if we scroll against the top
+        if pos != Gtk.PositionType.TOP:
+            return
+        self.fetch_n_lines_history(30)
+
+    def _on_scroll_value_changed(self, adjustment):
+        if self.conversation_view.clearing or self._fetching_history:
+            return
+
+        value = adjustment.get_value()
+        previous_value = self._scrolled_previous_value
+        self._scrolled_previous_value = value
+
+        if value >= previous_value:
+            return
+
+        # Fetch messages before we reach the top
+        if value <= int(0.1 * adjustment.get_upper()):
+            self.fetch_n_lines_history(10)
+
+    def _on_scroll_realize(self, _widget):
+        # Initial message fetching when ChatControl is first opened
+        if not self._initial_fetch_done:
+            self._initial_fetch_done = True
+            restore_lines = app.settings.get('restore_lines')
+            if restore_lines <= 0:
+                return
+            self.fetch_n_lines_history(restore_lines)
+
     def _on_scrollbar_button_release(self, scrollbar, event):
         if event.get_button()[1] != 1:
             # We want only to catch the left mouse button
             return
+
         if not at_the_end(scrollbar.get_parent()):
             app.log('autoscroll').info('Autoscroll disabled')
-            self.conv_textview.autoscroll = False
+            self.conversation_view.autoscroll = False
 
     def has_focus(self):
         if self.parent_win:
@@ -1453,12 +1549,12 @@ def has_focus(self):
         return False
 
     def _on_scroll(self, widget, event):
-        if not self.conv_textview.autoscroll:
+        if not self.conversation_view.autoscroll:
             # autoscroll is already disabled
             return
 
         if widget is None:
-            # call from _conv_textview_key_press_event()
+            # call from _on_conversation_view_key_press()
             # SHIFT + Gdk.KEY_Page_Up
             if event != Gdk.KEY_Page_Up:
                 return
@@ -1490,10 +1586,18 @@ def _on_scroll(self, widget, event):
         adjustment = self.xml.conversation_scrolledwindow.get_vadjustment()
         if adjustment.get_upper() != adjustment.get_page_size():
             app.log('autoscroll').info('Autoscroll disabled')
-            self.conv_textview.autoscroll = False
+            self.conversation_view.autoscroll = False
 
-    def on_conversation_vadjustment_changed(self, _adjustment):
+    def _on_conversation_vadjustment_changed(self, adjustment):
         self.scroll_to_end()
+        if self._fetching_history:
+            # Make sure the scroll position is kept
+            new_upper = adjustment.get_upper()
+            diff = new_upper - self._scrolled_old_upper
+            new_value = diff + self._scrolled_old_value
+            adjustment.set_value(new_value)
+            self._fetching_history = False
+            return
 
     def scroll_messages(self, direction, msg_buf, msg_type):
         if msg_type == 'sent':
@@ -1550,7 +1654,7 @@ def got_connected(self):
     def got_disconnected(self):
         self.msg_textview.set_sensitive(False)
         self.msg_textview.set_editable(False)
-        self.conv_textview.tv.grab_focus()
+        self.conversation_view.grab_focus()
 
         self.update_toolbar()
 
diff --git a/gajim/command_system/implementation/middleware.py b/gajim/command_system/implementation/middleware.py
index ebec45c557ce2389df2b7e23a6926ce7e4eefc8f..296387776ff39152bad68f4a434eb52583426a40 100644
--- a/gajim/command_system/implementation/middleware.py
+++ b/gajim/command_system/implementation/middleware.py
@@ -33,7 +33,7 @@
 
 from traceback import print_exc
 
-from gi.repository import Pango
+# from gi.repository import Pango
 
 from gajim.common import app
 from gajim.common.i18n import _
@@ -117,29 +117,33 @@ def __init__(self):
         self.install_tags()
 
     def install_tags(self):
-        buffer_ = self.conv_textview.tv.get_buffer()
+        # TODO implement this in ConversationView
+        # buffer_ = self.conv_textview.tv.get_buffer()
 
-        name = "Monospace"
-        font = Pango.FontDescription(name)
+        # name = "Monospace"
+        # font = Pango.FontDescription(name)
 
-        command_ok_tag = buffer_.create_tag("command_ok")
-        command_ok_tag.set_property("font-desc", font)
-        command_ok_tag.set_property("foreground", "#3465A4")
+        # command_ok_tag = buffer_.create_tag("command_ok")
+        # command_ok_tag.set_property("font-desc", font)
+        # command_ok_tag.set_property("foreground", "#3465A4")
 
-        command_error_tag = buffer_.create_tag("command_error")
-        command_error_tag.set_property("font-desc", font)
-        command_error_tag.set_property("foreground", "#F57900")
+        # command_error_tag = buffer_.create_tag("command_error")
+        # command_error_tag.set_property("font-desc", font)
+        # command_error_tag.set_property("foreground", "#F57900")
+        return
 
     def shift_line(self):
-        buffer_ = self.conv_textview.tv.get_buffer()
-        iter_ = buffer_.get_end_iter()
-        if iter_.ends_line() and not iter_.is_start():
-            buffer_.insert_with_tags_by_name(iter_, "\n", "eol")
+        # buffer_ = self.conv_textview.tv.get_buffer()
+        # iter_ = buffer_.get_end_iter()
+        # if iter_.ends_line() and not iter_.is_start():
+        #     buffer_.insert_with_tags_by_name(iter_, "\n", "eol")
+        return
 
     def append_with_tags(self, text, *tags):
-        buffer_ = self.conv_textview.tv.get_buffer()
-        iter_ = buffer_.get_end_iter()
-        buffer_.insert_with_tags_by_name(iter_, text, *tags)
+        # buffer_ = self.conv_textview.tv.get_buffer()
+        # iter_ = buffer_.get_end_iter()
+        # buffer_.insert_with_tags_by_name(iter_, text, *tags)
+        return
 
     def echo(self, text, tag="command_ok"):
         """
diff --git a/gajim/common/const.py b/gajim/common/const.py
index 2ce553f11fe94fbec04e16ad26023a8bd62c6ef1..aeb6302b19b2077bfa0a2dd44a2600d0033b9bd6 100644
--- a/gajim/common/const.py
+++ b/gajim/common/const.py
@@ -1049,6 +1049,7 @@ def is_active(self):
     'iris.xpcs:',
     'iris.lwz:',
     'ldap://',
+    'mailto:',
     'mid:',
     'modem:',
     'msrp://',
@@ -1081,6 +1082,7 @@ def is_active(self):
     'vemmi://',
     'xmlrpc.beep://',
     'xmlrpc.beeps://',
+    'xmpp:',
     'z39.50r://',
     'z39.50s://',
     'about:',
@@ -1107,6 +1109,22 @@ def is_active(self):
 }
 
 
+TRUST_SYMBOL_DATA = {
+    Trust.UNTRUSTED: ('dialog-error-symbolic',
+                      _('Untrusted'),
+                      'error-color'),
+    Trust.UNDECIDED: ('security-low-symbolic',
+                      _('Trust Not Decided'),
+                      'warning-color'),
+    Trust.BLIND: ('security-medium-symbolic',
+                  _('Unverified'),
+                  'encrypted-color'),
+    Trust.VERIFIED: ('security-high-symbolic',
+                     _('Verified'),
+                     'encrypted-color')
+}
+
+
 THRESHOLD_OPTIONS = {
     -1: _('No Sync'),
     1: _('1 Day'),
diff --git a/gajim/common/storage/archive.py b/gajim/common/storage/archive.py
index 80277b621570b634da570ec854f5e4dc38659439..a78131022598eeba4ad8a109b5a3a4a6aab9dcbf 100644
--- a/gajim/common/storage/archive.py
+++ b/gajim/common/storage/archive.py
@@ -321,72 +321,72 @@ def convert_show_values_to_db_api_values(show):
         return None
 
     @timeit
-    def load_groupchat_messages(self, account, jid):
-        account_id = self.get_account_id(account, type_=JIDConstant.ROOM_TYPE)
+    def get_conversation_before(self, account, jid, end_timestamp, n_lines):
+        """
+        Load n_lines lines of conversation with jid before end_timestamp
 
-        sql = '''
-            SELECT time, contact_name, message, additional_data, message_id
-            FROM logs NATURAL JOIN jids WHERE jid = ?
-            AND account_id = ? AND kind = ?
-            ORDER BY time DESC, log_line_id DESC LIMIT ?'''
+        :param account:         The account
 
-        messages = self._con.execute(
-            sql, (jid, account_id, KindConstant.GC_MSG, 50)).fetchall()
+        :param jid:             The jid for which we request the conversation
 
-        messages.reverse()
-        return messages
+        :param end_timestamp:   end timestamp / datetime.datetime instance
 
-    @timeit
-    def get_last_conversation_lines(self, account, jid, pending):
+        returns a list of namedtuples
         """
-        Get recent messages
-
-        Pending messages are already in queue to be printed when the
-        ChatControl is opened, so we don’t want to request those messages.
-        How many messages are requested depends on the 'restore_lines'
-        config value. How far back in time messages are requested depends on
-        _get_timeout().
-
-        :param account: The account
+        jids = self._get_family_jids(account, jid)
+        account_id = self.get_account_id(account)
 
-        :param jid:     The jid from which we request the conversation lines
+        sql = '''
+            SELECT contact_name, time, kind, show, message, subject,
+                   additional_data, log_line_id, message_id,
+                   error as "error [common_error]",
+                   marker as "marker [marker]"
+            FROM logs NATURAL JOIN jids WHERE jid IN ({jids})
+            AND account_id = {account_id}
+            AND time < ?
+            ORDER BY time DESC, log_line_id DESC
+            LIMIT ?
+            '''.format(jids=', '.join('?' * len(jids)),
+                       account_id=account_id)
 
-        :param pending: How many messages are currently pending so we don’t
-                        request those messages
+        return self._con.execute(
+            sql,
+            tuple(jids) + (end_timestamp.timestamp(), n_lines)).fetchall()
 
-        returns a list of namedtuples
+    @timeit
+    def get_conversation_muc_before(self, account, jid, end_timestamp,
+                                    n_lines):
         """
+        Load n_lines lines of conversation with jid before end_timestamp
+
+        :param account:         The account
 
-        restore = app.settings.get('restore_lines')
-        if restore <= 0:
-            return []
+        :param jid:             The jid for which we request the conversation
 
-        kinds = map(str, [KindConstant.SINGLE_MSG_RECV,
-                          KindConstant.SINGLE_MSG_SENT,
-                          KindConstant.CHAT_MSG_RECV,
-                          KindConstant.CHAT_MSG_SENT,
-                          KindConstant.ERROR])
+        :param end_timestamp:   end timestamp / datetime.datetime instance
 
+        returns a list of namedtuples
+        """
         jids = self._get_family_jids(account, jid)
-        account_id = self.get_account_id(account)
+        # TODO: this does not load messages correctly when account_id is set
+        # account_id = self.get_account_id(account, type_=JIDConstant.ROOM_TYPE)
 
         sql = '''
-            SELECT time, kind, message, error as "error [common_error]",
-                   subject, additional_data, marker as "marker [marker]",
-                   message_id
+            SELECT contact_name, time, kind, show, message, subject,
+                   additional_data, log_line_id, message_id,
+                   error as "error [common_error]",
+                   marker as "marker [marker]"
             FROM logs NATURAL JOIN jids WHERE jid IN ({jids})
-            AND account_id = {account_id} AND kind IN ({kinds})
-            AND time > get_timeout()
-            ORDER BY time DESC, log_line_id DESC LIMIT ? OFFSET ?
+            AND kind = {kind}
+            AND time < ?
+            ORDER BY time DESC, log_line_id DESC
+            LIMIT ?
             '''.format(jids=', '.join('?' * len(jids)),
-                       account_id=account_id,
-                       kinds=', '.join(kinds))
-
-        messages = self._con.execute(
-            sql, tuple(jids) + (restore, pending)).fetchall()
+                       kind=KindConstant.GC_MSG)
 
-        messages.reverse()
-        return messages
+        return self._con.execute(
+            sql,
+            tuple(jids) + (end_timestamp.timestamp(), n_lines)).fetchall()
 
     @timeit
     def get_last_conversation_line(self, account, jid):
diff --git a/gajim/data/gui/chat_control.ui b/gajim/data/gui/chat_control.ui
index f47c1419027d016cf993d7adf507c57f0b8654fc..a402f01708a18783afaf2defc7b4e5871d5cbf24 100644
--- a/gajim/data/gui/chat_control.ui
+++ b/gajim/data/gui/chat_control.ui
@@ -623,7 +623,9 @@
                     <property name="can_focus">True</property>
                     <property name="shadow_type">in</property>
                     <property name="overlay_scrolling">False</property>
+                    <signal name="edge-overshot" handler="_on_edge_overshot" swapped="no"/>
                     <signal name="edge-reached" handler="_on_edge_reached" swapped="no"/>
+                    <signal name="realize" handler="_on_scroll_realize" swapped="no"/>
                     <signal name="scroll-event" handler="_on_scroll" swapped="no"/>
                     <child>
                       <placeholder/>
diff --git a/gajim/data/gui/groupchat_control.ui b/gajim/data/gui/groupchat_control.ui
index 0695290b957759c321482de42db81627c7fed13e..5e11b173c0568d72dd89a8294dee11612b9eb224 100644
--- a/gajim/data/gui/groupchat_control.ui
+++ b/gajim/data/gui/groupchat_control.ui
@@ -348,7 +348,9 @@
                         <property name="can_focus">True</property>
                         <property name="shadow_type">in</property>
                         <property name="overlay_scrolling">False</property>
+                        <signal name="edge-overshot" handler="_on_edge_overshot" swapped="no"/>
                         <signal name="edge-reached" handler="_on_edge_reached" swapped="no"/>
+                        <signal name="realize" handler="_on_scroll_realize" swapped="no"/>
                         <signal name="scroll-event" handler="_on_scroll" swapped="no"/>
                         <child>
                           <placeholder/>
diff --git a/gajim/data/style/gajim-dark.css b/gajim/data/style/gajim-dark.css
index 54a04099beab5bfa85693246db6255860302843c..90c4f1b4bcc1652f2e68781012f79ac6c323f41c 100644
--- a/gajim/data/style/gajim-dark.css
+++ b/gajim/data/style/gajim-dark.css
@@ -17,3 +17,14 @@ infobar.error > revealer > box {
     color: #FCE5D5;
     background-color: #D64937;
 }
+
+/* ConversationView */
+.conversation-mention-highlight {
+    background-color: rgb(251, 151, 97);
+}
+.conversation-mention-highlight:hover {
+    background-color: rgb(251, 151, 97);
+}
+.conversation-mention-highlight grid textview text {
+	background-color: rgb(251, 151, 97);
+}
diff --git a/gajim/data/style/gajim.css b/gajim/data/style/gajim.css
index 5988c679e5fbf5efe99e529d8b6d04427f3c3714..63de50cc7d4d4114353ad282548d1a21ba198ca9 100644
--- a/gajim/data/style/gajim.css
+++ b/gajim/data/style/gajim.css
@@ -40,6 +40,38 @@ .message-input-focus {
     border-radius: 4px;
 }
 
+.conversation-row {
+    padding: 6px 3px 6px 6px;
+}
+.conversation-row grid textview {
+	background: transparent;
+}
+.conversation-row grid textview text {
+	background: transparent;
+}
+.conversation-mention-highlight {
+    background-color: rgb(255, 215, 194);
+}
+.conversation-system-row {
+    padding: 18px;
+}
+.conversation-date-row {
+    padding: 12px;
+}
+.conversation-meta {
+    color: @insensitive_fg_color;
+    font-size: small;
+}
+.conversation-read-marker {
+    color: @insensitive_fg_color;
+    font-size: small;
+    font-style: italic;
+}
+.conversation-nickname {
+    font-size: small;
+    font-weight: bold;
+}
+
 .link-button { min-height: 0px; }
 
 /* Main Window */
diff --git a/gajim/groupchat_control.py b/gajim/groupchat_control.py
index 2c96f7492526156e99664ddd33e43f74c76a246b..34ea24319640bd0955bb5ca8a9a4fc01fc74a9c1 100644
--- a/gajim/groupchat_control.py
+++ b/gajim/groupchat_control.py
@@ -221,7 +221,6 @@ def __init__(self, parent_win, jid, muc_data, acct):
         # PluginSystem: adding GUI extension point for this GroupchatControl
         # instance object
         app.plugin_manager.gui_extension_point('groupchat_control', self)
-        self._restore_conversation()
 
     def _connect_contact_signals(self):
         self.contact.multi_connect({
@@ -259,6 +258,8 @@ def _on_muc_state_changed(self, _contact, _signal_name):
 
             self.xml.formattings_button.set_sensitive(True)
 
+            self.conversation_view.update_avatars()
+
             self.update_actions()
 
         elif state == MUCJoinedState.NOT_JOINED:
@@ -425,25 +426,6 @@ def remove_actions(self):
         for action in actions:
             self.parent_win.window.remove_action(f'{action}{self.control_id}')
 
-    def _restore_conversation(self):
-        rows = app.storage.archive.load_groupchat_messages(
-            self.account, self.contact.jid)
-
-        for row in rows:
-            other_tags_for_name = ['muc_nickname_color_%s' % row.contact_name]
-            ChatControlBase.add_message(self,
-                                        row.message,
-                                        'incoming',
-                                        row.contact_name,
-                                        float(row.time),
-                                        other_tags_for_name=other_tags_for_name,
-                                        message_id=row.message_id,
-                                        restored=True,
-                                        additional_data=row.additional_data)
-
-        if rows:
-            self.conv_textview.print_empty_line()
-
     def _is_subject_change_allowed(self):
         contact = self.contact.get_resource(self.nick)
         if contact.affiliation in (Affiliation.OWNER, Affiliation.ADMIN):
@@ -851,7 +833,7 @@ def add_message(self, text, contact='', tim=None,
 
             self._nick_completion.record_message(contact, highlight)
 
-            self.check_focus_out_line()
+            # self.check_focus_out_line()
 
         ChatControlBase.add_message(self,
                                     text,
@@ -908,7 +890,7 @@ def check_focus_out_line(self):
         if app.window.is_chat_active(self.account, self.room_jid):
             return
 
-        self.conv_textview.show_focus_out_line()
+        # self.conv_textview.show_focus_out_line()
 
     def needs_visual_notification(self, text):
         """
@@ -1102,11 +1084,12 @@ def _on_user_nickname_changed(self, _contact, _signal_name, user_contact, proper
 
         self.add_info_message(message)
 
-        tv = self.conv_textview
-        if nick in tv.last_received_message_id:
-            tv.last_received_message_id[new_nick] = \
-                tv.last_received_message_id[nick]
-            del tv.last_received_message_id[nick]
+        # TODO: What to do with this?
+        # tv = self.conv_textview
+        # if nick in tv.last_received_message_id:
+        #     tv.last_received_message_id[new_nick] = \
+        #         tv.last_received_message_id[nick]
+        #     del tv.last_received_message_id[nick]
 
 
     def _on_user_status_show_changed(self,
@@ -1410,7 +1393,7 @@ def send_message(self, message, xhtml=None, process_commands=True):
             self.msg_textview.grab_focus()
 
     def _on_message_error(self, event):
-        self.conv_textview.show_error(event.message_id, event.error)
+        self.conversation_view.show_error(event.message_id, event.error)
 
     def shutdown(self, reason=None):
         app.settings.disconnect_signals(self)
@@ -1483,12 +1466,12 @@ def _close_control(self, reason=None):
             # self.parent_win.remove_tab(self, None, reason=reason, force=True)
 
     def set_control_active(self, state):
-        self.conv_textview.allow_focus_out_line = True
+        # self.conv_textview.allow_focus_out_line = True
         self.attention_flag = False
         ChatControlBase.set_control_active(self, state)
-        if not state:
-            # add the focus-out line to the tab we are leaving
-            self.check_focus_out_line()
+        # if not state:
+        #    # add the focus-out line to the tab we are leaving
+        #    self.check_focus_out_line()
         # Sending active to undo unread state
         self.parent_win.redraw_tab(self, 'active')
 
diff --git a/gajim/gtk/conversation_textview.py b/gajim/gtk/conversation_textview.py
new file mode 100644
index 0000000000000000000000000000000000000000..d9b34b2230cfbc7bdd65cfdebfbb45f2c56b8156
--- /dev/null
+++ b/gajim/gtk/conversation_textview.py
@@ -0,0 +1,608 @@
+# This file is part of Gajim.
+#
+# Gajim is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published
+# by the Free Software Foundation; version 3 only.
+#
+# Gajim is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+from urllib.parse import quote
+
+from gi.repository import GObject
+from gi.repository import Gtk
+from gi.repository import Pango
+
+from gajim.common import app
+from gajim.common import i18n
+from gajim.common.const import StyleAttr
+from gajim.common.const import URI_SCHEMES
+from gajim.common.helpers import AdditionalDataDict
+from gajim.common.helpers import open_uri
+from gajim.common.helpers import puny_encode_url
+from gajim.common.helpers import reduce_chars_newlines
+from gajim.common.i18n import _
+from gajim.common.regex import STH_AT_STH_DOT_STH_REGEX
+from gajim.common.regex import BASIC_REGEX
+from gajim.common.regex import LINK_REGEX
+from gajim.common.regex import EMOT_AND_BASIC_REGEX
+from gajim.common.regex import EMOT_AND_LINK_REGEX
+
+from .htmltextview import HtmlTextView
+from .emoji_data import emoji_pixbufs
+from .emoji_data import is_emoji
+from .emoji_data import get_emoji_pixbuf
+from .util import get_cursor
+
+log = logging.getLogger('gajim.gui.conversation_view')
+
+
+class ConversationTextview(HtmlTextView):
+
+    __gsignals__ = {
+        'quote': (
+            GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
+            None,
+            (str, )
+        ),
+    }
+
+    def __init__(self, account, history_mode=False):
+        HtmlTextView.__init__(self, account)
+        self.set_hexpand(True)
+        self.set_vexpand(True)
+        self.set_margin_start(0)
+        self.set_margin_end(0)
+        self.set_border_width(0)
+        self.set_left_margin(0)
+        self.set_right_margin(0)
+
+        self._history_mode = history_mode
+
+        self.handlers = {}
+
+        id_ = self.connect('query-tooltip', self._query_tooltip)
+        self.handlers[id_] = self
+        id_ = self.connect('button-press-event', self._on_button_press)
+        self.handlers[id_] = self
+        id_ = self.connect('populate-popup', self._on_populate_popup)
+        self.handlers[id_] = self
+
+        # Used for changing the mouse pointer when hovering clickable URIs
+        self._cursor_changed = False
+
+        # Keeps text selections for quoting and search actions
+        self._selected_text = ''
+
+        self.get_style_context().add_class('gajim-conversation-font')
+
+        buffer_ = self.get_buffer()
+
+        self.tag_in = buffer_.create_tag('incoming')
+        color = app.css_config.get_value(
+            '.gajim-incoming-nickname', StyleAttr.COLOR)
+        self.tag_in.set_property('foreground', color)
+        desc = app.css_config.get_font('.gajim-incoming-nickname')
+        self.tag_in.set_property('font-desc', desc)
+
+        self.tag_out = buffer_.create_tag('outgoing')
+        color = app.css_config.get_value(
+            '.gajim-outgoing-nickname', StyleAttr.COLOR)
+        self.tag_out.set_property('foreground', color)
+        desc = app.css_config.get_font('.gajim-outgoing-nickname')
+        self.tag_out.set_property('font-desc', desc)
+
+        self.tag_status = buffer_.create_tag('status')
+        color = app.css_config.get_value(
+            '.gajim-status-message', StyleAttr.COLOR)
+        self.tag_status.set_property('foreground', color)
+        desc = app.css_config.get_font('.gajim-status-message')
+        self.tag_status.set_property('font-desc', desc)
+
+        tag_in_text = buffer_.create_tag('incomingtxt')
+        color = app.css_config.get_value(
+            '.gajim-incoming-message-text', StyleAttr.COLOR)
+        if color:
+            tag_in_text.set_property('foreground', color)
+        desc = app.css_config.get_font('.gajim-incoming-message-text')
+        tag_in_text.set_property('font-desc', desc)
+
+        tag_out_text = buffer_.create_tag('outgoingtxt')
+        color = app.css_config.get_value(
+            '.gajim-outgoing-message-text', StyleAttr.COLOR)
+        if color:
+            tag_out_text.set_property('foreground', color)
+        desc = app.css_config.get_font('.gajim-outgoing-message-text')
+        tag_out_text.set_property('font-desc', desc)
+
+        self.tag_marked = buffer_.create_tag('marked')
+        color = app.css_config.get_value(
+            '.gajim-highlight-message', StyleAttr.COLOR)
+        self.tag_marked.set_property('foreground', color)
+        self.tag_marked.set_property('weight', Pango.Weight.BOLD)
+
+        tag = buffer_.create_tag('small')
+        tag.set_property('scale', 0.8333333333333)
+
+        tag = buffer_.create_tag('bold')
+        tag.set_property('weight', Pango.Weight.BOLD)
+
+        tag = buffer_.create_tag('italic')
+        tag.set_property('style', Pango.Style.ITALIC)
+
+        tag = buffer_.create_tag('strikethrough')
+        tag.set_property('strikethrough', True)
+
+        self.create_tags()
+
+        self.connect('destroy', self._on_destroy)
+
+    def _on_destroy(self, *args):
+        super()._on_destroy()
+        for i in self.handlers:
+            if self.handlers[i].handler_is_connected(i):
+                self.handlers[i].disconnect(i)
+        self.handlers.clear()
+
+    def update_text_tags(self):
+        self.tag_in.set_property('foreground', app.css_config.get_value(
+            '.gajim-incoming-nickname', StyleAttr.COLOR))
+        self.tag_out.set_property('foreground', app.css_config.get_value(
+            '.gajim-outgoing-nickname', StyleAttr.COLOR))
+        self.tag_status.set_property('foreground', app.css_config.get_value(
+            '.gajim-status-message', StyleAttr.COLOR))
+        self.tag_marked.set_property('foreground', app.css_config.get_value(
+            '.gajim-highlight-message', StyleAttr.COLOR))
+        self.update_tags()
+
+    def clear(self):
+        buffer_ = self.get_buffer()
+        start, end = buffer_.get_bounds()
+        buffer_.delete(start, end)
+
+    def get_text(self):
+        buffer_ = self.get_buffer()
+        start, end = buffer_.get_bounds()
+        return buffer_.get_text(start, end, False)
+
+    def print_text(self, text, other_text_tags=None, kind=None, graphics=True,
+                   name=None, additional_data=None):
+        if additional_data is None:
+            additional_data = AdditionalDataDict()
+
+        # Print XHTML if present
+        xhtml = additional_data.get_value('gajim', 'xhtml', False)
+        if xhtml and app.settings.get('show_xhtml'):
+            try:
+                if (name and (text.startswith('/me ') or
+                              text.startswith('/me\n'))):
+                    xhtml = xhtml.replace('/me', '<i>* %s</i>' % (name,), 1)
+                self.display_html(
+                    xhtml, self, self)
+                return
+            except Exception as err:
+                log.debug('Error processing xhtml: %s', err)
+                log.debug('with |%s|', xhtml)
+
+        text_tags = []
+        if other_text_tags:
+            text_tags = other_text_tags[:]  # create a new list
+
+        if (kind == 'status' or
+                text.startswith('/me') or text.startswith('/me\n')):
+            text_tags.append(kind)
+
+        if name and (text.startswith('/me ') or text.startswith('/me\n')):
+            text = '* ' + name + text[3:]
+            text_tags.append('italic')
+
+        if kind in ('incoming', 'incoming_queue'):
+            text_tags.append('incomingtxt')
+        elif kind == 'outgoing':
+            text_tags.append('outgoingtxt')
+
+        self.parse_formatting(
+            text, text_tags, graphics=graphics, additional_data=additional_data)
+
+    def parse_formatting(self, text, text_tags, graphics=True,
+                         additional_data=None):
+        '''
+        Parses message formatting (Emojis, URIs, Styles).
+        A regex is used for text matching. Each text fragment gets
+        passed to apply_formatting(), where respective TextTags are added.
+        Unformatted text (no match) will be passed through unaltered.
+        '''
+        if not text:
+            return
+
+        if text_tags is None:
+            text_tags = []
+
+        buffer_ = self.get_buffer()
+
+        if text_tags:
+            insert_tags_func = buffer_.insert_with_tags_by_name
+        else:
+            insert_tags_func = buffer_.insert
+
+        # TODO: Adapt HtmlHandler.handle_specials()
+        # detect_and_print_special_text() is used by
+        # HtmlHandler.handle_specials() and uses Gtk.TextTag objects,
+        # not strings
+        if text_tags and isinstance(text_tags[0], Gtk.TextTag):
+            insert_tags_func = buffer_.insert_with_tags
+
+        # TODO: Plugin system GUI extension point
+        # self.plugin_modified = False
+        # app.plugin_manager.extension_point('print_real_text', self,
+        #    text, text_tags, graphics, additional_data)
+        # if self.plugin_modified:
+        #    return
+
+        # Add XEP-0066 Out of Band text to the end
+        oob_url = additional_data.get_value('gajim', 'oob_url')
+        if oob_url is not None:
+            oob_desc = additional_data.get_value('gajim', 'oob_desc', 'URL:')
+            if oob_url != text:
+                text += f'\n{oob_desc} {oob_url}'
+
+        if app.settings.get('emoticons_theme') and graphics:
+            # Match for Emojis & URIs
+            if app.settings.get('ascii_formatting'):
+                regex = EMOT_AND_BASIC_REGEX
+            else:
+                regex = EMOT_AND_LINK_REGEX
+        else:
+            if app.settings.get('ascii_formatting'):
+                # Match for URIs + mail + formatting
+                regex = BASIC_REGEX
+            else:
+                # Match only for URIs + formatting
+                regex = LINK_REGEX
+
+        iterator = regex.finditer(text)
+        end_iter = buffer_.get_end_iter()
+        # TODO: Evaluate limit
+        # Too many fragments (emoticons, LaTeX formulas, etc)
+        # may cause Gajim to freeze (see #5129).
+        # We impose an arbitrary limit of 100 fragments per message.
+        fragment_limit = 100
+        index = 0
+        for match in iterator:
+            start, end = match.span()
+            fragment = text[start:end]
+            if start > index:
+                text_before_fragment = text[index:start]
+                end_iter = buffer_.get_end_iter()
+                if text_tags:
+                    insert_tags_func(
+                        end_iter, text_before_fragment, *text_tags)
+                else:
+                    buffer_.insert(end_iter, text_before_fragment)
+            index = end
+
+            self.apply_formatting(fragment,
+                                  text_tags,
+                                  graphics=graphics,
+                                  additional_data=additional_data)
+            fragment_limit += 1
+            if fragment_limit <= 0:
+                break
+
+        # Add remaining text after last match
+        insert_tags_func(buffer_.get_end_iter(), text[index:], *text_tags)
+
+    def apply_formatting(self, fragment, text_tags, graphics=True,
+                         additional_data=None):
+        # TODO: Plugin system GUI extension point
+        # self.plugin_modified = False
+        # app.plugin_manager.extension_point('print_special_text', self,
+        #    fragment, text_tags, graphics, additional_data)
+        # if self.plugin_modified:
+        #     return
+
+        tags = []
+        buffer_ = self.get_buffer()
+        ttt = buffer_.get_tag_table()
+
+        # Detect XHTML-IM link
+        is_xhtml_uri = False
+        tags_ = [
+            (ttt.lookup(t) if isinstance(t, str) else t) for t in text_tags]
+        for tag in tags_:
+            is_xhtml_uri = getattr(tag, 'href', False)
+            if is_xhtml_uri:
+                break
+
+        # Check if we accept this as an uri
+        is_valid_uri = fragment.startswith(tuple(URI_SCHEMES))
+
+        end_iter = buffer_.get_end_iter()
+
+        theme = app.settings.get('emoticons_theme')
+        show_emojis = theme and theme != 'font'
+
+        # XEP-0393 Message Styling
+        # * = bold
+        # _ = italic
+        # ~ = strikethrough
+        show_formatting_chars = app.settings.get(
+            'show_ascii_formatting_chars')
+
+        if show_emojis and graphics and is_emoji(fragment):
+            # it's an emoticon
+            if emoji_pixbufs.complete:
+                # only search for the pixbuf if we are sure
+                # that loading is completed
+                pixbuf = get_emoji_pixbuf(fragment)
+                if pixbuf is None:
+                    buffer_.insert(end_iter, fragment)
+                else:
+                    pixbuf = pixbuf.copy()
+                    anchor = buffer_.create_child_anchor(end_iter)
+                    anchor.plaintext = fragment
+                    img = Gtk.Image.new_from_pixbuf(pixbuf)
+                    img.show()
+                    self.add_child_at_anchor(img, anchor)
+            else:
+                # Set marks and save them so we can replace emojis
+                # once the loading is complete
+                start_mark = buffer_.create_mark(None, end_iter, True)
+                buffer_.insert(end_iter, fragment)
+                end_mark = buffer_.create_mark(None, end_iter, True)
+                emoji_pixbufs.append_marks(
+                    self, start_mark, end_mark, fragment)
+        elif (fragment.startswith('www.') or
+                fragment.startswith('ftp.') or
+                is_valid_uri and not is_xhtml_uri):
+            tags.append('url')
+        elif fragment.startswith('mailto:') and not is_xhtml_uri:
+            tags.append('mail')
+        elif fragment.startswith('xmpp:') and not is_xhtml_uri:
+            tags.append('xmpp')
+        elif STH_AT_STH_DOT_STH_REGEX.match(fragment) and not is_xhtml_uri:
+            # JID or E-Mail
+            tags.append('sth_at_sth')
+        elif fragment.startswith('*'):
+            tags.append('bold')
+            if (fragment[1] == '~' and fragment[-2] == '~' and
+                    len(fragment) > 4):
+                tags.append('strikethrough')
+                if not show_formatting_chars:
+                    fragment = fragment[2:-2]
+            elif (fragment[1] == '_' and fragment[-2] == '_' and
+                    len(fragment) > 4):
+                tags.append('italic')
+                if not show_formatting_chars:
+                    fragment = fragment[2:-2]
+            else:
+                if not show_formatting_chars:
+                    fragment = fragment[1:-1]
+        elif fragment.startswith('~'):
+            tags.append('strikethrough')
+            if (fragment[1] == '*' and fragment[-2] == '*' and
+                    len(fragment) > 4):
+                tags.append('bold')
+                if not show_formatting_chars:
+                    fragment = fragment[2:-2]
+            elif (fragment[1] == '_' and fragment[-2] == '_' and
+                    len(fragment) > 4):
+                tags.append('italic')
+                if not show_formatting_chars:
+                    fragment = fragment[2:-2]
+            else:
+                if not show_formatting_chars:
+                    fragment = fragment[1:-1]
+        elif fragment.startswith('_'):
+            tags.append('italic')
+            if (fragment[1] == '*' and fragment[-2] == '*' and
+                    len(fragment) > 4):
+                tags.append('bold')
+                if not show_formatting_chars:
+                    fragment = fragment[2:-2]
+            elif (fragment[1] == '~' and fragment[-2] == '~' and
+                    len(fragment) > 4):
+                tags.append('strikethrough')
+                if not show_formatting_chars:
+                    fragment = fragment[2:-2]
+            else:
+                if not show_formatting_chars:
+                    fragment = fragment[1:-1]
+        else:
+            insert_tags_func = buffer_.insert_with_tags_by_name
+            if text_tags and isinstance(text_tags[0], Gtk.TextTag):
+                insert_tags_func = buffer_.insert_with_tags
+            if text_tags:
+                insert_tags_func(end_iter, fragment, *text_tags)
+            else:
+                buffer_.insert(end_iter, fragment)
+
+        if tags:
+            all_tags = tags[:]
+            all_tags += text_tags
+            # convert all names to TextTag
+            all_tags = [
+                (ttt.lookup(t) if isinstance(t, str) else t) for t in all_tags]
+            buffer_.insert_with_tags(end_iter, fragment, *all_tags)
+            if 'url' in tags:
+                puny_text = puny_encode_url(fragment)
+                if puny_text != fragment:
+                    puny_tags = []
+                    if not puny_text:
+                        puny_text = _('Invalid URL')
+                    puny_tags = [(ttt.lookup(t) if isinstance(
+                        t, str) else t) for t in puny_tags]
+                    puny_tags += text_tags
+                    buffer_.insert_with_tags(
+                        end_iter, " (%s)" % puny_text, *puny_tags)
+
+    def _query_tooltip(self, widget, x_pos, y_pos, _keyboard_mode, tooltip):
+        window = widget.get_window(Gtk.TextWindowType.TEXT)
+        x_pos, y_pos = self.window_to_buffer_coords(
+            Gtk.TextWindowType.TEXT, x_pos, y_pos)
+
+        iter_ = self.get_iter_at_position(x_pos, y_pos)[1]
+        for tag in iter_.get_tags():
+            tag_name = tag.get_property('name')
+            if getattr(tag, 'is_anchor', False):
+                text = getattr(tag, 'title', False)
+                if text:
+                    if len(text) > 50:
+                        text = reduce_chars_newlines(text, 47, 1)
+                    tooltip.set_text(text)
+                    window.set_cursor(get_cursor('pointer'))
+                    self._cursor_changed = True
+                    return True
+            if tag_name in ('url', 'mail', 'xmpp', 'sth_at_sth'):
+                window.set_cursor(get_cursor('pointer'))
+                self._cursor_changed = True
+                return False
+
+        if self._cursor_changed:
+            window.set_cursor(get_cursor('text'))
+            self._cursor_changed = False
+        return False
+
+    def _on_button_press(self, _widget, event):
+        '''
+        We don’t open the standard context menu when receiving
+        a click on tagged text.
+        If it’s untagged text, check if something is selected
+        '''
+        self._selected_text = ''
+
+        if event.button != 3:
+            # If it’s not a right click
+            return False
+
+        x_pos, y_pos = self.window_to_buffer_coords(
+            Gtk.TextWindowType.TEXT,
+            int(event.x),
+            int(event.y))
+        iter_ = self.get_iter_at_location(x_pos, y_pos)
+        if isinstance(iter_, tuple):
+            iter_ = iter_[1]
+        tags = iter_.get_tags()
+
+        if tags:
+            # A tagged text fragment has been clicked
+            for tag in tags:
+                tag_name = tag.get_property('name')
+                if tag_name in ('url', 'mail', 'xmpp', 'sth_at_sth'):
+                    # Block regular context menu
+                    return True
+
+        # Check if there is a selection and make it available for
+        # _on_populate_popup
+        buffer_ = self.get_buffer()
+        return_val = buffer_.get_selection_bounds()
+        if return_val:
+            # Something has been selected, get the text
+            start_sel, finish_sel = return_val[0], return_val[1]
+            self._selected_text = buffer_.get_text(
+                start_sel, finish_sel, True)
+        elif iter_.get_char() and ord(iter_.get_char()) > 31:
+            # Clicked on a word, take whole word for selection
+            start_sel = iter_.copy()
+            if not start_sel.starts_word():
+                start_sel.backward_word_start()
+            finish_sel = iter_.copy()
+            if not finish_sel.ends_word():
+                finish_sel.forward_word_end()
+            self._selected_text = buffer_.get_text(
+                start_sel, finish_sel, True)
+        return False
+
+    def _on_populate_popup(self, _textview, menu):
+        '''
+        Overrides the default context menu.
+        If text is selected, a submenu with actions on the selection is added.
+        (see _on_button_press)
+        '''
+        if not self._selected_text:
+            menu.show_all()
+            return
+
+        if not self._history_mode:
+            item = Gtk.MenuItem.new_with_mnemonic(_('_Quote'))
+            id_ = item.connect('activate', self._on_quote)
+            self.handlers[id_] = item
+            menu.prepend(item)
+
+        selected_text_short = reduce_chars_newlines(
+            self._selected_text, 25, 2)
+        item = Gtk.MenuItem.new_with_mnemonic(
+            _('_Actions for "%s"') % selected_text_short)
+        menu.prepend(item)
+        submenu = Gtk.Menu()
+        item.set_submenu(submenu)
+        uri_text = quote(self._selected_text.encode('utf-8'))
+
+        if app.settings.get('always_english_wikipedia'):
+            uri = (f'https://en.wikipedia.org/wiki/'
+                   f'Special:Search?search={uri_text}')
+        else:
+            uri = (f'https://{i18n.get_short_lang_code()}.wikipedia.org/'
+                   f'wiki/Special:Search?search={uri_text}')
+        item = Gtk.MenuItem.new_with_mnemonic(_('Read _Wikipedia Article'))
+        id_ = item.connect('activate', self._visit_uri, uri)
+        self.handlers[id_] = item
+        submenu.append(item)
+
+        item = Gtk.MenuItem.new_with_mnemonic(
+            _('Look it up in _Dictionary'))
+        dict_link = app.settings.get('dictionary_url')
+        if dict_link == 'WIKTIONARY':
+            # Default is wikitionary.org
+            if app.settings.get('always_english_wiktionary'):
+                uri = (f'https://en.wiktionary.org/wiki/'
+                       f'Special:Search?search={uri_text}')
+            else:
+                uri = (f'https://{i18n.get_short_lang_code()}.wiktionary.org/'
+                       f'wiki/Special:Search?search={uri_text}')
+            id_ = item.connect('activate', self._visit_uri, uri)
+            self.handlers[id_] = item
+        else:
+            if dict_link.find('%s') == -1:
+                # There has to be a '%s' in the url if it’s not WIKTIONARY
+                item = Gtk.MenuItem.new_with_label(
+                    _('Dictionary URL is missing a "%s"'))
+                item.set_sensitive(False)
+            else:
+                uri = dict_link % uri_text
+                id_ = item.connect('activate', self._visit_uri, uri)
+                self.handlers[id_] = item
+        submenu.append(item)
+
+        search_link = app.settings.get('search_engine')
+        if search_link.find('%s') == -1:
+            # There has to be a '%s' in the url
+            item = Gtk.MenuItem.new_with_label(
+                _('Web Search URL is missing a "%s"'))
+            item.set_sensitive(False)
+        else:
+            item = Gtk.MenuItem.new_with_mnemonic(_('Web _Search for it'))
+            uri = search_link % uri_text
+            id_ = item.connect('activate', self._visit_uri, uri)
+            self.handlers[id_] = item
+        submenu.append(item)
+
+        item = Gtk.MenuItem.new_with_mnemonic(_('Open as _Link'))
+        id_ = item.connect('activate', self._visit_uri, uri)
+        self.handlers[id_] = item
+        submenu.append(item)
+
+        menu.show_all()
+
+    def _on_quote(self, _widget):
+        self.emit('quote', self._selected_text)
+
+    @staticmethod
+    def _visit_uri(_widget, uri):
+        open_uri(uri)
diff --git a/gajim/gtk/conversation_view.py b/gajim/gtk/conversation_view.py
new file mode 100644
index 0000000000000000000000000000000000000000..c6fdf3cdde01aeed7f57a660accfc20fe3ef5f5d
--- /dev/null
+++ b/gajim/gtk/conversation_view.py
@@ -0,0 +1,977 @@
+# This file is part of Gajim.
+#
+# Gajim is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published
+# by the Free Software Foundation; version 3 only.
+#
+# Gajim is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with Gajim. If not, see <http://www.gnu.org/licenses/>.
+
+import logging
+import time
+
+from bisect import bisect_left
+from bisect import bisect_right
+from collections import deque
+from datetime import datetime
+from datetime import timedelta
+
+from gi.repository import Gdk
+from gi.repository import GLib
+from gi.repository import GObject
+from gi.repository import Gtk
+from gi.repository import Pango
+
+from gajim.common import app
+from gajim.common.const import AvatarSize
+from gajim.common.const import TRUST_SYMBOL_DATA
+from gajim.common.helpers import from_one_line
+from gajim.common.helpers import reduce_chars_newlines
+from gajim.common.helpers import to_user_string
+from gajim.common.i18n import _
+from gajim.common.i18n import Q_
+
+from .conversation_textview import ConversationTextview
+from .util import convert_rgba_to_hex
+from .util import format_fingerprint
+from .util import scroll_to_end
+from .util import text_to_color
+
+log = logging.getLogger('gajim.gui.conversation_view')
+
+
+class ConversationView(Gtk.ListBox):
+
+    __gsignals__ = {
+        'quote': (
+            GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
+            None,
+            (str, )
+        ),
+        'load-history': (
+            GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
+            None,
+            (str, )
+        )
+    }
+
+    def __init__(self, account, contact, history_mode=False):
+        Gtk.ListBox.__init__(self)
+        self.set_selection_mode(Gtk.SelectionMode.NONE)
+        self._account = account
+        self._client = app.get_client(account)
+        self._contact = contact
+        self._history_mode = history_mode
+
+        self.encryption_enabled = False
+        self.autoscroll = True
+        self.clearing = False
+
+        # Both first and last DateRow (datetime)
+        self._first_date = None
+        self._last_date = None
+
+        # Keeps track of the number of rows shown in ConversationView
+        self._row_count = 0
+        self._max_row_count = 100
+
+        # Keeps inserted message IDs to avoid re-inserting the same message
+        self._message_ids_inserted = {}
+
+        # We keep a sorted array of all the timestamps we've inserted, which
+        # have a 1-to-1 mapping to the actual child elements of this ListBox
+        # (*all* rows are included).
+        # Binary-searching this array enables us to insert a message with a
+        # discontinuous timestamp (not less than or greater than the first or
+        # last message timestamp in the list), and insert it at the correct
+        # place in the ListBox.
+        self._timestamps_inserted = deque()
+
+        # Stores the timestamp of the first *chat* row inserted
+        # (not a system row or date row)
+        self.first_message_timestamp = None
+
+        # Last incoming chat message timestamp (used for ReadMarkerRows)
+        self._last_incoming_timestamp = datetime.fromtimestamp(0)
+
+        # Insert the very first row, containing the scroll hint and load button
+        self.add(ScrollHintRow(self._account))
+        self._timestamps_inserted.append(datetime.fromtimestamp(0))
+
+    def clear(self):
+        self.clearing = True
+        for row in self.get_children()[1:]:
+            self.remove(row)
+
+        GLib.idle_add(self._reset_conversation_view)
+
+    def _reset_conversation_view(self):
+        self._first_date = None
+        self._last_date = None
+        self._message_ids_inserted.clear()
+        self.first_message_timestamp = None
+        self._last_incoming_timestamp = datetime.fromtimestamp(0)
+        self._timestamps_inserted.clear()
+        self._row_count = 0
+        self.clearing = False
+
+    def add_message(self,
+                    text,
+                    kind,
+                    name,
+                    timestamp,
+                    other_text_tags=None,
+                    message_id=None,
+                    correct_id=None,
+                    display_marking=None,
+                    additional_data=None,
+                    subject=None,
+                    marker=None,
+                    error=None,
+                    history=False,
+                    graphics=True):
+
+        log.debug(
+            'Adding message: %s, %s, %s, %s, message_id: %s, correct_id: %s, '
+            'other_text_tags: %s, display_marking: %s, additional_data: %s, '
+            'subject: %s, marker: %s, error: %s, history: %s, graphics: %s',
+            text, kind, name, timestamp, message_id, correct_id,
+            other_text_tags, display_marking, additional_data, subject,
+            marker, error, history, graphics)
+
+        if message_id:
+            if message_id in self._message_ids_inserted:
+                log.warning('Rejecting insertion of duplicate message_id %s',
+                            str(message_id))
+                return
+            self._message_ids_inserted[message_id] = True
+
+        if not timestamp:
+            timestamp = time.time()
+        time_ = datetime.fromtimestamp(timestamp)
+
+        if other_text_tags is None:
+            other_text_tags = []
+
+        if kind in ('status', 'info') or subject:
+            message = InfoMessageRow(
+                self._account,
+                time_,
+                text,
+                other_text_tags,
+                kind,
+                subject,
+                graphics,
+                history_mode=self._history_mode)
+        else:
+            if correct_id:
+                self.correct_message(
+                    correct_id,
+                    message_id,
+                    text,
+                    other_text_tags,
+                    kind,
+                    name,
+                    additional_data=additional_data)
+                return
+
+            avatar = self._get_avatar(kind, name)
+            message = TextMessageRow(
+                self._account,
+                message_id,
+                time_,
+                kind,
+                name,
+                text,
+                other_text_tags,
+                avatar,
+                self._contact.is_groupchat,
+                additional_data=additional_data,
+                display_marking=display_marking,
+                marker=marker,
+                error=error,
+                encryption_enabled=self.encryption_enabled,
+                history_mode=self._history_mode)
+
+        self._insert_message(message, time_, kind, history)
+
+        # Check for maximum message count
+        if self.autoscroll and self._row_count > self._max_row_count:
+            self._reduce_message_count()
+
+    def _get_avatar(self, kind, name):
+        scale = self.get_scale_factor()
+        if self._contact.is_groupchat:
+            contact = self._contact.get_resource(name)
+            return contact.get_avatar(AvatarSize.ROSTER, scale, add_show=False)
+
+        if kind == 'outgoing':
+            contact = self._client.get_module('Contacts').get_contact(
+                str(self._client.get_own_jid().bare))
+        else:
+            contact = self._contact
+
+        return contact.get_avatar(AvatarSize.ROSTER, scale, add_show=False)
+
+    def _insert_message(self, message, time_, kind, history):
+        current_date = time_.strftime('%a, %d %b %Y')
+
+        if self._is_out_of_order(time_, history):
+            insertion_point = bisect_left(self._timestamps_inserted, time_)
+            date_check_point = min(len(
+                self._timestamps_inserted) - 1, insertion_point - 1)
+            date_at_dcp = self._timestamps_inserted[date_check_point].strftime(
+                '%a, %d %b %Y')
+            if date_at_dcp != current_date:
+                associated_timestamp = time_ - timedelta(
+                    hours=time_.hour,
+                    minutes=time_.minute,
+                    seconds=time_.second)
+                date_row = DateRow(
+                    self._account, current_date, associated_timestamp)
+                self.insert(date_row, insertion_point)
+                self._timestamps_inserted.insert(
+                    insertion_point, associated_timestamp)
+                self._row_count += 1
+                insertion_point += 1
+            if (kind in ('incoming', 'incoming_queue') and
+                    time_ > self._last_incoming_timestamp):
+                self._last_incoming_timestamp = time_
+            self.insert(message, insertion_point)
+            self._timestamps_inserted.insert(insertion_point, time_)
+            current_timestamp = time_
+            self._row_count += 1
+        elif history:
+            if current_date != self._first_date:
+                associated_timestamp = time_ - timedelta(
+                    hours=time_.hour,
+                    minutes=time_.minute,
+                    seconds=time_.second)
+                date_row = DateRow(
+                    self._account, current_date, associated_timestamp)
+                self.insert(date_row, 1)
+                self._timestamps_inserted.insert(1, associated_timestamp)
+                self._row_count += 1
+            self._first_date = current_date
+            if kind in ('incoming', 'incoming_queue', 'outgoing'):
+                self.first_message_timestamp = time_
+            if (kind in ('incoming', 'incoming_queue') and
+                    time_ > self._last_incoming_timestamp):
+                self._last_incoming_timestamp = time_
+            self.insert(message, 2)
+            self._timestamps_inserted.insert(2, time_)
+            if self._last_date is None:
+                self._last_date = current_date
+            current_timestamp = time_
+            self._row_count += 1
+        else:
+            if current_date != self._last_date:
+                associated_timestamp = time_ - timedelta(
+                    hours=time_.hour,
+                    minutes=time_.minute,
+                    seconds=time_.second)
+                date_row = DateRow(
+                    self._account, current_date, associated_timestamp)
+                self.add(date_row)
+                self._timestamps_inserted.append(associated_timestamp)
+                self._row_count += 1
+            if self._first_date is None:
+                self._first_date = current_date
+            if (kind in ('incoming', 'incoming_queue', 'outgoing') and not
+                    self.first_message_timestamp):
+                self.first_message_timestamp = time_
+            if (kind in ('incoming', 'incoming_queue') and
+                    time_ > self._last_incoming_timestamp):
+                self._last_incoming_timestamp = time_
+            self._last_date = current_date
+            self.add(message)
+            self._timestamps_inserted.append(time_)
+            current_timestamp = time_
+            self._row_count += 1
+
+        if message.type == 'chat':
+            self._merge_message(current_timestamp)
+            self._update_read_marker(current_timestamp)
+
+        self.show_all()
+
+    def _is_out_of_order(self, time_: datetime, history: bool) -> bool:
+        if history:
+            if self.first_message_timestamp:
+                return time_ > self.first_message_timestamp
+            return False
+        if len(self._timestamps_inserted) > 1:
+            return time_ < self._timestamps_inserted[-1]
+        return False
+
+    def _merge_message(self, timestamp):
+        # 'Merge' message rows if they both meet certain conditions
+        # (see _is_mergeable). A merged message row does not display any
+        # avatar or meta info, and makes it look merged with the previous row.
+        if self._contact.is_groupchat:
+            return
+
+        current_index = self._timestamps_inserted.index(timestamp)
+        previous_row = self.get_row_at_index(current_index - 1)
+        current_row = self.get_row_at_index(current_index)
+        next_row = self.get_row_at_index(current_index + 1)
+        if next_row is not None:
+            if self._is_mergeable(current_row, next_row):
+                next_row.set_merged(True)
+
+        if self._is_mergeable(current_row, previous_row):
+            current_row.set_merged(True)
+            if next_row is not None:
+                if self._is_mergeable(current_row, next_row):
+                    next_row.set_merged(True)
+
+    @staticmethod
+    def _is_mergeable(row1, row2):
+        # TODO: Check for same encryption
+        timestamp1 = row1.timestamp.strftime('%H:%M')
+        timestamp2 = row2.timestamp.strftime('%H:%M')
+        kind1 = row1.kind
+        kind2 = row2.kind
+        if timestamp1 == timestamp2 and kind1 == kind2:
+            return True
+        return False
+
+    def _reduce_message_count(self):
+        while self._row_count > self._max_row_count:
+            # We want to keep relevant DateRows when removing rows
+            row1 = self.get_row_at_index(1)
+            row2 = self.get_row_at_index(2)
+
+            if row1.type == row2.type == 'date':
+                # First two rows are date rows,
+                # it’s safe to remove the fist row
+                self.remove(row1)
+                self._timestamps_inserted.remove(row1.timestamp)
+                self._first_date = row2.timestamp.strftime('%a, %d %b %Y')
+                self._row_count -= 1
+                continue
+
+            if row1.type == 'date' and row2.type != 'date':
+                # First one is a date row, keep it and
+                # remove the second row instead
+                self.remove(row2)
+                self._timestamps_inserted.remove(row2.timestamp)
+                if row2.message_id:
+                    self._message_ids_inserted.pop(row2.message_id)
+                chat_row = self._get_first_chat_row()
+                if chat_row is not None:
+                    self.first_message_timestamp = chat_row.timestamp
+                else:
+                    self.first_message_timestamp = None
+                self._row_count -= 1
+                continue
+
+            if row1.type != 'date':
+                # Not a date row, safe to remove
+                self.remove(row1)
+                self._timestamps_inserted.remove(row1.timestamp)
+                if row1.message_id:
+                    self._message_ids_inserted.pop(row1.message_id)
+                if row2.type == 'chat':
+                    self.first_message_timestamp = row2.timestamp
+                else:
+                    chat_row = self._get_first_chat_row()
+                    if chat_row is not None:
+                        self.first_message_timestamp = chat_row.timestamp
+                    else:
+                        self.first_message_timestamp = None
+                self._row_count -= 1
+
+    def _get_row_by_id(self, id_):
+        for row in self.get_children():
+            if row.message_id == id_:
+                return row
+        return None
+
+    def _get_first_chat_row(self):
+        for row in self.get_children():
+            if row.type == 'chat':
+                return row
+        return None
+
+    def set_read_marker(self, id_):
+        message_row = self._get_row_by_id(id_)
+        if message_row is None:
+            return
+
+        message_row.set_displayed()
+        self._update_read_marker(message_row.timestamp)
+
+    def _update_read_marker(self, current_timestamp):
+        marker_shown = False
+
+        for row in self.get_children():
+            if row.type == 'read_marker':
+                # We already have a ReadMarkerRow, decide if we keep it
+                marker_shown = True
+
+                if self._last_incoming_timestamp > row.timestamp:
+                    # Last incoming message is newer than read marker
+                    self.remove(row)
+                    self._timestamps_inserted.remove(row.timestamp)
+                    marker_shown = False
+                    break
+
+                if self._last_incoming_timestamp > current_timestamp:
+                    # Last incoming message is newer than current message
+                    break
+
+                if current_timestamp > row.timestamp:
+                    # Message is newer than current ReadMarkerRow
+                    current_row = self.get_row_at_index(
+                        self._timestamps_inserted.index(current_timestamp))
+                    if current_row.has_displayed:
+                        # Current row has a displayed marker, which means
+                        # that the current ReadMarkerRow is out of date
+                        self.remove(row)
+                        self._timestamps_inserted.remove(row.timestamp)
+                        marker_shown = False
+                        break
+                break
+
+        if self._last_incoming_timestamp >= current_timestamp:
+            # Don’t add ReadMarkerRow if last incoming message is newer
+            return
+
+        if marker_shown:
+            # There is a ReadMarkerRow which has not been removed by previous
+            # rules, thus it’s the most current one (nothing more to do)
+            return
+
+        current_row = self.get_row_at_index(
+            self._timestamps_inserted.index(current_timestamp))
+        if current_row.type != 'chat':
+            return
+
+        if current_row.has_displayed:
+            # Add a new ReadMarkerRow, if there is a marker for the current row
+            self._insert_read_marker(current_timestamp)
+
+    def _insert_read_marker(self, timestamp):
+        insertion_point = bisect_right(
+            self._timestamps_inserted, timestamp)
+        read_marker_row = ReadMarkerRow(
+            self._account, self._contact, timestamp)
+        self.insert(read_marker_row, insertion_point)
+        self._timestamps_inserted.insert(insertion_point, timestamp)
+
+    def update_avatars(self):
+        for row in self.get_children():
+            if row.type == 'chat':
+                avatar = self._get_avatar(row.kind, row.name)
+                row.update_avatar(avatar)
+
+    def update_text_tags(self):
+        for row in self.get_children():
+            row.update_text_tags()
+
+    def scroll_to_end(self, force=False):
+        if self.autoscroll or force:
+            GLib.idle_add(scroll_to_end, self.get_parent().get_parent())
+
+    def correct_message(self, correct_id, message_id, text,
+                        other_text_tags, kind, name, additional_data=None):
+        message_row = self._get_row_by_id(correct_id)
+        if message_row is not None:
+            message_row.set_correction(
+                message_id, text, other_text_tags, kind, name,
+                additional_data=additional_data)
+            message_row.set_merged(False)
+
+    def show_receipt(self, id_):
+        message_row = self._get_row_by_id(id_)
+        if message_row is not None:
+            message_row.set_receipt()
+
+    def show_error(self, id_, error):
+        message_row = self._get_row_by_id(id_)
+        if message_row is not None:
+            message_row.set_error(to_user_string(error))
+            message_row.set_merged(False)
+
+    def on_quote(self, text):
+        self.emit('quote', text)
+
+    # TODO: focus_out_line for group chats
+
+
+class ConversationRow(Gtk.ListBoxRow):
+    def __init__(self, account, widget='label', history_mode=False):
+        Gtk.ListBoxRow.__init__(self)
+        self.type = ''
+        self.timestamp = None
+        self.kind = None
+        self.name = None
+        self.message_id = None
+        self.text = ''
+
+        self.get_style_context().add_class('conversation-row')
+
+        self.grid = Gtk.Grid(row_spacing=3, column_spacing=12)
+        self.add(self.grid)
+
+        if widget == 'textview':
+            self.label = None
+            self.textview = ConversationTextview(
+                account, history_mode=history_mode)
+        else:
+            self.textview = None
+            self.label = Gtk.Label()
+            self.label.set_selectable(True)
+            self.label.set_line_wrap(True)
+            self.label.set_xalign(0)
+            self.label.set_line_wrap_mode(
+                Pango.WrapMode.WORD_CHAR)
+
+    def update_text_tags(self):
+        if self.textview is not None:
+            self.textview.update_text_tags()
+
+    @staticmethod
+    def create_timestamp_widget(timestamp: datetime) -> Gtk.Label:
+        # TODO: maybe change default to '%H:%M'
+        time_format = from_one_line(app.settings.get('time_stamp'))
+        timestamp_formatted = timestamp.strftime(time_format)
+        label = Gtk.Label(label=timestamp_formatted)
+        label.set_halign(Gtk.Align.START)
+        label.set_valign(Gtk.Align.END)
+        label.get_style_context().add_class('conversation-meta')
+        label.set_tooltip_text(timestamp.strftime('%a, %d %b %Y - %H:%M:%S'))
+        return label
+
+    @staticmethod
+    def create_name_widget(name: str, kind: str,
+                           is_groupchat: bool) -> Gtk.Label:
+        label = Gtk.Label()
+        label.set_selectable(True)
+        label.get_style_context().add_class('conversation-nickname')
+
+        # TODO: Maybe set default for 'after_nickname' to empty string
+        before_name = from_one_line(app.settings.get('before_nickname'))
+        after_name = from_one_line(app.settings.get('after_nickname'))
+        new_name = f'{before_name}{GLib.markup_escape_text(name)}{after_name}'
+
+        if is_groupchat:
+            rgba = Gdk.RGBA(*text_to_color(name))
+            nick_color = convert_rgba_to_hex(rgba)
+            label.set_markup(
+                f'<span foreground="{nick_color}">{new_name}</span>')
+        else:
+            if kind in ('incoming', 'incoming_queue'):
+                label.get_style_context().add_class(
+                    'gajim-incoming-nickname')
+            elif kind == 'outgoing':
+                label.get_style_context().add_class(
+                    'gajim-outgoing-nickname')
+            label.set_markup(new_name)
+        return label
+
+
+class ScrollHintRow(ConversationRow):
+    def __init__(self, account):
+        ConversationRow.__init__(self, account)
+        self.type = 'system'
+        self.timestamp = datetime.fromtimestamp(0)
+        self.get_style_context().add_class('conversation-system-row')
+
+        self._button = Gtk.Button.new_from_icon_name(
+            'go-up-symbolic', Gtk.IconSize.BUTTON)
+        self._button.set_tooltip_text(_('Load more messages'))
+        self._button.connect('clicked', self._on_load_history)
+        self.grid.attach(self._button, 0, 0, 1, 1)
+
+        self.label.set_text(_('Scroll up to load more chat history…'))
+        self.label.set_halign(Gtk.Align.CENTER)
+        self.label.set_hexpand(True)
+        self.label.get_style_context().add_class(
+            'conversation-meta')
+        self.grid.attach(self.label, 0, 1, 1, 1)
+
+    def _on_load_history(self, _button):
+        self.get_parent().emit('load-history', 30)
+
+
+class ReadMarkerRow(ConversationRow):
+    def __init__(self, account, contact, timestamp):
+        ConversationRow.__init__(self, account)
+        self.type = 'read_marker'
+        self.timestamp = timestamp
+
+        text = _('%s has read up to this point') % contact.name
+        self.label.set_text(text)
+        self.label.set_halign(Gtk.Align.CENTER)
+        self.label.set_hexpand(True)
+        self.label.get_style_context().add_class(
+            'conversation-read-marker')
+        self.grid.attach(self.label, 0, 0, 1, 1)
+        self.show_all()
+
+
+class DateRow(ConversationRow):
+    def __init__(self, account, date_string, timestamp):
+        ConversationRow.__init__(self, account)
+        self.type = 'date'
+        self.timestamp = timestamp
+        self.get_style_context().add_class('conversation-date-row')
+
+        self.label.set_text(date_string)
+        self.label.set_halign(Gtk.Align.CENTER)
+        self.label.set_hexpand(True)
+        self.label.get_style_context().add_class(
+            'conversation-meta')
+        self.grid.attach(self.label, 0, 0, 1, 1)
+
+
+class InfoMessageRow(ConversationRow):
+    def __init__(self,
+                 account,
+                 timestamp,
+                 text,
+                 other_text_tags,
+                 kind,
+                 subject,
+                 graphics,
+                 history_mode=False):
+        ConversationRow.__init__(self, account, widget='textview',
+                                 history_mode=history_mode)
+        self.type = 'info'
+        self.timestamp = timestamp
+        self.kind = kind
+
+        if subject:
+            subject_title = _('Subject:')
+            text = (f'{subject_title}\n'
+                    f'{GLib.markup_escape_text(subject)}\n'
+                    f'{GLib.markup_escape_text(text)}')
+        else:
+            text = GLib.markup_escape_text(text)
+
+        other_text_tags.append('status')
+
+        avatar_placeholder = Gtk.Box()
+        avatar_placeholder.set_size_request(AvatarSize.ROSTER, -1)
+        self.grid.attach(avatar_placeholder, 0, 0, 1, 2)
+        timestamp_widget = self.create_timestamp_widget(timestamp)
+        timestamp_widget.set_valign(Gtk.Align.START)
+        self.grid.attach(timestamp_widget, 2, 0, 1, 1)
+
+        self.textview.set_justification(Gtk.Justification.CENTER)
+        self.textview.print_text(
+            text,
+            other_text_tags=other_text_tags,
+            kind=kind,
+            graphics=graphics)
+
+        self.grid.attach(self.textview, 1, 0, 1, 1)
+
+
+class TextMessageRow(ConversationRow):
+    def __init__(self,
+                 account,
+                 message_id,
+                 timestamp,
+                 kind,
+                 name,
+                 text,
+                 other_text_tags,
+                 avatar,
+                 is_groupchat,
+                 additional_data=None,
+                 display_marking=None,
+                 marker=None,
+                 error=None,
+                 encryption_enabled=False,
+                 history_mode=False):
+
+        # other_tags_for_name contained 'marked', 'bold' and
+        # 'muc_nickname_color_', which are now derived from
+        # other_text_tags ('marked')
+
+        # other_tags_for_time were always empty?
+
+        ConversationRow.__init__(self, account, widget='textview',
+                                 history_mode=history_mode)
+        self.type = 'chat'
+        self.timestamp = timestamp
+        self.message_id = message_id
+        self.kind = kind
+        self.name = name or ''
+        self.text = text
+
+        self._corrections = []
+        self._has_receipt = marker == 'received'
+        self._has_displayed = marker == 'displayed'
+        self._button_icon = None
+
+        if is_groupchat:
+            if other_text_tags and 'marked' in other_text_tags:
+                self.get_style_context().add_class(
+                    'conversation-mention-highlight')
+
+        self.textview.connect('quote', self._on_quote_selection)
+        self.textview.print_text(
+            text,
+            other_text_tags=other_text_tags,
+            kind=kind,
+            name=name,
+            additional_data=additional_data)
+
+        self._meta_box = Gtk.Box(spacing=6)
+        self._meta_box.pack_start(
+            self.create_name_widget(name, kind, is_groupchat), False, True, 0)
+        timestamp_label = self.create_timestamp_widget(timestamp)
+        timestamp_label.set_margin_start(6)
+        self._meta_box.pack_end(timestamp_label, False, True, 0)
+        # TODO: implement app.settings.get('print_time') 'always', 'sometimes'?
+
+        if kind in ('incoming', 'incoming_queue', 'outgoing'):
+            encryption_img = self._get_encryption_image(
+                additional_data, encryption_enabled)
+            if encryption_img:
+                self._meta_box.pack_end(encryption_img, False, True, 0)
+
+        if display_marking:
+            label_text = GLib.markup_escape_text(display_marking.name)
+            if label_text:
+                bgcolor = display_marking.bgcolor
+                fgcolor = display_marking.fgcolor
+                label_text = (
+                    f'<span size="small" bgcolor="{bgcolor}" '
+                    f'fgcolor="{fgcolor}"><tt>[{label_text}]</tt></span>')
+                display_marking_label = Gtk.Label()
+                display_marking_label.set_markup(label_text)
+                self._meta_box.add(display_marking_label)
+
+        self._message_icons = MessageIcons()
+
+        if error is not None:
+            self.set_error(to_user_string(error))
+
+        if marker is not None:
+            if marker in ('received', 'displayed'):
+                self.set_receipt()
+
+        self._meta_box.pack_end(self._message_icons, False, True, 0)
+
+        self._avatar_surface = Gtk.Image.new_from_surface(avatar)
+        avatar_placeholder = Gtk.Box()
+        avatar_placeholder.set_size_request(AvatarSize.ROSTER, -1)
+        avatar_placeholder.set_valign(Gtk.Align.START)
+        avatar_placeholder.add(self._avatar_surface)
+
+        bottom_box = Gtk.Box(spacing=6)
+        bottom_box.add(self.textview)
+        bottom_box.add(self._create_more_button())
+
+        self.grid.attach(avatar_placeholder, 0, 0, 1, 2)
+        self.grid.attach(self._meta_box, 1, 0, 1, 1)
+        self.grid.attach(bottom_box, 1, 1, 1, 1)
+
+    def _create_more_button(self):
+        more_button = Gtk.MenuButton()
+        more_button.connect(
+            'enter-notify-event', self._on_more_button_enter)
+        more_button.connect(
+            'leave-notify-event', self._on_more_button_leave)
+        more_button.set_valign(Gtk.Align.START)
+        more_button.set_halign(Gtk.Align.END)
+        self._button_icon = Gtk.Image.new_from_icon_name(
+            'view-more-symbolic', Gtk.IconSize.BUTTON)
+        self._button_icon.set_no_show_all(True)
+        more_button.add(self._button_icon)
+        more_button.set_relief(Gtk.ReliefStyle.NONE)
+
+        menu_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=6)
+        menu_box.get_style_context().add_class('padding-6')
+
+        quote_button = Gtk.ModelButton()
+        quote_button.set_halign(Gtk.Align.START)
+        quote_button.connect('clicked', self._on_quote_message)
+        quote_button.set_label(_('Quote…'))
+        quote_button.set_image(Gtk.Image.new_from_icon_name(
+            'mail-reply-sender-symbolic', Gtk.IconSize.MENU))
+        menu_box.add(quote_button)
+
+        copy_button = Gtk.ModelButton()
+        copy_button.set_halign(Gtk.Align.START)
+        copy_button.connect('clicked', self._on_copy_message)
+        copy_button.set_label(_('Copy'))
+        copy_button.set_image(Gtk.Image.new_from_icon_name(
+            'edit-copy-symbolic', Gtk.IconSize.MENU))
+        menu_box.add(copy_button)
+
+        menu_box.show_all()
+
+        popover = Gtk.PopoverMenu()
+        popover.add(menu_box)
+        more_button.set_popover(popover)
+        return more_button
+
+    def _on_copy_message(self, _widget):
+        timestamp = self.timestamp.strftime('%x, %X')
+        clip = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
+        clip.set_text(
+            f'{timestamp} - {self.name}: {self.textview.get_text()}', -1)
+
+    def _on_quote_message(self, _widget):
+        self.get_parent().on_quote(self.textview.get_text())
+
+    def _on_more_button_enter(self, _widget, _event):
+        self._button_icon.show()
+
+    def _on_more_button_leave(self, widget, _event):
+        if not widget.get_active():
+            self._button_icon.hide()
+
+    def _get_encryption_image(self, additional_data, encryption_enabled=None):
+        details = self._get_encryption_details(additional_data)
+        if details is None:
+            # Message was not encrypted
+            if not encryption_enabled:
+                return None
+            icon = 'channel-insecure-symbolic'
+            color = 'unencrypted-color'
+            tooltip = _('Not encrypted')
+        else:
+            name, fingerprint, trust = details
+            tooltip = _('Encrypted (%s)') % (name)
+            if trust is None:
+                # The encryption plugin did not pass trust information
+                icon = 'channel-secure-symbolic'
+                color = 'encrypted-color'
+            else:
+                icon, trust_tooltip, color = TRUST_SYMBOL_DATA[trust]
+                tooltip = '%s\n%s' % (tooltip, trust_tooltip)
+            if fingerprint is not None:
+                fingerprint = format_fingerprint(fingerprint)
+                tooltip = '%s\n<tt>%s</tt>' % (tooltip, fingerprint)
+
+        image = Gtk.Image.new_from_icon_name(icon, Gtk.IconSize.MENU)
+        image.set_tooltip_markup(tooltip)
+        image.get_style_context().add_class(color)
+        image.show()
+        return image
+
+    @staticmethod
+    def _get_encryption_details(additional_data):
+        name = additional_data.get_value('encrypted', 'name')
+        if name is None:
+            return None
+
+        fingerprint = additional_data.get_value('encrypted', 'fingerprint')
+        trust = additional_data.get_value('encrypted', 'trust')
+        return name, fingerprint, trust
+
+    def _on_quote_selection(self, _widget, text):
+        self.get_parent().on_quote(text)
+
+    @property
+    def has_receipt(self):
+        return self._has_receipt
+
+    @property
+    def has_displayed(self):
+        return self._has_displayed
+
+    def set_receipt(self):
+        self._has_receipt = True
+        self._message_icons.set_receipt_icon_visible(True)
+
+    def set_displayed(self):
+        self._has_displayed = True
+
+    def set_correction(self, message_id, text, other_text_tags, kind, name,
+                       additional_data=None):
+        self._corrections.append(self.textview.get_text())
+        self.textview.clear()
+        self._has_receipt = False
+        self._message_icons.set_receipt_icon_visible(False)
+        self._message_icons.set_correction_icon_visible(True)
+
+        self.textview.print_text(
+            text,
+            other_text_tags=other_text_tags,
+            kind=kind,
+            name=name,
+            additional_data=additional_data)
+
+        corrections = '\n'.join(line for line in self._corrections)
+        corrections = reduce_chars_newlines(
+            corrections, max_chars=150, max_lines=10)
+        self._message_icons.set_correction_tooltip(
+            _('Message corrected. Original message:\n%s') % corrections)
+        # Update message_id for this row
+        self.message_id = message_id
+
+    def set_error(self, tooltip):
+        self._message_icons.set_error_icon_visible(True)
+        self._message_icons.set_error_tooltip(tooltip)
+
+    def update_avatar(self, avatar):
+        self._avatar_surface.set_from_surface(avatar)
+
+    def set_merged(self, merged):
+        if merged:
+            self._avatar_surface.set_no_show_all(True)
+            self._avatar_surface.hide()
+            self._meta_box.set_no_show_all(True)
+            self._meta_box.hide()
+        else:
+            self._avatar_surface.set_no_show_all(False)
+            self._avatar_surface.show()
+            self._meta_box.set_no_show_all(False)
+            self._meta_box.show()
+
+
+class MessageIcons(Gtk.Box):
+    def __init__(self):
+        Gtk.Box.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
+
+        self._correction_image = Gtk.Image.new_from_icon_name(
+            'document-edit-symbolic', Gtk.IconSize.MENU)
+        self._correction_image.set_no_show_all(True)
+        self._correction_image.get_style_context().add_class('dim-label')
+
+        self._marker_image = Gtk.Image()
+        self._marker_image.set_no_show_all(True)
+        self._marker_image.get_style_context().add_class('dim-label')
+
+        self._error_image = Gtk.Image.new_from_icon_name(
+            'dialog-warning-symbolic', Gtk.IconSize.MENU)
+        self._error_image.get_style_context().add_class('warning-color')
+        self._error_image.set_no_show_all(True)
+
+        self.add(self._correction_image)
+        self.add(self._marker_image)
+        self.add(self._error_image)
+        self.show_all()
+
+    def set_receipt_icon_visible(self, visible):
+        if not app.settings.get('positive_184_ack'):
+            return
+        self._marker_image.set_visible(visible)
+        self._marker_image.set_from_icon_name(
+            'feather-check-symbolic', Gtk.IconSize.MENU)
+        self._marker_image.set_tooltip_text(Q_('?Message state:Received'))
+
+    def set_correction_icon_visible(self, visible):
+        self._correction_image.set_visible(visible)
+
+    def set_correction_tooltip(self, text):
+        self._correction_image.set_tooltip_markup(text)
+
+    def set_error_icon_visible(self, visible):
+        self._error_image.set_visible(visible)
+
+    def set_error_tooltip(self, text):
+        self._error_image.set_tooltip_markup(text)