import re
from typing import List, Match, Tuple

from bs4 import BeautifulSoup

# The phrases in this list will be ignored. The longest phrase is
# tried first; this removes the chance of smaller phrases changing
# the text before longer phrases are tried.
# The errors shown by `tools/check-capitalization` can be added to
# this list without any modification.
IGNORED_PHRASES = [
    # Proper nouns and acronyms
    r"API",
    r"APNS",
    r"Botserver",
    r"Cookie Bot",
    r"DevAuthBackend",
    r"GCM",
    r"GitHub",
    r"Gravatar",
    r"Help Center",
    r"HTTP",
    r"ID",
    r"IDs",
    r"IP",
    r"JSON",
    r"Kerberos",
    r"LDAP",
    r"Markdown",
    r"OTP",
    r"Pivotal",
    r"PM",
    r"PMs",
    r"Slack",
    r"Google",
    r"Terms of Service",
    r"Tuesday",
    r"URL",
    r"UUID",
    r"Webathena",
    r"WordPress",
    r"Zephyr",
    r"Zoom",
    r"Zulip",
    r"Zulip Server",
    r"Zulip Account Security",
    r"Zulip Security",
    r"Zulip Cloud Standard",
    r"BigBlueButton",
    # Code things
    r"\.zuliprc",
    # BeautifulSoup will remove <z-user> which is horribly confusing,
    # so we need more of the sentence.
    r"<z-user></z-user> will have the same role",
    # Things using "I"
    r"I understand",
    r"I'm",
    r"I've",
    # Specific short words
    r"beta",
    r"and",
    r"bot",
    r"e\.g\.",
    r"enabled",
    r"signups",
    # Placeholders
    r"keyword",
    r"streamname",
    r"user@example\.com",
    # Fragments of larger strings
    (r"your subscriptions on your Streams page"),
    r"Add global time<br />Everyone sees global times in their own time zone\.",
    r"user",
    r"an unknown operating system",
    r"Go to Settings",
    # SPECIAL CASES
    # Because topics usually are lower-case, this would look weird if it were capitalized
    r"more topics",
    # Used alone in a parenthetical where capitalized looks worse.
    r"^deprecated$",
    # Capital 'i' looks weird in reminders popover
    r"in 1 hour",
    r"in 20 minutes",
    r"in 3 hours",
    # these are used as topics
    r"^new streams$",
    r"^stream events$",
    # These are used as example short names (e.g. an uncapitalized context):
    r"^marketing$",
    r"^cookie$",
    # Used to refer custom time limits
    r"\bN\b",
    # Capital c feels obtrusive in clear status option
    r"clear",
    r"group private messages with \{recipient\}",
    r"private messages with \{recipient\}",
    r"private messages with yourself",
    r"GIF",
    # Emoji name placeholder
    r"leafy green vegetable",
    # Subdomain placeholder
    r"your-organization-url",
    # Used in invite modal
    r"or",
    # Used in GIPHY popover.
    r"GIFs",
    r"GIPHY",
    # Used in our case studies
    r"Technical University of Munich",
    r"University of California San Diego",
    # Used in stream creation form
    r"email hidden",
    # Use in compose box.
    r"to send",
    r"to add a new line",
    # Used in showing Notification Bot read receipts message
    "Notification Bot",
    # Used in presence_enabled setting label
    r"invisible mode off",
    # Typeahead suggestions for "Pronouns" custom field type.
    r"he/him",
    r"she/her",
    r"they/them",
]

# Sort regexes in descending order of their lengths. As a result, the
# longer phrases will be ignored first.
IGNORED_PHRASES.sort(key=lambda regex: len(regex), reverse=True)

# Compile regexes to improve performance. This also extracts the
# text using BeautifulSoup and then removes extra whitespaces from
# it. This step enables us to add HTML in our regexes directly.
COMPILED_IGNORED_PHRASES = [
    re.compile(" ".join(BeautifulSoup(regex, "lxml").text.split())) for regex in IGNORED_PHRASES
]

SPLIT_BOUNDARY = "?.!"  # Used to split string into sentences.
SPLIT_BOUNDARY_REGEX = re.compile(rf"[{SPLIT_BOUNDARY}]")

# Regexes which check capitalization in sentences.
DISALLOWED = [
    r"^[a-z](?!\})",  # Checks if the sentence starts with a lower case character.
    r"^[A-Z][a-z]+[\sa-z0-9]+[A-Z]",  # Checks if an upper case character exists
    # after a lower case character when the first character is in upper case.
]
DISALLOWED_REGEX = re.compile(r"|".join(DISALLOWED))

BANNED_WORDS = {
    "realm": "The term realm should not appear in user-facing strings. Use organization instead.",
}


def get_safe_phrase(phrase: str) -> str:
    """
    Safe phrase is in lower case and doesn't contain characters which can
    conflict with split boundaries. All conflicting characters are replaced
    with low dash (_).
    """
    phrase = SPLIT_BOUNDARY_REGEX.sub("_", phrase)
    return phrase.lower()


def replace_with_safe_phrase(matchobj: Match[str]) -> str:
    """
    The idea is to convert IGNORED_PHRASES into safe phrases, see
    `get_safe_phrase()` function. The only exception is when the
    IGNORED_PHRASE is at the start of the text or after a split
    boundary; in this case, we change the first letter of the phrase
    to upper case.
    """
    ignored_phrase = matchobj.group(0)
    safe_string = get_safe_phrase(ignored_phrase)

    start_index = matchobj.start()
    complete_string = matchobj.string

    is_string_start = start_index == 0
    # We expect that there will be one space between split boundary
    # and the next word.
    punctuation = complete_string[max(start_index - 2, 0)]
    is_after_split_boundary = punctuation in SPLIT_BOUNDARY
    if is_string_start or is_after_split_boundary:
        return safe_string.capitalize()

    return safe_string


def get_safe_text(text: str) -> str:
    """
    This returns text which is rendered by BeautifulSoup and is in the
    form that can be split easily and has all IGNORED_PHRASES processed.
    """
    soup = BeautifulSoup(text, "lxml")
    text = " ".join(soup.text.split())  # Remove extra whitespaces.
    for phrase_regex in COMPILED_IGNORED_PHRASES:
        text = phrase_regex.sub(replace_with_safe_phrase, text)

    return text


def is_capitalized(safe_text: str) -> bool:
    sentences = SPLIT_BOUNDARY_REGEX.split(safe_text)
    return not any(DISALLOWED_REGEX.search(sentence.strip()) for sentence in sentences)


def check_banned_words(text: str) -> List[str]:
    lower_cased_text = text.lower()
    errors = []
    for word, reason in BANNED_WORDS.items():
        if word in lower_cased_text:
            # Hack: Should move this into BANNED_WORDS framework; for
            # now, just hand-code the skips:
            if "realm_name" in lower_cased_text:
                continue
            kwargs = dict(word=word, text=text, reason=reason)
            msg = "{word} found in '{text}'. {reason}".format(**kwargs)
            errors.append(msg)

    return errors


def check_capitalization(strings: List[str]) -> Tuple[List[str], List[str], List[str]]:
    errors = []
    ignored = []
    banned_word_errors = []
    for text in strings:
        text = " ".join(text.split())  # Remove extra whitespaces.
        safe_text = get_safe_text(text)
        has_ignored_phrase = text != safe_text
        capitalized = is_capitalized(safe_text)
        if not capitalized:
            errors.append(text)
        elif has_ignored_phrase:
            ignored.append(text)

        banned_word_errors.extend(check_banned_words(text))

    return sorted(errors), sorted(ignored), sorted(banned_word_errors)

tools/lib/capitalization.py
import re
from typing import List, Match, Tuple

from bs4 import BeautifulSoup

# The phrases in this list will be ignored. The longest phrase is
# tried first; this removes the chance of smaller phrases changing
# the text before longer phrases are tried.
# The errors shown by `tools/check-capitalization` can be added to
# this list without any modification.
IGNORED_PHRASES = [
    # Proper nouns and acronyms
    r"API",
    r"APNS",
    r"Botserver",
    r"Cookie Bot",
    r"DevAuthBackend",
    r"GCM",
    r"GitHub",
    r"Gravatar",
    r"Help Center",
    r"HTTP",
    r"ID",
    r"IDs",
    r"IP",
    r"JSON",
    r"Kerberos",
    r"LDAP",
    r"Markdown",
    r"OTP",
    r"Pivotal",
    r"PM",
    r"PMs",
    r"Slack",
    r"Google",
    r"Terms of Service",
    r"Tuesday",
    r"URL",
    r"UUID",
    r"Webathena",
    r"WordPress",
    r"Zephyr",
    r"Zoom",
    r"Zulip",
    r"Zulip Server",
    r"Zulip Account Security",
    r"Zulip Security",
    r"Zulip Cloud Standard",
    r"BigBlueButton",
    # Code things
    r"\.zuliprc",
    # BeautifulSoup will remove <z-user> which is horribly confusing,
    # so we need more of the sentence.
    r"<z-user></z-user> will have the same role",
    # Things using "I"
    r"I understand",
    r"I'm",
    r"I've",
    # Specific short words
    r"beta",
    r"and",
    r"bot",
    r"e\.g\.",
    r"enabled",
    r"signups",
    # Placeholders
    r"keyword",
    r"streamname",
    r"user@example\.com",
    # Fragments of larger strings
    (r"your subscriptions on your Streams page"),
    r"Add global time<br />Everyone sees global times in their own time zone\.",
    r"user",
    r"an unknown operating system",
    r"Go to Settings",
    # SPECIAL CASES
    # Because topics usually are lower-case, this would look weird if it were capitalized
    r"more topics",
    # Used alone in a parenthetical where capitalized looks worse.
    r"^deprecated$",
    # We want the similar text in the Private Messages section to have the same capitalization.
    r"more conversations",
    r"back to streams",
    # Capital 'i' looks weird in reminders popover
    r"in 1 hour",
    r"in 20 minutes",
    r"in 3 hours",
    # these are used as topics
    r"^new streams$",
    r"^stream events$",
    # These are used as example short names (e.g. an uncapitalized context):
    r"^marketing$",
    r"^cookie$",
    # Used to refer custom time limits
    r"\bN\b",
    # Capital c feels obtrusive in clear status option
    r"clear",
    r"group private messages with \{recipient\}",
    r"private messages with \{recipient\}",
    r"private messages with yourself",
    r"GIF",
    # Emoji name placeholder
    r"leafy green vegetable",
    # Subdomain placeholder
    r"your-organization-url",
    # Used in invite modal
    r"or",
    # Used in GIPHY popover.
    r"GIFs",
    r"GIPHY",
    # Used in our case studies
    r"Technical University of Munich",
    r"University of California San Diego",
    # Used in stream creation form
    r"email hidden",
    # Use in compose box.
    r"to send",
    r"to add a new line",
    # Used in showing Notification Bot read receipts message
    "Notification Bot",
    # Used in presence_enabled setting label
    r"invisible mode off",
    # Typeahead suggestions for "Pronouns" custom field type.
    r"he/him",
    r"she/her",
    r"they/them",
]

# Sort regexes in descending order of their lengths. As a result, the
# longer phrases will be ignored first.
IGNORED_PHRASES.sort(key=lambda regex: len(regex), reverse=True)

# Compile regexes to improve performance. This also extracts the
# text using BeautifulSoup and then removes extra whitespaces from
# it. This step enables us to add HTML in our regexes directly.
COMPILED_IGNORED_PHRASES = [
    re.compile(" ".join(BeautifulSoup(regex, "lxml").text.split())) for regex in IGNORED_PHRASES
]

SPLIT_BOUNDARY = "?.!"  # Used to split string into sentences.
SPLIT_BOUNDARY_REGEX = re.compile(rf"[{SPLIT_BOUNDARY}]")

# Regexes which check capitalization in sentences.
DISALLOWED = [
    r"^[a-z](?!\})",  # Checks if the sentence starts with a lower case character.
    r"^[A-Z][a-z]+[\sa-z0-9]+[A-Z]",  # Checks if an upper case character exists
    # after a lower case character when the first character is in upper case.
]
DISALLOWED_REGEX = re.compile(r"|".join(DISALLOWED))

BANNED_WORDS = {
    "realm": "The term realm should not appear in user-facing strings. Use organization instead.",
}


def get_safe_phrase(phrase: str) -> str:
    """
    Safe phrase is in lower case and doesn't contain characters which can
    conflict with split boundaries. All conflicting characters are replaced
    with low dash (_).
    """
    phrase = SPLIT_BOUNDARY_REGEX.sub("_", phrase)
    return phrase.lower()


def replace_with_safe_phrase(matchobj: Match[str]) -> str:
    """
    The idea is to convert IGNORED_PHRASES into safe phrases, see
    `get_safe_phrase()` function. The only exception is when the
    IGNORED_PHRASE is at the start of the text or after a split
    boundary; in this case, we change the first letter of the phrase
    to upper case.
    """
    ignored_phrase = matchobj.group(0)
    safe_string = get_safe_phrase(ignored_phrase)

    start_index = matchobj.start()
    complete_string = matchobj.string

    is_string_start = start_index == 0
    # We expect that there will be one space between split boundary
    # and the next word.
    punctuation = complete_string[max(start_index - 2, 0)]
    is_after_split_boundary = punctuation in SPLIT_BOUNDARY
    if is_string_start or is_after_split_boundary:
        return safe_string.capitalize()

    return safe_string


def get_safe_text(text: str) -> str:
    """
    This returns text which is rendered by BeautifulSoup and is in the
    form that can be split easily and has all IGNORED_PHRASES processed.
    """
    soup = BeautifulSoup(text, "lxml")
    text = " ".join(soup.text.split())  # Remove extra whitespaces.
    for phrase_regex in COMPILED_IGNORED_PHRASES:
        text = phrase_regex.sub(replace_with_safe_phrase, text)

    return text


def is_capitalized(safe_text: str) -> bool:
    sentences = SPLIT_BOUNDARY_REGEX.split(safe_text)
    return not any(DISALLOWED_REGEX.search(sentence.strip()) for sentence in sentences)


def check_banned_words(text: str) -> List[str]:
    lower_cased_text = text.lower()
    errors = []
    for word, reason in BANNED_WORDS.items():
        if word in lower_cased_text:
            # Hack: Should move this into BANNED_WORDS framework; for
            # now, just hand-code the skips:
            if "realm_name" in lower_cased_text:
                continue
            kwargs = dict(word=word, text=text, reason=reason)
            msg = "{word} found in '{text}'. {reason}".format(**kwargs)
            errors.append(msg)

    return errors


def check_capitalization(strings: List[str]) -> Tuple[List[str], List[str], List[str]]:
    errors = []
    ignored = []
    banned_word_errors = []
    for text in strings:
        text = " ".join(text.split())  # Remove extra whitespaces.
        safe_text = get_safe_text(text)
        has_ignored_phrase = text != safe_text
        capitalized = is_capitalized(safe_text)
        if not capitalized:
            errors.append(text)
        elif has_ignored_phrase:
            ignored.append(text)

        banned_word_errors.extend(check_banned_words(text))

    return sorted(errors), sorted(ignored), sorted(banned_word_errors)

tools/lib/capitalization.py
diff --git a/frontend_tests/node_tests/dispatch.js b/frontend_tests/node_tests/dispatch.js
index d35f691150ea5..d6161ebf463b3 100644
--- a/frontend_tests/node_tests/dispatch.js
+++ b/frontend_tests/node_tests/dispatch.js
@@ -40,6 +40,7 @@ const message_lists = mock_esm("../../static/js/message_lists");
 const muted_topics_ui = mock_esm("../../static/js/muted_topics_ui");
 const muted_users_ui = mock_esm("../../static/js/muted_users_ui");
 const notifications = mock_esm("../../static/js/notifications");
+const pm_list = mock_esm("../../static/js/pm_list");
 const reactions = mock_esm("../../static/js/reactions");
 const realm_icon = mock_esm("../../static/js/realm_icon");
 const realm_logo = mock_esm("../../static/js/realm_logo");
@@ -1020,6 +1021,7 @@ run_test("user_status", ({override}) => {
     {
         const stub = make_stub();
         override(activity, "redraw_user", stub.f);
+        override(pm_list, "update_private_messages", noop);
         dispatch(event);
         assert.equal(stub.num_calls, 1);
         const args = stub.get_args("user_id");
diff --git a/frontend_tests/node_tests/pm_list.js b/frontend_tests/node_tests/pm_list.js
index daba91ef7df55..68aba53f7badd 100644
--- a/frontend_tests/node_tests/pm_list.js
+++ b/frontend_tests/node_tests/pm_list.js
@@ -17,14 +17,16 @@ run_test("update_dom_with_unread_counts", () => {
     assert.equal(narrow_state.active(), true);
 
     const $total_count = $.create("total-count-stub");
-    const $private_li = $(".top_left_private_messages .private_messages_header");
+    const $private_li = $(
+        ".private_messages_container #private_messages_section #private_messages_section_header",
+    );
     $private_li.set_find_results(".unread_count", $total_count);
 
     counts = {
         private_message_count: 10,
     };
 
-    pm_list.update_dom_with_unread_counts(counts);
+    pm_list.set_count(counts.private_message_count);
     assert.equal($total_count.text(), "10");
     assert.ok($total_count.visible());
 
@@ -32,7 +34,7 @@ run_test("update_dom_with_unread_counts", () => {
         private_message_count: 0,
     };
 
-    pm_list.update_dom_with_unread_counts(counts);
+    pm_list.set_count(counts.private_message_count);
     assert.equal($total_count.text(), "");
     assert.ok(!$total_count.visible());
 });
diff --git a/frontend_tests/node_tests/recent_topics.js b/frontend_tests/node_tests/recent_topics.js
index c04a649998ac4..5b490380bf784 100644
--- a/frontend_tests/node_tests/recent_topics.js
+++ b/frontend_tests/node_tests/recent_topics.js
@@ -107,6 +107,10 @@ const narrow = mock_esm("../../static/js/narrow", {
     handle_middle_pane_transition: noop,
     has_shown_message_list_view: true,
 });
+mock_esm("../../static/js/pm_list", {
+    update_private_messages: noop,
+    handle_narrow_deactivated: noop,
+});
 mock_esm("../../static/js/popovers", {
     any_active: () => false,
 });
diff --git a/frontend_tests/node_tests/stream_list.js b/frontend_tests/node_tests/stream_list.js
index 905d4e4142605..3c81b39a14e63 100644
--- a/frontend_tests/node_tests/stream_list.js
+++ b/frontend_tests/node_tests/stream_list.js
@@ -409,6 +409,7 @@ test_ui("narrowing", ({mock_template}) => {
     topic_list.rebuild = noop;
     topic_list.active_stream_id = noop;
     topic_list.get_stream_li = noop;
+    $("#streams_header").outerHeight = () => 0;
 
     assert.ok(!$("<devel-sidebar-row-stub>").hasClass("active-filter"));
 
@@ -700,6 +701,7 @@ test_ui("refresh_pin", ({override, override_rewire, mock_template}) => {
 
     override_rewire(stream_list, "update_count_in_dom", noop);
     $("#stream_filters").append = noop;
+    $("#streams_header").outerHeight = () => 0;
 
     let scrolled;
     override(scroll_util, "scroll_element_into_container", ($li) => {
diff --git a/frontend_tests/node_tests/vdom.js b/frontend_tests/node_tests/vdom.js
index 83f7dda6a3347..3a6aeec4d843c 100644
--- a/frontend_tests/node_tests/vdom.js
+++ b/frontend_tests/node_tests/vdom.js
@@ -26,7 +26,7 @@ run_test("basics", () => {
 
 run_test("attribute escaping", () => {
     // So far most of the time our attributes are
-    // hard-coded classes like "expanded_private_messages",
+    // hard-coded classes like "pm-list",
     // but we need to be defensive about future code
     // that might use data from possibly malicious users.
     const opts = {
diff --git a/frontend_tests/puppeteer_tests/compose.ts b/frontend_tests/puppeteer_tests/compose.ts
index c9790e9e8abbb..00e6a6d3b530a 100644
--- a/frontend_tests/puppeteer_tests/compose.ts
+++ b/frontend_tests/puppeteer_tests/compose.ts
@@ -113,7 +113,7 @@ async function test_narrow_to_private_messages_with_cordelia(page: Page): Promis
         you_and_cordelia_selector,
     );
     const cordelia_user_id = await common.get_user_id_from_name(page, "Cordelia, Lear's daughter");
-    const pm_list_selector = `li[data-user-ids-string="${cordelia_user_id}"].expanded_private_message.active-sub-filter`;
+    const pm_list_selector = `li[data-user-ids-string="${cordelia_user_id}"].pm-list-item.active-sub-filter`;
     await page.waitForSelector(pm_list_selector, {visible: true});
     await close_compose_box(page);
 
diff --git a/frontend_tests/puppeteer_tests/message-basics.ts b/frontend_tests/puppeteer_tests/message-basics.ts
index 7066190d6e2b3..685002cd7df4b 100644
--- a/frontend_tests/puppeteer_tests/message-basics.ts
+++ b/frontend_tests/puppeteer_tests/message-basics.ts
@@ -280,7 +280,9 @@ async function test_narrow_by_clicking_the_left_sidebar(page: Page): Promise<voi
     await page.click(".top_left_all_messages a");
     await expect_home(page);
 
-    await page.click(".top_left_private_messages a");
+    const all_private_messages_icon = "#show_all_private_messages";
+    await page.waitForSelector(all_private_messages_icon, {visible: true});
+    await page.click(all_private_messages_icon);
     await expect_all_pm(page);
 
     await un_narrow(page);
diff --git a/frontend_tests/puppeteer_tests/navigation.ts b/frontend_tests/puppeteer_tests/navigation.ts
index eb12cbb8d231b..a80a4304b395c 100644
--- a/frontend_tests/puppeteer_tests/navigation.ts
+++ b/frontend_tests/puppeteer_tests/navigation.ts
@@ -64,6 +64,16 @@ async function navigate_to_subscriptions(page: Page): Promise<void> {
     await page.waitForSelector("#subscription_overlay", {hidden: true});
 }
 
+async function navigate_to_private_messages(page: Page): Promise<void> {
+    console.log("Navigate to private messages");
+
+    const all_private_messages_icon = "#show_all_private_messages";
+    await page.waitForSelector(all_private_messages_icon, {visible: true});
+    await page.click(all_private_messages_icon);
+
+    await page.waitForSelector("#message_view_header .fa-envelope", {visible: true});
+}
+
 async function test_reload_hash(page: Page): Promise<void> {
     const initial_page_load_time = await page.evaluate(
         (): number => zulip_test.page_params.page_load_time,
@@ -99,7 +109,7 @@ async function navigation_tests(page: Page): Promise<void> {
     await navigate_to_subscriptions(page);
     await navigate_using_left_sidebar(page, "all_messages", "message_feed_container");
     await navigate_to_settings(page);
-    await navigate_using_left_sidebar(page, "narrow/is/private", "message_feed_container");
+    await navigate_to_private_messages(page);
     await navigate_to_subscriptions(page);
     await navigate_using_left_sidebar(page, verona_narrow, "message_feed_container");
 
diff --git a/static/js/click_handlers.js b/static/js/click_handlers.js
index 3cb75c7be23ad..0e109e9f14350 100644
--- a/static/js/click_handlers.js
+++ b/static/js/click_handlers.js
@@ -32,6 +32,7 @@ import * as notifications from "./notifications";
 import * as overlays from "./overlays";
 import {page_params} from "./page_params";
 import * as people from "./people";
+import * as pm_list from "./pm_list";
 import * as popovers from "./popovers";
 import * as reactions from "./reactions";
 import * as recent_topics_ui from "./recent_topics_ui";
@@ -759,6 +760,47 @@ export function initialize() {
         stream_list.toggle_filter_displayed(e);
     });
 
+    $("body").on(
+        "click",
+        ".private_messages_container.zoom-out #private_messages_section_header",
+        (e) => {
+            if (e.target.classList.value === "fa fa-align-right") {
+                // Let the browser handle the "all private messages" widget.
+                return;
+            }
+
+            e.preventDefault();
+            e.stopPropagation();
+            const $left_sidebar_scrollbar = $(
+                "#left_sidebar_scroll_container .simplebar-content-wrapper",
+            );
+            const scroll_position = $left_sidebar_scrollbar.scrollTop();
+
+            // This next bit of logic is a bit subtle; this header
+            // button scrolls to the top of the private messages
+            // section is uncollapsed but out of view; otherwise, we
+            // toggle its collapsed state.
+            if (scroll_position === 0 || pm_list.is_private_messages_collapsed()) {
+                pm_list.toggle_private_messages_section();
+            }
+            $left_sidebar_scrollbar.scrollTop(0);
+        },
+    );
+
+    /* The PRIVATE MESSAGES label's click behavior is complicated;
+     * only when zoomed in does it have a navigation effect, so we need
+     * this click handler rather than just a link. */
+    $("body").on(
+        "click",
+        ".private_messages_container.zoom-in #private_messages_section_header",
+        (e) => {
+            e.preventDefault();
+            e.stopPropagation();
+
+            window.location.hash = "narrow/is/private";
+        },
+    );
+
     // WEBATHENA
 
     $("body").on("click", ".webathena_login", (e) => {
diff --git a/static/js/pm_list.js b/static/js/pm_list.js
index a8d28e77e120a..d99900ac474fa 100644
--- a/static/js/pm_list.js
+++ b/static/js/pm_list.js
@@ -1,107 +1,192 @@
 import $ from "jquery";
+import _ from "lodash";
 
-import * as narrow_state from "./narrow_state";
-import * as people from "./people";
 import * as pm_list_data from "./pm_list_data";
 import * as pm_list_dom from "./pm_list_dom";
+import * as resize from "./resize";
 import * as ui from "./ui";
 import * as ui_util from "./ui_util";
 import * as vdom from "./vdom";
 
 let prior_dom;
-let private_messages_open = false;
 
 // This module manages the "Private messages" section in the upper
 // left corner of the app.  This was split out from stream_list.js.
 
-function get_filter_li() {
-    return $(".top_left_private_messages .private_messages_header");
-}
+let private_messages_collapsed = false;
+
+// The private messages section can be zoomed in to view more messages.
+// This keeps track of if we're zoomed in or not.
+let zoomed = false;
 
-function set_count(count) {
-    ui_util.update_unread_count_in_dom(get_filter_li(), count);
+function get_private_messages_section_header() {
+    return $(
+        ".private_messages_container #private_messages_section #private_messages_section_header",
+    );
 }
 
-function remove_expanded_private_messages() {
-    ui.get_content_element($("#private-container")).empty();
+export function set_count(count) {
+    ui_util.update_unread_count_in_dom(get_private_messages_section_header(), count);
 }
 
-export function close() {
-    private_messages_open = false;
-    prior_dom = undefined;
-    remove_expanded_private_messages();
+function close() {
+    private_messages_collapsed = true;
+    $("#toggle_private_messages_section_icon").removeClass("fa-caret-down");
+    $("#toggle_private_messages_section_icon").addClass("fa-caret-right");
+
+    update_private_messages();
 }
 
 export function _build_private_messages_list() {
     const conversations = pm_list_data.get_conversations();
-    const dom_ast = pm_list_dom.pm_ul(conversations);
+    const pm_list_info = pm_list_data.get_list_info(zoomed);
+    const conversations_to_be_shown = pm_list_info.conversations_to_be_shown;
+    const more_conversations_unread_count = pm_list_info.more_conversations_unread_count;
+
+    const pm_list_nodes = conversations_to_be_shown.map((conversation) =>
+        pm_list_dom.keyed_pm_li(conversation),
+    );
+
+    const all_conversations_shown = conversations_to_be_shown.length === conversations.length;
+    if (!all_conversations_shown) {
+        pm_list_nodes.push(
+            pm_list_dom.more_private_conversations_li(more_conversations_unread_count),
+        );
+    }
+    const dom_ast = pm_list_dom.pm_ul(pm_list_nodes);
     return dom_ast;
 }
 
-export function update_private_messages() {
-    if (!narrow_state.active()) {
-        return;
+function set_dom_to(new_dom) {
+    const $container = ui.get_content_element($("#private_messages_list"));
+
+    function replace_content(html) {
+        $container.html(html);
     }
 
-    if (private_messages_open) {
-        const $container = ui.get_content_element($("#private-container"));
-        const new_dom = _build_private_messages_list();
+    function find() {
+        return $container.find("ul");
+    }
 
-        function replace_content(html) {
-            $container.html(html);
-        }
+    vdom.update(replace_content, find, new_dom, prior_dom);
+    prior_dom = new_dom;
+}
 
-        function find() {
-            return $container.find("ul");
+export function update_private_messages() {
+    if (private_messages_collapsed) {
+        // In the collapsed state, we will still display the current
+        // conversation, to preserve the UI invariant that there's
+        // always something highlighted in the left sidebar.
+        const conversations = pm_list_data.get_conversations();
+        const active_conversation = conversations.find((conversation) => conversation.is_active);
+
+        if (active_conversation) {
+            const node = [pm_list_dom.keyed_pm_li(active_conversation)];
+            const new_dom = pm_list_dom.pm_ul(node);
+            set_dom_to(new_dom);
+        } else {
+            // Otherwise, empty the section.
+            $(".pm-list").empty();
+            prior_dom = undefined;
         }
-
-        vdom.update(replace_content, find, new_dom, prior_dom);
-        prior_dom = new_dom;
+    } else {
+        const new_dom = _build_private_messages_list();
+        set_dom_to(new_dom);
     }
+    // Make sure to update the left sidebar heights after updating PMs.
+    setTimeout(resize.resize_stream_filters_container, 0);
 }
 
 export function expand() {
-    private_messages_open = true;
+    private_messages_collapsed = false;
+    $("#toggle_private_messages_section_icon").addClass("fa-caret-down");
+    $("#toggle_private_messages_section_icon").removeClass("fa-caret-right");
     update_private_messages();
-    if (pm_list_data.is_all_privates()) {
-        $(".top_left_private_messages").addClass("active-filter");
-    }
 }
 
 export function update_dom_with_unread_counts(counts) {
+    // In theory, we could support passing the counts object through
+    // to pm_list_data, rather than fetching it directly there. But
+    // it's not an important optimization, because it's unlikely a
+    // user would have 10,000s of unread PMs where it could matter.
     update_private_messages();
+    // This is just the global unread count.
     set_count(counts.private_message_count);
 }
 
-function should_expand_pm_list(filter) {
-    const op_is = filter.operands("is");
-
-    if (op_is.length >= 1 && op_is.includes("private")) {
-        return true;
-    }
+export function highlight_all_private_messages_view() {
+    $(".private_messages_container").addClass("active_private_messages_section");
+}
 
-    const op_pm = filter.operands("pm-with");
+function unhighlight_all_private_messages_view() {
+    $(".private_messages_container").removeClass("active_private_messages_section");
+}
 
-    if (op_pm.length !== 1) {
-        return false;
+export function handle_narrow_activated(filter) {
+    const active_filter = filter;
+    const is_all_private_message_view = _.isEqual(active_filter.sorted_term_types(), [
+        "is-private",
+    ]);
+    const narrow_to_private_messages_section = active_filter.operands("pm-with").length !== 0;
+
+    if (is_all_private_message_view) {
+        highlight_all_private_messages_view();
+    } else {
+        unhighlight_all_private_messages_view();
     }
+    if (narrow_to_private_messages_section) {
+        update_private_messages();
+    }
+}
 
-    const emails_strings = op_pm[0];
-    const emails = emails_strings.split(",");
-
-    const has_valid_emails = people.is_valid_bulk_emails_for_compose(emails);
+export function handle_narrow_deactivated() {
+    // Since one can renarrow via the keyboard shortcut or similar, we
+    // avoid disturbing the zoomed state here.
+    unhighlight_all_private_messages_view();
+    update_private_messages();
+}
 
-    return has_valid_emails;
+export function is_private_messages_collapsed() {
+    return private_messages_collapsed;
 }
 
-export function handle_narrow_activated(filter) {
-    if (should_expand_pm_list(filter)) {
+export function toggle_private_messages_section() {
+    // change the state of PM section depending on the previous state.
+    if (private_messages_collapsed) {
         expand();
     } else {
         close();
     }
 }
 
-export function handle_narrow_deactivated() {
-    close();
+function zoom_in() {
+    zoomed = true;
+    update_private_messages();
+    $(".private_messages_container").removeClass("zoom-out").addClass("zoom-in");
+    $("#streams_list").hide();
+    $(".left-sidebar .right-sidebar-items").hide();
+}
+
+function zoom_out() {
+    zoomed = false;
+    update_private_messages();
+    $(".private_messages_container").removeClass("zoom-in").addClass("zoom-out");
+    $("#streams_list").show();
+    $(".left-sidebar .right-sidebar-items").show();
+}
+
+export function initialize() {
+    $(".private_messages_container").on("click", "#show_more_private_messages", (e) => {
+        e.stopPropagation();
+        e.preventDefault();
+
+        zoom_in();
+    });
+
+    $(".private_messages_container").on("click", "#hide_more_private_messages", (e) => {
+        e.stopPropagation();
+        e.preventDefault();
+
+        zoom_out();
+    });
 }
diff --git a/static/js/pm_list_dom.js b/static/js/pm_list_dom.js
index e3617237bb217..f633b74f15053 100644
--- a/static/js/pm_list_dom.js
+++ b/static/js/pm_list_dom.js
@@ -1,5 +1,6 @@
 import _ from "lodash";
 
+import render_more_private_conversations from "../templates/more_pms.hbs";
 import render_pm_list_item from "../templates/pm_list_item.hbs";
 
 import * as vdom from "./vdom";
@@ -19,13 +20,34 @@ export function keyed_pm_li(conversation) {
     };
 }
 
-export function pm_ul(conversations) {
+export function more_private_conversations_li(more_conversations_unread_count) {
+    const render = () => render_more_private_conversations({more_conversations_unread_count});
+
+    // Used in vdom.js to check if an element has changed and needs to
+    // be updated in the DOM.
+    const eq = (other) =>
+        other.more_items &&
+        more_conversations_unread_count === other.more_conversations_unread_count;
+
+    // This special key must be impossible as a user_ids_string.
+    const key = "more_private_conversations";
+
+    return {
+        key,
+        more_items: true,
+        more_conversations_unread_count,
+        render,
+        eq,
+    };
+}
+
+export function pm_ul(nodes) {
     const attrs = [
-        ["class", "expanded_private_messages"],
+        ["class", "pm-list"],
         ["data-name", "private"],
     ];
     return vdom.ul({
         attrs,
-        keyed_nodes: conversations.map((conversation) => keyed_pm_li(conversation)),
+        keyed_nodes: nodes,
     });
 }
diff --git a/static/js/recent_topics_ui.js b/static/js/recent_topics_ui.js
index 3683b87503e51..9356cf63279bc 100644
--- a/static/js/recent_topics_ui.js
+++ b/static/js/recent_topics_ui.js
@@ -20,6 +20,7 @@ import * as narrow_state from "./narrow_state";
 import * as navigate from "./navigate";
 import {page_params} from "./page_params";
 import * as people from "./people";
+import * as pm_list from "./pm_list";
 import * as recent_senders from "./recent_senders";
 import {get, process_message, topics} from "./recent_topics_data";
 import {
@@ -767,6 +768,7 @@ export function show() {
     narrow.set_narrow_title(recent_topics_title);
     message_view_header.render_title_area();
     narrow.handle_middle_pane_transition();
+    pm_list.handle_narrow_deactivated();
 
     complete_rerender();
 }
diff --git a/static/js/resize.js b/static/js/resize.js
index aaaa7823b5676..52dd3fa67eb19 100644
--- a/static/js/resize.js
+++ b/static/js/resize.js
@@ -56,7 +56,7 @@ function get_new_heights() {
         Number.parseInt($(".narrows_panel").css("marginTop"), 10) -
         Number.parseInt($(".narrows_panel").css("marginBottom"), 10) -
         $("#global_filters").safeOuterHeight(true) -
-        $("#streams_header").safeOuterHeight(true);
+        $("#private_messages_sticky_header").safeOuterHeight(true);
 
     // Don't let us crush the stream sidebar completely out of view
     res.stream_filters_max_height = Math.max(80, res.stream_filters_max_height);
@@ -100,10 +100,9 @@ function left_userlist_get_new_heights() {
         Number.parseInt($(".narrows_panel").css("marginTop"), 10) -
         Number.parseInt($(".narrows_panel").css("marginBottom"), 10) -
         $("#global_filters").safeOuterHeight(true) -
-        $("#streams_header").safeOuterHeight(true) -
         $("#userlist-header").safeOuterHeight(true) -
         $("#user_search_section").safeOuterHeight(true) -
-        Number.parseInt($stream_filters.css("marginBottom"), 10);
+        $("#private_messages_sticky_header").safeOuterHeight(true);
 
     const blocks = [
         {
@@ -211,7 +210,7 @@ export function resize_bottom_whitespace(h) {
 export function resize_stream_filters_container(h) {
     h = narrow_window ? left_userlist_get_new_heights() : get_new_heights();
     resize_bottom_whitespace(h);
-    $("#stream-filters-container").css("max-height", h.stream_filters_max_height);
+    $("#left_sidebar_scroll_container").css("max-height", h.stream_filters_max_height);
 }
 
 export function resize_sidebars() {
@@ -248,7 +247,7 @@ export function resize_sidebars() {
     const h = narrow_window ? left_userlist_get_new_heights() : get_new_heights();
 
     $("#buddy_list_wrapper").css("max-height", h.buddy_list_wrapper_max_height);
-    $("#stream-filters-container").css("max-height", h.stream_filters_max_height);
+    $("#left_sidebar_scroll_container").css("max-height", h.stream_filters_max_height);
 
     return h;
 }
diff --git a/static/js/scroll_util.js b/static/js/scroll_util.js
index f81ae30a2201e..7275068121149 100644
--- a/static/js/scroll_util.js
+++ b/static/js/scroll_util.js
@@ -20,20 +20,21 @@ export function scroll_delta(opts) {
     return delta;
 }
 
-export function scroll_element_into_container($elem, $container) {
+export function scroll_element_into_container($elem, $container, sticky_header_height = 0) {
     // This does the minimum amount of scrolling that is needed to make
     // the element visible.  It doesn't try to center the element, so
     // this will be non-intrusive to users when they already have
     // the element visible.
 
     $container = ui.get_scroll_element($container);
-    const elem_top = $elem.position().top;
+    const elem_top = $elem.position().top - sticky_header_height;
     const elem_bottom = elem_top + $elem.innerHeight();
+    const container_height = $container.height() - sticky_header_height;
 
     const opts = {
         elem_top,
         elem_bottom,
-        container_height: $container.height(),
+        container_height,
     };
 
     const delta = scroll_delta(opts);
diff --git a/static/js/stream_list.js b/static/js/stream_list.js
index 936211ff95246..cbf55cd303289 100644
--- a/static/js/stream_list.js
+++ b/static/js/stream_list.js
@@ -14,6 +14,7 @@ import * as keydown_util from "./keydown_util";
 import {ListCursor} from "./list_cursor";
 import * as narrow from "./narrow";
 import * as narrow_state from "./narrow_state";
+import * as pm_list from "./pm_list";
 import * as popovers from "./popovers";
 import * as resize from "./resize";
 import * as scroll_util from "./scroll_util";
@@ -268,9 +269,16 @@ export function zoom_in_topics(options) {
             $elt.hide();
         }
     });
+
+    // we also need to hide the PM section and allow
+    // stream list to take complete left-sidebar in zoomedIn view.
+    $(".private_messages_container").hide();
 }
 
 export function zoom_out_topics() {
+    // Show PM section
+    $(".private_messages_container").show();
+
     // Show stream list titles and pinned stream splitter
     $(".stream-filters-label").each(function () {
         $(this).show();
@@ -599,16 +607,33 @@ export function set_event_handlers() {
             toggle_filter_displayed(e);
         });
 
+    function toggle_pm_header_icon() {
+        if (pm_list.is_private_messages_collapsed()) {
+            return;
+        }
+
+        const scroll_position = $(
+            "#left_sidebar_scroll_container .simplebar-content-wrapper",
+        ).scrollTop();
+        const pm_list_height = $("#private_messages_list").height();
+        if (scroll_position > pm_list_height) {
+            $("#toggle_private_messages_section_icon").addClass("fa-caret-right");
+            $("#toggle_private_messages_section_icon").removeClass("fa-caret-down");
+        } else {
+            $("#toggle_private_messages_section_icon").addClass("fa-caret-down");
+            $("#toggle_private_messages_section_icon").removeClass("fa-caret-right");
+        }
+    }
+
     // check for user scrolls on streams list for first time
-    ui.get_scroll_element($("#stream-filters-container")).on("scroll", function () {
+    ui.get_scroll_element($("#left_sidebar_scroll_container")).on("scroll", () => {
         has_scrolled = true;
-        // remove listener once user has scrolled
-        $(this).off("scroll");
+        toggle_pm_header_icon();
     });
 
     stream_cursor = new ListCursor({
         list: {
-            scroll_container_sel: "#stream-filters-container",
+            scroll_container_sel: "#left_sidebar_scroll_container",
             find_li(opts) {
                 const stream_id = opts.key;
                 const li = get_stream_li(stream_id);
@@ -722,14 +747,14 @@ export function toggle_filter_displayed(e) {
 }
 
 export function scroll_stream_into_view($stream_li) {
-    const $container = $("#stream-filters-container");
+    const $container = $("#left_sidebar_scroll_container");
 
     if ($stream_li.length !== 1) {
         blueslip.error("Invalid stream_li was passed in");
         return;
     }
-
-    scroll_util.scroll_element_into_container($stream_li, $container);
+    const stream_header_height = $("#streams_header").outerHeight();
+    scroll_util.scroll_element_into_container($stream_li, $container, stream_header_height);
 }
 
 export function maybe_scroll_narrow_into_view() {
diff --git a/static/js/tippyjs.js b/static/js/tippyjs.js
index 3fb9928f7fcb9..c30fbae13e158 100644
--- a/static/js/tippyjs.js
+++ b/static/js/tippyjs.js
@@ -222,7 +222,8 @@ export function initialize() {
     delegate("body", {
         target: [
             ".recipient_bar_icon",
-            ".sidebar-title",
+            "#streams_header .sidebar-title",
+            "#userlist-title",
             "#user_filter_icon",
             "#scroll-to-bottom-button-clickable-area",
             ".code_external_link",
@@ -396,4 +397,39 @@ export function initialize() {
             }
         },
     });
+
+    delegate("body", {
+        target: "#pm_tooltip_container",
+        onShow(instance) {
+            if ($(".private_messages_container").hasClass("zoom-in")) {
+                return false;
+            }
+
+            if ($("#toggle_private_messages_section_icon").hasClass("fa-caret-down")) {
+                instance.setContent(
+                    $t({
+                        defaultMessage: "Collapse private messages",
+                    }),
+                );
+            } else {
+                instance.setContent($t({defaultMessage: "Expand private messages"}));
+            }
+            return true;
+        },
+        delay: [500, 20],
+        appendTo: () => document.body,
+    });
+
+    delegate("body", {
+        target: "#show_all_private_messages",
+        placement: "bottom",
+        onShow(instance) {
+            instance.setContent(
+                $t({
+                    defaultMessage: "All private messages (P)",
+                }),
+            );
+        },
+        appendTo: () => document.body,
+    });
 }
diff --git a/static/js/top_left_corner.js b/static/js/top_left_corner.js
index fa4731ff49b47..87fb8eacbccd7 100644
--- a/static/js/top_left_corner.js
+++ b/static/js/top_left_corner.js
@@ -1,6 +1,5 @@
 import $ from "jquery";
 
-import * as pm_list from "./pm_list";
 import * as resize from "./resize";
 import * as ui_util from "./ui_util";
 import * as unread_ui from "./unread_ui";
@@ -27,9 +26,8 @@ function remove($elem) {
     $elem.removeClass("active-filter active-sub-filter");
 }
 
-function deselect_top_left_corner_items() {
+export function deselect_top_left_corner_items() {
     remove($(".top_left_all_messages"));
-    remove($(".top_left_private_messages"));
     remove($(".top_left_starred_messages"));
     remove($(".top_left_mentions"));
     remove($(".top_left_recent_topics"));
@@ -73,11 +71,9 @@ export function handle_narrow_deactivated() {
 
 export function narrow_to_recent_topics() {
     remove($(".top_left_all_messages"));
-    remove($(".top_left_private_messages"));
     remove($(".top_left_starred_messages"));
     remove($(".top_left_mentions"));
     $(".top_left_recent_topics").addClass("active-filter");
-    pm_list.close();
     setTimeout(() => {
         resize.resize_stream_filters_container();
     }, 0);
diff --git a/static/js/topic_list.js b/static/js/topic_list.js
index 3701c6a4619b4..9e5734bf2155c 100644
--- a/static/js/topic_list.js
+++ b/static/js/topic_list.js
@@ -331,7 +331,7 @@ export function zoom_in() {
         active_widget.build();
     }
 
-    ui.get_scroll_element($("#stream-filters-container")).scrollTop(0);
+    ui.get_scroll_element($("#left_sidebar_scroll_container")).scrollTop(0);
 
     const spinner = true;
     active_widget.build(spinner);
diff --git a/static/js/ui_init.js b/static/js/ui_init.js
index f87a098f18c29..14573ba6482c6 100644
--- a/static/js/ui_init.js
+++ b/static/js/ui_init.js
@@ -59,6 +59,7 @@ import * as overlays from "./overlays";
 import {page_params} from "./page_params";
 import * as people from "./people";
 import * as pm_conversations from "./pm_conversations";
+import * as pm_list from "./pm_list";
 import * as popover_menus from "./popover_menus";
 import * as presence from "./presence";
 import * as realm_logo from "./realm_logo";
@@ -677,6 +678,7 @@ export function initialize_everything() {
     unread_ui.initialize();
     activity.initialize();
     emoji_picker.initialize();
+    pm_list.initialize();
     topic_list.initialize();
     topic_zoom.initialize();
     drafts.initialize();
diff --git a/static/styles/dark_theme.css b/static/styles/dark_theme.css
index 011b43849e037..12e69c3a5539a 100644
--- a/static/styles/dark_theme.css
+++ b/static/styles/dark_theme.css
@@ -144,7 +144,9 @@ body.dark-theme {
     .column-right .right-sidebar,
     #groups_overlay .right,
     #subscription_overlay .right,
-    #settings_page .right {
+    #settings_page .right,
+    #streams_header,
+    .private_messages_container {
         background-color: hsl(212, 28%, 18%);
     }
 
@@ -225,6 +227,14 @@ body.dark-theme {
         background-color: hsl(208, 17%, 29%);
     }
 
+    .active_private_messages_section {
+        #private_messages_section,
+        #private_messages_list,
+        #hide_more_private_messages {
+            background-color: hsla(199, 33%, 46%, 0.2);
+        }
+    }
+
     /* do not turn the .message_header .stream_label text dark on hover because they're
        on a dark background, and don't change the dark labels dark either. */
     .message_header:not(.dark_background)
diff --git a/static/styles/left_sidebar.css b/static/styles/left_sidebar.css
index f82e8aa7eb267..4dc33d31b8e58 100644
--- a/static/styles/left_sidebar.css
+++ b/static/styles/left_sidebar.css
@@ -8,6 +8,9 @@ $left_col_size: 19px;
    the above (and another 5px of padding not measured here) */
 $topic_indent: calc($far_left_gutter_size + $left_col_size + 4px);
 $topic_resolve_width: 13px;
+/* Space between section in the left sidebar. */
+$sections_vertical_gutter: 8px;
+$bottom_scrolling_buffer: 25px;
 
 #left-sidebar {
     #user-list {
@@ -50,11 +53,6 @@ $topic_resolve_width: 13px;
     }
 }
 
-.pm_left_col {
-    min-width: $left_col_size;
-    margin-left: 15px;
-}
-
 #stream_filters,
 #global_filters {
     margin-right: 12px;
@@ -72,7 +70,7 @@ li.show-more-topics {
     float: right;
     opacity: 0.5;
     padding: 3px;
-    margin-left: 7px;
+    margin-left: 4px;
 
     &:hover {
         opacity: 1;
@@ -89,7 +87,7 @@ li.show-more-topics {
 }
 
 #streams_inline_icon {
-    margin-right: 10px;
+    margin-right: 8px;
 }
 
 .tooltip {
@@ -106,6 +104,10 @@ li.show-more-topics {
     li {
         a {
             padding: 1px 0;
+
+            &:hover {
+                text-decoration: none;
+            }
         }
 
         ul {
@@ -190,8 +192,8 @@ li.show-more-topics {
     }
 }
 
-#private-container,
-#stream-filters-container {
+#left_sidebar_scroll_container {
+    outline: none;
     overflow-x: hidden;
     overflow-y: auto;
     position: relative;
@@ -199,16 +201,111 @@ li.show-more-topics {
     width: 100%;
 }
 
-#stream-filters-container .simplebar-content-wrapper {
-    outline: none;
+.private_messages_container {
+    background: hsl(0, 0%, 100%);
+    margin-right: 16px;
+    margin-left: 6px;
+    z-index: 1;
+
+    #toggle_private_messages_section_icon {
+        opacity: 0.7;
+        margin-left: -15px;
+        min-width: 12px;
+
+        &.fa-caret-right {
+            position: relative;
+            left: 3px;
+        }
+
+        &:hover {
+            opacity: 1;
+        }
+    }
+
+    #private_messages_section_header {
+        cursor: pointer;
+        padding: 0 10px 1px 4px;
+        white-space: nowrap;
+
+        #show_all_private_messages {
+            right: 0;
+            float: right;
+            position: absolute;
+            opacity: 0.7;
+            text-decoration: none;
+            color: inherit;
+            margin-right: 21px;
+            margin-top: 1px;
+
+            &:hover {
+                opacity: 1;
+            }
+        }
+
+        .unread_count {
+            margin-right: 16px;
+            margin-top: 2px;
+        }
+    }
+
+    ul.pm-list {
+        list-style-type: none;
+        font-weight: 400;
+        margin-left: 0;
+        margin-bottom: 0;
+
+        span.fa-group {
+            font-size: 90%;
+        }
+
+        li.pm-list-item {
+            position: relative;
+            padding: 1px 10px 1px 4px;
+            margin-left: 2px;
+
+            a {
+                text-decoration: none;
+                color: inherit;
+            }
+
+            .pm_left_col {
+                min-width: $left_col_size;
+            }
+        }
+
+        li#show_more_private_messages {
+            cursor: pointer;
+            padding-right: 26px;
+            padding-left: 6px;
+
+            a {
+                font-size: 12px;
+            }
+
+            .unread_count {
+                margin-top: 2px;
+            }
+        }
+    }
 }
 
-#private-container {
-    max-height: 210px;
+.active_private_messages_section {
+    #private_messages_section,
+    #private_messages_list,
+    #hide_more_private_messages {
+        background-color: hsl(202, 56%, 91%);
+    }
+
+    #private_messages_section {
+        border-radius: 4px 4px 0 0;
+    }
 
-    /* Match the opacity for global-filters icons. */
-    span.fa-group {
-        opacity: 0.7;
+    #private_messages_list {
+        border-radius: 0 0 4px 4px;
+    }
+
+    #more_private_messages_sidebar_title {
+        font-weight: 600;
     }
 }
 
@@ -224,7 +321,7 @@ li.show-more-topics {
 #subscribe-to-more-streams {
     text-decoration: none;
     margin: 5px auto 5.5px 10px;
-    margin-bottom: 25px;
+    margin-bottom: $bottom_scrolling_buffer;
 
     i {
         min-width: 19px;
@@ -277,7 +374,7 @@ li.active-sub-filter {
 }
 
 #global_filters {
-    margin-bottom: 16px;
+    margin-bottom: $sections_vertical_gutter;
 
     .filter-icon {
         display: inline-block;
@@ -301,20 +398,12 @@ li.active-sub-filter {
         margin-top: 1px !important;
     }
 
-    .expanded_private_message .unread_count {
-        /* This margin accounts for the fact that the private messages
-           container gets a few pixels taller when expanded  */
-        margin: 0;
-        display: inline;
-    }
-
     i {
         opacity: 0.7;
     }
 }
 
 li.top_left_all_messages,
-.private_messages_header,
 li.top_left_mentions,
 li.top_left_starred_messages,
 li.top_left_drafts,
@@ -333,12 +422,6 @@ li.top_left_recent_topics {
     padding-right: 10px;
 }
 
-.top_left_row,
-.bottom_left_row,
-.top_left_private_messages {
-    border-radius: 4px;
-}
-
 .conversation-partners {
     line-height: 1.25;
 }
@@ -348,12 +431,6 @@ li.top_left_recent_topics {
     font-size: 15px;
 }
 
-.top_left_private_messages i.fa-envelope {
-    position: relative;
-    top: -1px;
-    font-size: 11px;
-}
-
 .top_left_mentions i.fa-at,
 .top_left_starred_messages i.fa-star {
     font-size: 13px;
@@ -479,32 +556,11 @@ ul.topic-list {
     font-weight: normal;
 }
 
-ul.expanded_private_messages {
-    list-style-type: none;
-
-    span.fa-group {
-        font-size: 90%;
-    }
-    font-weight: 400;
-    margin-left: 0;
-    padding-bottom: 2px;
-}
-
 li.topic-list-item {
     position: relative;
     padding-right: 5px;
 }
 
-li.expanded_private_message {
-    position: relative;
-    padding-top: 1px;
-    padding-bottom: 1px;
-
-    a {
-        margin: 1px 0;
-    }
-}
-
 .show-all-streams {
     a {
         color: hsl(0, 0%, 20%);
@@ -525,7 +581,7 @@ li.expanded_private_message {
 }
 
 .pm-box {
-    margin-right: 20px;
+    margin-right: 16px;
     align-items: center;
 
     .user_circle {
@@ -548,6 +604,10 @@ li.expanded_private_message {
     #topics_header {
         display: none;
     }
+
+    .zoom-out-hide {
+        display: none;
+    }
 }
 
 #topics_header {
@@ -560,7 +620,7 @@ li.expanded_private_message {
         text-transform: uppercase;
 
         i {
-            margin: 0 5px 0 10px;
+            margin: 0 6px 0 13px;
             position: relative;
             top: 1px;
         }
@@ -569,9 +629,12 @@ li.expanded_private_message {
 
 #streams_header {
     margin-right: 12px;
-    padding-left: $far_left_gutter_size;
     cursor: pointer;
-    margin-top: 3px;
+    padding: $sections_vertical_gutter 0 3px $far_left_gutter_size;
+    position: sticky;
+    top: 0;
+    background: hsl(0, 0%, 100%);
+    z-index: 1;
 
     input {
         padding-right: 20px;
@@ -643,7 +706,45 @@ li.expanded_private_message {
         display: none;
     }
 
+    &.private_messages_container ul.pm-list {
+        margin-bottom: $bottom_scrolling_buffer;
+    }
+
+    #more_private_messages_sidebar_title {
+        display: inline;
+    }
+
+    #hide_more_private_messages {
+        display: block;
+        text-decoration: none;
+        color: inherit;
+        font-size: 12px;
+
+        span {
+            display: block;
+            padding: 2px 0 2px 4px;
+        }
+
+        &:hover {
+            span {
+                background-color: hsla(120, 12.3%, 71.4%, 0.38);
+                border-radius: 4px;
+            }
+        }
+    }
+
     .zoom-in-hide {
         display: none;
     }
+
+    .zoom-in-sticky {
+        position: sticky;
+        top: 0;
+        z-index: 1;
+        padding: 3px 0 3px $far_left_gutter_size;
+    }
+
+    #show_all_private_messages {
+        margin-right: 5px !important;
+    }
 }
diff --git a/static/styles/zulip.css b/static/styles/zulip.css
index 44a7570591355..90d052b0a2c58 100644
--- a/static/styles/zulip.css
+++ b/static/styles/zulip.css
@@ -18,7 +18,7 @@ go beneath the header.
 */
 $sidebar_top: calc($header_height + $header_padding_bottom);
 
-/* These need to agree with scroll_bar.js */
+$left_sidebar_collapse_widget_gutter: 10px;
 $left_sidebar_width: 270px;
 $right_sidebar_width: 250px;
 
@@ -532,7 +532,7 @@ p.n-margin {
 
     .column-left .left-sidebar {
         width: $left_sidebar_width;
-        padding-left: 0;
+        padding-left: $left_sidebar_collapse_widget_gutter;
     }
 
     .column-right .right-sidebar {
@@ -548,7 +548,9 @@ p.n-margin {
 .column-middle,
 #compose-content {
     margin-right: $right_sidebar_width;
-    margin-left: $left_sidebar_width;
+    margin-left: calc(
+        $left_sidebar_width + $left_sidebar_collapse_widget_gutter
+    );
     position: relative;
 }
 
diff --git a/static/templates/left_sidebar.hbs b/static/templates/left_sidebar.hbs
index 20ba84226580f..c753c72d83090 100644
--- a/static/templates/left_sidebar.hbs
+++ b/static/templates/left_sidebar.hbs
@@ -22,20 +22,6 @@
                     <span>{{t 'Recent conversations' }}</span>
                 </a>
             </li>
-            <li class="top_left_private_messages hidden-for-spectators">
-                <div class="private_messages_header top_left_row" title="{{t 'Private messages' }} (P)">
-                    <a href="#narrow/is/private">
-                        <span class="filter-icon">
-                            <i class="fa fa-envelope" aria-hidden="true"></i>
-                        </span>
-                        {{~!-- squash whitespace --~}}
-                        <span>{{t 'Private messages' }}</span>
-                        <span class="unread_count"></span>
-                    </a>
-                </div>
-                <div id="private-container" class="scrolling_list" data-simplebar>
-                </div>
-            </li>
             <li class="top_left_mentions top_left_row hidden-for-spectators" title="{{t 'Mentions' }}">
                 <a href="#narrow/is/mentioned">
                     <span class="filter-icon">
@@ -69,6 +55,31 @@
                 <span class="arrow drafts-sidebar-menu-icon"><i class="zulip-icon zulip-icon-ellipsis-v-solid" aria-hidden="true"></i></span>
             </li>
         </ul>
+    </div>
+
+    <div id="private_messages_sticky_header" class="private_messages_container zoom-out hidden-for-spectators">
+        <div id="private_messages_section">
+            <div id="private_messages_section_header" class="zoom-out zoom-in-sticky">
+                <span id="pm_tooltip_container">
+                    <i id="toggle_private_messages_section_icon" class="fa fa-sm fa-caret-down toggle_private_messages_section zoom-in-hide" aria-hidden="true"></i>
+                    <h4 class="sidebar-title toggle_private_messages_section">{{t 'PRIVATE MESSAGES' }}</h4>
+                </span>
+                <span class="unread_count"></span>
+                <a id="show_all_private_messages" href="#narrow/is/private">
+                    <i class="fa fa-align-right" aria-label="{{t 'All private messages' }}"></i>
+                </a>
+            </div>
+        </div>
+        <a class="zoom-out-hide" id="hide_more_private_messages">
+            <span> {{t 'back to streams' }}</span>
+        </a>
+    </div>
+    {{~!-- squash whitespace --~}}
+    <div id="left_sidebar_scroll_container" class="scrolling_list" data-simplebar>
+        <div class="private_messages_container zoom-out hidden-for-spectators">
+            <div id="private_messages_list"></div>
+        </div>
+
         <div id="streams_list" class="zoom-out">
             <div id="streams_header" class="zoom-in-hide"><h4 class="sidebar-title" data-tippy-content="{{t 'Filter streams' }} (q)">{{t 'STREAMS' }}</h4>
                 <span class="tippy-zulip-tooltip streams_inline_icon_wrapper hidden-for-spectators" data-tippy-content="{{t 'Add streams' }}">
@@ -85,7 +96,7 @@
             <div id="topics_header">
                 <a class="show-all-streams" tabindex="0"> <i class="fa fa-chevron-left" aria-hidden="true"></i>{{t 'Back to streams' }}</a>
             </div>
-            <div id="stream-filters-container" class="scrolling_list" data-simplebar>
+            <div id="stream-filters-container">
                 <ul id="stream_filters" class="filters"></ul>
                 {{#unless is_guest }}
                     <div id="subscribe-to-more-streams"></div>
diff --git a/static/templates/more_pms.hbs b/static/templates/more_pms.hbs
new file mode 100644
index 0000000000000..1804e9a31f5e9
--- /dev/null
+++ b/static/templates/more_pms.hbs
@@ -0,0 +1,8 @@
+<li id="show_more_private_messages" class="pm-list-item bottom_left_row {{#unless more_conversations_unread_count}}zero-pm-unreads{{/unless}}">
+    <span>
+        <a class="pm-name" tabindex="0">{{t "more conversations" }}</a>
+        <span class="unread_count {{#unless more_conversations_unread_count}}zero_count{{/unless}}">
+            {{more_conversations_unread_count}}
+        </span>
+    </span>
+</li>
diff --git a/static/templates/pm_list_item.hbs b/static/templates/pm_list_item.hbs
index 6c1ce47295d39..5ec4821e6e3c2 100644
--- a/static/templates/pm_list_item.hbs
+++ b/static/templates/pm_list_item.hbs
@@ -1,4 +1,4 @@
-<li class='{{#if is_active}}active-sub-filter{{/if}} {{#if is_zero}}zero-pm-unreads{{/if}} top_left_row expanded_private_message' data-user-ids-string='{{user_ids_string}}'>
+<li class='{{#if is_active}}active-sub-filter{{/if}} {{#if is_zero}}zero-pm-unreads{{/if}} pm-list-item bottom_left_row' data-user-ids-string='{{user_ids_string}}'>
     <span class='pm-box' id='pm_user_status' data-user-ids-string='{{user_ids_string}}' data-is-group='{{is_group}}'>
 
         <div class="pm_left_col">
@@ -18,4 +18,3 @@
         </span>
     </span>
 </li>
-
diff --git a/tools/lib/capitalization.py b/tools/lib/capitalization.py
index b81ceaa8eb66d..d57951da75973 100644
--- a/tools/lib/capitalization.py
+++ b/tools/lib/capitalization.py
@@ -78,6 +78,9 @@
     r"more topics",
     # Used alone in a parenthetical where capitalized looks worse.
     r"^deprecated$",
+    # We want the similar text in the Private Messages section to have the same capitalization.
+    r"more conversations",
+    r"back to streams",
     # Capital 'i' looks weird in reminders popover
     r"in 1 hour",
     r"in 20 minutes",

Create collapsible "Private messages" section in left sidebar
At present, private messages are collapsed in the left sidebar, unless the user is in a private message narrow. This has a few down sides:

1. Getting to a PM conversation generally requires multiple clicks.
2. It's not immediately clear who send you a new private message, which is important for determining whether one needs to read it right away.
3. It can be hard for new users to figure out how to view and send private messages.

In order to address this, we should try making a private messages section in the left sidebar that is open by default. Specifically:

1. Make a Private messages section just above STREAMS in the left sidebar that is open by default.
2. In the new PMs section, use the same algorithm we use for stream topics to decide how many conversations to show.
3. Make the PMs section collapsible, similar to the collapsible sections in #20072. The open/collapsed state should be sticky as the user navigates around Zulip, closes and reopens the window, logs out and in, etc.

Note that this will likely require experimentation for us to get it right. To avoid misdirected effort, please post screenshots in the #design stream on chat.zulip.org for feedback. Also, if (3) can't be implemented quickly, we can test the experience in chat.zulip.org without waiting for it to be completed.

[Prior discussion on CZO](https://chat.zulip.org/#narrow/stream/101-design/topic/private.20messages.20UI/near/1159032).

See also #11108.
