blob: 97b20dc87aaae51328b5809a958d2b7e79b7c307 [file] [log] [blame]
/**
* @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,
LabelNameToInfoMap,
NumericChangeId,
PatchSetNum,
VotingRangeInfo,
} from '../../../types/common';
import {CommentThread, isRobot} from '../../../utils/comment-util';
import {GrMessage, MessageAnchorTapDetail} from '../gr-message/gr-message';
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} 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 {Interaction} from '../../../constants/reporting';
/**
* 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 (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;
}
/**
* 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 but used in tests.
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().threads$,
x => {
this.commentThreads = x;
}
);
subscribe(
this,
() => this.changeModel().change$,
x => {
this.change = x;
}
);
subscribe(
this,
() => this.changeModel().changeNum$,
x => {
this.changeNum = x;
}
);
// for COMMENTS_AUTOCLOSE logging purposes only
this.reporting.reportInteraction(
Interaction.COMMENTS_AUTOCLOSE_MESSAGES_LIST_CREATED
);
}
override updated(): void {
// for COMMENTS_AUTOCLOSE logging purposes only
const messages = this.shadowRoot!.querySelectorAll('gr-message');
if (messages.length > 0) {
this.reporting.reportInteraction(
Interaction.COMMENTS_AUTOCLOSE_MESSAGES_LIST_UPDATED,
{uid: messages[0].uid}
);
}
}
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) {
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 || !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;
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);
}
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;
}
}