blob: 22b12905f50cf865d101947a70e8c7ba642ea951 [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.
123 See our
124 <a href="${this.codeOfConductUrl}"
125 target="_blank">code of conduct</a>.
126 `;
127 } else if (this.cuePrefName === cueNames.AVAILABILITY_MSGS) {
128 if (this._availablityMsgsRelevant(this.issue)) {
129 return html`
130 <b>Note:</b>
131 Clock icons indicate that users may not be available.
132 Tooltips show the reason.
133 `;
134 }
135 } else if (this.cuePrefName === cueNames.SWITCH_TO_PARENT_ACCOUNT) {
136 if (this._switchToParentAccountRelevant()) {
137 return html`
138 You are signed in to a linked account.
139 <a href="${this.loginUrl}">
140 Switch to ${this.user.linkedParentRef.displayName}</a>.
141 `;
142 }
143 } else if (this.cuePrefName === cueNames.SEARCH_FOR_NUMBERS) {
144 if (this._searchForNumbersRelevant(this.jumpLocalId)) {
145 return html`
146 <b>Tip:</b>
147 To find issues containing "${this.jumpLocalId}", use quotes.
148 `;
149 }
150 }
151 return;
152 }
153
154 /**
155 * Conditionally returns a hardcoded code of conduct URL for
156 * different projects.
157 * @return {string} the URL for the code of conduct.
158 */
159 get codeOfConductUrl() {
160 // TODO(jrobbins): Store this in the DB and pass it via the API.
161 if (this.projectName === 'fuchsia') {
162 return 'https://fuchsia.dev/fuchsia-src/CODE_OF_CONDUCT';
163 }
164 return ('https://chromium.googlesource.com/' +
165 'chromium/src/+/main/CODE_OF_CONDUCT.md');
166 }
167
168 /** @override */
169 updated(changedProperties) {
170 const hiddenWatchProps = ['prefsLoaded', 'cuePrefName', 'signedIn',
171 'prefs'];
172 const shouldUpdateHidden = Array.from(changedProperties.keys())
173 .some((propName) => hiddenWatchProps.includes(propName));
174 if (shouldUpdateHidden) {
175 this.hidden = this._shouldBeHidden(this.signedIn, this.prefsLoaded,
176 this.cuePrefName, this.message);
177 }
178 }
179
180 /**
181 * Checks if there are any unavailable users and only displays this cue if so.
182 * @param {Issue} issue
183 * @return {boolean} Whether the User Availability cue should be
184 * displayed or not.
185 */
186 _availablityMsgsRelevant(issue) {
187 if (!issue) return false;
188 return (this._anyUnvailable([issue.ownerRef]) ||
189 this._anyUnvailable(issue.ccRefs));
190 }
191
192 /**
193 * Checks if a given list of users contains any unavailable users.
194 * @param {Array<UserRef>} userRefList
195 * @return {boolean} Whether there are unavailable users.
196 */
197 _anyUnvailable(userRefList) {
198 if (!userRefList) return false;
199 for (const userRef of userRefList) {
200 if (userRef) {
201 const participant = this.referencedUsers.get(userRef.displayName);
202 if (participant && participant.availability) return true;
203 }
204 }
205 }
206
207 /**
208 * Finds if the user has a linked parent account that's separate from the
209 * one they are logged into and conditionally hides the cue if so.
210 * @return {boolean} Whether to show the cue to switch to a parent account.
211 */
212 _switchToParentAccountRelevant() {
213 return this.user && this.user.linkedParentRef;
214 }
215
216 /**
217 * Determines whether the user should see a cue telling them how to avoid the
218 * "jump to issue" feature.
219 * @param {number} jumpLocalId the ID of the issue the user jumped to.
220 * @return {boolean} Whether the user jumped to a number or not.
221 */
222 _searchForNumbersRelevant(jumpLocalId) {
223 return !!jumpLocalId;
224 }
225
226 /**
227 * Checks the user's preferences to hide a particular cue if they have
228 * dismissed it.
229 * @param {boolean} signedIn Whether the user is signed in.
230 * @param {boolean} prefsLoaded Whether the user's prefs have been fetched
231 * from the API.
232 * @param {string} cuePrefName The name of the cue being checked.
233 * @param {string} message
234 * @return {boolean} Whether the cue should be hidden.
235 */
236 _shouldBeHidden(signedIn, prefsLoaded, cuePrefName, message) {
237 if (signedIn && !prefsLoaded) return true;
238 if (this.alreadyDismissed(cuePrefName)) return true;
239 return !message;
240 }
241
242 /** @override */
243 stateChanged(state) {
244 this.projectName = projectV0.viewedProjectName(state);
245 this.issue = issueV0.viewedIssue(state);
246 this.referencedUsers = issueV0.referencedUsers(state);
247 this.user = userV0.currentUser(state);
248 this.prefs = userV0.prefs(state);
249 this.signedIn = this.user && this.user.userId;
250 this.prefsLoaded = userV0.currentUser(state).prefsLoaded;
251
252 const queryString = window.location.search.substring(1);
253 const queryParams = qs.parse(queryString);
254 const q = queryParams.q;
255 if (q && q.match(new RegExp('^\\d+$'))) {
256 this.jumpLocalId = Number(q);
257 }
258 }
259
260 /**
261 * Check whether a cue has already been dismissed in a user's
262 * preferences.
263 * @param {string} pref The name of the user preference to check.
264 * @return {boolean} Whether the cue was dismissed or not.
265 */
266 alreadyDismissed(pref) {
267 return this.prefs && this.prefs.get(pref);
268 }
269
270 /**
271 * Sends a request to the API to save that a user has dismissed a cue.
272 * The results of this request update Redux's state, which leads to
273 * the cue disappearing for the user after the request finishes.
274 * @return {void}
275 */
276 dismiss() {
277 const newPrefs = [{name: this.cuePrefName, value: 'true'}];
278 store.dispatch(userV0.setPrefs(newPrefs, this.signedIn));
279 }
280}
281
282customElements.define('mr-cue', MrCue);