blob: 5c39d2b4af88d5b61b05a114aa071a1594375483 [file] [log] [blame]
Copybara854996b2021-09-07 19:36:02 +00001// Copyright 2019 The Chromium Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5import {LitElement, html, css} from 'lit-element';
6import qs from 'qs';
7import {store, connectStore} from 'reducers/base.js';
8import * as userV0 from 'reducers/userV0.js';
9import * as issueV0 from 'reducers/issueV0.js';
10import * as projectV0 from 'reducers/projectV0.js';
11import 'elements/chops/chops-button/chops-button.js';
12import 'elements/chops/chops-dialog/chops-dialog.js';
13import {SHARED_STYLES} from 'shared/shared-styles.js';
14import {cueNames} from './cue-helpers.js';
15
16
17/**
18 * `<mr-cue>`
19 *
20 * An element that displays one of a set of predefined help messages
21 * iff that message is appropriate to the current user and page.
22 *
23 * TODO: Factor this class out into a base view component and separate
24 * usage-specific components, such as those for user prefs.
25 *
26 */
27export class MrCue extends connectStore(LitElement) {
28 /** @override */
29 constructor() {
30 super();
31 this.prefs = new Map();
32 this.issue = null;
33 this.referencedUsers = new Map();
34 this.nondismissible = false;
35 this.cuePrefName = '';
36 this.loginUrl = '';
37 this.hidden = this._shouldBeHidden(this.signedIn, this.prefsLoaded,
38 this.cuePrefName, this.message);
39 }
40
41 /** @override */
42 static get properties() {
43 return {
44 issue: {type: Object},
45 referencedUsers: {type: Object},
46 user: {type: Object},
47 cuePrefName: {type: String},
48 nondismissible: {type: Boolean},
49 prefs: {type: Object},
50 prefsLoaded: {type: Boolean},
51 jumpLocalId: {type: Number},
52 loginUrl: {type: String},
53 hidden: {
54 type: Boolean,
55 reflect: true,
56 },
57 };
58 }
59
60 /** @override */
61 static get styles() {
62 return [SHARED_STYLES, css`
63 :host {
64 display: block;
65 margin: 2px 0;
66 padding: 2px 4px 2px 8px;
67 background: var(--chops-notice-bubble-bg);
68 border: var(--chops-notice-border);
69 text-align: center;
70 }
71 :host([centered]) {
72 display: flex;
73 justify-content: center;
74 }
75 :host([hidden]) {
76 display: none;
77 }
78 button[hidden] {
79 visibility: hidden;
80 }
81 i.material-icons {
82 font-size: 14px;
83 }
84 button {
85 background: none;
86 border: none;
87 float: right;
88 padding: 2px;
89 cursor: pointer;
90 border-radius: 50%;
91 display: inline-flex;
92 align-items: center;
93 justify-content: center;
94 }
95 button:hover {
96 background: rgba(0, 0, 0, .2);
97 }
98 `];
99 }
100
101 /** @override */
102 render() {
103 return html`
104 <link href="https://fonts.googleapis.com/icon?family=Material+Icons" rel="stylesheet">
105 <button
106 @click=${this.dismiss}
107 title="Don't show this message again."
108 ?hidden=${this.nondismissible}>
109 <i class="material-icons">close</i>
110 </button>
111 <div id="message">${this.message}</div>
112 `;
113 }
114
115 /**
116 * @return {TemplateResult} lit-html template for the cue message a user
117 * should see.
118 */
119 get message() {
120 if (this.cuePrefName === cueNames.CODE_OF_CONDUCT) {
121 return html`
122 Please keep discussions respectful and constructive.
Copybara854996b2021-09-07 19:36:02 +0000123 `;
124 } else if (this.cuePrefName === cueNames.AVAILABILITY_MSGS) {
125 if (this._availablityMsgsRelevant(this.issue)) {
126 return html`
127 <b>Note:</b>
128 Clock icons indicate that users may not be available.
129 Tooltips show the reason.
130 `;
131 }
132 } else if (this.cuePrefName === cueNames.SWITCH_TO_PARENT_ACCOUNT) {
133 if (this._switchToParentAccountRelevant()) {
134 return html`
135 You are signed in to a linked account.
136 <a href="${this.loginUrl}">
137 Switch to ${this.user.linkedParentRef.displayName}</a>.
138 `;
139 }
140 } else if (this.cuePrefName === cueNames.SEARCH_FOR_NUMBERS) {
141 if (this._searchForNumbersRelevant(this.jumpLocalId)) {
142 return html`
143 <b>Tip:</b>
144 To find issues containing "${this.jumpLocalId}", use quotes.
145 `;
146 }
147 }
148 return;
149 }
150
151 /**
152 * Conditionally returns a hardcoded code of conduct URL for
153 * different projects.
154 * @return {string} the URL for the code of conduct.
155 */
156 get codeOfConductUrl() {
157 // TODO(jrobbins): Store this in the DB and pass it via the API.
158 if (this.projectName === 'fuchsia') {
159 return 'https://fuchsia.dev/fuchsia-src/CODE_OF_CONDUCT';
160 }
161 return ('https://chromium.googlesource.com/' +
162 'chromium/src/+/main/CODE_OF_CONDUCT.md');
163 }
164
165 /** @override */
166 updated(changedProperties) {
167 const hiddenWatchProps = ['prefsLoaded', 'cuePrefName', 'signedIn',
168 'prefs'];
169 const shouldUpdateHidden = Array.from(changedProperties.keys())
170 .some((propName) => hiddenWatchProps.includes(propName));
171 if (shouldUpdateHidden) {
172 this.hidden = this._shouldBeHidden(this.signedIn, this.prefsLoaded,
173 this.cuePrefName, this.message);
174 }
175 }
176
177 /**
178 * Checks if there are any unavailable users and only displays this cue if so.
179 * @param {Issue} issue
180 * @return {boolean} Whether the User Availability cue should be
181 * displayed or not.
182 */
183 _availablityMsgsRelevant(issue) {
184 if (!issue) return false;
185 return (this._anyUnvailable([issue.ownerRef]) ||
186 this._anyUnvailable(issue.ccRefs));
187 }
188
189 /**
190 * Checks if a given list of users contains any unavailable users.
191 * @param {Array<UserRef>} userRefList
192 * @return {boolean} Whether there are unavailable users.
193 */
194 _anyUnvailable(userRefList) {
195 if (!userRefList) return false;
196 for (const userRef of userRefList) {
197 if (userRef) {
198 const participant = this.referencedUsers.get(userRef.displayName);
199 if (participant && participant.availability) return true;
200 }
201 }
202 }
203
204 /**
205 * Finds if the user has a linked parent account that's separate from the
206 * one they are logged into and conditionally hides the cue if so.
207 * @return {boolean} Whether to show the cue to switch to a parent account.
208 */
209 _switchToParentAccountRelevant() {
210 return this.user && this.user.linkedParentRef;
211 }
212
213 /**
214 * Determines whether the user should see a cue telling them how to avoid the
215 * "jump to issue" feature.
216 * @param {number} jumpLocalId the ID of the issue the user jumped to.
217 * @return {boolean} Whether the user jumped to a number or not.
218 */
219 _searchForNumbersRelevant(jumpLocalId) {
220 return !!jumpLocalId;
221 }
222
223 /**
224 * Checks the user's preferences to hide a particular cue if they have
225 * dismissed it.
226 * @param {boolean} signedIn Whether the user is signed in.
227 * @param {boolean} prefsLoaded Whether the user's prefs have been fetched
228 * from the API.
229 * @param {string} cuePrefName The name of the cue being checked.
230 * @param {string} message
231 * @return {boolean} Whether the cue should be hidden.
232 */
233 _shouldBeHidden(signedIn, prefsLoaded, cuePrefName, message) {
234 if (signedIn && !prefsLoaded) return true;
235 if (this.alreadyDismissed(cuePrefName)) return true;
236 return !message;
237 }
238
239 /** @override */
240 stateChanged(state) {
241 this.projectName = projectV0.viewedProjectName(state);
242 this.issue = issueV0.viewedIssue(state);
243 this.referencedUsers = issueV0.referencedUsers(state);
244 this.user = userV0.currentUser(state);
245 this.prefs = userV0.prefs(state);
246 this.signedIn = this.user && this.user.userId;
247 this.prefsLoaded = userV0.currentUser(state).prefsLoaded;
248
249 const queryString = window.location.search.substring(1);
250 const queryParams = qs.parse(queryString);
251 const q = queryParams.q;
252 if (q && q.match(new RegExp('^\\d+$'))) {
253 this.jumpLocalId = Number(q);
254 }
255 }
256
257 /**
258 * Check whether a cue has already been dismissed in a user's
259 * preferences.
260 * @param {string} pref The name of the user preference to check.
261 * @return {boolean} Whether the cue was dismissed or not.
262 */
263 alreadyDismissed(pref) {
264 return this.prefs && this.prefs.get(pref);
265 }
266
267 /**
268 * Sends a request to the API to save that a user has dismissed a cue.
269 * The results of this request update Redux's state, which leads to
270 * the cue disappearing for the user after the request finishes.
271 * @return {void}
272 */
273 dismiss() {
274 const newPrefs = [{name: this.cuePrefName, value: 'true'}];
275 store.dispatch(userV0.setPrefs(newPrefs, this.signedIn));
276 }
277}
278
279customElements.define('mr-cue', MrCue);