personal_menu: Add tippy personal_menu dropdown.

Add a personal menu dropdown that opens on clicking user avatar icon
in navbar added in previous commit.

The args passed to render_personal_menu() in onShow() are returned by
get_personal_menu_content_context() in popover_menus_data.js so that
they can be unit tested.

Additionally, added CSS to get a custom arrow for dropdown menu.

Added a `?` hotkey in keyboard shortcuts option in personal_menu
dropdown in a style similar to our tooltip's hotkey by adding
? in a span with class .tooltip-hotkey-hint and adding some CSS.

Fixes part of #22802.
This commit is contained in:
Hardik Dharmani 2023-07-17 21:31:16 +05:30 committed by Tim Abbott
parent 5fd8b95454
commit 49f7f02a0a
16 changed files with 536 additions and 6 deletions

View File

@ -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",

View File

@ -537,6 +537,12 @@ export async function open_streams_modal(page: Page): Promise<void> {
assert.ok(url.includes("#streams/all"));
}
export async function open_personal_menu(page: Page): Promise<void> {
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<void> {
const menu_selector = "#settings-dropdown";
await page.waitForSelector(menu_selector, {visible: true});

View File

@ -19,9 +19,9 @@ async function open_menu(page: Page): Promise<void> {
async function navigate_to_settings(page: Page): Promise<void> {
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);

View File

@ -18,11 +18,9 @@ async function get_decoded_url_in_selector(page: Page, selector: string): Promis
}
async function open_settings(page: Page): Promise<void> {
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);

View File

@ -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();
});
}

View File

@ -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;
},
});
}

View File

@ -34,6 +34,7 @@ export const popover_instances = {
topics_menu: null,
send_later: null,
change_visibility_policy: null,
personal_menu: null,
};
/* Keyboard UI functions */

View File

@ -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),
};
}

View File

@ -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,
});

View File

@ -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) {

View File

@ -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);

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -56,6 +56,7 @@
<a class="header-button tippy-zulip-delayed-tooltip" tabindex="0" role="button" data-tippy-content="{{t 'Personal menu' }}">
<img class="header-button-avatar" src="{{user_avatar}}"/>
</a>
<span tabindex="0" class="focus-dropdown"></span>
</div>
</div>
</nav>

View File

@ -0,0 +1,117 @@
<div class="dropdown-menu" id="personal-menu-dropdown" data-simplebar>
<nav class="inner">
<header class="personal-menu-header">
<div class="avatar">
<img class="avatar-image{{#if user_is_guest}} guest-avatar{{/if}}" src="{{user_avatar}}"/>
{{#if is_active }}
<span class="status-circle {{user_circle_class}} user_circle hidden-for-spectators" data-tippy-placement="bottom" data-tippy-content="{{user_last_seen_time_status}}"></span>
{{/if}}
</div>
<div class="text-area">
<p class="full-name">{{user_full_name}}</p>
<p class="user-type">{{user_type}}</p>
</div>
</header>
<section class="popover-menu personal-menu-actions" data-user-id="{{user_id}}">
<ul class="list">
<li class="text-item hidden-for-spectators">
<i class="zulip-icon zulip-icon-clock"></i>
{{#tr}}{user_time} local time{{/tr}}
</li>
</ul>
<ul class="list">
{{#if status_content_available}}
<li class="text-item">
<span>
{{#if status_emoji_info}}
{{#if status_emoji_info.emoji_alt_code}}
<span class="emoji_alt_code">&nbsp;:{{status_emoji_info.emoji_name}}:</span>
{{else if status_emoji_info.url}}
<img src="{{status_emoji_info.url}}" class="emoji status_emoji" />
{{else}}
<span class="emoji status_emoji emoji-{{status_emoji_info.emoji_code}}"></span>
{{/if}}
{{/if}}
<span class="status_text">
{{status_text}}
</span>
</span>
<a href="" class="clear_status" aria-label="{{t 'Clear status'}}" data-tippy-content="{{t 'Clear your status' }}">
<i class="zulip-icon zulip-icon-x-circle"></i>
</a>
</li>
<li class="link-item">
<a href="" class="update_status_text">
<i class="zulip-icon zulip-icon-smile"></i>
{{#tr}}Edit status{{/tr}}
</a>
</li>
{{else}}
<li class="link-item hidden-for-spectators">
<a href="" class="update_status_text">
<i class="zulip-icon zulip-icon-smile"></i>
{{#tr}}Set status{{/tr}}
</a>
</li>
{{/if}}
{{#if invisible_mode}}
<li class="link-item hidden-for-spectators">
<a href="" class="invisible_mode_turn_off">
<i class="zulip-icon zulip-icon-play-circle"></i>
{{#tr}}Turn off invisible mode{{/tr}}
</a>
</li>
{{else}}
<li class="link-item hidden-for-spectators">
<a href="" class="invisible_mode_turn_on">
<i class="zulip-icon zulip-icon-stop-circle"></i>
{{#tr}}Go invisible{{/tr}}
</a>
</li>
{{/if}}
</ul>
<ul class="list">
<li class="link-item">
<a tabindex="0" class="view_full_user_profile">
<i class="zulip-icon zulip-icon-account"></i>
{{#tr}}View your profile{{/tr}}
</a>
</li>
<li class="link-item">
<a href="" class="narrow-self-direct-message">
<i class="zulip-icon zulip-icon-users"></i>
{{#tr}}View messages with yourself{{/tr}}
</a>
</li>
<li class="link-item">
<a href="" class="narrow-messages-sent">
<i class="zulip-icon zulip-icon-message-square"></i>
{{#tr}}View messages sent{{/tr}}
</a>
</li>
</ul>
<ul class="list">
<li class="link-item">
<a href="#settings/profile" class="open-profile-settings">
<i class="zulip-icon zulip-icon-tool"></i>
{{#tr}}Settings{{/tr}}
</a>
</li>
</ul>
<ul class="list">
<li class="link-item" role="presentation">
<a class="logout_button hidden-for-spectators" tabindex="0">
<i class="zulip-icon zulip-icon-power" aria-hidden="true"></i>
{{t 'Log out' }}
</a>
</li>
</ul>
</section>
</nav>
</div>

View File

@ -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");