blob: 4abfeff0c0c84934779a233e480bd257d3dd86d5 [file] [log] [blame]
/**
* @license
* Copyright 2015 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import '../../shared/gr-account-label/gr-account-label';
import '../../shared/gr-account-chip/gr-account-chip';
import '../../shared/gr-button/gr-button';
import '../../shared/gr-icon/gr-icon';
import '../../shared/gr-date-formatter/gr-date-formatter';
import '../../shared/gr-formatted-text/gr-formatted-text';
import '../gr-message-scores/gr-message-scores';
import {css, html, LitElement, nothing} from 'lit';
import {MessageTag, SpecialFilePath} from '../../../constants/constants';
import {customElement, property, state} from 'lit/decorators.js';
import {hasOwnProperty} from '../../../utils/common-util';
import {
ChangeInfo,
ServerInfo,
ReviewInputTag,
NumericChangeId,
ChangeMessageId,
RevisionPatchSetNum,
AccountInfo,
BasePatchSetNum,
LabelNameToInfoMap,
CommentThread,
ChangeMessage,
} from '../../../types/common';
import {
isFormattedReviewerUpdate,
LabelExtreme,
PATCH_SET_PREFIX_PATTERN,
isUnresolved,
} from '../../../utils/comment-util';
import {LABEL_TITLE_SCORE_PATTERN} from '../gr-message-scores/gr-message-scores';
import {getAppContext} from '../../../services/app-context';
import {pluralize} from '../../../utils/string-util';
import {navigationToken} from '../../core/gr-navigation/gr-navigation';
import {
computeAllPatchSets,
computeLatestPatchNum,
computePredecessor,
} from '../../../utils/patch-set-util';
import {isServiceUser, replaceTemplates} from '../../../utils/account-util';
import {assertIsDefined} from '../../../utils/common-util';
import {when} from 'lit/directives/when.js';
import {FormattedReviewerUpdateInfo} from '../../../types/types';
import {resolve} from '../../../models/dependency';
import {createChangeUrl} from '../../../models/views/change';
import {fire} from '../../../utils/event-util';
import {ChangeMessageDeletedEventDetail} from '../../../types/events';
const UPLOADED_NEW_PATCHSET_PATTERN = /Uploaded patch set (\d+)./;
const MERGED_PATCHSET_PATTERN = /(\d+) is the latest approved patch-set/;
declare global {
interface HTMLElementTagNameMap {
'gr-message': GrMessage;
}
interface HTMLElementEventMap {
'message-anchor-tap': CustomEvent<MessageAnchorTapDetail>;
'change-message-deleted': CustomEvent<ChangeMessageDeletedEventDetail>;
}
}
export interface MessageAnchorTapDetail {
id: ChangeMessageId;
}
@customElement('gr-message')
export class GrMessage extends LitElement {
/**
* Fired when this message's reply link is tapped.
*
* @event reply
*/
/**
* Fired when a change message is deleted.
*
* @event change-message-deleted
*/
@property({type: Object})
change?: ChangeInfo;
@property({type: Number})
changeNum?: NumericChangeId;
@property({type: Object})
message?: ChangeMessage | (ChangeMessage & FormattedReviewerUpdateInfo);
@property({type: Array})
commentThreads: CommentThread[] = [];
get author() {
return this.message?.author || this.message?.updated_by;
}
@property({type: Object})
config?: ServerInfo;
@property({type: Boolean})
hideAutomated = false;
/**
* A mapping from label names to objects representing the minimum and
* maximum possible values for that label.
*/
@property({type: Object})
labelExtremes?: LabelExtreme;
@property({type: Boolean})
loggedIn = false;
@state()
private isAdmin = false;
@state()
private isDeletingChangeMsg = false;
private readonly restApiService = getAppContext().restApiService;
private readonly getNavigation = resolve(this, navigationToken);
constructor() {
super();
this.addEventListener('click', e => this.handleClick(e));
}
override connectedCallback() {
super.connectedCallback();
this.restApiService.getConfig().then(config => {
this.config = config;
});
this.restApiService.getLoggedIn().then(loggedIn => {
this.loggedIn = loggedIn;
});
this.restApiService.getIsAdmin().then(isAdmin => {
this.isAdmin = !!isAdmin;
});
}
static override get styles() {
return [
css`
:host {
display: block;
position: relative;
cursor: pointer;
overflow-y: hidden;
}
:host(.expanded) {
cursor: auto;
}
.collapsed .contentContainer {
align-items: center;
color: var(--deemphasized-text-color);
display: flex;
white-space: nowrap;
}
.contentContainer {
padding: var(--spacing-m) var(--spacing-l);
}
.expanded .contentContainer {
background-color: var(--background-color-secondary);
}
.collapsed .contentContainer {
background-color: var(--background-color-primary);
}
div.serviceUser.expanded div.contentContainer {
background-color: var(
--background-color-service-user,
var(--background-color-secondary)
);
}
div.serviceUser.collapsed div.contentContainer {
background-color: var(
--background-color-service-user,
var(--background-color-primary)
);
}
.name {
font-weight: var(--font-weight-bold);
}
.message {
--gr-formatted-text-prose-max-width: 120ch;
}
.collapsed .message {
max-width: none;
overflow: hidden;
text-overflow: ellipsis;
}
.collapsed .author,
.collapsed .content,
.collapsed .message,
.collapsed .updateCategory,
gr-account-chip {
display: inline;
}
gr-button {
margin: 0 -4px;
}
.collapsed gr-thread-list,
.collapsed .deleteBtn,
.collapsed .hideOnCollapsed,
.hideOnOpen {
display: none;
}
.collapsed .hideOnOpen {
display: block;
}
.collapsed .content {
flex: 1;
margin-right: var(--spacing-m);
min-width: 0;
overflow: hidden;
}
.collapsed .content.messageContent {
text-overflow: ellipsis;
}
.collapsed .dateContainer {
position: static;
}
.collapsed .author {
overflow: hidden;
color: var(--primary-text-color);
margin-right: var(--spacing-s);
}
.authorLabel {
min-width: 130px;
--account-max-length: 120px;
margin-right: var(--spacing-s);
}
.expanded .author {
cursor: pointer;
margin-bottom: var(--spacing-m);
}
.expanded .content {
padding-left: 40px;
}
.dateContainer {
position: absolute;
/* right and top values should match .contentContainer padding */
right: var(--spacing-l);
top: var(--spacing-m);
}
.dateContainer gr-icon {
margin-right: var(--spacing-m);
color: var(--deemphasized-text-color);
}
.dateContainer .patchset:before {
content: 'Patchset ';
}
.dateContainer .patchsetDiffButton {
margin-right: var(--spacing-m);
--gr-button-padding: 0 var(--spacing-m);
}
span.date {
color: var(--deemphasized-text-color);
}
span.date:hover {
text-decoration: underline;
}
.dateContainer gr-icon {
cursor: pointer;
vertical-align: top;
}
.commentsSummary {
margin-right: var(--spacing-s);
}
.expanded .commentsSummary {
display: none;
}
gr-icon.commentsIcon {
vertical-align: top;
}
gr-icon.unresolved.commentsIcon {
color: var(--warning-foreground);
}
.numberOfComments {
padding-right: var(--spacing-m);
}
gr-account-label::part(gr-account-label-text) {
font-weight: var(--font-weight-bold);
}
@media screen and (max-width: 50em) {
.expanded .content {
padding-left: 0;
}
.commentsSummary {
min-width: 0px;
}
.authorLabel {
width: 100px;
}
.dateContainer .patchset:before {
content: 'PS ';
}
}
`,
];
}
override render() {
if (!this.message) return nothing;
if (this.hideAutomated && this.computeIsAutomated()) return nothing;
this.updateExpandedClass();
return html` <div class=${this.computeClass()}>
<div class="contentContainer">
${this.renderAuthor()} ${this.renderCommentsSummary()}
${this.renderMessageContent()} ${this.renderReviewerUpdate()}
${this.renderDateContainer()}
</div>
</div>`;
}
private renderAuthor() {
assertIsDefined(this.message, 'message');
return html` <div class="author" @click=${this.handleAuthorClick}>
${when(
this.computeShowOnBehalfOf(),
() => html`
<span>
<span class="name">${this.message?.real_author?.name}</span>
on behalf of
</span>
`
)}
<gr-account-label
.account=${this.author}
.change=${this.change}
class="authorLabel"
></gr-account-label>
<gr-message-scores
.labelExtremes=${this.labelExtremes}
.message=${this.message}
.change=${this.change}
></gr-message-scores>
</div>`;
}
private renderCommentIcon({
commentThreadsCount,
unresolved,
}: {
commentThreadsCount: number;
unresolved: boolean;
}) {
if (commentThreadsCount === 0) {
return nothing;
}
return html` <span
class="numberOfComments"
title=${pluralize(
commentThreadsCount,
(unresolved ? 'unresolved' : 'resolved') + ' comment'
)}
>
<gr-icon
small
icon=${unresolved ? 'chat_bubble' : 'mark_chat_read'}
?filled=${unresolved}
class="${unresolved ? 'unresolved ' : ''}commentsIcon"
></gr-icon>
${commentThreadsCount}</span
>`;
}
private renderCommentsSummary() {
if (!this.commentThreads?.length) return nothing;
const unresolvedThreadsCount =
this.commentThreads.filter(isUnresolved).length;
const resolvedThreadsCount =
this.commentThreads.length - unresolvedThreadsCount;
return html`
<div class="commentsSummary">
${this.renderCommentIcon({
commentThreadsCount: unresolvedThreadsCount,
unresolved: true,
})}
${this.renderCommentIcon({
commentThreadsCount: resolvedThreadsCount,
unresolved: false,
})}
</div>
`;
}
private renderMessageContent() {
if (!this.message?.message) return nothing;
const messageContentCollapsed =
this.computeMessageContent(
false,
this.message.message.substring(0, 1000),
this.message.accounts_in_message,
this.message.tag,
this.change?.labels
) || this.patchsetCommentSummary();
return html` <div class="content messageContent">
<div class="message hideOnOpen">${messageContentCollapsed}</div>
${this.renderExpandedMessageContent()}
</div>`;
}
private renderExpandedMessageContent() {
if (!this.message?.expanded) return nothing;
const messageContentExpanded = this.computeMessageContent(
true,
this.message.message,
this.message.accounts_in_message,
this.message.tag,
this.change?.labels
);
return html`
<gr-formatted-text
class="message hideOnCollapsed"
.markdown=${true}
.content=${messageContentExpanded}
></gr-formatted-text>
${when(messageContentExpanded, () => this.renderActionContainer())}
<gr-thread-list
?hidden=${!this.commentThreads.length}
.threads=${this.commentThreads}
hide-dropdown
show-comment-context
.messageId=${this.message.id}
>
</gr-thread-list>
`;
}
private renderActionContainer() {
if (!this.isAdmin || !this.loggedIn || this.computeIsAutomated()) {
return nothing;
}
return html` <div class="replyActionContainer">
<gr-button
?disabled=${this.isDeletingChangeMsg}
class="deleteBtn"
link=""
@click=${this.handleDeleteMessage}
>
Delete
</gr-button>
</div>`;
}
private renderReviewerUpdate() {
assertIsDefined(this.message, 'message');
if (!isFormattedReviewerUpdate(this.message)) return;
return html` <div class="content">
${this.message.updates.map(update => this.renderMessageUpdate(update))}
</div>`;
}
private renderMessageUpdate(update: {
message: string;
reviewers: AccountInfo[];
}) {
return html`<div class="updateCategory">
${update.message}
${update.reviewers.map(
reviewer => html`
<gr-account-chip .account=${reviewer} .change=${this.change}>
</gr-account-chip>
`
)}
</div>`;
}
private renderDateContainer() {
return html`<span class="dateContainer">
${this.renderDiffButton()}
${when(
this.message?._revision_number,
() => html`
<span class="patchset">${this.message?._revision_number} |</span>
`
)}
${when(
this.message?.id,
() => html`
<span class="date" @click=${this.handleAnchorClick}>
<gr-date-formatter
withTooltip
showDateAndTime
.dateStr=${this.message?.date}
></gr-date-formatter>
</span>
`,
() => html`
<span class="date">
<gr-date-formatter
withTooltip
showDateAndTime
.dateStr=${this.message?.date}
></gr-date-formatter>
</span>
`
)}
<gr-icon
id="expandToggle"
@click=${this.toggleExpanded}
title="Toggle expanded state"
icon=${this.computeExpandToggleIcon()}
></gr-icon>
</span>`;
}
private renderDiffButton() {
if (!this.showViewDiffButton()) return nothing;
return html` <gr-button
class="patchsetDiffButton"
@click=${this.handleViewPatchsetDiff}
link
>
View Diff
</gr-button>`;
}
private updateExpandedClass() {
if (this.message?.expanded) {
this.classList.add('expanded');
} else {
this.classList.remove('expanded');
}
}
// Private but used in tests.
patchsetCommentSummary() {
const id = this.message?.id;
if (!id) return '';
const patchsetThreads = (this.commentThreads ?? []).filter(
thread => thread.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS
);
for (const thread of patchsetThreads) {
// Find if there was a patchset level comment created through the reply
// dialog and use it to determine the summary
if (thread.comments[0].change_message_id === id) {
return thread.comments[0].message;
}
}
// Find if there is a reply to some patchset comment left
for (const thread of patchsetThreads) {
for (const comment of thread.comments) {
if (comment.change_message_id === id) {
return comment.message;
}
}
}
return '';
}
private showViewDiffButton() {
return (
this.isNewPatchsetTag(this.message?.tag) ||
this.isMergePatchset(this.message)
);
}
private isMergePatchset(message?: ChangeMessage) {
return (
message?.tag === MessageTag.TAG_MERGED &&
message?.message.match(MERGED_PATCHSET_PATTERN)
);
}
private isNewPatchsetTag(tag?: ReviewInputTag) {
return (
tag === MessageTag.TAG_NEW_PATCHSET ||
tag === MessageTag.TAG_NEW_WIP_PATCHSET ||
tag === MessageTag.TAG_NEW_PATCHSET_OUTDATED_VOTES
);
}
// Private but used in tests
handleViewPatchsetDiff(e: Event) {
if (!this.message || !this.change) return;
let patchNum: RevisionPatchSetNum;
let basePatchNum: BasePatchSetNum;
if (this.message.message.match(UPLOADED_NEW_PATCHSET_PATTERN)) {
const match = this.message.message.match(UPLOADED_NEW_PATCHSET_PATTERN)!;
if (isNaN(Number(match[1])))
throw new Error('invalid patchnum in message');
patchNum = Number(match[1]) as RevisionPatchSetNum;
basePatchNum = computePredecessor(patchNum)!;
} else if (this.message.message.match(MERGED_PATCHSET_PATTERN)) {
const match = this.message.message.match(MERGED_PATCHSET_PATTERN)!;
if (isNaN(Number(match[1])))
throw new Error('invalid patchnum in message');
basePatchNum = Number(match[1]) as BasePatchSetNum;
patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
} else {
// Message is of the form "Commit Message was updated" or "Patchset X
// was rebased"
patchNum = computeLatestPatchNum(computeAllPatchSets(this.change))!;
basePatchNum = computePredecessor(patchNum)!;
}
this.getNavigation().setUrl(
createChangeUrl({change: this.change, patchNum, basePatchNum})
);
// stop propagation to stop message expansion
e.stopPropagation();
}
// private but used in tests
computeMessageContent(
isExpanded: boolean,
content?: string,
accountsInMessage?: AccountInfo[],
tag?: ReviewInputTag,
labels?: LabelNameToInfoMap
) {
if (!content) return '';
const isNewPatchSet = this.isNewPatchsetTag(tag);
if (accountsInMessage) {
content = replaceTemplates(content, accountsInMessage, this.config);
}
const lines = content.split('\n');
const filteredLines = lines.filter(line => {
if (!isExpanded && line.startsWith('>')) {
return false;
}
if (line.startsWith('(') && line.endsWith(' comment)')) {
return false;
}
if (line.startsWith('(') && line.endsWith(' comments)')) {
return false;
}
if (!isNewPatchSet && labels) {
// Legacy change messages may contain the 'Patch Set' prefix
// and a message(not containing label scores) on the same line.
// To handle them correctly, only filter out lines which contain
// the 'Patch Set' prefix and label scores.
const match = line.match(PATCH_SET_PREFIX_PATTERN);
if (match && match[1]) {
const message = match[1].split(' ');
if (
message
.map(s => s.match(LABEL_TITLE_SCORE_PATTERN))
.filter(
ms => ms && ms.length === 4 && hasOwnProperty(labels, ms[2])
).length === message.length
) {
return false;
}
}
}
return true;
});
const mappedLines = filteredLines.map(line => {
// The change message formatting is not very consistent, so
// unfortunately we have to do a bit of tweaking here:
// Labels should be stripped from lines like this:
// Patch Set 29: Verified+1
// Rebase messages (which have a ':newPatchSet' tag) should be kept on
// lines like this:
// Patch Set 27: Patch Set 26 was rebased
// Only make this replacement if the line starts with Patch Set, since if
// it starts with "Uploaded patch set" (e.g for votes) we want to keep the
// "Uploaded patch set".
if (line.startsWith('Patch Set')) {
line = line.replace(PATCH_SET_PREFIX_PATTERN, '$1');
}
return line;
});
return mappedLines.join('\n').trim();
}
// private but used in tests
computeShowOnBehalfOf() {
if (!this.message) return false;
return !!(
this.author &&
this.message.real_author &&
this.author._account_id !== this.message.real_author._account_id
);
}
private handleClick(e: Event) {
if (!this.message || this.message?.expanded) {
return;
}
e.stopPropagation();
this.message.expanded = true;
this.requestUpdate();
}
private handleAuthorClick(e: Event) {
if (!this.message || !this.message?.expanded) {
return;
}
e.stopPropagation();
this.message.expanded = false;
this.requestUpdate();
}
// private but used in tests.
computeIsAutomated() {
return !!(
this.message?.reviewer ||
this.computeIsReviewerUpdate() ||
(this.message?.tag && this.message.tag.startsWith('autogenerated'))
);
}
private computeIsReviewerUpdate() {
return this.message?.type === 'REVIEWER_UPDATE';
}
private computeClass() {
const expanded = this.message?.expanded;
const classes = [];
classes.push(expanded ? 'expanded' : 'collapsed');
if (isServiceUser(this.author)) classes.push('serviceUser');
return classes.join(' ');
}
private handleAnchorClick(e: Event) {
e.preventDefault();
assertIsDefined(this.message, 'message');
// The element which triggers handleAnchorClick is rendered only if
// message.id defined: the element is wrapped in dom-if if="[[message.id]]"
const detail: MessageAnchorTapDetail = {
id: this.message.id,
};
fire(this, 'message-anchor-tap', detail);
}
private handleDeleteMessage(e: Event) {
e.preventDefault();
if (!this.message || !this.message.id || !this.changeNum) return;
this.isDeletingChangeMsg = true;
this.restApiService
.deleteChangeCommitMessage(this.changeNum, this.message.id)
.then(() => {
this.isDeletingChangeMsg = false;
// TODO: Fix the type casting. Might actually be a bug.
fire(this, 'change-message-deleted', {
message: this.message as ChangeMessage,
});
});
}
private computeExpandToggleIcon() {
return this.message?.expanded ? 'expand_less' : 'expand_more';
}
private toggleExpanded(e: Event) {
e.stopPropagation();
if (!this.message) return;
this.message = {...this.message, expanded: !this.message.expanded};
}
}