diff --git a/lib/shared/messages/add-members-message-spec.js b/lib/shared/messages/add-members-message-spec.js
--- a/lib/shared/messages/add-members-message-spec.js
+++ b/lib/shared/messages/add-members-message-spec.js
@@ -6,8 +6,10 @@
   CreateMessageInfoParams,
   MessageSpec,
   NotificationTextsParams,
+  MergeRobotextMessageItemResult,
 } from './message-spec.js';
 import { joinResult } from './utils.js';
+import type { RobotextChatMessageInfoItem } from '../../selectors/chat-selectors.js';
 import { messageTypes } from '../../types/message-types-enum.js';
 import type {
   ClientDBMessageInfo,
@@ -30,6 +32,18 @@
 import { values } from '../../utils/objects.js';
 import { notifRobotextForMessageInfo } from '../notif-utils.js';
 
+function getAddMembersRobotext(messageInfo: AddMembersMessageInfo): EntityText {
+  const users = messageInfo.addedMembers;
+  invariant(users.length !== 0, 'added who??');
+
+  const creator = ET.user({ userInfo: messageInfo.creator });
+  const addedUsers = pluralizeEntityText(
+    users.map(user => ET`${ET.user({ userInfo: user })}`),
+  );
+
+  return ET`${creator} added ${addedUsers}`;
+}
+
 type AddMembersMessageSpec = MessageSpec<
   AddMembersMessageData,
   RawAddMembersMessageInfo,
@@ -111,15 +125,7 @@
   },
 
   robotext(messageInfo: AddMembersMessageInfo): EntityText {
-    const users = messageInfo.addedMembers;
-    invariant(users.length !== 0, 'added who??');
-
-    const creator = ET.user({ userInfo: messageInfo.creator });
-    const addedUsers = pluralizeEntityText(
-      users.map(user => ET`${ET.user({ userInfo: user })}`),
-    );
-
-    return ET`${creator} added ${addedUsers}`;
+    return getAddMembersRobotext(messageInfo);
   },
 
   async notificationTexts(
@@ -180,4 +186,42 @@
   canBePinned: false,
 
   validator: rawAddMembersMessageInfoValidator,
+
+  mergeIntoPrecedingRobotextMessageItem(
+    messageInfo: AddMembersMessageInfo,
+    precedingMessageInfoItem: RobotextChatMessageInfoItem,
+  ): MergeRobotextMessageItemResult {
+    if (precedingMessageInfoItem.messageInfos.length === 0) {
+      return { shouldMerge: false };
+    }
+
+    const addedMembers = [];
+    const creatorID = messageInfo.creator.id;
+    for (const precedingMessageInfo of precedingMessageInfoItem.messageInfos) {
+      if (
+        precedingMessageInfo.type !== messageTypes.ADD_MEMBERS ||
+        precedingMessageInfo.creator.id !== creatorID
+      ) {
+        return { shouldMerge: false };
+      }
+      for (const addedMember of precedingMessageInfo.addedMembers) {
+        addedMembers.push(addedMember);
+      }
+    }
+
+    const messageInfos = [
+      messageInfo,
+      ...precedingMessageInfoItem.messageInfos,
+    ];
+    const newRobotext = getAddMembersRobotext({
+      ...messageInfo,
+      addedMembers,
+    });
+    const mergedItem = {
+      ...precedingMessageInfoItem,
+      messageInfos,
+      robotext: newRobotext,
+    };
+    return { shouldMerge: true, item: mergedItem };
+  },
 });
diff --git a/lib/shared/messages/join-thread-message-spec.js b/lib/shared/messages/join-thread-message-spec.js
--- a/lib/shared/messages/join-thread-message-spec.js
+++ b/lib/shared/messages/join-thread-message-spec.js
@@ -2,8 +2,13 @@
 
 import invariant from 'invariant';
 
-import type { MessageSpec, RobotextParams } from './message-spec.js';
+import type {
+  MessageSpec,
+  RobotextParams,
+  MergeRobotextMessageItemResult,
+} from './message-spec.js';
 import { joinResult } from './utils.js';
+import type { RobotextChatMessageInfoItem } from '../../selectors/chat-selectors.js';
 import { messageTypes } from '../../types/message-types-enum.js';
 import type {
   ClientDBMessageInfo,
@@ -25,6 +30,19 @@
 } from '../../utils/entity-text.js';
 import { values } from '../../utils/objects.js';
 
+function getJoinThreadRobotext(
+  joinerString: EntityText,
+  threadID: string,
+  params: RobotextParams,
+): EntityText {
+  return ET`${joinerString} joined ${ET.thread({
+    display: 'alwaysDisplayShortName',
+    threadID,
+    threadType: params.threadInfo?.type,
+    parentThreadID: params.threadInfo?.parentThreadID,
+  })}`;
+}
+
 export const joinThreadMessageSpec: MessageSpec<
   JoinThreadMessageData,
   RawJoinThreadMessageInfo,
@@ -79,12 +97,7 @@
     params: RobotextParams,
   ): EntityText {
     const creator = ET.user({ userInfo: messageInfo.creator });
-    return ET`${creator} joined ${ET.thread({
-      display: 'alwaysDisplayShortName',
-      threadID: messageInfo.threadID,
-      threadType: params.threadInfo?.type,
-      parentThreadID: params.threadInfo?.parentThreadID,
-    })}`;
+    return getJoinThreadRobotext(ET`${creator}`, messageInfo.threadID, params);
   },
 
   async notificationTexts(
@@ -124,4 +137,37 @@
   canBePinned: false,
 
   validator: rawJoinThreadMessageInfoValidator,
+
+  mergeIntoPrecedingRobotextMessageItem(
+    messageInfo: JoinThreadMessageInfo,
+    precedingMessageInfoItem: RobotextChatMessageInfoItem,
+    params: RobotextParams,
+  ): MergeRobotextMessageItemResult {
+    if (precedingMessageInfoItem.messageInfos.length === 0) {
+      return { shouldMerge: false };
+    }
+    for (const precedingMessageInfo of precedingMessageInfoItem.messageInfos) {
+      if (precedingMessageInfo.type !== messageTypes.JOIN_THREAD) {
+        return { shouldMerge: false };
+      }
+    }
+    const messageInfos = [
+      messageInfo,
+      ...precedingMessageInfoItem.messageInfos,
+    ];
+    const joiningUsers = pluralizeEntityText(
+      messageInfos.map(info => ET`${ET.user({ userInfo: info.creator })}`),
+    );
+    const newRobotext = getJoinThreadRobotext(
+      joiningUsers,
+      messageInfo.threadID,
+      params,
+    );
+    const mergedItem = {
+      ...precedingMessageInfoItem,
+      messageInfos,
+      robotext: newRobotext,
+    };
+    return { shouldMerge: true, item: mergedItem };
+  },
 });
diff --git a/lib/shared/messages/leave-thread-message-spec.js b/lib/shared/messages/leave-thread-message-spec.js
--- a/lib/shared/messages/leave-thread-message-spec.js
+++ b/lib/shared/messages/leave-thread-message-spec.js
@@ -2,8 +2,13 @@
 
 import invariant from 'invariant';
 
-import type { MessageSpec, RobotextParams } from './message-spec.js';
+import type {
+  MessageSpec,
+  RobotextParams,
+  MergeRobotextMessageItemResult,
+} from './message-spec.js';
 import { joinResult } from './utils.js';
+import type { RobotextChatMessageInfoItem } from '../../selectors/chat-selectors.js';
 import { messageTypes } from '../../types/message-types-enum.js';
 import type {
   ClientDBMessageInfo,
@@ -25,6 +30,19 @@
 } from '../../utils/entity-text.js';
 import { values } from '../../utils/objects.js';
 
+function getLeaveThreadRobotext(
+  leaverString: EntityText,
+  threadID: string,
+  params: RobotextParams,
+): EntityText {
+  return ET`${leaverString} left ${ET.thread({
+    display: 'alwaysDisplayShortName',
+    threadID,
+    threadType: params.threadInfo?.type,
+    parentThreadID: params.threadInfo?.parentThreadID,
+  })}`;
+}
+
 export const leaveThreadMessageSpec: MessageSpec<
   LeaveThreadMessageData,
   RawLeaveThreadMessageInfo,
@@ -79,12 +97,7 @@
     params: RobotextParams,
   ): EntityText {
     const creator = ET.user({ userInfo: messageInfo.creator });
-    return ET`${creator} left ${ET.thread({
-      display: 'alwaysDisplayShortName',
-      threadID: messageInfo.threadID,
-      threadType: params.threadInfo?.type,
-      parentThreadID: params.threadInfo?.parentThreadID,
-    })}`;
+    return getLeaveThreadRobotext(ET`${creator}`, messageInfo.threadID, params);
   },
 
   async notificationTexts(
@@ -124,4 +137,37 @@
   canBePinned: false,
 
   validator: rawLeaveThreadMessageInfoValidator,
+
+  mergeIntoPrecedingRobotextMessageItem(
+    messageInfo: LeaveThreadMessageInfo,
+    precedingMessageInfoItem: RobotextChatMessageInfoItem,
+    params: RobotextParams,
+  ): MergeRobotextMessageItemResult {
+    if (precedingMessageInfoItem.messageInfos.length === 0) {
+      return { shouldMerge: false };
+    }
+    for (const precedingMessageInfo of precedingMessageInfoItem.messageInfos) {
+      if (precedingMessageInfo.type !== messageTypes.LEAVE_THREAD) {
+        return { shouldMerge: false };
+      }
+    }
+    const messageInfos = [
+      messageInfo,
+      ...precedingMessageInfoItem.messageInfos,
+    ];
+    const leavingUsers = pluralizeEntityText(
+      messageInfos.map(info => ET`${ET.user({ userInfo: info.creator })}`),
+    );
+    const newRobotext = getLeaveThreadRobotext(
+      leavingUsers,
+      messageInfo.threadID,
+      params,
+    );
+    const mergedItem = {
+      ...precedingMessageInfoItem,
+      messageInfos,
+      robotext: newRobotext,
+    };
+    return { shouldMerge: true, item: mergedItem };
+  },
 });
diff --git a/lib/shared/messages/remove-members-message-spec.js b/lib/shared/messages/remove-members-message-spec.js
--- a/lib/shared/messages/remove-members-message-spec.js
+++ b/lib/shared/messages/remove-members-message-spec.js
@@ -6,8 +6,10 @@
   CreateMessageInfoParams,
   MessageSpec,
   NotificationTextsParams,
+  MergeRobotextMessageItemResult,
 } from './message-spec.js';
 import { joinResult } from './utils.js';
+import type { RobotextChatMessageInfoItem } from '../../selectors/chat-selectors.js';
 import { messageTypes } from '../../types/message-types-enum.js';
 import type {
   ClientDBMessageInfo,
@@ -30,6 +32,20 @@
 import { values } from '../../utils/objects.js';
 import { notifRobotextForMessageInfo } from '../notif-utils.js';
 
+function getRemoveMembersRobotext(
+  messageInfo: RemoveMembersMessageInfo,
+): EntityText {
+  const users = messageInfo.removedMembers;
+  invariant(users.length !== 0, 'removed who??');
+
+  const creator = ET.user({ userInfo: messageInfo.creator });
+  const removedUsers = pluralizeEntityText(
+    users.map(user => ET`${ET.user({ userInfo: user })}`),
+  );
+
+  return ET`${creator} removed ${removedUsers}`;
+}
+
 type RemoveMembersMessageSpec = MessageSpec<
   RemoveMembersMessageData,
   RawRemoveMembersMessageInfo,
@@ -112,15 +128,7 @@
     },
 
     robotext(messageInfo: RemoveMembersMessageInfo): EntityText {
-      const users = messageInfo.removedMembers;
-      invariant(users.length !== 0, 'added who??');
-
-      const creator = ET.user({ userInfo: messageInfo.creator });
-      const removedUsers = pluralizeEntityText(
-        users.map(user => ET`${ET.user({ userInfo: user })}`),
-      );
-
-      return ET`${creator} removed ${removedUsers}`;
+      return getRemoveMembersRobotext(messageInfo);
     },
 
     async notificationTexts(
@@ -185,5 +193,42 @@
     canBePinned: false,
 
     validator: rawRemoveMembersMessageInfoValidator,
+
+    mergeIntoPrecedingRobotextMessageItem(
+      messageInfo: RemoveMembersMessageInfo,
+      precedingMessageInfoItem: RobotextChatMessageInfoItem,
+    ): MergeRobotextMessageItemResult {
+      if (precedingMessageInfoItem.messageInfos.length === 0) {
+        return { shouldMerge: false };
+      }
+      const removedMembers = [];
+      const creatorID = messageInfo.creator.id;
+      for (const precedingMessageInfo of precedingMessageInfoItem.messageInfos) {
+        if (
+          precedingMessageInfo.type !== messageTypes.REMOVE_MEMBERS ||
+          precedingMessageInfo.creator.id !== creatorID
+        ) {
+          return { shouldMerge: false };
+        }
+        for (const removedMember of precedingMessageInfo.removedMembers) {
+          removedMembers.push(removedMember);
+        }
+      }
+
+      const messageInfos = [
+        messageInfo,
+        ...precedingMessageInfoItem.messageInfos,
+      ];
+      const newRobotext = getRemoveMembersRobotext({
+        ...messageInfo,
+        removedMembers,
+      });
+      const mergedItem = {
+        ...precedingMessageInfoItem,
+        messageInfos,
+        robotext: newRobotext,
+      };
+      return { shouldMerge: true, item: mergedItem };
+    },
   },
 );