diff --git a/lib/shared/dm-ops/create-thread-spec.js b/lib/shared/dm-ops/create-thread-spec.js
--- a/lib/shared/dm-ops/create-thread-spec.js
+++ b/lib/shared/dm-ops/create-thread-spec.js
@@ -9,7 +9,6 @@
   makePermissionsBlob,
   getThickThreadRolePermissionsBlob,
 } from '../../permissions/thread-permissions.js';
-import { generatePendingThreadColor } from '../../shared/color-utils.js';
 import type { DMCreateThreadOperation } from '../../types/dm-ops.js';
 import { messageTypes } from '../../types/message-types-enum.js';
 import {
@@ -24,9 +23,11 @@
   minimallyEncodeThreadCurrentUserInfo,
 } from '../../types/minimally-encoded-thread-permissions-types.js';
 import { joinThreadSubscription } from '../../types/subscription-types.js';
+import type { ThreadPermissionsInfo } from '../../types/thread-permission-types.js';
 import type { ThickThreadType } from '../../types/thread-types-enum.js';
 import type { ThickMemberInfo } from '../../types/thread-types.js';
 import { updateTypes } from '../../types/update-types-enum.js';
+import { generatePendingThreadColor } from '../color-utils.js';
 
 type CreateThickRawThreadInfoInput = {
   +threadID: string,
@@ -38,6 +39,32 @@
   +creatorID: string,
   +viewerID: string,
 };
+
+function createRoleAndPermissionForThickThreads(
+  threadType: ThickThreadType,
+  threadID: string,
+  roleID: string,
+): { +role: RoleInfo, +membershipPermissions: ThreadPermissionsInfo } {
+  const rolePermissions = getThickThreadRolePermissionsBlob(threadType);
+  const membershipPermissions = getAllThreadPermissions(
+    makePermissionsBlob(rolePermissions, null, threadID, threadType),
+    threadID,
+  );
+  const role: RoleInfo = {
+    ...minimallyEncodeRoleInfo({
+      id: roleID,
+      name: 'Members',
+      permissions: rolePermissions,
+      isDefault: true,
+    }),
+    specialRole: specialRoles.DEFAULT_ROLE,
+  };
+  return {
+    membershipPermissions,
+    role,
+  };
+}
+
 type MutableThickRawThreadInfo = { ...ThickRawThreadInfo };
 function createThickRawThreadInfo(
   input: CreateThickRawThreadInfoInput,
@@ -55,20 +82,8 @@
 
   const color = generatePendingThreadColor(allMemberIDs);
 
-  const rolePermissions = getThickThreadRolePermissionsBlob(threadType);
-  const membershipPermissions = getAllThreadPermissions(
-    makePermissionsBlob(rolePermissions, null, threadID, threadType),
-    threadID,
-  );
-  const role: RoleInfo = {
-    ...minimallyEncodeRoleInfo({
-      id: roleID,
-      name: 'Members',
-      permissions: rolePermissions,
-      isDefault: true,
-    }),
-    specialRole: specialRoles.DEFAULT_ROLE,
-  };
+  const { membershipPermissions, role } =
+    createRoleAndPermissionForThickThreads(threadType, threadID, roleID);
 
   return {
     thick: true,
@@ -159,4 +174,8 @@
     },
   });
 
-export { createThickRawThreadInfo, createThreadSpec };
+export {
+  createThickRawThreadInfo,
+  createThreadSpec,
+  createRoleAndPermissionForThickThreads,
+};
diff --git a/lib/shared/dm-ops/dm-op-specs.js b/lib/shared/dm-ops/dm-op-specs.js
--- a/lib/shared/dm-ops/dm-op-specs.js
+++ b/lib/shared/dm-ops/dm-op-specs.js
@@ -4,6 +4,7 @@
 import { createSidebarSpec } from './create-sidebar-spec.js';
 import { createThreadSpec } from './create-thread-spec.js';
 import type { DMOperationSpec } from './dm-op-spec.js';
+import { joinThreadSpec } from './join-thread-spec.js';
 import { sendEditMessageSpec } from './send-edit-message-spec.js';
 import { sendReactionMessageSpec } from './send-reaction-message-spec.js';
 import { sendTextMessageSpec } from './send-text-message-spec.js';
@@ -18,4 +19,5 @@
   [dmOperationTypes.SEND_REACTION_MESSAGE]: sendReactionMessageSpec,
   [dmOperationTypes.SEND_EDIT_MESSAGE]: sendEditMessageSpec,
   [dmOperationTypes.ADD_MEMBERS]: addMembersSpec,
+  [dmOperationTypes.JOIN_THREAD]: joinThreadSpec,
 });
diff --git a/lib/shared/dm-ops/join-thread-spec.js b/lib/shared/dm-ops/join-thread-spec.js
new file mode 100644
--- /dev/null
+++ b/lib/shared/dm-ops/join-thread-spec.js
@@ -0,0 +1,112 @@
+// @flow
+
+import invariant from 'invariant';
+import uuid from 'uuid';
+
+import { createRoleAndPermissionForThickThreads } from './create-thread-spec.js';
+import type {
+  DMOperationSpec,
+  ProcessDMOperationUtilities,
+} from './dm-op-spec.js';
+import type { DMJoinThreadOperation } from '../../types/dm-ops.js';
+import { messageTypes } from '../../types/message-types-enum.js';
+import { minimallyEncodeMemberInfo } from '../../types/minimally-encoded-thread-permissions-types.js';
+import type { ThickRawThreadInfo } from '../../types/minimally-encoded-thread-permissions-types.js';
+import { joinThreadSubscription } from '../../types/subscription-types.js';
+import type { ThickMemberInfo } from '../../types/thread-types.js';
+import { updateTypes } from '../../types/update-types-enum.js';
+import type { ClientUpdateInfo } from '../../types/update-types.js';
+import { values } from '../../utils/objects.js';
+import { roleIsDefaultRole, userIsMember } from '../thread-utils.js';
+
+const joinThreadSpec: DMOperationSpec<DMJoinThreadOperation> = Object.freeze({
+  processDMOperation: async (
+    dmOperation: DMJoinThreadOperation,
+    viewerID: string,
+    utilities: ProcessDMOperationUtilities,
+  ) => {
+    const {
+      editorID,
+      time,
+      messageID,
+      threadInfo,
+      rawMessageInfos,
+      truncationStatus,
+      rawEntryInfos,
+    } = dmOperation;
+
+    const currentThreadInfoOptional = utilities.getThreadInfo(threadInfo.id);
+    if (userIsMember(currentThreadInfoOptional, editorID)) {
+      return {
+        rawMessageInfos: [],
+        updateInfos: [],
+      };
+    }
+
+    const joinThreadMessage = {
+      type: messageTypes.JOIN_THREAD,
+      id: messageID,
+      threadID: threadInfo.id,
+      creatorID: editorID,
+      time,
+    };
+
+    const updateInfos: Array<ClientUpdateInfo> = [];
+    if (viewerID === editorID) {
+      updateInfos.push({
+        type: updateTypes.JOIN_THREAD,
+        id: uuid.v4(),
+        time,
+        threadInfo,
+        rawMessageInfos,
+        truncationStatus,
+        rawEntryInfos,
+      });
+    } else {
+      if (!currentThreadInfoOptional || !currentThreadInfoOptional.thick) {
+        // We can't perform this operation now. It should be queued for later.
+        return {
+          rawMessageInfos: [],
+          updateInfos: [],
+        };
+      }
+      const currentThreadInfo: ThickRawThreadInfo = currentThreadInfoOptional;
+
+      const defaultRoleID = values(currentThreadInfo.roles).find(role =>
+        roleIsDefaultRole(role),
+      )?.id;
+      invariant(defaultRoleID, 'Default role ID must exist');
+      const { membershipPermissions } = createRoleAndPermissionForThickThreads(
+        currentThreadInfo.type,
+        currentThreadInfo.id,
+        defaultRoleID,
+      );
+
+      const member = minimallyEncodeMemberInfo<ThickMemberInfo>({
+        id: editorID,
+        role: defaultRoleID,
+        permissions: membershipPermissions,
+        isSender: editorID === viewerID,
+        subscription: joinThreadSubscription,
+      });
+      if (currentThreadInfo?.thick) {
+        const updatedThreadInfo = {
+          ...currentThreadInfo,
+          members: [...threadInfo.members, member],
+        };
+        updateInfos.push({
+          type: updateTypes.UPDATE_THREAD,
+          id: uuid.v4(),
+          time,
+          threadInfo: updatedThreadInfo,
+        });
+      }
+    }
+    return {
+      rawMessageInfos: [joinThreadMessage],
+      updateInfos,
+    };
+  },
+});
+
+export { joinThreadSpec };
diff --git a/lib/types/dm-ops.js b/lib/types/dm-ops.js
--- a/lib/types/dm-ops.js
+++ b/lib/types/dm-ops.js
@@ -28,6 +28,7 @@
   SEND_REACTION_MESSAGE: 'send_reaction_message',
   SEND_EDIT_MESSAGE: 'send_edit_message',
   ADD_MEMBERS: 'add_members',
+  JOIN_THREAD: 'join_thread',
 });
 export type DMOperationType = $Values<typeof dmOperationTypes>;
 
@@ -163,13 +164,36 @@
     rawEntryInfos: t.list(rawEntryInfoValidator),
   });
 
+export type DMJoinThreadOperation = {
+  +type: 'join_thread',
+  +editorID: string,
+  +time: number,
+  +messageID: string,
+  +threadInfo: ThickRawThreadInfo,
+  +rawMessageInfos: $ReadOnlyArray<RawMessageInfo>,
+  +truncationStatus: MessageTruncationStatus,
+  +rawEntryInfos: $ReadOnlyArray<RawEntryInfo>,
+};
+export const dmJoinThreadOperation: TInterface<DMJoinThreadOperation> =
+  tShape<DMJoinThreadOperation>({
+    type: tString(dmOperationTypes.JOIN_THREAD),
+    editorID: tUserID,
+    time: t.Number,
+    messageID: t.String,
+    threadInfo: threadInfoValidator,
+    rawMessageInfos: t.list(rawMessageInfoValidator),
+    truncationStatus: messageTruncationStatusValidator,
+    rawEntryInfos: t.list(rawEntryInfoValidator),
+  });
+
 export type DMOperation =
   | DMCreateThreadOperation
   | DMCreateSidebarOperation
   | DMSendTextMessageOperation
   | DMSendReactionMessageOperation
   | DMSendEditMessageOperation
-  | DMAddMembersOperation;
+  | DMAddMembersOperation
+  | DMJoinThreadOperation;
 export const dmOperationValidator: TUnion<DMOperation> = t.union([
   dmCreateThreadOperationValidator,
   dmCreateSidebarOperationValidator,
@@ -177,6 +201,7 @@
   dmSendReactionMessageOperation,
   dmSendEditMessageOperation,
   dmAddMembersOperation,
+  dmJoinThreadOperation,
 ]);
 
 export type DMOperationResult = {