diff --git a/api_docs/changelog.md b/api_docs/changelog.md index 595425f51c..7928d4e50d 100644 --- a/api_docs/changelog.md +++ b/api_docs/changelog.md @@ -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 diff --git a/tools/test-js-with-node b/tools/test-js-with-node index b2df22c002..84f2d127e2 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -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", diff --git a/version.py b/version.py index e2c25610f7..e812d0a121 100644 --- a/version.py +++ b/version.py @@ -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 diff --git a/web/images/hotspots/fish.svg b/web/images/hotspots/fish.svg deleted file mode 100644 index 051aa24f57..0000000000 Binary files a/web/images/hotspots/fish.svg and /dev/null differ diff --git a/web/images/hotspots/kelp.svg b/web/images/hotspots/kelp.svg deleted file mode 100644 index aeee86a697..0000000000 Binary files a/web/images/hotspots/kelp.svg and /dev/null differ diff --git a/web/images/hotspots/speech-bubble.svg b/web/images/hotspots/speech-bubble.svg deleted file mode 100644 index 3eadb0b39a..0000000000 Binary files a/web/images/hotspots/speech-bubble.svg and /dev/null differ diff --git a/web/images/hotspots/whale-bubble.svg b/web/images/hotspots/whale-bubble.svg deleted file mode 100644 index 0faccc0ffe..0000000000 Binary files a/web/images/hotspots/whale-bubble.svg and /dev/null differ diff --git a/web/images/hotspots/whale.svg b/web/images/hotspots/whale.svg deleted file mode 100644 index ac5d05a62d..0000000000 Binary files a/web/images/hotspots/whale.svg and /dev/null differ diff --git a/web/src/bundles/app.ts b/web/src/bundles/app.ts index adac9dd353..5b75af4dd7 100644 --- a/web/src/bundles/app.ts +++ b/web/src/bundles/app.ts @@ -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"; diff --git a/web/src/hotkey.js b/web/src/hotkey.js index 2a493cda11..9d25f0690b 100644 --- a/web/src/hotkey.js +++ b/web/src/hotkey.js @@ -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(); diff --git a/web/src/hotspots.ts b/web/src/hotspots.ts deleted file mode 100644 index 72144fe761..0000000000 --- a/web/src/hotspots.ts +++ /dev/null @@ -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([ - [ - "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(); - }); -} diff --git a/web/src/onboarding_steps.ts b/web/src/onboarding_steps.ts index d1b094f441..933dfe5cdc 100644 --- a/web/src/onboarding_steps.ts +++ b/web/src/onboarding_steps.ts @@ -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(); @@ -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(); diff --git a/web/src/server_events_dispatch.js b/web/src/server_events_dispatch.js index b1be13bb47..666f45f697 100644 --- a/web/src/server_events_dispatch.js +++ b/web/src/server_events_dispatch.js @@ -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] diff --git a/web/src/state_data.ts b/web/src/state_data.ts index d0d6bd6b1f..53cc6cc2fd 100644 --- a/web/src/state_data.ts +++ b/web/src/state_data.ts @@ -21,49 +21,17 @@ export const narrow_term_schema = z.object({ export type NarrowTerm = z.output; // 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; - -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; - -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; - -const hotspot_schema = raw_hotspot_schema.extend({ - location: hotspot_location_schema, -}); - -export type Hotspot = z.output; - 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; diff --git a/web/src/ui_init.js b/web/src/ui_init.js index 391cfa9347..d6e415efbf 100644 --- a/web/src/ui_init.js +++ b/web/src/ui_init.js @@ -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(); diff --git a/web/styles/dark_theme.css b/web/styles/dark_theme.css index 56f5e4f1f9..ba55ff750e 100644 --- a/web/styles/dark_theme.css +++ b/web/styles/dark_theme.css @@ -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; } diff --git a/web/styles/hotspots.css b/web/styles/hotspots.css deleted file mode 100644 index e38b074f65..0000000000 --- a/web/styles/hotspots.css +++ /dev/null @@ -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; -} diff --git a/web/templates/hotspot_icon.hbs b/web/templates/hotspot_icon.hbs deleted file mode 100644 index 87deca5e52..0000000000 --- a/web/templates/hotspot_icon.hbs +++ /dev/null @@ -1,5 +0,0 @@ -
- - -
?
-
diff --git a/web/templates/hotspot_overlay.hbs b/web/templates/hotspot_overlay.hbs deleted file mode 100644 index f8a2d1fe89..0000000000 --- a/web/templates/hotspot_overlay.hbs +++ /dev/null @@ -1,14 +0,0 @@ -
-
-
-

{{title}}

-
-
-

{{description}}

-
-
- - -
-
-
diff --git a/web/tests/dispatch.test.js b/web/tests/dispatch.test.js index ef04b161fb..706436e0ba 100644 --- a/web/tests/dispatch.test.js +++ b/web/tests/dispatch.test.js @@ -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); }); diff --git a/web/tests/hotkey.test.js b/web/tests/hotkey.test.js index b83b8b508e..440fd3958d 100644 --- a/web/tests/hotkey.test.js +++ b/web/tests/hotkey.test.js @@ -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, }); diff --git a/web/tests/lib/events.js b/web/tests/lib/events.js index 5779347625..a1feb5e726 100644 --- a/web/tests/lib/events.js +++ b/web/tests/lib/events.js @@ -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", }, ], }, diff --git a/zerver/lib/event_schema.py b/zerver/lib/event_schema.py index 7ce886d658..b3e49b7c60 100644 --- a/zerver/lib/event_schema.py +++ b/zerver/lib/event_schema.py @@ -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( diff --git a/zerver/lib/hotspots.py b/zerver/lib/hotspots.py index 4fcbe8a4cf..df749a209b 100644 --- a/zerver/lib/hotspots.py +++ b/zerver/lib/hotspots.py @@ -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 diff --git a/zerver/openapi/zulip.yaml b/zerver/openapi/zulip.yaml index ef65e3c9dd..1f91bf0c73 100644 --- a/zerver/openapi/zulip.yaml +++ b/zerver/openapi/zulip.yaml @@ -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: diff --git a/zerver/tests/test_events.py b/zerver/tests/test_events.py index ce20c4ec45..250cf7e2f5 100644 --- a/zerver/tests/test_events.py +++ b/zerver/tests/test_events.py @@ -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: diff --git a/zerver/tests/test_hotspots.py b/zerver/tests/test_hotspots.py index 0da8ec6e37..2ad8c1ba3c 100644 --- a/zerver/tests/test_hotspots.py +++ b/zerver/tests/test_hotspots.py @@ -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"], ) diff --git a/zerver/tests/test_import_export.py b/zerver/tests/test_import_export.py index b94b6077bb..9e685272f5 100644 --- a/zerver/tests/test_import_export.py +++ b/zerver/tests/test_import_export.py @@ -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 diff --git a/zerver/tests/test_users.py b/zerver/tests/test_users.py index 3485ec8b6e..a0d79e3beb 100644 --- a/zerver/tests/test_users.py +++ b/zerver/tests/test_users.py @@ -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") diff --git a/zproject/default_settings.py b/zproject/default_settings.py index 84f5a987bb..f69f7f5fc2 100644 --- a/zproject/default_settings.py +++ b/zproject/default_settings.py @@ -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 diff --git a/zproject/dev_settings.py b/zproject/dev_settings.py index 57c0268541..5c132426ca 100644 --- a/zproject/dev_settings.py +++ b/zproject/dev_settings.py @@ -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! # diff --git a/zproject/prod_settings_template.py b/zproject/prod_settings_template.py index adc18a92cd..289630c8ed 100644 --- a/zproject/prod_settings_template.py +++ b/zproject/prod_settings_template.py @@ -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.