diff --git a/lib/selectors/chat-selectors.js b/lib/selectors/chat-selectors.js
--- a/lib/selectors/chat-selectors.js
+++ b/lib/selectors/chat-selectors.js
@@ -21,6 +21,7 @@
   robotextForMessageInfo,
   sortMessageInfoList,
 } from '../shared/message-utils.js';
+import { messageSpecs } from '../shared/messages/message-specs.js';
 import {
   threadInChatList,
   threadIsPending,
@@ -504,6 +505,41 @@
       return result;
     })();
 
+    const threadInfo = threadInfos[threadID];
+    const parentThreadInfo = threadInfo?.parentThreadID
+      ? threadInfos[threadInfo.parentThreadID]
+      : null;
+
+    const lastChatMessageItem =
+      chatMessageItems.length > 0
+        ? chatMessageItems[chatMessageItems.length - 1]
+        : undefined;
+    const messageSpec = messageSpecs[originalMessageInfo.type];
+    if (
+      !threadCreatedFromMessage &&
+      Object.keys(renderedReactions).length === 0 &&
+      !hasBeenEdited &&
+      !isPinned &&
+      lastChatMessageItem &&
+      lastChatMessageItem.itemType === 'message' &&
+      lastChatMessageItem.messageInfoType === 'robotext' &&
+      !lastChatMessageItem.threadCreatedFromMessage &&
+      Object.keys(lastChatMessageItem.reactions).length === 0 &&
+      !isComposableMessageType(originalMessageInfo.type) &&
+      messageSpec?.mergeIntoPrecedingRobotextMessageItem
+    ) {
+      const { mergeIntoPrecedingRobotextMessageItem } = messageSpec;
+      const mergeResult = mergeIntoPrecedingRobotextMessageItem(
+        originalMessageInfo,
+        lastChatMessageItem,
+        { threadInfo, parentThreadInfo },
+      );
+      if (mergeResult.shouldMerge) {
+        chatMessageItems[chatMessageItems.length - 1] = mergeResult.item;
+        continue;
+      }
+    }
+
     if (isComposableMessageType(originalMessageInfo.type)) {
       // We use these invariants instead of just checking the messageInfo.type
       // directly in the conditional above so that isComposableMessageType can
@@ -536,11 +572,6 @@
           originalMessageInfo.type !== messageTypes.MULTIMEDIA,
         "Flow doesn't understand isComposableMessageType above",
       );
-      const threadInfo = threadInfos[threadID];
-      const parentThreadInfo = threadInfo?.parentThreadID
-        ? threadInfos[threadInfo.parentThreadID]
-        : null;
-
       const robotext = robotextForMessageInfo(
         originalMessageInfo,
         threadInfo,
diff --git a/lib/shared/messages/message-spec.js b/lib/shared/messages/message-spec.js
--- a/lib/shared/messages/message-spec.js
+++ b/lib/shared/messages/message-spec.js
@@ -2,6 +2,7 @@
 
 import type { TType } from 'tcomb';
 
+import type { RobotextChatMessageInfoItem } from '../../selectors/chat-selectors.js';
 import type { PlatformDetails } from '../../types/device-types.js';
 import type { Media } from '../../types/media-types.js';
 import type {
@@ -71,6 +72,10 @@
   parentThreadInfo: ?ThreadInfo,
 ) => Promise<mixed>;
 
+export type MergeRobotextMessageItemResult =
+  | { +shouldMerge: false }
+  | { +shouldMerge: true, +item: RobotextChatMessageInfoItem };
+
 export type MessageSpec<Data, RawInfo, Info> = {
   +messageContentForServerDB?: (data: Data | RawInfo) => string,
   +messageContentForClientDB?: (data: RawInfo) => string,
@@ -121,4 +126,9 @@
   +parseDerivedMessages?: (row: Object, requiredIDs: Set<string>) => void,
   +useCreationSideEffectsFunc?: () => CreationSideEffectsFunc<RawInfo>,
   +validator: TType<RawInfo>,
+  +mergeIntoPrecedingRobotextMessageItem?: (
+    messageInfo: Info,
+    precedingMessageInfoItem: RobotextChatMessageInfoItem,
+    params: RobotextParams,
+  ) => MergeRobotextMessageItemResult,
 };