diff --git a/gajim/common/helpers.py b/gajim/common/helpers.py
index 5aa930f1d0d59b327c32454d2bd7840245a0b456..53c78a6a39134a3372af9ee4fc85bbfd807dc9f6 100644
--- a/gajim/common/helpers.py
+++ b/gajim/common/helpers.py
@@ -1360,3 +1360,10 @@ def get_muc_context(jid):
     if (disco_info.muc_is_members_only and disco_info.muc_is_nonanonymous):
         return 'private'
     return 'public'
+
+
+def get_start_of_day(date_time):
+    return date_time.replace(hour=0,
+                             minute=0,
+                             second=0,
+                             microsecond=0)
diff --git a/gajim/gtk/conversation/rows/base.py b/gajim/gtk/conversation/rows/base.py
index 96c3b4576f367dc891012526bef3cfd9f1d8915e..55a043acda3fd6b304d5e107d9ea10bd07a1004a 100644
--- a/gajim/gtk/conversation/rows/base.py
+++ b/gajim/gtk/conversation/rows/base.py
@@ -39,6 +39,7 @@ def __init__(self, account, widget='label', history_mode=False):
         self.message_id = None
         self.log_line_id = None
         self.text = ''
+        self._merged = False
 
         self.get_style_context().add_class('conversation-row')
 
@@ -58,6 +59,10 @@ def __init__(self, account, widget='label', history_mode=False):
             self.label.set_line_wrap_mode(
                 Pango.WrapMode.WORD_CHAR)
 
+    @property
+    def is_merged(self):
+        return self._merged
+
     def update_text_tags(self):
         if self.textview is not None:
             self.textview.update_text_tags()
diff --git a/gajim/gtk/conversation/rows/date.py b/gajim/gtk/conversation/rows/date.py
index faaff2ab65dbbd651f66c196495a777f9a5d8af5..a44b9713d0b7ccab22c4f904c709e65e97fc7236 100644
--- a/gajim/gtk/conversation/rows/date.py
+++ b/gajim/gtk/conversation/rows/date.py
@@ -19,7 +19,7 @@
 
 
 class DateRow(BaseRow):
-    def __init__(self, account, date_string, timestamp):
+    def __init__(self, account, timestamp):
         BaseRow.__init__(self, account)
 
         self.set_selectable(False)
@@ -29,8 +29,10 @@ def __init__(self, account, date_string, timestamp):
         self.timestamp = timestamp
         self.get_style_context().add_class('conversation-date-row')
 
-        self.label.set_text(date_string)
+        self.label.set_text(timestamp.strftime('%a, %d %b %Y'))
         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)
+
+        self.show_all()
diff --git a/gajim/gtk/conversation/rows/message.py b/gajim/gtk/conversation/rows/message.py
index 667efee978b254746b9099fee0fdc37583047b26..b23f57e7965ec9d1964ec7dd97b307a18a6f7818 100644
--- a/gajim/gtk/conversation/rows/message.py
+++ b/gajim/gtk/conversation/rows/message.py
@@ -13,6 +13,7 @@
 # along with Gajim. If not, see <http://www.gnu.org/licenses/>.
 
 from datetime import datetime
+from datetime import timedelta
 
 from gi.repository import Gdk
 from gi.repository import GLib
@@ -31,6 +32,9 @@
 from ...util import format_fingerprint
 
 
+MERGE_TIMEFRAME = timedelta(seconds=120)
+
+
 class MessageRow(BaseRow):
     def __init__(self,
                  account,
@@ -135,6 +139,18 @@ def __init__(self,
         self.grid.attach(self._meta_box, 1, 0, 1, 1)
         self.grid.attach(bottom_box, 1, 1, 1, 1)
 
+        self.show_all()
+
+    def is_same_sender(self, message):
+        return message.name == self.name
+
+    def is_mergeable(self, message):
+        if message.type != self.type:
+            return False
+        if not self.is_same_sender(message):
+            return False
+        return abs(message.timestamp - self.timestamp) < MERGE_TIMEFRAME
+
     def on_copy_message(self, _widget):
         timestamp = self.timestamp.strftime('%x, %X')
         clip = Gtk.Clipboard.get(Gdk.SELECTION_CLIPBOARD)
@@ -232,6 +248,7 @@ def update_avatar(self, avatar):
         self._avatar_surface.set_from_surface(avatar)
 
     def set_merged(self, merged):
+        self._merged = merged
         if merged:
             self.get_style_context().add_class('merged')
             self._avatar_surface.set_no_show_all(True)
diff --git a/gajim/gtk/conversation/view.py b/gajim/gtk/conversation/view.py
index 6ad58f154ad2c4a9934374a5bb170aaccbc1eaa3..215f58d71c4c98ed88ffcd7c13d6e59a78829efb 100644
--- a/gajim/gtk/conversation/view.py
+++ b/gajim/gtk/conversation/view.py
@@ -28,6 +28,7 @@
 from gajim.common import app
 from gajim.common.const import AvatarSize
 from gajim.common.helpers import to_user_string
+from gajim.common.helpers import get_start_of_day
 
 from .util import scroll_to_end
 from .conversation.rows.read_marker import ReadMarkerRow
@@ -53,6 +54,7 @@ class ConversationView(Gtk.ListBox):
     def __init__(self, account, contact, history_mode=False):
         Gtk.ListBox.__init__(self)
         self.set_selection_mode(Gtk.SelectionMode.NONE)
+        self.set_sort_func(self._sort_func)
         self._account = account
         self._client = app.get_client(account)
         self._contact = contact
@@ -62,30 +64,15 @@ def __init__(self, account, contact, history_mode=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
 
+        self._active_date_rows = set()
+
         # 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)
 
@@ -93,7 +80,6 @@ def __init__(self, account, contact, history_mode=False):
         self._scroll_hint_row = ScrollHintRow(self._account,
                                               history_mode=self._history_mode)
         self.add(self._scroll_hint_row)
-        self._timestamps_inserted.append(datetime.fromtimestamp(0))
 
     def clear(self):
         self.clearing = True
@@ -112,15 +98,17 @@ def set_history_complete(self, complete):
         self._scroll_hint_row.set_history_complete(complete)
 
     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 _sort_func(self, row1, row2):
+        if row1.timestamp == row2.timestamp:
+            return 0
+        return -1 if row1.timestamp < row2.timestamp else 1
+
     def add_message(self,
                     text,
                     kind,
@@ -201,7 +189,7 @@ def add_message(self,
                 history_mode=self._history_mode,
                 log_line_id=log_line_id)
 
-        self._insert_message(message, kind, history)
+        self._insert_message(message)
 
     def _get_avatar(self, kind, name):
         scale = self.get_scale_factor()
@@ -217,128 +205,77 @@ def _get_avatar(self, kind, name):
 
         return contact.get_avatar(AvatarSize.ROSTER, scale, add_show=False)
 
-    def _insert_message(self, message, kind, history):
-        current_date = message.timestamp.strftime('%a, %d %b %Y')
-
-        if self._is_out_of_order(message.timestamp, history):
-            insertion_point = bisect_left(self._timestamps_inserted, message.timestamp)
-            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 = message.timestamp - timedelta(
-                    hours=message.timestamp.hour,
-                    minutes=message.timestamp.minute,
-                    seconds=message.timestamp.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
-                    message.timestamp > self._last_incoming_timestamp):
-                self._last_incoming_timestamp = message.timestamp
-            self.insert(message, insertion_point)
-            self._timestamps_inserted.insert(insertion_point, message.timestamp)
-            current_timestamp = message.timestamp
-            self._row_count += 1
-        elif history:
-            if current_date != self._first_date:
-                associated_timestamp = message.timestamp - timedelta(
-                    hours=message.timestamp.hour,
-                    minutes=message.timestamp.minute,
-                    seconds=message.timestamp.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 = message.timestamp
-            if (kind in ('incoming', 'incoming_queue') and
-                    message.timestamp > self._last_incoming_timestamp):
-                self._last_incoming_timestamp = message.timestamp
-            self.insert(message, 2)
-            self._timestamps_inserted.insert(2, message.timestamp)
-            if self._last_date is None:
-                self._last_date = current_date
-            current_timestamp = message.timestamp
-            self._row_count += 1
-        else:
-            if current_date != self._last_date:
-                associated_timestamp = message.timestamp - timedelta(
-                    hours=message.timestamp.hour,
-                    minutes=message.timestamp.minute,
-                    seconds=message.timestamp.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 = message.timestamp
-            if (kind in ('incoming', 'incoming_queue') and
-                    message.timestamp > self._last_incoming_timestamp):
-                self._last_incoming_timestamp = message.timestamp
-            self._last_date = current_date
-            self.add(message)
-            self._timestamps_inserted.append(message.timestamp)
-            current_timestamp = message.timestamp
-            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:
+    def _insert_message(self, message):
+        self.add(message)
+        self._add_date_row(message.timestamp)
+        self._check_for_merge(message)
+
+    def _add_date_row(self, timestamp):
+        start_of_day = get_start_of_day(timestamp)
+        if start_of_day in self._active_date_rows:
             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
+        date_row = DateRow(self._account, start_of_day)
+        self._active_date_rows.add(start_of_day)
+        self.add(date_row)
+
+        row = self.get_row_at_index(date_row.get_index() + 1)
+        if row is None:
+            return
+
+        if row.type != 'chat':
+            return
+
+        row.set_merged(False)
+
+    def _check_for_merge(self, message):
+        if message.type != 'chat':
+            return
+
+        ancestor = self._find_ancestor(message)
+        if ancestor is None:
+            self._update_descendants(message)
+        else:
+            if message.is_mergeable(ancestor):
+                message.set_merged(True)
+
+    def _find_ancestor(self, message):
+        index = message.get_index()
+        while index != 0:
+            index -= 1
+            row = self.get_row_at_index(index)
+            if row is None:
+                return None
+
+            if row.type != 'chat':
+                return None
+
+            if not message.is_same_sender(row):
+                return None
+
+            if not row.is_merged:
+                return row
+        return None
+
+    def _update_descendants(self, message):
+        index = message.get_index()
+        while True:
+            index += 1
+            row = self.get_row_at_index(index)
+            if row is None:
+                return
+
+            if row.type != 'chat':
+                return
+
+            if message.is_mergeable(row):
+                row.set_merged(True)
+                continue
+
+            if message.is_same_sender(row):
+                row.set_merged(False)
+                self._update_descendants(row)
+            return
 
     def reduce_message_count(self):
         successful = False
@@ -352,8 +289,6 @@ def reduce_message_count(self):
                 # it’s safe to remove the fist row
                 self.remove(row1)
                 successful = True
-                self._timestamps_inserted.remove(row1.timestamp)
-                self._first_date = row2.timestamp.strftime('%a, %d %b %Y')
                 self._row_count -= 1
                 continue
 
@@ -362,14 +297,6 @@ def reduce_message_count(self):
                 # remove the second row instead
                 self.remove(row2)
                 successful = True
-                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
 
@@ -377,17 +304,6 @@ def reduce_message_count(self):
                 # Not a date row, safe to remove
                 self.remove(row1)
                 successful = True
-                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
 
         return successful
@@ -408,12 +324,6 @@ def iter_rows(self):
         for row in self.get_children():
             yield row
 
-    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_message_id(id_)
         if message_row is None:
@@ -516,5 +426,3 @@ def show_error(self, id_, error):
 
     def on_quote(self, text):
         self.emit('quote', text)
-
-    # TODO: focus_out_line for group chats