|  | /** | 
|  | * @license | 
|  | * Copyright 2020 Google LLC | 
|  | * SPDX-License-Identifier: Apache-2.0 | 
|  | */ | 
|  | import '@polymer/paper-toggle-button/paper-toggle-button'; | 
|  | import '../../shared/gr-button/gr-button'; | 
|  | import '../gr-message/gr-message'; | 
|  | import '../../../styles/gr-paper-styles'; | 
|  | import {parseDate} from '../../../utils/date-util'; | 
|  | import {MessageTag} from '../../../constants/constants'; | 
|  | import {getAppContext} from '../../../services/app-context'; | 
|  | import {customElement, property, state} from 'lit/decorators.js'; | 
|  | import { | 
|  | ChangeId, | 
|  | ChangeMessageId, | 
|  | ChangeMessageInfo, | 
|  | CommentThread, | 
|  | LabelNameToInfoMap, | 
|  | NumericChangeId, | 
|  | PatchSetNum, | 
|  | VotingRangeInfo, | 
|  | isRobot, | 
|  | PatchSetNumber, | 
|  | } from '../../../types/common'; | 
|  | import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message'; | 
|  | import {getVotingRange} from '../../../utils/label-util'; | 
|  | import { | 
|  | FormattedReviewerUpdateInfo, | 
|  | ParsedChangeInfo, | 
|  | isPatchSetNumber, | 
|  | } from '../../../types/types'; | 
|  | import {commentsModelToken} from '../../../models/comments/comments-model'; | 
|  | import {changeModelToken} from '../../../models/change/change-model'; | 
|  | import {resolve} from '../../../models/dependency'; | 
|  | import {query, queryAll} from '../../../utils/common-util'; | 
|  | import {css, html, LitElement, PropertyValues} from 'lit'; | 
|  | import {sharedStyles} from '../../../styles/shared-styles'; | 
|  | import {subscribe} from '../../lit/subscription-controller'; | 
|  | import {paperStyles} from '../../../styles/gr-paper-styles'; | 
|  | import {when} from 'lit/directives/when.js'; | 
|  | import {ifDefined} from 'lit/directives/if-defined.js'; | 
|  | import { | 
|  | Shortcut, | 
|  | ShortcutSection, | 
|  | shortcutsServiceToken, | 
|  | } from '../../../services/shortcuts/shortcuts-service'; | 
|  | import {GrFormattedText} from '../../shared/gr-formatted-text/gr-formatted-text'; | 
|  | import {waitUntil} from '../../../utils/async-util'; | 
|  |  | 
|  | /** | 
|  | * The content of the enum is also used in the UI for the button text. | 
|  | */ | 
|  | enum ExpandAllState { | 
|  | EXPAND_ALL = 'Expand All', | 
|  | COLLAPSE_ALL = 'Collapse All', | 
|  | } | 
|  |  | 
|  | interface TagsCountReportInfo { | 
|  | [tag: string]: number; | 
|  | all: number; | 
|  | } | 
|  |  | 
|  | export type CombinedMessage = Omit< | 
|  | FormattedReviewerUpdateInfo | ChangeMessageInfo, | 
|  | 'tag' | 
|  | > & { | 
|  | _revision_number?: PatchSetNum; | 
|  | _index?: number; | 
|  | expanded?: boolean; | 
|  | isImportant?: boolean; | 
|  | commentThreads?: CommentThread[]; | 
|  | tag?: string; | 
|  | }; | 
|  |  | 
|  | function isChangeMessageInfo(x: CombinedMessage): x is ChangeMessageInfo { | 
|  | return (x as ChangeMessageInfo).id !== undefined; | 
|  | } | 
|  |  | 
|  | function getMessageId(x: CombinedMessage): ChangeMessageId | undefined { | 
|  | return isChangeMessageInfo(x) ? x.id : undefined; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Computes message author's comments for this change message. The backend | 
|  | * sets comment.change_message_id for matching, so this computation is fairly | 
|  | * straightforward. | 
|  | */ | 
|  | function computeThreads( | 
|  | message: CombinedMessage, | 
|  | allThreadsForChange: CommentThread[] | 
|  | ): CommentThread[] { | 
|  | if (message._index === undefined) return []; | 
|  | const messageId = getMessageId(message); | 
|  | return allThreadsForChange.filter(thread => | 
|  | thread.comments.some(comment => comment.change_message_id === messageId) | 
|  | ); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * If messages have the same tag, then that influences grouping and whether | 
|  | * a message is initially hidden or not, see isImportant(). So we are applying | 
|  | * some "magic" rules here in order to hide exactly the right messages. | 
|  | * | 
|  | * 1. If a message does not have a tag, but is associated with robot comments, | 
|  | * then it gets a tag. | 
|  | * | 
|  | * 2. Use the same tag for some of Gerrit's standard events, if they should be | 
|  | * considered one group, e.g. normal and wip patchset uploads. | 
|  | * | 
|  | * 3. Everything beyond the ~ character is cut off from the tag. That gives | 
|  | * tools control over which messages will be hidden. | 
|  | * | 
|  | * 4. (Non-WIP) patchset uploads get a separate tag when they invalidate any | 
|  | * votes. | 
|  | */ | 
|  | function computeTag(message: CombinedMessage) { | 
|  | if (!message.tag) { | 
|  | const threads = message.commentThreads || []; | 
|  | const messageId = getMessageId(message); | 
|  | const comments = threads.map(t => | 
|  | t.comments.find(c => c.change_message_id === messageId) | 
|  | ); | 
|  | const hasRobotComments = comments.some(isRobot); | 
|  | return hasRobotComments ? 'autogenerated:has-robot-comments' : undefined; | 
|  | } | 
|  |  | 
|  | if (message.tag === MessageTag.TAG_NEW_PATCHSET) { | 
|  | const hasOutdatedVotes = | 
|  | isChangeMessageInfo(message) && | 
|  | message.message.indexOf('\nOutdated Votes:\n') !== -1; | 
|  |  | 
|  | return hasOutdatedVotes | 
|  | ? MessageTag.TAG_NEW_PATCHSET_OUTDATED_VOTES | 
|  | : MessageTag.TAG_NEW_PATCHSET; | 
|  | } | 
|  | if (message.tag === MessageTag.TAG_NEW_WIP_PATCHSET) { | 
|  | return MessageTag.TAG_NEW_PATCHSET; | 
|  | } | 
|  | if (message.tag === MessageTag.TAG_UNSET_PRIVATE) { | 
|  | return MessageTag.TAG_SET_PRIVATE; | 
|  | } | 
|  | if (message.tag === MessageTag.TAG_SET_WIP) { | 
|  | return MessageTag.TAG_SET_READY; | 
|  | } | 
|  |  | 
|  | return message.tag.replace(/~.*/, ''); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Try to set a revision number that makes sense, if none is set. Just copy | 
|  | * over the revision number of the next older message. This is mostly relevant | 
|  | * for reviewer updates. Other messages should typically have the revision | 
|  | * number already set. | 
|  | */ | 
|  | function computeRevision( | 
|  | message: CombinedMessage, | 
|  | allMessages: CombinedMessage[] | 
|  | ): PatchSetNum | undefined { | 
|  | if (isPatchSetNumber(message._revision_number)) { | 
|  | return message._revision_number; | 
|  | } | 
|  | let revision: PatchSetNumber | undefined = undefined; | 
|  | for (const m of allMessages) { | 
|  | if (m.date > message.date) break; | 
|  | if (isPatchSetNumber(m._revision_number)) { | 
|  | revision = m._revision_number; | 
|  | } | 
|  | } | 
|  | return revision; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Merges change messages and reviewer updates into one array. Also processes | 
|  | * all messages and updates, aligns or massages some of the properties. | 
|  | */ | 
|  | function computeCombinedMessages( | 
|  | messages: ChangeMessageInfo[], | 
|  | reviewerUpdates: FormattedReviewerUpdateInfo[], | 
|  | commentThreads: CommentThread[] | 
|  | ): CombinedMessage[] { | 
|  | let mi = 0; | 
|  | let ri = 0; | 
|  | let combinedMessages: CombinedMessage[] = []; | 
|  | let mDate; | 
|  | let rDate; | 
|  | for (let i = 0; i < messages.length; i++) { | 
|  | // TODO(TS): clone message instead and avoid API object mutation | 
|  | (messages[i] as CombinedMessage)._index = i; | 
|  | } | 
|  |  | 
|  | while (mi < messages.length || ri < reviewerUpdates.length) { | 
|  | if (mi >= messages.length) { | 
|  | combinedMessages = combinedMessages.concat(reviewerUpdates.slice(ri)); | 
|  | break; | 
|  | } | 
|  | if (ri >= reviewerUpdates.length) { | 
|  | combinedMessages = combinedMessages.concat(messages.slice(mi)); | 
|  | break; | 
|  | } | 
|  | mDate = mDate || parseDate(messages[mi].date); | 
|  | rDate = rDate || parseDate(reviewerUpdates[ri].date); | 
|  | if (rDate < mDate) { | 
|  | combinedMessages.push(reviewerUpdates[ri++]); | 
|  | rDate = null; | 
|  | } else { | 
|  | combinedMessages.push(messages[mi++]); | 
|  | mDate = null; | 
|  | } | 
|  | } | 
|  |  | 
|  | for (let i = 0; i < combinedMessages.length; i++) { | 
|  | const message = combinedMessages[i]; | 
|  | if (message.expanded === undefined) { | 
|  | message.expanded = false; | 
|  | } | 
|  | message.commentThreads = computeThreads(message, commentThreads); | 
|  | message._revision_number = computeRevision(message, combinedMessages); | 
|  | message.tag = computeTag(message); | 
|  | } | 
|  | // computeIsImportant() depends on tags and revision numbers already being | 
|  | // updated for all messages, so we have to compute this in its own forEach | 
|  | // loop. | 
|  | combinedMessages.forEach(m => { | 
|  | m.isImportant = computeIsImportant(m, combinedMessages); | 
|  | }); | 
|  | return combinedMessages; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Unimportant messages are initially hidden. | 
|  | * | 
|  | * Human messages are always important. They have an undefined tag. | 
|  | * | 
|  | * Autogenerated messages are unimportant, if there is a message with the same | 
|  | * tag and a higher revision number. | 
|  | */ | 
|  | function computeIsImportant( | 
|  | message: CombinedMessage, | 
|  | allMessages: CombinedMessage[] | 
|  | ) { | 
|  | if (!message.tag) return true; | 
|  |  | 
|  | const hasSameTag = (m: CombinedMessage) => m.tag === message.tag; | 
|  | const revNumber = message._revision_number || 0; | 
|  | const hasHigherRevisionNumber = (m: CombinedMessage) => | 
|  | (m._revision_number || 0) > revNumber; | 
|  | return !allMessages.filter(hasSameTag).some(hasHigherRevisionNumber); | 
|  | } | 
|  |  | 
|  | export const TEST_ONLY = { | 
|  | computeTag, | 
|  | computeRevision, | 
|  | computeIsImportant, | 
|  | }; | 
|  |  | 
|  | @customElement('gr-messages-list') | 
|  | export class GrMessagesList extends LitElement { | 
|  | // TODO: Evaluate if we still need to have display: flex on the :host and | 
|  | // .header. | 
|  | static override get styles() { | 
|  | return [ | 
|  | sharedStyles, | 
|  | paperStyles, | 
|  | css` | 
|  | :host { | 
|  | display: flex; | 
|  | justify-content: space-between; | 
|  | } | 
|  | .header { | 
|  | align-items: center; | 
|  | border-bottom: 1px solid var(--border-color); | 
|  | display: flex; | 
|  | justify-content: space-between; | 
|  | padding: var(--spacing-s) var(--spacing-l); | 
|  | } | 
|  | .highlighted { | 
|  | animation: 3s fadeOut; | 
|  | } | 
|  | @keyframes fadeOut { | 
|  | 0% { | 
|  | background-color: var(--emphasis-color); | 
|  | } | 
|  | 100% { | 
|  | background-color: var(--view-background-color); | 
|  | } | 
|  | } | 
|  | .container { | 
|  | align-items: center; | 
|  | display: flex; | 
|  | } | 
|  | .hiddenEntries { | 
|  | color: var(--deemphasized-text-color); | 
|  | } | 
|  | gr-message:not(:last-of-type) { | 
|  | border-bottom: 1px solid var(--border-color); | 
|  | } | 
|  | `, | 
|  | ]; | 
|  | } | 
|  |  | 
|  | @property({type: Array}) | 
|  | messages: ChangeMessageInfo[] = []; | 
|  |  | 
|  | @property({type: Array}) | 
|  | reviewerUpdates: FormattedReviewerUpdateInfo[] = []; | 
|  |  | 
|  | @property({type: Object}) | 
|  | labels?: LabelNameToInfoMap; | 
|  |  | 
|  | @state() | 
|  | private change?: ParsedChangeInfo; | 
|  |  | 
|  | @state() | 
|  | private changeNum?: ChangeId | NumericChangeId; | 
|  |  | 
|  | @state() | 
|  | private commentThreads: CommentThread[] = []; | 
|  |  | 
|  | @state() | 
|  | expandAllState = ExpandAllState.EXPAND_ALL; | 
|  |  | 
|  | // Private but used in tests. | 
|  | @state() | 
|  | showAllActivity = false; | 
|  |  | 
|  | @state() | 
|  | private combinedMessages: CombinedMessage[] = []; | 
|  |  | 
|  | private readonly getCommentsModel = resolve(this, commentsModelToken); | 
|  |  | 
|  | private readonly changeModel = resolve(this, changeModelToken); | 
|  |  | 
|  | private readonly reporting = getAppContext().reportingService; | 
|  |  | 
|  | private readonly getShortcutsService = resolve(this, shortcutsServiceToken); | 
|  |  | 
|  | constructor() { | 
|  | super(); | 
|  | subscribe( | 
|  | this, | 
|  | () => this.getCommentsModel().threadsSaved$, | 
|  | x => { | 
|  | this.commentThreads = x; | 
|  | } | 
|  | ); | 
|  | subscribe( | 
|  | this, | 
|  | () => this.changeModel().change$, | 
|  | x => { | 
|  | this.change = x; | 
|  | } | 
|  | ); | 
|  | subscribe( | 
|  | this, | 
|  | () => this.changeModel().changeNum$, | 
|  | x => { | 
|  | this.changeNum = x; | 
|  | } | 
|  | ); | 
|  | } | 
|  |  | 
|  | override willUpdate(changedProperties: PropertyValues): void { | 
|  | if ( | 
|  | changedProperties.has('messages') || | 
|  | changedProperties.has('reviewerUpdates') || | 
|  | changedProperties.has('commentThreads') | 
|  | ) { | 
|  | this.combinedMessages = computeCombinedMessages( | 
|  | this.messages ?? [], | 
|  | this.reviewerUpdates ?? [], | 
|  | this.commentThreads ?? [] | 
|  | ); | 
|  | this.combinedMessagesChanged(); | 
|  | } | 
|  | } | 
|  |  | 
|  | override render() { | 
|  | const labelExtremes = this.computeLabelExtremes(); | 
|  | return html`${this.renderHeader()} | 
|  | ${this.combinedMessages | 
|  | .filter(m => this.showAllActivity || m.isImportant) | 
|  | .map( | 
|  | message => html`<gr-message | 
|  | .change=${this.change} | 
|  | .changeNum=${this.changeNum} | 
|  | .message=${message} | 
|  | .commentThreads=${message.commentThreads} | 
|  | @message-anchor-tap=${this.handleAnchorClick} | 
|  | .labelExtremes=${labelExtremes} | 
|  | data-message-id=${ifDefined(getMessageId(message) as String)} | 
|  | ></gr-message>` | 
|  | )}`; | 
|  | } | 
|  |  | 
|  | private renderHeader() { | 
|  | return html`<div class="header"> | 
|  | <div id="showAllActivityToggleContainer" class="container"> | 
|  | ${when( | 
|  | this.combinedMessages.some(m => !m.isImportant), | 
|  | () => html` | 
|  | <paper-toggle-button | 
|  | class="showAllActivityToggle" | 
|  | ?checked=${this.showAllActivity} | 
|  | @change=${this.handleShowAllActivityChanged} | 
|  | aria-labelledby="showAllEntriesLabel" | 
|  | role="switch" | 
|  | @click=${this.onTapShowAllActivityToggle} | 
|  | ></paper-toggle-button> | 
|  | <div id="showAllEntriesLabel" aria-hidden="true"> | 
|  | <span>Show all entries</span> | 
|  | <span class="hiddenEntries" ?hidden=${this.showAllActivity}> | 
|  | (${this.combinedMessages.filter(m => !m.isImportant).length} | 
|  | hidden) | 
|  | </span> | 
|  | </div> | 
|  | <span class="transparent separator"></span> | 
|  | ` | 
|  | )} | 
|  | </div> | 
|  | <gr-button | 
|  | id="collapse-messages" | 
|  | link | 
|  | .title=${this.computeExpandAllTitle()} | 
|  | @click=${this.handleExpandCollapseTap} | 
|  | > | 
|  | ${this.expandAllState} | 
|  | </gr-button> | 
|  | </div>`; | 
|  | } | 
|  |  | 
|  | async scrollToMessage(messageID: string) { | 
|  | await waitUntil(() => this.messages && this.messages.length > 0); | 
|  | await this.updateComplete; | 
|  |  | 
|  | const selector = `[data-message-id="${messageID}"]`; | 
|  | const el = this.shadowRoot!.querySelector(selector) as | 
|  | | GrMessage | 
|  | | undefined; | 
|  |  | 
|  | if (!el && this.showAllActivity) { | 
|  | this.reporting.error( | 
|  | 'GrMessagesList scroll', | 
|  | new Error(`Failed to scroll to message: ${messageID}`) | 
|  | ); | 
|  | return; | 
|  | } | 
|  | if (!el || !el.message) { | 
|  | this.showAllActivity = true; | 
|  | setTimeout(() => this.scrollToMessage(messageID)); | 
|  | return; | 
|  | } | 
|  |  | 
|  | el.message.expanded = true; | 
|  | // Must wait for message to expand and render before we can scroll to it | 
|  | el.requestUpdate(); | 
|  | await el.updateComplete; | 
|  | await query<GrFormattedText>(el, 'gr-formatted-text.message') | 
|  | ?.updateComplete; | 
|  | el.scrollIntoView(); | 
|  | this.highlightEl(el); | 
|  | } | 
|  |  | 
|  | private handleShowAllActivityChanged(e: Event) { | 
|  | this.showAllActivity = (e.target as HTMLInputElement).checked ?? false; | 
|  | } | 
|  |  | 
|  | private refreshMessages() { | 
|  | for (const message of queryAll<GrMessage>(this, 'gr-message')) { | 
|  | message.requestUpdate(); | 
|  | } | 
|  | } | 
|  |  | 
|  | private computeExpandAllTitle() { | 
|  | if (this.expandAllState === ExpandAllState.COLLAPSE_ALL) { | 
|  | return this.getShortcutsService().createTitle( | 
|  | Shortcut.COLLAPSE_ALL_MESSAGES, | 
|  | ShortcutSection.ACTIONS | 
|  | ); | 
|  | } | 
|  | if (this.expandAllState === ExpandAllState.EXPAND_ALL) { | 
|  | return this.getShortcutsService().createTitle( | 
|  | Shortcut.EXPAND_ALL_MESSAGES, | 
|  | ShortcutSection.ACTIONS | 
|  | ); | 
|  | } | 
|  | return ''; | 
|  | } | 
|  |  | 
|  | // Private but used in tests. | 
|  | highlightEl(el: HTMLElement) { | 
|  | const highlightedEls = | 
|  | this.shadowRoot?.querySelectorAll('.highlighted') ?? []; | 
|  | for (const highlightedEl of highlightedEls) { | 
|  | highlightedEl.classList.remove('highlighted'); | 
|  | } | 
|  | function handleAnimationEnd() { | 
|  | el.removeEventListener('animationend', handleAnimationEnd); | 
|  | el.classList.remove('highlighted'); | 
|  | } | 
|  | el.addEventListener('animationend', handleAnimationEnd); | 
|  | el.classList.add('highlighted'); | 
|  | } | 
|  |  | 
|  | // Private but used in tests. | 
|  | handleExpandCollapse(expand: boolean) { | 
|  | this.expandAllState = expand | 
|  | ? ExpandAllState.COLLAPSE_ALL | 
|  | : ExpandAllState.EXPAND_ALL; | 
|  | if (!this.combinedMessages) return; | 
|  | for (let i = 0; i < this.combinedMessages.length; i++) { | 
|  | this.combinedMessages[i].expanded = expand; | 
|  | } | 
|  | this.refreshMessages(); | 
|  | } | 
|  |  | 
|  | private handleExpandCollapseTap(e: Event) { | 
|  | e.preventDefault(); | 
|  | this.handleExpandCollapse( | 
|  | this.expandAllState === ExpandAllState.EXPAND_ALL | 
|  | ); | 
|  | } | 
|  |  | 
|  | private handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) { | 
|  | this.scrollToMessage(e.detail.id); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Called when this.combinedMessages has changed. | 
|  | */ | 
|  | private combinedMessagesChanged() { | 
|  | if (this.combinedMessages.length === 0) return; | 
|  | this.refreshMessages(); | 
|  | const tags = this.combinedMessages.map( | 
|  | message => | 
|  | message.tag || (message as FormattedReviewerUpdateInfo).type || 'none' | 
|  | ); | 
|  | const tagsCounted = tags.reduce( | 
|  | (acc, val) => { | 
|  | acc[val] = (acc[val] || 0) + 1; | 
|  | return acc; | 
|  | }, | 
|  | {all: this.combinedMessages.length} as TagsCountReportInfo | 
|  | ); | 
|  | this.reporting.reportInteraction('messages-count', tagsCounted); | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Compute a mapping from label name to objects representing the minimum and | 
|  | * maximum possible values for that label. | 
|  | * Private but used in tests. | 
|  | */ | 
|  | computeLabelExtremes() { | 
|  | const extremes: {[labelName: string]: VotingRangeInfo} = {}; | 
|  | if (!this.labels) { | 
|  | return extremes; | 
|  | } | 
|  | for (const key of Object.keys(this.labels)) { | 
|  | const range = getVotingRange(this.labels[key]); | 
|  | if (range) { | 
|  | extremes[key] = range; | 
|  | } | 
|  | } | 
|  | return extremes; | 
|  | } | 
|  |  | 
|  | /** | 
|  | * Work around a issue on iOS when clicking turns into double tap | 
|  | */ | 
|  | private onTapShowAllActivityToggle(e: Event) { | 
|  | e.preventDefault(); | 
|  | } | 
|  | } | 
|  |  | 
|  | declare global { | 
|  | interface HTMLElementTagNameMap { | 
|  | 'gr-messages-list': GrMessagesList; | 
|  | } | 
|  | } |