blob: 3a27faa7d7ac80ca11975401b2cf5d2df697a2ca [file] [log] [blame]
// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import {LitElement, html, css} from 'lit-element';
import qs from 'qs';
import {store, connectStore} from 'reducers/base.js';
import * as userV0 from 'reducers/userV0.js';
import * as issueV0 from 'reducers/issueV0.js';
import * as projectV0 from 'reducers/projectV0.js';
import 'elements/chops/chops-button/chops-button.js';
import 'elements/chops/chops-dialog/chops-dialog.js';
import {SHARED_STYLES} from 'shared/shared-styles.js';
import {cueNames} from './cue-helpers.js';
/**
* `<mr-cue>`
*
* An element that displays one of a set of predefined help messages
* iff that message is appropriate to the current user and page.
*
* TODO: Factor this class out into a base view component and separate
* usage-specific components, such as those for user prefs.
*
*/
export class MrCue extends connectStore(LitElement) {
/** @override */
constructor() {
super();
this.prefs = new Map();
this.issue = null;
this.referencedUsers = new Map();
this.nondismissible = false;
this.cuePrefName = '';
this.loginUrl = '';
this.hidden = this._shouldBeHidden(this.signedIn, this.prefsLoaded,
this.cuePrefName, this.message);
}
/** @override */
static get properties() {
return {
issue: {type: Object},
referencedUsers: {type: Object},
user: {type: Object},
cuePrefName: {type: String},
nondismissible: {type: Boolean},
prefs: {type: Object},
prefsLoaded: {type: Boolean},
jumpLocalId: {type: Number},
loginUrl: {type: String},
hidden: {
type: Boolean,
reflect: true,
},
};
}
/** @override */
static get styles() {
return [SHARED_STYLES, css`
:host {
display: block;
margin: 2px 0;
padding: 2px 4px 2px 8px;
background: var(--chops-notice-bubble-bg);
border: var(--chops-notice-border);
text-align: center;
}
:host([centered]) {
display: flex;
justify-content: center;
}
:host([hidden]) {
display: none;
}
button[hidden] {
visibility: hidden;
}
i.material-icons {
font-size: 14px;
}
button {
background: none;
border: none;
float: right;
padding: 2px;
cursor: pointer;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
}
button:hover {
background: rgba(0, 0, 0, .2);
}
`];
}
/** @override */
render() {
return html`
<link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
<button
@click=${this.dismiss}
title="Don't show this message again."
?hidden=${this.nondismissible}>
<i class="material-icons">close</i>
</button>
<div id="message">${this.message}</div>
`;
}
/**
* @return {TemplateResult} lit-html template for the cue message a user
* should see.
*/
get message() {
if (this.cuePrefName === cueNames.CODE_OF_CONDUCT) {
return html`
Please keep discussions respectful and constructive.
`;
} else if (this.cuePrefName === cueNames.AVAILABILITY_MSGS) {
if (this._availablityMsgsRelevant(this.issue)) {
return html`
<b>Note:</b>
Clock icons indicate that users may not be available.
Tooltips show the reason.
`;
}
} else if (this.cuePrefName === cueNames.SWITCH_TO_PARENT_ACCOUNT) {
if (this._switchToParentAccountRelevant()) {
return html`
You are signed in to a linked account.
<a href="${this.loginUrl}">
Switch to ${this.user.linkedParentRef.displayName}</a>.
`;
}
} else if (this.cuePrefName === cueNames.SEARCH_FOR_NUMBERS) {
if (this._searchForNumbersRelevant(this.jumpLocalId)) {
return html`
<b>Tip:</b>
To find issues containing "${this.jumpLocalId}", use quotes.
`;
}
}
return;
}
/**
* Conditionally returns a hardcoded code of conduct URL for
* different projects.
* @return {string} the URL for the code of conduct.
*/
get codeOfConductUrl() {
// TODO(jrobbins): Store this in the DB and pass it via the API.
if (this.projectName === 'fuchsia') {
return 'https://fuchsia.dev/fuchsia-src/CODE_OF_CONDUCT';
}
return ('https://chromium.googlesource.com/' +
'chromium/src/+/main/CODE_OF_CONDUCT.md');
}
/** @override */
updated(changedProperties) {
const hiddenWatchProps = ['prefsLoaded', 'cuePrefName', 'signedIn',
'prefs'];
const shouldUpdateHidden = Array.from(changedProperties.keys())
.some((propName) => hiddenWatchProps.includes(propName));
if (shouldUpdateHidden) {
this.hidden = this._shouldBeHidden(this.signedIn, this.prefsLoaded,
this.cuePrefName, this.message);
}
}
/**
* Checks if there are any unavailable users and only displays this cue if so.
* @param {Issue} issue
* @return {boolean} Whether the User Availability cue should be
* displayed or not.
*/
_availablityMsgsRelevant(issue) {
if (!issue) return false;
return (this._anyUnvailable([issue.ownerRef]) ||
this._anyUnvailable(issue.ccRefs));
}
/**
* Checks if a given list of users contains any unavailable users.
* @param {Array<UserRef>} userRefList
* @return {boolean} Whether there are unavailable users.
*/
_anyUnvailable(userRefList) {
if (!userRefList) return false;
for (const userRef of userRefList) {
if (userRef) {
const participant = this.referencedUsers.get(userRef.displayName);
if (participant && participant.availability) return true;
}
}
}
/**
* Finds if the user has a linked parent account that's separate from the
* one they are logged into and conditionally hides the cue if so.
* @return {boolean} Whether to show the cue to switch to a parent account.
*/
_switchToParentAccountRelevant() {
return this.user && this.user.linkedParentRef;
}
/**
* Determines whether the user should see a cue telling them how to avoid the
* "jump to issue" feature.
* @param {number} jumpLocalId the ID of the issue the user jumped to.
* @return {boolean} Whether the user jumped to a number or not.
*/
_searchForNumbersRelevant(jumpLocalId) {
return !!jumpLocalId;
}
/**
* Checks the user's preferences to hide a particular cue if they have
* dismissed it.
* @param {boolean} signedIn Whether the user is signed in.
* @param {boolean} prefsLoaded Whether the user's prefs have been fetched
* from the API.
* @param {string} cuePrefName The name of the cue being checked.
* @param {string} message
* @return {boolean} Whether the cue should be hidden.
*/
_shouldBeHidden(signedIn, prefsLoaded, cuePrefName, message) {
if (signedIn && !prefsLoaded) return true;
if (this.alreadyDismissed(cuePrefName)) return true;
return !message;
}
/** @override */
stateChanged(state) {
this.projectName = projectV0.viewedProjectName(state);
this.issue = issueV0.viewedIssue(state);
this.referencedUsers = issueV0.referencedUsers(state);
this.user = userV0.currentUser(state);
this.prefs = userV0.prefs(state);
this.signedIn = this.user && this.user.userId;
this.prefsLoaded = userV0.currentUser(state).prefsLoaded;
const queryString = window.location.search.substring(1);
const queryParams = qs.parse(queryString);
const q = queryParams.q;
if (q && q.match(new RegExp('^\\d+$'))) {
this.jumpLocalId = Number(q);
}
}
/**
* Check whether a cue has already been dismissed in a user's
* preferences.
* @param {string} pref The name of the user preference to check.
* @return {boolean} Whether the cue was dismissed or not.
*/
alreadyDismissed(pref) {
return this.prefs && this.prefs.get(pref);
}
/**
* Sends a request to the API to save that a user has dismissed a cue.
* The results of this request update Redux's state, which leads to
* the cue disappearing for the user after the request finishes.
* @return {void}
*/
dismiss() {
const newPrefs = [{name: this.cuePrefName, value: 'true'}];
store.dispatch(userV0.setPrefs(newPrefs, this.signedIn));
}
}
customElements.define('mr-cue', MrCue);