mirror of https://github.com/zulip/zulip.git
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:
parent
e8769f80a6
commit
bf2360bcf2
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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 |
|
@ -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";
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
}
|
|
@ -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();
|
||||
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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>;
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
|
@ -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>
|
|
@ -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>
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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,
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
},
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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)
|
||||
|
||||
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")
|
||||
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, 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")
|
||||
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:
|
||||
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(get_next_onboarding_steps(self.user), [])
|
||||
|
||||
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), [])
|
||||
|
||||
|
||||
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"],
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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!
|
||||
#
|
||||
|
|
|
@ -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.
|
||||
|
|
Loading…
Reference in New Issue