blob: d9004ae287f6e900c3a3f0aa33ee9d159090e3ed [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-textarea/gr-textarea';
15import '../gr-tooltip-content/gr-tooltip-content';
16import '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
17import '../gr-account-label/gr-account-label';
Chris Poucetc6e880b2021-11-15 19:57:06 +010018import {getAppContext} from '../../../services/app-context';
Milutin Kristofic1d219672022-06-21 14:57:25 +020019import {css, html, LitElement, nothing, PropertyValues} from 'lit';
Frank Borden42c1a452022-08-11 16:27:20 +020020import {customElement, property, query, state} from 'lit/decorators.js';
Chris Poucet9221cce2022-01-05 16:37:11 +010021import {resolve} from '../../../models/dependency';
Milutin Kristoficafae0052020-09-17 10:38:08 +020022import {GrTextarea} from '../gr-textarea/gr-textarea';
Milutin Kristoficafae0052020-09-17 10:38:08 +020023import {
Milutin Kristoficafae0052020-09-17 10:38:08 +020024 AccountDetailInfo,
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,
Milutin Kristoficafae0052020-09-17 10:38:08 +020028} from '../../../types/common';
Milutin Kristoficafae0052020-09-17 10:38:08 +020029import {GrConfirmDeleteCommentDialog} from '../gr-confirm-delete-comment-dialog/gr-confirm-delete-comment-dialog';
Ben Rohlfs1d487062020-09-26 11:26:03 +020030import {
Ben Rohlfs05750b92021-10-29 08:23:08 +020031 Comment,
Ben Rohlfs23843882022-08-04 18:06:27 +020032 createUserFixSuggestion,
Ben Rohlfs607126f2021-12-07 08:21:52 +010033 DraftInfo,
Milutin Kristofic1d219672022-06-21 14:57:25 +020034 getContentInCommentRange,
Ben Rohlfs23843882022-08-04 18:06:27 +020035 getUserSuggestion,
Milutin Kristofic1d219672022-06-21 14:57:25 +020036 hasUserSuggestion,
Ben Rohlfs05750b92021-10-29 08:23:08 +020037 isDraftOrUnsaved,
Ben Rohlfsc31773c2021-10-01 11:50:13 +020038 isRobot,
Ben Rohlfs05750b92021-10-29 08:23:08 +020039 isUnsaved,
Ben Rohlfs23843882022-08-04 18:06:27 +020040 NEWLINE_PATTERN,
Milutin Kristofic1d219672022-06-21 14:57:25 +020041 USER_SUGGESTION_START_PATTERN,
Ben Rohlfs31825d82020-10-02 18:08:04 +020042} from '../../../utils/comment-util';
Ben Rohlfs05750b92021-10-29 08:23:08 +020043import {
44 OpenFixPreviewEventDetail,
Ben Rohlfs23843882022-08-04 18:06:27 +020045 ReplyToCommentEventDetail,
Ben Rohlfs05750b92021-10-29 08:23:08 +020046 ValueChangedEvent,
47} from '../../../types/events';
Ben Rohlfs23843882022-08-04 18:06:27 +020048import {fire, fireEvent} from '../../../utils/event-util';
49import {assertIsDefined, assert} from '../../../utils/common-util';
Dhruv Srivastavaa49dffd2022-10-20 14:04:37 +020050import {Key, Modifier, whenVisible} from '../../../utils/dom-util';
Chris Poucetdae98bf2022-01-05 15:23:45 +010051import {commentsModelToken} from '../../../models/comments/comments-model';
Ben Rohlfs05750b92021-10-29 08:23:08 +020052import {sharedStyles} from '../../../styles/shared-styles';
53import {subscribe} from '../../lit/subscription-controller';
54import {ShortcutController} from '../../lit/shortcut-controller';
Frank Borden42c1a452022-08-11 16:27:20 +020055import {classMap} from 'lit/directives/class-map.js';
Ben Rohlfs05750b92021-10-29 08:23:08 +020056import {LineNumber} from '../../../api/diff';
Milutin Kristofic1d219672022-06-21 14:57:25 +020057import {CommentSide, SpecialFilePath} from '../../../constants/constants';
Ben Rohlfs2e237552021-11-24 10:34:28 +010058import {Subject} from 'rxjs';
59import {debounceTime} from 'rxjs/operators';
Chris Poucetbf65b8f2022-01-18 21:18:12 +000060import {changeModelToken} from '../../../models/change/change-model';
Ben Rohlfs19b6c722022-06-02 13:55:59 +020061import {Interaction} from '../../../constants/reporting';
Milutin Kristofic1d219672022-06-21 14:57:25 +020062import {KnownExperimentId} from '../../../services/flags/flags';
63import {isBase64FileContent} from '../../../api/rest-api';
Ben Rohlfs731738b2022-09-15 15:55:33 +020064import {createDiffUrl} from '../../../models/views/diff';
Chris Poucetbb0cf832022-10-24 12:32:10 +020065import {userModelToken} from '../../../models/user/user-model';
Dhruv Srivastava4063d262022-11-09 18:46:29 +053066import {modalStyles} from '../../../styles/gr-modal-styles';
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
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530131 @query('#confirmDeleteModal')
132 confirmDeleteModal?: HTMLDialogElement;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200133
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})
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530204 showConfirmDeleteModal = 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 Poucetbb0cf832022-10-24 12:32:10 +0200232 private readonly getCommentsModel = resolve(this, commentsModelToken);
Dhruv Srivastavadb2ab602021-06-24 15:20:29 +0200233
Chris Poucetbb0cf832022-10-24 12:32:10 +0200234 private readonly getUserModel = resolve(this, userModelToken);
Ben Rohlfsf92d3b52021-03-10 23:13:03 +0100235
Ben Rohlfs05750b92021-10-29 08:23:08 +0200236 private readonly shortcuts = new ShortcutController(this);
Ben Rohlfsf92d3b52021-03-10 23:13:03 +0100237
Ben Rohlfs2e237552021-11-24 10:34:28 +0100238 /**
239 * This is triggered when the user types into the editing textarea. We then
240 * debounce it and call autoSave().
241 */
242 private autoSaveTrigger$ = new Subject();
243
244 /**
245 * Set to the content of DraftInfo when entering editing mode.
246 * Only used for "Cancel".
247 */
248 private originalMessage = '';
249
250 /**
251 * Set to the content of DraftInfo when entering editing mode.
252 * Only used for "Cancel".
253 */
254 private originalUnresolved = false;
255
Ben Rohlfs05750b92021-10-29 08:23:08 +0200256 constructor() {
257 super();
Dhruv Srivastavae110a372022-09-08 12:18:33 +0200258 // Allow the shortcuts to bubble up so that GrReplyDialog can respond to
259 // them as well.
260 this.shortcuts.addLocal({key: Key.ESC}, () => this.handleEsc(), {
261 preventDefault: false,
262 });
Dhruv Srivastavaf43eee72022-09-14 11:03:01 +0200263 for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
264 this.shortcuts.addLocal(
265 {key: Key.ENTER, modifiers: [modifier]},
266 () => {
267 this.save();
268 },
269 {preventDefault: false}
270 );
271 }
272 // For Ctrl+s add shorctut with preventDefault so that it does
273 // not bubble up to the browser
274 for (const modifier of [Modifier.CTRL_KEY, Modifier.META_KEY]) {
275 this.shortcuts.addLocal({key: 's', modifiers: [modifier]}, () => {
276 this.save();
277 });
Ben Rohlfsaadbdd12021-10-19 11:49:01 +0200278 }
Dhruv Srivastavaa320d372022-09-06 14:42:39 +0200279 if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) {
280 this.messagePlaceholder = 'Mention others with @';
281 }
Chris Poucet0b961412022-01-05 16:24:50 +0100282 subscribe(
283 this,
Chris Poucetbb0cf832022-10-24 12:32:10 +0200284 () => this.getUserModel().account$,
Chris Poucet5ec77f02022-05-12 11:25:21 +0200285 x => (this.account = x)
286 );
287 subscribe(
288 this,
Chris Poucetbb0cf832022-10-24 12:32:10 +0200289 () => this.getUserModel().isAdmin$,
Chris Poucet5ec77f02022-05-12 11:25:21 +0200290 x => (this.isAdmin = x)
291 );
292
293 subscribe(
294 this,
295 () => this.getChangeModel().repo$,
296 x => (this.repoName = x)
297 );
298 subscribe(
299 this,
300 () => this.getChangeModel().changeNum$,
Chris Poucetbf65b8f2022-01-18 21:18:12 +0000301 x => (this.changeNum = x)
302 );
303 subscribe(
304 this,
Milutin Kristofic15bfb3e2022-08-16 20:01:33 +0200305 () => this.getChangeModel().isOwner$,
306 x => (this.isOwner = x)
307 );
308 subscribe(
309 this,
Chris Poucet5ec77f02022-05-12 11:25:21 +0200310 () =>
311 this.autoSaveTrigger$.pipe(debounceTime(AUTO_SAVE_DEBOUNCE_DELAY_MS)),
Chris Poucetbf65b8f2022-01-18 21:18:12 +0000312 () => {
313 this.autoSave();
314 }
315 );
Chris Poucet0b961412022-01-05 16:24:50 +0100316 }
317
Gerrit Code Review86b969c2021-08-19 14:33:41 +0000318 override disconnectedCallback() {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200319 // Clean up emoji dropdown.
320 if (this.textarea) this.textarea.closeDropdown();
Ben Rohlfs19b6c722022-06-02 13:55:59 +0200321 if (this.editing) {
322 this.reporting.reportInteraction(
323 Interaction.COMMENTS_AUTOCLOSE_EDITING_DISCONNECTED
324 );
325 }
Ben Rohlfs5f520da2021-03-10 14:58:43 +0100326 super.disconnectedCallback();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +0100327 }
Andrew Bonventre78792e82016-03-04 17:48:22 -0500328
Ben Rohlfs05750b92021-10-29 08:23:08 +0200329 static override get styles() {
330 return [
331 sharedStyles,
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530332 modalStyles,
Ben Rohlfs05750b92021-10-29 08:23:08 +0200333 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 Rohlfs7a167842022-09-29 21:55:50 +0200511 <gr-endpoint-decorator name="comment">
512 <gr-endpoint-param name="comment" .value=${this.comment}>
513 </gr-endpoint-param>
514 <gr-endpoint-param name="editing" .value=${this.editing}>
515 </gr-endpoint-param>
Ben Rohlfs57c2c592022-10-25 12:49:11 +0200516 <gr-endpoint-param name="message" .value=${this.messageText}>
517 </gr-endpoint-param>
518 <gr-endpoint-param
519 name="isDraft"
520 .value=${isDraftOrUnsaved(this.comment)}
521 >
522 </gr-endpoint-param>
Ben Rohlfs7a167842022-09-29 21:55:50 +0200523 <div id="container" class=${classMap(classes)}>
524 ${this.renderHeader()}
525 <div class="body">
526 ${this.renderRobotAuthor()} ${this.renderEditingTextarea()}
527 ${this.renderCommentMessage()}
528 <gr-endpoint-slot name="above-actions"></gr-endpoint-slot>
529 ${this.renderHumanActions()} ${this.renderRobotActions()}
530 ${this.renderSuggestEditActions()}
531 </div>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200532 </div>
Ben Rohlfs7a167842022-09-29 21:55:50 +0200533 </gr-endpoint-decorator>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200534 ${this.renderConfirmDialog()}
535 `;
536 }
537
Dhruv Srivastavae75f6f72022-08-25 10:14:37 +0200538 private renderHeader() {
539 if (this.hideHeader) return nothing;
540 return html`
541 <div
542 class="header"
543 id="header"
544 @click=${() => (this.collapsed = !this.collapsed)}
545 >
546 <div class="headerLeft">
547 ${this.renderAuthor()} ${this.renderPortedCommentMessage()}
548 ${this.renderDraftLabel()}
549 </div>
550 <div class="headerMiddle">${this.renderCollapsedContent()}</div>
551 ${this.renderRunDetails()} ${this.renderDeleteButton()}
552 ${this.renderPatchset()} ${this.renderDate()} ${this.renderToggle()}
553 </div>
554 `;
555 }
556
Ben Rohlfs05750b92021-10-29 08:23:08 +0200557 private renderAuthor() {
Ben Rohlfsba361a42022-09-01 12:12:45 +0200558 if (isDraftOrUnsaved(this.comment)) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200559 if (isRobot(this.comment)) {
560 const id = this.comment.robot_id;
561 return html`<span class="robotName">${id}</span>`;
562 }
563 const classes = {draft: isDraftOrUnsaved(this.comment)};
564 return html`
565 <gr-account-label
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200566 .account=${this.comment?.author ?? this.account}
567 class=${classMap(classes)}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200568 >
569 </gr-account-label>
570 `;
571 }
572
573 private renderPortedCommentMessage() {
574 if (!this.showPortedComment) return;
575 if (!this.comment?.patch_set) return;
576 return html`
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200577 <a href=${this.getUrlForComment()}>
578 <span class="portedMessage" @click=${this.handlePortedMessageClick}>
Ben Rohlfs95796222021-12-01 16:39:42 +0100579 From patchset ${this.comment?.patch_set}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200580 </span>
581 </a>
582 `;
583 }
584
585 private renderDraftLabel() {
586 if (!isDraftOrUnsaved(this.comment)) return;
Ben Rohlfsba361a42022-09-01 12:12:45 +0200587 let label = 'Draft';
Ben Rohlfs05750b92021-10-29 08:23:08 +0200588 let tooltip =
589 'This draft is only visible to you. ' +
590 "To publish drafts, click the 'Reply' or 'Start review' button " +
591 "at the top of the change or press the 'a' key.";
592 if (this.unableToSave) {
593 label += ' (Failed to save)';
594 tooltip = 'Unable to save draft. Please try to save again.';
595 }
596 return html`
597 <gr-tooltip-content
598 class="draftTooltip"
599 has-tooltip
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200600 title=${tooltip}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200601 max-width="20em"
Ben Rohlfs05750b92021-10-29 08:23:08 +0200602 >
Ben Rohlfsba361a42022-09-01 12:12:45 +0200603 <gr-icon filled icon="rate_review"></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200604 <span class="draftLabel">${label}</span>
605 </gr-tooltip-content>
606 `;
607 }
608
609 private renderCollapsedContent() {
610 if (!this.collapsed) return;
611 return html`
612 <span class="collapsedContent">${this.comment?.message}</span>
613 `;
614 }
615
616 private renderRunDetails() {
617 if (!isRobot(this.comment)) return;
618 if (!this.comment?.url || this.collapsed) return;
619 return html`
620 <div class="runIdMessage message">
621 <div class="runIdInformation">
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200622 <a class="robotRunLink" href=${this.comment.url}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200623 <span class="robotRun link">Run Details</span>
624 </a>
625 </div>
626 </div>
627 `;
628 }
629
630 /**
631 * Deleting a comment is an admin feature. It means more than just discarding
632 * a draft. It is an action applied to published comments.
633 */
634 private renderDeleteButton() {
635 if (
636 !this.isAdmin ||
637 isDraftOrUnsaved(this.comment) ||
638 isRobot(this.comment)
639 )
640 return;
641 if (this.collapsed) return;
642 return html`
643 <gr-button
644 id="deleteBtn"
645 title="Delete Comment"
646 link
647 class="action delete"
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530648 @click=${this.openDeleteCommentModal}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200649 >
Chris Poucet1c713862022-07-25 13:12:24 +0200650 <gr-icon id="icon" icon="delete" filled></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200651 </gr-button>
652 `;
653 }
654
655 private renderPatchset() {
656 if (!this.showPatchset) return;
657 assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
658 return html`
659 <span class="patchset-text"> Patchset ${this.comment.patch_set}</span>
660 `;
661 }
662
663 private renderDate() {
664 if (!this.comment?.updated || this.collapsed) return;
665 return html`
666 <span class="separator"></span>
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200667 <span class="date" tabindex="0" @click=${this.handleAnchorClick}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200668 <gr-date-formatter
669 withTooltip
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200670 .dateStr=${this.comment.updated}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200671 ></gr-date-formatter>
672 </span>
673 `;
674 }
675
676 private renderToggle() {
Chris Poucetb8c06392022-07-08 16:35:43 +0200677 const icon = this.collapsed ? 'expand_more' : 'expand_less';
Ben Rohlfs05750b92021-10-29 08:23:08 +0200678 const ariaLabel = this.collapsed ? 'Expand' : 'Collapse';
679 return html`
680 <div class="show-hide" tabindex="0">
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200681 <label class="show-hide" aria-label=${ariaLabel}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200682 <input
683 type="checkbox"
684 class="show-hide"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200685 ?checked=${this.collapsed}
686 @change=${() => (this.collapsed = !this.collapsed)}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200687 />
Chris Poucet1c713862022-07-25 13:12:24 +0200688 <gr-icon icon=${icon} id="icon"></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200689 </label>
690 </div>
691 `;
692 }
693
694 private renderRobotAuthor() {
695 if (!isRobot(this.comment) || this.collapsed) return;
696 return html`<div class="robotId">${this.comment.author?.name}</div>`;
697 }
698
699 private renderEditingTextarea() {
700 if (!this.editing || this.collapsed) return;
701 return html`
702 <gr-textarea
703 id="editTextarea"
704 class="editMessage"
705 autocomplete="on"
706 code=""
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200707 ?disabled=${this.saving}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200708 rows="4"
Dhruv Srivastavaf007abc2022-09-06 11:18:57 +0200709 .placeholder=${this.messagePlaceholder}
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200710 text=${this.messageText}
711 @text-changed=${(e: ValueChangedEvent) => {
Ben Rohlfs05750b92021-10-29 08:23:08 +0200712 // TODO: This is causing a re-render of <gr-comment> on every key
713 // press. Try to avoid always setting `this.messageText` or at least
Ben Rohlfs2e237552021-11-24 10:34:28 +0100714 // debounce it. Most of the code can just inspect the current value
Ben Rohlfs05750b92021-10-29 08:23:08 +0200715 // of the textare instead of needing a dedicated property.
716 this.messageText = e.detail.value;
Ben Rohlfs2e237552021-11-24 10:34:28 +0100717 this.autoSaveTrigger$.next();
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200718 }}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200719 ></gr-textarea>
720 `;
721 }
722
Ben Rohlfs05750b92021-10-29 08:23:08 +0200723 private renderCommentMessage() {
724 if (this.collapsed || this.editing) return;
Frank Bordenf9a29992022-08-24 20:19:23 +0200725
Ben Rohlfs05750b92021-10-29 08:23:08 +0200726 return html`
727 <!--The "message" class is needed to ensure selectability from
728 gr-diff-selection.-->
729 <gr-formatted-text
730 class="message"
Frank Bordenabdd1872022-09-26 12:55:59 +0200731 .markdown=${true}
732 .content=${this.comment?.message ?? ''}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200733 ></gr-formatted-text>
734 `;
735 }
736
737 private renderCopyLinkIcon() {
738 // Only show the icon when the thread contains a published comment.
739 if (!this.comment?.in_reply_to && isDraftOrUnsaved(this.comment)) return;
740 return html`
Chris Poucet1c713862022-07-25 13:12:24 +0200741 <gr-icon
742 icon="link"
743 class="copy link-icon"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200744 @click=${this.handleCopyLink}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200745 title="Copy link to this comment"
Ben Rohlfs05750b92021-10-29 08:23:08 +0200746 role="button"
747 tabindex="0"
Chris Poucet1c713862022-07-25 13:12:24 +0200748 ></gr-icon>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200749 `;
750 }
751
752 private renderHumanActions() {
753 if (!this.account || isRobot(this.comment)) return;
754 if (this.collapsed || !isDraftOrUnsaved(this.comment)) return;
755 return html`
756 <div class="actions">
757 <div class="action resolve">
758 <label>
759 <input
760 type="checkbox"
761 id="resolvedCheckbox"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200762 ?checked=${!this.unresolved}
763 @change=${this.handleToggleResolved}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200764 />
765 Resolved
766 </label>
767 </div>
768 ${this.renderDraftActions()}
769 </div>
770 `;
771 }
772
773 private renderDraftActions() {
774 if (!isDraftOrUnsaved(this.comment)) return;
775 return html`
776 <div class="rightActions">
Ben Rohlfs2e237552021-11-24 10:34:28 +0100777 ${this.autoSaving ? html`.&nbsp;&nbsp;` : ''}
Milutin Kristofic1d219672022-06-21 14:57:25 +0200778 ${this.renderDiscardButton()} ${this.renderSuggestEditButton()}
779 ${this.renderPreviewSuggestEditButton()} ${this.renderEditButton()}
Chris Poucetc4142042022-06-28 17:51:50 +0200780 ${this.renderCancelButton()} ${this.renderSaveButton()}
781 ${this.renderCopyLinkIcon()}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200782 </div>
783 `;
784 }
785
Milutin Kristofic1d219672022-06-21 14:57:25 +0200786 private renderPreviewSuggestEditButton() {
787 if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
788 return nothing;
789 }
790 assertIsDefined(this.comment, 'comment');
791 if (!hasUserSuggestion(this.comment)) return nothing;
792 return html`
793 <gr-button
794 link
795 secondary
796 class="action show-fix"
797 ?disabled=${this.saving}
798 @click=${this.handleShowFix}
799 >
800 Preview Fix
801 </gr-button>
802 `;
803 }
804
805 private renderSuggestEditButton() {
806 if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
807 return nothing;
808 }
Dhruv Srivastava66a15632022-09-06 11:57:34 +0200809 if (
810 this.permanentEditingMode ||
811 this.comment?.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
812 ) {
813 return nothing;
814 }
Milutin Kristofic1d219672022-06-21 14:57:25 +0200815 assertIsDefined(this.comment, 'comment');
816 if (hasUserSuggestion(this.comment)) return nothing;
817 // TODO(milutin): remove this check once suggesting on commit message is
818 // fixed. Currently diff line doesn't match commit message line, because
819 // of metadata in diff, which aren't in content api request.
820 if (this.comment.path === SpecialFilePath.COMMIT_MESSAGE) return nothing;
Milutin Kristofic3ca39cc2022-10-21 10:10:20 +0200821 // TODO(milutin): disable user suggestions for owners, after user study.
822 // if (this.isOwner) return nothing;
Milutin Kristofic7dec89b2022-09-13 12:11:35 +0200823 return html`<gr-button
824 link
825 class="action suggestEdit"
826 @click=${this.createSuggestEdit}
Milutin Kristofic1d219672022-06-21 14:57:25 +0200827 >Suggest Fix</gr-button
828 >`;
829 }
830
Ben Rohlfs05750b92021-10-29 08:23:08 +0200831 private renderDiscardButton() {
Dhruv Srivastava705eac92022-09-01 11:33:27 +0200832 if (this.editing || this.permanentEditingMode) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200833 return html`<gr-button
834 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200835 ?disabled=${this.saving}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200836 class="action discard"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200837 @click=${this.discard}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200838 >Discard</gr-button
839 >`;
840 }
841
842 private renderEditButton() {
843 if (this.editing) return;
844 return html`<gr-button
845 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200846 ?disabled=${this.saving}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200847 class="action edit"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200848 @click=${this.edit}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200849 >Edit</gr-button
850 >`;
851 }
852
853 private renderCancelButton() {
Dhruv Srivastava705eac92022-09-01 11:33:27 +0200854 if (!this.editing || this.permanentEditingMode) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200855 return html`
856 <gr-button
857 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200858 ?disabled=${this.saving}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200859 class="action cancel"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200860 @click=${this.cancel}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200861 >Cancel</gr-button
862 >
863 `;
864 }
865
866 private renderSaveButton() {
867 if (!this.editing && !this.unableToSave) return;
868 return html`
869 <gr-button
870 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200871 ?disabled=${this.isSaveDisabled()}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200872 class="action save"
Dhruv Srivastava705eac92022-09-01 11:33:27 +0200873 @click=${this.handleSaveButtonClicked}
Dhruv Srivastava00831e72022-09-05 08:20:20 +0200874 >${this.permanentEditingMode ? 'Preview' : 'Save'}</gr-button
Ben Rohlfs05750b92021-10-29 08:23:08 +0200875 >
876 `;
877 }
878
879 private renderRobotActions() {
880 if (!this.account || !isRobot(this.comment)) return;
881 const endpoint = html`
882 <gr-endpoint-decorator name="robot-comment-controls">
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200883 <gr-endpoint-param name="comment" .value=${this.comment}>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200884 </gr-endpoint-param>
885 </gr-endpoint-decorator>
886 `;
887 return html`
888 <div class="robotActions">
889 ${this.renderCopyLinkIcon()} ${endpoint} ${this.renderShowFixButton()}
890 ${this.renderPleaseFixButton()}
891 </div>
892 `;
893 }
894
Milutin Kristofic1d219672022-06-21 14:57:25 +0200895 private renderSuggestEditActions() {
896 if (!this.flagsService.isEnabled(KnownExperimentId.SUGGEST_EDIT)) {
897 return nothing;
898 }
899 if (
900 !this.account ||
901 isRobot(this.comment) ||
902 isDraftOrUnsaved(this.comment)
903 ) {
904 return nothing;
905 }
906 return html`
907 <div class="robotActions">${this.renderPreviewSuggestEditButton()}</div>
908 `;
909 }
910
Ben Rohlfs05750b92021-10-29 08:23:08 +0200911 private renderShowFixButton() {
912 if (!(this.comment as RobotCommentInfo)?.fix_suggestions) return;
913 return html`
914 <gr-button
915 link
916 secondary
917 class="action show-fix"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200918 ?disabled=${this.saving}
919 @click=${this.handleShowFix}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200920 >
921 Show Fix
922 </gr-button>
923 `;
924 }
925
926 private renderPleaseFixButton() {
927 if (this.hasHumanReply()) return;
928 return html`
929 <gr-button
930 link
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200931 ?disabled=${this.robotButtonDisabled}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200932 class="action fix"
Ben Rohlfs23843882022-08-04 18:06:27 +0200933 @click=${this.handlePleaseFix}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200934 >
935 Please Fix
936 </gr-button>
937 `;
938 }
939
940 private renderConfirmDialog() {
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530941 if (!this.showConfirmDeleteModal) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200942 return html`
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530943 <dialog id="confirmDeleteModal" tabindex="-1">
Ben Rohlfs05750b92021-10-29 08:23:08 +0200944 <gr-confirm-delete-comment-dialog
945 id="confirmDeleteComment"
Ben Rohlfs8003bd32022-04-05 18:24:42 +0200946 @confirm=${this.handleConfirmDeleteComment}
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530947 @cancel=${this.closeDeleteCommentModal}
Ben Rohlfs05750b92021-10-29 08:23:08 +0200948 >
949 </gr-confirm-delete-comment-dialog>
Dhruv Srivastava4063d262022-11-09 18:46:29 +0530950 </dialog>
Ben Rohlfs05750b92021-10-29 08:23:08 +0200951 `;
952 }
953
954 private getUrlForComment() {
955 const comment = this.comment;
956 if (!comment || !this.changeNum || !this.repoName) return '';
Dhruv Srivastava0287bf92020-09-11 16:56:38 +0200957 if (!comment.id) throw new Error('comment must have an id');
Ben Rohlfs731738b2022-09-15 15:55:33 +0200958 return createDiffUrl({
959 changeNum: this.changeNum,
Ben Rohlfsbfc688b2022-10-21 12:38:37 +0200960 repo: this.repoName,
Ben Rohlfs731738b2022-09-15 15:55:33 +0200961 commentId: comment.id,
962 });
Dhruv Srivastava0287bf92020-09-11 16:56:38 +0200963 }
964
Ben Rohlfs05750b92021-10-29 08:23:08 +0200965 private firstWillUpdateDone = false;
966
967 firstWillUpdate() {
968 if (this.firstWillUpdateDone) return;
969 this.firstWillUpdateDone = true;
Dhruv Srivastava4e5fd112022-08-25 12:01:22 +0200970 if (this.permanentEditingMode) this.editing = true;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200971 assertIsDefined(this.comment, 'comment');
972 this.unresolved = this.comment.unresolved ?? true;
Ben Rohlfs05750b92021-10-29 08:23:08 +0200973 if (isUnsaved(this.comment)) this.editing = true;
974 if (isDraftOrUnsaved(this.comment)) {
Ben Rohlfs19b6c722022-06-02 13:55:59 +0200975 this.reporting.reportInteraction(
976 Interaction.COMMENTS_AUTOCLOSE_FIRST_UPDATE,
977 {editing: this.editing, unsaved: isUnsaved(this.comment)}
978 );
Ben Rohlfs05750b92021-10-29 08:23:08 +0200979 this.collapsed = false;
980 } else {
981 this.collapsed = !!this.initiallyCollapsed;
982 }
983 }
984
Dhruv Srivastavaa49dffd2022-10-20 14:04:37 +0200985 override updated(changed: PropertyValues) {
986 if (changed.has('editing')) {
Dhruv Srivastavae8b86392022-10-20 17:17:21 +0200987 if (this.editing && !this.permanentEditingMode) {
Dhruv Srivastavaa49dffd2022-10-20 14:04:37 +0200988 whenVisible(this, () => this.textarea?.putCursorAtEnd());
989 }
990 }
991 }
992
Ben Rohlfs05750b92021-10-29 08:23:08 +0200993 override willUpdate(changed: PropertyValues) {
994 this.firstWillUpdate();
995 if (changed.has('editing')) {
Chris Poucetafd0f7c2022-10-04 10:04:43 +0000996 this.onEditingChanged();
Ben Rohlfs05750b92021-10-29 08:23:08 +0200997 }
998 if (changed.has('unresolved')) {
999 // The <gr-comment-thread> component wants to change its color based on
1000 // the (dirty) unresolved state, so let's notify it about changes.
Dhruv Srivastava463bb332022-08-31 13:00:49 +02001001 fire(this, 'comment-unresolved-changed', {value: this.unresolved});
1002 }
1003 if (changed.has('messageText')) {
1004 // GrReplyDialog updates it's state when text inside patchset level
1005 // comment changes.
1006 fire(this, 'comment-text-changed', {value: this.messageText});
Ben Rohlfs05750b92021-10-29 08:23:08 +02001007 }
1008 }
1009
1010 private handlePortedMessageClick() {
Ben Rohlfsc1c6afd2021-02-18 13:13:22 +01001011 assertIsDefined(this.comment, 'comment');
Dhruv Srivastavac8df7602021-01-15 10:59:00 +01001012 this.reporting.reportInteraction('navigate-to-original-comment', {
1013 line: this.comment.line,
1014 range: this.comment.range,
1015 });
1016 }
1017
Ben Rohlfs05750b92021-10-29 08:23:08 +02001018 private handleCopyLink() {
1019 fireEvent(this, 'copy-comment-link');
1020 }
1021
1022 /** Enter editing mode. */
1023 private edit() {
1024 if (!isDraftOrUnsaved(this.comment)) {
1025 throw new Error('Cannot edit published comment.');
1026 }
1027 if (this.editing) return;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001028 this.editing = true;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001029 }
1030
1031 // TODO: Move this out of gr-comment. gr-comment should not have a comments
1032 // property.
1033 private hasHumanReply() {
1034 if (!this.comment || !this.comments) return false;
1035 return this.comments.some(
1036 c => c.in_reply_to && c.in_reply_to === this.comment?.id && !isRobot(c)
Milutin Kristoficafae0052020-09-17 10:38:08 +02001037 );
Ben Rohlfs05750b92021-10-29 08:23:08 +02001038 }
1039
1040 // private, but visible for testing
Ben Rohlfs23843882022-08-04 18:06:27 +02001041 async createFixPreview(): Promise<OpenFixPreviewEventDetail> {
Ben Rohlfs05750b92021-10-29 08:23:08 +02001042 assertIsDefined(this.comment?.patch_set, 'comment.patch_set');
Ben Rohlfs23843882022-08-04 18:06:27 +02001043 assertIsDefined(this.comment?.path, 'comment.path');
1044
1045 if (hasUserSuggestion(this.comment)) {
1046 const replacement = getUserSuggestion(this.comment);
1047 assert(!!replacement, 'malformed user suggestion');
1048 const line = await this.getCommentedCode();
1049
1050 return {
1051 fixSuggestions: createUserFixSuggestion(
1052 this.comment,
1053 line,
1054 replacement
1055 ),
1056 patchNum: this.comment.patch_set,
1057 };
1058 }
1059 if (isRobot(this.comment) && this.comment.fix_suggestions.length > 0) {
1060 const id = this.comment.robot_id;
1061 return {
1062 fixSuggestions: this.comment.fix_suggestions.map(s => {
1063 return {
1064 ...s,
1065 description: `${id ?? ''} - ${s.description ?? ''}`,
1066 };
1067 }),
1068 patchNum: this.comment.patch_set,
1069 };
1070 }
1071 throw new Error('unable to create preview fix event');
Ben Rohlfs05750b92021-10-29 08:23:08 +02001072 }
1073
Chris Poucetafd0f7c2022-10-04 10:04:43 +00001074 private onEditingChanged() {
1075 if (this.editing) {
1076 this.collapsed = false;
1077 this.messageText = this.comment?.message ?? '';
1078 this.unresolved = this.comment?.unresolved ?? true;
1079 this.originalMessage = this.messageText;
1080 this.originalUnresolved = this.unresolved;
Chris Poucetafd0f7c2022-10-04 10:04:43 +00001081 }
1082
1083 // Parent components such as the reply dialog might be interested in whether
1084 // come of their child components are in editing mode.
1085 fire(this, 'comment-editing-changed', {
1086 editing: this.editing,
1087 path: this.comment?.path ?? '',
1088 });
Ben Rohlfs05750b92021-10-29 08:23:08 +02001089 }
1090
Ben Rohlfs05750b92021-10-29 08:23:08 +02001091 // private, but visible for testing
1092 isSaveDisabled() {
1093 assertIsDefined(this.comment, 'comment');
1094 if (this.saving) return true;
Ben Rohlfs2e237552021-11-24 10:34:28 +01001095 return !this.messageText?.trimEnd();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001096 }
1097
Dhruv Srivastavae8b86392022-10-20 17:17:21 +02001098 override focus() {
1099 this.textarea?.focus();
1100 }
1101
Ben Rohlfs05750b92021-10-29 08:23:08 +02001102 private handleEsc() {
1103 // vim users don't like ESC to cancel/discard, so only do this when the
1104 // comment text is empty.
Ben Rohlfs2e237552021-11-24 10:34:28 +01001105 if (!this.messageText?.trimEnd()) this.cancel();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001106 }
1107
Ben Rohlfs05750b92021-10-29 08:23:08 +02001108 private handleAnchorClick() {
1109 assertIsDefined(this.comment, 'comment');
1110 fire(this, 'comment-anchor-tap', {
1111 number: this.comment.line || FILE,
1112 side: this.comment?.side,
Milutin Kristoficafae0052020-09-17 10:38:08 +02001113 });
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001114 }
1115
Dhruv Srivastava15950b452022-09-12 10:56:53 +02001116 private async handleSaveButtonClicked() {
1117 await this.save();
1118 if (this.permanentEditingMode) {
1119 this.editing = !this.editing;
1120 }
Dhruv Srivastava705eac92022-09-01 11:33:27 +02001121 }
1122
Ben Rohlfs23843882022-08-04 18:06:27 +02001123 private handlePleaseFix() {
1124 const message = this.comment?.message;
1125 assert(!!message, 'empty message');
1126 const quoted = message.replace(NEWLINE_PATTERN, '\n> ');
1127 const eventDetail: ReplyToCommentEventDetail = {
1128 content: `> ${quoted}\n\nPlease fix.`,
1129 userWantsToEdit: false,
1130 unresolved: true,
1131 };
Ben Rohlfs05750b92021-10-29 08:23:08 +02001132 // Handled by <gr-comment-thread>.
Ben Rohlfs23843882022-08-04 18:06:27 +02001133 fire(this, 'reply-to-comment', eventDetail);
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001134 }
1135
Ben Rohlfs23843882022-08-04 18:06:27 +02001136 private async handleShowFix() {
Ben Rohlfs05750b92021-10-29 08:23:08 +02001137 // Handled top-level in the diff and change view components.
Ben Rohlfs23843882022-08-04 18:06:27 +02001138 fire(this, 'open-fix-preview', await this.createFixPreview());
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001139 }
1140
Milutin Kristofic1d219672022-06-21 14:57:25 +02001141 async createSuggestEdit() {
Ben Rohlfs23843882022-08-04 18:06:27 +02001142 const line = await this.getCommentedCode();
1143 this.messageText += `${USER_SUGGESTION_START_PATTERN}${line}${'\n```'}`;
1144 }
1145
1146 async getCommentedCode() {
Milutin Kristofic1d219672022-06-21 14:57:25 +02001147 assertIsDefined(this.comment, 'comment');
1148 assertIsDefined(this.changeNum, 'changeNum');
Ben Rohlfs23843882022-08-04 18:06:27 +02001149 // TODO(milutin): Show a toast while the file is being loaded.
1150 // TODO(milutin): This should be moved into a service/model.
Milutin Kristofic1d219672022-06-21 14:57:25 +02001151 const file = await this.restApiService.getFileContent(
1152 this.changeNum,
1153 this.comment.path!,
1154 this.comment.patch_set!
1155 );
Ben Rohlfs23843882022-08-04 18:06:27 +02001156 assert(
1157 !!file && isBase64FileContent(file) && !!file.content,
1158 'file content for comment not found'
1159 );
Milutin Kristofic1d219672022-06-21 14:57:25 +02001160 const line = getContentInCommentRange(file.content, this.comment);
Ben Rohlfs23843882022-08-04 18:06:27 +02001161 assert(!!line, 'file content for comment not found');
1162 return line;
Milutin Kristofic1d219672022-06-21 14:57:25 +02001163 }
1164
Ben Rohlfs05750b92021-10-29 08:23:08 +02001165 // private, but visible for testing
1166 cancel() {
1167 assertIsDefined(this.comment, 'comment');
1168 if (!isDraftOrUnsaved(this.comment)) {
1169 throw new Error('only unsaved and draft comments are editable');
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001170 }
Ben Rohlfs2e237552021-11-24 10:34:28 +01001171 this.messageText = this.originalMessage;
1172 this.unresolved = this.originalUnresolved;
1173 this.save();
Ben Rohlfs05750b92021-10-29 08:23:08 +02001174 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001175
Ben Rohlfs2e237552021-11-24 10:34:28 +01001176 async autoSave() {
1177 if (this.saving || this.autoSaving) return;
1178 if (!this.editing || !this.comment) return;
1179 if (!isDraftOrUnsaved(this.comment)) return;
1180 const messageToSave = this.messageText.trimEnd();
1181 if (messageToSave === '') return;
1182 if (messageToSave === this.comment.message) return;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001183
Ben Rohlfs2e237552021-11-24 10:34:28 +01001184 try {
1185 this.autoSaving = this.rawSave(messageToSave, {showToast: false});
1186 await this.autoSaving;
1187 } finally {
1188 this.autoSaving = undefined;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001189 }
Ben Rohlfs2e237552021-11-24 10:34:28 +01001190 }
1191
1192 async discard() {
1193 this.messageText = '';
1194 await this.save();
1195 }
1196
1197 async save() {
1198 if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
Dhruv Srivastavafb8e34f2022-10-18 10:12:13 +02001199 // If it's an unsaved comment then it does not have a draftID yet which
1200 // means sending another save() request will create a new draft
1201 if (isUnsaved(this.comment) && this.saving) return;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001202
Ben Rohlfs05750b92021-10-29 08:23:08 +02001203 try {
1204 this.saving = true;
1205 this.unableToSave = false;
Ben Rohlfs607126f2021-12-07 08:21:52 +01001206 if (this.autoSaving) {
1207 this.comment = await this.autoSaving;
1208 }
Ben Rohlfs2e237552021-11-24 10:34:28 +01001209 // Depending on whether `messageToSave` is empty we treat this either as
1210 // a discard or a save action.
1211 const messageToSave = this.messageText.trimEnd();
1212 if (messageToSave === '') {
1213 // Don't try to discard UnsavedInfo. Nothing to do then.
1214 if (this.comment.id) {
Chris Poucet6c6b54f2021-12-09 02:53:13 +01001215 await this.getCommentsModel().discardDraft(this.comment.id);
Ben Rohlfs2e237552021-11-24 10:34:28 +01001216 }
1217 } else {
1218 // No need to make a backend call when nothing has changed.
1219 if (
1220 messageToSave !== this.comment?.message ||
1221 this.unresolved !== this.comment.unresolved
1222 ) {
1223 await this.rawSave(messageToSave, {showToast: true});
1224 }
1225 }
Ben Rohlfs19b6c722022-06-02 13:55:59 +02001226 this.reporting.reportInteraction(
1227 Interaction.COMMENTS_AUTOCLOSE_EDITING_FALSE_SAVE
1228 );
Dhruv Srivastava4e5fd112022-08-25 12:01:22 +02001229 if (!this.permanentEditingMode) {
1230 this.editing = false;
1231 }
Ben Rohlfs05750b92021-10-29 08:23:08 +02001232 } catch (e) {
1233 this.unableToSave = true;
1234 throw e;
1235 } finally {
1236 this.saving = false;
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001237 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001238 }
1239
Ben Rohlfs2e237552021-11-24 10:34:28 +01001240 /** For sharing between save() and autoSave(). */
1241 private rawSave(message: string, options: {showToast: boolean}) {
1242 if (!isDraftOrUnsaved(this.comment)) throw new Error('not a draft');
Chris Poucet6c6b54f2021-12-09 02:53:13 +01001243 return this.getCommentsModel().saveDraft(
Ben Rohlfs2e237552021-11-24 10:34:28 +01001244 {
1245 ...this.comment,
1246 message,
1247 unresolved: this.unresolved,
1248 },
1249 options.showToast
1250 );
1251 }
1252
Ben Rohlfs05750b92021-10-29 08:23:08 +02001253 private handleToggleResolved() {
1254 this.unresolved = !this.unresolved;
Dhruv Srivastava73f9edc2021-12-02 11:23:27 +01001255 if (!this.editing) {
1256 // messageText is only assigned a value if the comment reaches editing
1257 // state, however it is possible that the user toggles the resolved state
1258 // without editing the comment in which case we assign the correct value
1259 // to messageText here
1260 this.messageText = this.comment?.message ?? '';
1261 this.save();
1262 }
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001263 }
1264
Dhruv Srivastava4063d262022-11-09 18:46:29 +05301265 private async openDeleteCommentModal() {
1266 this.showConfirmDeleteModal = true;
Ben Rohlfs05750b92021-10-29 08:23:08 +02001267 await this.updateComplete;
Dhruv Srivastava4063d262022-11-09 18:46:29 +05301268 await this.confirmDeleteModal?.showModal();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001269 }
1270
Dhruv Srivastava4063d262022-11-09 18:46:29 +05301271 private closeDeleteCommentModal() {
1272 this.showConfirmDeleteModal = false;
1273 this.confirmDeleteModal?.remove();
1274 this.confirmDeleteModal?.close();
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001275 }
1276
Ben Rohlfs05750b92021-10-29 08:23:08 +02001277 /**
1278 * Deleting a *published* comment is an admin feature. It means more than just
1279 * discarding a draft.
1280 *
1281 * TODO: Also move this into the comments-service.
1282 * TODO: Figure out a good reloading strategy when deleting was successful.
1283 * `this.comment = newComment` does not seem sufficient.
1284 */
1285 // private, but visible for testing
1286 handleConfirmDeleteComment() {
Dhruv Srivastava4063d262022-11-09 18:46:29 +05301287 const dialog = this.confirmDeleteModal?.querySelector(
Milutin Kristoficafae0052020-09-17 10:38:08 +02001288 '#confirmDeleteComment'
1289 ) as GrConfirmDeleteCommentDialog | null;
1290 if (!dialog || !dialog.message) {
1291 throw new Error('missing confirm delete dialog');
1292 }
Ben Rohlfs05750b92021-10-29 08:23:08 +02001293 assertIsDefined(this.changeNum, 'changeNum');
1294 assertIsDefined(this.comment, 'comment');
1295 assertIsDefined(this.comment.patch_set, 'comment.patch_set');
1296 if (isDraftOrUnsaved(this.comment)) {
1297 throw new Error('Admin deletion is only for published comments.');
Milutin Kristofica6af5aa2020-09-23 09:08:14 +02001298 }
Ben Rohlfs43935a42020-12-01 19:14:09 +01001299 this.restApiService
Milutin Kristoficafae0052020-09-17 10:38:08 +02001300 .deleteComment(
1301 this.changeNum,
Ben Rohlfs05750b92021-10-29 08:23:08 +02001302 this.comment.patch_set,
Milutin Kristoficafae0052020-09-17 10:38:08 +02001303 this.comment.id,
1304 dialog.message
1305 )
1306 .then(newComment => {
Dhruv Srivastava4063d262022-11-09 18:46:29 +05301307 this.closeDeleteCommentModal();
Milutin Kristoficafae0052020-09-17 10:38:08 +02001308 this.comment = newComment;
1309 });
Dmitrii Filippovdaf0ec92020-03-17 11:27:28 +01001310 }
1311}
1312
Milutin Kristoficafae0052020-09-17 10:38:08 +02001313declare global {
1314 interface HTMLElementTagNameMap {
1315 'gr-comment': GrComment;
1316 }
1317}