diff --git a/gajim/chat_control_base.py b/gajim/chat_control_base.py
index 7bf19ea766bbf24fc66d1855c4f895b4c1063da8..bb45a5b70f9552f658c66873bf6b6ffee73aebd1 100644
--- a/gajim/chat_control_base.py
+++ b/gajim/chat_control_base.py
@@ -54,7 +54,6 @@
 from gajim.gui.dialogs import ConfirmationDialog
 from gajim.gui.dialogs import PastePreviewDialog
 from gajim.gui.message_input import MessageInputTextView
-from gajim.gui.util import at_the_end
 from gajim.gui.util import get_hardware_key_codes
 from gajim.gui.util import get_builder
 from gajim.gui.util import generate_account_badge
diff --git a/gajim/common/styling.py b/gajim/common/styling.py
new file mode 100644
index 0000000000000000000000000000000000000000..7ffc5e782857c1e84b9a2fd6859c8e1ec874cbd6
--- /dev/null
+++ b/gajim/common/styling.py
@@ -0,0 +1,337 @@
+import string
+import re
+from dataclasses import dataclass
+from dataclasses import field
+
+PRE = '`'
+STRONG = '*'
+STRIKE = '~'
+EMPH = '_'
+
+QUOTE = '> '
+PRE_TEXT = '```'
+
+WHITESPACE = set(string.whitespace)
+BLOCK_DIRS = set([QUOTE, PRE_TEXT])
+SPAN_DIRS = set([PRE, STRONG, STRIKE, EMPH])
+VALID_SPAN_START = WHITESPACE | SPAN_DIRS
+
+SD = 0
+SD_POS = 1
+
+PRE_RX = r'(?P<pre>^```.+?(^```$(.|\Z)))'
+PRE_NESTED_RX = r'(?P<pre>^```.+?((^```$(.|\Z))|\Z))'
+QUOTE_RX = r'(?P<quote>^(?=>).*?(^|\Z)(?!>))'
+
+BLOCK_RX = re.compile(PRE_RX + '|' + QUOTE_RX, re.S | re.M)
+BLOCK_NESTED_RX = re.compile(PRE_NESTED_RX + '|' + QUOTE_RX, re.S | re.M)
+UNQUOTE_RX = re.compile(r'^> |^>', re.M)
+
+URI_RX = r'((?P<protocol>[\w-]+://?|www[.])[\S()<>]+?(?=[,]?(\s|\Z)+))'
+URI_RX = re.compile(URI_RX)
+
+ADDRESS_RX = r'(\b(?P<protocol>(xmpp|mailto)+:)?[\w-]*@(.*?\.)+[\w]+([\?].*?(?=([\s\),]|$)))?)'
+ADDRESS_RX = re.compile(ADDRESS_RX)
+
+
+@dataclass
+class StyleObject:
+    start: int
+    end: int
+    text: str
+
+
+@dataclass
+class Uri(StyleObject):
+    name: str = field(default='uri', init=False)
+
+
+@dataclass
+class Address(StyleObject):
+    name: str = field(default='address', init=False)
+
+
+@dataclass
+class XMPPAddress(StyleObject):
+    name: str = field(default='xmppadr', init=False)
+
+
+@dataclass
+class MailAddress(StyleObject):
+    name: str = field(default='mailadr', init=False)
+
+
+@dataclass
+class Block(StyleObject):
+
+    @classmethod
+    def from_match(cls, match):
+        return cls(start=match.start(),
+                   end=match.end(),
+                   text=str(match.group(cls.name)))
+
+
+@dataclass
+class PlainBlock(Block):
+    name: str = field(default='plain', init=False)
+    spans: list = field(default_factory=list)
+    uris: list = field(default_factory=list)
+
+
+@dataclass
+class PreBlock(Block):
+    name: str = field(default='pre', init=False)
+
+
+@dataclass
+class QuoteBlock(Block):
+    name: str = field(default='quote', init=False)
+    blocks: list = field(default_factory=list)
+
+    def unquote(self):
+        return UNQUOTE_RX.sub('', self.text)
+
+
+@dataclass
+class PlainSpan(StyleObject):
+    name: str = field(default='plain', init=False)
+
+
+@dataclass
+class StrongSpan(StyleObject):
+    name: str = field(default='strong', init=False)
+
+
+@dataclass
+class EmphasisSpan(StyleObject):
+    name: str = field(default='emphasis', init=False)
+
+
+@dataclass
+class PreTextSpan(StyleObject):
+    name: str = field(default='pre', init=False)
+
+
+@dataclass
+class StrikeSpan(StyleObject):
+    name: str = field(default='strike', init=False)
+
+
+SPAN_CLS_DICT = {
+    STRONG: StrongSpan,
+    EMPH: EmphasisSpan,
+    PRE: PreTextSpan,
+    STRIKE: StrikeSpan
+}
+
+
+@dataclass
+class ParsingResult:
+    blocks: list
+
+
+def process(text, nested=False):
+    blocks = _parse_blocks(text, nested)
+    for block in blocks:
+        if isinstance(block, PlainBlock):
+            offset = block.start
+            for line in block.text.splitlines(keepends=True):
+                block.spans += _parse_line(line, offset)
+                block.uris += _parse_uris(line, offset)
+                offset += len(line)
+
+        if isinstance(block, QuoteBlock):
+            result = process(block.unquote(), nested=True)
+            block.blocks = result.blocks
+
+    return ParsingResult(blocks)
+
+
+def _parse_blocks(text, nested):
+    blocks = []
+    text_len = len(text)
+    last_end_pos = 0
+
+    rx = BLOCK_NESTED_RX if nested else BLOCK_RX
+
+    for match in rx.finditer(text):
+        if match.start() != last_end_pos:
+            blocks.append(PlainBlock(start=last_end_pos,
+                                     end=match.start(),
+                                     text=text[last_end_pos:match.start()]))
+
+        last_end_pos = match.end()
+        group_dict = match.groupdict()
+
+        if group_dict.get('quote') is not None:
+            blocks.append(QuoteBlock.from_match(match))
+
+        if group_dict.get('pre') is not None:
+            blocks.append(PreBlock.from_match(match))
+
+    if last_end_pos != text_len:
+        blocks.append(PlainBlock(start=last_end_pos,
+                                 end=text_len,
+                                 text=text[last_end_pos:]))
+    return blocks
+
+
+def _parse_line(line, offset):
+    index = 0
+    length = len(line)
+    stack = []
+    spans = []
+
+    while index < length:
+        sd = line[index]
+        if sd not in SPAN_DIRS:
+            index += 1
+            continue
+
+        is_valid_start = _is_valid_span_start(line, index)
+        is_valid_end = _is_valid_span_end(line, index)
+
+        if is_valid_start and is_valid_end:
+            # Favor end over new start, this means parsing is done non-greedy
+            if sd in [open_sd for open_sd, _ in stack]:
+                is_valid_start = False
+
+        if is_valid_start:
+            if sd == PRE:
+                index = _handle_pre_span(line, index, offset, spans)
+                continue
+
+            stack.append((sd, index))
+            index += 1
+            continue
+
+        if is_valid_end:
+            if sd not in [open_sd for open_sd, _ in stack]:
+                index += 1
+                continue
+
+            if _is_span_empty(sd, index, stack):
+                stack.pop()
+                index += 1
+                continue
+
+            start_pos = _find_span_start_position(sd, stack)
+            spans.append(_make_span(line, sd, start_pos, index, offset))
+
+        index += 1
+
+    return spans
+
+
+def _parse_uris(line, offset):
+    uris = []
+    for match in URI_RX.finditer(line):
+        uri = _make_uri(line, match.start(), match.end(), offset)
+        uris.append(uri)
+
+    for match in ADDRESS_RX.finditer(line):
+        uri = _make_address(line, match.start(), match.end(), offset)
+        uris.append(uri)
+
+    return uris
+
+
+def _handle_pre_span(line, index, offset, spans):
+    # Scan ahead for the end
+    end = line.find(PRE, index + 1)
+    if end == -1:
+        return index + 1
+
+    if end - index == 1:
+        # empty span
+        return index + 1
+
+    spans.append(_make_span(line, PRE, index, end, offset))
+    return end + 1
+
+
+def _make_span(line, sd, start, end, offset):
+    text = line[start:end + 1]
+    start += offset
+    end += offset + 1
+    span_class = SPAN_CLS_DICT.get(sd)
+    return span_class(start=start, end=end, text=text)
+
+
+def _make_uri(line, start, end, offset):
+    text = line[start:end]
+    start += offset
+    end += offset
+    return Uri(start=start, end=end, text=text)
+
+
+def _make_address(line, start, end, offset):
+    text = line[start:end]
+    start += offset
+    end += offset
+
+    if text.startswith('xmpp'):
+        return XMPPAddress(start=start, end=end, text=text)
+
+    if text.startswith('mailto'):
+        return MailAddress(start=start, end=end, text=text)
+
+    return Address(start=start, end=end, text=text)
+
+
+def _is_span_empty(sd, index, stack):
+    start_sd = stack[-1][SD]
+    if start_sd != sd:
+        return False
+
+    pos = stack[-1][SD_POS]
+    return pos == index - 1
+
+
+def _find_span_start_position(sd, stack):
+    while stack:
+        start_sd, pos = stack.pop()
+        if start_sd == sd:
+            return pos
+
+    raise ValueError('Unable to find opening span')
+
+
+def _is_valid_span_start(line, index):
+    '''
+    https://xmpp.org/extensions/xep-0393.html#span
+
+    ... The opening styling directive MUST be located at the beginning
+    of the line, after a whitespace character, or after a different opening
+    styling directive. The opening styling directive MUST NOT be followed
+    by a whitespace character ...
+    '''
+
+    try:
+        char = line[index + 1]
+    except IndexError:
+        return False
+
+    if char in WHITESPACE:
+        return False
+
+    if index == 0:
+        return True
+
+    char = line[index - 1]
+    return char in VALID_SPAN_START
+
+
+def _is_valid_span_end(line, index):
+    '''
+    https://xmpp.org/extensions/xep-0393.html#span
+
+    ... and the closing styling directive MUST NOT be preceeded
+    by a whitespace character ...
+    '''
+
+    if index == 0:
+        return False
+
+    char = line[index - 1]
+    return char not in WHITESPACE
diff --git a/gajim/gtk/conversation/code_widget.py b/gajim/gtk/conversation/code_widget.py
new file mode 100644
index 0000000000000000000000000000000000000000..8601bce027c1a4b96ca93a2d8bc2cb2526b8711b
--- /dev/null
+++ b/gajim/gtk/conversation/code_widget.py
@@ -0,0 +1,59 @@
+# 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 gi
+gi.require_version('GtkSource', '4')
+from gi.repository import GObject
+from gi.repository import Gtk
+from gi.repository import GtkSource
+
+
+class CodeWidget(Gtk.Box):
+    def __init__(self, account):
+        Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
+        self.set_vexpand(True)
+
+        self._account = account
+
+        self._textview = CodeTextview()
+        # self._scrolled = Gtk.ScrolledWindow()
+        # self._scrolled.set_policy(Gtk.PolicyType.AUTOMATIC,
+        #                           Gtk.PolicyType.AUTOMATIC)
+        # self._scrolled.set_hexpand(True)
+        # self._scrolled.set_vexpand(True)
+        # self._scrolled.set_propagate_natural_height(True)
+        # self._scrolled.set_max_content_height(400)
+        # self._scrolled.add(self._textview)
+
+        self.add(self._textview)
+
+    def add_content(self, block):
+        self._textview.print_code(block)
+
+
+class CodeTextview(GtkSource.View):
+    def __init__(self):
+        GtkSource.View.__init__(self)
+        self.set_editable(False)
+        self.set_cursor_visible(False)
+        self.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
+
+        self._source_manager = GtkSource.LanguageManager.get_default()
+
+    def print_code(self, block):
+        buffer_ = self.get_buffer()
+        lang = self._source_manager.get_language('python3')
+        buffer_.set_language(lang)
+        self.set_show_line_numbers(True)
+        buffer_.insert(buffer_.get_start_iter(), block.text)
diff --git a/gajim/gtk/conversation/message_widget.py b/gajim/gtk/conversation/message_widget.py
new file mode 100644
index 0000000000000000000000000000000000000000..cb4fab76c9c808c5132b6a156719c550709ee95e
--- /dev/null
+++ b/gajim/gtk/conversation/message_widget.py
@@ -0,0 +1,47 @@
+# 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/>.
+
+from gi.repository import Gtk
+
+from .code_widget import CodeWidget
+from .quote_widget import QuoteWidget
+from .plain_widget import PlainWidget
+
+
+class MessageWidget(Gtk.Box):
+    def __init__(self, account):
+        Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
+        self._account = account
+
+    def add_content(self, blocks):
+        for block in blocks:
+            if block.name == 'plain':
+                widget = PlainWidget(self._account)
+                widget.add_content(block)
+                self.add(widget)
+                continue
+
+            if block.name == 'pre':
+                widget = CodeWidget(self._account)
+                widget.add_content(block)
+                self.add(widget)
+                continue
+
+            if block.name == 'quote':
+                message_widget = MessageWidget(self._account)
+                message_widget.add_content(block.blocks)
+                widget = QuoteWidget(self._account)
+                widget.attach_message_widget(message_widget)
+                self.add(widget)
+                continue
diff --git a/gajim/gtk/conversation/plain_widget.py b/gajim/gtk/conversation/plain_widget.py
new file mode 100644
index 0000000000000000000000000000000000000000..3e3f7caf287c0311a97c4b1ea9da9badc2641bd5
--- /dev/null
+++ b/gajim/gtk/conversation/plain_widget.py
@@ -0,0 +1,335 @@
+# 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/>.
+
+from urllib.parse import quote
+
+from gi.repository import GObject
+from gi.repository import Gtk
+from gi.repository import Pango
+from gi.repository import Gdk
+from gi.repository import GLib
+
+from gajim.common import app
+from gajim.common import i18n
+from gajim.common.const import StyleAttr
+from gajim.common.helpers import open_uri
+from gajim.common.helpers import reduce_chars_newlines
+from gajim.common.helpers import parse_uri
+from gajim.common.i18n import _
+
+from .util import get_cursor
+
+
+URI_TAGS = ['uri', 'address', 'xmppadr', 'mailadr']
+STYLE_TAGS = ['strong', 'emphasis', 'strike', 'pre']
+
+
+class PlainWidget(Gtk.Box):
+    def __init__(self, account):
+        Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
+        self.set_vexpand(True)
+
+        self._account = account
+
+        self._textview = MessageTextview(self._account)
+        self.add(self._textview)
+
+    def add_content(self, block):
+        self._textview.print_text_with_styling(block)
+
+
+class MessageTextview(Gtk.TextView):
+
+    __gsignals__ = {
+        'quote': (
+            GObject.SignalFlags.RUN_LAST | GObject.SignalFlags.ACTION,
+            None,
+            (str, )
+        ),
+    }
+
+    def __init__(self, account):
+        Gtk.TextView.__init__(self)
+        self.set_hexpand(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.set_has_tooltip(True)
+        self.set_editable(False)
+        self.set_cursor_visible(False)
+        self.set_wrap_mode(Gtk.WrapMode.WORD_CHAR)
+
+        self.connect('query-tooltip', self._query_tooltip)
+        self.connect('button-press-event', self._on_button_press)
+        self.connect('populate-popup', self._on_populate_popup)
+
+        self._account = account
+
+        # 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')
+
+        # Create Tags
+        self._create_url_tags()
+        self.get_buffer().create_tag('strong', weight=Pango.Weight.BOLD)
+        self.get_buffer().create_tag('emphasis', style=Pango.Style.ITALIC)
+        self.get_buffer().create_tag('strike', strikethrough=True)
+        self.get_buffer().create_tag('pre', family='monospace')
+
+        self.connect('destroy', self._on_destroy)
+
+    def _on_destroy(self, *args):
+        pass
+
+    def _create_url_tags(self):
+        color = app.css_config.get_value('.gajim-url', StyleAttr.COLOR)
+        for name in URI_TAGS:
+            tag = self.get_buffer().create_tag(name,
+                                               foreground=color,
+                                               underline=Pango.Underline.SINGLE)
+            tag.connect('event', self._on_uri_clicked, tag)
+
+    def update_tags(self):
+        tag_table = self.get_buffer().get_tag_table()
+        color = app.css_config.get_value('.gajim-url', StyleAttr.COLOR)
+
+        for tag in URI_TAGS:
+            tag_table.lookup(tag).set_property('foreground', color)
+
+    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_with_styling(self, block):
+        buffer_ = self.get_buffer()
+        buffer_.insert(buffer_.get_start_iter(), block.text)
+
+        for span in block.spans:
+            start_iter = buffer_.get_iter_at_offset(span.start)
+            end_iter = buffer_.get_iter_at_offset(span.end)
+            buffer_.apply_tag_by_name(span.name, start_iter, end_iter)
+
+        for uri in block.uris:
+            start_iter = buffer_.get_iter_at_offset(uri.start)
+            end_iter = buffer_.get_iter_at_offset(uri.end)
+            buffer_.apply_tag_by_name(uri.name, start_iter, end_iter)
+
+    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 URI_TAGS:
+                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 URI_TAGS:
+                    # 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_uri_clicked(self, texttag, _widget, event, iter_, _kind):
+        if event.type != Gdk.EventType.BUTTON_PRESS:
+            return Gdk.EVENT_PROPAGATE
+
+        begin_iter = iter_.copy()
+        # we get the beginning of the tag
+        while not begin_iter.starts_tag(texttag):
+            begin_iter.backward_char()
+        end_iter = iter_.copy()
+        # we get the end of the tag
+        while not end_iter.ends_tag(texttag):
+            end_iter.forward_char()
+
+        # Detect XHTML-IM link
+        word = getattr(texttag, 'href', None)
+        if not word:
+            word = self.get_buffer().get_text(begin_iter, end_iter, True)
+
+        uri = parse_uri(word)
+        if event.button.button == 3: # right click
+            self.show_context_menu(uri)
+            return Gdk.EVENT_STOP
+
+        # self.plugin_modified = False
+        # app.plugin_manager.extension_point(
+        #     'hyperlink_handler', uri, self, self.get_toplevel())
+        # if self.plugin_modified:
+        #     return Gdk.EVENT_STOP
+
+        open_uri(uri, account=self._account)
+        return Gdk.EVENT_STOP
+
+    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/quote_widget.py b/gajim/gtk/conversation/quote_widget.py
new file mode 100644
index 0000000000000000000000000000000000000000..a66d5089e4e98092c16e107dc794fb976d111599
--- /dev/null
+++ b/gajim/gtk/conversation/quote_widget.py
@@ -0,0 +1,33 @@
+# 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/>.
+
+
+from gi.repository import Gtk
+
+
+class QuoteWidget(Gtk.Box):
+    def __init__(self, account):
+        Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
+        self.set_vexpand(True)
+
+        self._account = account
+
+        self._message_widget = None
+
+    def attach_message_widget(self, message_widget):
+        # Purpose of this method is to prevent circular imports
+        if self._message_widget is not None:
+            raise ValueError('QuoteWidget already has a MessageWidget attached')
+        self._message_widget = message_widget
+        self.add(message_widget)
diff --git a/gajim/gtk/conversation/rows/base.py b/gajim/gtk/conversation/rows/base.py
index 55a043acda3fd6b304d5e107d9ea10bd07a1004a..6710b4d8ea55e82d48795855ce6f0e90a0630da4 100644
--- a/gajim/gtk/conversation/rows/base.py
+++ b/gajim/gtk/conversation/rows/base.py
@@ -30,7 +30,7 @@
 
 
 class BaseRow(Gtk.ListBoxRow):
-    def __init__(self, account, widget='label', history_mode=False):
+    def __init__(self, account, widget=None, history_mode=False):
         Gtk.ListBoxRow.__init__(self)
         self.type = ''
         self.timestamp = None
@@ -46,18 +46,12 @@ def __init__(self, account, widget='label', history_mode=False):
         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
+        if widget == 'label':
             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)
+            self.label.set_line_wrap_mode(Pango.WrapMode.WORD_CHAR)
 
     @property
     def is_merged(self):
diff --git a/gajim/gtk/conversation/rows/date.py b/gajim/gtk/conversation/rows/date.py
index a44b9713d0b7ccab22c4f904c709e65e97fc7236..34e1c997ea545e7537c610fc060241e89f9dc725 100644
--- a/gajim/gtk/conversation/rows/date.py
+++ b/gajim/gtk/conversation/rows/date.py
@@ -20,7 +20,7 @@
 
 class DateRow(BaseRow):
     def __init__(self, account, timestamp):
-        BaseRow.__init__(self, account)
+        BaseRow.__init__(self, account, widget='label')
 
         self.set_selectable(False)
         self.set_activatable(False)
diff --git a/gajim/gtk/conversation/rows/info.py b/gajim/gtk/conversation/rows/info.py
index 31e9c6d4bffd4439eaf1279ea52a9dc0f60f4d6b..2c9f83685682522a5fff55d12c1ae3425330f25f 100644
--- a/gajim/gtk/conversation/rows/info.py
+++ b/gajim/gtk/conversation/rows/info.py
@@ -33,8 +33,7 @@ def __init__(self,
                  subject,
                  graphics,
                  history_mode=False):
-        BaseRow.__init__(self, account, widget='textview',
-                         history_mode=history_mode)
+        BaseRow.__init__(self, account, history_mode=history_mode)
         self.type = 'info'
         self.timestamp = datetime.fromtimestamp(timestamp)
         self.db_timestamp = timestamp
diff --git a/gajim/gtk/conversation/rows/message.py b/gajim/gtk/conversation/rows/message.py
index b23f57e7965ec9d1964ec7dd97b307a18a6f7818..7979305a19f9fb0db7f68822c385de81329f990f 100644
--- a/gajim/gtk/conversation/rows/message.py
+++ b/gajim/gtk/conversation/rows/message.py
@@ -24,11 +24,13 @@
 from gajim.common.const import TRUST_SYMBOL_DATA
 from gajim.common.helpers import reduce_chars_newlines
 from gajim.common.helpers import to_user_string
+from gajim.common.styling import process
 from gajim.common.i18n import _
 from gajim.common.i18n import Q_
 
 from .base import BaseRow
 from .base import MoreMenuButton
+from ..message_widget import MessageWidget
 from ...util import format_fingerprint
 
 
@@ -60,8 +62,7 @@ def __init__(self,
 
         # other_tags_for_time were always empty?
 
-        BaseRow.__init__(self, account, widget='textview',
-                         history_mode=history_mode)
+        BaseRow.__init__(self, account, history_mode=history_mode)
         self.type = 'chat'
         self.timestamp = datetime.fromtimestamp(timestamp)
         self.db_timestamp = timestamp
@@ -80,13 +81,9 @@ def __init__(self,
                 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)
+        result = process(text)
+        message_widget = MessageWidget(account)
+        message_widget.add_content(result.blocks)
 
         self._meta_box = Gtk.Box(spacing=6)
         self._meta_box.pack_start(
@@ -132,7 +129,7 @@ def __init__(self,
         avatar_placeholder.add(self._avatar_surface)
 
         bottom_box = Gtk.Box(spacing=6)
-        bottom_box.add(self.textview)
+        bottom_box.add(message_widget)
         bottom_box.add(MoreMenuButton(self, history_mode=history_mode))
 
         self.grid.attach(avatar_placeholder, 0, 0, 1, 2)
diff --git a/gajim/gtk/conversation/rows/read_marker.py b/gajim/gtk/conversation/rows/read_marker.py
index f0fe69a511892aa523d8184edd532fa3a86f16b2..10a84751a4d9de09bda1db4fcaf564c60dff1bc9 100644
--- a/gajim/gtk/conversation/rows/read_marker.py
+++ b/gajim/gtk/conversation/rows/read_marker.py
@@ -23,7 +23,7 @@
 
 class ReadMarkerRow(BaseRow):
     def __init__(self, account, contact):
-        BaseRow.__init__(self, account)
+        BaseRow.__init__(self, account, widget='label')
         self.type = 'read_marker'
         self.timestamp = datetime.fromtimestamp(0)
 
diff --git a/gajim/gtk/conversation/rows/scroll_hint.py b/gajim/gtk/conversation/rows/scroll_hint.py
index e68326d1335b54aaaac6c7459a610f4d4eb6c310..b3d1079144f94407507dbddfe5b6bd5bb37c35e9 100644
--- a/gajim/gtk/conversation/rows/scroll_hint.py
+++ b/gajim/gtk/conversation/rows/scroll_hint.py
@@ -23,7 +23,7 @@
 
 class ScrollHintRow(BaseRow):
     def __init__(self, account, history_mode=False):
-        BaseRow.__init__(self, account)
+        BaseRow.__init__(self, account, widget='label')
         self.set_selectable(False)
         self.set_activatable(False)
 
diff --git a/gajim/gtk/conversation/view.py b/gajim/gtk/conversation/view.py
index c63cc24be50885954c065e6feb0841b5ea62139b..141729b783c9cc87d474ad17d015688e00595e4a 100644
--- a/gajim/gtk/conversation/view.py
+++ b/gajim/gtk/conversation/view.py
@@ -210,6 +210,7 @@ def _insert_message(self, message):
         self.add(message)
         self._add_date_row(message.timestamp)
         self._check_for_merge(message)
+        GLib.idle_add(message.queue_resize)
 
     def _add_date_row(self, timestamp):
         start_of_day = get_start_of_day(timestamp)
diff --git a/test/no_gui/test_styling.py b/test/no_gui/test_styling.py
new file mode 100644
index 0000000000000000000000000000000000000000..de38e4c2a34baaaa0d07f54d3c3ce68333881481
--- /dev/null
+++ b/test/no_gui/test_styling.py
@@ -0,0 +1,352 @@
+import unittest
+
+import gajim.common.styling as styling
+from gajim.common.styling import PlainBlock
+from gajim.common.styling import PreBlock
+from gajim.common.styling import QuoteBlock
+from gajim.common.styling import PreTextSpan
+from gajim.common.styling import StrongSpan
+from gajim.common.styling import EmphasisSpan
+from gajim.common.styling import StrikeSpan
+from gajim.common.styling import Url
+from gajim.common.styling import URL_RX
+from gajim.common.styling import ADDRESS_RX
+
+
+STYLING = {
+    'pre cannot have children':  {
+        'input': '_no pre `with *children*`_',
+        'tokens': [
+            PlainBlock(start=0, end=26, text='_no pre `with *children*`_', spans=[
+                PreTextSpan(start=8, end=25, text='`with *children*`'),
+                EmphasisSpan(start=0, end=26, text='_no pre `with *children*`_')
+            ])
+        ]
+    },
+
+    'nested spans':  {
+        'input': '_*~children~*_',
+        'tokens': [
+            PlainBlock(start=0, end=14, text='_*~children~*_', spans=[
+                StrikeSpan(start=2, end=12, text='~children~'),
+                StrongSpan(start=1, end=13, text='*~children~*'),
+                EmphasisSpan(start=0, end=14, text='_*~children~*_'),
+            ])
+        ]
+    },
+
+    'spans': {
+        'input': '*strong* _emph_~strike~  `pre`',
+        'tokens': [
+            PlainBlock(start=0, end=30, text='*strong* _emph_~strike~  `pre`', spans=[
+                StrongSpan(start=0, end=8, text='*strong*'),
+                EmphasisSpan(start=9, end=15, text='_emph_'),
+                StrikeSpan(start=15, end=23, text='~strike~'),
+                PreTextSpan(start=25, end=30, text='`pre`')
+            ])
+        ]
+    },
+
+    'spans lazily match': {
+        'input': '*strong*plain*',
+        'tokens': [
+            PlainBlock(start=0, end=14, text='*strong*plain*', spans=[
+                StrongSpan(start=0, end=8, text='*strong*')
+            ])
+        ]
+    },
+
+    'start span only': {
+        'input': '*not strong',
+        'tokens': [
+            PlainBlock(start=0, end=11, text='*not strong', spans=[])
+        ]
+    },
+
+    'end span only': {
+        'input': 'not strong*',
+        'tokens': [
+            PlainBlock(start=0, end=11, text='not strong*', spans=[])
+        ]
+    },
+
+    'invalid end span': {
+        'input': '*not *strong',
+        'tokens': [
+            PlainBlock(start=0, end=12, text='*not *strong', spans=[])
+        ]
+    },
+
+    'empty span': {
+        'input': '**',
+        'tokens': [
+            PlainBlock(start=0, end=2, text='**', spans=[])
+        ]
+    },
+
+    '3 unmatched directives': {
+        'input': '***',
+        'tokens': [
+            PlainBlock(start=0, end=3, text='***', spans=[])
+        ]
+    },
+
+    '4 unmatched directives': {
+        'input': '****',
+        'tokens': [
+            PlainBlock(start=0, end=4, text='****', spans=[])
+        ]
+    },
+
+    'invalid diretives ignored': {
+        'input': '* plain *strong*',
+        'tokens': [
+            PlainBlock(start=0, end=16, text='* plain *strong*', spans=[
+                StrongSpan(start=8, end=16, text='*strong*')
+            ])
+        ]
+    },
+
+    'uneven start directives': {
+        'input': '*this is *uneven*',
+        'tokens': [
+            PlainBlock(start=0, end=17, text='*this is *uneven*', spans=[
+                StrongSpan(start=9, end=17, text='*uneven*')
+            ])
+        ]
+    },
+
+    'overlapping directives': {
+        'input': '*this cannot _overlap*_',
+        'tokens': [
+            PlainBlock(start=0, end=23, text='*this cannot _overlap*_', spans=[
+                StrongSpan(start=0, end=22, text='*this cannot _overlap*')
+            ])
+        ]
+    },
+
+    'plain blocks': {
+        'input': 'one\nand two',
+        'tokens': [
+            PlainBlock(start=0, end=11, text='one\nand two', spans=[])
+        ]
+    },
+
+    'pre block with closing': {
+        'input': '```\npre *fmt* ```\n```\nplain',
+        'tokens': [
+            PreBlock(start=0, end=22, text='```\npre *fmt* ```\n```\n', spans=[]),
+            PlainBlock(start=22, end=27, text='plain', spans=[])
+        ]
+    },
+
+    'pre block EOF': {
+        'input': '````\na\n```',
+        'tokens': [
+            PreBlock(start=0, end=10, text='````\na\n```', spans=[])
+        ]
+    },
+
+    'pre block no terminator EOF': {
+        'input': '```\na```',
+        'tokens': [
+            PlainBlock(start=0, end=8, text='```\na```', spans=[])
+        ]
+    },
+
+    'pre block no body EOF': {
+        'input': '```newtoken\n',
+        'tokens': [
+            PlainBlock(start=0, end=12, text='```newtoken\n', spans=[])
+        ]
+    },
+
+    'single level block quote': {
+        'input': '>  quoted\nnot quoted',
+        'tokens': [
+            QuoteBlock(start=0, end=10, text='>  quoted\n', spans=[], blocks=[
+                PlainBlock(start=0, end=8, text=' quoted\n', spans=[])
+            ]),
+            PlainBlock(start=10, end=20, text='not quoted', spans=[])
+        ]
+    },
+
+    'multi level block quote': {
+        'input': '>  quoted\n>>   quote > 2\n>quote 1\n\nnot quoted',
+        'tokens': [
+            QuoteBlock(start=0, end=34, text='>  quoted\n>>   quote > 2\n>quote 1\n', spans=[], blocks=[
+                PlainBlock(start=0, end=8, text=' quoted\n', spans=[]),
+                QuoteBlock(start=8, end=22, text='>   quote > 2\n', spans=[], blocks=[
+                    PlainBlock(start=0, end=12, text='  quote > 2\n', spans=[])
+                ]),
+                PlainBlock(start=22, end=30, text='quote 1\n', spans=[])
+            ]),
+            PlainBlock(start=34, end=45, text='\nnot quoted', spans=[])
+        ]
+    },
+
+    'quote start then EOF': {
+        'input': '> ',
+        'tokens': [
+            QuoteBlock(start=0, end=2, text='> ', spans=[], blocks=[])
+        ]
+    },
+
+    'quote with children': {
+        'input': '> ```\n> pre\n> ```\n> not pre',
+        'tokens': [
+            QuoteBlock(start=0, end=27, text='> ```\n> pre\n> ```\n> not pre', spans=[], blocks=[
+                PreBlock(start=0, end=12, text='```\npre\n```\n', spans=[]),
+                PlainBlock(start=12, end=19, text='not pre', spans=[])
+            ])
+        ]
+    },
+
+    'pre end of parent': {
+        'input': '> ``` \n> pre\nplain',
+        'tokens': [
+            QuoteBlock(start=0, end=13, text='> ``` \n> pre\n', spans=[], blocks=[
+                PreBlock(start=0, end=9, text='``` \npre\n', spans=[])
+            ]),
+            PlainBlock(start=13, end=18, text='plain', spans=[])
+        ]
+    },
+
+    'span lines': {
+        'input': '*not \n strong*',
+        'tokens': [
+            PlainBlock(start=0, end=14, text='*not \n strong*', spans=[])
+        ]
+    },
+
+    'plain with uri': {
+        'input': 'some kind of link http://foo.com/blah_blah',
+        'tokens': [
+            PlainBlock(start=0, end=42, text='some kind of link http://foo.com/blah_blah', spans=[], uris=[
+                Url(start=18, end=42, text='http://foo.com/blah_blah')
+            ])
+        ]
+    },
+
+    'plain with uri don’t consider comma': {
+        'input': 'some kind of link http://foo.com/blah_blah,',
+        'tokens': [
+            PlainBlock(start=0, end=43, text='some kind of link http://foo.com/blah_blah,', spans=[], uris=[
+                Url(start=18, end=42, text='http://foo.com/blah_blah')
+            ])
+        ]
+    },
+
+    'plain with uri and styling': {
+        'input': 'some *kind* of link http://foo.com/blah_blah',
+        'tokens': [
+            PlainBlock(start=0, end=44, text='some *kind* of link http://foo.com/blah_blah', spans=[
+                StrongSpan(start=5, end=11, text='*kind*')
+            ], uris=[
+                Url(start=20, end=44, text='http://foo.com/blah_blah')
+            ])
+        ]
+    },
+
+}
+
+
+URLS = [
+    'http://foo.com/blah_blah',
+    'http://foo.com/blah_blah/',
+    'http://foo.com/blah_blah_(wikipedia)',
+    'http://foo.com/blah_blah_(wikipedia)_(again)',
+    'http://www.example.com/wpstyle/?p=364',
+    'https://www.example.com/foo/?bar=baz&inga=42&quux',
+    'http://✪df.ws/123',
+    'http://userid:password@example.com:8080',
+    'http://userid:password@example.com:8080/',
+    'http://userid@example.com',
+    'http://userid@example.com/',
+    'http://userid@example.com:8080',
+    'http://userid@example.com:8080/',
+    'http://userid:password@example.com',
+    'http://userid:password@example.com/',
+    'http://142.42.1.1/',
+    'http://142.42.1.1:8080/',
+    'http://➡.ws/䨹',
+    'http://⌘.ws',
+    'http://⌘.ws/',
+    'http://foo.com/blah_(wikipedia)#cite-1',
+    'http://foo.com/blah_(wikipedia)_blah#cite-1',
+    'http://foo.com/unicode_(✪)_in_parens',
+    'http://foo.com/(something)?after=parens',
+    'http://☺.damowmow.com/',
+    'http://code.google.com/events/#&product=browser',
+    'http://j.mp',
+    'ftp://foo.bar/baz',
+    'http://foo.bar/?q=Test%20URL-encoded%20stuff',
+    'http://مثال.إختبار',
+    'http://例子.测试',
+    'http://उदाहरण.परीक्षा',
+    'http://-.~_!$&\'()*+,;=:%40:80%2f::::::@example.com',
+    'http://1337.net',
+    'http://a.b-c.de',
+    'http://223.255.255.254',
+    'https://foo_bar.example.com/',
+]
+
+
+EMAILS = [
+    'asd@asd.at',
+    'asd@asd.asd.at',
+]
+
+
+URL_WITH_TEXT = [
+    ('see this http://userid@example.com/ link', 'http://userid@example.com/'),
+    ('see this http://userid@example.com/, and ..', 'http://userid@example.com/'),
+]
+
+XMPP_URIS = [
+    ('see xmpp:romeo@montague.net?message;subject=Test%20Message;body=Here%27s%20a%20test%20message ...', 'xmpp:romeo@montague.net?message;subject=Test%20Message;body=Here%27s%20a%20test%20message'),
+]
+
+
+class Test(unittest.TestCase):
+    def test_styling(self):
+        for _name, params in STYLING.items():
+            result = styling.process(params['input'])
+            self.assertTrue(result.blocks == params['tokens'])
+
+    def test_urls(self):
+        for url in URLS:
+            match = URL_RX.search(url)
+            self.assertIsNotNone(match)
+            start = match.start()
+            end = match.end()
+            self.assertTrue(url[start:end] == url)
+
+    def test_emails(self):
+        for email in EMAILS:
+            match = ADDRESS_RX.search(email)
+            self.assertIsNotNone(match)
+            start = match.start()
+            end = match.end()
+            self.assertTrue(email[start:end] == email)
+
+    def test_url_with_text(self):
+        for text, result in URL_WITH_TEXT:
+            match = URL_RX.search(text)
+            self.assertIsNotNone(match)
+            start = match.start()
+            end = match.end()
+            self.assertTrue(text[start:end] == result)
+
+    def test_xmpp_uris(self):
+        for text, result in XMPP_URIS:
+            match = ADDRESS_RX.search(text)
+            self.assertIsNotNone(match)
+            start = match.start()
+            end = match.end()
+            self.assertTrue(text[start:end] == result)
+
+
+
+if __name__ == "__main__":
+    unittest.main()