diff --git a/api_docs/changelog.md b/api_docs/changelog.md index b5fc3ef257..fa854dd0ca 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -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"` diff --git a/static/images/navigation-tour-video-thumbnail.png b/static/images/navigation-tour-video-thumbnail.png new file mode 100644 index 0000000000..7d4f1ea214 Binary files /dev/null and b/static/images/navigation-tour-video-thumbnail.png differ diff --git a/web/e2e-tests/realm-creation.test.ts b/web/e2e-tests/realm-creation.test.ts index 0b87dd1474..969178d576 100644 --- a/web/e2e-tests/realm-creation.test.ts +++ b/web/e2e-tests/realm-creation.test.ts @@ -78,6 +78,11 @@ async function realm_creation_tests(page: Page): Promise { // 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()); } diff --git a/web/src/onboarding_steps.ts b/web/src/onboarding_steps.ts index 23903ba775..c780ac6264 100644 --- a/web/src/onboarding_steps.ts +++ b/web/src/onboarding_steps.ts @@ -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; export const ONE_TIME_NOTICES_TO_DISPLAY = new Set(); -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 = $("#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); } diff --git a/web/src/state_data.ts b/web/src/state_data.ts index ce881fc521..5aeec8725e 100644 --- a/web/src/state_data.ts +++ b/web/src/state_data.ts @@ -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}))); diff --git a/web/src/ui_init.js b/web/src/ui_init.js index 2c85e6ddb7..eb4b4456d0 100644 --- a/web/src/ui_init.js +++ b/web/src/ui_init.js @@ -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(); diff --git a/web/styles/modal.css b/web/styles/modal.css index bccd8b7598..e6a9f26305 100644 --- a/web/styles/modal.css +++ b/web/styles/modal.css @@ -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); + } + } + } +} diff --git a/web/templates/navigation_tour_video_modal.hbs b/web/templates/navigation_tour_video_modal.hbs new file mode 100644 index 0000000000..2b1ad145a5 --- /dev/null +++ b/web/templates/navigation_tour_video_modal.hbs @@ -0,0 +1,9 @@ +

{{t "Learn where to find everything you need to get started with this 2-minute video tour."}}

+ diff --git a/zerver/actions/scheduled_messages.py b/zerver/actions/scheduled_messages.py index b3d372096c..e15713975a 100644 --- a/zerver/actions/scheduled_messages.py +++ b/zerver/actions/scheduled_messages.py @@ -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,14 +108,15 @@ def do_schedule_messages( scheduled_message.has_attachment = True scheduled_message.save(update_fields=["has_attachment"]) - event = { - "type": "scheduled_messages", - "op": "add", - "scheduled_messages": [ - scheduled_message.to_dict() for scheduled_message, ignored in scheduled_messages - ], - } - send_event_on_commit(sender.realm, event, [sender.id]) + if not skip_events: + event = { + "type": "scheduled_messages", + "op": "add", + "scheduled_messages": [ + scheduled_message.to_dict() for scheduled_message, ignored in scheduled_messages + ], + } + send_event_on_commit(sender.realm, event, [sender.id]) return [scheduled_message.id for scheduled_message, ignored in scheduled_messages] diff --git a/zerver/lib/events.py b/zerver/lib/events.py index ed8f78d3b5..0badefc75d 100644 --- a/zerver/lib/events.py +++ b/zerver/lib/events.py @@ -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, diff --git a/zerver/lib/onboarding_steps.py b/zerver/lib/onboarding_steps.py index 28a9c80fc6..c5ce00527a 100644 --- a/zerver/lib/onboarding_steps.py +++ b/zerver/lib/onboarding_steps.py @@ -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()) diff --git a/zerver/migrations/0689_mark_navigation_tour_video_as_read.py b/zerver/migrations/0689_mark_navigation_tour_video_as_read.py new file mode 100644 index 0000000000..09dcbdf5ac --- /dev/null +++ b/zerver/migrations/0689_mark_navigation_tour_video_as_read.py @@ -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, + ), + ] diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index f17da7d413..462db6c8c0 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -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 diff --git a/zerver/tests/test_home.py b/zerver/tests/test_home.py index bf6443bf2e..ceea5a9bf4 100644 --- a/zerver/tests/test_home.py +++ b/zerver/tests/test_home.py @@ -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", diff --git a/zerver/tests/test_onboarding_steps.py b/zerver/tests/test_onboarding_steps.py index 2ad1ba2bed..a86153439e 100644 --- a/zerver/tests/test_onboarding_steps.py +++ b/zerver/tests/test_onboarding_steps.py @@ -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) diff --git a/zerver/views/onboarding_steps.py b/zerver/views/onboarding_steps.py index 890036a249..00c2b1a699 100644 --- a/zerver/views/onboarding_steps.py +++ b/zerver/views/onboarding_steps.py @@ -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) diff --git a/zproject/default_settings.py b/zproject/default_settings.py index e648791219..ceb56b17e0 100644 --- a/zproject/default_settings.py +++ b/zproject/default_settings.py @@ -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" +) diff --git a/zproject/prod_settings_template.py b/zproject/prod_settings_template.py index bbe48dadde..08a96e4730 100644 --- a/zproject/prod_settings_template.py +++ b/zproject/prod_settings_template.py @@ -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"