blob: 8557c10a2c1b68663f2135927fc9ed94030f606d [file] [log] [blame]
/**
* @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 '@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/shared-styles';
import {GestureEventListeners} from '@polymer/polymer/lib/mixins/gesture-event-listeners';
import {LegacyElementMixin} from '@polymer/polymer/lib/legacy/legacy-element-mixin';
import {PolymerElement} from '@polymer/polymer/polymer-element';
import {htmlTemplate} from './gr-messages-list_html';
import {
KeyboardShortcutMixin,
Shortcut,
ShortcutSection,
} from '../../../mixins/keyboard-shortcut-mixin/keyboard-shortcut-mixin';
import {parseDate} from '../../../utils/date-util';
import {MessageTag} from '../../../constants/constants';
import {appContext} from '../../../services/app-context';
import {customElement, property} from '@polymer/decorators';
import {
ChangeId,
ChangeMessageId,
ChangeMessageInfo,
ChangeViewChangeInfo,
LabelNameToInfoMap,
NumericChangeId,
PatchSetNum,
RepoName,
ReviewerUpdateInfo,
VotingRangeInfo,
} from '../../../types/common';
import {ChangeComments} from '../../diff/gr-comment-api/gr-comment-api';
import {CommentThread, isRobot} from '../../../utils/comment-util';
import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
import {PolymerDeepPropertyChange} from '@polymer/polymer/interfaces';
import {FormattedReviewerUpdateInfo} from '../../shared/gr-rest-api-interface/gr-reviewer-updates-parser';
import {DomRepeat} from '@polymer/polymer/lib/elements/dom-repeat';
import {getVotingRange} from '../../../utils/label-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;
}
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,
changeComments: ChangeComments
): CommentThread[] {
if (message._index === undefined) {
return [];
}
const messageId = getMessageId(message);
return changeComments.getAllThreadsForChange().filter(thread =>
thread.comments
.map(comment => {
// collapse all by default
comment.collapsed = true;
return comment;
})
.some(comment => {
const condition = comment.change_message_id === messageId;
// Since getAllThreadsForChange() always returns a new copy of
// all comments we can modify them here without worrying about
// polluting other threads.
comment.collapsed = !condition;
return condition;
})
);
}
/**
* If messages have the same tag, then that influences grouping and whether
* a message is initally 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_ASSIGNEE) {
return MessageTag.TAG_SET_ASSIGNEE;
}
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 = {
computeThreads,
computeTag,
computeRevision,
computeIsImportant,
};
export interface GrMessagesList {
$: {
messageRepeat: DomRepeat;
};
}
@customElement('gr-messages-list')
export class GrMessagesList extends KeyboardShortcutMixin(
GestureEventListeners(LegacyElementMixin(PolymerElement))
) {
static get template() {
return htmlTemplate;
}
@property({type: Object})
change?: ChangeViewChangeInfo;
@property({type: String})
changeNum?: ChangeId | NumericChangeId;
@property({type: Array})
messages: ChangeMessageInfo[] = [];
@property({type: Array})
reviewerUpdates: ReviewerUpdateInfo[] = [];
@property({type: Object})
changeComments?: ChangeComments;
@property({type: String})
projectName?: RepoName;
@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, ' +
'changeComments)',
observer: '_combinedMessagesChanged',
})
_combinedMessages: CombinedMessage[] = [];
@property({type: Object, computed: '_computeLabelExtremes(labels.*)'})
_labelExtremes: {[lableName: string]: VotingRangeInfo} = {};
private readonly reporting = appContext.reportingService;
scrollToMessage(messageID: string) {
const selector = `[data-message-id="${messageID}"]`;
const el = this.shadowRoot!.querySelector(selector) as
| GrMessage
| undefined;
if (!el && this._showAllActivity) {
console.warn(`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[],
reviewerUpdates?: FormattedReviewerUpdateInfo[],
changeComments?: ChangeComments
) {
if (
messages === undefined ||
reviewerUpdates === undefined ||
changeComments === 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;
}
}
combinedMessages.forEach(m => {
if (m.expanded === undefined) {
m.expanded = false;
}
m.commentThreads = computeThreads(m, changeComments);
m._revision_number = computeRevision(m, combinedMessages);
m.tag = computeTag(m);
});
// 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.createTitle(
Shortcut.COLLAPSE_ALL_MESSAGES,
ShortcutSection.ACTIONS
);
}
if (_expandAllState === ExpandAllState.EXPAND_ALL) {
return this.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;
}
/**
* This method is for reporting stats only.
*/
_combinedMessagesChanged(combinedMessages?: CombinedMessage[]) {
if (combinedMessages) {
if (combinedMessages.length === 0) return;
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: {[lableName: 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;
}
}