mirror of
https://github.com/zulip/zulip.git
synced 2025-11-01 12:33:40 +00:00
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:
committed by
Tim Abbott
parent
60498701a4
commit
5f3896710f
@@ -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"`
|
||||
|
||||
BIN
static/images/navigation-tour-video-thumbnail.png
Normal file
BIN
static/images/navigation-tour-video-thumbnail.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.3 MiB |
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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})));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
9
web/templates/navigation_tour_video_modal.hbs
Normal file
9
web/templates/navigation_tour_video_modal.hbs
Normal 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>
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
68
zerver/migrations/0689_mark_navigation_tour_video_as_read.py
Normal file
68
zerver/migrations/0689_mark_navigation_tour_video_as_read.py
Normal 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,
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user