| /** |
| * @license |
| * Copyright 2018 Google LLC |
| * SPDX-License-Identifier: Apache-2.0 |
| */ |
| import '../../../styles/shared-styles'; |
| import '../../shared/gr-comment-thread/gr-comment-thread'; |
| import '../../shared/gr-dropdown-list/gr-dropdown-list'; |
| import {SpecialFilePath} from '../../../constants/constants'; |
| import { |
| AccountDetailInfo, |
| AccountInfo, |
| NumericChangeId, |
| UrlEncodedCommentId, |
| } from '../../../types/common'; |
| import {ChangeMessageId} from '../../../api/rest-api'; |
| import { |
| CommentThread, |
| getCommentAuthors, |
| getMentionedThreads, |
| hasHumanReply, |
| isDraft, |
| isDraftThread, |
| isMentionedThread, |
| isRobotThread, |
| isUnresolved, |
| lastUpdated, |
| } from '../../../utils/comment-util'; |
| import {pluralize} from '../../../utils/string-util'; |
| import {assertIsDefined} from '../../../utils/common-util'; |
| import {CommentTabState, TabState} from '../../../types/events'; |
| import {DropdownItem} from '../../shared/gr-dropdown-list/gr-dropdown-list'; |
| import {GrAccountChip} from '../../shared/gr-account-chip/gr-account-chip'; |
| import {css, html, LitElement, PropertyValues} from 'lit'; |
| import {customElement, property, queryAll, state} from 'lit/decorators.js'; |
| import {sharedStyles} from '../../../styles/shared-styles'; |
| import {subscribe} from '../../lit/subscription-controller'; |
| import {ParsedChangeInfo} from '../../../types/types'; |
| import {repeat} from 'lit/directives/repeat.js'; |
| import {GrCommentThread} from '../../shared/gr-comment-thread/gr-comment-thread'; |
| import {getAppContext} from '../../../services/app-context'; |
| import {resolve} from '../../../models/dependency'; |
| import {changeModelToken} from '../../../models/change/change-model'; |
| import {Interaction} from '../../../constants/reporting'; |
| import {KnownExperimentId} from '../../../services/flags/flags'; |
| import {HtmlPatched} from '../../../utils/lit-util'; |
| import {userModelToken} from '../../../models/user/user-model'; |
| import {specialFilePathCompare} from '../../../utils/path-list-util'; |
| |
| enum SortDropdownState { |
| TIMESTAMP = 'Latest timestamp', |
| FILES = 'Files', |
| } |
| |
| export const __testOnly_SortDropdownState = SortDropdownState; |
| |
| /** |
| * Order as follows: |
| * - Patchset level threads (descending based on patchset number) |
| * - unresolved |
| * - comments with drafts |
| * - comments without drafts |
| * - resolved |
| * - comments with drafts |
| * - comments without drafts |
| * - File name |
| * - Line number |
| * - Unresolved (descending based on patchset number) |
| * - comments with drafts |
| * - comments without drafts |
| * - Resolved (descending based on patchset number) |
| * - comments with drafts |
| * - comments without drafts |
| */ |
| export function compareThreads( |
| c1: CommentThread, |
| c2: CommentThread, |
| byTimestamp = false |
| ) { |
| if (byTimestamp) { |
| const c1Time = lastUpdated(c1)?.getTime() ?? 0; |
| const c2Time = lastUpdated(c2)?.getTime() ?? 0; |
| const timeDiff = c2Time - c1Time; |
| if (timeDiff !== 0) return c2Time - c1Time; |
| } |
| |
| if (c1.path !== c2.path) { |
| // '/PATCHSET' will not come before '/COMMIT' when sorting |
| // alphabetically so move it to the front explicitly |
| if (c1.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) { |
| return -1; |
| } |
| if (c2.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS) { |
| return 1; |
| } |
| return specialFilePathCompare(c1.path, c2.path); |
| } |
| |
| // Convert 'FILE' and 'LOST' to undefined. |
| const line1 = typeof c1.line === 'number' ? c1.line : undefined; |
| const line2 = typeof c2.line === 'number' ? c2.line : undefined; |
| if (line1 !== line2) { |
| // one of them is a FILE/LOST comment, show first |
| if (line1 === undefined) return -1; |
| if (line2 === undefined) return 1; |
| // Lower line numbers first. |
| return line1 < line2 ? -1 : 1; |
| } |
| |
| if (c1.patchNum !== c2.patchNum) { |
| // `patchNum` should be required, but show undefined first. |
| if (c1.patchNum === undefined) return -1; |
| if (c2.patchNum === undefined) return 1; |
| // Higher patchset numbers first. |
| return c1.patchNum > c2.patchNum ? -1 : 1; |
| } |
| |
| // Sorting should not be based on the thread being unresolved or being a draft |
| // thread, because that would be a surprising re-sort when the thread changes |
| // state. |
| |
| const c1Time = lastUpdated(c1)?.getTime() ?? 0; |
| const c2Time = lastUpdated(c2)?.getTime() ?? 0; |
| if (c2Time !== c1Time) { |
| // Newer comments first. |
| return c2Time - c1Time; |
| } |
| |
| return 0; |
| } |
| |
| @customElement('gr-thread-list') |
| export class GrThreadList extends LitElement { |
| @queryAll('gr-comment-thread') |
| threadElements?: NodeListOf<GrCommentThread>; |
| |
| /** |
| * Raw list of threads for the component to show. |
| * |
| * ATTENTION! this.threads should never be used directly within the component. |
| * |
| * Either use getAllThreads(), which applies filters that are inherent to what |
| * the component is supposed to render, |
| * e.g. onlyShowRobotCommentsWithHumanReply. |
| * |
| * Or use getDisplayedThreads(), which applies the currently selected filters |
| * on top. |
| */ |
| @property({type: Array}) |
| threads: CommentThread[] = []; |
| |
| @property({type: Boolean, attribute: 'show-comment-context'}) |
| showCommentContext = false; |
| |
| /** Along with `draftsOnly` is the currently selected filter. */ |
| @property({type: Boolean, attribute: 'unresolved-only'}) |
| unresolvedOnly = false; |
| |
| @property({ |
| type: Boolean, |
| attribute: 'only-show-robot-comments-with-human-reply', |
| }) |
| onlyShowRobotCommentsWithHumanReply = false; |
| |
| @property({type: Boolean, attribute: 'hide-dropdown'}) |
| hideDropdown = false; |
| |
| @property({type: Object, attribute: 'comment-tab-state'}) |
| commentTabState?: TabState; |
| |
| @property({type: String, attribute: 'scroll-comment-id'}) |
| scrollCommentId?: UrlEncodedCommentId; |
| |
| /** |
| * Optional context information when threads are being displayed for a |
| * specific change message. That influences which comments are expanded or |
| * collapsed by default. |
| */ |
| @property({type: String, attribute: 'message-id'}) |
| messageId?: ChangeMessageId; |
| |
| @state() |
| changeNum?: NumericChangeId; |
| |
| @state() |
| change?: ParsedChangeInfo; |
| |
| @state() |
| account?: AccountDetailInfo; |
| |
| @state() |
| selectedAuthors: AccountInfo[] = []; |
| |
| @state() |
| sortDropdownValue: SortDropdownState = SortDropdownState.TIMESTAMP; |
| |
| /** Along with `unresolvedOnly` is the currently selected filter. */ |
| @state() |
| draftsOnly = false; |
| |
| @state() |
| mentionsOnly = false; |
| |
| private readonly getChangeModel = resolve(this, changeModelToken); |
| |
| private readonly reporting = getAppContext().reportingService; |
| |
| private readonly flagsService = getAppContext().flagsService; |
| |
| private readonly getUserModel = resolve(this, userModelToken); |
| |
| private readonly patched = new HtmlPatched(key => { |
| this.reporting.reportInteraction(Interaction.AUTOCLOSE_HTML_PATCHED, { |
| component: this.tagName, |
| key: key.substring(0, 300), |
| }); |
| }); |
| |
| constructor() { |
| super(); |
| subscribe( |
| this, |
| () => this.getChangeModel().changeNum$, |
| x => (this.changeNum = x) |
| ); |
| subscribe( |
| this, |
| () => this.getChangeModel().change$, |
| x => (this.change = x) |
| ); |
| subscribe( |
| this, |
| () => this.getUserModel().account$, |
| x => (this.account = x) |
| ); |
| // for COMMENTS_AUTOCLOSE logging purposes only |
| this.reporting.reportInteraction( |
| Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_CREATED |
| ); |
| } |
| |
| override willUpdate(changed: PropertyValues) { |
| if (changed.has('commentTabState')) this.onCommentTabStateUpdate(); |
| if (changed.has('scrollCommentId')) this.onScrollCommentIdUpdate(); |
| } |
| |
| private onCommentTabStateUpdate() { |
| switch (this.commentTabState?.commentTab) { |
| case CommentTabState.MENTIONS: |
| this.handleOnlyMentions(); |
| break; |
| case CommentTabState.UNRESOLVED: |
| this.handleOnlyUnresolved(); |
| break; |
| case CommentTabState.DRAFTS: |
| this.handleOnlyDrafts(); |
| break; |
| case CommentTabState.SHOW_ALL: |
| this.handleAllComments(); |
| break; |
| } |
| } |
| |
| /** |
| * When user wants to scroll to a comment, render all comments so that the |
| * appropriate comment can be scrolled into view. |
| */ |
| private onScrollCommentIdUpdate() { |
| if (this.scrollCommentId) this.handleAllComments(); |
| } |
| |
| static override get styles() { |
| return [ |
| sharedStyles, |
| css` |
| #threads { |
| display: block; |
| } |
| gr-comment-thread { |
| display: block; |
| margin-bottom: var(--spacing-m); |
| } |
| .header { |
| align-items: center; |
| background-color: var(--background-color-primary); |
| border-bottom: 1px solid var(--border-color); |
| border-top: 1px solid var(--border-color); |
| display: flex; |
| justify-content: left; |
| padding: var(--spacing-s) var(--spacing-l); |
| } |
| .draftsOnly:not(.unresolvedOnly) gr-comment-thread[has-draft], |
| .unresolvedOnly:not(.draftsOnly) gr-comment-thread[unresolved], |
| .draftsOnly.unresolvedOnly gr-comment-thread[has-draft][unresolved] { |
| display: block; |
| } |
| .thread-separator { |
| border-top: 1px solid var(--border-color); |
| margin-top: var(--spacing-xl); |
| } |
| .show-resolved-comments { |
| box-shadow: none; |
| padding-left: var(--spacing-m); |
| } |
| .partypopper { |
| margin-right: var(--spacing-s); |
| } |
| gr-dropdown-list { |
| --trigger-style-text-color: var(--primary-text-color); |
| --trigger-style-font-family: var(--font-family); |
| } |
| .filter-text, |
| .sort-text, |
| .author-text { |
| margin-right: var(--spacing-s); |
| color: var(--deemphasized-text-color); |
| } |
| .author-text { |
| margin-left: var(--spacing-m); |
| } |
| gr-account-label { |
| --account-max-length: 120px; |
| display: inline-block; |
| user-select: none; |
| --label-border-radius: 8px; |
| margin: 0 var(--spacing-xs); |
| padding: var(--spacing-xs) var(--spacing-m); |
| line-height: var(--line-height-normal); |
| cursor: pointer; |
| } |
| gr-account-label:focus { |
| outline: none; |
| } |
| gr-account-label:hover, |
| gr-account-label:hover { |
| box-shadow: var(--elevation-level-1); |
| cursor: pointer; |
| } |
| `, |
| ]; |
| } |
| |
| override updated(): void { |
| // for COMMENTS_AUTOCLOSE logging purposes only |
| const threads = this.shadowRoot!.querySelectorAll('gr-comment-thread'); |
| if (threads.length > 0) { |
| this.reporting.reportInteraction( |
| Interaction.COMMENTS_AUTOCLOSE_THREAD_LIST_UPDATED, |
| {uid: threads[0].uid} |
| ); |
| } |
| } |
| |
| override render() { |
| return html` |
| ${this.renderDropdown()} |
| <div id="threads" part="threads"> |
| ${this.renderEmptyThreadsMessage()} ${this.renderCommentThreads()} |
| </div> |
| `; |
| } |
| |
| private renderDropdown() { |
| if (this.hideDropdown) return; |
| return html` |
| <div class="header"> |
| <span class="sort-text">Sort By:</span> |
| <gr-dropdown-list |
| id="sortDropdown" |
| .value=${this.sortDropdownValue} |
| @value-change=${(e: CustomEvent) => |
| (this.sortDropdownValue = e.detail.value)} |
| .items=${this.getSortDropdownEntries()} |
| > |
| </gr-dropdown-list> |
| <span class="separator"></span> |
| <span class="filter-text">Filter By:</span> |
| <gr-dropdown-list |
| id="filterDropdown" |
| .value=${this.getCommentsDropdownValue()} |
| @value-change=${this.handleCommentsDropdownValueChange} |
| .items=${this.getCommentsDropdownEntries()} |
| > |
| </gr-dropdown-list> |
| ${this.renderAuthorChips()} |
| </div> |
| `; |
| } |
| |
| private renderEmptyThreadsMessage() { |
| const threads = this.getAllThreads(); |
| const threadsEmpty = threads.length === 0; |
| const displayedEmpty = this.getDisplayedThreads().length === 0; |
| if (!displayedEmpty) return; |
| const showPopper = this.unresolvedOnly && !threadsEmpty; |
| const popper = html`<span class="partypopper">🎉</span>`; |
| const showButton = this.unresolvedOnly && !threadsEmpty; |
| const button = html` |
| <gr-button |
| class="show-resolved-comments" |
| link |
| @click=${this.handleAllComments} |
| >Show ${pluralize(threads.length, 'resolved comment')}</gr-button |
| > |
| `; |
| return html` |
| <div> |
| <span> |
| ${showPopper ? popper : undefined} |
| ${threadsEmpty ? 'No comments' : 'No unresolved comments'} |
| ${showButton ? button : undefined} |
| </span> |
| </div> |
| `; |
| } |
| |
| private renderCommentThreads() { |
| const threads = this.getDisplayedThreads(); |
| return repeat( |
| threads, |
| thread => thread.rootId, |
| (thread, index) => { |
| const isFirst = |
| index === 0 || threads[index - 1].path !== threads[index].path; |
| const separator = |
| index !== 0 && isFirst |
| ? this.patched.html`<div class="thread-separator"></div>` |
| : undefined; |
| const commentThread = this.renderCommentThread(thread, isFirst); |
| return this.patched.html`${separator}${commentThread}`; |
| } |
| ); |
| } |
| |
| private renderCommentThread(thread: CommentThread, isFirst: boolean) { |
| return this.patched.html` |
| <gr-comment-thread |
| .thread=${thread} |
| show-file-path |
| ?show-ported-comment=${thread.ported} |
| ?show-comment-context=${this.showCommentContext} |
| ?show-file-name=${isFirst} |
| .messageId=${this.messageId} |
| ?should-scroll-into-view=${thread.rootId === this.scrollCommentId} |
| @comment-thread-editing-changed=${() => { |
| this.requestUpdate(); |
| }} |
| ></gr-comment-thread> |
| `; |
| } |
| |
| private renderAuthorChips() { |
| const authors = getCommentAuthors(this.getDisplayedThreads(), this.account); |
| if (authors.length === 0) return; |
| return html`<span class="author-text">From:</span>${authors.map(author => |
| this.renderAccountChip(author) |
| )}`; |
| } |
| |
| private renderAccountChip(account: AccountInfo) { |
| const selected = this.selectedAuthors.some( |
| a => a._account_id === account._account_id |
| ); |
| return html` |
| <gr-account-label |
| .account=${account} |
| @click=${this.handleAccountClicked} |
| selectionChipStyle |
| noStatusIcons |
| ?selected=${selected} |
| ></gr-account-label> |
| `; |
| } |
| |
| private getCommentsDropdownValue() { |
| if (this.mentionsOnly) return CommentTabState.MENTIONS; |
| if (this.draftsOnly) return CommentTabState.DRAFTS; |
| if (this.unresolvedOnly) return CommentTabState.UNRESOLVED; |
| return CommentTabState.SHOW_ALL; |
| } |
| |
| private getSortDropdownEntries() { |
| return [ |
| {text: SortDropdownState.FILES, value: SortDropdownState.FILES}, |
| {text: SortDropdownState.TIMESTAMP, value: SortDropdownState.TIMESTAMP}, |
| ]; |
| } |
| |
| // private, but visible for testing |
| getCommentsDropdownEntries() { |
| const items: DropdownItem[] = []; |
| const threads = this.getAllThreads(); |
| items.push({ |
| text: `Unresolved (${threads.filter(isUnresolved).length})`, |
| value: CommentTabState.UNRESOLVED, |
| }); |
| if (this.account) { |
| if (this.flagsService.isEnabled(KnownExperimentId.MENTION_USERS)) { |
| items.push({ |
| text: `Mentions (${ |
| getMentionedThreads(threads, this.account).length |
| })`, |
| value: CommentTabState.MENTIONS, |
| }); |
| } |
| items.push({ |
| text: `Drafts (${threads.filter(isDraftThread).length})`, |
| value: CommentTabState.DRAFTS, |
| }); |
| } |
| items.push({ |
| text: `All (${threads.length})`, |
| value: CommentTabState.SHOW_ALL, |
| }); |
| return items; |
| } |
| |
| private handleAccountClicked(e: MouseEvent) { |
| const account = (e.target as GrAccountChip).account; |
| assertIsDefined(account, 'account'); |
| const predicate = (a: AccountInfo) => a._account_id === account._account_id; |
| const found = this.selectedAuthors.find(predicate); |
| if (found) { |
| this.selectedAuthors = this.selectedAuthors.filter(a => !predicate(a)); |
| } else { |
| this.selectedAuthors = [...this.selectedAuthors, account]; |
| } |
| } |
| |
| // private, but visible for testing |
| handleCommentsDropdownValueChange(e: CustomEvent) { |
| const value = e.detail.value; |
| switch (value) { |
| case CommentTabState.UNRESOLVED: |
| this.handleOnlyUnresolved(); |
| break; |
| case CommentTabState.MENTIONS: |
| this.handleOnlyMentions(); |
| break; |
| case CommentTabState.DRAFTS: |
| this.handleOnlyDrafts(); |
| break; |
| default: |
| this.handleAllComments(); |
| } |
| } |
| |
| /** |
| * Returns all threads that the list may show. |
| */ |
| // private, but visible for testing |
| getAllThreads() { |
| return this.threads.filter( |
| t => |
| !this.onlyShowRobotCommentsWithHumanReply || |
| !isRobotThread(t) || |
| hasHumanReply(t) |
| ); |
| } |
| |
| /** |
| * Returns all threads that are currently shown in the list, respecting the |
| * currently selected filter. |
| */ |
| // private, but visible for testing |
| getDisplayedThreads() { |
| const byTimestamp = |
| this.sortDropdownValue === SortDropdownState.TIMESTAMP && |
| !this.hideDropdown; |
| return this.getAllThreads() |
| .sort((t1, t2) => compareThreads(t1, t2, byTimestamp)) |
| .filter(t => this.shouldShowThread(t)); |
| } |
| |
| private isASelectedAuthor(account?: AccountInfo) { |
| if (!account) return false; |
| return this.selectedAuthors.some( |
| author => account._account_id === author._account_id |
| ); |
| } |
| |
| private shouldShowThread(thread: CommentThread) { |
| // Never make a thread disappear while the user is editing it. |
| assertIsDefined(thread.rootId, 'thread.rootId'); |
| const el = this.queryThreadElement(thread.rootId); |
| if (el?.editing) return true; |
| |
| if (this.selectedAuthors.length > 0) { |
| const hasACommentFromASelectedAuthor = thread.comments.some( |
| c => |
| (isDraft(c) && this.isASelectedAuthor(this.account)) || |
| this.isASelectedAuthor(c.author) |
| ); |
| if (!hasACommentFromASelectedAuthor) return false; |
| } |
| |
| // This is probably redundant, because getAllThreads() filters this out. |
| if (this.onlyShowRobotCommentsWithHumanReply) { |
| if (isRobotThread(thread) && !hasHumanReply(thread)) return false; |
| } |
| |
| if (this.mentionsOnly && !isMentionedThread(thread, this.account)) |
| return false; |
| |
| if (this.draftsOnly && !isDraftThread(thread)) return false; |
| if (this.unresolvedOnly && !isUnresolved(thread)) return false; |
| |
| return true; |
| } |
| |
| private handleOnlyUnresolved() { |
| this.unresolvedOnly = true; |
| this.draftsOnly = false; |
| this.mentionsOnly = false; |
| } |
| |
| private handleOnlyMentions() { |
| this.mentionsOnly = true; |
| this.unresolvedOnly = true; |
| this.draftsOnly = false; |
| } |
| |
| private handleOnlyDrafts() { |
| this.draftsOnly = true; |
| this.unresolvedOnly = false; |
| this.mentionsOnly = false; |
| } |
| |
| private handleAllComments() { |
| this.draftsOnly = false; |
| this.unresolvedOnly = false; |
| this.mentionsOnly = false; |
| } |
| |
| private queryThreadElement(rootId: string): GrCommentThread | undefined { |
| const els = [...(this.threadElements ?? [])] as GrCommentThread[]; |
| return els.find(el => el.rootId === rootId); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-thread-list': GrThreadList; |
| } |
| } |