| /** |
| * @license |
| * Copyright (C) 2020 The Android Open Source Project |
| * |
| * Licensed under the Apache License, Version 2.0 (the "License"); |
| * you may not use this file except in compliance with the License. |
| * You may obtain a copy of the License at |
| * |
| * http://www.apache.org/licenses/LICENSE-2.0 |
| * |
| * Unless required by applicable law or agreed to in writing, software |
| * distributed under the License is distributed on an "AS IS" BASIS, |
| * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| * See the License for the specific language governing permissions and |
| * limitations under the License. |
| */ |
| import {Subscription} from 'rxjs'; |
| import '@polymer/paper-toggle-button/paper-toggle-button'; |
| import '../../shared/gr-button/gr-button'; |
| import '../../shared/gr-icons/gr-icons'; |
| import '../gr-message/gr-message'; |
| import '../../../styles/gr-paper-styles'; |
| import '../../../styles/shared-styles'; |
| import {htmlTemplate} from './gr-messages-list_html'; |
| import { |
| Shortcut, |
| ShortcutSection, |
| } from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin'; |
| import {parseDate} from '../../../utils/date-util'; |
| import {MessageTag} from '../../../constants/constants'; |
| import {getAppContext} from '../../../services/app-context'; |
| import {customElement, property} from '@polymer/decorators'; |
| import { |
| ChangeId, |
| ChangeMessageId, |
| ChangeMessageInfo, |
| LabelNameToInfoMap, |
| NumericChangeId, |
| PatchSetNum, |
| RepoName, |
| ReviewerUpdateInfo, |
| VotingRangeInfo, |
| } from '../../../types/common'; |
| import { |
| CommentThread, |
| isRobot, |
| LabelExtreme, |
| } from '../../../utils/comment-util'; |
| import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message'; |
| import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces'; |
| import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat'; |
| import {getVotingRange} from '../../../utils/label-util'; |
| import { |
| FormattedReviewerUpdateInfo, |
| ParsedChangeInfo, |
| } from '../../../types/types'; |
| import {commentsModelToken} from '../../../models/comments/comments-model'; |
| import {changeModelToken} from '../../../models/change/change-model'; |
| import {resolve, DIPolymerElement} from '../../../models/dependency'; |
| |
| /** |
| * 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. |
| */ |
| 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_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 (message._revision_number && message._revision_number > 0) |
| return message._revision_number; |
| let revision: PatchSetNum = 0 as PatchSetNum; |
| for (const m of allMessages) { |
| if (m.date > message.date) break; |
| if (m._revision_number && m._revision_number > revision) |
| revision = m._revision_number; |
| } |
| return revision > 0 ? revision : undefined; |
| } |
| |
| /** |
| * 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, |
| }; |
| |
| export interface GrMessagesList { |
| $: { |
| messageRepeat: DomRepeat; |
| }; |
| } |
| |
| @customElement('gr-messages-list') |
| export class GrMessagesList extends DIPolymerElement { |
| static get template() { |
| return htmlTemplate; |
| } |
| |
| // Private internal @state, derived from the application state. |
| @property({type: Object}) |
| change?: ParsedChangeInfo; |
| |
| // Private internal @state, derived from the application state. |
| @property({type: String}) |
| changeNum?: ChangeId | NumericChangeId; |
| |
| @property({type: Array}) |
| messages: ChangeMessageInfo[] = []; |
| |
| @property({type: Array}) |
| reviewerUpdates: ReviewerUpdateInfo[] = []; |
| |
| // Private internal @state, derived from the application state. |
| @property({type: Object}) |
| commentThreads: CommentThread[] = []; |
| |
| // Private internal @state, derived from the application state. |
| @property({type: String}) |
| projectName?: RepoName; |
| |
| // Private internal @state, derived from the application state. |
| @property({type: Boolean}) |
| showReplyButtons = false; |
| |
| @property({type: Object}) |
| labels?: LabelNameToInfoMap; |
| |
| @property({type: String}) |
| _expandAllState = ExpandAllState.EXPAND_ALL; |
| |
| @property({type: String, computed: '_computeExpandAllTitle(_expandAllState)'}) |
| _expandAllTitle = ''; |
| |
| @property({type: Boolean, observer: '_observeShowAllActivity'}) |
| _showAllActivity = false; |
| |
| @property({ |
| type: Array, |
| computed: |
| '_computeCombinedMessages(messages, reviewerUpdates, ' + |
| 'commentThreads)', |
| observer: '_combinedMessagesChanged', |
| }) |
| _combinedMessages: CombinedMessage[] = []; |
| |
| @property({type: Object, computed: '_computeLabelExtremes(labels.*)'}) |
| _labelExtremes: LabelExtreme = {}; |
| |
| private readonly userModel = getAppContext().userModel; |
| |
| // Private but used in tests. |
| readonly getCommentsModel = resolve(this, commentsModelToken); |
| |
| private readonly changeModel = resolve(this, changeModelToken); |
| |
| private readonly reporting = getAppContext().reportingService; |
| |
| private readonly shortcuts = getAppContext().shortcutsService; |
| |
| private subscriptions: Subscription[] = []; |
| |
| override connectedCallback() { |
| super.connectedCallback(); |
| this.subscriptions.push( |
| this.getCommentsModel().threads$.subscribe(x => { |
| this.commentThreads = x; |
| }) |
| ); |
| this.subscriptions.push( |
| this.changeModel().change$.subscribe(x => { |
| this.change = x; |
| }) |
| ); |
| this.subscriptions.push( |
| this.userModel.loggedIn$.subscribe(x => { |
| this.showReplyButtons = x; |
| }) |
| ); |
| this.subscriptions.push( |
| this.changeModel().repo$.subscribe(x => { |
| this.projectName = x; |
| }) |
| ); |
| this.subscriptions.push( |
| this.changeModel().changeNum$.subscribe(x => { |
| this.changeNum = x; |
| }) |
| ); |
| } |
| |
| override disconnectedCallback() { |
| for (const s of this.subscriptions) { |
| s.unsubscribe(); |
| } |
| this.subscriptions = []; |
| super.disconnectedCallback(); |
| } |
| |
| scrollToMessage(messageID: string) { |
| const selector = `[data-message-id="${messageID}"]`; |
| const el = this.shadowRoot!.querySelector(selector) as |
| | GrMessage |
| | undefined; |
| |
| if (!el && this._showAllActivity) { |
| this.reporting.error( |
| new Error(`Failed to scroll to message: ${messageID}`) |
| ); |
| return; |
| } |
| if (!el) { |
| this._showAllActivity = true; |
| setTimeout(() => this.scrollToMessage(messageID)); |
| return; |
| } |
| |
| el.set('message.expanded', true); |
| let top = el.offsetTop; |
| for ( |
| let offsetParent = el.offsetParent as HTMLElement | null; |
| offsetParent; |
| offsetParent = offsetParent.offsetParent as HTMLElement | null |
| ) { |
| top += offsetParent.offsetTop; |
| } |
| window.scrollTo(0, top); |
| this._highlightEl(el); |
| } |
| |
| _observeShowAllActivity() { |
| // We have to call render() such that the dom-repeat filter picks up the |
| // change. |
| this.$.messageRepeat.render(); |
| } |
| |
| /** |
| * Filter for the dom-repeat of combinedMessages. |
| */ |
| _isMessageVisible(message: CombinedMessage) { |
| return this._showAllActivity || message.isImportant; |
| } |
| |
| /** |
| * Merges change messages and reviewer updates into one array. Also processes |
| * all messages and updates, aligns or massages some of the properties. |
| */ |
| _computeCombinedMessages( |
| messages: ChangeMessageInfo[] | undefined, |
| reviewerUpdates: FormattedReviewerUpdateInfo[] | undefined, |
| commentThreads: CommentThread[] |
| ) { |
| if (messages === undefined || reviewerUpdates === undefined) return; |
| |
| 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; |
| } |
| |
| _updateExpandedStateOfAllMessages(exp: boolean) { |
| if (this._combinedMessages) { |
| for (let i = 0; i < this._combinedMessages.length; i++) { |
| this._combinedMessages[i].expanded = exp; |
| this.notifyPath(`_combinedMessages.${i}.expanded`); |
| } |
| } |
| } |
| |
| _computeExpandAllTitle(_expandAllState?: string) { |
| if (_expandAllState === ExpandAllState.COLLAPSE_ALL) { |
| return this.shortcuts.createTitle( |
| Shortcut.COLLAPSE_ALL_MESSAGES, |
| ShortcutSection.ACTIONS |
| ); |
| } |
| if (_expandAllState === ExpandAllState.EXPAND_ALL) { |
| return this.shortcuts.createTitle( |
| Shortcut.EXPAND_ALL_MESSAGES, |
| ShortcutSection.ACTIONS |
| ); |
| } |
| return ''; |
| } |
| |
| _highlightEl(el: HTMLElement) { |
| const highlightedEls = this.root!.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'); |
| } |
| |
| handleExpandCollapse(expand: boolean) { |
| this._expandAllState = expand |
| ? ExpandAllState.COLLAPSE_ALL |
| : ExpandAllState.EXPAND_ALL; |
| this._updateExpandedStateOfAllMessages(expand); |
| } |
| |
| _handleExpandCollapseTap(e: Event) { |
| e.preventDefault(); |
| this.handleExpandCollapse( |
| this._expandAllState === ExpandAllState.EXPAND_ALL |
| ); |
| } |
| |
| _handleAnchorClick(e: CustomEvent<MessageAnchorTapDetail>) { |
| this.scrollToMessage(e.detail.id); |
| } |
| |
| _isVisibleShowAllActivityToggle(messages: CombinedMessage[] = []) { |
| return messages.some(m => !m.isImportant); |
| } |
| |
| _computeHiddenEntriesCount(messages: CombinedMessage[] = []) { |
| return messages.filter(m => !m.isImportant).length; |
| } |
| |
| /** |
| * Called when this._combinedMessages has changed. |
| */ |
| _combinedMessagesChanged(combinedMessages?: CombinedMessage[]) { |
| if (!combinedMessages) return; |
| if (combinedMessages.length === 0) return; |
| for (let i = 0; i < combinedMessages.length; i++) { |
| this.notifyPath(`_combinedMessages.${i}.commentThreads`); |
| } |
| const tags = 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: 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. |
| */ |
| _computeLabelExtremes( |
| labelRecord: PolymerDeepPropertyChange< |
| LabelNameToInfoMap, |
| LabelNameToInfoMap |
| > |
| ) { |
| const extremes: {[labelName: string]: VotingRangeInfo} = {}; |
| const labels = labelRecord.base; |
| if (!labels) { |
| return extremes; |
| } |
| for (const key of Object.keys(labels)) { |
| const range = getVotingRange(labels[key]); |
| if (range) { |
| extremes[key] = range; |
| } |
| } |
| return extremes; |
| } |
| |
| /** |
| * Work around a issue on iOS when clicking turns into double tap |
| */ |
| _onTapShowAllActivityToggle(e: Event) { |
| e.preventDefault(); |
| } |
| } |
| |
| declare global { |
| interface HTMLElementTagNameMap { |
| 'gr-messages-list': GrMessagesList; |
| } |
| } |