diff --git a/tools/test-js-with-node b/tools/test-js-with-node index a37364fd74..15c05e76bb 100755 --- a/tools/test-js-with-node +++ b/tools/test-js-with-node @@ -156,6 +156,7 @@ EXEMPT_FILES = make_set( "web/src/overlays.ts", "web/src/padded_widget.ts", "web/src/page_params.ts", + "web/src/personal_menu_popover.js", "web/src/playground_links_popover.js", "web/src/plotly.js.d.ts", "web/src/pm_list.js", diff --git a/web/e2e-tests/lib/common.ts b/web/e2e-tests/lib/common.ts index 7e124f7419..b30e076acd 100644 --- a/web/e2e-tests/lib/common.ts +++ b/web/e2e-tests/lib/common.ts @@ -537,6 +537,12 @@ export async function open_streams_modal(page: Page): Promise { assert.ok(url.includes("#streams/all")); } +export async function open_personal_menu(page: Page): Promise { + const menu_selector = "#personal-menu"; + await page.waitForSelector(menu_selector, {visible: true}); + await page.click(menu_selector); +} + export async function manage_organization(page: Page): Promise { const menu_selector = "#settings-dropdown"; await page.waitForSelector(menu_selector, {visible: true}); diff --git a/web/e2e-tests/navigation.test.ts b/web/e2e-tests/navigation.test.ts index be48254ffa..882a801aa9 100644 --- a/web/e2e-tests/navigation.test.ts +++ b/web/e2e-tests/navigation.test.ts @@ -19,9 +19,9 @@ async function open_menu(page: Page): Promise { async function navigate_to_settings(page: Page): Promise { console.log("Navigating to settings"); - await open_menu(page); + await common.open_personal_menu(page); - const settings_selector = ".dropdown-menu a[href^='#settings']"; + const settings_selector = "#personal-menu-dropdown a[href^='#settings']"; await page.waitForSelector(settings_selector, {visible: true}); await page.click(settings_selector); diff --git a/web/e2e-tests/settings.test.ts b/web/e2e-tests/settings.test.ts index 92bd75f677..8d0d7b9052 100644 --- a/web/e2e-tests/settings.test.ts +++ b/web/e2e-tests/settings.test.ts @@ -18,11 +18,9 @@ async function get_decoded_url_in_selector(page: Page, selector: string): Promis } async function open_settings(page: Page): Promise { - const menu_selector = "#settings-dropdown"; - await page.waitForSelector(menu_selector, {visible: true}); - await page.click(menu_selector); + await common.open_personal_menu(page); - const settings_selector = '.dropdown-menu a[href="#settings"]'; + const settings_selector = "#personal-menu-dropdown a[href^='#settings']"; await page.waitForSelector(settings_selector, {visible: true}); await page.click(settings_selector); diff --git a/web/src/click_handlers.js b/web/src/click_handlers.js index d8ed8d8667..6c0e178b6d 100644 --- a/web/src/click_handlers.js +++ b/web/src/click_handlers.js @@ -926,4 +926,10 @@ export function initialize() { $(".settings-header.mobile .fa-chevron-left").on("click", () => { settings_panel_menu.mobile_deactivate_section(); }); + + $("body").on("click", ".trigger-natural-click", (e) => { + // Jquery prevents default action on anchor for `trigger("click")` + // so we need to use click on element to trigger the default action. + e.currentTarget.click(); + }); } diff --git a/web/src/personal_menu_popover.js b/web/src/personal_menu_popover.js new file mode 100644 index 0000000000..0f588c6fa4 --- /dev/null +++ b/web/src/personal_menu_popover.js @@ -0,0 +1,114 @@ +import $ from "jquery"; +import tippy from "tippy.js"; + +import render_personal_menu from "../templates/personal_menu.hbs"; + +import * as gear_menu from "./gear_menu"; +import * as narrow from "./narrow"; +import {page_params} from "./page_params"; +import * as people from "./people"; +import * as popover_menus from "./popover_menus"; +import * as popover_menus_data from "./popover_menus_data"; +import * as popovers from "./popovers"; +import {parse_html} from "./ui_util"; +import * as user_profile from "./user_profile"; +import * as user_status from "./user_status"; + +function elem_to_user_id($elem) { + return Number.parseInt($elem.attr("data-user-id"), 10); +} + +export function initialize() { + popover_menus.register_popover_menu("#personal-menu", { + placement: "bottom", + offset: [-50, 0], + // The strategy: "fixed"; and eventlisteners modifier option + // ensure that the personal menu does not modify its position + // or disappear when user zooms the page. + popperOptions: { + strategy: "fixed", + modifiers: [ + { + name: "eventListeners", + options: { + scroll: false, + }, + }, + ], + }, + onMount(instance) { + const $popper = $(instance.popper); + $popper.addClass("personal-menu-tippy"); + popover_menus.popover_instances.personal_menu = instance; + + // Workaround for the gear menu not being a tippy popover + // and thus not auto-closing. + gear_menu.close(); + + tippy(".clear_status", { + placement: "top", + appendTo: document.body, + interactive: true, + }); + + $popper.one("click", ".clear_status", (e) => { + e.preventDefault(); + const me = page_params.user_id; + user_status.server_update_status({ + user_id: me, + status_text: "", + emoji_name: "", + emoji_code: "", + success() { + instance.hide(); + }, + }); + }); + + $popper.one("click", ".personal-menu-actions .view_full_user_profile", (e) => { + const user_id = elem_to_user_id($(e.target).closest(".personal-menu-actions")); + const user = people.get_by_user_id(user_id); + popovers.hide_all(); + user_profile.show_user_profile(user); + e.preventDefault(); + }); + + $popper.one("click", ".narrow-self-direct-message", (e) => { + const user_id = page_params.user_id; + const email = people.get_by_user_id(user_id).email; + narrow.by("dm", email, {trigger: "personal menu"}); + popovers.hide_all(); + e.preventDefault(); + }); + + $popper.one("click", ".narrow-messages-sent", (e) => { + const user_id = page_params.user_id; + const email = people.get_by_user_id(user_id).email; + narrow.by("sender", email, {trigger: "personal menu"}); + popovers.hide_all(); + e.preventDefault(); + }); + + $popper.one("click", ".open-profile-settings", (e) => { + e.currentTarget.click(); + popovers.hide_all(); + e.preventDefault(); + }); + + $(".focus-dropdown").on("focus", (e) => { + e.preventDefault(); + $popper.find("li:visible a").eq(0).trigger("focus"); + }); + + instance.popperInstance.update(); + }, + onShow(instance) { + const args = popover_menus_data.get_personal_menu_content_context(); + instance.setContent(parse_html(render_personal_menu(args))); + }, + onHidden(instance) { + instance.destroy(); + popover_menus.popover_instances.personal_menu = undefined; + }, + }); +} diff --git a/web/src/popover_menus.js b/web/src/popover_menus.js index ca26b39568..139e41ea95 100644 --- a/web/src/popover_menus.js +++ b/web/src/popover_menus.js @@ -34,6 +34,7 @@ export const popover_instances = { topics_menu: null, send_later: null, change_visibility_policy: null, + personal_menu: null, }; /* Keyboard UI functions */ diff --git a/web/src/popover_menus_data.js b/web/src/popover_menus_data.js index e0d4dff3e7..72dc153b4e 100644 --- a/web/src/popover_menus_data.js +++ b/web/src/popover_menus_data.js @@ -3,6 +3,7 @@ import * as resolved_topic from "../shared/src/resolved_topic"; +import * as buddy_data from "./buddy_data"; import * as hash_util from "./hash_util"; import {$t} from "./i18n"; import * as message_edit from "./message_edit"; @@ -10,10 +11,13 @@ import * as message_lists from "./message_lists"; import * as muted_users from "./muted_users"; import * as narrow_state from "./narrow_state"; import {page_params} from "./page_params"; +import * as people from "./people"; import * as settings_data from "./settings_data"; import * as starred_messages from "./starred_messages"; import * as stream_data from "./stream_data"; import * as sub_store from "./sub_store"; +import {user_settings} from "./user_settings"; +import * as user_status from "./user_status"; import * as user_topics from "./user_topics"; export function get_actions_popover_content_context(message_id) { @@ -154,3 +158,30 @@ export function get_change_visibility_policy_popover_content_context(stream_id, all_visibility_policies, }; } + +export function get_personal_menu_content_context() { + const my_user_id = page_params.user_id; + const invisible_mode = !user_settings.presence_enabled; + const status_text = user_status.get_status_text(my_user_id); + const status_emoji_info = user_status.get_status_emoji(my_user_id); + return { + user_id: my_user_id, + invisible_mode, + user_is_guest: page_params.is_guest, + spectator_view: page_params.is_spectator, + + // user information + user_avatar: page_params.avatar_url_medium, + is_active: people.is_active_user_for_popover(my_user_id), + user_circle_class: buddy_data.get_user_circle_class(my_user_id), + user_last_seen_time_status: buddy_data.user_last_seen_time_status(my_user_id), + user_full_name: page_params.full_name, + user_type: people.get_user_type(my_user_id), + + // user status + status_content_available: Boolean(status_text || status_emoji_info), + status_text, + status_emoji_info, + user_time: people.get_user_time(my_user_id), + }; +} diff --git a/web/src/tippyjs.js b/web/src/tippyjs.js index e7aa60d69a..0a239dc534 100644 --- a/web/src/tippyjs.js +++ b/web/src/tippyjs.js @@ -194,6 +194,7 @@ export function initialize() { "#add_streams_tooltip", "#filter_streams_tooltip", ".error-icon-message-recipient .zulip-icon", + "#personal-menu-dropdown .status-circle", ], appendTo: () => document.body, }); diff --git a/web/src/ui_init.js b/web/src/ui_init.js index f22e6a3b78..27429c9f17 100644 --- a/web/src/ui_init.js +++ b/web/src/ui_init.js @@ -79,6 +79,7 @@ import * as navigate from "./navigate"; import * as overlays from "./overlays"; import {page_params} from "./page_params"; import * as people from "./people"; +import * as personal_menu_popover from "./personal_menu_popover"; import * as playground_links_popover from "./playground_links_popover"; import * as pm_conversations from "./pm_conversations"; import * as pm_list from "./pm_list"; @@ -709,6 +710,7 @@ export function initialize_everything() { user_group_popover.initialize(); user_card_popover.initialize(); playground_links_popover.initialize(); + personal_menu_popover.initialize(); pm_list.initialize(); topic_list.initialize({ on_topic_click(stream_id, topic) { diff --git a/web/src/user_events.js b/web/src/user_events.js index a1e0a1bc08..5cc9c8aeab 100644 --- a/web/src/user_events.js +++ b/web/src/user_events.js @@ -118,6 +118,7 @@ export const update_person = function update(person) { page_params.avatar_url = url; page_params.avatar_url_medium = person.avatar_url_medium; $("#user-avatar-upload-widget .image-block").attr("src", person.avatar_url_medium); + $("#personal-menu .header-button-avatar").attr("src", `${person.avatar_url_medium}`); } message_live_update.update_avatar(person_obj.user_id, person.avatar_url); diff --git a/web/styles/popovers.css b/web/styles/popovers.css index 2342dca6d0..f0c6b1a96c 100644 --- a/web/styles/popovers.css +++ b/web/styles/popovers.css @@ -1033,3 +1033,161 @@ ul { border: 1px solid hsl(0deg 0% 0% / 20%); } } + +.personal-menu-header { + display: flex; + flex-flow: row nowrap; + gap: 7px; + text-align: left; + padding: 4px; + + .avatar { + position: relative; + width: 64px; + height: 64px; + } + + .avatar-image { + width: 64px; + height: 64px; + border-radius: 4px; + background-size: cover; + background-position: center; + } + + .status-circle { + position: absolute; + width: 8px; + height: 8px; + top: unset; + left: unset; + right: -1px; + bottom: -1px; + border: solid 1px var(--color-background); + border-radius: 50%; + } + + .user_circle_empty { + background-color: var(--color-background-dropdown-menu); + border-color: hsl(0deg 0% 50%); + } + + .text-area { + flex-grow: 1; + padding-top: 4px; + + & p { + margin: 0 0 4px; + } + } + + .full-name { + font-size: 18px; + font-weight: 600; + line-height: 20px; + color: var(--color-text-full-name) !important; + max-width: 150px; + word-break: break-word; + } + + .user-type { + font-size: 14px; + font-weight: 400; + line-height: 16px; + color: var(--color-text-item) !important; + } +} + +.personal-menu-actions { + display: flex; + flex-flow: column nowrap; + + .list { + margin: 0; + padding: 4px 0; + list-style: none; + border-bottom: solid 1px var(--color-border-popover-menu); + background: none !important; + + &:last-child { + border-bottom: none; + } + } + + .text-item, + .link-item a { + display: flex; + flex-flow: row nowrap; + align-items: center; + gap: 5px; + padding: 5px 10px; + font-size: 15px; + line-height: 16px; + + & i { + width: 16px; + height: 16px; + font-size: 16px; + text-align: center; + } + } + + .text-item { + color: var(--color-text-item); + width: auto; + user-select: text; + } + + .link-item { + outline: none; + + &:focus-within { + background: var(--color-background-hover-dropdown-menu); + } + + & i { + color: var(--color-icon-purple); + } + + & a { + color: var(--color-text-dropdown-menu) !important; + text-decoration: none; + display: flex; + flex-flow: row nowrap; + flex-grow: 1; + align-items: center; + gap: 5px; + padding: 5px 10px; + + &:hover { + background: var(--color-background-hover-dropdown-menu); + outline: none; + } + + &:focus { + border-radius: 4px; + /* Override the default focus style */ + outline: 1px solid var(--color-outline-focus) !important; + outline-offset: -1px; + } + + &:active { + background: var(--color-background-active-dropdown-menu); + } + } + } + + .clear_status { + margin-left: auto; + color: hsl(0deg 0% 40%) !important; + + &:hover { + text-decoration: none; + } + } + + .status_emoji { + width: 16px; + height: 16px; + } +} diff --git a/web/styles/zulip.css b/web/styles/zulip.css index 3f0818accb..6c2cd7eff3 100644 --- a/web/styles/zulip.css +++ b/web/styles/zulip.css @@ -197,6 +197,14 @@ body { --color-search-shadow-tight: hsl(0deg 0% 0% / 10%); --color-search-dropdown-top-border: hsla(0deg 0% 0% / 10%); --color-background-image-loader: hsl(0deg 0% 0% / 10%); + --color-icon-purple: hsl(240deg 35% 60%); + --color-background-dropdown-menu: hsl(0deg 0% 100%); + --color-border-popover-menu: hsl(0deg 0% 0% / 10%); + --color-hotkey-hint: hsl(227deg 78% 59%); + --color-background-hover-dropdown-menu: hsl(220deg 12% 5% / 5%); + --color-background-active-dropdown-menu: hsl(220deg 12% 5% / 7%); + --color-border-dropdown-menu: hsl(0deg 0% 0% / 40%); + --color-border-personal-menu-avatar: hsl(0deg 0% 0% / 10%); --color-background-unread-counter: hsl(105deg 2% 50%); --color-background-unread-counter-popover-menu: hsl(200deg 100% 40%); @@ -228,6 +236,9 @@ body { --color-text-search: hsl(0deg 0% 35%); --color-text-search-hover: hsl(0deg 0% 0%); --color-text-search-placeholder: hsl(0deg 0% 50%); + --color-text-dropdown-menu: hsl(0deg 0% 15%); + --color-text-full-name: hsl(0deg 0% 15%); + --color-text-item: hsl(0deg 0% 40%); /* Icon colors */ --color-icon-bot: hsl(180deg 8% 65% / 100%); @@ -312,6 +323,13 @@ body { --color-search-box-hover-shadow: hsl(0deg 0% 0% / 30%); --color-search-dropdown-top-border: hsla(0deg 0% 0% / 35%); --color-background-image-loader: hsl(0deg 0% 100% / 10%); + --color-background-dropdown-menu: hsl(0deg 0% 17%); + --color-border-popover-menu: hsl(0deg 0% 0% / 40%); + --color-hotkey-hint: hsl(225deg 100% 84%); + --color-background-hover-dropdown-menu: hsl(220deg 12% 95% / 5%); + --color-background-active-dropdown-menu: hsl(220deg 12% 95% / 7%); + --color-border-dropdown-menu: hsl(0deg 0% 0%); + --color-border-personal-menu-avatar: hsl(0deg 0% 100% / 20%); --color-background-unread-counter: hsl(105deg 2% 50% / 50%); --color-background-unread-counter-popover-menu: hsl(105deg 2% 50% / 50%); @@ -349,6 +367,9 @@ body { --color-text-search: hsl(0deg 0% 100% / 75%); --color-text-search-hover: hsl(0deg 0% 100%); --color-text-search-placeholder: hsl(0deg 0% 100% / 50%); + --color-text-dropdown-menu: hsl(0deg 0% 100% / 80%); + --color-text-full-name: hsl(0deg 0% 100%); + --color-text-item: hsl(0deg 0% 50%); /* Icon colors */ --color-icon-bot: hsl(180deg 5% 50% / 100%); @@ -3451,5 +3472,74 @@ select.invite-as { background-size: cover; border-radius: 4px; background-color: var(--color-background-image-loader); + border: 1px solid var(--color-border-personal-menu-avatar); + } +} + +#personal-menu-dropdown { + display: block; + margin: 0; + position: relative; + padding: 0; + border: solid 1px var(--color-border-dropdown-menu); + background-color: var(--color-background-dropdown-menu); + max-height: 85vh; + min-width: 230px; + overflow-x: hidden; + user-select: none; + + .simplebar-content { + min-width: max-content; + } + + .inner { + border-radius: 6px; + overflow: hidden; + } +} + +.personal-menu-tippy { + .tippy-box { + border: 0; + } + + .tippy-content { + padding: 0; + } + + & > .tippy-box > .tippy-arrow { + position: absolute; + left: 50%; + top: -5.5px !important; + width: 16px; + height: 8px; + z-index: 1; + transform: translateX(-50%); + filter: drop-shadow(0 -1.25px 0 var(--color-border-dropdown-menu)); + + &::before { + display: none; + } + + &::after { + border: 0 !important; + content: ""; + top: 0; + width: 100%; + height: 100%; + mask-image: url("../shared/icons/popover-arrow.svg"); + mask-size: contain; + mask-repeat: no-repeat; + background-color: var(--color-background-dropdown-menu); + } + } +} + +.keyboard-shortcut-option { + & span.tooltip-hotkey-hint { + margin-left: auto; + padding: 0 4px; + color: var(--color-hotkey-hint); + border-color: var(--color-hotkey-hint); } } diff --git a/web/templates/navbar.hbs b/web/templates/navbar.hbs index 334a0d4a8a..8a76e45717 100644 --- a/web/templates/navbar.hbs +++ b/web/templates/navbar.hbs @@ -56,6 +56,7 @@ + diff --git a/web/templates/personal_menu.hbs b/web/templates/personal_menu.hbs new file mode 100644 index 0000000000..02e7d844bc --- /dev/null +++ b/web/templates/personal_menu.hbs @@ -0,0 +1,117 @@ + diff --git a/web/tests/user_events.test.js b/web/tests/user_events.test.js index fd87d53f74..95dcf78bb2 100644 --- a/web/tests/user_events.test.js +++ b/web/tests/user_events.test.js @@ -5,6 +5,7 @@ const {strict: assert} = require("assert"); const {mock_esm, zrequire} = require("./lib/namespace"); const {run_test} = require("./lib/test"); const blueslip = require("./lib/zblueslip"); +const $ = require("./lib/zjquery"); const {page_params} = require("./lib/zpage_params"); const message_live_update = mock_esm("../src/message_live_update"); @@ -206,6 +207,8 @@ run_test("updates", () => { assert.equal(user_id, isaac.user_id); assert.equal(person.avatar_url, avatar_url); + $("#personal-menu .header-button-avatar").css = () => {}; + user_events.update_person({user_id: me.user_id, avatar_url: "http://gravatar.com/789456"}); person = people.get_by_email(me.email); assert.equal(person.full_name, "Me V2");