diff --git a/ROADMAP.md b/ROADMAP.md
index ace643678cea6b70b664f622954ca75c97cdad1d..79936ff4ea5d3e89e450ec6bcd1d1e9c14c5b376 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -36,9 +36,9 @@
       * [ ] Videos
       * [x] Images
       * [x] Locations
-    * [ ] Formatting
-    * [ ] Replies
-    * [ ] Mentions
+    * [x] Formatting
+    * [x] Replies
+    * [x] Mentions
     * [ ] Polls
   * [x] Message unsend
   * [x] Message reactions
diff --git a/mautrix_facebook/formatter/__init__.py b/mautrix_facebook/formatter/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..f890fb73dd0f1021fa7053fcd975086cbac726ce
--- /dev/null
+++ b/mautrix_facebook/formatter/__init__.py
@@ -0,0 +1 @@
+from .from_facebook import facebook_to_matrix
diff --git a/mautrix_facebook/formatter/from_facebook.py b/mautrix_facebook/formatter/from_facebook.py
new file mode 100644
index 0000000000000000000000000000000000000000..a4984e6e4b0bb45a0bbf0027f185146c93379cbb
--- /dev/null
+++ b/mautrix_facebook/formatter/from_facebook.py
@@ -0,0 +1,112 @@
+# mautrix-facebook - A Matrix-Facebook Messenger puppeting bridge
+# Copyright (C) 2019 Tulir Asokan
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU Affero General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program 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 Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program.  If not, see <https://www.gnu.org/licenses/>.
+from typing import Tuple, Match
+from html import escape
+import re
+
+from fbchat.models import Message
+
+from mautrix.types import TextMessageEventContent, Format, MessageType
+
+from .. import puppet as pu, user as u
+
+_START = r"^|\s"
+_END = r"$|\s"
+_TEXT_NO_SURROUNDING_SPACE = r"(?:[^\s].*?[^\s])|[^\s]"
+COMMON_REGEX = re.compile(rf"({_START})([_~*])({_TEXT_NO_SURROUNDING_SPACE})\2({_END})")
+INLINE_CODE_REGEX = re.compile(rf"({_START})(`)(.+?)`({_END})")
+CODE_BLOCK_REGEX = re.compile(r"(```.+```)")
+MENTION_REGEX = re.compile(r"@([0-9]{15})\u2063(.+)\u2063")
+
+tags = {
+    "_": "em",
+    "*": "strong",
+    "~": "del",
+    "`": "code"
+}
+
+
+def _code_block_replacer(code: str) -> str:
+    if "\n" in code:
+        lang, code = code.split("\n", 1)
+        lang = lang.strip()
+        if lang:
+            return f"<pre><code class=\"language-{lang}\">{code}</code></pre>"
+    return f"<pre><code>{code}</code></pre>"
+
+
+def _mention_replacer(match: Match) -> str:
+    fbid = match.group(1)
+
+    user = u.User.get_by_fbid(fbid)
+    if user:
+        return f"<a href=\"https://matrix.to/#/{user.mxid}\">{match.group(2)}</a>"
+
+    puppet = pu.Puppet.get_by_fbid(fbid, create=False)
+    if puppet:
+        return f"<a href=\"https://matrix.to/#/{puppet.mxid}\">{match.group(2)}</a>"
+
+
+def _handle_match(html: str, match: Match, nested: bool) -> Tuple[str, int]:
+    start, end = match.start(), match.end()
+    prefix, sigil, text, suffix = match.groups()
+    if nested:
+        text = _convert_formatting(text)
+    tag = tags[sigil]
+    # We don't want to include the whitespace suffix length, as that could be used as the
+    # whitespace prefix right after this formatting block.
+    pos = start + len(prefix) + (2 * len(tag) + 5) + len(text)
+    html = (f"{html[:start]}{prefix}"
+            f"<{tag}>{text}</{tag}>"
+            f"{suffix}{html[end:]}")
+    return html, pos
+
+
+def _convert_formatting(html: str) -> str:
+    pos = 0
+    while pos < len(html):
+        i_match = INLINE_CODE_REGEX.search(html, pos)
+        c_match = COMMON_REGEX.search(html, pos)
+        if i_match and c_match:
+            match = min(i_match, c_match, key=lambda match: match.start())
+        else:
+            match = i_match or c_match
+
+        if match:
+            html, pos = _handle_match(html, match, nested=match != i_match)
+        else:
+            break
+    return html
+
+
+def facebook_to_matrix(message: Message) -> TextMessageEventContent:
+    content = TextMessageEventContent(msgtype=MessageType.TEXT, body=message.text)
+    text = message.text
+    for m in reversed(message.mentions):
+        original = text[m.offset:m.offset + m.length]
+        if len(original) > 0 and original[0] == "@":
+            original = original[1:]
+        text = f"{text[:m.offset]}@{m.thread_id}\u2063{original}\u2063{text[m.offset + m.length:]}"
+    html = escape(text)
+    html = "".join(_code_block_replacer(part[3:-3]) if part[:3] == "```" == part[-3:]
+                   else _convert_formatting(part)
+                   for part in CODE_BLOCK_REGEX.split(html))
+
+    html = MENTION_REGEX.sub(_mention_replacer, html)
+    if html != escape(content.body):
+        content.format = Format.HTML
+        content.formatted_body = html
+    return content
diff --git a/mautrix_facebook/portal.py b/mautrix_facebook/portal.py
index cf798425f0b0d74fea29899a96a74656fa7ce2cc..dcdd414852ebb86cadb4c66e53431fdda81d6f16 100644
--- a/mautrix_facebook/portal.py
+++ b/mautrix_facebook/portal.py
@@ -28,11 +28,13 @@ from fbchat.models import (ThreadType, Thread, User as FBUser, Group as FBGroup,
                            ShareAttachment, TypingStatus, MessageReaction)
 from mautrix.types import (RoomID, EventType, ContentURI, MessageEventContent, EventID,
                            ImageInfo, MessageType, LocationMessageEventContent, LocationInfo,
-                           ThumbnailInfo, FileInfo, AudioInfo, VideoInfo, Format,
-                           TextMessageEventContent, MediaMessageEventContent, Membership)
+                           ThumbnailInfo, FileInfo, AudioInfo, VideoInfo, Format, RelatesTo,
+                           TextMessageEventContent, MediaMessageEventContent, Membership,
+                           RelationType)
 from mautrix.appservice import AppService, IntentAPI
 from mautrix.errors import MForbidden, IntentError, MatrixError
 
+from .formatter import facebook_to_matrix
 from .config import Config
 from .db import Portal as DBPortal, Message as DBMessage, Reaction as DBReaction
 from . import puppet as p, user as u
@@ -473,10 +475,11 @@ class Portal:
         intent = sender.intent_for(self)
         event_ids = []
         if message.sticker:
-            event_ids = [await self._handle_facebook_sticker(intent, message.sticker)]
+            event_ids = [await self._handle_facebook_sticker(intent, message.sticker,
+                                                             message.reply_to_id)]
         elif len(message.attachments) > 0:
             event_ids = await asyncio.gather(
-                *[self._handle_facebook_attachment(intent, attachment)
+                *[self._handle_facebook_attachment(intent, attachment, message.reply_to_id)
                   for attachment in message.attachments])
             event_ids = [event_id for event_id in event_ids if event_id]
         if not event_ids and message.text:
@@ -487,10 +490,28 @@ class Portal:
                               event_ids=[event_id for event_id in event_ids if event_id])
         await source.markAsDelivered(self.fbid, message.uid)
 
+    async def _add_facebook_reply(self, content: TextMessageEventContent, reply: str) -> None:
+        if reply:
+            message = DBMessage.get_by_fbid(reply, self.fb_receiver)
+            if message:
+                evt = await self.main_intent.get_event(message.mx_room, message.mxid)
+                if evt:
+                    content.set_reply(evt)
+
+    def _get_facebook_reply(self, reply: str) -> Optional[RelatesTo]:
+        if reply:
+            message = DBMessage.get_by_fbid(reply, self.fb_receiver)
+            if message:
+                return RelatesTo(rel_type=RelationType.REFERENCE, event_id=message.mxid)
+        return None
+
     async def _handle_facebook_text(self, intent: IntentAPI, message: FBMessage) -> EventID:
-        return await intent.send_text(self.mxid, message.text)
+        content = facebook_to_matrix(message)
+        await self._add_facebook_reply(content, message.reply_to_id)
+        return await intent.send_message(self.mxid, content)
 
-    async def _handle_facebook_sticker(self, intent: IntentAPI, sticker: FBSticker) -> EventID:
+    async def _handle_facebook_sticker(self, intent: IntentAPI, sticker: FBSticker,
+                                       reply_to: str) -> EventID:
         # TODO handle animated stickers?
         mxc, mime, size = await self._reupload_fb_photo(sticker.url, intent)
         return await intent.send_sticker(room_id=self.mxid, url=mxc,
@@ -498,17 +519,19 @@ class Portal:
                                                         height=sticker.height,
                                                         mimetype=mime,
                                                         size=size),
-                                         text=sticker.label)
+                                         text=sticker.label,
+                                         relates_to=self._get_facebook_reply(reply_to))
 
-    async def _handle_facebook_attachment(self, intent: IntentAPI, attachment: AttachmentClass
-                                          ) -> Optional[EventID]:
+    async def _handle_facebook_attachment(self, intent: IntentAPI, attachment: AttachmentClass,
+                                          reply_to: str) -> Optional[EventID]:
         if isinstance(attachment, AudioAttachment):
             mxc, mime, size = await self._reupload_fb_photo(attachment.url, intent,
                                                             attachment.filename)
             event_id = await intent.send_file(self.mxid, mxc, file_type=MessageType.AUDIO,
                                               info=AudioInfo(size=size, mimetype=mime,
                                                              duration=attachment.duration),
-                                              file_name=attachment.filename, )
+                                              file_name=attachment.filename,
+                                              relates_to=self._get_facebook_reply(reply_to))
         # elif isinstance(attachment, VideoAttachment):
         # TODO
         elif isinstance(attachment, FileAttachment):
@@ -516,16 +539,19 @@ class Portal:
                                                             attachment.name)
             event_id = await intent.send_file(self.mxid, mxc,
                                               info=FileInfo(size=size, mimetype=mime),
-                                              file_name=attachment.name)
+                                              file_name=attachment.name,
+                                              relates_to=self._get_facebook_reply(reply_to))
         elif isinstance(attachment, ImageAttachment):
             mxc, mime, size = await self._reupload_fb_photo(attachment.large_preview_url, intent)
-            event_id = await intent.send_image(self.mxid, mxc,
+            info = ImageInfo(size=size, mimetype=mime,
+                             width=attachment.large_preview_width,
+                             height=attachment.large_preview_height)
+            event_id = await intent.send_image(self.mxid, mxc, info=info,
                                                file_name=f"image.{attachment.original_extension}",
-                                               info=ImageInfo(size=size, mimetype=mime,
-                                                              width=attachment.large_preview_width,
-                                                              height=attachment.large_preview_height))
+                                               relates_to=self._get_facebook_reply(reply_to))
         elif isinstance(attachment, LocationAttachment):
             content = await self._convert_facebook_location(intent, attachment)
+            content.relates_to = self._get_facebook_reply(reply_to)
             event_id = await intent.send_message(self.mxid, content)
         else:
             self.log.warn(f"Unsupported attachment type: {attachment}")
diff --git a/mautrix_facebook/user.py b/mautrix_facebook/user.py
index 3aee460a30adcf83e846a80d79b5c86858bef917..0412fe6198018991625fb489035b9c17b7bb5483 100644
--- a/mautrix_facebook/user.py
+++ b/mautrix_facebook/user.py
@@ -39,6 +39,7 @@ class User(Client):
     loop: asyncio.AbstractEventLoop
     log: logging.Logger = logging.getLogger("mau.user")
     by_mxid: Dict[UserID, 'User'] = {}
+    by_fbid: Dict[str, 'User'] = {}
 
     command_status: Optional[Dict[str, Any]]
     is_whitelisted: bool
@@ -111,6 +112,19 @@ class User(Client):
 
         return None
 
+    @classmethod
+    def get_by_fbid(cls, fbid: str) -> Optional['User']:
+        try:
+            return cls.by_fbid[fbid]
+        except KeyError:
+            pass
+
+        db_user = DBUser.get_by_fbid(fbid)
+        if db_user:
+            return cls.from_db(db_user)
+
+        return None
+
     async def load_session(self) -> bool:
         if self._is_logged_in:
             return True
@@ -139,6 +153,7 @@ class User(Client):
 
     async def post_login(self) -> None:
         self.log.info("Running post-login actions")
+        self.by_fbid[self.fbid] = self
         await self.sync_threads()
         self.log.debug("Updating own puppet info")
         own_info = (await self.fetchUserInfo(self.uid))[self.uid]