Repository: zulip/zulip

Issue:
Add modals to introduce views
We should add one-time modals to explain some Zulip views the first time that a user enters the view.

## Inbox 

Text to use (no header):

> The **Inbox** view provides an overview of your conversations with unread messages. In Zulip, a conversation is either a [direct message](https://zulip.com/help/direct-messages) thread (one-on-one or with a group), or a [topic in a stream](https://zulip.com/help/streams-and-topics). The colored bars show where each conversation took place.

> Click on a conversation you're interested in to view it. You can return to your **Inbox** when done to select the next conversation.

> Tip: **Inbox** is your home view, so use `Esc` to come back any time. [if home view and Esc navigation to home view is enabled]

[Continue] <- action button; dismisses modal

## Recent conversations
> The **Recent conversations** view provides an overview of all the ongoing conversations. In Zulip, a conversation is a [direct message](https://zulip.com/help/direct-messages) thread (one-on-one or with a group), or a [topic in a stream](https://zulip.com/help/streams-and-topics).

> Click on a conversation you're interested in to view it. You can return to your **Recent conversations** when done to select the next conversation.

> Tip: **Recent conversations** is your home view, so use `Esc` to come back any time. [if home view and Esc navigation to home view is enabled]

[Continue] <- action button; dismisses modal


Discussion:
Hello @zulip/server-onboarding members, this issue was labeled with the "area: onboarding" label, so you may want to check it out!

<!-- areaLabelAddition -->

@alya can u explain more about the purpose of this modal ?

Resolving patch:
diff --git a/tools/lib/capitalization.py b/tools/lib/capitalization.py
--- a/tools/lib/capitalization.py
+++ b/tools/lib/capitalization.py
@@ -16,6 +16,7 @@
     r"Cookie Bot",
     r"DevAuthBackend",
     r"DSN",
+    r"Esc",
     r"GCM",
     r"GitHub",
     r"Gravatar",
@@ -23,6 +24,7 @@
     r"HTTP",
     r"ID",
     r"IDs",
+    r"Inbox",
     r"IP",
     r"JSON",
     r"Kerberos",
@@ -31,6 +33,7 @@
     r"Markdown",
     r"OTP",
     r"Pivotal",
+    r"Recent conversations",
     r"DM",
     r"DMs",
     r"Slack",
diff --git a/zerver/lib/hotspots.py b/zerver/lib/hotspots.py
--- a/zerver/lib/hotspots.py
+++ b/zerver/lib/hotspots.py
@@ -83,6 +83,12 @@ def to_dict(self) -> Dict[str, str]:
     OneTimeNotice(
         name="visibility_policy_banner",
     ),
+    OneTimeNotice(
+        name="intro_inbox_view_modal",
+    ),
+    OneTimeNotice(
+        name="intro_recent_view_modal",
+    ),
 ]
 
 # We would most likely implement new hotspots in the future that aren't
diff --git a/zerver/migrations/0501_mark_introduce_zulip_view_modals_as_read.py b/zerver/migrations/0501_mark_introduce_zulip_view_modals_as_read.py
new file mode 100644
--- /dev/null
+++ b/zerver/migrations/0501_mark_introduce_zulip_view_modals_as_read.py
@@ -0,0 +1,75 @@
+# Generated by Django 4.2.10 on 2024-03-11 04:43
+
+from django.db import connection, migrations
+from django.db.backends.base.schema import BaseDatabaseSchemaEditor
+from django.db.migrations.state import StateApps
+from django.db.models import Q
+from django.utils.timezone import now as timezone_now
+from psycopg2.sql import SQL
+
+
+def mark_introduce_zulip_view_modals_as_read(
+    apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
+) -> None:
+    with connection.cursor() as cursor:
+        cursor.execute(
+            SQL(
+                "SELECT MAX(id) FROM zerver_userprofile WHERE is_bot = False AND is_mirror_dummy = False;"
+            )
+        )
+        (max_id,) = cursor.fetchone()
+
+    if max_id is None:
+        return
+
+    BATCH_SIZE = 10000
+    lower_id_bound = 0
+    timestamp_value = timezone_now()
+    while lower_id_bound < max_id:
+        upper_id_bound = min(lower_id_bound + BATCH_SIZE, max_id)
+        with connection.cursor() as cursor:
+            query = SQL("""
+                INSERT INTO zerver_onboardingstep (user_id, onboarding_step, timestamp)
+                SELECT id, step, %(timestamp_value)s
+                FROM zerver_userprofile
+                CROSS JOIN UNNEST(%(step_names)s) step
+                WHERE is_bot = False
+                AND is_mirror_dummy = False
+                AND id > %(lower_id_bound)s AND id <= %(upper_id_bound)s;
+                """)
+            cursor.execute(
+                query,
+                {
+                    "timestamp_value": timestamp_value,
+                    "step_names": ["intro_inbox_view_modal", "intro_recent_view_modal"],
+                    "lower_id_bound": lower_id_bound,
+                    "upper_id_bound": upper_id_bound,
+                },
+            )
+
+        print(f"Processed {upper_id_bound} / {max_id}")
+        lower_id_bound = lower_id_bound + BATCH_SIZE
+
+
+def mark_introduce_zulip_view_modals_as_unread(
+    apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
+) -> None:
+    OnboardingStep = apps.get_model("zerver", "OnboardingStep")
+
+    OnboardingStep.objects.filter(
+        Q(onboarding_step="intro_inbox_view_modal") | Q(onboarding_step="intro_recent_view_modal")
+    ).delete()
+
+
+class Migration(migrations.Migration):
+    atomic = False
+    dependencies = [
+        ("zerver", "0500_realm_zulip_update_announcements_stream"),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            mark_introduce_zulip_view_modals_as_read,
+            reverse_code=mark_introduce_zulip_view_modals_as_unread,
+        ),
+    ]


Test patch:
diff --git a/zerver/tests/test_hotspots.py b/zerver/tests/test_hotspots.py
--- a/zerver/tests/test_hotspots.py
+++ b/zerver/tests/test_hotspots.py
@@ -39,9 +39,11 @@ def test_some_done_some_not(self) -> None:
         do_mark_onboarding_step_as_read(self.user, "intro_streams")
         do_mark_onboarding_step_as_read(self.user, "intro_compose")
         onboarding_steps = get_next_onboarding_steps(self.user)
-        self.assert_length(onboarding_steps, 2)
+        self.assert_length(onboarding_steps, 4)
         self.assertEqual(onboarding_steps[0]["name"], "visibility_policy_banner")
-        self.assertEqual(onboarding_steps[1]["name"], "intro_topics")
+        self.assertEqual(onboarding_steps[1]["name"], "intro_inbox_view_modal")
+        self.assertEqual(onboarding_steps[2]["name"], "intro_recent_view_modal")
+        self.assertEqual(onboarding_steps[3]["name"], "intro_topics")
 
     def test_all_onboarding_steps_done(self) -> None:
         with self.settings(TUTORIAL_ENABLED=True):