onboarding_steps: Remove hotspot as an onboarding_step.

Earlier, hotspots and one-time notices were the valid
type of onboarding step.

Now, one-time notice is the only valid type.

Fixes #29296.
This commit is contained in:
Prakhar Pratyush 2024-05-10 19:07:43 +05:30 committed by Tim Abbott
parent e8769f80a6
commit bf2360bcf2
32 changed files with 82 additions and 1015 deletions

View File

@ -20,6 +20,16 @@ format used by the Zulip server that they are interacting with.
## Changes in Zulip 9.0
**Feature level 259**:
* [`POST /register`](/api/register-queue), [`GET /events`](/api/get-events):
For the `onboarding_steps` event type, an array of onboarding steps
to be displayed to clients is sent. Onboarding step now has one-time
notices as the only valid type. Prior to this, both hotspots and
one-time notices were valid types of onboarding steps. There is no compatibility
support, as we expect that only official Zulip clients will interact with
this data. Currently, no client other than the Zulip web app uses this.
**Feature level 258**:
* [`GET /user_groups`](/api/get-user-groups), [`POST

View File

@ -117,7 +117,6 @@ EXEMPT_FILES = make_set(
"web/src/hashchange.js",
"web/src/hbs.d.ts",
"web/src/hotkey.js",
"web/src/hotspots.ts",
"web/src/inbox_ui.js",
"web/src/inbox_util.ts",
"web/src/info_overlay.ts",

View File

@ -33,7 +33,7 @@ DESKTOP_WARNING_VERSION = "5.9.3"
# Changes should be accompanied by documentation explaining what the
# new level means in api_docs/changelog.md, as well as "**Changes**"
# entries in the endpoint's documentation in `zulip.yaml`.
API_FEATURE_LEVEL = 258
API_FEATURE_LEVEL = 259
# Bump the minor PROVISION_VERSION to indicate that folks should provision
# only when going from an old version of the code to a newer version. Bump

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

View File

@ -53,7 +53,6 @@ import "../../styles/lightbox.css";
import "../../styles/popovers.css";
import "../../styles/recent_view.css";
import "../../styles/typing_notifications.css";
import "../../styles/hotspots.css";
import "../../styles/dark_theme.css";
import "../../styles/user_status.css";
import "../../styles/widgets.css";

View File

@ -24,7 +24,6 @@ import * as gear_menu from "./gear_menu";
import * as giphy from "./giphy";
import * as hash_util from "./hash_util";
import * as hashchange from "./hashchange";
import * as hotspots from "./hotspots";
import * as inbox_ui from "./inbox_ui";
import * as lightbox from "./lightbox";
import * as list_util from "./list_util";
@ -454,11 +453,6 @@ export function process_enter_key(e) {
return true;
}
if (hotspots.is_open()) {
$(e.target).find(".hotspot.overlay.show .hotspot-confirm").trigger("click");
return false;
}
if (emoji_picker.is_open()) {
return emoji_picker.navigate("enter", e);
}
@ -786,10 +780,6 @@ export function process_hotkey(e, hotkey) {
return false;
}
if (hotspots.is_open()) {
return false;
}
if (overlays.info_overlay_open()) {
if (event_name === "show_shortcuts") {
overlays.close_active();

View File

@ -1,403 +0,0 @@
import $ from "jquery";
import _ from "lodash";
import assert from "minimalistic-assert";
import whale_image from "../images/hotspots/whale.svg";
import render_hotspot_icon from "../templates/hotspot_icon.hbs";
import render_hotspot_overlay from "../templates/hotspot_overlay.hbs";
import * as blueslip from "./blueslip";
import * as message_viewport from "./message_viewport";
import * as onboarding_steps from "./onboarding_steps";
import * as overlays from "./overlays";
import {current_user} from "./state_data";
import type {Hotspot, HotspotLocation, Placement, RawHotspot} from "./state_data";
// popover orientations
const TOP = "top";
const LEFT = "left";
const RIGHT = "right";
const BOTTOM = "bottom";
const LEFT_BOTTOM = "left_bottom";
const VIEWPORT_CENTER = "viewport_center";
// popover orientation can optionally be fixed here to override the
// defaults calculated by compute_placement.
const HOTSPOT_LOCATIONS = new Map<string, HotspotLocation>([
[
"intro_streams",
{
element: "#streams_header .left-sidebar-title .streams-tooltip-target",
offset_x: 1.3,
offset_y: 0.44,
},
],
[
"intro_topics",
{
element: ".topic-name",
offset_x: 1,
offset_y: 0.4,
},
],
[
"intro_gear",
{
element: "#personal-menu",
offset_x: 0.45,
offset_y: 1.15,
popover: LEFT_BOTTOM,
},
],
[
"intro_compose",
{
element: "#new_conversation_button",
offset_x: 0.5,
offset_y: -0.7,
},
],
]);
const meta: {
opened_hotspot_name: null | string;
} = {
opened_hotspot_name: null,
};
function compute_placement(
$elt: JQuery,
popover_height: number,
popover_width: number,
prefer_vertical_positioning: boolean,
): Placement {
const client_rect = $elt.get(0)!.getBoundingClientRect();
const distance_from_top = client_rect.top;
const distance_from_bottom = message_viewport.height() - client_rect.bottom;
const distance_from_left = client_rect.left;
const distance_from_right = message_viewport.width() - client_rect.right;
const element_width = $elt.width()!;
const element_height = $elt.height()!;
const elt_will_fit_horizontally =
distance_from_left + element_width / 2 > popover_width / 2 &&
distance_from_right + element_width / 2 > popover_width / 2;
const elt_will_fit_vertically =
distance_from_bottom + element_height / 2 > popover_height / 2 &&
distance_from_top + element_height / 2 > popover_height / 2;
// default to placing the popover in the center of the screen
let placement: Placement = "viewport_center";
// prioritize left/right over top/bottom
if (distance_from_top > popover_height && elt_will_fit_horizontally) {
placement = "top";
}
if (distance_from_bottom > popover_height && elt_will_fit_horizontally) {
placement = "bottom";
}
if (prefer_vertical_positioning && placement !== "viewport_center") {
// If vertical positioning is preferred and the popover fits in
// either top or bottom position then return.
return placement;
}
if (distance_from_left > popover_width && elt_will_fit_vertically) {
placement = "left";
}
if (distance_from_right > popover_width && elt_will_fit_vertically) {
placement = "right";
}
return placement;
}
export function post_hotspot_as_read(hotspot_name: string): void {
onboarding_steps.post_onboarding_step_as_read(hotspot_name);
}
function place_icon(hotspot: Hotspot): boolean {
const $element = $(hotspot.location.element);
const $icon = $(`#hotspot_${CSS.escape(hotspot.name)}_icon`);
if (
$element.length === 0 ||
$element.css("display") === "none" ||
!$element.is(":visible") ||
$element.is(":hidden")
) {
$icon.css("display", "none");
return false;
}
const offset = {
top: $element.outerHeight()! * hotspot.location.offset_y,
left: $element.outerWidth()! * hotspot.location.offset_x,
};
const client_rect = $element.get(0)!.getBoundingClientRect();
const placement = {
top: client_rect.top + offset.top,
left: client_rect.left + offset.left,
};
$icon.css("display", "block");
$icon.css(placement);
return true;
}
function place_popover(hotspot: Hotspot): void {
const popover_width = $(
`#hotspot_${CSS.escape(hotspot.name)}_overlay .hotspot-popover`,
).outerWidth()!;
const popover_height = $(
`#hotspot_${CSS.escape(hotspot.name)}_overlay .hotspot-popover`,
).outerHeight()!;
const el_width = $(hotspot.location.element).outerWidth()!;
const el_height = $(hotspot.location.element).outerHeight()!;
const arrow_offset = 20;
let popover_offset;
let arrow_placement;
const orientation =
hotspot.location.popover ??
compute_placement($(hotspot.location.element), popover_height, popover_width, false);
switch (orientation) {
case TOP:
popover_offset = {
top: -(popover_height + arrow_offset),
left: el_width / 2 - popover_width / 2,
};
arrow_placement = "bottom";
break;
case LEFT:
popover_offset = {
top: el_height / 2 - popover_height / 2,
left: -(popover_width + arrow_offset),
};
arrow_placement = "right";
break;
case BOTTOM:
popover_offset = {
top: el_height + arrow_offset,
left: el_width / 2 - popover_width / 2,
};
arrow_placement = "top";
break;
case RIGHT:
popover_offset = {
top: el_height / 2 - popover_height / 2,
left: el_width + arrow_offset,
};
arrow_placement = "left";
break;
case LEFT_BOTTOM:
popover_offset = {
top: 0,
left: -(popover_width + arrow_offset / 2),
};
arrow_placement = "";
break;
case VIEWPORT_CENTER:
popover_offset = {
top: el_height / 2,
left: el_width / 2,
};
arrow_placement = "";
break;
default:
blueslip.error("Invalid popover placement value for hotspot", {name: hotspot.name});
return;
}
// position arrow
arrow_placement = "arrow-" + arrow_placement;
$(`#hotspot_${CSS.escape(hotspot.name)}_overlay .hotspot-popover`)
.removeClass("arrow-top arrow-left arrow-bottom arrow-right")
.addClass(arrow_placement);
// position popover
let popover_placement;
if (orientation === VIEWPORT_CENTER) {
popover_placement = {
top: "45%",
left: "50%",
transform: "translate(-50%, -50%)",
};
} else {
const client_rect = $(hotspot.location.element).get(0)!.getBoundingClientRect();
popover_placement = {
top: client_rect.top + popover_offset.top,
left: client_rect.left + popover_offset.left,
transform: "",
};
}
$(`#hotspot_${CSS.escape(hotspot.name)}_overlay .hotspot-popover`).css(popover_placement);
}
function insert_hotspot_into_DOM(hotspot: Hotspot): void {
const hotspot_overlay_HTML = render_hotspot_overlay({
name: hotspot.name,
title: hotspot.title,
description: hotspot.description,
img: whale_image,
});
const hotspot_icon_HTML = render_hotspot_icon({
name: hotspot.name,
});
setTimeout(() => {
if (!hotspot.has_trigger) {
$("body").prepend($(hotspot_icon_HTML));
}
$("body").prepend($(hotspot_overlay_HTML));
if (hotspot.has_trigger || place_icon(hotspot)) {
place_popover(hotspot);
}
// reposition on any event that might update the UI
for (const event_name of ["resize", "scroll", "onkeydown", "click"]) {
window.addEventListener(
event_name,
_.debounce(() => {
if (hotspot.has_trigger || place_icon(hotspot)) {
place_popover(hotspot);
}
}, 10),
true,
);
}
}, hotspot.delay * 1000);
}
export function is_open(): boolean {
return meta.opened_hotspot_name !== null;
}
function is_hotspot_displayed(hotspot_name: string): number {
return $(`#hotspot_${hotspot_name}_overlay`).length;
}
export function close_hotspot_icon($elem: JQuery): void {
$elem.animate(
{opacity: 0},
{
duration: 300,
done() {
$elem.css({display: "none"});
},
},
);
}
function close_read_hotspots(new_hotspots: RawHotspot[]): void {
const unwanted_hotspots = _.difference(
[...HOTSPOT_LOCATIONS.keys()],
new_hotspots.map((hotspot) => hotspot.name),
);
for (const hotspot_name of unwanted_hotspots) {
close_hotspot_icon($(`#hotspot_${CSS.escape(hotspot_name)}_icon`));
$(`#hotspot_${CSS.escape(hotspot_name)}_overlay`).remove();
}
}
export function open_popover_if_hotspot_exist(
hotspot_name: string,
bind_element: HTMLElement,
): void {
const overlay_name = "hotspot_" + hotspot_name + "_overlay";
if (is_hotspot_displayed(hotspot_name)) {
overlays.open_overlay({
name: overlay_name,
$overlay: $(`#${CSS.escape(overlay_name)}`),
on_close: function (this: HTMLElement) {
// close popover
$(this).css({display: "block"});
$(this).animate(
{opacity: 1},
{
duration: 300,
},
);
}.bind(bind_element),
});
}
}
export function load_new(new_hotspots: RawHotspot[]): void {
close_read_hotspots(new_hotspots);
let hotspot_with_location: Hotspot;
for (const hotspot of new_hotspots) {
hotspot_with_location = {
...hotspot,
location: HOTSPOT_LOCATIONS.get(hotspot.name)!,
};
if (!is_hotspot_displayed(hotspot.name)) {
insert_hotspot_into_DOM(hotspot_with_location);
}
}
}
export function initialize(): void {
load_new(onboarding_steps.filter_new_hotspots(current_user.onboarding_steps));
// open
$("body").on("click", ".hotspot-icon", function (this: HTMLElement, e) {
// hide icon
close_hotspot_icon($(this));
// show popover
const match_array = /^hotspot_(.*)_icon$/.exec(
$(e.target).closest(".hotspot-icon").attr("id")!,
);
assert(match_array !== null);
const [, hotspot_name] = match_array;
open_popover_if_hotspot_exist(hotspot_name, this);
meta.opened_hotspot_name = hotspot_name;
e.preventDefault();
e.stopPropagation();
});
// confirm
$("body").on("click", ".hotspot.overlay .hotspot-confirm", function (e) {
e.preventDefault();
e.stopPropagation();
const overlay_name = $(this).closest(".hotspot.overlay").attr("id")!;
const match_array = /^hotspot_(.*)_overlay$/.exec(overlay_name);
assert(match_array !== null);
const [, hotspot_name] = match_array;
// Comment below to disable marking hotspots as read in production
post_hotspot_as_read(hotspot_name);
overlays.close_overlay(overlay_name);
$(`#hotspot_${CSS.escape(hotspot_name)}_icon`).remove();
// We are removing the hotspot overlay after it's read as it will help us avoid
// multiple copies of the hotspots when ALWAYS_SEND_ALL_HOTSPOTS is set to true.
$(`#${overlay_name}`).remove();
});
// stop propagation
$("body").on("click", ".hotspot.overlay .hotspot-popover", (e) => {
e.stopPropagation();
});
}

View File

@ -1,7 +1,7 @@
import * as blueslip from "./blueslip";
import * as channel from "./channel";
import {current_user} from "./state_data";
import type {OnboardingStep, RawHotspot} from "./state_data";
import type {OnboardingStep} from "./state_data";
export const ONE_TIME_NOTICES_TO_DISPLAY = new Set<string>();
@ -21,12 +21,6 @@ export function post_onboarding_step_as_read(onboarding_step_name: string): void
});
}
export function filter_new_hotspots(onboarding_steps: OnboardingStep[]): RawHotspot[] {
return onboarding_steps.flatMap((onboarding_step) =>
onboarding_step.type === "hotspot" ? [onboarding_step] : [],
);
}
export function update_notice_to_display(onboarding_steps: OnboardingStep[]): void {
ONE_TIME_NOTICES_TO_DISPLAY.clear();

View File

@ -20,7 +20,6 @@ import * as emoji from "./emoji";
import * as emoji_picker from "./emoji_picker";
import * as gear_menu from "./gear_menu";
import * as giphy from "./giphy";
import * as hotspots from "./hotspots";
import * as information_density from "./information_density";
import * as left_sidebar_navigation_area from "./left_sidebar_navigation_area";
import * as linkifiers from "./linkifiers";
@ -147,7 +146,6 @@ export function dispatch_normal_event(event) {
break;
case "onboarding_steps":
hotspots.load_new(onboarding_steps.filter_new_hotspots(event.onboarding_steps));
onboarding_steps.update_notice_to_display(event.onboarding_steps);
current_user.onboarding_steps = current_user.onboarding_steps
? [...current_user.onboarding_steps, ...event.onboarding_steps]

View File

@ -21,49 +21,17 @@ export const narrow_term_schema = z.object({
export type NarrowTerm = z.output<typeof narrow_term_schema>;
// Sync this with zerver.lib.events.do_events_register.
const placement_schema = z.enum([
"top",
"left",
"right",
"bottom",
"left_bottom",
"viewport_center",
]);
export type Placement = z.infer<typeof placement_schema>;
const hotspot_location_schema = z.object({
element: z.string(),
offset_x: z.number(),
offset_y: z.number(),
popover: z.optional(placement_schema),
});
export type HotspotLocation = z.output<typeof hotspot_location_schema>;
const raw_hotspot_schema = z.object({
delay: z.number(),
description: z.string(),
has_trigger: z.boolean(),
name: z.string(),
title: z.string(),
type: z.literal("hotspot"),
});
export type RawHotspot = z.output<typeof raw_hotspot_schema>;
const hotspot_schema = raw_hotspot_schema.extend({
location: hotspot_location_schema,
});
export type Hotspot = z.output<typeof hotspot_schema>;
const one_time_notice_schema = z.object({
name: z.string(),
type: z.literal("one_time_notice"),
});
const onboarding_step_schema = z.union([one_time_notice_schema, raw_hotspot_schema]);
/* We may introduce onboarding step of types other than 'one time notice'
in future. Earlier, we had 'hotspot' and 'one time notice' as the two
types. We can simply do:
const onboarding_step_schema = z.union([one_time_notice_schema, other_type_schema]);
to avoid major refactoring when new type is introduced in the future. */
const onboarding_step_schema = one_time_notice_schema;
export type OnboardingStep = z.output<typeof onboarding_step_schema>;

View File

@ -48,7 +48,6 @@ import * as gear_menu from "./gear_menu";
import * as giphy from "./giphy";
import * as hashchange from "./hashchange";
import * as hotkey from "./hotkey";
import * as hotspots from "./hotspots";
import * as i18n from "./i18n";
import * as inbox_ui from "./inbox_ui";
import * as information_density from "./information_density";
@ -877,7 +876,6 @@ export function initialize_everything(state_data) {
drafts.initialize_ui();
drafts_overlay_ui.initialize();
onboarding_steps.initialize();
hotspots.initialize();
typing.initialize();
starred_messages_ui.initialize();
user_status_ui.initialize();

View File

@ -923,14 +923,6 @@
}
}
/* Popover: */
.hotspot.overlay .hotspot-popover {
border-color: hsl(0deg 0% 0% / 20%) !important;
/* Based on the `.hotspot-popover` shadow in `hotspots.css`, but with a new
color. */
box-shadow: 0 5px 10px hsl(0deg 0% 0% / 40%);
}
#user-profile-modal {
#default-section {
.default-field {
@ -962,47 +954,6 @@
}
}
/* Arrows: */
.hotspot.overlay {
.hotspot-popover.arrow-right::before {
border-left-color: hsl(0deg 0% 0% / 20%);
}
.hotspot-popover.arrow-right::after {
border-left-color: hsl(212deg 28% 18%);
}
.hotspot-popover.arrow-bottom::before {
border-top-color: hsl(0deg 0% 0% / 20%);
}
.hotspot-popover.arrow-bottom::after {
border-top-color: hsl(212deg 28% 18%);
}
.hotspot-popover.arrow-left::before {
border-right-color: hsl(0deg 0% 0% / 20%);
}
.hotspot-popover.arrow-left::after {
border-right-color: hsl(212deg 28% 18%);
}
.hotspot-popover.arrow-top::before {
border-bottom-color: hsl(0deg 0% 0% / 20%);
}
.hotspot-popover.arrow-top::after {
border-bottom-color: hsl(212deg 28% 18%);
}
}
/* Content: */
.hotspot.overlay .hotspot-popover .hotspot-popover-content,
.hotspot.overlay .hotspot-popover .hotspot-popover-bottom {
background-color: var(--color-background);
}
.top-messages-logo {
opacity: 0.7;
}

View File

@ -1,245 +0,0 @@
/* icon */
.hotspot-icon {
position: fixed;
cursor: pointer;
z-index: 100;
.dot {
width: 25px;
height: 25px;
margin: -12.5px 0 0 -12.5px;
border-radius: 50%;
position: absolute;
background-color: hsl(196deg 100% 82% / 30%);
border: 2px solid var(--color-outline-focus);
top: 50%;
left: 50%;
}
.pulse {
width: 25px;
height: 25px;
margin: -11.5px 0 0 -11.5px;
position: absolute;
top: 50%;
left: 50%;
background-color: hsl(0deg 0% 100%);
border-radius: 50%;
border: 1px solid hsl(205deg 100% 70%);
transform: scale(2.2);
opacity: 0;
animation: pulsate 5s ease-out 0.375s 5;
}
.bounce {
animation: bounce 5s 5;
.bounce-icon {
position: absolute;
left: -5px;
bottom: 3px;
transform: rotate(7deg);
color: var(--color-outline-focus);
font-size: 2.75em;
font-weight: 600;
}
}
}
@keyframes pulsate {
0% {
transform: scale(1);
opacity: 0.8;
}
30%,
100% {
transform: scale(2.2);
opacity: 0;
}
}
@keyframes bounce {
0%,
15%,
100% {
transform: translateY(0);
}
7.5% {
transform: translateY(4px);
}
}
/* popover */
.hotspot.overlay {
z-index: 104;
background-color: hsl(191deg 7% 20% / 15%);
.hotspot-popover {
position: fixed;
width: 250px;
text-align: left;
box-shadow: 0 5px 10px hsl(223deg 4% 54% / 20%);
border: 1px solid hsl(0deg 0% 80%);
border-radius: 4px;
/* arrows */
&::after,
&::before {
border: solid transparent;
content: "";
height: 0;
width: 0;
position: absolute;
pointer-events: none;
}
&::after {
border-width: 12px;
}
&::before {
border-width: 13px;
}
&.arrow-top {
&::before,
&::after {
bottom: 100%;
right: 50%;
}
&::after {
border-bottom-color: hsl(164deg 44% 47%);
margin-right: -12px;
}
&::before {
border-bottom-color: hsl(0deg 0% 80%);
margin-right: -13px;
}
}
&.arrow-left {
&::before,
&::after {
right: 100%;
top: 50%;
}
&::after {
border-right-color: hsl(0deg 0% 100%);
margin-top: -12px;
}
&::before {
border-right-color: hsl(0deg 0% 80%);
margin-top: -13px;
}
}
&.arrow-bottom {
&::before,
&::after {
top: 100%;
right: 50%;
}
&::after {
border-top-color: hsl(0deg 0% 100%);
margin-right: -12px;
}
&::before {
border-top-color: hsl(0deg 0% 80%);
margin-right: -13px;
}
}
&.arrow-right {
&::before,
&::after {
left: 100%;
top: 50%;
}
&::after {
border-left-color: hsl(0deg 0% 100%);
margin-top: -12px;
}
&::before {
border-left-color: hsl(0deg 0% 80%);
margin-top: -13px;
}
}
}
.hotspot-popover-top {
padding: 0 15px;
border-top-left-radius: 4px;
border-top-right-radius: 4px;
background-color: hsl(164deg 44% 47%);
}
.hotspot-title {
margin: 0;
font-size: 1.15em;
font-weight: 600;
color: hsl(0deg 0% 100%);
}
.hotspot-popover-content {
background-color: hsl(0deg 0% 100%);
padding: 15px;
}
.hotspot-popover-bottom {
background-color: hsl(0deg 0% 100%);
height: 90px;
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
}
.hotspot-img {
position: absolute;
bottom: 10px;
left: 4px;
}
.hotspot-confirm {
position: absolute;
bottom: 15px;
right: 15px;
}
}
.hotspot-img {
height: 83px;
}
.hotspot-confirm {
max-width: 125px;
max-height: 70px;
border: none;
font-size: 1.15em;
font-weight: 600;
color: hsl(0deg 0% 100%);
background-color: hsl(164deg 44% 47%);
border-radius: 4px;
white-space: normal;
padding: 7px 20px;
outline: none;
&:hover {
background-color: hsl(164deg 44% 56%);
}
}
/* individual icon z-indexing */
#hotspot_intro_streams_icon,
#hotspot_intro_topics_icon,
#hotspot_intro_gear_icon {
z-index: 103;
}

View File

@ -1,5 +0,0 @@
<div class="hotspot-icon" id="hotspot_{{name}}_icon">
<span class="dot"></span>
<span class="pulse"></span>
<div class="bounce"><span class="bounce-icon">?</span></div>
</div>

View File

@ -1,14 +0,0 @@
<div id="hotspot_{{name}}_overlay" class="hotspot overlay" data-overlay="hotspot_{{name}}_overlay">
<div class="hotspot-popover">
<div class="hotspot-popover-top">
<h1 class="hotspot-title">{{title}}</h1>
</div>
<div class="hotspot-popover-content">
<p class="hotspot-description">{{description}}</p>
</div>
<div class="hotspot-popover-bottom">
<img class="hotspot-img" src="{{img}}" />
<button class="hotspot-confirm">{{t 'Got it!' }}</button>
</div>
</div>
</div>

View File

@ -32,7 +32,6 @@ const compose_pm_pill = mock_esm("../src/compose_pm_pill");
const dark_theme = mock_esm("../src/dark_theme");
const emoji_picker = mock_esm("../src/emoji_picker");
const gear_menu = mock_esm("../src/gear_menu");
const hotspots = mock_esm("../src/hotspots");
const information_density = mock_esm("../src/information_density");
const linkifiers = mock_esm("../src/linkifiers");
const message_events = mock_esm("../src/message_events");
@ -320,10 +319,9 @@ run_test("default_streams", ({override}) => {
assert_same(args.realm_default_streams, event.default_streams);
});
run_test("onboarding_steps", ({override}) => {
run_test("onboarding_steps", () => {
current_user.onboarding_steps = [];
const event = event_fixtures.onboarding_steps;
override(hotspots, "load_new", noop);
dispatch(event);
assert_same(current_user.onboarding_steps, event.onboarding_steps);
});

View File

@ -88,10 +88,6 @@ const settings_data = mock_esm("../src/settings_data");
const stream_list = mock_esm("../src/stream_list");
const stream_settings_ui = mock_esm("../src/stream_settings_ui");
mock_esm("../src/hotspots", {
is_open: () => false,
});
mock_esm("../src/recent_view_ui", {
is_in_focus: () => false,
});

View File

@ -195,20 +195,12 @@ exports.fixtures = {
type: "onboarding_steps",
onboarding_steps: [
{
type: "hotspot",
name: "topics",
title: "About topics",
description: "Topics are good.",
delay: 1.5,
has_trigger: false,
type: "one_time_notice",
name: "intro_inbox_view_modal",
},
{
type: "hotspot",
name: "compose",
title: "Compose box",
description: "This is where you compose messages.",
delay: 3.14159,
has_trigger: false,
type: "one_time_notice",
name: "intro_recent_view_modal",
},
],
},

View File

@ -338,13 +338,7 @@ _onboarding_steps = DictType(
required_keys=[
("type", str),
("name", str),
],
optional_keys=[
("title", str),
("description", str),
("delay", NumberType()),
("has_trigger", bool),
],
]
)
onboarding_steps_event = event_dict_type(

View File

@ -1,73 +1,13 @@
# See https://zulip.readthedocs.io/en/latest/subsystems/hotspots.html
# for documentation on this subsystem.
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Union
from typing import Any, Dict, List
from django.conf import settings
from django.utils.translation import gettext_lazy
from django_stubs_ext import StrPromise
from zerver.models import OnboardingStep, UserProfile
@dataclass
class Hotspot:
name: str
title: Optional[StrPromise]
description: Optional[StrPromise]
has_trigger: bool = False
def to_dict(self, delay: float = 0) -> Dict[str, Union[str, float, bool]]:
return {
"type": "hotspot",
"name": self.name,
"title": str(self.title),
"description": str(self.description),
"delay": delay,
"has_trigger": self.has_trigger,
}
INTRO_HOTSPOTS: List[Hotspot] = [
Hotspot(
name="intro_streams",
title=gettext_lazy("Catch up on a channel"),
description=gettext_lazy(
"Messages sent to a channel are seen by everyone subscribed "
"to that channel. Try clicking on one of the channel links below."
),
),
Hotspot(
name="intro_topics",
title=gettext_lazy("Topics"),
description=gettext_lazy(
"Every message has a topic. Topics keep conversations "
"easy to follow, and make it easy to reply to conversations that start "
"while you are offline."
),
),
Hotspot(
# In theory, this should be renamed to intro_personal, since
# it's no longer attached to the gear menu, but renaming these
# requires a migration that is not worth doing at this time.
name="intro_gear",
title=gettext_lazy("Settings"),
description=gettext_lazy("Go to Settings to configure your notifications and preferences."),
),
Hotspot(
name="intro_compose",
title=gettext_lazy("Compose"),
description=gettext_lazy(
"Click here to start a new conversation. Pick a topic "
"(2-3 words is best), and give it a go!"
),
),
]
NON_INTRO_HOTSPOTS: List[Hotspot] = []
@dataclass
class OneTimeNotice:
name: str
@ -94,24 +34,17 @@ ONE_TIME_NOTICES: List[OneTimeNotice] = [
),
]
# We would most likely implement new hotspots in the future that aren't
# a part of the initial tutorial. To that end, classifying them into
# categories which are aggregated in ALL_HOTSPOTS, seems like a good start.
ALL_HOTSPOTS = [*INTRO_HOTSPOTS, *NON_INTRO_HOTSPOTS]
ALL_ONBOARDING_STEPS: List[Union[Hotspot, OneTimeNotice]] = [*ALL_HOTSPOTS, *ONE_TIME_NOTICES]
# We may introduce onboarding step of types other than 'one time notice'
# in future. Earlier, we had 'hotspot' and 'one time notice' as the two
# types. We can simply do:
# ALL_ONBOARDING_STEPS: List[Union[OneTimeNotice, OtherType]]
# to avoid API changes when new type is introduced in the future.
ALL_ONBOARDING_STEPS: List[OneTimeNotice] = ONE_TIME_NOTICES
def get_next_onboarding_steps(user: UserProfile) -> List[Dict[str, Any]]:
# For manual testing, it can be convenient to set
# ALWAYS_SEND_ALL_HOTSPOTS=True in `zproject/dev_settings.py` to
# make it easy to click on all of the hotspots.
#
# Since this is just for development purposes, it's convenient for us to send
# all the hotspots rather than any specific category.
if settings.ALWAYS_SEND_ALL_HOTSPOTS:
return [hotspot.to_dict() for hotspot in ALL_HOTSPOTS]
# If a Zulip server has disabled the tutorial, never send hotspots.
# If a Zulip server has disabled the tutorial, never send any
# onboarding steps.
if not settings.TUTORIAL_ENABLED:
return []
@ -119,25 +52,12 @@ def get_next_onboarding_steps(user: UserProfile) -> List[Dict[str, Any]]:
OnboardingStep.objects.filter(user=user).values_list("onboarding_step", flat=True)
)
onboarding_steps: List[Dict[str, Any]] = [hotspot.to_dict() for hotspot in NON_INTRO_HOTSPOTS]
onboarding_steps: List[Dict[str, Any]] = []
for one_time_notice in ONE_TIME_NOTICES:
if one_time_notice.name in seen_onboarding_steps:
continue
onboarding_steps.append(one_time_notice.to_dict())
if user.tutorial_status == UserProfile.TUTORIAL_FINISHED:
return onboarding_steps
for hotspot in INTRO_HOTSPOTS:
if hotspot.name in seen_onboarding_steps:
continue
onboarding_steps.append(hotspot.to_dict(delay=0.5))
return onboarding_steps
user.tutorial_status = UserProfile.TUTORIAL_FINISHED
user.save(update_fields=["tutorial_status"])
return onboarding_steps

View File

@ -2325,14 +2325,6 @@ paths:
"type": "onboarding_steps",
"onboarding_steps":
[
{
"type": "hotspot",
"name": "intro_compose",
"title": "Compose",
"description": "Click here to start a new conversation. Pick a topic (2-3 words is best), and give it a go!",
"delay": 0.5,
"has_trigger": false,
},
{
"type": "one_time_notice",
"name": "visibility_policy_banner",
@ -19854,43 +19846,15 @@ components:
type:
type: string
description: |
The type of the onboarding step. Valid values are either
`"hotspot"` or `"one_time_notice"`.
The type of the onboarding step. Valid value is `"one_time_notice"`.
**Changes**: New in Zulip 8.0 (feature level 233).
**Changes**: Removed type `"hotspot"` in Zulip 9.0 (feature level 259).
New in Zulip 8.0 (feature level 233).
name:
type: string
description: |
The name of the onboarding step.
title:
type: string
description: |
The title of the onboarding step, as displayed to the user.
Only present for onboarding steps with type `"hotspot"`.
description:
type: string
description: |
The description of the onboarding step, as displayed to the
user.
Only present for onboarding steps with type `"hotspot"`.
delay:
type: number
description: |
The delay, in seconds, after which the user should be shown
the onboarding step.
Only present for onboarding steps with type `"hotspot"`.
has_trigger:
type: boolean
description: |
Identifies if the onboarding step will activate only when some
specific event occurs.
Only present for onboarding steps with type `"hotspot"`.
**Changes**: New in Zulip 8.0 (feature level 230).
RealmAuthenticationMethod:
type: object
properties:

View File

@ -2930,11 +2930,8 @@ class NormalActionsTest(BaseAction):
check_realm_deactivated("events[0]", events[0])
def test_do_mark_onboarding_step_as_read(self) -> None:
self.user_profile.tutorial_status = UserProfile.TUTORIAL_WAITING
self.user_profile.save(update_fields=["tutorial_status"])
with self.verify_action() as events:
do_mark_onboarding_step_as_read(self.user_profile, "intro_streams")
do_mark_onboarding_step_as_read(self.user_profile, "intro_inbox_view_modal")
check_onboarding_steps("events[0]", events[0])
def test_rename_stream(self) -> None:

View File

@ -2,15 +2,9 @@ from typing_extensions import override
from zerver.actions.create_user import do_create_user
from zerver.actions.hotspots import do_mark_onboarding_step_as_read
from zerver.lib.hotspots import (
ALL_HOTSPOTS,
INTRO_HOTSPOTS,
NON_INTRO_HOTSPOTS,
ONE_TIME_NOTICES,
get_next_onboarding_steps,
)
from zerver.lib.hotspots import ONE_TIME_NOTICES, get_next_onboarding_steps
from zerver.lib.test_classes import ZulipTestCase
from zerver.models import OnboardingStep, UserProfile
from zerver.models import OnboardingStep
from zerver.models.realms import get_realm
@ -24,77 +18,50 @@ class TestGetNextOnboardingSteps(ZulipTestCase):
"user@zulip.com", "password", get_realm("zulip"), "user", acting_user=None
)
def test_first_hotspot(self) -> None:
for hotspot in NON_INTRO_HOTSPOTS: # nocoverage
do_mark_onboarding_step_as_read(self.user, hotspot.name)
def test_some_done_some_not(self) -> None:
do_mark_onboarding_step_as_read(self.user, "visibility_policy_banner")
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, 2)
self.assertEqual(onboarding_steps[0]["name"], "intro_recent_view_modal")
self.assertEqual(onboarding_steps[1]["name"], "first_stream_created_banner")
with self.settings(TUTORIAL_ENABLED=False):
onboarding_steps = get_next_onboarding_steps(self.user)
self.assert_length(onboarding_steps, 0)
def test_all_onboarding_steps_done(self) -> None:
self.assertNotEqual(get_next_onboarding_steps(self.user), [])
for one_time_notice in ONE_TIME_NOTICES: # nocoverage
do_mark_onboarding_step_as_read(self.user, one_time_notice.name)
hotspots = get_next_onboarding_steps(self.user)
self.assert_length(hotspots, 1)
self.assertEqual(hotspots[0]["name"], "intro_streams")
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, 5)
self.assertEqual(onboarding_steps[0]["name"], "visibility_policy_banner")
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"], "first_stream_created_banner")
self.assertEqual(onboarding_steps[4]["name"], "intro_topics")
def test_all_onboarding_steps_done(self) -> None:
with self.settings(TUTORIAL_ENABLED=True):
self.assertNotEqual(self.user.tutorial_status, UserProfile.TUTORIAL_FINISHED)
for hotspot in NON_INTRO_HOTSPOTS: # nocoverage
do_mark_onboarding_step_as_read(self.user, hotspot.name)
self.assertNotEqual(self.user.tutorial_status, UserProfile.TUTORIAL_FINISHED)
for one_time_notice in ONE_TIME_NOTICES: # nocoverage
do_mark_onboarding_step_as_read(self.user, one_time_notice.name)
self.assertNotEqual(self.user.tutorial_status, UserProfile.TUTORIAL_FINISHED)
for hotspot in INTRO_HOTSPOTS:
do_mark_onboarding_step_as_read(self.user, hotspot.name)
self.assertEqual(self.user.tutorial_status, UserProfile.TUTORIAL_FINISHED)
self.assertEqual(get_next_onboarding_steps(self.user), [])
def test_send_all_hotspots(self) -> None:
with self.settings(DEVELOPMENT=True, ALWAYS_SEND_ALL_HOTSPOTS=True):
self.assert_length(ALL_HOTSPOTS, len(get_next_onboarding_steps(self.user)))
def test_tutorial_disabled(self) -> None:
with self.settings(TUTORIAL_ENABLED=False):
self.assertEqual(get_next_onboarding_steps(self.user), [])
self.assertEqual(get_next_onboarding_steps(self.user), [])
class TestOnboardingSteps(ZulipTestCase):
def test_do_mark_onboarding_step_as_read(self) -> None:
user = self.example_user("hamlet")
do_mark_onboarding_step_as_read(user, "intro_compose")
do_mark_onboarding_step_as_read(user, "intro_inbox_view_modal")
self.assertEqual(
list(
OnboardingStep.objects.filter(user=user).values_list("onboarding_step", flat=True)
),
["intro_compose"],
["intro_inbox_view_modal"],
)
def test_onboarding_steps_url_endpoint(self) -> None:
user = self.example_user("hamlet")
self.login_user(user)
result = self.client_post(
"/json/users/me/onboarding_steps", {"onboarding_step": "intro_streams"}
"/json/users/me/onboarding_steps", {"onboarding_step": "intro_recent_view_modal"}
)
self.assert_json_success(result)
self.assertEqual(
list(
OnboardingStep.objects.filter(user=user).values_list("onboarding_step", flat=True)
),
["intro_streams"],
["intro_recent_view_modal"],
)
result = self.client_post("/json/users/me/onboarding_steps", {"onboarding_step": "invalid"})
@ -103,5 +70,5 @@ class TestOnboardingSteps(ZulipTestCase):
list(
OnboardingStep.objects.filter(user=user).values_list("onboarding_step", flat=True)
),
["intro_streams"],
["intro_recent_view_modal"],
)

View File

@ -836,10 +836,10 @@ class RealmImportExportTest(ExportFile):
# Verify strange invariant for Reaction/RealmEmoji.
self.assertEqual(reaction.emoji_code, str(realm_emoji.id))
# data to test import of hotspots
# data to test import of onboaring step
OnboardingStep.objects.create(
user=sample_user,
onboarding_step="intro_streams",
onboarding_step="intro_inbox_view_modal",
)
# data to test import of muted topic
@ -1232,13 +1232,16 @@ class RealmImportExportTest(ExportFile):
self.assertEqual(tups, {("hawaii", cordelia.full_name)})
return tups
# test userhotspot
# test onboarding step
@getter
def get_user_hotspots(r: Realm) -> Set[str]:
def get_onboarding_steps(r: Realm) -> Set[str]:
user_id = get_user_id(r, "King Hamlet")
hotspots = OnboardingStep.objects.filter(user_id=user_id)
user_hotspots = {hotspot.onboarding_step for hotspot in hotspots}
return user_hotspots
onboarding_steps = set(
OnboardingStep.objects.filter(user_id=user_id).values_list(
"onboarding_step", flat=True
)
)
return onboarding_steps
# test muted topics
@getter

View File

@ -1313,9 +1313,9 @@ class UserProfileTest(ZulipTestCase):
OnboardingStep.objects.filter(user=cordelia).delete()
OnboardingStep.objects.filter(user=iago).delete()
hotspots_completed = {"intro_streams", "intro_topics"}
for hotspot in hotspots_completed:
OnboardingStep.objects.create(user=cordelia, onboarding_step=hotspot)
onboarding_steps_completed = {"intro_inbox_view_modal", "intro_recent_view_modal"}
for onboarding_step in onboarding_steps_completed:
OnboardingStep.objects.create(user=cordelia, onboarding_step=onboarding_step)
# Check that we didn't send an realm_user update events to
# users; this work is happening before the user account is
@ -1357,10 +1357,10 @@ class UserProfileTest(ZulipTestCase):
self.assertEqual(cordelia.enter_sends, False)
self.assertEqual(hamlet.enter_sends, True)
hotspots = set(
onboarding_steps = set(
OnboardingStep.objects.filter(user=iago).values_list("onboarding_step", flat=True)
)
self.assertEqual(hotspots, hotspots_completed)
self.assertEqual(onboarding_steps, onboarding_steps_completed)
def test_copy_default_settings_from_realm_user_default(self) -> None:
cordelia = self.example_user("cordelia")

View File

@ -310,12 +310,9 @@ RATE_LIMITING_RULES: Dict[str, List[Tuple[int, int]]] = {}
# Two factor authentication is not yet implementation-complete
TWO_FACTOR_AUTHENTICATION_ENABLED = False
# This is used to send all hotspots for convenient manual testing
# in development mode.
ALWAYS_SEND_ALL_HOTSPOTS = False
# The new user tutorial is enabled by default, but can be disabled for
# self-hosters who want to disable the tutorial entirely on their system.
# The new user tutorial can be disabled for self-hosters who want to
# disable the tutorial entirely on their system. Primarily useful for
# products embedding Zulip as their chat feature.
TUTORIAL_ENABLED = True
# We log emails in development environment for accessing

View File

@ -121,9 +121,6 @@ PASSWORD_MIN_GUESSES = 0
TWO_FACTOR_CALL_GATEWAY = "two_factor.gateways.fake.Fake"
TWO_FACTOR_SMS_GATEWAY = "two_factor.gateways.fake.Fake"
# Set this True to send all hotspots in development
ALWAYS_SEND_ALL_HOTSPOTS = False
# FAKE_LDAP_MODE supports using a fake LDAP database in the
# development environment, without needing an LDAP server!
#

View File

@ -824,7 +824,9 @@ ENABLE_GRAVATAR = True
## to "" will disable the Camo integration.
CAMO_URI = "/external_content/"
## Controls the tutorial popups for new users.
## Controls various features explaining Zulip to new users. Disabling
## this is only recommended for installations that are using a limited
## subset of the Zulip UI, such as embedding it in a larger app.
# TUTORIAL_ENABLED = True
## Controls whether Zulip will rate-limit user requests.