blob: ba14d722e7c3898f58d92e8eeaf94a457e0106b1 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001// Copyright 2019 The Chromium Authors
Copybara854996b2021-09-07 19:36:02 +00002// 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 {store} from 'reducers/base.js';
7import * as issueV0 from 'reducers/issueV0.js';
8
9import 'elements/chops/chops-button/chops-button.js';
10import 'elements/chops/chops-timestamp/chops-timestamp.js';
11import 'elements/framework/mr-comment-content/mr-comment-content.js';
12import 'elements/framework/mr-comment-content/mr-attachment.js';
13import 'elements/framework/mr-dropdown/mr-dropdown.js';
14import 'elements/framework/links/mr-issue-link/mr-issue-link.js';
15import 'elements/framework/links/mr-user-link/mr-user-link.js';
16import {SHARED_STYLES} from 'shared/shared-styles.js';
17import {issueStringToRef} from 'shared/convertersV0.js';
18import {prpcClient} from 'prpc-client-instance.js';
19import 'shared/typedef.js';
20
21const ISSUE_REF_FIELD_NAMES = [
22 'Blocking',
23 'Blockedon',
24 'Mergedinto',
25];
26
27/**
28 * `<mr-comment>`
29 *
30 * A component for an individual comment.
31 *
32 */
33export class MrComment extends LitElement {
34 /** @override */
35 constructor() {
36 super();
37
38 this._isExpandedIfDeleted = false;
39 }
40
41 /** @override */
42 static get properties() {
43 return {
44 comment: {type: Object},
45 headingLevel: {type: String},
46 highlighted: {
47 type: Boolean,
48 reflect: true,
49 },
50 commenterIsMember: {type: Boolean},
51 _isExpandedIfDeleted: {type: Boolean},
52 _showOriginalContent: {type: Boolean},
53 };
54 }
55
56 /** @override */
57 updated(changedProperties) {
58 super.updated(changedProperties);
59
60 if (changedProperties.has('highlighted') && this.highlighted) {
61 window.requestAnimationFrame(() => {
62 this.scrollIntoView();
63 // TODO(ehmaldonado): Figure out a way to get the height from the issue
64 // header, and scroll by that amount.
65 window.scrollBy(0, -150);
66 });
67 }
68 }
69
70 /** @override */
71 static get styles() {
72 return [
73 SHARED_STYLES,
74 css`
75 :host {
76 display: block;
77 margin: 1.5em 0 0 0;
78 }
79 :host([highlighted]) {
80 border: 1px solid var(--chops-primary-accent-color);
81 box-shadow: 0 0 4px 4px var(--chops-active-choice-bg);
82 }
83 :host([hidden]) {
84 display: none;
85 }
86 .comment-header {
87 background: var(--chops-card-heading-bg);
88 padding: 3px 1px 1px 8px;
89 width: 100%;
90 display: flex;
91 flex-direction: row;
92 justify-content: space-between;
93 align-items: center;
94 box-sizing: border-box;
95 }
96 .comment-header a {
97 display: inline-flex;
98 }
99 .role-label {
100 background-color: var(--chops-gray-600);
101 border-radius: 3px;
102 color: var(--chops-white);
103 display: inline-block;
104 padding: 2px 4px;
105 font-size: 75%;
106 font-weight: bold;
107 line-height: 14px;
108 vertical-align: text-bottom;
109 margin-left: 16px;
110 }
111 .comment-options {
112 float: right;
113 text-align: right;
114 text-decoration: none;
115 }
116 .comment-body {
117 margin: 4px;
118 box-sizing: border-box;
119 }
120 .deleted-comment-notice {
121 margin-left: 4px;
122 }
123 .issue-diff {
124 background: var(--chops-card-details-bg);
125 display: inline-block;
126 padding: 4px 8px;
127 width: 100%;
128 box-sizing: border-box;
129 }
130 `,
131 ];
132 }
133
134 /** @override */
135 render() {
136 return html`
137 ${this._renderHeading()}
138 ${_shouldShowComment(this._isExpandedIfDeleted, this.comment) ? html`
139 ${this._renderDiff()}
140 ${this._renderBody()}
141 ` : ''}
142 `;
143 }
144
145 /**
146 * @return {TemplateResult}
147 */
148 _renderHeading() {
149 return html`
150 <div
151 role="heading"
152 aria-level=${this.headingLevel}
153 class="comment-header">
154 <div>
155 <a
156 href="?id=${this.comment.localId}#c${this.comment.sequenceNum}"
157 class="comment-link"
158 >Comment ${this.comment.sequenceNum}</a>
159
160 ${this._renderByline()}
161 </div>
162 ${_shouldOfferCommentOptions(this.comment) ? html`
163 <div class="comment-options">
164 <mr-dropdown
165 .items=${this._commentOptions}
166 label="Comment options"
167 icon="more_vert"
168 ></mr-dropdown>
169 </div>
170 ` : ''}
171 </div>
172 `;
173 }
174
175 /**
176 * @return {TemplateResult}
177 */
178 _renderByline() {
179 if (_shouldShowComment(this._isExpandedIfDeleted, this.comment)) {
180 return html`
181 by
182 <mr-user-link .userRef=${this.comment.commenter}></mr-user-link>
183 on
184 <chops-timestamp
185 .timestamp=${this.comment.timestamp}
186 ></chops-timestamp>
187 ${this.commenterIsMember && !this.comment.isDeleted ? html`
188 <span class="role-label">Project Member</span>` : ''}
189 `;
190 } else {
191 return html`<span class="deleted-comment-notice">Deleted</span>`;
192 }
193 }
194
195 /**
196 * @return {TemplateResult}
197 */
198 _renderDiff() {
199 if (!(this.comment.descriptionNum || this.comment.amendments)) return '';
200
201 return html`
202 <div class="issue-diff">
203 ${(this.comment.amendments || []).map((delta) => html`
204 <strong>${delta.fieldName}:</strong>
205 ${_issuesForAmendment(delta, this.comment.projectName).map((issueForAmendment) => html`
206 <mr-issue-link
207 projectName=${this.comment.projectName}
208 .issue=${issueForAmendment.issue}
209 text=${issueForAmendment.text}
210 ></mr-issue-link>
211 `)}
212 ${!_amendmentHasIssueRefs(delta.fieldName) ? delta.newOrDeltaValue : ''}
213 ${delta.oldValue ? `(was: ${delta.oldValue})` : ''}
214 <br>
215 `)}
216 ${this.comment.descriptionNum ? 'Description was changed.' : ''}
217 </div><br>
218 `;
219 }
220
221 /**
222 * @return {TemplateResult}
223 */
224 _renderBody() {
225 const commentContent = this._showOriginalContent ?
226 this.comment.inboundMessage :
227 this.comment.content;
228 return html`
229 <div class="comment-body">
230 <mr-comment-content
231 ?hidden=${this.comment.descriptionNum}
232 .content=${commentContent}
233 .author=${this.comment.commenter.displayName}
234 ?isDeleted=${this.comment.isDeleted}
235 ></mr-comment-content>
236 <div ?hidden=${this.comment.descriptionNum}>
237 ${(this.comment.attachments || []).map((attachment) => html`
238 <mr-attachment
239 .attachment=${attachment}
240 projectName=${this.comment.projectName}
241 localId=${this.comment.localId}
242 sequenceNum=${this.comment.sequenceNum}
243 ?canDelete=${this.comment.canDelete}
244 ></mr-attachment>
245 `)}
246 </div>
247 </div>
248 `;
249 }
250
251 /**
252 * Displays three dot menu options available to the current user for a given
253 * comment.
254 * @return {Array<MenuItem>}
255 */
256 get _commentOptions() {
257 const options = [];
258 if (_canExpandDeletedComment(this.comment)) {
259 const text =
260 (this._isExpandedIfDeleted ? 'Hide' : 'Show') + ' comment content';
261 options.push({
262 text: text,
263 handler: this._toggleHideDeletedComment.bind(this),
264 });
265 options.push({separator: true});
266 }
267 if (this.comment.canDelete) {
268 const text =
269 (this.comment.isDeleted ? 'Undelete' : 'Delete') + ' comment';
270 options.push({
271 text: text,
272 handler: _deleteComment.bind(null, this.comment),
273 });
274 }
275 if (this.comment.canFlag) {
276 const text = (this.comment.isSpam ? 'Unflag' : 'Flag') + ' comment';
277 options.push({
278 text: text,
279 handler: _flagComment.bind(null, this.comment),
280 });
281 }
282 if (this.comment.inboundMessage) {
283 const text =
284 (this._showOriginalContent ? 'Hide' : 'Show') + ' original email';
285 options.push({
286 text: text,
287 handler: this._toggleShowOriginalContent.bind(this),
288 });
289 }
290 return options;
291 }
292
293 /**
294 * Toggles whether the email of the user who deleted the comment should be
295 * shown.
296 */
297 _toggleShowOriginalContent() {
298 this._showOriginalContent = !this._showOriginalContent;
299 }
300
301 /**
302 * Change if deleted content for a comment is shown or not.
303 */
304 _toggleHideDeletedComment() {
305 this._isExpandedIfDeleted = !this._isExpandedIfDeleted;
306 }
307}
308
309/**
310 * Says whether a comment should be shown or not.
311 * @param {boolean} isExpandedIfDeleted If the user has chosen to see the
312 * deleted comment.
313 * @param {IssueComment} comment
314 * @return {boolean} If the comment should be shown.
315 */
316function _shouldShowComment(isExpandedIfDeleted, comment) {
317 return !comment.isDeleted || isExpandedIfDeleted;
318}
319
320/**
321 * Whether the user can view additional comment options like flagging or
322 * deleting.
323 * @param {IssueComment} comment
324 * @return {boolean}
325 */
326function _shouldOfferCommentOptions(comment) {
327 return comment.canDelete || comment.canFlag;
328}
329
330/**
331 * Whether a user has permission to view a given deleted comment.
332 * @param {IssueComment} comment
333 * @return {boolean}
334 */
335function _canExpandDeletedComment(comment) {
336 return ((comment.isSpam && comment.canFlag) ||
337 (comment.isDeleted && comment.canDelete));
338}
339
340/**
341 * Deletes a given comment or undeletes it if it's already deleted.
342 * @param {IssueComment} comment The comment to delete.
343 */
344async function _deleteComment(comment) {
345 const issueRef = {
346 projectName: comment.projectName,
347 localId: comment.localId,
348 };
349 await prpcClient.call('monorail.Issues', 'DeleteIssueComment', {
350 issueRef,
351 sequenceNum: comment.sequenceNum,
352 delete: comment.isDeleted === undefined,
353 });
354 store.dispatch(issueV0.fetchComments(issueRef));
355}
356
357/**
358 * Sends a request to flag a comment as spam. Flags or unflags based on
359 * the comments existing isSpam state.
360 * @param {IssueComment} comment The comment to flag.
361 */
362async function _flagComment(comment) {
363 const issueRef = {
364 projectName: comment.projectName,
365 localId: comment.localId,
366 };
367 await prpcClient.call('monorail.Issues', 'FlagComment', {
368 issueRef,
369 sequenceNum: comment.sequenceNum,
370 flag: comment.isSpam === undefined,
371 });
372 store.dispatch(issueV0.fetchComments(issueRef));
373}
374
375/**
376 * Finds if a given change in a comment contains issues (ie: for Blocking or
377 * BlockedOn edits), then formats those issues into a list to be rendered by the
378 * frontend.
379 * @param {Amendment} delta
380 * @param {string} projectName The project name the user is currently viewing.
381 * @return {Array<{issue: Issue, text: string}>}
382 */
383function _issuesForAmendment(delta, projectName) {
384 if (!_amendmentHasIssueRefs(delta.fieldName) ||
385 !delta.newOrDeltaValue) {
386 return [];
387 }
388 // TODO(ehmaldonado): Request the issue to check for permissions and display
389 // the issue summary.
390 return delta.newOrDeltaValue.split(' ').map((deltaValue) => {
391 let refString = deltaValue;
392
393 // When an issue is removed, its ID is prepended with a minus sign.
394 if (refString.startsWith('-')) {
395 refString = refString.substr(1);
396 }
397 const issueRef = issueStringToRef(refString, projectName);
398 return {
399 issue: {
400 ...issueRef,
401 },
402 text: deltaValue,
403 };
404 });
405}
406
407/**
408 * Check if a field is one of the field types that accepts issues as input.
409 * @param {string} fieldName
410 * @return {boolean} If the field contains issues.
411 */
412function _amendmentHasIssueRefs(fieldName) {
413 return ISSUE_REF_FIELD_NAMES.includes(fieldName);
414}
415
416customElements.define('mr-comment', MrComment);