diff --git a/static/js/copy_and_paste.js b/static/js/copy_and_paste.js
index 3f42fec6b2..4e28680d61 100644
--- a/static/js/copy_and_paste.js
+++ b/static/js/copy_and_paste.js
@@ -88,83 +88,15 @@ function copy_handler() {
     //
     // * Otherwise, we want to copy the bodies of all messages that
     //   were partially covered by the selection.
-    //
-    // Firefox and Chrome handle selection of multiple messages
-    // differently. Firefox typically creates multiple ranges for the
-    // selection, whereas Chrome typically creates just one.
-    //
-    // Our goal in the below loop is to compute and be prepared to
-    // analyze the combined range of the selections, and copy their
-    // full content.
 
     var selection = window.getSelection();
-    var i;
-    var range;
-    var ranges = [];
-    var startc;
-    var endc;
-    var initial_end_tr;
-    var start_id;
-    var end_id;
-    var start_data;
-    var end_data;
-    // skip_same_td_check is true whenever we know for a fact that the
-    // selection covers multiple messages (and thus we should no
-    // longer consider letting the browser handle the copy event).
-    var skip_same_td_check = false;
+    var analysis = exports.analyze_selection(selection);
+    var ranges = analysis.ranges;
+    var start_id = analysis.start_id;
+    var end_id = analysis.end_id;
+    var skip_same_td_check = analysis.skip_same_td_check;
     var div = $('
');
 
-    for (i = 0; i < selection.rangeCount; i += 1) {
-        range = selection.getRangeAt(i);
-        ranges.push(range);
-
-        startc = $(range.startContainer);
-        start_data = find_boundary_tr($(startc.parents('.selectable_row, .message_header')[0]), function (row) {
-            return row.next();
-        });
-        if (start_data === undefined) {
-            // Skip any selection sections that don't intersect a message.
-            continue;
-        }
-        if (start_id === undefined) {
-            // start_id is the Zulip message ID of the first message
-            // touched by the selection.
-            start_id = start_data[0];
-        }
-
-        endc = $(range.endContainer);
-        // If the selection ends in the bottom whitespace, we should
-        // act as though the selection ends on the final message.
-        // This handles the issue that Chrome seems to like selecting
-        // the compose_close button when you go off the end of the
-        // last message
-        if (endc.attr('id') === "bottom_whitespace" || endc.attr('id') === "compose_close") {
-            initial_end_tr = $(".message_row:last");
-            // The selection goes off the end of the message feed, so
-            // this is a multi-message selection.
-            skip_same_td_check = true;
-        } else {
-            initial_end_tr = $(endc.parents('.selectable_row')[0]);
-        }
-        end_data = find_boundary_tr(initial_end_tr, function (row) {
-            return row.prev();
-        });
-
-        if (end_data === undefined) {
-            // Skip any selection sections that don't intersect a message.
-            continue;
-        }
-        if (end_data[0] !== undefined) {
-            end_id = end_data[0];
-        }
-
-        if (start_data[1] || end_data[1]) {
-            // If the find_boundary_tr call for either the first or
-            // the last message covered by the selection
-            skip_same_td_check = true;
-        }
-    }
-
     if (start_id === undefined || end_id === undefined) {
         // In this case either the starting message or the ending
         // message is not defined, so this is definitely not a
@@ -236,6 +168,92 @@ function copy_handler() {
     }, 0);
 }
 
+exports.analyze_selection = function (selection) {
+    // Here we analyze our selection to determine if part of a message
+    // or multiple messages are selected.
+    //
+    // Firefox and Chrome handle selection of multiple messages
+    // differently. Firefox typically creates multiple ranges for the
+    // selection, whereas Chrome typically creates just one.
+    //
+    // Our goal in the below loop is to compute and be prepared to
+    // analyze the combined range of the selections, and copy their
+    // full content.
+
+    var i;
+    var range;
+    var ranges = [];
+    var startc;
+    var endc;
+    var initial_end_tr;
+    var start_id;
+    var end_id;
+    var start_data;
+    var end_data;
+    // skip_same_td_check is true whenever we know for a fact that the
+    // selection covers multiple messages (and thus we should no
+    // longer consider letting the browser handle the copy event).
+    var skip_same_td_check = false;
+
+    for (i = 0; i < selection.rangeCount; i += 1) {
+        range = selection.getRangeAt(i);
+        ranges.push(range);
+
+        startc = $(range.startContainer);
+        start_data = find_boundary_tr($(startc.parents('.selectable_row, .message_header')[0]), function (row) {
+            return row.next();
+        });
+        if (start_data === undefined) {
+            // Skip any selection sections that don't intersect a message.
+            continue;
+        }
+        if (start_id === undefined) {
+            // start_id is the Zulip message ID of the first message
+            // touched by the selection.
+            start_id = start_data[0];
+        }
+
+        endc = $(range.endContainer);
+        // If the selection ends in the bottom whitespace, we should
+        // act as though the selection ends on the final message.
+        // This handles the issue that Chrome seems to like selecting
+        // the compose_close button when you go off the end of the
+        // last message
+        if (endc.attr('id') === "bottom_whitespace" || endc.attr('id') === "compose_close") {
+            initial_end_tr = $(".message_row:last");
+            // The selection goes off the end of the message feed, so
+            // this is a multi-message selection.
+            skip_same_td_check = true;
+        } else {
+            initial_end_tr = $(endc.parents('.selectable_row')[0]);
+        }
+        end_data = find_boundary_tr(initial_end_tr, function (row) {
+            return row.prev();
+        });
+
+        if (end_data === undefined) {
+            // Skip any selection sections that don't intersect a message.
+            continue;
+        }
+        if (end_data[0] !== undefined) {
+            end_id = end_data[0];
+        }
+
+        if (start_data[1] || end_data[1]) {
+            // If the find_boundary_tr call for either the first or
+            // the last message covered by the selection
+            skip_same_td_check = true;
+        }
+    }
+
+    return {
+        ranges: ranges,
+        start_id: start_id,
+        end_id: end_id,
+        skip_same_td_check: skip_same_td_check,
+    };
+};
+
 exports.paste_handler_converter = function (paste_html) {
     var converters = {
         converters: [