blob: 74c5806c1d84dfe446f06d9104deaef1ffa1b3cd [file] [log] [blame]
Dave Borowitz8cdc76b2018-03-26 10:04:27 -04001/**
2 * @license
Ben Rohlfs94fcbbc2022-05-27 10:45:03 +02003 * Copyright 2015 Google LLC
4 * SPDX-License-Identifier: Apache-2.0
Dave Borowitz8cdc76b2018-03-26 10:04:27 -04005 */
Milutin Kristoficafae0052020-09-17 10:38:08 +02006import '../../../styles/shared-styles';
7import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
8import '../../plugins/gr-endpoint-param/gr-endpoint-param';
9import '../gr-button/gr-button';
10import '../gr-dialog/gr-dialog';
Milutin Kristoficafae0052020-09-17 10:38:08 +020011import '../gr-formatted-text/gr-formatted-text';
Chris Poucet1c713862022-07-25 13:12:24 +020012import '../gr-icon/gr-icon';
Milutin Kristoficafae0052020-09-17 10:38:08 +020013import '../gr-textarea/gr-textarea';
14import '../gr-tooltip-content/gr-tooltip-content';
15import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
16import '../gr-account-label/gr-account-label';
Chris Poucetc6e880b2021-11-15 19:57:06 +010017import {getAppContext} from '../../../services/app-context';
Milutin Kristofic1d219672022-06-21 14:57:25 +020018import {css, html, LitElement, nothing, PropertyValues} from 'lit';
Frank Borden42c1a452022-08-11 16:27:20 +020019import {customElement, property, query, state} from 'lit/decorators.js';
Chris Poucet9221cce2022-01-05 16:37:11 +010020import {resolve} from '../../../models/dependency';
Milutin Kristoficafae0052020-09-17 10:38:08 +020021import {GrTextarea} from '../gr-textarea/gr-textarea';
Milutin Kristoficafae0052020-09-17 10:38:08 +020022import {
Milutin Kristoficafae0052020-09-17 10:38:08 +020023 AccountDetailInfo,
Dhruv Srivastava65edec82023-02-28 19:37:59 +010024 DraftInfo,
Ben Rohlfs4401b232021-10-21 13:51:59 +020025 NumericChangeId,
Dhruv Srivastava0287bf92020-09-11 16:56:38 +020026 RepoName,
Ben Rohlfs05750b92021-10-29 08:23:08 +020027 RobotCommentInfo,
Dhruv Srivastava4e27dc42023-03-01 10:49:49 +010028 Comment,
Dhruv Srivastava4e27dc42023-03-01 10:49:49 +010029 isRobot,
Ben Rohlfs1efb1a72023-04-12 23:25:31 +020030 isSaving,
31 isError,
Ben Rohlfs1efb1a72023-04-12 23:25:31 +020032 isDraft,
Ben Rohlfs610bb4f2023-04-17 12:34:35 +020033 isNew,
Milutin Kristoficafae0052020-09-17 10:38:08 +020034} from '../../../types/common';
Milutin Kristoficafae0052020-09-17 10:38:08 +020035import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
Ben Rohlfs1d487062020-09-26 11:26:03 +020036import {
Ben Rohlfs23843882022-08-04 18:06:27 +020037 createUserFixSuggestion,
Milutin Kristofic1d219672022-06-21 14:57:25 +020038 getContentInCommentRange,
Ben Rohlfs23843882022-08-04 18:06:27 +020039 getUserSuggestion,
Milutin Kristofic1d219672022-06-21 14:57:25 +020040 hasUserSuggestion,
Ben Rohlfsba440822023-04-11 18:08:03 +020041 id,
Ben Rohlfs23843882022-08-04 18:06:27 +020042 NEWLINE_PATTERN,
Milutin Kristofic1d219672022-06-21 14:57:25 +020043 USER_SUGGESTION_START_PATTERN,
Ben Rohlfs31825d82020-10-02 18:08:04 +020044} from '../../../utils/comment-util';
Ben Rohlfs05750b92021-10-29 08:23:08 +020045import {
46 OpenFixPreviewEventDetail,
Ben Rohlfs23843882022-08-04 18:06:27 +020047 ReplyToCommentEventDetail,
Ben Rohlfs05750b92021-10-29 08:23:08 +020048 ValueChangedEvent,
49} from '../../../types/events';
Ben Rohlfs44f01042023-02-18 13:27:57 +010050import {fire} from '../../../utils/event-util';
Ben Rohlfs23843882022-08-04 18:06:27 +020051import {assertIsDefined, assert} from '../../../utils/common-util';
Dhruv Srivastavaa49dffd2022-10-20 14:04:37 +020052import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
Chris Poucetdae98bf2022-01-05 15:23:45 +010053import {commentsModelToken} from '../../../models/comments/comments-model';
Ben Rohlfs05750b92021-10-29 08:23:08 +020054import {sharedStyles} from '../../../styles/shared-styles';
55import {subscribe} from '../../lit/subscription-controller';
56import {ShortcutController} from '../../lit/shortcut-controller';
Frank Borden42c1a452022-08-11 16:27:20 +020057import {classMap} from 'lit/directives/class-map.js';
Ben Rohlfsb9956102023-05-12 17:07:06 +020058import {FILE, LineNumber} from '../../../api/diff';
Milutin Kristofic1d219672022-06-21 14:57:25 +020059import {CommentSide, SpecialFilePath} from '../../../constants/constants';
Ben Rohlfs2e237552021-11-24 10:34:28 +010060import {Subject} from 'rxjs';
61import {debounceTime} from 'rxjs/operators';
Chris Poucetbf65b8f2022-01-18 21:18:12 +000062import {changeModelToken} from '../../../models/change/change-model';
Milutin Kristofic1d219672022-06-21 14:57:25 +020063import {KnownExperimentId} from '../../../services/flags/flags';
64import {isBase64FileContent} from '../../../api/rest-api';
Ben Rohlfsb91a6a42023-01-13 09:29:31 +010065import {createDiffUrl} from '../../../models/views/change';
Chris Poucetbb0cf832022-10-24 12:32:10 +020066import {userModelToken} from '../../../models/user/user-model';
Dhruv Srivastava4063d262022-11-09 18:46:29 +053067import {modalStyles} from '../../../styles/gr-modal-styles';
Wyatt Allen846ac2f2018-05-14 12:59:23 -070068
Ben Rohlfs2e237552021-11-24 10:34:28 +010069// visible for testing
70export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
71
Ben Rohlfs05750b92021-10-29 08:23:08 +020072declare global {
73 interface HTMLElementEventMap {
Dhruv Srivastavaee018e92022-08-31 11:37:46 +020074 'comment-editing-changed': CustomEvent<CommentEditingChangedDetail>;
Dhruv Srivastava463bb332022-08-31 13:00:49 +020075 'comment-unresolved-changed': ValueChangedEvent<boolean>;
76 'comment-text-changed': ValueChangedEvent<string>;
Ben Rohlfs05750b92021-10-29 08:23:08 +020077 'comment-anchor-tap': CustomEvent<CommentAnchorTapEventDetail>;
78 }
Milutin Kristoficafae0052020-09-17 10:38:08 +020079}
80
Ben Rohlfs05750b92021-10-29 08:23:08 +020081export interface CommentAnchorTapEventDetail {
82 number: LineNumber;
83 side?: CommentSide;
Milutin Kristoficafae0052020-09-17 10:38:08 +020084}
Dmitrii Filippov3f3c2052020-09-22 16:51:18 +020085
Dhruv Srivastavaee018e92022-08-31 11:37:46 +020086export interface CommentEditingChangedDetail {
87 editing: boolean;
88 path: string;
89}
90
Milutin Kristoficafae0052020-09-17 10:38:08 +020091@customElement('gr-comment')
Ben Rohlfs05750b92021-10-29 08:23:08 +020092export class GrComment extends LitElement {
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010093 /**
Ben Rohlfs23843882022-08-04 18:06:27 +020094 * Fired when the parent thread component should create a reply.
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010095 *
Ben Rohlfs23843882022-08-04 18:06:27 +020096 * @event reply-to-comment
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010097 */
Kasper Nilssond43d2a72018-10-19 14:26:41 -070098
Dmitrii Filippov3fd2b102019-11-15 16:16:46 +010099 /**
Ben Rohlfs23843882022-08-04 18:06:27 +0200100 * Fired when the open fix preview action is triggered.
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100101 *
102 * @event open-fix-preview
Tao Zhou500437d2020-02-14 16:57:27 +0100103 */
Tao Zhou500437d2020-02-14 16:57:27 +0100104
105 /**
Tao Zhou31f3f102020-04-27 16:15:29 +0200106 * Fired when editing status changed.
107 *
108 * @event comment-editing-changed
109 */
110
111 /**
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100112 * Fired when the comment's timestamp is tapped.
113 *
114 * @event comment-anchor-tap
115 */
Andrew Bonventre28165262016-05-19 17:24:45 -0700116
Ben Rohlfs05750b92021-10-29 08:23:08 +0200117 @query('#editTextarea')
118 textarea?: GrTextarea;
Viktar Donich7ad28922016-05-23 15:24:05 -0700119
Ben Rohlfs05750b92021-10-29 08:23:08 +0200120 @query('#container')
121 container?: HTMLElement;
Dhruv Srivastava0287bf92020-09-11 16:56:38 +0200122
Ben Rohlfs05750b92021-10-29 08:23:08 +0200123 @query('#resolvedCheckbox')
124 resolvedCheckbox?: HTMLInputElement;
Kasper Nilssond43d2a72018-10-19 14:26:41 -0700125
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530126 @query('#confirmDeleteModal')
127 confirmDeleteModal?: HTMLDialogElement;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200128
Kamil Musinc7d3f282022-12-29 13:27:55 +0100129 @query('#confirmDeleteCommentDialog')
130 confirmDeleteDialog?: GrConfirmDeleteCommentDialog;
131
Ben Rohlfs05750b92021-10-29 08:23:08 +0200132 @property({type: Object})
133 comment?: Comment;
134
135 // TODO: Move this out of gr-comment. gr-comment should not have a comments
136 // property. This is only used for hasHumanReply at the moment.
Milutin Kristoficafae0052020-09-17 10:38:08 +0200137 @property({type: Array})
Ben Rohlfs05750b92021-10-29 08:23:08 +0200138 comments?: Comment[];
Milutin Kristoficafae0052020-09-17 10:38:08 +0200139
140 /**
Ben Rohlfs05750b92021-10-29 08:23:08 +0200141 * Initial collapsed state of the comment.
Milutin Kristoficafae0052020-09-17 10:38:08 +0200142 */
Ben Rohlfs05750b92021-10-29 08:23:08 +0200143 @property({type: Boolean, attribute: 'initially-collapsed'})
144 initiallyCollapsed?: boolean;
145
146 /**
Dhruv Srivastavae75f6f72022-08-25 10:14:37 +0200147 * Hide the header for patchset level comments used in GrReplyDialog.
148 */
149 @property({type: Boolean, attribute: 'hide-header'})
150 hideHeader = false;
151
152 /**
Ben Rohlfs05750b92021-10-29 08:23:08 +0200153 * This is the *current* (internal) collapsed state of the comment. Do not set
154 * from the outside. Use `initiallyCollapsed` instead. This is just a
155 * reflected property such that css rules can be based on it.
156 */
157 @property({type: Boolean, reflect: true})
158 collapsed?: boolean;
159
160 @property({type: Boolean, attribute: 'robot-button-disabled'})
161 robotButtonDisabled = false;
162
Dhruv Srivastavaf007abc2022-09-06 11:18:57 +0200163 @property({type: String})
164 messagePlaceholder?: string;
165
Dhruv Srivastava4e5fd112022-08-25 12:01:22 +0200166 // GrReplyDialog requires the patchset level comment to always remain
167 // editable.
168 @property({type: Boolean, attribute: 'permanent-editing-mode'})
169 permanentEditingMode = false;
170
Ben Rohlfs2e237552021-11-24 10:34:28 +0100171 @state()
Ben Rohlfs607126f2021-12-07 08:21:52 +0100172 autoSaving?: Promise<DraftInfo>;
Ben Rohlfs2e237552021-11-24 10:34:28 +0100173
Ben Rohlfs05750b92021-10-29 08:23:08 +0200174 @state()
175 changeNum?: NumericChangeId;
176
177 @state()
178 editing = false;
179
180 @state()
Ben Rohlfs05750b92021-10-29 08:23:08 +0200181 repoName?: RepoName;
182
183 /* The 'dirty' state of the comment.message, which will be saved on demand. */
184 @state()
185 messageText = '';
186
187 /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
188 @state()
189 unresolved = true;
Milutin Kristoficafae0052020-09-17 10:38:08 +0200190
Ben Rohlfs05750b92021-10-29 08:23:08 +0200191 @property({type: Boolean, attribute: 'show-patchset'})
192 showPatchset = false;
Tao Zhou500437d2020-02-14 16:57:27 +0100193
Ben Rohlfs05750b92021-10-29 08:23:08 +0200194 @property({type: Boolean, attribute: 'show-ported-comment'})
Dhruv Srivastava0287bf92020-09-11 16:56:38 +0200195 showPortedComment = false;
196
Ben Rohlfs05750b92021-10-29 08:23:08 +0200197 @state()
198 account?: AccountDetailInfo;
199
200 @state()
201 isAdmin = false;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100202
Milutin Kristofic15bfb3e2022-08-16 20:01:33 +0200203 @state()
204 isOwner = false;
205
Chris Poucetc6e880b2021-11-15 19:57:06 +0100206 private readonly restApiService = getAppContext().restApiService;
Ben Rohlfs43935a42020-12-01 19:14:09 +0100207
Chris Poucetc6e880b2021-11-15 19:57:06 +0100208 private readonly reporting = getAppContext().reportingService;
Milutin Kristoficafae0052020-09-17 10:38:08 +0200209
Milutin Kristofic1d219672022-06-21 14:57:25 +0200210 private readonly flagsService = getAppContext().flagsService;
211
Chris Poucetbf65b8f2022-01-18 21:18:12 +0000212 private readonly getChangeModel = resolve(this, changeModelToken);
Chris Poucet01422482021-11-30 19:43:28 +0100213
Chris Poucetbb0cf832022-10-24 12:32:10 +0200214 private readonly getCommentsModel = resolve(this, commentsModelToken);
Dhruv Srivastavadb2ab602021-06-24 15:20:29 +0200215
Chris Poucetbb0cf832022-10-24 12:32:10 +0200216 private readonly getUserModel = resolve(this, userModelToken);
Ben Rohlfsf92d3b52021-03-10 23:13:03 +0100217
Ben Rohlfs05750b92021-10-29 08:23:08 +0200218 private readonly shortcuts = new ShortcutController(this);
Ben Rohlfsf92d3b52021-03-10 23:13:03 +0100219
Ben Rohlfs2e237552021-11-24 10:34:28 +0100220 /**
221 * This is triggered when the user types into the editing textarea. We then
222 * debounce it and call autoSave().
223 */
Frank Borden3801d7d2023-03-27 09:00:58 +0000224 private autoSaveTrigger$ = new Subject();
Ben Rohlfs2e237552021-11-24 10:34:28 +0100225
226 /**
227 * Set to the content of DraftInfo when entering editing mode.
228 * Only used for "Cancel".
229 */
230 private originalMessage = '';
231
232 /**
233 * Set to the content of DraftInfo when entering editing mode.
234 * Only used for "Cancel".
235 */
236 private originalUnresolved = false;
237
Ben Rohlfs05750b92021-10-29 08:23:08 +0200238 constructor() {
239 super();
Dhruv Srivastavae110a372022-09-08 12:18:33 +0200240 // Allow the shortcuts to bubble up so that GrReplyDialog can respond to
241 // them as well.
242 this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc(), {
243 preventDefault: false,
244 });
Dhruv Srivastavaf43eee72022-09-14 11:03:01 +0200245 for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
246 this.shortcuts.addLocal(
247 {key: Key.ENTER, modifiers: [modifier]},
248 () => {
249 this.save();
250 },
251 {preventDefault: false}
252 );
253 }
254 // For Ctrl+s add shorctut with preventDefault so that it does
255 // not bubble up to the browser
256 for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
257 this.shortcuts.addLocal({key: 's', modifiers: [modifier]}, () => {
258 this.save();
259 });
Ben Rohlfsaadbdd12021-10-19 11:49:01 +0200260 }
Milutin Kristofic1ebae372022-11-22 20:35:38 +0100261 this.addEventListener('open-user-suggest-preview', e => {
262 this.handleShowFix(e.detail.code);
263 });
Ben Rohlfsb7082e12023-01-23 11:43:48 +0100264 this.messagePlaceholder = 'Mention others with @';
Chris Poucet0b961412022-01-05 16:24:50 +0100265 subscribe(
266 this,
Chris Poucetbb0cf832022-10-24 12:32:10 +0200267 () => this.getUserModel().account$,
Chris Poucet5ec77f02022-05-12 11:25:21 +0200268 x => (this.account = x)
269 );
270 subscribe(
271 this,
Chris Poucetbb0cf832022-10-24 12:32:10 +0200272 () => this.getUserModel().isAdmin$,
Chris Poucet5ec77f02022-05-12 11:25:21 +0200273 x => (this.isAdmin = x)
274 );
275
276 subscribe(
277 this,
278 () => this.getChangeModel().repo$,
279 x => (this.repoName = x)
280 );
281 subscribe(
282 this,
283 () => this.getChangeModel().changeNum$,
Chris Poucetbf65b8f2022-01-18 21:18:12 +0000284 x => (this.changeNum = x)
285 );
286 subscribe(
287 this,
Milutin Kristofic15bfb3e2022-08-16 20:01:33 +0200288 () => this.getChangeModel().isOwner$,
289 x => (this.isOwner = x)
290 );
291 subscribe(
292 this,
Chris Poucet5ec77f02022-05-12 11:25:21 +0200293 () =>
294 this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
Chris Poucetbf65b8f2022-01-18 21:18:12 +0000295 () => {
296 this.autoSave();
297 }
298 );
Chris Poucet0b961412022-01-05 16:24:50 +0100299 }
300
Gerrit Code Review86b969c2021-08-19 14:33:41 +0000301 override disconnectedCallback() {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200302 // Clean up emoji dropdown.
303 if (this.textarea) this.textarea.closeDropdown();
Ben Rohlfs5f520da2021-03-10 14:58:43 +0100304 super.disconnectedCallback();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100305 }
Andrew Bonventre78792e82016-03-04 17:48:22 -0500306
Ben Rohlfs05750b92021-10-29 08:23:08 +0200307 static override get styles() {
308 return [
309 sharedStyles,
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530310 modalStyles,
Ben Rohlfs05750b92021-10-29 08:23:08 +0200311 css`
312 :host {
313 display: block;
314 font-family: var(--font-family);
315 padding: var(--spacing-m);
316 }
317 :host([collapsed]) {
318 padding: var(--spacing-s) var(--spacing-m);
319 }
Ben Rohlfs1efb1a72023-04-12 23:25:31 +0200320 :host([error]) {
321 background-color: var(--error-background);
322 border-radius: var(--border-radius);
Ben Rohlfs05750b92021-10-29 08:23:08 +0200323 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200324 .header {
325 align-items: center;
326 cursor: pointer;
327 display: flex;
Dhruv Srivastavad8f61e72022-09-16 07:34:34 +0000328 padding-bottom: var(--spacing-m);
329 }
330 :host([collapsed]) .header {
331 padding-bottom: 0px;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200332 }
333 .headerLeft > span {
334 font-weight: var(--font-weight-bold);
335 }
336 .headerMiddle {
337 color: var(--deemphasized-text-color);
338 flex: 1;
339 overflow: hidden;
340 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200341 .draftTooltip {
Ben Rohlfsba361a42022-09-01 12:12:45 +0200342 font-weight: var(--font-weight-bold);
Ben Rohlfs05750b92021-10-29 08:23:08 +0200343 display: inline;
344 }
Ben Rohlfsba361a42022-09-01 12:12:45 +0200345 .draftTooltip gr-icon {
346 color: var(--info-foreground);
347 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200348 .date {
349 justify-content: flex-end;
350 text-align: right;
351 white-space: nowrap;
352 }
353 span.date {
354 color: var(--deemphasized-text-color);
355 }
356 span.date:hover {
357 text-decoration: underline;
358 }
359 .actions,
360 .robotActions {
361 display: flex;
362 justify-content: flex-end;
363 padding-top: 0;
364 }
365 .robotActions {
366 /* Better than the negative margin would be to remove the gr-button
367 * padding, but then we would also need to fix the buttons that are
368 * inserted by plugins. :-/ */
369 margin: 4px 0 -4px;
370 }
371 .action {
372 margin-left: var(--spacing-l);
373 }
374 .rightActions {
375 display: flex;
376 justify-content: flex-end;
377 }
378 .rightActions gr-button {
379 --gr-button-padding: 0 var(--spacing-s);
380 }
381 .editMessage {
382 display: block;
Dhruv Srivastava694e9372022-09-13 10:29:08 +0200383 margin-bottom: var(--spacing-m);
Ben Rohlfs05750b92021-10-29 08:23:08 +0200384 width: 100%;
385 }
386 .show-hide {
387 margin-left: var(--spacing-s);
388 }
389 .robotId {
390 color: var(--deemphasized-text-color);
391 margin-bottom: var(--spacing-m);
392 }
393 .robotRun {
394 margin-left: var(--spacing-m);
395 }
396 .robotRunLink {
397 margin-left: var(--spacing-m);
398 }
399 /* just for a11y */
400 input.show-hide {
401 display: none;
402 }
403 label.show-hide {
404 cursor: pointer;
405 display: block;
406 }
Chris Poucet1c713862022-07-25 13:12:24 +0200407 label.show-hide gr-icon {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200408 vertical-align: top;
409 }
410 :host([collapsed]) #container .body {
411 padding-top: 0;
412 }
413 #container .collapsedContent {
414 display: block;
415 overflow: hidden;
416 padding-left: var(--spacing-m);
417 text-overflow: ellipsis;
418 white-space: nowrap;
419 }
420 .resolve,
421 .unresolved {
422 align-items: center;
423 display: flex;
424 flex: 1;
425 margin: 0;
426 }
427 .resolve label {
428 color: var(--comment-text-color);
429 }
430 gr-dialog .main {
431 display: flex;
432 flex-direction: column;
433 width: 100%;
434 }
435 #deleteBtn {
436 --gr-button-text-color: var(--deemphasized-text-color);
437 --gr-button-padding: 0;
438 }
439
440 /** Disable select for the caret and actions */
441 .actions,
442 .show-hide {
443 -webkit-user-select: none;
444 -moz-user-select: none;
445 -ms-user-select: none;
446 user-select: none;
447 }
448
Ben Rohlfs05750b92021-10-29 08:23:08 +0200449 .pointer {
450 cursor: pointer;
451 }
452 .patchset-text {
453 color: var(--deemphasized-text-color);
454 margin-left: var(--spacing-s);
455 }
456 .headerLeft gr-account-label {
457 --account-max-length: 130px;
458 width: 150px;
459 }
460 .headerLeft gr-account-label::part(gr-account-label-text) {
461 font-weight: var(--font-weight-bold);
462 }
463 .draft gr-account-label {
464 width: unset;
465 }
Frank Borden0c078842022-09-19 15:47:26 +0200466 .draft gr-formatted-text.message {
Frank Borden3b3a4c92022-09-28 14:14:00 +0200467 display: block;
Frank Borden0c078842022-09-19 15:47:26 +0200468 margin-bottom: var(--spacing-m);
469 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200470 .portedMessage {
471 margin: 0 var(--spacing-m);
472 }
473 .link-icon {
Chris Poucetc4142042022-06-28 17:51:50 +0200474 margin-left: var(--spacing-m);
Ben Rohlfs05750b92021-10-29 08:23:08 +0200475 cursor: pointer;
476 }
Milutin Kristofic80c3c7e2023-03-16 20:22:28 +0100477 .suggestEdit {
478 /** same height as header */
479 --margin: calc(0px - var(--spacing-s));
480 margin-right: var(--spacing-s);
481 }
482 .suggestEdit gr-icon {
483 color: inherit;
484 margin-right: var(--spacing-s);
485 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200486 `,
487 ];
Dhruv Srivastavacf70e792020-07-24 15:35:39 +0200488 }
489
Ben Rohlfs05750b92021-10-29 08:23:08 +0200490 override render() {
Ben Rohlfs1efb1a72023-04-12 23:25:31 +0200491 if (!this.comment) return;
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200492 this.toggleAttribute('saving', isSaving(this.comment));
493 this.toggleAttribute('error', isError(this.comment));
Ben Rohlfs1efb1a72023-04-12 23:25:31 +0200494 const classes = {
495 container: true,
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200496 draft: isDraft(this.comment),
Ben Rohlfs1efb1a72023-04-12 23:25:31 +0200497 };
Ben Rohlfs05750b92021-10-29 08:23:08 +0200498 return html`
Ben Rohlfs7a167842022-09-29 21:55:50 +0200499 <gr-endpoint-decorator name="comment">
500 <gr-endpoint-param name="comment" .value=${this.comment}>
501 </gr-endpoint-param>
502 <gr-endpoint-param name="editing" .value=${this.editing}>
503 </gr-endpoint-param>
Ben Rohlfs57c2c592022-10-25 12:49:11 +0200504 <gr-endpoint-param name="message" .value=${this.messageText}>
505 </gr-endpoint-param>
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200506 <gr-endpoint-param name="isDraft" .value=${isDraft(this.comment)}>
Ben Rohlfs57c2c592022-10-25 12:49:11 +0200507 </gr-endpoint-param>
Ben Rohlfs7a167842022-09-29 21:55:50 +0200508 <div id="container" class=${classMap(classes)}>
509 ${this.renderHeader()}
510 <div class="body">
511 ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
512 ${this.renderCommentMessage()}
513 <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
514 ${this.renderHumanActions()} ${this.renderRobotActions()}
Ben Rohlfs7a167842022-09-29 21:55:50 +0200515 </div>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200516 </div>
Ben Rohlfs7a167842022-09-29 21:55:50 +0200517 </gr-endpoint-decorator>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200518 ${this.renderConfirmDialog()}
519 `;
520 }
521
Dhruv Srivastavae75f6f72022-08-25 10:14:37 +0200522 private renderHeader() {
523 if (this.hideHeader) return nothing;
524 return html`
525 <div
526 class="header"
527 id="header"
528 @click=${() => (this.collapsed = !this.collapsed)}
529 >
530 <div class="headerLeft">
531 ${this.renderAuthor()} ${this.renderPortedCommentMessage()}
532 ${this.renderDraftLabel()}
533 </div>
534 <div class="headerMiddle">${this.renderCollapsedContent()}</div>
Milutin Kristofic8238de52023-01-12 19:33:45 +0100535 ${this.renderSuggestEditButton()} ${this.renderRunDetails()}
536 ${this.renderDeleteButton()} ${this.renderPatchset()}
Ben Rohlfs0ed33592023-04-12 11:33:27 +0200537 ${this.renderSeparator()} ${this.renderDate()} ${this.renderToggle()}
Dhruv Srivastavae75f6f72022-08-25 10:14:37 +0200538 </div>
539 `;
540 }
541
Ben Rohlfs05750b92021-10-29 08:23:08 +0200542 private renderAuthor() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200543 if (isDraft(this.comment)) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200544 if (isRobot(this.comment)) {
545 const id = this.comment.robot_id;
546 return html`<span class="robotName">${id}</span>`;
547 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200548 return html`
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200549 <gr-account-label .account=${this.comment?.author ?? this.account}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200550 </gr-account-label>
551 `;
552 }
553
554 private renderPortedCommentMessage() {
555 if (!this.showPortedComment) return;
556 if (!this.comment?.patch_set) return;
557 return html`
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200558 <a href=${this.getUrlForComment()}>
559 <span class="portedMessage" @click=${this.handlePortedMessageClick}>
Ben Rohlfs95796222021-12-01 16:39:42 +0100560 From patchset ${this.comment?.patch_set}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200561 </span>
562 </a>
563 `;
564 }
565
566 private renderDraftLabel() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200567 if (!isDraft(this.comment)) return;
Ben Rohlfsba361a42022-09-01 12:12:45 +0200568 let label = 'Draft';
Ben Rohlfs05750b92021-10-29 08:23:08 +0200569 let tooltip =
570 'This draft is only visible to you. ' +
571 "To publish drafts, click the 'Reply' or 'Start review' button " +
572 "at the top of the change or press the 'a' key.";
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200573 if (isError(this.comment)) {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200574 label += ' (Failed to save)';
575 tooltip = 'Unable to save draft. Please try to save again.';
576 }
577 return html`
578 <gr-tooltip-content
579 class="draftTooltip"
580 has-tooltip
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200581 title=${tooltip}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200582 max-width="20em"
Ben Rohlfs05750b92021-10-29 08:23:08 +0200583 >
Ben Rohlfsba361a42022-09-01 12:12:45 +0200584 <gr-icon filled icon="rate_review"></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200585 <span class="draftLabel">${label}</span>
586 </gr-tooltip-content>
587 `;
588 }
589
590 private renderCollapsedContent() {
591 if (!this.collapsed) return;
592 return html`
593 <span class="collapsedContent">${this.comment?.message}</span>
594 `;
595 }
596
597 private renderRunDetails() {
598 if (!isRobot(this.comment)) return;
599 if (!this.comment?.url || this.collapsed) return;
600 return html`
601 <div class="runIdMessage message">
602 <div class="runIdInformation">
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200603 <a class="robotRunLink" href=${this.comment.url}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200604 <span class="robotRun link">Run Details</span>
605 </a>
606 </div>
607 </div>
608 `;
609 }
610
611 /**
612 * Deleting a comment is an admin feature. It means more than just discarding
613 * a draft. It is an action applied to published comments.
614 */
615 private renderDeleteButton() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200616 if (!this.isAdmin || isDraft(this.comment) || isRobot(this.comment)) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200617 if (this.collapsed) return;
618 return html`
619 <gr-button
620 id="deleteBtn"
621 title="Delete Comment"
622 link
623 class="action delete"
Kamil Musin462428b2022-12-29 11:12:08 +0100624 @click=${(e: MouseEvent) => {
625 e.stopPropagation();
626 this.openDeleteCommentModal();
627 }}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200628 >
Chris Poucet1c713862022-07-25 13:12:24 +0200629 <gr-icon id="icon" icon="delete" filled></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200630 </gr-button>
631 `;
632 }
633
634 private renderPatchset() {
635 if (!this.showPatchset) return;
636 assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
637 return html`
638 <span class="patchset-text"> Patchset ${this.comment.patch_set}</span>
639 `;
640 }
641
Ben Rohlfs0ed33592023-04-12 11:33:27 +0200642 private renderSeparator() {
643 // This should match the condition of `renderPatchset()`.
644 if (!this.showPatchset) return;
645 // This should match the condition of `renderDate()`.
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200646 if (this.collapsed) return;
Ben Rohlfs0ed33592023-04-12 11:33:27 +0200647 // Render separator, if both are present: patchset AND date.
648 return html`<span class="separator"></span>`;
649 }
650
Ben Rohlfs05750b92021-10-29 08:23:08 +0200651 private renderDate() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200652 if (this.collapsed) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200653 return html`
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200654 <span class="date" tabindex="0" @click=${this.handleAnchorClick}>
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200655 ${this.renderDateInner()}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200656 </span>
657 `;
658 }
659
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200660 private renderDateInner() {
661 if (isError(this.comment)) return 'Error';
Ben Rohlfsd98a04c2023-05-04 11:31:15 +0200662 if (isSaving(this.comment) && !this.autoSaving) return 'Saving';
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200663 if (isNew(this.comment)) return 'New';
664 return html`
665 <gr-date-formatter
666 withTooltip
667 .dateStr=${this.comment!.updated}
668 ></gr-date-formatter>
669 `;
670 }
671
Ben Rohlfs05750b92021-10-29 08:23:08 +0200672 private renderToggle() {
Chris Poucetb8c06392022-07-08 16:35:43 +0200673 const icon = this.collapsed ? 'expand_more' : 'expand_less';
Ben Rohlfs05750b92021-10-29 08:23:08 +0200674 const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
675 return html`
676 <div class="show-hide" tabindex="0">
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200677 <label class="show-hide" aria-label=${ariaLabel}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200678 <input
679 type="checkbox"
680 class="show-hide"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200681 ?checked=${this.collapsed}
682 @change=${() => (this.collapsed = !this.collapsed)}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200683 />
Chris Poucet1c713862022-07-25 13:12:24 +0200684 <gr-icon icon=${icon} id="icon"></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200685 </label>
686 </div>
687 `;
688 }
689
690 private renderRobotAuthor() {
691 if (!isRobot(this.comment) || this.collapsed) return;
692 return html`<div class="robotId">${this.comment.author?.name}</div>`;
693 }
694
695 private renderEditingTextarea() {
696 if (!this.editing || this.collapsed) return;
697 return html`
698 <gr-textarea
699 id="editTextarea"
700 class="editMessage"
701 autocomplete="on"
702 code=""
Ben Rohlfs05750b92021-10-29 08:23:08 +0200703 rows="4"
Dhruv Srivastavaf007abc2022-09-06 11:18:57 +0200704 .placeholder=${this.messagePlaceholder}
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200705 text=${this.messageText}
706 @text-changed=${(e: ValueChangedEvent) => {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200707 // TODO: This is causing a re-render of <gr-comment> on every key
708 // press. Try to avoid always setting `this.messageText` or at least
Ben Rohlfs2e237552021-11-24 10:34:28 +0100709 // debounce it. Most of the code can just inspect the current value
Ben Rohlfs05750b92021-10-29 08:23:08 +0200710 // of the textare instead of needing a dedicated property.
711 this.messageText = e.detail.value;
Ben Rohlfs2e237552021-11-24 10:34:28 +0100712 this.autoSaveTrigger$.next();
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200713 }}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200714 ></gr-textarea>
715 `;
716 }
717
Ben Rohlfs05750b92021-10-29 08:23:08 +0200718 private renderCommentMessage() {
719 if (this.collapsed || this.editing) return;
Frank Bordenf9a29992022-08-24 20:19:23 +0200720
Ben Rohlfs05750b92021-10-29 08:23:08 +0200721 return html`
722 <!--The "message" class is needed to ensure selectability from
723 gr-diff-selection.-->
724 <gr-formatted-text
725 class="message"
Frank Bordenabdd1872022-09-26 12:55:59 +0200726 .markdown=${true}
727 .content=${this.comment?.message ?? ''}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200728 ></gr-formatted-text>
729 `;
730 }
731
732 private renderCopyLinkIcon() {
733 // Only show the icon when the thread contains a published comment.
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200734 if (!this.comment?.in_reply_to && isDraft(this.comment)) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200735 return html`
Chris Poucet1c713862022-07-25 13:12:24 +0200736 <gr-icon
737 icon="link"
738 class="copy link-icon"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200739 @click=${this.handleCopyLink}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200740 title="Copy link to this comment"
Ben Rohlfs05750b92021-10-29 08:23:08 +0200741 role="button"
742 tabindex="0"
Chris Poucet1c713862022-07-25 13:12:24 +0200743 ></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200744 `;
745 }
746
747 private renderHumanActions() {
748 if (!this.account || isRobot(this.comment)) return;
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200749 if (this.collapsed || !isDraft(this.comment)) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200750 return html`
751 <div class="actions">
752 <div class="action resolve">
753 <label>
754 <input
755 type="checkbox"
756 id="resolvedCheckbox"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200757 ?checked=${!this.unresolved}
758 @change=${this.handleToggleResolved}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200759 />
760 Resolved
761 </label>
762 </div>
763 ${this.renderDraftActions()}
764 </div>
765 `;
766 }
767
768 private renderDraftActions() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200769 if (!isDraft(this.comment)) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200770 return html`
771 <div class="rightActions">
Milutin Kristofic1ebae372022-11-22 20:35:38 +0100772 ${this.renderDiscardButton()} ${this.renderEditButton()}
773 ${this.renderCancelButton()} ${this.renderSaveButton()}
774 ${this.renderCopyLinkIcon()}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200775 </div>
776 `;
777 }
778
Milutin Kristofic1d219672022-06-21 14:57:25 +0200779 private renderSuggestEditButton() {
780 if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
781 return nothing;
782 }
Dhruv Srivastava66a15632022-09-06 11:57:34 +0200783 if (
Milutin Kristofic8238de52023-01-12 19:33:45 +0100784 !this.editing ||
Dhruv Srivastava66a15632022-09-06 11:57:34 +0200785 this.permanentEditingMode ||
786 this.comment?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
787 ) {
788 return nothing;
789 }
Milutin Kristofic1d219672022-06-21 14:57:25 +0200790 assertIsDefined(this.comment, 'comment');
791 if (hasUserSuggestion(this.comment)) return nothing;
792 // TODO(milutin): remove this check once suggesting on commit message is
793 // fixed. Currently diff line doesn't match commit message line, because
794 // of metadata in diff, which aren't in content api request.
795 if (this.comment.path === SpecialFilePath.COMMIT_MESSAGE) return nothing;
Milutin Kristofic50394b12023-02-01 11:28:52 +0000796 if (this.isOwner) return nothing;
Milutin Kristofic7dec89b2022-09-13 12:11:35 +0200797 return html`<gr-button
798 link
799 class="action suggestEdit"
Milutin Kristofic80c3c7e2023-03-16 20:22:28 +0100800 title="This button copies the text to make a suggestion"
Milutin Kristofic7dec89b2022-09-13 12:11:35 +0200801 @click=${this.createSuggestEdit}
Milutin Kristofic80c3c7e2023-03-16 20:22:28 +0100802 ><gr-icon icon="edit" id="icon" filled></gr-icon> Suggest edit</gr-button
Milutin Kristofic1d219672022-06-21 14:57:25 +0200803 >`;
804 }
805
Ben Rohlfs05750b92021-10-29 08:23:08 +0200806 private renderDiscardButton() {
Dhruv Srivastava705eac92022-09-01 11:33:27 +0200807 if (this.editing || this.permanentEditingMode) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200808 return html`<gr-button
809 link
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200810 ?disabled=${isSaving(this.comment) && !this.autoSaving}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200811 class="action discard"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200812 @click=${this.discard}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200813 >Discard</gr-button
814 >`;
815 }
816
817 private renderEditButton() {
818 if (this.editing) return;
Ben Rohlfs1efb1a72023-04-12 23:25:31 +0200819 return html`<gr-button link class="action edit" @click=${this.edit}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200820 >Edit</gr-button
821 >`;
822 }
823
824 private renderCancelButton() {
Dhruv Srivastava705eac92022-09-01 11:33:27 +0200825 if (!this.editing || this.permanentEditingMode) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200826 return html`
827 <gr-button
828 link
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200829 ?disabled=${isSaving(this.comment) && !this.autoSaving}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200830 class="action cancel"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200831 @click=${this.cancel}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200832 >Cancel</gr-button
833 >
834 `;
835 }
836
837 private renderSaveButton() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200838 if (!this.editing) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200839 return html`
840 <gr-button
841 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200842 ?disabled=${this.isSaveDisabled()}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200843 class="action save"
Dhruv Srivastava705eac92022-09-01 11:33:27 +0200844 @click=${this.handleSaveButtonClicked}
Dhruv Srivastava00831e72022-09-05 08:20:20 +0200845 >${this.permanentEditingMode ? 'Preview' : 'Save'}</gr-button
Ben Rohlfs05750b92021-10-29 08:23:08 +0200846 >
847 `;
848 }
849
850 private renderRobotActions() {
851 if (!this.account || !isRobot(this.comment)) return;
852 const endpoint = html`
853 <gr-endpoint-decorator name="robot-comment-controls">
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200854 <gr-endpoint-param name="comment" .value=${this.comment}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200855 </gr-endpoint-param>
856 </gr-endpoint-decorator>
857 `;
858 return html`
859 <div class="robotActions">
860 ${this.renderCopyLinkIcon()} ${endpoint} ${this.renderShowFixButton()}
861 ${this.renderPleaseFixButton()}
862 </div>
863 `;
864 }
865
866 private renderShowFixButton() {
Kamil Musind4418632023-03-07 10:20:49 +0100867 const fix_suggestions = (this.comment as RobotCommentInfo)?.fix_suggestions;
868 if (!fix_suggestions || fix_suggestions.length === 0) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200869 return html`
870 <gr-button
871 link
872 secondary
873 class="action show-fix"
Kamil Musind4418632023-03-07 10:20:49 +0100874 @click=${() => this.handleShowFix()}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200875 >
876 Show Fix
877 </gr-button>
878 `;
879 }
880
881 private renderPleaseFixButton() {
882 if (this.hasHumanReply()) return;
883 return html`
884 <gr-button
885 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200886 ?disabled=${this.robotButtonDisabled}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200887 class="action fix"
Ben Rohlfs23843882022-08-04 18:06:27 +0200888 @click=${this.handlePleaseFix}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200889 >
890 Please Fix
891 </gr-button>
892 `;
893 }
894
895 private renderConfirmDialog() {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200896 return html`
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530897 <dialog id="confirmDeleteModal" tabindex="-1">
Ben Rohlfs05750b92021-10-29 08:23:08 +0200898 <gr-confirm-delete-comment-dialog
Kamil Musinc7d3f282022-12-29 13:27:55 +0100899 id="confirmDeleteCommentDialog"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200900 @confirm=${this.handleConfirmDeleteComment}
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530901 @cancel=${this.closeDeleteCommentModal}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200902 >
903 </gr-confirm-delete-comment-dialog>
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530904 </dialog>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200905 `;
906 }
907
908 private getUrlForComment() {
Ben Rohlfsba440822023-04-11 18:08:03 +0200909 if (!this.changeNum || !this.repoName || !this.comment?.id) return '';
Ben Rohlfs731738b2022-09-15 15:55:33 +0200910 return createDiffUrl({
911 changeNum: this.changeNum,
Ben Rohlfsbfc688b2022-10-21 12:38:37 +0200912 repo: this.repoName,
Ben Rohlfsba440822023-04-11 18:08:03 +0200913 commentId: this.comment.id,
Ben Rohlfs731738b2022-09-15 15:55:33 +0200914 });
Dhruv Srivastava0287bf92020-09-11 16:56:38 +0200915 }
916
Ben Rohlfs05750b92021-10-29 08:23:08 +0200917 private firstWillUpdateDone = false;
918
919 firstWillUpdate() {
920 if (this.firstWillUpdateDone) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200921 assertIsDefined(this.comment, 'comment');
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200922 this.firstWillUpdateDone = true;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200923 this.unresolved = this.comment.unresolved ?? true;
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200924 if (this.permanentEditingMode) {
925 this.edit();
926 }
927 if (
928 isDraft(this.comment) &&
929 isNew(this.comment) &&
930 !isSaving(this.comment)
931 ) {
932 this.edit();
933 }
934 if (isDraft(this.comment)) {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200935 this.collapsed = false;
936 } else {
937 this.collapsed = !!this.initiallyCollapsed;
938 }
939 }
940
Dhruv Srivastavaa49dffd2022-10-20 14:04:37 +0200941 override updated(changed: PropertyValues) {
942 if (changed.has('editing')) {
Dhruv Srivastavae8b86392022-10-20 17:17:21 +0200943 if (this.editing && !this.permanentEditingMode) {
Dhruv Srivastavaa49dffd2022-10-20 14:04:37 +0200944 whenVisible(this, () => this.textarea?.putCursorAtEnd());
945 }
946 }
947 }
948
Ben Rohlfs05750b92021-10-29 08:23:08 +0200949 override willUpdate(changed: PropertyValues) {
950 this.firstWillUpdate();
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200951 if (changed.has('comment')) {
952 if (isDraft(this.comment) && isError(this.comment)) {
953 this.edit();
954 }
955 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200956 if (changed.has('editing')) {
Chris Poucetafd0f7c2022-10-04 10:04:43 +0000957 this.onEditingChanged();
Ben Rohlfs05750b92021-10-29 08:23:08 +0200958 }
959 if (changed.has('unresolved')) {
960 // The <gr-comment-thread> component wants to change its color based on
961 // the (dirty) unresolved state, so let's notify it about changes.
Dhruv Srivastava463bb332022-08-31 13:00:49 +0200962 fire(this, 'comment-unresolved-changed', {value: this.unresolved});
963 }
964 if (changed.has('messageText')) {
965 // GrReplyDialog updates it's state when text inside patchset level
966 // comment changes.
967 fire(this, 'comment-text-changed', {value: this.messageText});
Ben Rohlfs05750b92021-10-29 08:23:08 +0200968 }
969 }
970
971 private handlePortedMessageClick() {
Ben Rohlfsc1c6afd2021-02-18 13:13:22 +0100972 assertIsDefined(this.comment, 'comment');
Dhruv Srivastavac8df7602021-01-15 10:59:00 +0100973 this.reporting.reportInteraction('navigate-to-original-comment', {
974 line: this.comment.line,
975 range: this.comment.range,
976 });
977 }
978
Ben Rohlfs05750b92021-10-29 08:23:08 +0200979 private handleCopyLink() {
Ben Rohlfs44f01042023-02-18 13:27:57 +0100980 fire(this, 'copy-comment-link', {});
Ben Rohlfs05750b92021-10-29 08:23:08 +0200981 }
982
983 /** Enter editing mode. */
984 private edit() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +0200985 assert(isDraft(this.comment), 'only drafts are editable');
Ben Rohlfs05750b92021-10-29 08:23:08 +0200986 if (this.editing) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200987 this.editing = true;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200988 }
989
990 // TODO: Move this out of gr-comment. gr-comment should not have a comments
991 // property.
992 private hasHumanReply() {
993 if (!this.comment || !this.comments) return false;
994 return this.comments.some(
995 c => c.in_reply_to && c.in_reply_to === this.comment?.id && !isRobot(c)
Milutin Kristoficafae0052020-09-17 10:38:08 +0200996 );
Ben Rohlfs05750b92021-10-29 08:23:08 +0200997 }
998
999 // private, but visible for testing
Milutin Kristofic1ebae372022-11-22 20:35:38 +01001000 async createFixPreview(
1001 replacement?: string
1002 ): Promise<OpenFixPreviewEventDetail> {
Ben Rohlfs05750b92021-10-29 08:23:08 +02001003 assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
Ben Rohlfs23843882022-08-04 18:06:27 +02001004 assertIsDefined(this.comment?.path, 'comment.path');
1005
Milutin Kristofic1ebae372022-11-22 20:35:38 +01001006 if (hasUserSuggestion(this.comment) || replacement) {
1007 replacement = replacement ?? getUserSuggestion(this.comment);
Ben Rohlfs23843882022-08-04 18:06:27 +02001008 assert(!!replacement, 'malformed user suggestion');
1009 const line = await this.getCommentedCode();
1010
1011 return {
1012 fixSuggestions: createUserFixSuggestion(
1013 this.comment,
1014 line,
1015 replacement
1016 ),
1017 patchNum: this.comment.patch_set,
Milutin Kristoficeb8f6552023-02-09 22:17:35 +01001018 onCloseFixPreviewCallbacks: [
1019 fixApplied => {
1020 if (fixApplied) this.handleAppliedFix();
1021 },
1022 ],
Ben Rohlfs23843882022-08-04 18:06:27 +02001023 };
1024 }
1025 if (isRobot(this.comment) && this.comment.fix_suggestions.length > 0) {
1026 const id = this.comment.robot_id;
1027 return {
1028 fixSuggestions: this.comment.fix_suggestions.map(s => {
1029 return {
1030 ...s,
1031 description: `${id ?? ''} - ${s.description ?? ''}`,
1032 };
1033 }),
1034 patchNum: this.comment.patch_set,
Milutin Kristoficeb8f6552023-02-09 22:17:35 +01001035 onCloseFixPreviewCallbacks: [],
Ben Rohlfs23843882022-08-04 18:06:27 +02001036 };
1037 }
1038 throw new Error('unable to create preview fix event');
Ben Rohlfs05750b92021-10-29 08:23:08 +02001039 }
1040
Chris Poucetafd0f7c2022-10-04 10:04:43 +00001041 private onEditingChanged() {
1042 if (this.editing) {
1043 this.collapsed = false;
1044 this.messageText = this.comment?.message ?? '';
1045 this.unresolved = this.comment?.unresolved ?? true;
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001046 if (!isError(this.comment) && !isSaving(this.comment)) {
1047 this.originalMessage = this.messageText;
1048 this.originalUnresolved = this.unresolved;
1049 }
Chris Poucetafd0f7c2022-10-04 10:04:43 +00001050 }
1051
1052 // Parent components such as the reply dialog might be interested in whether
1053 // come of their child components are in editing mode.
1054 fire(this, 'comment-editing-changed', {
1055 editing: this.editing,
1056 path: this.comment?.path ?? '',
1057 });
Ben Rohlfs05750b92021-10-29 08:23:08 +02001058 }
1059
Ben Rohlfs05750b92021-10-29 08:23:08 +02001060 // private, but visible for testing
1061 isSaveDisabled() {
1062 assertIsDefined(this.comment, 'comment');
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001063 if (isSaving(this.comment) && !this.autoSaving) return true;
Ben Rohlfs2e237552021-11-24 10:34:28 +01001064 return !this.messageText?.trimEnd();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001065 }
1066
Dhruv Srivastavae8b86392022-10-20 17:17:21 +02001067 override focus() {
1068 this.textarea?.focus();
1069 }
1070
Ben Rohlfs05750b92021-10-29 08:23:08 +02001071 private handleEsc() {
1072 // vim users don't like ESC to cancel/discard, so only do this when the
1073 // comment text is empty.
Ben Rohlfs2e237552021-11-24 10:34:28 +01001074 if (!this.messageText?.trimEnd()) this.cancel();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001075 }
1076
Ben Rohlfs05750b92021-10-29 08:23:08 +02001077 private handleAnchorClick() {
1078 assertIsDefined(this.comment, 'comment');
1079 fire(this, 'comment-anchor-tap', {
1080 number: this.comment.line || FILE,
1081 side: this.comment?.side,
Milutin Kristoficafae0052020-09-17 10:38:08 +02001082 });
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001083 }
1084
Dhruv Srivastava15950b452022-09-12 10:56:53 +02001085 private async handleSaveButtonClicked() {
1086 await this.save();
1087 if (this.permanentEditingMode) {
1088 this.editing = !this.editing;
1089 }
Dhruv Srivastava705eac92022-09-01 11:33:27 +02001090 }
1091
Ben Rohlfs23843882022-08-04 18:06:27 +02001092 private handlePleaseFix() {
1093 const message = this.comment?.message;
1094 assert(!!message, 'empty message');
1095 const quoted = message.replace(NEWLINE_PATTERN, '\n> ');
1096 const eventDetail: ReplyToCommentEventDetail = {
1097 content: `> ${quoted}\n\nPlease fix.`,
1098 userWantsToEdit: false,
1099 unresolved: true,
1100 };
Ben Rohlfs05750b92021-10-29 08:23:08 +02001101 // Handled by <gr-comment-thread>.
Ben Rohlfs23843882022-08-04 18:06:27 +02001102 fire(this, 'reply-to-comment', eventDetail);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001103 }
1104
Milutin Kristoficeb8f6552023-02-09 22:17:35 +01001105 private handleAppliedFix() {
1106 const message = this.comment?.message;
1107 assert(!!message, 'empty message');
1108 const eventDetail: ReplyToCommentEventDetail = {
1109 content: 'Fix applied.',
1110 userWantsToEdit: false,
1111 unresolved: false,
1112 };
1113 // Handled by <gr-comment-thread>.
1114 fire(this, 'reply-to-comment', eventDetail);
1115 }
1116
Milutin Kristofic1ebae372022-11-22 20:35:38 +01001117 private async handleShowFix(replacement?: string) {
Ben Rohlfs05750b92021-10-29 08:23:08 +02001118 // Handled top-level in the diff and change view components.
Milutin Kristofic1ebae372022-11-22 20:35:38 +01001119 fire(this, 'open-fix-preview', await this.createFixPreview(replacement));
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001120 }
1121
Milutin Kristofic8238de52023-01-12 19:33:45 +01001122 async createSuggestEdit(e: MouseEvent) {
1123 e.stopPropagation();
Ben Rohlfs23843882022-08-04 18:06:27 +02001124 const line = await this.getCommentedCode();
1125 this.messageText += `${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`;
1126 }
1127
1128 async getCommentedCode() {
Milutin Kristofic1d219672022-06-21 14:57:25 +02001129 assertIsDefined(this.comment, 'comment');
1130 assertIsDefined(this.changeNum, 'changeNum');
Ben Rohlfs23843882022-08-04 18:06:27 +02001131 // TODO(milutin): Show a toast while the file is being loaded.
1132 // TODO(milutin): This should be moved into a service/model.
Milutin Kristofic1d219672022-06-21 14:57:25 +02001133 const file = await this.restApiService.getFileContent(
1134 this.changeNum,
1135 this.comment.path!,
1136 this.comment.patch_set!
1137 );
Ben Rohlfs23843882022-08-04 18:06:27 +02001138 assert(
1139 !!file && isBase64FileContent(file) && !!file.content,
1140 'file content for comment not found'
1141 );
Milutin Kristofic1d219672022-06-21 14:57:25 +02001142 const line = getContentInCommentRange(file.content, this.comment);
Ben Rohlfs23843882022-08-04 18:06:27 +02001143 assert(!!line, 'file content for comment not found');
1144 return line;
Milutin Kristofic1d219672022-06-21 14:57:25 +02001145 }
1146
Ben Rohlfs05750b92021-10-29 08:23:08 +02001147 // private, but visible for testing
1148 cancel() {
1149 assertIsDefined(this.comment, 'comment');
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001150 assert(isDraft(this.comment), 'only drafts are editable');
Ben Rohlfs2e237552021-11-24 10:34:28 +01001151 this.messageText = this.originalMessage;
1152 this.unresolved = this.originalUnresolved;
1153 this.save();
Ben Rohlfs05750b92021-10-29 08:23:08 +02001154 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001155
Ben Rohlfs2e237552021-11-24 10:34:28 +01001156 async autoSave() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001157 if (isSaving(this.comment) || this.autoSaving) return;
Ben Rohlfs2e237552021-11-24 10:34:28 +01001158 if (!this.editing || !this.comment) return;
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001159 assert(isDraft(this.comment), 'only drafts are editable');
Ben Rohlfs2e237552021-11-24 10:34:28 +01001160 const messageToSave = this.messageText.trimEnd();
1161 if (messageToSave === '') return;
1162 if (messageToSave === this.comment.message) return;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001163
Ben Rohlfs2e237552021-11-24 10:34:28 +01001164 try {
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001165 this.autoSaving = this.rawSave({showToast: false});
Ben Rohlfs2e237552021-11-24 10:34:28 +01001166 await this.autoSaving;
1167 } finally {
1168 this.autoSaving = undefined;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001169 }
Ben Rohlfs2e237552021-11-24 10:34:28 +01001170 }
1171
1172 async discard() {
1173 this.messageText = '';
1174 await this.save();
1175 }
1176
1177 async save() {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001178 assert(isDraft(this.comment), 'only drafts are editable');
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001179 // There is a minimal chance of `isSaving()` being false between iterations
1180 // of the below while loop. But this will be extremely rare and just lead
1181 // to a harmless assertion error. So let's not bother.
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001182 if (isSaving(this.comment) && !this.autoSaving) return;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001183
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001184 if (!this.permanentEditingMode) {
1185 this.editing = false;
1186 }
1187 if (this.autoSaving) {
1188 this.comment = await this.autoSaving;
1189 }
1190 // Depending on whether `messageToSave` is empty we treat this either as
1191 // a discard or a save action.
1192 const messageToSave = this.messageText.trimEnd();
1193 if (messageToSave === '') {
Ben Rohlfs0d9d0c32023-04-20 18:12:06 +02001194 if (!this.permanentEditingMode || this.somethingToSave()) {
1195 await this.getCommentsModel().discardDraft(id(this.comment));
1196 }
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001197 } else {
1198 // No need to make a backend call when nothing has changed.
1199 while (this.somethingToSave()) {
1200 this.comment = await this.rawSave({showToast: true});
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001201 if (isError(this.comment)) return;
Ben Rohlfs607126f2021-12-07 08:21:52 +01001202 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001203 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001204 }
1205
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001206 private somethingToSave() {
1207 if (!this.comment) return false;
1208 return (
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001209 isError(this.comment) ||
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001210 this.messageText.trimEnd() !== this.comment?.message ||
1211 this.unresolved !== this.comment.unresolved
1212 );
1213 }
1214
Ben Rohlfs2e237552021-11-24 10:34:28 +01001215 /** For sharing between save() and autoSave(). */
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001216 private rawSave(options: {showToast: boolean}) {
Ben Rohlfs610bb4f2023-04-17 12:34:35 +02001217 assert(isDraft(this.comment), 'only drafts are editable');
1218 assert(!isSaving(this.comment), 'saving already in progress');
Chris Poucet6c6b54f2021-12-09 02:53:13 +01001219 return this.getCommentsModel().saveDraft(
Ben Rohlfs2e237552021-11-24 10:34:28 +01001220 {
1221 ...this.comment,
Ben Rohlfs1efb1a72023-04-12 23:25:31 +02001222 message: this.messageText.trimEnd(),
Ben Rohlfs2e237552021-11-24 10:34:28 +01001223 unresolved: this.unresolved,
1224 },
1225 options.showToast
1226 );
1227 }
1228
Ben Rohlfs05750b92021-10-29 08:23:08 +02001229 private handleToggleResolved() {
1230 this.unresolved = !this.unresolved;
Dhruv Srivastava73f9edc2021-12-02 11:23:27 +01001231 if (!this.editing) {
1232 // messageText is only assigned a value if the comment reaches editing
1233 // state, however it is possible that the user toggles the resolved state
1234 // without editing the comment in which case we assign the correct value
1235 // to messageText here
1236 this.messageText = this.comment?.message ?? '';
1237 this.save();
1238 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001239 }
1240
Kamil Musin9c8833a2022-12-29 12:05:08 +01001241 private openDeleteCommentModal() {
1242 this.confirmDeleteModal?.showModal();
Kamil Musinc7d3f282022-12-29 13:27:55 +01001243 whenVisible(this.confirmDeleteDialog!, () => {
1244 this.confirmDeleteDialog!.resetFocus();
1245 });
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001246 }
1247
Dhruv Srivastava4063d262022-11-09 18:46:29 +05301248 private closeDeleteCommentModal() {
Dhruv Srivastava4063d262022-11-09 18:46:29 +05301249 this.confirmDeleteModal?.close();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001250 }
1251
Ben Rohlfs05750b92021-10-29 08:23:08 +02001252 /**
1253 * Deleting a *published* comment is an admin feature. It means more than just
1254 * discarding a draft.
Ben Rohlfs05750b92021-10-29 08:23:08 +02001255 */
1256 // private, but visible for testing
Kamil Musind88622f2023-01-02 11:52:57 +01001257 async handleConfirmDeleteComment() {
Kamil Musinc7d3f282022-12-29 13:27:55 +01001258 if (!this.confirmDeleteDialog || !this.confirmDeleteDialog.message) {
Milutin Kristoficafae0052020-09-17 10:38:08 +02001259 throw new Error('missing confirm delete dialog');
1260 }
Ben Rohlfs05750b92021-10-29 08:23:08 +02001261 assertIsDefined(this.changeNum, 'changeNum');
1262 assertIsDefined(this.comment, 'comment');
Kamil Musind88622f2023-01-02 11:52:57 +01001263
1264 await this.getCommentsModel().deleteComment(
1265 this.changeNum,
1266 this.comment,
Kamil Musinc7d3f282022-12-29 13:27:55 +01001267 this.confirmDeleteDialog.message
Kamil Musind88622f2023-01-02 11:52:57 +01001268 );
1269 this.closeDeleteCommentModal();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001270 }
1271}
1272
Milutin Kristoficafae0052020-09-17 10:38:08 +02001273declare global {
1274 interface HTMLElementTagNameMap {
1275 'gr-comment': GrComment;
1276 }
Ben Rohlfs5b3c6552023-02-18 13:02:46 +01001277 interface HTMLElementEventMap {
1278 'copy-comment-link': CustomEvent<{}>;
1279 }
Milutin Kristoficafae0052020-09-17 10:38:08 +02001280}