diff --git a/keyserver/src/fetchers/thread-fetchers.js b/keyserver/src/fetchers/thread-fetchers.js
--- a/keyserver/src/fetchers/thread-fetchers.js
+++ b/keyserver/src/fetchers/thread-fetchers.js
@@ -36,12 +36,13 @@
   `.append(whereClause);
 
   const threadsQuery = SQL`
-    SELECT t.id, t.name, t.parent_thread_id, t.containing_thread_id,
-      t.community, t.depth, t.color, t.description, t.type, t.creation_time,
-      t.source_message, t.replies_count, t.avatar, m.user, m.role, m.permissions,
-      m.subscription, m.last_read_message < m.last_message AS unread, m.sender
-    FROM threads t
-    LEFT JOIN memberships m ON m.thread = t.id AND m.role >= 0
+  SELECT t.id, t.name, t.parent_thread_id, t.containing_thread_id,
+    t.community, t.depth, t.color, t.description, t.type, t.creation_time,
+    t.source_message, t.replies_count, t.avatar, t.pinned_count, m.user, 
+    m.role, m.permissions, m.subscription, 
+    m.last_read_message < m.last_message AS unread, m.sender
+  FROM threads t
+  LEFT JOIN memberships m ON m.thread = t.id AND m.role >= 0
   `
     .append(whereClause)
     .append(SQL` ORDER BY m.user ASC`);
@@ -74,6 +75,7 @@
         members: [],
         roles: {},
         repliesCount: threadsRow.replies_count,
+        pinnedCount: threadsRow.pinned_count,
       };
       if (threadsRow.avatar) {
         threadInfos[threadID] = {
@@ -165,6 +167,10 @@
   //              native release with thread avatar editing enabled.
   const filterThreadEditAvatarPermission = true;
 
+  const hasCodeVersionBelow209 = !hasMinCodeVersion(
+    viewer.platformDetails,
+    209,
+  );
   const threadInfos = {};
   for (const threadID in serverResult.threadInfos) {
     const serverThreadInfo = serverResult.threadInfos[threadID];
@@ -178,6 +184,7 @@
         hideThreadStructure: hasCodeVersionBelow102,
         filterDetailedThreadEditPermissions: hasCodeVersionBelow104,
         filterThreadEditAvatarPermission,
+        excludePinInfo: hasCodeVersionBelow209,
       },
     );
     if (threadInfo) {
diff --git a/lib/shared/thread-utils.js b/lib/shared/thread-utils.js
--- a/lib/shared/thread-utils.js
+++ b/lib/shared/thread-utils.js
@@ -377,6 +377,7 @@
     },
     repliesCount: 0,
     sourceMessageID,
+    pinnedCount: 0,
   };
 
   const userInfos = {};
@@ -701,6 +702,7 @@
   },
   +filterDetailedThreadEditPermissions?: boolean,
   +filterThreadEditAvatarPermission?: boolean,
+  +excludePinInfo?: boolean,
 };
 function rawThreadInfoFromServerThreadInfo(
   serverThreadInfo: ServerThreadInfo,
@@ -715,6 +717,7 @@
     options?.filterDetailedThreadEditPermissions;
   const filterThreadEditAvatarPermission =
     options?.filterThreadEditAvatarPermission;
+  const excludePinInfo = options?.excludePinInfo;
 
   const filterThreadPermissions = _omitBy(
     (v, k) =>
@@ -728,6 +731,12 @@
           threadPermissions.EDIT_THREAD_AVATAR,
           threadPermissionPropagationPrefixes.DESCENDANT +
             threadPermissions.EDIT_THREAD_AVATAR,
+        ].includes(k)) ||
+      (excludePinInfo &&
+        [
+          threadPermissions.MANAGE_PINS,
+          threadPermissionPropagationPrefixes.DESCENDANT +
+            threadPermissions.MANAGE_PINS,
         ].includes(k)),
   );
 
@@ -836,6 +845,12 @@
       visibilityRules: rawThreadInfo.type,
     };
   }
+  if (!excludePinInfo) {
+    return {
+      ...rawThreadInfo,
+      pinnedCount: serverThreadInfo.pinnedCount,
+    };
+  }
   return rawThreadInfo;
 }
 
@@ -883,7 +898,7 @@
     ...threadInfo,
     uiName: threadUIName(threadInfo),
   };
-  const { sourceMessageID, avatar } = rawThreadInfo;
+  const { sourceMessageID, avatar, pinnedCount } = rawThreadInfo;
   if (sourceMessageID) {
     threadInfo = { ...threadInfo, sourceMessageID };
   }
@@ -899,6 +914,11 @@
       avatar: getUserAvatarForThread(rawThreadInfo, viewerID, userInfos),
     };
   }
+
+  if (pinnedCount) {
+    threadInfo = { ...threadInfo, pinnedCount };
+  }
+
   return threadInfo;
 }
 
diff --git a/lib/shared/unshim-utils.js b/lib/shared/unshim-utils.js
--- a/lib/shared/unshim-utils.js
+++ b/lib/shared/unshim-utils.js
@@ -44,6 +44,7 @@
   messageTypes.SIDEBAR_SOURCE,
   messageTypes.MULTIMEDIA,
   messageTypes.REACTION,
+  messageTypes.TOGGLE_PIN,
 ]);
 function unshimMessageInfos(
   messageInfos: $ReadOnlyArray<RawMessageInfo>,
diff --git a/lib/types/thread-types.js b/lib/types/thread-types.js
--- a/lib/types/thread-types.js
+++ b/lib/types/thread-types.js
@@ -217,6 +217,7 @@
   +currentUser: ThreadCurrentUserInfo,
   +sourceMessageID?: string,
   +repliesCount: number,
+  +pinnedCount?: number,
 };
 
 export type ThreadInfo = {
@@ -236,6 +237,7 @@
   +currentUser: ThreadCurrentUserInfo,
   +sourceMessageID?: string,
   +repliesCount: number,
+  +pinnedCount?: number,
 };
 
 export type ResolvedThreadInfo = {
@@ -255,6 +257,7 @@
   +currentUser: ThreadCurrentUserInfo,
   +sourceMessageID?: string,
   +repliesCount: number,
+  +pinnedCount?: number,
 };
 
 export type ServerMemberInfo = {
@@ -282,6 +285,7 @@
   +roles: { [id: string]: RoleInfo },
   +sourceMessageID?: string,
   +repliesCount: number,
+  +pinnedCount: number,
 };
 
 export type ThreadStore = {
@@ -323,6 +327,7 @@
   +currentUser: string,
   +sourceMessageID?: string,
   +repliesCount: number,
+  +pinnedCount?: number,
 };
 
 export type ClientDBReplaceThreadOperation = {
diff --git a/native/cpp/CommonCpp/NativeModules/CommCoreModule.cpp b/native/cpp/CommonCpp/NativeModules/CommCoreModule.cpp
--- a/native/cpp/CommonCpp/NativeModules/CommCoreModule.cpp
+++ b/native/cpp/CommonCpp/NativeModules/CommCoreModule.cpp
@@ -255,6 +255,7 @@
             ? jsi::String::createFromUtf8(rt, *thread.source_message_id)
             : jsi::Value::null());
     jsiThread.setProperty(rt, "repliesCount", thread.replies_count);
+    jsiThread.setProperty(rt, "pinnedCount", thread.pinned_count);
 
     if (thread.avatar) {
       auto avatar = jsi::String::createFromUtf8(rt, *thread.avatar);
@@ -643,6 +644,10 @@
           ? std::make_unique<std::string>(maybeAvatar.asString(rt).utf8(rt))
           : nullptr;
 
+      jsi::Value maybePinnedCount = threadObj.getProperty(rt, "pinnedCount");
+      int pinnedCount = maybePinnedCount.isNumber()
+          ? std::lround(maybePinnedCount.asNumber())
+          : 0;
       Thread thread{
           threadID,
           type,
@@ -658,7 +663,8 @@
           currentUser,
           std::move(sourceMessageID),
           repliesCount,
-          std::move(avatar)};
+          std::move(avatar),
+          pinnedCount};
 
       threadStoreOps.push_back(
           std::make_unique<ReplaceThreadOperation>(std::move(thread)));
diff --git a/native/redux/manage-pins-permission-migration.js b/native/redux/manage-pins-permission-migration.js
new file mode 100644
--- /dev/null
+++ b/native/redux/manage-pins-permission-migration.js
@@ -0,0 +1,89 @@
+// @flow
+
+import type {
+  RawThreadInfo,
+  MemberInfo,
+  ThreadCurrentUserInfo,
+  RoleInfo,
+} from 'lib/types/thread-types.js';
+
+type ThreadStoreThreadInfos = { +[id: string]: RawThreadInfo };
+type TargetMemberInfo = MemberInfo | ThreadCurrentUserInfo;
+
+const adminRoleName = 'Admins';
+
+function addManagePinsThreadPermissionToUser(
+  threadInfo: RawThreadInfo,
+  member: TargetMemberInfo,
+  threadID: string,
+): TargetMemberInfo {
+  const isAdmin =
+    member.role && threadInfo.roles[member.role].name === adminRoleName;
+  let newPermissionsForMember;
+  if (isAdmin) {
+    newPermissionsForMember = {
+      ...member.permissions,
+      manage_pins: { value: true, source: threadID },
+    };
+  }
+
+  return newPermissionsForMember
+    ? {
+        ...member,
+        permissions: newPermissionsForMember,
+      }
+    : member;
+}
+
+function addManagePinsThreadPermissionToRole(role: RoleInfo): RoleInfo {
+  const isAdminRole = role.name === adminRoleName;
+  let updatedPermissions;
+
+  if (isAdminRole) {
+    updatedPermissions = {
+      ...role.permissions,
+      manage_pins: true,
+      descendant_manage_pins: true,
+    };
+  }
+
+  return updatedPermissions
+    ? { ...role, permissions: updatedPermissions }
+    : role;
+}
+
+function persistMigrationForManagePinsThreadPermission(
+  threadInfos: ThreadStoreThreadInfos,
+): ThreadStoreThreadInfos {
+  const newThreadInfos = {};
+  for (const threadID in threadInfos) {
+    const threadInfo: RawThreadInfo = threadInfos[threadID];
+    const updatedMembers = threadInfo.members.map(member =>
+      addManagePinsThreadPermissionToUser(threadInfo, member, threadID),
+    );
+
+    const updatedCurrentUser = addManagePinsThreadPermissionToUser(
+      threadInfo,
+      threadInfo.currentUser,
+      threadID,
+    );
+
+    const updatedRoles = {};
+    for (const roleID in threadInfo.roles) {
+      updatedRoles[roleID] = addManagePinsThreadPermissionToRole(
+        threadInfo.roles[roleID],
+      );
+    }
+
+    const updatedThreadInfo = {
+      ...threadInfo,
+      members: updatedMembers,
+      currentUser: updatedCurrentUser,
+      roles: updatedRoles,
+    };
+    newThreadInfos[threadID] = updatedThreadInfo;
+  }
+  return newThreadInfos;
+}
+
+export { persistMigrationForManagePinsThreadPermission };
diff --git a/native/redux/persist.js b/native/redux/persist.js
--- a/native/redux/persist.js
+++ b/native/redux/persist.js
@@ -13,7 +13,10 @@
   getContainingThreadID,
   getCommunity,
 } from 'lib/shared/thread-utils.js';
-import { DEPRECATED_unshimMessageStore } from 'lib/shared/unshim-utils.js';
+import {
+  DEPRECATED_unshimMessageStore,
+  unshimFunc,
+} from 'lib/shared/unshim-utils.js';
 import { defaultEnabledApps } from 'lib/types/enabled-apps.js';
 import { defaultCalendarFilters } from 'lib/types/filter-types.js';
 import {
@@ -23,11 +26,23 @@
   type ClientDBMessageStoreOperation,
 } from 'lib/types/message-types.js';
 import { defaultConnectionInfo } from 'lib/types/socket-types.js';
-import { translateRawMessageInfoToClientDBMessageInfo } from 'lib/utils/message-ops-utils.js';
+import type {
+  ClientDBThreadStoreOperation,
+  ClientDBThreadInfo,
+} from 'lib/types/thread-types.js';
+import {
+  translateClientDBMessageInfoToRawMessageInfo,
+  translateRawMessageInfoToClientDBMessageInfo,
+} from 'lib/utils/message-ops-utils.js';
 import { defaultNotifPermissionAlertInfo } from 'lib/utils/push-alerts.js';
-import { convertThreadStoreOperationsToClientDBOperations } from 'lib/utils/thread-ops-utils.js';
+import {
+  convertClientDBThreadInfoToRawThreadInfo,
+  convertRawThreadInfoToClientDBThreadInfo,
+  convertThreadStoreOperationsToClientDBOperations,
+} from 'lib/utils/thread-ops-utils.js';
 
 import { migrateThreadStoreForEditThreadPermissions } from './edit-thread-permission-migration.js';
+import { persistMigrationForManagePinsThreadPermission } from './manage-pins-permission-migration.js';
 import type { AppState } from './state-types.js';
 import { unshimClientDB } from './unshim-utils.js';
 import { commCoreModule } from '../native-modules.js';
@@ -391,6 +406,104 @@
     return stateSansThreadIDsToNotifIDs;
   },
   [35]: (state: AppState) => unshimClientDB(state, [messageTypes.MULTIMEDIA]),
+  [36]: (state: AppState) => {
+    // 1. Get threads and messages from SQLite `threads` and `messages` tables.
+    const clientDBThreadInfos = commCoreModule.getAllThreadsSync();
+    const clientDBMessageInfos = commCoreModule.getAllMessagesSync();
+
+    // 2. Translate `ClientDBThreadInfo`s to `RawThreadInfo`s and
+    //    `ClientDBMessageInfo`s to `RawMessageInfo`s.
+    const rawThreadInfos = clientDBThreadInfos.map(
+      convertClientDBThreadInfoToRawThreadInfo,
+    );
+    const rawMessageInfos = clientDBMessageInfos.map(
+      translateClientDBMessageInfoToRawMessageInfo,
+    );
+
+    // 3. Unshim translated `RawMessageInfos` to get the TOGGLE_PIN messages
+    const unshimmedRawMessageInfos = rawMessageInfos.map(messageInfo =>
+      unshimFunc(messageInfo, new Set([messageTypes.TOGGLE_PIN])),
+    );
+
+    // 4. Filter out non-TOGGLE_PIN messages
+    const filteredRawMessageInfos = unshimmedRawMessageInfos.filter(
+      messageInfo => messageInfo.type === messageTypes.TOGGLE_PIN,
+    );
+
+    // 5. We want only the last TOGGLE_PIN message for each message ID,
+    // so 'pin', 'unpin', 'pin' don't count as 3 pins, but only 1.
+    const lastMessageIDToRawMessageInfoMap = new Map();
+    for (const messageInfo of filteredRawMessageInfos) {
+      const { targetMessageID } = messageInfo;
+      lastMessageIDToRawMessageInfoMap.set(targetMessageID, messageInfo);
+    }
+    const lastMessageIDToRawMessageInfos = Array.from(
+      lastMessageIDToRawMessageInfoMap.values(),
+    );
+
+    // 6. Create a Map of threadIDs to pinnedCount
+    const threadIDsToPinnedCount = new Map();
+    for (const messageInfo of lastMessageIDToRawMessageInfos) {
+      const { threadID, type } = messageInfo;
+      if (type === messageTypes.TOGGLE_PIN) {
+        const pinnedCount = threadIDsToPinnedCount.get(threadID) || 0;
+        threadIDsToPinnedCount.set(threadID, pinnedCount + 1);
+      }
+    }
+
+    // 7. Include a pinnedCount for each rawThreadInfo
+    const rawThreadInfosWithPinnedCount = rawThreadInfos.map(threadInfo => ({
+      ...threadInfo,
+      pinnedCount: threadIDsToPinnedCount.get(threadInfo.id) || 0,
+    }));
+
+    // 8. Convert rawThreadInfos to a map of threadID to threadInfo
+    const threadIDToThreadInfo = rawThreadInfosWithPinnedCount.reduce(
+      (acc, threadInfo) => {
+        acc[threadInfo.id] = threadInfo;
+        return acc;
+      },
+      {},
+    );
+
+    // 9. Add threadPermission to each threadInfo
+    const rawThreadInfosWithThreadPermission =
+      persistMigrationForManagePinsThreadPermission(threadIDToThreadInfo);
+
+    // 10. Convert the new threadInfos back into an array
+    const rawThreadInfosWithCountAndPermission = Object.keys(
+      rawThreadInfosWithThreadPermission,
+    ).map(id => rawThreadInfosWithThreadPermission[id]);
+
+    // 11. Translate `RawThreadInfo`s to `ClientDBThreadInfo`s.
+    const convertedClientDBThreadInfos =
+      rawThreadInfosWithCountAndPermission.map(
+        convertRawThreadInfoToClientDBThreadInfo,
+      );
+
+    // 12. Construct `ClientDBThreadStoreOperation`s to clear SQLite `threads`
+    //    table and repopulate with `ClientDBThreadInfo`s.
+    const operations: $ReadOnlyArray<ClientDBThreadStoreOperation> = [
+      {
+        type: 'remove_all',
+      },
+      ...convertedClientDBThreadInfos.map((thread: ClientDBThreadInfo) => ({
+        type: 'replace',
+        payload: thread,
+      })),
+    ];
+
+    // 13. Try processing `ClientDBThreadStoreOperation`s and log out if
+    //    `processThreadStoreOperationsSync(...)` throws an exception.
+    try {
+      commCoreModule.processThreadStoreOperationsSync(operations);
+    } catch (exception) {
+      console.log(exception);
+      return { ...state, cookie: null };
+    }
+
+    return state;
+  },
 };
 
 // After migration 31, we'll no longer want to persist `messageStore.messages`
@@ -471,7 +584,7 @@
     'storeLoaded',
   ],
   debug: __DEV__,
-  version: 35,
+  version: 36,
   transforms: [messageStoreMessagesBlocklistTransform],
   migrate: (createMigrate(migrations, { debug: __DEV__ }): any),
   timeout: ((__DEV__ ? 0 : undefined): number | void),