onboarding_steps: Add 'navigation_tour_video' for new users.

This commit adds a one-time modal to display navigation tour
video to new users.

Includes an `NAVIGATION_TOUR_VIDEO_URL` server-setting to specify
the video's URL. When set to None, the modal is not displayed.

Fixes #29304.
This commit is contained in:
Prakhar Pratyush
2024-10-21 16:30:06 +05:30
committed by Tim Abbott
parent 60498701a4
commit 5f3896710f
18 changed files with 406 additions and 21 deletions

View File

@@ -20,6 +20,11 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 10.0
**Feature level 369**
* [`POST /register`](/api/register-queue): Added `navigation_tour_video_url`
to the response.
**Feature level 368**
* [`GET /events`](/api/get-events): An event with `type: "saved_snippet"`

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

View File

@@ -78,6 +78,11 @@ async function realm_creation_tests(page: Page): Promise<void> {
// element of id `lightbox_overlay` exists.
await page.waitForSelector("#lightbox_overlay"); // if element doesn't exist,timeout error raises
// Check if the modal having the onboarding video has been displayed.
await common.wait_for_micromodal_to_open(page);
await page.click("#navigation-tour-video-modal .modal__close");
await common.wait_for_micromodal_to_close(page);
// Updating common.realm_url because we are redirecting to it when logging out.
common.set_realm_url(page.url());
}

View File

@@ -1,18 +1,37 @@
import $ from "jquery";
import assert from "minimalistic-assert";
import type {z} from "zod";
import render_navigation_tour_video_modal from "../templates/navigation_tour_video_modal.hbs";
import * as blueslip from "./blueslip.ts";
import * as channel from "./channel.ts";
import * as dialog_widget from "./dialog_widget.ts";
import {$t, $t_html} from "./i18n.ts";
import * as people from "./people.ts";
import type {NarrowTerm, StateData, onboarding_step_schema} from "./state_data.ts";
import * as util from "./util.ts";
export type OnboardingStep = z.output<typeof onboarding_step_schema>;
export const ONE_TIME_NOTICES_TO_DISPLAY = new Set<string>();
export function post_onboarding_step_as_read(onboarding_step_name: string): void {
export function post_onboarding_step_as_read(
onboarding_step_name: string,
schedule_navigation_tour_video_reminder_delay?: number,
): void {
const data: {onboarding_step: string; schedule_navigation_tour_video_reminder_delay?: number} =
{
onboarding_step: onboarding_step_name,
};
if (schedule_navigation_tour_video_reminder_delay !== undefined) {
assert(onboarding_step_name === "navigation_tour_video");
data.schedule_navigation_tour_video_reminder_delay =
schedule_navigation_tour_video_reminder_delay;
}
void channel.post({
url: "/json/users/me/onboarding_steps",
data: {onboarding_step: onboarding_step_name},
data,
error(err) {
if (err.readyState !== 0) {
blueslip.error(`Failed to mark ${onboarding_step_name} as read.`, {
@@ -57,10 +76,100 @@ function narrow_to_dm_with_welcome_bot_new_user(
}
}
function show_navigation_tour_video(navigation_tour_video_url: string | null): void {
if (ONE_TIME_NOTICES_TO_DISPLAY.has("navigation_tour_video")) {
assert(navigation_tour_video_url !== null);
const html_body = render_navigation_tour_video_modal({
video_src: navigation_tour_video_url,
poster_src: "/static/images/navigation-tour-video-thumbnail.png",
});
let watch_later_clicked = false;
dialog_widget.launch({
html_heading: $t_html({defaultMessage: "Welcome to Zulip!"}),
html_body,
on_click() {
// Do nothing
},
html_submit_button: $t_html({defaultMessage: "Skip video — I'm familiar with Zulip"}),
html_exit_button: $t_html({defaultMessage: "Watch later"}),
close_on_submit: true,
id: "navigation-tour-video-modal",
footer_minor_text: $t({defaultMessage: "Tip: You can watch this video without sound."}),
close_on_overlay_click: false,
post_render() {
const $watch_later_button = $("#navigation-tour-video-modal .dialog_exit_button");
$watch_later_button.on("click", (e) => {
e.preventDefault();
e.stopPropagation();
// Schedule a reminder message for a few hours from now.
const reminder_delay_seconds = 2 * 60 * 60;
post_onboarding_step_as_read("navigation_tour_video", reminder_delay_seconds);
watch_later_clicked = true;
dialog_widget.close();
});
const $skip_video_button = $("#navigation-tour-video-modal .dialog_submit_button");
$skip_video_button
.removeClass("dialog_submit_button")
.addClass("dialog_exit_button");
$skip_video_button.css({"margin-left": "12px"});
const $video = $<HTMLVideoElement>("#navigation-tour-video");
$video.on("play", () => {
// Remove the custom play button overlaying the video.
$("#navigation-tour-video-wrapper").addClass("hide-play-button");
});
let skip_video_button_text_updated = false;
let video_ended_button_visible = false;
$video.on("timeupdate", () => {
const $video_elem = util.the($video);
const current_time = $video_elem.currentTime;
if (!skip_video_button_text_updated && current_time >= 30) {
$skip_video_button.text($t({defaultMessage: "Skip the rest"}));
skip_video_button_text_updated = true;
}
if (video_ended_button_visible && current_time < $video_elem.duration) {
$("#navigation-tour-video-ended-button-wrapper").css(
"visibility",
"hidden",
);
video_ended_button_visible = false;
}
});
$video.on("ended", () => {
$("#navigation-tour-video-ended-button-wrapper").css("visibility", "visible");
video_ended_button_visible = true;
$skip_video_button.css("visibility", "hidden");
$watch_later_button.css("visibility", "hidden");
// Exit fullscreen to make the 'video-ended-button-wrapper' button visible.
const $video_elem = util.the($video);
if (document.fullscreenElement === $video_elem) {
void document.exitFullscreen();
}
});
$("#navigation-tour-video-ended-button").on("click", () => {
dialog_widget.close();
});
},
on_hide() {
if (!watch_later_clicked) {
// $watch_later_button click handler already calls this function.
post_onboarding_step_as_read("navigation_tour_video");
}
},
});
}
}
export function initialize(
params: StateData["onboarding_steps"],
navigation_tour_video_url: StateData["navigation_tour_video_url"],
show_message_view: (raw_terms: NarrowTerm[], opts: {trigger: string}) => void,
): void {
update_onboarding_steps_to_display(params.onboarding_steps);
narrow_to_dm_with_welcome_bot_new_user(params.onboarding_steps, show_message_view);
show_navigation_tour_video(navigation_tour_video_url);
}

View File

@@ -556,8 +556,14 @@ export const state_data_schema = z
.and(z.object({max_message_id: z.number()}).transform((local_message) => ({local_message})))
.and(
z
.object({onboarding_steps: z.array(onboarding_step_schema)})
.transform((onboarding_steps) => ({onboarding_steps})),
.object({
onboarding_steps: z.array(onboarding_step_schema),
navigation_tour_video_url: z.nullable(z.string()),
})
.transform(({onboarding_steps, navigation_tour_video_url}) => ({
onboarding_steps: {onboarding_steps},
navigation_tour_video_url,
})),
)
.and(current_user_schema.transform((current_user) => ({current_user})))
.and(realm_schema.transform((realm) => ({realm})));

View File

@@ -688,7 +688,11 @@ export function initialize_everything(state_data) {
drafts_overlay_ui.initialize();
// This needs to happen after activity_ui.initialize, so that user_filter
// is defined. Also, must happen after people.initialize()
onboarding_steps.initialize(state_data.onboarding_steps, message_view.show);
onboarding_steps.initialize(
state_data.onboarding_steps,
state_data.navigation_tour_video_url,
message_view.show,
);
typing.initialize();
starred_messages_ui.initialize();
user_status_ui.initialize();

View File

@@ -667,3 +667,67 @@
#topic-summary-modal {
width: 45em;
}
#navigation-tour-video-modal {
width: 97vw;
max-width: 1024px;
.modal__content {
overflow: hidden;
}
#navigation-tour-video-wrapper {
display: flex;
justify-content: center;
align-items: center;
&::after {
content: "";
position: absolute;
background-image: url("../images/play_button.svg");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
background-color: transparent;
width: 20%;
height: 20%;
pointer-events: none;
}
&.hide-play-button::after {
content: none;
background-image: none;
}
}
#navigation-tour-video {
width: 100%;
aspect-ratio: 16 / 9;
cursor: pointer;
border: 1px solid hsl(0deg 0% 50%);
}
#navigation-tour-video-ended-button-wrapper {
position: absolute;
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
visibility: hidden;
#navigation-tour-video-ended-button {
background-color: hsl(240deg 96% 68%);
color: hsl(0deg 0% 100%) !important;
cursor: pointer;
font-size: 200%;
padding: 1% 5%;
border-radius: 8px;
border: 1px solid hsl(0deg 0% 100%);
transition: transform 0.25s ease-out;
&:hover {
transform: scale(1.05);
}
}
}
}

View File

@@ -0,0 +1,9 @@
<p>{{t "Learn where to find everything you need to get started with this 2-minute video tour."}}</p>
<div id="navigation-tour-video-wrapper">
<video id="navigation-tour-video" controls poster="{{poster_src}}">
<source src="{{video_src}}" type="video/mp4"/>
</video>
<div id="navigation-tour-video-ended-button-wrapper">
<button id="navigation-tour-video-ended-button">{{t "Let's go!"}}</button>
</div>
</div>

View File

@@ -41,8 +41,9 @@ def check_schedule_message(
*,
forwarder_user_profile: UserProfile | None = None,
read_by_sender: bool | None = None,
skip_events: bool = False,
) -> int:
addressee = Addressee.legacy_build(sender, recipient_type_name, message_to, topic_name)
addressee = Addressee.legacy_build(sender, recipient_type_name, message_to, topic_name, realm)
send_request = check_message(
sender,
client,
@@ -61,7 +62,9 @@ def check_schedule_message(
client.default_read_by_sender() and send_request.message.recipient != sender.recipient
)
return do_schedule_messages([send_request], sender, read_by_sender=read_by_sender)[0]
return do_schedule_messages(
[send_request], sender, read_by_sender=read_by_sender, skip_events=skip_events
)[0]
def do_schedule_messages(
@@ -69,6 +72,7 @@ def do_schedule_messages(
sender: UserProfile,
*,
read_by_sender: bool = False,
skip_events: bool = False,
) -> list[int]:
scheduled_messages: list[tuple[ScheduledMessage, SendMessageRequest]] = []
@@ -104,6 +108,7 @@ def do_schedule_messages(
scheduled_message.has_attachment = True
scheduled_message.save(update_fields=["has_attachment"])
if not skip_events:
event = {
"type": "scheduled_messages",
"op": "add",

View File

@@ -273,6 +273,7 @@ def fetch_initial_state_data(
state["onboarding_steps"] = (
[] if user_profile is None else get_next_onboarding_steps(user_profile)
)
state["navigation_tour_video_url"] = settings.NAVIGATION_TOUR_VIDEO_URL
if want("message"):
# Since the introduction of `anchor="latest"` in the API,

View File

@@ -55,6 +55,9 @@ ONE_TIME_NOTICES: list[OneTimeNotice] = [
OneTimeNotice(
name="intro_resolve_topic",
),
OneTimeNotice(
name="navigation_tour_video",
),
]
ONE_TIME_ACTIONS = [OneTimeAction(name="narrow_to_dm_with_welcome_bot_new_user")]
@@ -68,13 +71,17 @@ def get_next_onboarding_steps(user: UserProfile) -> list[dict[str, Any]]:
if not settings.TUTORIAL_ENABLED:
return []
seen_onboarding_steps = frozenset(
seen_onboarding_steps: list[str] = list(
OnboardingStep.objects.filter(user=user).values_list("onboarding_step", flat=True)
)
if settings.NAVIGATION_TOUR_VIDEO_URL is None:
# Server admin disabled navigation tour video, treat it as seen.
seen_onboarding_steps.append("navigation_tour_video")
seen_onboarding_steps_set = frozenset(seen_onboarding_steps)
onboarding_steps: list[dict[str, Any]] = []
for onboarding_step in ALL_ONBOARDING_STEPS:
if onboarding_step.name in seen_onboarding_steps:
if onboarding_step.name in seen_onboarding_steps_set:
continue
onboarding_steps.append(onboarding_step.to_dict())

View File

@@ -0,0 +1,68 @@
# Generated by Django 5.0.9 on 2024-10-21 15:55
from django.db import connection, migrations
from django.db.backends.base.schema import BaseDatabaseSchemaEditor
from django.db.migrations.state import StateApps
from django.utils.timezone import now as timezone_now
from psycopg2.sql import SQL
def mark_navigation_tour_video_as_read(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
with connection.cursor() as cursor:
cursor.execute(SQL("SELECT MAX(id) FROM zerver_userprofile;"))
(max_id,) = cursor.fetchone()
if max_id is None:
return
BATCH_SIZE = 10000
max_id += BATCH_SIZE / 2
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, 'navigation_tour_video', %(timestamp_value)s
FROM zerver_userprofile
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,
"lower_id_bound": lower_id_bound,
"upper_id_bound": upper_id_bound,
},
)
print(f"Processed {upper_id_bound} / {max_id}")
lower_id_bound += BATCH_SIZE
def mark_navigation_tour_video_as_unread(
apps: StateApps, schema_editor: BaseDatabaseSchemaEditor
) -> None:
OnboardingStep = apps.get_model("zerver", "OnboardingStep")
OnboardingStep.objects.filter(onboarding_step="navigation_tour_video").delete()
class Migration(migrations.Migration):
atomic = False
dependencies = [
("zerver", "0688_alter_realm_can_resolve_topics_group"),
]
operations = [
migrations.RunPython(
mark_navigation_tour_video_as_read,
reverse_code=mark_navigation_tour_video_as_unread,
elidable=True,
),
]

View File

@@ -14967,6 +14967,16 @@ paths:
exist as all onboarding steps were implicitly hotspots.
items:
$ref: "#/components/schemas/OnboardingStep"
navigation_tour_video_url:
type: string
nullable: true
description: |
Present if `onboarding_steps` is present in `fetch_event_types`.
URL of the navigation tour video to display to new users during
onboarding. If `null`, the onboarding video experience is disabled.
**Changes**: New in Zulip 10.0 (feature level 369).
max_message_id:
type: integer
deprecated: true

View File

@@ -104,6 +104,7 @@ class HomeTest(ZulipTestCase):
"max_topic_length",
"muted_topics",
"muted_users",
"navigation_tour_video_url",
"never_subscribed",
"onboarding_steps",
"password_min_guesses",

View File

@@ -1,11 +1,17 @@
from datetime import timedelta
import time_machine
from django.conf import settings
from django.utils.timezone import now as timezone_now
from typing_extensions import override
from zerver.actions.create_user import do_create_user
from zerver.actions.onboarding_steps import do_mark_onboarding_step_as_read
from zerver.lib.onboarding_steps import ALL_ONBOARDING_STEPS, get_next_onboarding_steps
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import OnboardingStep
from zerver.models import OnboardingStep, ScheduledMessage
from zerver.models.realms import get_realm
from zerver.models.users import get_system_bot
# Splitting this out, since I imagine this will eventually have most of the
@@ -25,19 +31,24 @@ class TestGetNextOnboardingSteps(ZulipTestCase):
do_mark_onboarding_step_as_read(self.user, "intro_inbox_view_modal")
onboarding_steps = get_next_onboarding_steps(self.user)
self.assert_length(onboarding_steps, 7)
self.assert_length(onboarding_steps, 8)
self.assertEqual(onboarding_steps[0]["name"], "intro_recent_view_modal")
self.assertEqual(onboarding_steps[1]["name"], "first_stream_created_banner")
self.assertEqual(onboarding_steps[2]["name"], "jump_to_conversation_banner")
self.assertEqual(onboarding_steps[3]["name"], "non_interleaved_view_messages_fading")
self.assertEqual(onboarding_steps[4]["name"], "interleaved_view_messages_fading")
self.assertEqual(onboarding_steps[5]["name"], "intro_resolve_topic")
self.assertEqual(onboarding_steps[6]["name"], "narrow_to_dm_with_welcome_bot_new_user")
self.assertEqual(onboarding_steps[6]["name"], "navigation_tour_video")
self.assertEqual(onboarding_steps[7]["name"], "narrow_to_dm_with_welcome_bot_new_user")
with self.settings(TUTORIAL_ENABLED=False):
onboarding_steps = get_next_onboarding_steps(self.user)
self.assert_length(onboarding_steps, 0)
with self.settings(NAVIGATION_TOUR_VIDEO_URL=None):
onboarding_steps = get_next_onboarding_steps(self.user)
self.assertNotIn("navigation_tour_video", onboarding_steps)
def test_all_onboarding_steps_done(self) -> None:
self.assertNotEqual(get_next_onboarding_steps(self.user), [])
@@ -85,3 +96,32 @@ class TestOnboardingSteps(ZulipTestCase):
),
["intro_recent_view_modal"],
)
def test_schedule_navigation_tour_video_reminder(self) -> None:
user = self.example_user("hamlet")
self.login_user(user)
now = timezone_now()
with time_machine.travel(now, tick=False):
result = self.client_post(
"/json/users/me/onboarding_steps",
{
"onboarding_step": "navigation_tour_video",
"schedule_navigation_tour_video_reminder_delay": 30,
},
)
self.assert_json_success(result)
self.assertEqual(
list(
OnboardingStep.objects.filter(user=user).values_list("onboarding_step", flat=True)
),
["navigation_tour_video"],
)
scheduled_message = ScheduledMessage.objects.last()
assert scheduled_message is not None
expected_scheduled_timestamp = now + timedelta(seconds=30)
self.assertEqual(scheduled_message.scheduled_timestamp, expected_scheduled_timestamp)
self.assertEqual(
scheduled_message.sender.id, get_system_bot(settings.WELCOME_BOT, user.realm_id).id
)
self.assertIn("Welcome to Zulip video", scheduled_message.content)

View File

@@ -1,23 +1,61 @@
from datetime import timedelta
from django.conf import settings
from django.http import HttpRequest, HttpResponse
from django.utils.timezone import now as timezone_now
from django.utils.translation import gettext as _
from pydantic import Json
from zerver.actions.onboarding_steps import do_mark_onboarding_step_as_read
from zerver.actions.scheduled_messages import check_schedule_message
from zerver.decorator import human_users_only
from zerver.lib.exceptions import JsonableError
from zerver.lib.onboarding_steps import ALL_ONBOARDING_STEPS
from zerver.lib.response import json_success
from zerver.lib.typed_endpoint import typed_endpoint
from zerver.models import UserProfile
from zerver.models.clients import get_client
from zerver.models.users import get_system_bot
@human_users_only
@typed_endpoint
def mark_onboarding_step_as_read(
request: HttpRequest, user: UserProfile, *, onboarding_step: str
request: HttpRequest,
user: UserProfile,
*,
onboarding_step: str,
schedule_navigation_tour_video_reminder_delay: Json[int] | None = None,
) -> HttpResponse:
if not any(step.name == onboarding_step for step in ALL_ONBOARDING_STEPS):
raise JsonableError(
_("Unknown onboarding_step: {onboarding_step}").format(onboarding_step=onboarding_step)
)
if schedule_navigation_tour_video_reminder_delay is not None:
assert onboarding_step == "navigation_tour_video"
realm = user.realm
sender = get_system_bot(settings.WELCOME_BOT, realm.id)
client = get_client("Internal")
message_content = _("""
You asked to watch the [Welcome to Zulip video]({navigation_tour_video_url}) later. Is this a good time?
""").format(navigation_tour_video_url=settings.NAVIGATION_TOUR_VIDEO_URL)
deliver_at = timezone_now() + timedelta(
seconds=schedule_navigation_tour_video_reminder_delay
)
check_schedule_message(
sender,
client,
"private",
[user.id],
None,
message_content,
deliver_at,
realm,
skip_events=True,
)
do_mark_onboarding_step_as_read(user, onboarding_step)
return json_success(request)

View File

@@ -708,3 +708,9 @@ TOPIC_SUMMARIZATION_PARAMETERS: dict[str, object] = {}
INPUT_COST_PER_GIGATOKEN: int = 0
OUTPUT_COST_PER_GIGATOKEN: int = 0
MAX_PER_USER_MONTHLY_AI_COST: float | None = 0.5
# URL of the navigation tour video displayed to new users.
# Set it to None to disable it.
NAVIGATION_TOUR_VIDEO_URL: str | None = (
"https://static.zulipchat.com/static/navigation-tour-video/zulip-10.mp4"
)

View File

@@ -886,3 +886,10 @@ CAMO_URI = "/external_content/"
## Directory containing Markdown files for the server's policies.
# POLICIES_DIRECTORY = "/etc/zulip/policies/"
## URL of the navigation tour video to show to new users. You can use this
## to host the official video on your network, or to provide your own
## introductory video with details on how your organization uses Zulip.
##
## A value of None disables the navigation tour video experience.
# NAVIGATION_TOUR_VIDEO_URL = "https://static.zulipchat.com/static/navigation-tour-video/zulip-10.mp4"