blob: ad2daf5269dc3c2acb039674c473a7a296f42757 [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 '@polymer/iron-autogrow-textarea/iron-autogrow-textarea';
7import '../../../styles/shared-styles';
8import '../../plugins/gr-endpoint-decorator/gr-endpoint-decorator';
9import '../../plugins/gr-endpoint-param/gr-endpoint-param';
10import '../gr-button/gr-button';
11import '../gr-dialog/gr-dialog';
Milutin Kristoficafae0052020-09-17 10:38:08 +020012import '../gr-formatted-text/gr-formatted-text';
Chris Poucet1c713862022-07-25 13:12:24 +020013import '../gr-icon/gr-icon';
Milutin Kristoficafae0052020-09-17 10:38:08 +020014import '../gr-overlay/gr-overlay';
Milutin Kristoficafae0052020-09-17 10:38:08 +020015import '../gr-textarea/gr-textarea';
16import '../gr-tooltip-content/gr-tooltip-content';
17import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
18import '../gr-account-label/gr-account-label';
Chris Poucetc6e880b2021-11-15 19:57:06 +010019import {getAppContext} from '../../../services/app-context';
Milutin Kristofic1d219672022-06-21 14:57:25 +020020import {css, html, LitElement, nothing, PropertyValues} from 'lit';
Frank Borden42c1a452022-08-11 16:27:20 +020021import {customElement, property, query, state} from 'lit/decorators.js';
Chris Poucet9221cce2022-01-05 16:37:11 +010022import {resolve} from '../../../models/dependency';
Milutin Kristoficafae0052020-09-17 10:38:08 +020023import {GrTextarea} from '../gr-textarea/gr-textarea';
Milutin Kristoficafae0052020-09-17 10:38:08 +020024import {GrOverlay} from '../gr-overlay/gr-overlay';
25import {
Milutin Kristoficafae0052020-09-17 10:38:08 +020026 AccountDetailInfo,
Ben Rohlfs4401b232021-10-21 13:51:59 +020027 NumericChangeId,
Dhruv Srivastava0287bf92020-09-11 16:56:38 +020028 RepoName,
Ben Rohlfs05750b92021-10-29 08:23:08 +020029 RobotCommentInfo,
Milutin Kristoficafae0052020-09-17 10:38:08 +020030} from '../../../types/common';
Milutin Kristoficafae0052020-09-17 10:38:08 +020031import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
Ben Rohlfs1d487062020-09-26 11:26:03 +020032import {
Ben Rohlfs05750b92021-10-29 08:23:08 +020033 Comment,
Ben Rohlfs23843882022-08-04 18:06:27 +020034 createUserFixSuggestion,
Ben Rohlfs607126f2021-12-07 08:21:52 +010035 DraftInfo,
Milutin Kristofic1d219672022-06-21 14:57:25 +020036 getContentInCommentRange,
Ben Rohlfs23843882022-08-04 18:06:27 +020037 getUserSuggestion,
Milutin Kristofic1d219672022-06-21 14:57:25 +020038 hasUserSuggestion,
Ben Rohlfs05750b92021-10-29 08:23:08 +020039 isDraftOrUnsaved,
Ben Rohlfsc31773c2021-10-01 11:50:13 +020040 isRobot,
Ben Rohlfs05750b92021-10-29 08:23:08 +020041 isUnsaved,
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 Rohlfs23843882022-08-04 18:06:27 +020050import {fire, fireEvent} from '../../../utils/event-util';
51import {assertIsDefined, assert} from '../../../utils/common-util';
Ben Rohlfs05750b92021-10-29 08:23:08 +020052import {Key, Modifier} 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 Rohlfs05750b92021-10-29 08:23:08 +020058import {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';
Ben Rohlfs19b6c722022-06-02 13:55:59 +020063import {Interaction} from '../../../constants/reporting';
Milutin Kristofic1d219672022-06-21 14:57:25 +020064import {KnownExperimentId} from '../../../services/flags/flags';
65import {isBase64FileContent} from '../../../api/rest-api';
Ben Rohlfs731738b2022-09-15 15:55:33 +020066import {createDiffUrl} from '../../../models/views/diff';
Wyatt Allen494e7d42017-09-12 17:01:42 -070067
Dhruv Srivastava8b015a62020-07-09 17:45:25 +020068const UNSAVED_MESSAGE = 'Unable to save draft';
Wyatt Allen846ac2f2018-05-14 12:59:23 -070069
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010070const FILE = 'FILE';
71
Ben Rohlfs2e237552021-11-24 10:34:28 +010072// visible for testing
73export const AUTO_SAVE_DEBOUNCE_DELAY_MS = 2000;
74
Dhruv Srivastava8b015a62020-07-09 17:45:25 +020075export const __testOnly_UNSAVED_MESSAGE = UNSAVED_MESSAGE;
76
Ben Rohlfs05750b92021-10-29 08:23:08 +020077declare global {
78 interface HTMLElementEventMap {
Dhruv Srivastavaee018e92022-08-31 11:37:46 +020079 'comment-editing-changed': CustomEvent<CommentEditingChangedDetail>;
Dhruv Srivastava463bb332022-08-31 13:00:49 +020080 'comment-unresolved-changed': ValueChangedEvent<boolean>;
81 'comment-text-changed': ValueChangedEvent<string>;
Ben Rohlfs05750b92021-10-29 08:23:08 +020082 'comment-anchor-tap': CustomEvent<CommentAnchorTapEventDetail>;
83 }
Milutin Kristoficafae0052020-09-17 10:38:08 +020084}
85
Ben Rohlfs05750b92021-10-29 08:23:08 +020086export interface CommentAnchorTapEventDetail {
87 number: LineNumber;
88 side?: CommentSide;
Milutin Kristoficafae0052020-09-17 10:38:08 +020089}
Dmitrii Filippov3f3c2052020-09-22 16:51:18 +020090
Dhruv Srivastavaee018e92022-08-31 11:37:46 +020091export interface CommentEditingChangedDetail {
92 editing: boolean;
93 path: string;
94}
95
Milutin Kristoficafae0052020-09-17 10:38:08 +020096@customElement('gr-comment')
Ben Rohlfs05750b92021-10-29 08:23:08 +020097export class GrComment extends LitElement {
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +010098 /**
Ben Rohlfs23843882022-08-04 18:06:27 +020099 * Fired when the parent thread component should create a reply.
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100100 *
Ben Rohlfs23843882022-08-04 18:06:27 +0200101 * @event reply-to-comment
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100102 */
Kasper Nilssond43d2a72018-10-19 14:26:41 -0700103
Dmitrii Filippov3fd2b102019-11-15 16:16:46 +0100104 /**
Ben Rohlfs23843882022-08-04 18:06:27 +0200105 * Fired when the open fix preview action is triggered.
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100106 *
107 * @event open-fix-preview
Tao Zhou500437d2020-02-14 16:57:27 +0100108 */
Tao Zhou500437d2020-02-14 16:57:27 +0100109
110 /**
Tao Zhou31f3f102020-04-27 16:15:29 +0200111 * Fired when editing status changed.
112 *
113 * @event comment-editing-changed
114 */
115
116 /**
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100117 * Fired when the comment's timestamp is tapped.
118 *
119 * @event comment-anchor-tap
120 */
Andrew Bonventre28165262016-05-19 17:24:45 -0700121
Ben Rohlfs05750b92021-10-29 08:23:08 +0200122 @query('#editTextarea')
123 textarea?: GrTextarea;
Viktar Donich7ad28922016-05-23 15:24:05 -0700124
Ben Rohlfs05750b92021-10-29 08:23:08 +0200125 @query('#container')
126 container?: HTMLElement;
Dhruv Srivastava0287bf92020-09-11 16:56:38 +0200127
Ben Rohlfs05750b92021-10-29 08:23:08 +0200128 @query('#resolvedCheckbox')
129 resolvedCheckbox?: HTMLInputElement;
Kasper Nilssond43d2a72018-10-19 14:26:41 -0700130
Ben Rohlfs05750b92021-10-29 08:23:08 +0200131 @query('#confirmDeleteOverlay')
132 confirmDeleteOverlay?: GrOverlay;
133
134 @property({type: Object})
135 comment?: Comment;
136
137 // TODO: Move this out of gr-comment. gr-comment should not have a comments
138 // property. This is only used for hasHumanReply at the moment.
Milutin Kristoficafae0052020-09-17 10:38:08 +0200139 @property({type: Array})
Ben Rohlfs05750b92021-10-29 08:23:08 +0200140 comments?: Comment[];
Milutin Kristoficafae0052020-09-17 10:38:08 +0200141
142 /**
Ben Rohlfs05750b92021-10-29 08:23:08 +0200143 * Initial collapsed state of the comment.
Milutin Kristoficafae0052020-09-17 10:38:08 +0200144 */
Ben Rohlfs05750b92021-10-29 08:23:08 +0200145 @property({type: Boolean, attribute: 'initially-collapsed'})
146 initiallyCollapsed?: boolean;
147
148 /**
Dhruv Srivastavae75f6f72022-08-25 10:14:37 +0200149 * Hide the header for patchset level comments used in GrReplyDialog.
150 */
151 @property({type: Boolean, attribute: 'hide-header'})
152 hideHeader = false;
153
154 /**
Ben Rohlfs05750b92021-10-29 08:23:08 +0200155 * This is the *current* (internal) collapsed state of the comment. Do not set
156 * from the outside. Use `initiallyCollapsed` instead. This is just a
157 * reflected property such that css rules can be based on it.
158 */
159 @property({type: Boolean, reflect: true})
160 collapsed?: boolean;
161
162 @property({type: Boolean, attribute: 'robot-button-disabled'})
163 robotButtonDisabled = false;
164
Dhruv Srivastavaf007abc2022-09-06 11:18:57 +0200165 @property({type: String})
166 messagePlaceholder?: string;
167
Chris Poucetfae532b2022-02-16 13:42:20 +0100168 /* private, but used in css rules */
Ben Rohlfs05750b92021-10-29 08:23:08 +0200169 @property({type: Boolean, reflect: true})
170 saving = false;
171
Dhruv Srivastava4e5fd112022-08-25 12:01:22 +0200172 // GrReplyDialog requires the patchset level comment to always remain
173 // editable.
174 @property({type: Boolean, attribute: 'permanent-editing-mode'})
175 permanentEditingMode = false;
176
Ben Rohlfs2e237552021-11-24 10:34:28 +0100177 /**
178 * `saving` and `autoSaving` are separate and cannot be set at the same time.
179 * `saving` affects the UI state (disabled buttons, etc.) and eventually
180 * leaves editing mode, but `autoSaving` just happens in the background
181 * without the user noticing.
182 */
183 @state()
Ben Rohlfs607126f2021-12-07 08:21:52 +0100184 autoSaving?: Promise<DraftInfo>;
Ben Rohlfs2e237552021-11-24 10:34:28 +0100185
Ben Rohlfs05750b92021-10-29 08:23:08 +0200186 @state()
187 changeNum?: NumericChangeId;
188
189 @state()
190 editing = false;
191
192 @state()
Ben Rohlfs05750b92021-10-29 08:23:08 +0200193 repoName?: RepoName;
194
195 /* The 'dirty' state of the comment.message, which will be saved on demand. */
196 @state()
197 messageText = '';
198
199 /* The 'dirty' state of !comment.unresolved, which will be saved on demand. */
200 @state()
201 unresolved = true;
Milutin Kristoficafae0052020-09-17 10:38:08 +0200202
203 @property({type: Boolean})
Ben Rohlfs05750b92021-10-29 08:23:08 +0200204 showConfirmDeleteOverlay = false;
Milutin Kristoficafae0052020-09-17 10:38:08 +0200205
206 @property({type: Boolean})
Ben Rohlfs05750b92021-10-29 08:23:08 +0200207 unableToSave = false;
Milutin Kristoficafae0052020-09-17 10:38:08 +0200208
Ben Rohlfs05750b92021-10-29 08:23:08 +0200209 @property({type: Boolean, attribute: 'show-patchset'})
210 showPatchset = false;
Tao Zhou500437d2020-02-14 16:57:27 +0100211
Ben Rohlfs05750b92021-10-29 08:23:08 +0200212 @property({type: Boolean, attribute: 'show-ported-comment'})
Dhruv Srivastava0287bf92020-09-11 16:56:38 +0200213 showPortedComment = false;
214
Ben Rohlfs05750b92021-10-29 08:23:08 +0200215 @state()
216 account?: AccountDetailInfo;
217
218 @state()
219 isAdmin = false;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100220
Milutin Kristofic15bfb3e2022-08-16 20:01:33 +0200221 @state()
222 isOwner = false;
223
Chris Poucetc6e880b2021-11-15 19:57:06 +0100224 private readonly restApiService = getAppContext().restApiService;
Ben Rohlfs43935a42020-12-01 19:14:09 +0100225
Chris Poucetc6e880b2021-11-15 19:57:06 +0100226 private readonly reporting = getAppContext().reportingService;
Milutin Kristoficafae0052020-09-17 10:38:08 +0200227
Milutin Kristofic1d219672022-06-21 14:57:25 +0200228 private readonly flagsService = getAppContext().flagsService;
229
Chris Poucetbf65b8f2022-01-18 21:18:12 +0000230 private readonly getChangeModel = resolve(this, changeModelToken);
Chris Poucet01422482021-11-30 19:43:28 +0100231
Chris Poucet6c6b54f2021-12-09 02:53:13 +0100232 // Private but used in tests.
233 readonly getCommentsModel = resolve(this, commentsModelToken);
Dhruv Srivastavadb2ab602021-06-24 15:20:29 +0200234
Ben Rohlfs05750b92021-10-29 08:23:08 +0200235 private readonly userModel = getAppContext().userModel;
Ben Rohlfsf92d3b52021-03-10 23:13:03 +0100236
Ben Rohlfs05750b92021-10-29 08:23:08 +0200237 private readonly shortcuts = new ShortcutController(this);
Ben Rohlfsf92d3b52021-03-10 23:13:03 +0100238
Ben Rohlfs2e237552021-11-24 10:34:28 +0100239 /**
240 * This is triggered when the user types into the editing textarea. We then
241 * debounce it and call autoSave().
242 */
243 private autoSaveTrigger$ = new Subject();
244
245 /**
246 * Set to the content of DraftInfo when entering editing mode.
247 * Only used for "Cancel".
248 */
249 private originalMessage = '';
250
251 /**
252 * Set to the content of DraftInfo when entering editing mode.
253 * Only used for "Cancel".
254 */
255 private originalUnresolved = false;
256
Ben Rohlfs05750b92021-10-29 08:23:08 +0200257 constructor() {
258 super();
Dhruv Srivastavae110a372022-09-08 12:18:33 +0200259 // Allow the shortcuts to bubble up so that GrReplyDialog can respond to
260 // them as well.
261 this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc(), {
262 preventDefault: false,
263 });
Dhruv Srivastavaf43eee72022-09-14 11:03:01 +0200264 for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
265 this.shortcuts.addLocal(
266 {key: Key.ENTER, modifiers: [modifier]},
267 () => {
268 this.save();
269 },
270 {preventDefault: false}
271 );
272 }
273 // For Ctrl+s add shorctut with preventDefault so that it does
274 // not bubble up to the browser
275 for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
276 this.shortcuts.addLocal({key: 's', modifiers: [modifier]}, () => {
277 this.save();
278 });
Ben Rohlfsaadbdd12021-10-19 11:49:01 +0200279 }
Dhruv Srivastavaa320d372022-09-06 14:42:39 +0200280 if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
281 this.messagePlaceholder = 'Mention others with @';
282 }
Chris Poucet0b961412022-01-05 16:24:50 +0100283 subscribe(
284 this,
Chris Poucet5ec77f02022-05-12 11:25:21 +0200285 () => this.userModel.account$,
286 x => (this.account = x)
287 );
288 subscribe(
289 this,
290 () => this.userModel.isAdmin$,
291 x => (this.isAdmin = x)
292 );
293
294 subscribe(
295 this,
296 () => this.getChangeModel().repo$,
297 x => (this.repoName = x)
298 );
299 subscribe(
300 this,
301 () => this.getChangeModel().changeNum$,
Chris Poucetbf65b8f2022-01-18 21:18:12 +0000302 x => (this.changeNum = x)
303 );
304 subscribe(
305 this,
Milutin Kristofic15bfb3e2022-08-16 20:01:33 +0200306 () => this.getChangeModel().isOwner$,
307 x => (this.isOwner = x)
308 );
309 subscribe(
310 this,
Chris Poucet5ec77f02022-05-12 11:25:21 +0200311 () =>
312 this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
Chris Poucetbf65b8f2022-01-18 21:18:12 +0000313 () => {
314 this.autoSave();
315 }
316 );
Chris Poucet0b961412022-01-05 16:24:50 +0100317 }
318
Gerrit Code Review86b969c2021-08-19 14:33:41 +0000319 override disconnectedCallback() {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200320 // Clean up emoji dropdown.
321 if (this.textarea) this.textarea.closeDropdown();
Ben Rohlfs19b6c722022-06-02 13:55:59 +0200322 if (this.editing) {
323 this.reporting.reportInteraction(
324 Interaction.COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED
325 );
326 }
Ben Rohlfs5f520da2021-03-10 14:58:43 +0100327 super.disconnectedCallback();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100328 }
Andrew Bonventre78792e82016-03-04 17:48:22 -0500329
Ben Rohlfs05750b92021-10-29 08:23:08 +0200330 static override get styles() {
331 return [
332 sharedStyles,
333 css`
334 :host {
335 display: block;
336 font-family: var(--font-family);
337 padding: var(--spacing-m);
338 }
339 :host([collapsed]) {
340 padding: var(--spacing-s) var(--spacing-m);
341 }
342 :host([saving]) {
343 pointer-events: none;
344 }
345 :host([saving]) .actions,
346 :host([saving]) .robotActions,
347 :host([saving]) .date {
348 opacity: 0.5;
349 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200350 .header {
351 align-items: center;
352 cursor: pointer;
353 display: flex;
Dhruv Srivastavad8f61e72022-09-16 07:34:34 +0000354 padding-bottom: var(--spacing-m);
355 }
356 :host([collapsed]) .header {
357 padding-bottom: 0px;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200358 }
359 .headerLeft > span {
360 font-weight: var(--font-weight-bold);
361 }
362 .headerMiddle {
363 color: var(--deemphasized-text-color);
364 flex: 1;
365 overflow: hidden;
366 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200367 .draftTooltip {
Ben Rohlfsba361a42022-09-01 12:12:45 +0200368 font-weight: var(--font-weight-bold);
Ben Rohlfs05750b92021-10-29 08:23:08 +0200369 display: inline;
370 }
Ben Rohlfsba361a42022-09-01 12:12:45 +0200371 .draftTooltip gr-icon {
372 color: var(--info-foreground);
373 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200374 .date {
375 justify-content: flex-end;
376 text-align: right;
377 white-space: nowrap;
378 }
379 span.date {
380 color: var(--deemphasized-text-color);
381 }
382 span.date:hover {
383 text-decoration: underline;
384 }
385 .actions,
386 .robotActions {
387 display: flex;
388 justify-content: flex-end;
389 padding-top: 0;
390 }
391 .robotActions {
392 /* Better than the negative margin would be to remove the gr-button
393 * padding, but then we would also need to fix the buttons that are
394 * inserted by plugins. :-/ */
395 margin: 4px 0 -4px;
396 }
397 .action {
398 margin-left: var(--spacing-l);
399 }
400 .rightActions {
401 display: flex;
402 justify-content: flex-end;
403 }
404 .rightActions gr-button {
405 --gr-button-padding: 0 var(--spacing-s);
406 }
407 .editMessage {
408 display: block;
Dhruv Srivastava694e9372022-09-13 10:29:08 +0200409 margin-bottom: var(--spacing-m);
Ben Rohlfs05750b92021-10-29 08:23:08 +0200410 width: 100%;
411 }
412 .show-hide {
413 margin-left: var(--spacing-s);
414 }
415 .robotId {
416 color: var(--deemphasized-text-color);
417 margin-bottom: var(--spacing-m);
418 }
419 .robotRun {
420 margin-left: var(--spacing-m);
421 }
422 .robotRunLink {
423 margin-left: var(--spacing-m);
424 }
425 /* just for a11y */
426 input.show-hide {
427 display: none;
428 }
429 label.show-hide {
430 cursor: pointer;
431 display: block;
432 }
Chris Poucet1c713862022-07-25 13:12:24 +0200433 label.show-hide gr-icon {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200434 vertical-align: top;
435 }
436 :host([collapsed]) #container .body {
437 padding-top: 0;
438 }
439 #container .collapsedContent {
440 display: block;
441 overflow: hidden;
442 padding-left: var(--spacing-m);
443 text-overflow: ellipsis;
444 white-space: nowrap;
445 }
446 .resolve,
447 .unresolved {
448 align-items: center;
449 display: flex;
450 flex: 1;
451 margin: 0;
452 }
453 .resolve label {
454 color: var(--comment-text-color);
455 }
456 gr-dialog .main {
457 display: flex;
458 flex-direction: column;
459 width: 100%;
460 }
461 #deleteBtn {
462 --gr-button-text-color: var(--deemphasized-text-color);
463 --gr-button-padding: 0;
464 }
465
466 /** Disable select for the caret and actions */
467 .actions,
468 .show-hide {
469 -webkit-user-select: none;
470 -moz-user-select: none;
471 -ms-user-select: none;
472 user-select: none;
473 }
474
Ben Rohlfs05750b92021-10-29 08:23:08 +0200475 .pointer {
476 cursor: pointer;
477 }
478 .patchset-text {
479 color: var(--deemphasized-text-color);
480 margin-left: var(--spacing-s);
481 }
482 .headerLeft gr-account-label {
483 --account-max-length: 130px;
484 width: 150px;
485 }
486 .headerLeft gr-account-label::part(gr-account-label-text) {
487 font-weight: var(--font-weight-bold);
488 }
489 .draft gr-account-label {
490 width: unset;
491 }
Frank Borden0c078842022-09-19 15:47:26 +0200492 .draft gr-formatted-text.message {
Frank Borden3b3a4c92022-09-28 14:14:00 +0200493 display: block;
Frank Borden0c078842022-09-19 15:47:26 +0200494 margin-bottom: var(--spacing-m);
495 }
Ben Rohlfs05750b92021-10-29 08:23:08 +0200496 .portedMessage {
497 margin: 0 var(--spacing-m);
498 }
499 .link-icon {
Chris Poucetc4142042022-06-28 17:51:50 +0200500 margin-left: var(--spacing-m);
Ben Rohlfs05750b92021-10-29 08:23:08 +0200501 cursor: pointer;
502 }
503 `,
504 ];
Dhruv Srivastavacf70e792020-07-24 15:35:39 +0200505 }
506
Ben Rohlfs05750b92021-10-29 08:23:08 +0200507 override render() {
508 if (isUnsaved(this.comment) && !this.editing) return;
509 const classes = {container: true, draft: isDraftOrUnsaved(this.comment)};
510 return html`
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200511 <div id="container" class=${classMap(classes)}>
Dhruv Srivastavae75f6f72022-08-25 10:14:37 +0200512 ${this.renderHeader()}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200513 <div class="body">
514 ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
Ben Rohlfs6e9a9c92022-01-20 10:18:02 +0100515 ${this.renderCommentMessage()} ${this.renderHumanActions()}
Milutin Kristofic1d219672022-06-21 14:57:25 +0200516 ${this.renderRobotActions()} ${this.renderSuggestEditActions()}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200517 </div>
518 </div>
519 ${this.renderConfirmDialog()}
520 `;
521 }
522
Dhruv Srivastavae75f6f72022-08-25 10:14:37 +0200523 private renderHeader() {
524 if (this.hideHeader) return nothing;
525 return html`
526 <div
527 class="header"
528 id="header"
529 @click=${() => (this.collapsed = !this.collapsed)}
530 >
531 <div class="headerLeft">
532 ${this.renderAuthor()} ${this.renderPortedCommentMessage()}
533 ${this.renderDraftLabel()}
534 </div>
535 <div class="headerMiddle">${this.renderCollapsedContent()}</div>
536 ${this.renderRunDetails()} ${this.renderDeleteButton()}
537 ${this.renderPatchset()} ${this.renderDate()} ${this.renderToggle()}
538 </div>
539 `;
540 }
541
Ben Rohlfs05750b92021-10-29 08:23:08 +0200542 private renderAuthor() {
Ben Rohlfsba361a42022-09-01 12:12:45 +0200543 if (isDraftOrUnsaved(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 }
548 const classes = {draft: isDraftOrUnsaved(this.comment)};
549 return html`
550 <gr-account-label
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200551 .account=${this.comment?.author ?? this.account}
552 class=${classMap(classes)}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200553 >
554 </gr-account-label>
555 `;
556 }
557
558 private renderPortedCommentMessage() {
559 if (!this.showPortedComment) return;
560 if (!this.comment?.patch_set) return;
561 return html`
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200562 <a href=${this.getUrlForComment()}>
563 <span class="portedMessage" @click=${this.handlePortedMessageClick}>
Ben Rohlfs95796222021-12-01 16:39:42 +0100564 From patchset ${this.comment?.patch_set}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200565 </span>
566 </a>
567 `;
568 }
569
570 private renderDraftLabel() {
571 if (!isDraftOrUnsaved(this.comment)) return;
Ben Rohlfsba361a42022-09-01 12:12:45 +0200572 let label = 'Draft';
Ben Rohlfs05750b92021-10-29 08:23:08 +0200573 let tooltip =
574 'This draft is only visible to you. ' +
575 "To publish drafts, click the 'Reply' or 'Start review' button " +
576 "at the top of the change or press the 'a' key.";
577 if (this.unableToSave) {
578 label += ' (Failed to save)';
579 tooltip = 'Unable to save draft. Please try to save again.';
580 }
581 return html`
582 <gr-tooltip-content
583 class="draftTooltip"
584 has-tooltip
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200585 title=${tooltip}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200586 max-width="20em"
Ben Rohlfs05750b92021-10-29 08:23:08 +0200587 >
Ben Rohlfsba361a42022-09-01 12:12:45 +0200588 <gr-icon filled icon="rate_review"></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200589 <span class="draftLabel">${label}</span>
590 </gr-tooltip-content>
591 `;
592 }
593
594 private renderCollapsedContent() {
595 if (!this.collapsed) return;
596 return html`
597 <span class="collapsedContent">${this.comment?.message}</span>
598 `;
599 }
600
601 private renderRunDetails() {
602 if (!isRobot(this.comment)) return;
603 if (!this.comment?.url || this.collapsed) return;
604 return html`
605 <div class="runIdMessage message">
606 <div class="runIdInformation">
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200607 <a class="robotRunLink" href=${this.comment.url}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200608 <span class="robotRun link">Run Details</span>
609 </a>
610 </div>
611 </div>
612 `;
613 }
614
615 /**
616 * Deleting a comment is an admin feature. It means more than just discarding
617 * a draft. It is an action applied to published comments.
618 */
619 private renderDeleteButton() {
620 if (
621 !this.isAdmin ||
622 isDraftOrUnsaved(this.comment) ||
623 isRobot(this.comment)
624 )
625 return;
626 if (this.collapsed) return;
627 return html`
628 <gr-button
629 id="deleteBtn"
630 title="Delete Comment"
631 link
632 class="action delete"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200633 @click=${this.openDeleteCommentOverlay}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200634 >
Chris Poucet1c713862022-07-25 13:12:24 +0200635 <gr-icon id="icon" icon="delete" filled></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200636 </gr-button>
637 `;
638 }
639
640 private renderPatchset() {
641 if (!this.showPatchset) return;
642 assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
643 return html`
644 <span class="patchset-text"> Patchset ${this.comment.patch_set}</span>
645 `;
646 }
647
648 private renderDate() {
649 if (!this.comment?.updated || this.collapsed) return;
650 return html`
651 <span class="separator"></span>
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200652 <span class="date" tabindex="0" @click=${this.handleAnchorClick}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200653 <gr-date-formatter
654 withTooltip
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200655 .dateStr=${this.comment.updated}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200656 ></gr-date-formatter>
657 </span>
658 `;
659 }
660
661 private renderToggle() {
Chris Poucetb8c06392022-07-08 16:35:43 +0200662 const icon = this.collapsed ? 'expand_more' : 'expand_less';
Ben Rohlfs05750b92021-10-29 08:23:08 +0200663 const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
664 return html`
665 <div class="show-hide" tabindex="0">
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200666 <label class="show-hide" aria-label=${ariaLabel}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200667 <input
668 type="checkbox"
669 class="show-hide"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200670 ?checked=${this.collapsed}
671 @change=${() => (this.collapsed = !this.collapsed)}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200672 />
Chris Poucet1c713862022-07-25 13:12:24 +0200673 <gr-icon icon=${icon} id="icon"></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200674 </label>
675 </div>
676 `;
677 }
678
679 private renderRobotAuthor() {
680 if (!isRobot(this.comment) || this.collapsed) return;
681 return html`<div class="robotId">${this.comment.author?.name}</div>`;
682 }
683
684 private renderEditingTextarea() {
685 if (!this.editing || this.collapsed) return;
686 return html`
687 <gr-textarea
688 id="editTextarea"
689 class="editMessage"
690 autocomplete="on"
691 code=""
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200692 ?disabled=${this.saving}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200693 rows="4"
Dhruv Srivastavaf007abc2022-09-06 11:18:57 +0200694 .placeholder=${this.messagePlaceholder}
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200695 text=${this.messageText}
696 @text-changed=${(e: ValueChangedEvent) => {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200697 // TODO: This is causing a re-render of <gr-comment> on every key
698 // press. Try to avoid always setting `this.messageText` or at least
Ben Rohlfs2e237552021-11-24 10:34:28 +0100699 // debounce it. Most of the code can just inspect the current value
Ben Rohlfs05750b92021-10-29 08:23:08 +0200700 // of the textare instead of needing a dedicated property.
701 this.messageText = e.detail.value;
Ben Rohlfs2e237552021-11-24 10:34:28 +0100702 this.autoSaveTrigger$.next();
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200703 }}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200704 ></gr-textarea>
705 `;
706 }
707
Ben Rohlfs05750b92021-10-29 08:23:08 +0200708 private renderCommentMessage() {
709 if (this.collapsed || this.editing) return;
Frank Bordenf9a29992022-08-24 20:19:23 +0200710
Ben Rohlfs05750b92021-10-29 08:23:08 +0200711 return html`
712 <!--The "message" class is needed to ensure selectability from
713 gr-diff-selection.-->
714 <gr-formatted-text
715 class="message"
Frank Bordenabdd1872022-09-26 12:55:59 +0200716 .markdown=${true}
717 .content=${this.comment?.message ?? ''}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200718 ></gr-formatted-text>
719 `;
720 }
721
722 private renderCopyLinkIcon() {
723 // Only show the icon when the thread contains a published comment.
724 if (!this.comment?.in_reply_to && isDraftOrUnsaved(this.comment)) return;
725 return html`
Chris Poucet1c713862022-07-25 13:12:24 +0200726 <gr-icon
727 icon="link"
728 class="copy link-icon"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200729 @click=${this.handleCopyLink}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200730 title="Copy link to this comment"
Ben Rohlfs05750b92021-10-29 08:23:08 +0200731 role="button"
732 tabindex="0"
Chris Poucet1c713862022-07-25 13:12:24 +0200733 ></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200734 `;
735 }
736
737 private renderHumanActions() {
738 if (!this.account || isRobot(this.comment)) return;
739 if (this.collapsed || !isDraftOrUnsaved(this.comment)) return;
740 return html`
741 <div class="actions">
742 <div class="action resolve">
743 <label>
744 <input
745 type="checkbox"
746 id="resolvedCheckbox"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200747 ?checked=${!this.unresolved}
748 @change=${this.handleToggleResolved}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200749 />
750 Resolved
751 </label>
752 </div>
753 ${this.renderDraftActions()}
754 </div>
755 `;
756 }
757
758 private renderDraftActions() {
759 if (!isDraftOrUnsaved(this.comment)) return;
760 return html`
761 <div class="rightActions">
Ben Rohlfs2e237552021-11-24 10:34:28 +0100762 ${this.autoSaving ? html`.&nbsp;&nbsp;` : ''}
Milutin Kristofic1d219672022-06-21 14:57:25 +0200763 ${this.renderDiscardButton()} ${this.renderSuggestEditButton()}
764 ${this.renderPreviewSuggestEditButton()} ${this.renderEditButton()}
Chris Poucetc4142042022-06-28 17:51:50 +0200765 ${this.renderCancelButton()} ${this.renderSaveButton()}
766 ${this.renderCopyLinkIcon()}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200767 </div>
768 `;
769 }
770
Milutin Kristofic1d219672022-06-21 14:57:25 +0200771 private renderPreviewSuggestEditButton() {
772 if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
773 return nothing;
774 }
775 assertIsDefined(this.comment, 'comment');
776 if (!hasUserSuggestion(this.comment)) return nothing;
777 return html`
778 <gr-button
779 link
780 secondary
781 class="action show-fix"
782 ?disabled=${this.saving}
783 @click=${this.handleShowFix}
784 >
785 Preview Fix
786 </gr-button>
787 `;
788 }
789
790 private renderSuggestEditButton() {
791 if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
792 return nothing;
793 }
Dhruv Srivastava66a15632022-09-06 11:57:34 +0200794 if (
795 this.permanentEditingMode ||
796 this.comment?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
797 ) {
798 return nothing;
799 }
Milutin Kristofic1d219672022-06-21 14:57:25 +0200800 assertIsDefined(this.comment, 'comment');
801 if (hasUserSuggestion(this.comment)) return nothing;
802 // TODO(milutin): remove this check once suggesting on commit message is
803 // fixed. Currently diff line doesn't match commit message line, because
804 // of metadata in diff, which aren't in content api request.
805 if (this.comment.path === SpecialFilePath.COMMIT_MESSAGE) return nothing;
Milutin Kristofic15bfb3e2022-08-16 20:01:33 +0200806 if (this.isOwner) return nothing;
Milutin Kristofic7dec89b2022-09-13 12:11:35 +0200807 return html`<gr-button
808 link
809 class="action suggestEdit"
810 @click=${this.createSuggestEdit}
Milutin Kristofic1d219672022-06-21 14:57:25 +0200811 >Suggest Fix</gr-button
812 >`;
813 }
814
Ben Rohlfs05750b92021-10-29 08:23:08 +0200815 private renderDiscardButton() {
Dhruv Srivastava705eac92022-09-01 11:33:27 +0200816 if (this.editing || this.permanentEditingMode) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200817 return html`<gr-button
818 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200819 ?disabled=${this.saving}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200820 class="action discard"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200821 @click=${this.discard}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200822 >Discard</gr-button
823 >`;
824 }
825
826 private renderEditButton() {
827 if (this.editing) return;
828 return html`<gr-button
829 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200830 ?disabled=${this.saving}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200831 class="action edit"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200832 @click=${this.edit}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200833 >Edit</gr-button
834 >`;
835 }
836
837 private renderCancelButton() {
Dhruv Srivastava705eac92022-09-01 11:33:27 +0200838 if (!this.editing || this.permanentEditingMode) 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.saving}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200843 class="action cancel"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200844 @click=${this.cancel}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200845 >Cancel</gr-button
846 >
847 `;
848 }
849
850 private renderSaveButton() {
851 if (!this.editing && !this.unableToSave) return;
852 return html`
853 <gr-button
854 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200855 ?disabled=${this.isSaveDisabled()}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200856 class="action save"
Dhruv Srivastava705eac92022-09-01 11:33:27 +0200857 @click=${this.handleSaveButtonClicked}
Dhruv Srivastava00831e72022-09-05 08:20:20 +0200858 >${this.permanentEditingMode ? 'Preview' : 'Save'}</gr-button
Ben Rohlfs05750b92021-10-29 08:23:08 +0200859 >
860 `;
861 }
862
863 private renderRobotActions() {
864 if (!this.account || !isRobot(this.comment)) return;
865 const endpoint = html`
866 <gr-endpoint-decorator name="robot-comment-controls">
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200867 <gr-endpoint-param name="comment" .value=${this.comment}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200868 </gr-endpoint-param>
869 </gr-endpoint-decorator>
870 `;
871 return html`
872 <div class="robotActions">
873 ${this.renderCopyLinkIcon()} ${endpoint} ${this.renderShowFixButton()}
874 ${this.renderPleaseFixButton()}
875 </div>
876 `;
877 }
878
Milutin Kristofic1d219672022-06-21 14:57:25 +0200879 private renderSuggestEditActions() {
880 if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
881 return nothing;
882 }
883 if (
884 !this.account ||
885 isRobot(this.comment) ||
886 isDraftOrUnsaved(this.comment)
887 ) {
888 return nothing;
889 }
890 return html`
891 <div class="robotActions">${this.renderPreviewSuggestEditButton()}</div>
892 `;
893 }
894
Ben Rohlfs05750b92021-10-29 08:23:08 +0200895 private renderShowFixButton() {
896 if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
897 return html`
898 <gr-button
899 link
900 secondary
901 class="action show-fix"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200902 ?disabled=${this.saving}
903 @click=${this.handleShowFix}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200904 >
905 Show Fix
906 </gr-button>
907 `;
908 }
909
910 private renderPleaseFixButton() {
911 if (this.hasHumanReply()) return;
912 return html`
913 <gr-button
914 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200915 ?disabled=${this.robotButtonDisabled}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200916 class="action fix"
Ben Rohlfs23843882022-08-04 18:06:27 +0200917 @click=${this.handlePleaseFix}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200918 >
919 Please Fix
920 </gr-button>
921 `;
922 }
923
924 private renderConfirmDialog() {
925 if (!this.showConfirmDeleteOverlay) return;
926 return html`
927 <gr-overlay id="confirmDeleteOverlay" with-backdrop>
928 <gr-confirm-delete-comment-dialog
929 id="confirmDeleteComment"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200930 @confirm=${this.handleConfirmDeleteComment}
931 @cancel=${this.closeDeleteCommentOverlay}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200932 >
933 </gr-confirm-delete-comment-dialog>
934 </gr-overlay>
935 `;
936 }
937
938 private getUrlForComment() {
939 const comment = this.comment;
940 if (!comment || !this.changeNum || !this.repoName) return '';
Dhruv Srivastava0287bf92020-09-11 16:56:38 +0200941 if (!comment.id) throw new Error('comment must have an id');
Ben Rohlfs731738b2022-09-15 15:55:33 +0200942 return createDiffUrl({
943 changeNum: this.changeNum,
944 project: this.repoName,
945 commentId: comment.id,
946 });
Dhruv Srivastava0287bf92020-09-11 16:56:38 +0200947 }
948
Ben Rohlfs05750b92021-10-29 08:23:08 +0200949 private firstWillUpdateDone = false;
950
951 firstWillUpdate() {
952 if (this.firstWillUpdateDone) return;
953 this.firstWillUpdateDone = true;
Dhruv Srivastava4e5fd112022-08-25 12:01:22 +0200954 if (this.permanentEditingMode) this.editing = true;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200955 assertIsDefined(this.comment, 'comment');
956 this.unresolved = this.comment.unresolved ?? true;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200957 if (isUnsaved(this.comment)) this.editing = true;
958 if (isDraftOrUnsaved(this.comment)) {
Ben Rohlfs19b6c722022-06-02 13:55:59 +0200959 this.reporting.reportInteraction(
960 Interaction.COMMENTS_AUTOCLOSE_FIRST_UPDATE,
961 {editing: this.editing, unsaved: isUnsaved(this.comment)}
962 );
Ben Rohlfs05750b92021-10-29 08:23:08 +0200963 this.collapsed = false;
964 } else {
965 this.collapsed = !!this.initiallyCollapsed;
966 }
967 }
968
969 override willUpdate(changed: PropertyValues) {
970 this.firstWillUpdate();
971 if (changed.has('editing')) {
Chris Poucetafd0f7c2022-10-04 10:04:43 +0000972 this.onEditingChanged();
Ben Rohlfs05750b92021-10-29 08:23:08 +0200973 }
974 if (changed.has('unresolved')) {
975 // The <gr-comment-thread> component wants to change its color based on
976 // the (dirty) unresolved state, so let's notify it about changes.
Dhruv Srivastava463bb332022-08-31 13:00:49 +0200977 fire(this, 'comment-unresolved-changed', {value: this.unresolved});
978 }
979 if (changed.has('messageText')) {
980 // GrReplyDialog updates it's state when text inside patchset level
981 // comment changes.
982 fire(this, 'comment-text-changed', {value: this.messageText});
Ben Rohlfs05750b92021-10-29 08:23:08 +0200983 }
984 }
985
986 private handlePortedMessageClick() {
Ben Rohlfsc1c6afd2021-02-18 13:13:22 +0100987 assertIsDefined(this.comment, 'comment');
Dhruv Srivastavac8df7602021-01-15 10:59:00 +0100988 this.reporting.reportInteraction('navigate-to-original-comment', {
989 line: this.comment.line,
990 range: this.comment.range,
991 });
992 }
993
Ben Rohlfs05750b92021-10-29 08:23:08 +0200994 private handleCopyLink() {
995 fireEvent(this, 'copy-comment-link');
996 }
997
998 /** Enter editing mode. */
999 private edit() {
1000 if (!isDraftOrUnsaved(this.comment)) {
1001 throw new Error('Cannot edit published comment.');
1002 }
1003 if (this.editing) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001004 this.editing = true;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001005 }
1006
1007 // TODO: Move this out of gr-comment. gr-comment should not have a comments
1008 // property.
1009 private hasHumanReply() {
1010 if (!this.comment || !this.comments) return false;
1011 return this.comments.some(
1012 c => c.in_reply_to && c.in_reply_to === this.comment?.id && !isRobot(c)
Milutin Kristoficafae0052020-09-17 10:38:08 +02001013 );
Ben Rohlfs05750b92021-10-29 08:23:08 +02001014 }
1015
1016 // private, but visible for testing
Ben Rohlfs23843882022-08-04 18:06:27 +02001017 async createFixPreview(): Promise<OpenFixPreviewEventDetail> {
Ben Rohlfs05750b92021-10-29 08:23:08 +02001018 assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
Ben Rohlfs23843882022-08-04 18:06:27 +02001019 assertIsDefined(this.comment?.path, 'comment.path');
1020
1021 if (hasUserSuggestion(this.comment)) {
1022 const replacement = getUserSuggestion(this.comment);
1023 assert(!!replacement, 'malformed user suggestion');
1024 const line = await this.getCommentedCode();
1025
1026 return {
1027 fixSuggestions: createUserFixSuggestion(
1028 this.comment,
1029 line,
1030 replacement
1031 ),
1032 patchNum: this.comment.patch_set,
1033 };
1034 }
1035 if (isRobot(this.comment) && this.comment.fix_suggestions.length > 0) {
1036 const id = this.comment.robot_id;
1037 return {
1038 fixSuggestions: this.comment.fix_suggestions.map(s => {
1039 return {
1040 ...s,
1041 description: `${id ?? ''} - ${s.description ?? ''}`,
1042 };
1043 }),
1044 patchNum: this.comment.patch_set,
1045 };
1046 }
1047 throw new Error('unable to create preview fix event');
Ben Rohlfs05750b92021-10-29 08:23:08 +02001048 }
1049
Chris Poucetafd0f7c2022-10-04 10:04:43 +00001050 private onEditingChanged() {
1051 if (this.editing) {
1052 this.collapsed = false;
1053 this.messageText = this.comment?.message ?? '';
1054 this.unresolved = this.comment?.unresolved ?? true;
1055 this.originalMessage = this.messageText;
1056 this.originalUnresolved = this.unresolved;
1057 setTimeout(() => this.textarea?.putCursorAtEnd(), 1);
1058 }
1059
1060 // Parent components such as the reply dialog might be interested in whether
1061 // come of their child components are in editing mode.
1062 fire(this, 'comment-editing-changed', {
1063 editing: this.editing,
1064 path: this.comment?.path ?? '',
1065 });
Ben Rohlfs05750b92021-10-29 08:23:08 +02001066 }
1067
Ben Rohlfs05750b92021-10-29 08:23:08 +02001068 // private, but visible for testing
1069 isSaveDisabled() {
1070 assertIsDefined(this.comment, 'comment');
1071 if (this.saving) return true;
1072 if (this.comment.unresolved !== this.unresolved) return false;
Ben Rohlfs2e237552021-11-24 10:34:28 +01001073 return !this.messageText?.trimEnd();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001074 }
1075
Ben Rohlfs05750b92021-10-29 08:23:08 +02001076 private handleEsc() {
1077 // vim users don't like ESC to cancel/discard, so only do this when the
1078 // comment text is empty.
Ben Rohlfs2e237552021-11-24 10:34:28 +01001079 if (!this.messageText?.trimEnd()) this.cancel();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001080 }
1081
Ben Rohlfs05750b92021-10-29 08:23:08 +02001082 private handleAnchorClick() {
1083 assertIsDefined(this.comment, 'comment');
1084 fire(this, 'comment-anchor-tap', {
1085 number: this.comment.line || FILE,
1086 side: this.comment?.side,
Milutin Kristoficafae0052020-09-17 10:38:08 +02001087 });
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001088 }
1089
Dhruv Srivastava15950b452022-09-12 10:56:53 +02001090 private async handleSaveButtonClicked() {
1091 await this.save();
1092 if (this.permanentEditingMode) {
1093 this.editing = !this.editing;
1094 }
Dhruv Srivastava705eac92022-09-01 11:33:27 +02001095 }
1096
Ben Rohlfs23843882022-08-04 18:06:27 +02001097 private handlePleaseFix() {
1098 const message = this.comment?.message;
1099 assert(!!message, 'empty message');
1100 const quoted = message.replace(NEWLINE_PATTERN, '\n> ');
1101 const eventDetail: ReplyToCommentEventDetail = {
1102 content: `> ${quoted}\n\nPlease fix.`,
1103 userWantsToEdit: false,
1104 unresolved: true,
1105 };
Ben Rohlfs05750b92021-10-29 08:23:08 +02001106 // Handled by <gr-comment-thread>.
Ben Rohlfs23843882022-08-04 18:06:27 +02001107 fire(this, 'reply-to-comment', eventDetail);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001108 }
1109
Ben Rohlfs23843882022-08-04 18:06:27 +02001110 private async handleShowFix() {
Ben Rohlfs05750b92021-10-29 08:23:08 +02001111 // Handled top-level in the diff and change view components.
Ben Rohlfs23843882022-08-04 18:06:27 +02001112 fire(this, 'open-fix-preview', await this.createFixPreview());
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001113 }
1114
Milutin Kristofic1d219672022-06-21 14:57:25 +02001115 async createSuggestEdit() {
Ben Rohlfs23843882022-08-04 18:06:27 +02001116 const line = await this.getCommentedCode();
1117 this.messageText += `${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`;
1118 }
1119
1120 async getCommentedCode() {
Milutin Kristofic1d219672022-06-21 14:57:25 +02001121 assertIsDefined(this.comment, 'comment');
1122 assertIsDefined(this.changeNum, 'changeNum');
Ben Rohlfs23843882022-08-04 18:06:27 +02001123 // TODO(milutin): Show a toast while the file is being loaded.
1124 // TODO(milutin): This should be moved into a service/model.
Milutin Kristofic1d219672022-06-21 14:57:25 +02001125 const file = await this.restApiService.getFileContent(
1126 this.changeNum,
1127 this.comment.path!,
1128 this.comment.patch_set!
1129 );
Ben Rohlfs23843882022-08-04 18:06:27 +02001130 assert(
1131 !!file && isBase64FileContent(file) && !!file.content,
1132 'file content for comment not found'
1133 );
Milutin Kristofic1d219672022-06-21 14:57:25 +02001134 const line = getContentInCommentRange(file.content, this.comment);
Ben Rohlfs23843882022-08-04 18:06:27 +02001135 assert(!!line, 'file content for comment not found');
1136 return line;
Milutin Kristofic1d219672022-06-21 14:57:25 +02001137 }
1138
Ben Rohlfs05750b92021-10-29 08:23:08 +02001139 // private, but visible for testing
1140 cancel() {
1141 assertIsDefined(this.comment, 'comment');
1142 if (!isDraftOrUnsaved(this.comment)) {
1143 throw new Error('only unsaved and draft comments are editable');
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001144 }
Ben Rohlfs2e237552021-11-24 10:34:28 +01001145 this.messageText = this.originalMessage;
1146 this.unresolved = this.originalUnresolved;
1147 this.save();
Ben Rohlfs05750b92021-10-29 08:23:08 +02001148 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001149
Ben Rohlfs2e237552021-11-24 10:34:28 +01001150 async autoSave() {
1151 if (this.saving || this.autoSaving) return;
1152 if (!this.editing || !this.comment) return;
1153 if (!isDraftOrUnsaved(this.comment)) return;
1154 const messageToSave = this.messageText.trimEnd();
1155 if (messageToSave === '') return;
1156 if (messageToSave === this.comment.message) return;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001157
Ben Rohlfs2e237552021-11-24 10:34:28 +01001158 try {
1159 this.autoSaving = this.rawSave(messageToSave, {showToast: false});
1160 await this.autoSaving;
1161 } finally {
1162 this.autoSaving = undefined;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001163 }
Ben Rohlfs2e237552021-11-24 10:34:28 +01001164 }
1165
1166 async discard() {
1167 this.messageText = '';
1168 await this.save();
1169 }
1170
1171 async save() {
1172 if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001173
Ben Rohlfs05750b92021-10-29 08:23:08 +02001174 try {
1175 this.saving = true;
1176 this.unableToSave = false;
Ben Rohlfs607126f2021-12-07 08:21:52 +01001177 if (this.autoSaving) {
1178 this.comment = await this.autoSaving;
1179 }
Ben Rohlfs2e237552021-11-24 10:34:28 +01001180 // Depending on whether `messageToSave` is empty we treat this either as
1181 // a discard or a save action.
1182 const messageToSave = this.messageText.trimEnd();
1183 if (messageToSave === '') {
1184 // Don't try to discard UnsavedInfo. Nothing to do then.
1185 if (this.comment.id) {
Chris Poucet6c6b54f2021-12-09 02:53:13 +01001186 await this.getCommentsModel().discardDraft(this.comment.id);
Ben Rohlfs2e237552021-11-24 10:34:28 +01001187 }
1188 } else {
1189 // No need to make a backend call when nothing has changed.
1190 if (
1191 messageToSave !== this.comment?.message ||
1192 this.unresolved !== this.comment.unresolved
1193 ) {
1194 await this.rawSave(messageToSave, {showToast: true});
1195 }
1196 }
Ben Rohlfs19b6c722022-06-02 13:55:59 +02001197 this.reporting.reportInteraction(
1198 Interaction.COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE
1199 );
Dhruv Srivastava4e5fd112022-08-25 12:01:22 +02001200 if (!this.permanentEditingMode) {
1201 this.editing = false;
1202 }
Ben Rohlfs05750b92021-10-29 08:23:08 +02001203 } catch (e) {
1204 this.unableToSave = true;
1205 throw e;
1206 } finally {
1207 this.saving = false;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001208 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001209 }
1210
Ben Rohlfs2e237552021-11-24 10:34:28 +01001211 /** For sharing between save() and autoSave(). */
1212 private rawSave(message: string, options: {showToast: boolean}) {
1213 if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
Chris Poucet6c6b54f2021-12-09 02:53:13 +01001214 return this.getCommentsModel().saveDraft(
Ben Rohlfs2e237552021-11-24 10:34:28 +01001215 {
1216 ...this.comment,
1217 message,
1218 unresolved: this.unresolved,
1219 },
1220 options.showToast
1221 );
1222 }
1223
Ben Rohlfs05750b92021-10-29 08:23:08 +02001224 private handleToggleResolved() {
1225 this.unresolved = !this.unresolved;
Dhruv Srivastava73f9edc2021-12-02 11:23:27 +01001226 if (!this.editing) {
1227 // messageText is only assigned a value if the comment reaches editing
1228 // state, however it is possible that the user toggles the resolved state
1229 // without editing the comment in which case we assign the correct value
1230 // to messageText here
1231 this.messageText = this.comment?.message ?? '';
1232 this.save();
1233 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001234 }
1235
Ben Rohlfs05750b92021-10-29 08:23:08 +02001236 private async openDeleteCommentOverlay() {
1237 this.showConfirmDeleteOverlay = true;
1238 await this.updateComplete;
Ben Rohlfscda7bf72021-11-30 17:57:14 +01001239 await this.confirmDeleteOverlay?.open();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001240 }
1241
Ben Rohlfs05750b92021-10-29 08:23:08 +02001242 private closeDeleteCommentOverlay() {
1243 this.showConfirmDeleteOverlay = false;
1244 this.confirmDeleteOverlay?.remove();
1245 this.confirmDeleteOverlay?.close();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001246 }
1247
Ben Rohlfs05750b92021-10-29 08:23:08 +02001248 /**
1249 * Deleting a *published* comment is an admin feature. It means more than just
1250 * discarding a draft.
1251 *
1252 * TODO: Also move this into the comments-service.
1253 * TODO: Figure out a good reloading strategy when deleting was successful.
1254 * `this.comment = newComment` does not seem sufficient.
1255 */
1256 // private, but visible for testing
1257 handleConfirmDeleteComment() {
Milutin Kristoficafae0052020-09-17 10:38:08 +02001258 const dialog = this.confirmDeleteOverlay?.querySelector(
1259 '#confirmDeleteComment'
1260 ) as GrConfirmDeleteCommentDialog | null;
1261 if (!dialog || !dialog.message) {
1262 throw new Error('missing confirm delete dialog');
1263 }
Ben Rohlfs05750b92021-10-29 08:23:08 +02001264 assertIsDefined(this.changeNum, 'changeNum');
1265 assertIsDefined(this.comment, 'comment');
1266 assertIsDefined(this.comment.patch_set, 'comment.patch_set');
1267 if (isDraftOrUnsaved(this.comment)) {
1268 throw new Error('Admin deletion is only for published comments.');
Milutin Kristofica6af5aa2020-09-23 09:08:14 +02001269 }
Ben Rohlfs43935a42020-12-01 19:14:09 +01001270 this.restApiService
Milutin Kristoficafae0052020-09-17 10:38:08 +02001271 .deleteComment(
1272 this.changeNum,
Ben Rohlfs05750b92021-10-29 08:23:08 +02001273 this.comment.patch_set,
Milutin Kristoficafae0052020-09-17 10:38:08 +02001274 this.comment.id,
1275 dialog.message
1276 )
1277 .then(newComment => {
Ben Rohlfs05750b92021-10-29 08:23:08 +02001278 this.closeDeleteCommentOverlay();
Milutin Kristoficafae0052020-09-17 10:38:08 +02001279 this.comment = newComment;
1280 });
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001281 }
1282}
1283
Milutin Kristoficafae0052020-09-17 10:38:08 +02001284declare global {
1285 interface HTMLElementTagNameMap {
1286 'gr-comment': GrComment;
1287 }
1288}