diff --git a/lib/shared/message-utils.js b/lib/shared/message-utils.js
--- a/lib/shared/message-utils.js
+++ b/lib/shared/message-utils.js
@@ -41,7 +41,7 @@
   RawReactionMessageInfo,
   ReactionMessageInfo,
 } from '../types/messages/reaction.js';
-import { type ThreadInfo } from '../types/thread-types.js';
+import type { ThreadInfo } from '../types/thread-types.js';
 import type { UserInfos } from '../types/user-types.js';
 import {
   type EntityText,
@@ -665,8 +665,24 @@
   );
 }
 
-function isInvalidPinSource(message: RawMessageInfo | MessageInfo): boolean {
-  return !messageSpecs[message.type].canBePinned;
+// Prefer checking isInvalidPinSourceForThread below. This function doesn't
+// check whether the user is attempting to pin a SIDEBAR_SOURCE in the context
+// of its parent thread, so it's not suitable for permission checks. We only
+// use it in the message-fetchers.js code where we don't have access to the
+// RawThreadInfo and don't need to do permission checks.
+function isInvalidPinSource(
+  messageInfo: RawMessageInfo | MessageInfo,
+): boolean {
+  return !messageSpecs[messageInfo.type].canBePinned;
+}
+
+function isInvalidPinSourceForThread(
+  messageInfo: RawMessageInfo | MessageInfo,
+  threadInfo: ThreadInfo,
+): boolean {
+  const isValidPinSource = !isInvalidPinSource(messageInfo);
+  const isFirstMessageInSidebar = threadInfo.sourceMessageID === messageInfo.id;
+  return !isValidPinSource || isFirstMessageInSidebar;
 }
 
 function isUnableToBeRenderedIndependently(
@@ -706,5 +722,6 @@
   useNextLocalID,
   isInvalidSidebarSource,
   isInvalidPinSource,
+  isInvalidPinSourceForThread,
   isUnableToBeRenderedIndependently,
 };
diff --git a/lib/utils/toggle-pin-utils.js b/lib/utils/toggle-pin-utils.js
new file mode 100644
--- /dev/null
+++ b/lib/utils/toggle-pin-utils.js
@@ -0,0 +1,25 @@
+// @flow
+
+import { isInvalidPinSourceForThread } from '../shared/message-utils.js';
+import { threadHasPermission } from '../shared/thread-utils.js';
+import type {
+  ComposableMessageInfo,
+  RobotextMessageInfo,
+} from '../types/message-types.js';
+import { threadPermissions } from '../types/thread-permission-types.js';
+import type { ThreadInfo } from '../types/thread-types.js';
+
+function canToggleMessagePin(
+  messageInfo: ComposableMessageInfo | RobotextMessageInfo,
+  threadInfo: ThreadInfo,
+): boolean {
+  const isValidMessage = !isInvalidPinSourceForThread(messageInfo, threadInfo);
+  const hasManagePinsPermission = threadHasPermission(
+    threadInfo,
+    threadPermissions.MANAGE_PINS,
+  );
+
+  return isValidMessage && hasManagePinsPermission;
+}
+
+export { canToggleMessagePin };
diff --git a/native/chat/message-results-screen.react.js b/native/chat/message-results-screen.react.js
--- a/native/chat/message-results-screen.react.js
+++ b/native/chat/message-results-screen.react.js
@@ -9,7 +9,7 @@
 import { messageListData } from 'lib/selectors/chat-selectors.js';
 import {
   createMessageInfo,
-  isInvalidPinSource,
+  isInvalidPinSourceForThread,
 } from 'lib/shared/message-utils.js';
 import type { ThreadInfo } from 'lib/types/thread-types.js';
 import { useServerCall } from 'lib/utils/action-utils.js';
@@ -77,7 +77,7 @@
       item =>
         item.itemType === 'message' &&
         item.isPinned &&
-        !isInvalidPinSource(item.messageInfo),
+        !isInvalidPinSourceForThread(item.messageInfo, threadInfo),
     );
 
     // By the nature of using messageListData and passing in
@@ -102,7 +102,7 @@
     }
 
     return sortedChatMessageInfoItems.filter(Boolean);
-  }, [chatMessageInfos, rawMessageResults]);
+  }, [chatMessageInfos, rawMessageResults, threadInfo]);
 
   const measureCallback = React.useCallback(
     (listDataWithHeights: $ReadOnlyArray<ChatMessageItemWithHeight>) => {
diff --git a/native/chat/message.react.js b/native/chat/message.react.js
--- a/native/chat/message.react.js
+++ b/native/chat/message.react.js
@@ -8,6 +8,7 @@
 } from 'react-native';
 
 import { messageKey } from 'lib/shared/message-utils.js';
+import { canToggleMessagePin } from 'lib/utils/toggle-pin-utils.js';
 
 import type { ChatNavigationProp } from './chat.react.js';
 import MultimediaMessage from './multimedia-message.react.js';
@@ -91,6 +92,11 @@
     [focused, item],
   );
 
+  const canTogglePins = React.useMemo(
+    () => canToggleMessagePin(props.item.messageInfo, props.item.threadInfo),
+    [props.item.messageInfo, props.item.threadInfo],
+  );
+
   const innerMessageNode = React.useMemo(() => {
     if (item.messageShapeType === 'text') {
       return (
@@ -101,6 +107,7 @@
           focused={focused}
           toggleFocus={toggleFocus}
           verticalBounds={verticalBounds}
+          canTogglePins={canTogglePins}
           shouldDisplayPinIndicator={shouldDisplayPinIndicator}
         />
       );
@@ -111,6 +118,7 @@
           focused={focused}
           toggleFocus={toggleFocus}
           verticalBounds={verticalBounds}
+          canTogglePins={canTogglePins}
           shouldDisplayPinIndicator={shouldDisplayPinIndicator}
         />
       );
@@ -134,6 +142,7 @@
     shouldDisplayPinIndicator,
     toggleFocus,
     verticalBounds,
+    canTogglePins,
   ]);
 
   const message = React.useMemo(
diff --git a/native/chat/multimedia-message.react.js b/native/chat/multimedia-message.react.js
--- a/native/chat/multimedia-message.react.js
+++ b/native/chat/multimedia-message.react.js
@@ -9,13 +9,9 @@
 import * as React from 'react';
 import { View } from 'react-native';
 
-import { isInvalidPinSource, messageKey } from 'lib/shared/message-utils.js';
-import {
-  threadHasPermission,
-  useCanCreateSidebarFromMessage,
-} from 'lib/shared/thread-utils.js';
+import { messageKey } from 'lib/shared/message-utils.js';
+import { useCanCreateSidebarFromMessage } from 'lib/shared/thread-utils.js';
 import type { MediaInfo } from 'lib/types/media-types.js';
-import { threadPermissions } from 'lib/types/thread-permission-types.js';
 
 import ComposedMessage from './composed-message.react.js';
 import { InnerMultimediaMessage } from './inner-multimedia-message.react.js';
@@ -45,6 +41,7 @@
   +focused: boolean,
   +toggleFocus: (messageKey: string) => void,
   +verticalBounds: ?VerticalBounds,
+  +canTogglePins: boolean,
   +shouldDisplayPinIndicator: boolean,
 };
 type Props = {
@@ -54,7 +51,6 @@
   +overlayContext: ?OverlayContextType,
   +chatContext: ?ChatContextType,
   +canCreateSidebarFromMessage: boolean,
-  +canTogglePins: boolean,
 };
 type State = {
   +clickable: boolean,
@@ -242,13 +238,6 @@
       props.item.threadInfo,
       props.item.messageInfo,
     );
-    const canTogglePins =
-      !isInvalidPinSource(props.item.messageInfo) &&
-      threadHasPermission(
-        props.item.threadInfo,
-        threadPermissions.MANAGE_PINS,
-      ) &&
-      props.item.threadInfo.sourceMessageID !== props.item.messageInfo.id;
 
     return (
       <MultimediaMessage
@@ -258,7 +247,6 @@
         overlayContext={overlayContext}
         chatContext={chatContext}
         canCreateSidebarFromMessage={canCreateSidebarFromMessage}
-        canTogglePins={canTogglePins}
       />
     );
   });
diff --git a/native/chat/text-message.react.js b/native/chat/text-message.react.js
--- a/native/chat/text-message.react.js
+++ b/native/chat/text-message.react.js
@@ -4,7 +4,7 @@
 import * as React from 'react';
 import { View } from 'react-native';
 
-import { isInvalidPinSource, messageKey } from 'lib/shared/message-utils.js';
+import { messageKey } from 'lib/shared/message-utils.js';
 import {
   threadHasPermission,
   useCanCreateSidebarFromMessage,
@@ -51,6 +51,7 @@
   +focused: boolean,
   +toggleFocus: (messageKey: string) => void,
   +verticalBounds: ?VerticalBounds,
+  +canTogglePins: boolean,
   +shouldDisplayPinIndicator: boolean,
 };
 type Props = {
@@ -65,7 +66,6 @@
   +isLinkModalActive: boolean,
   +isUserProfileBottomSheetActive: boolean,
   +canEditMessage: boolean,
-  +canTogglePins: boolean,
 };
 class TextMessage extends React.PureComponent<Props> {
   message: ?React.ElementRef<typeof View>;
@@ -291,14 +291,6 @@
       useCanEditMessageNative(props.item.threadInfo, props.item.messageInfo) &&
       !isThisMessageEdited;
 
-    const canTogglePins =
-      !isInvalidPinSource(props.item.messageInfo) &&
-      threadHasPermission(
-        props.item.threadInfo,
-        threadPermissions.MANAGE_PINS,
-      ) &&
-      props.item.threadInfo.sourceMessageID !== props.item.messageInfo.id;
-
     React.useEffect(() => clearMarkdownContextData, [clearMarkdownContextData]);
 
     return (
@@ -310,7 +302,6 @@
         isLinkModalActive={isLinkModalActive}
         isUserProfileBottomSheetActive={isUserProfileBottomSheetActive}
         canEditMessage={canEditMessage}
-        canTogglePins={canTogglePins}
       />
     );
   });
diff --git a/web/modals/chat/message-results-modal.react.js b/web/modals/chat/message-results-modal.react.js
--- a/web/modals/chat/message-results-modal.react.js
+++ b/web/modals/chat/message-results-modal.react.js
@@ -11,7 +11,7 @@
 import { createLoadingStatusSelector } from 'lib/selectors/loading-selectors.js';
 import {
   createMessageInfo,
-  isInvalidPinSource,
+  isInvalidPinSourceForThread,
   modifyItemForResultScreen,
 } from 'lib/shared/message-utils.js';
 import { type ThreadInfo } from 'lib/types/thread-types.js';
@@ -80,7 +80,7 @@
       item =>
         item.itemType === 'message' &&
         item.isPinned &&
-        !isInvalidPinSource(item.messageInfo),
+        !isInvalidPinSourceForThread(item.messageInfo, threadInfo),
     );
 
     // By the nature of using messageListData and passing in
@@ -105,7 +105,7 @@
     }
 
     return sortedChatMessageInfoItems;
-  }, [chatMessageInfos, rawMessageResults]);
+  }, [chatMessageInfos, rawMessageResults, threadInfo]);
 
   const modifiedItems = React.useMemo(
     () =>
diff --git a/web/utils/tooltip-action-utils.js b/web/utils/tooltip-action-utils.js
--- a/web/utils/tooltip-action-utils.js
+++ b/web/utils/tooltip-action-utils.js
@@ -10,10 +10,7 @@
   ChatMessageInfoItem,
 } from 'lib/selectors/chat-selectors.js';
 import { useCanEditMessage } from 'lib/shared/edit-messages-utils.js';
-import {
-  createMessageReply,
-  isInvalidPinSource,
-} from 'lib/shared/message-utils.js';
+import { createMessageReply } from 'lib/shared/message-utils.js';
 import { useCanCreateReactionFromMessage } from 'lib/shared/reaction-utils.js';
 import {
   threadHasPermission,
@@ -23,6 +20,7 @@
 import { threadPermissions } from 'lib/types/thread-permission-types.js';
 import type { ThreadInfo } from 'lib/types/thread-types.js';
 import { longAbsoluteDate } from 'lib/utils/date-utils.js';
+import { canToggleMessagePin } from 'lib/utils/toggle-pin-utils.js';
 
 import {
   type MessageTooltipAction,
@@ -274,10 +272,7 @@
   const { pushModal } = useModalContext();
   const { messageInfo, isPinned } = item;
 
-  const canTogglePin =
-    !isInvalidPinSource(messageInfo) &&
-    threadHasPermission(threadInfo, threadPermissions.MANAGE_PINS) &&
-    threadInfo.sourceMessageID !== item.messageInfo.id;
+  const canTogglePin = canToggleMessagePin(messageInfo, threadInfo);
 
   const inputState = React.useContext(InputStateContext);