diff --git a/ROADMAP.md b/ROADMAP.md
index d2d5b640af71a2227472ebb0d1c117493e80e920..a4bbc415c4ae543871e74dfc49270b97c73db152 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -15,7 +15,7 @@
     * [ ] Mentions
   * [x] Message redactions
   * [x] Presence
-  * [ ] Typing notifications
+  * [x] Typing notifications
   * [x] Read receipts
   * [ ] Power level
   * [ ] Membership actions
diff --git a/mautrix_facebook/custom_puppet.py b/mautrix_facebook/custom_puppet.py
index 7a9c229a06cf2e3c10f3dd3b8dfd8f2b79fca8cd..9d86f93d606615fe68e15639650500ffced7f184 100644
--- a/mautrix_facebook/custom_puppet.py
+++ b/mautrix_facebook/custom_puppet.py
@@ -24,12 +24,12 @@ from aiohttp import ClientConnectionError
 from mautrix.types import (UserID, FilterID, Filter, RoomEventFilter, RoomFilter, EventFilter,
                            EventType, SyncToken, RoomID, Event, PresenceState)
 from mautrix.appservice import AppService, IntentAPI
-from mautrix.errors import IntentError, MatrixRequestError
+from mautrix.errors import IntentError, MatrixError, MatrixRequestError
 
 from . import matrix as m
 
 
-class CustomPuppetError(Exception):
+class CustomPuppetError(MatrixError):
     """Base class for double puppeting setup errors."""
 
 
@@ -214,10 +214,10 @@ class CustomPuppetMixin(ABC):
                 if next_batch is not None:
                     self.handle_sync(sync_resp)
                 next_batch = sync_resp.get("next_batch", None)
-            except (MatrixRequestError, ClientConnectionError) as e:
+            except (MatrixError, ClientConnectionError) as e:
+                errors += 1
                 wait = min(errors, 11) ** 2
                 self.log.warning(f"Syncer for {custom_mxid} errored: {e}. "
                                  f"Waiting for {wait} seconds...")
-                errors += 1
                 await asyncio.sleep(wait)
         self.log.debug(f"Syncer for custom puppet {custom_mxid} stopped.")
diff --git a/mautrix_facebook/matrix.py b/mautrix_facebook/matrix.py
index 17ba8c6c0ada656e770e9030d480a183f5a0ae00..c3e0959531800c0a797cda50cc0b60f7e1d72878 100644
--- a/mautrix_facebook/matrix.py
+++ b/mautrix_facebook/matrix.py
@@ -251,7 +251,13 @@ class MatrixHandler:
         user.setActiveStatus(evt.content.presence == PresenceState.ONLINE)
 
     async def handle_typing(self, evt: TypingEvent) -> None:
-        pass
+        portal = po.Portal.get_by_mxid(evt.room_id)
+        if not portal:
+            return
+
+        users = (u.User.get_by_mxid(mxid, create=False) for mxid in evt.content.user_ids)
+        await portal.handle_matrix_typing({user for user in users
+                                           if user is not None})
 
     @staticmethod
     async def handle_receipt(evt: ReceiptEvent) -> None:
diff --git a/mautrix_facebook/portal.py b/mautrix_facebook/portal.py
index 071b95c502c194a5366b447d6036e2c764fe9a2c..8d741a80d74772892545550e5962133dd43d7ed1 100644
--- a/mautrix_facebook/portal.py
+++ b/mautrix_facebook/portal.py
@@ -13,7 +13,7 @@
 #
 # 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 Dict, Deque, Optional, Tuple, Union, TYPE_CHECKING
+from typing import Dict, Deque, Optional, Tuple, Union, Set, TYPE_CHECKING
 from collections import deque
 import asyncio
 import logging
@@ -25,7 +25,7 @@ import magic
 from fbchat.models import (ThreadType, Thread, User as FBUser, Group as FBGroup, Page as FBPage,
                            Message as FBMessage, Sticker as FBSticker, AudioAttachment,
                            VideoAttachment, FileAttachment, ImageAttachment, LocationAttachment,
-                           ShareAttachment)
+                           ShareAttachment, TypingStatus)
 from mautrix.types import (RoomID, EventType, ContentURI, MessageEventContent, EventID,
                            ImageInfo, MessageType, LocationMessageEventContent, LocationInfo,
                            ThumbnailInfo, FileInfo, AudioInfo, VideoInfo, Format,
@@ -80,6 +80,7 @@ class Portal:
     _avatar_uri: Optional[ContentURI]
     _send_locks: Dict[str, asyncio.Lock]
     _noop_lock: FakeLock = FakeLock()
+    _typing: Set['u.User']
 
     def __init__(self, fbid: str, fb_receiver: str, fb_type: ThreadType,
                  mxid: Optional[RoomID] = None, name: str = "", photo_id: str = "",
@@ -100,6 +101,7 @@ class Portal:
         self._dedup = deque(maxlen=100)
         self._avatar_uri = None
         self._send_locks = {}
+        self._typing = set()
 
         self.log = self.log.getChild(self.fbid_log)
 
@@ -376,6 +378,14 @@ class Portal:
         else:
             self.log.debug(f"{user.mxid} left portal to {self.fbid}")
 
+    async def handle_matrix_typing(self, users: Set['u.User']) -> None:
+        stopped_typing = [user.setTypingStatus(TypingStatus.STOPPED, self.fbid, self.fb_type)
+                          for user in self._typing - users]
+        started_typing = [user.setTypingStatus(TypingStatus.TYPING, self.fbid, self.fb_type)
+                          for user in users - self._typing]
+        self._typing = users
+        await asyncio.gather(*stopped_typing, *started_typing, loop=self.loop)
+
     # endregion
     # region Facebook event handling
 
@@ -509,7 +519,7 @@ class Portal:
         self._dedup.appendleft(message_id)
         # When we fetch thread info manually, we only get the URL instead of the ID,
         # so we can't use the actual ID here either.
-        #self.photo_id = new_photo_id
+        # self.photo_id = new_photo_id
         photo_url = await source.fetchImageUrl(new_photo_id)
         photo_id = self._get_photo_id(photo_url)
         if self.photo_id == photo_id: