blob: 41b47ef7d81e81f9c574fb44e5246febfcd3210b [file] [log] [blame]
/**
* @license
* Copyright 2021 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import {ChangeComments} from '../../elements/diff/gr-comment-api/gr-comment-api';
import {
CommentInfo,
NumericChangeId,
RevisionId,
UrlEncodedCommentId,
RobotCommentInfo,
PathToRobotCommentsInfoMap,
AccountInfo,
DraftInfo,
Comment,
SavingState,
isSaving,
isError,
isDraft,
isNew,
} from '../../types/common';
import {
addPath,
convertToCommentInput,
createNew,
createNewPatchsetLevel,
id,
isDraftThread,
isNewThread,
reportingDetails,
} from '../../utils/comment-util';
import {deepEqual} from '../../utils/deep-util';
import {select} from '../../utils/observable-util';
import {define} from '../dependency';
import {
BehaviorSubject,
combineLatest,
forkJoin,
from,
Observable,
of,
} from 'rxjs';
import {fire, fireAlert} from '../../utils/event-util';
import {CURRENT} from '../../utils/patch-set-util';
import {RestApiService} from '../../services/gr-rest-api/gr-rest-api';
import {ChangeModel} from '../change/change-model';
import {Interaction, Timing} from '../../constants/reporting';
import {assert, assertIsDefined} from '../../utils/common-util';
import {debounce, DelayedTask} from '../../utils/async-util';
import {ReportingService} from '../../services/gr-reporting/gr-reporting';
import {Model} from '../base/model';
import {Deduping} from '../../api/reporting';
import {extractMentionedUsers, getUserId} from '../../utils/account-util';
import {SpecialFilePath} from '../../constants/constants';
import {AccountsModel} from '../accounts-model/accounts-model';
import {
distinctUntilChanged,
map,
shareReplay,
switchMap,
} from 'rxjs/operators';
import {isDefined} from '../../types/types';
import {ChangeViewModel} from '../views/change';
import {NavigationService} from '../../elements/core/gr-navigation/gr-navigation';
import {readJSONResponsePayload} from '../../elements/shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
export interface CommentState {
/** undefined means 'still loading' */
comments?: {[path: string]: CommentInfo[]};
/** undefined means 'still loading' */
robotComments?: {[path: string]: RobotCommentInfo[]};
// All drafts are DraftInfo objects and have `state` state set.
/** undefined means 'still loading' */
drafts?: {[path: string]: DraftInfo[]};
// Ported comments only affect `CommentThread` properties, not individual
// comments.
/**
* Comments ported from earlier patchsets.
*
* This only considers current patchset (right side), not the base patchset
* (left-side).
*
* undefined means 'still loading'
*/
portedComments?: {[path: string]: CommentInfo[]};
/**
* Drafts ported from earlier patchsets.
*
* undefined means 'still loading'
*/
portedDrafts?: {[path: string]: DraftInfo[]};
/**
* If a draft is discarded by the user, then we temporarily keep it in this
* array in case the user decides to Undo the discard operation and bring the
* draft back. Once restored, the draft is removed from this array.
*/
discardedDrafts: DraftInfo[];
}
const initialState: CommentState = {
comments: undefined,
robotComments: undefined,
drafts: undefined,
portedComments: undefined,
portedDrafts: undefined,
discardedDrafts: [],
};
const TOAST_DEBOUNCE_INTERVAL = 200;
function getSavingMessage(numPending: number, requestFailed?: boolean) {
if (requestFailed) {
return 'Unable to save draft';
}
if (numPending === 0) {
return 'All changes saved';
}
return undefined;
}
// Private but used in tests.
export function setComments(
state: CommentState,
comments?: {
[path: string]: CommentInfo[];
}
): CommentState {
const nextState = {...state};
if (deepEqual(comments, nextState.comments)) return state;
nextState.comments = addPath(comments) || {};
return nextState;
}
/** Updates a single comment in a state. */
export function updateComment(
state: CommentState,
comment: CommentInfo
): CommentState {
if (!comment.path || !state.comments) {
return state;
}
const newCommentsAtPath = [...state.comments[comment.path]];
for (let i = 0; i < newCommentsAtPath.length; ++i) {
if (newCommentsAtPath[i].id === comment.id) {
// TODO: In "delete comment" the returned comment is missing some of the
// fields (for example patch_set), which would throw errors when
// rendering. Remove merging with the old comment, once that is fixed in
// server code.
newCommentsAtPath[i] = {...newCommentsAtPath[i], ...comment};
return {
...state,
comments: {
...state.comments,
[comment.path]: newCommentsAtPath,
},
};
}
}
throw new Error('Comment to be updated does not exist');
}
// Private but used in tests.
export function setRobotComments(
state: CommentState,
robotComments?: {
[path: string]: RobotCommentInfo[];
}
): CommentState {
if (deepEqual(robotComments, state.robotComments)) return state;
const nextState = {...state};
nextState.robotComments = addPath(robotComments) || {};
return nextState;
}
// Private but used in tests.
export function setDrafts(
state: CommentState,
drafts?: {[path: string]: DraftInfo[]}
): CommentState {
if (deepEqual(drafts, state.drafts)) return state;
const nextState = {...state};
nextState.drafts = addPath(drafts);
return nextState;
}
// Private but used in tests.
export function setPortedComments(
state: CommentState,
portedComments?: {[path: string]: CommentInfo[]}
): CommentState {
if (deepEqual(portedComments, state.portedComments)) return state;
const nextState = {...state};
nextState.portedComments = portedComments || {};
return nextState;
}
// Private but used in tests.
export function setPortedDrafts(
state: CommentState,
portedDrafts?: {[path: string]: DraftInfo[]}
): CommentState {
if (deepEqual(portedDrafts, state.portedDrafts)) return state;
const nextState = {...state};
nextState.portedDrafts = portedDrafts || {};
return nextState;
}
// Private but used in tests.
export function setDiscardedDraft(
state: CommentState,
draft: DraftInfo
): CommentState {
const nextState = {...state};
nextState.discardedDrafts = [...nextState.discardedDrafts, draft];
return nextState;
}
// Private but used in tests.
export function deleteDiscardedDraft(
state: CommentState,
draftID?: string
): CommentState {
const nextState = {...state};
const drafts = [...nextState.discardedDrafts];
const index = drafts.findIndex(draft => id(draft) === draftID);
if (index === -1) {
throw new Error('discarded draft not found');
}
drafts.splice(index, 1);
nextState.discardedDrafts = drafts;
return nextState;
}
/** Adds or updates a draft in the state. */
export function setDraft(state: CommentState, draft: DraftInfo): CommentState {
const nextState = {...state};
assert(!!draft.path, 'draft without path');
assert(isDraft(draft), 'draft is not a draft');
nextState.drafts = {...nextState.drafts};
const drafts = nextState.drafts;
if (!drafts[draft.path]) drafts[draft.path] = [] as DraftInfo[];
else drafts[draft.path] = [...drafts[draft.path]];
const index = drafts[draft.path].findIndex(d => id(d) === id(draft));
if (index !== -1) {
drafts[draft.path][index] = draft;
} else {
drafts[draft.path].push(draft);
}
return nextState;
}
/** Removes a draft from the state.
*
* Removed draft is stored in discardedDrafts for potential undo operation.
* discardedDrafts however is only a client-side cache and such drafts are not
* retained in the server.
*/
export function deleteDraft(
state: CommentState,
draft: DraftInfo
): CommentState {
const nextState = {...state};
assert(!!draft.path, 'draft without path');
assert(isDraft(draft), 'draft is not a draft');
nextState.drafts = {...nextState.drafts};
const drafts = nextState.drafts;
const index = (drafts[draft.path] || []).findIndex(d => id(d) === id(draft));
if (index === -1) return state;
const discardedDraft = drafts[draft.path][index];
drafts[draft.path] = [...drafts[draft.path]];
drafts[draft.path].splice(index, 1);
return setDiscardedDraft(nextState, discardedDraft);
}
export const commentsModelToken = define<CommentsModel>('comments-model');
/**
* Model that maintains the state of all comments and drafts for the current
* change in the context of change-view.
*/
export class CommentsModel extends Model<CommentState> {
public readonly commentsLoading$ = select(
this.state$,
commentState =>
commentState.comments === undefined ||
commentState.robotComments === undefined ||
commentState.drafts === undefined
);
public readonly comments$ = select(
this.state$,
commentState => commentState.comments
);
public readonly robotComments$ = select(
this.state$,
commentState => commentState.robotComments
);
public readonly robotCommentCount$ = select(
this.robotComments$,
robotComments => Object.values(robotComments ?? {}).flat().length
);
public readonly drafts$ = select(
this.state$,
commentState => commentState.drafts
);
public readonly draftsLoading$ = select(
this.drafts$,
drafts => drafts === undefined
);
public readonly draftsArray$ = select(this.drafts$, drafts =>
Object.values(drafts ?? {}).flat()
);
public readonly draftsSaved$ = select(this.draftsArray$, drafts =>
drafts.filter(d => !isNew(d))
);
public readonly draftsCount$ = select(
this.draftsSaved$,
drafts => drafts.length
);
public readonly portedComments$ = select(
this.state$,
commentState => commentState.portedComments
);
public readonly discardedDrafts$ = select(
this.state$,
commentState => commentState.discardedDrafts
);
public readonly savingInProgress$ = select(this.draftsArray$, drafts =>
drafts.some(isSaving)
);
public readonly savingError$ = select(this.draftsArray$, drafts =>
drafts.some(isError)
);
public readonly patchsetLevelDrafts$ = select(this.draftsArray$, drafts =>
drafts.filter(
draft =>
draft.path === SpecialFilePath.PATCHSET_LEVEL_COMMENTS &&
!draft.in_reply_to
)
);
public readonly mentionedUsersInDrafts$: Observable<AccountInfo[]> =
this.draftsArray$.pipe(
switchMap(comments => {
const users: AccountInfo[] = [];
for (const comment of comments) {
users.push(...extractMentionedUsers(comment.message));
}
const uniqueUsers = users.filter(
(user, index) =>
index === users.findIndex(u => getUserId(u) === getUserId(user))
);
// forkJoin only emits value when the array is non-empty
if (uniqueUsers.length === 0) {
return of(uniqueUsers);
}
const filledUsers$: Observable<AccountInfo | undefined>[] =
uniqueUsers.map(user => from(this.accountsModel.fillDetails(user)));
return forkJoin(filledUsers$);
}),
map(users => users.filter(isDefined)),
distinctUntilChanged(deepEqual),
shareReplay(1)
);
public readonly mentionedUsersInUnresolvedDrafts$: Observable<AccountInfo[]> =
this.draftsArray$.pipe(
switchMap(drafts => {
const users: AccountInfo[] = [];
const comments = drafts.filter(c => c.unresolved);
for (const comment of comments) {
users.push(...extractMentionedUsers(comment.message));
}
const uniqueUsers = users.filter(
(user, index) =>
index === users.findIndex(u => getUserId(u) === getUserId(user))
);
// forkJoin only emits value when the array is non-empty
if (uniqueUsers.length === 0) {
return of(uniqueUsers);
}
const filledUsers$: Observable<AccountInfo | undefined>[] =
uniqueUsers.map(user => from(this.accountsModel.fillDetails(user)));
return forkJoin(filledUsers$);
}),
map(users => users.filter(isDefined)),
distinctUntilChanged(deepEqual),
shareReplay(1)
);
// Emits a new value even if only a single draft is changed. Components should
// aim to subsribe to something more specific.
public readonly changeComments$ = select(
this.state$,
commentState =>
new ChangeComments(
commentState.comments,
commentState.robotComments,
commentState.drafts,
commentState.portedComments,
commentState.portedDrafts
)
);
public readonly threads$ = select(this.changeComments$, changeComments =>
changeComments.getAllThreadsForChange()
);
public readonly threadsSaved$ = select(this.threads$, threads =>
threads.filter(t => !isNewThread(t))
);
public readonly draftThreadsSaved$ = select(this.threads$, threads =>
threads.filter(t => !isNewThread(t) && isDraftThread(t))
);
public readonly commentedPaths$ = select(
combineLatest([
this.changeComments$,
this.changeModel.basePatchNum$,
this.changeModel.patchNum$,
]),
([changeComments, basePatchNum, patchNum]) => {
if (!patchNum) return [];
const pathsMap = changeComments.getPaths({basePatchNum, patchNum});
return Object.keys(pathsMap);
}
);
public readonly reloadAllComments$ = new BehaviorSubject(undefined);
public thread$(id: UrlEncodedCommentId) {
return select(this.threads$, threads => threads.find(t => t.rootId === id));
}
private numPendingDraftRequests = 0;
private changeNum?: NumericChangeId;
private drafts: {[path: string]: DraftInfo[]} = {};
private draftToastTask?: DelayedTask;
private discardedDrafts: DraftInfo[] = [];
constructor(
private readonly changeViewModel: ChangeViewModel,
private readonly changeModel: ChangeModel,
private readonly accountsModel: AccountsModel,
private readonly restApiService: RestApiService,
private readonly reporting: ReportingService,
private readonly navigation: NavigationService
) {
super(initialState);
this.subscriptions.push(
this.savingInProgress$.subscribe(savingInProgress => {
if (savingInProgress) {
this.navigation.blockNavigation('draft comment still saving');
} else {
this.navigation.releaseNavigation('draft comment still saving');
}
})
);
this.subscriptions.push(
this.savingError$.subscribe(savingError => {
if (savingError) {
this.navigation.blockNavigation('draft comment failed to save');
} else {
this.navigation.releaseNavigation('draft comment failed to save');
}
})
);
this.subscriptions.push(
this.discardedDrafts$.subscribe(x => (this.discardedDrafts = x))
);
this.subscriptions.push(
this.drafts$.subscribe(x => (this.drafts = x ?? {}))
);
// Patchset-level draft should always exist when opening reply dialog.
// If there are none, create an empty one.
this.subscriptions.push(
combineLatest([
this.draftsLoading$,
this.patchsetLevelDrafts$,
this.changeModel.latestPatchNum$,
]).subscribe(([loading, plDraft, latestPatchNum]) => {
if (loading || plDraft.length > 0 || !latestPatchNum) return;
this.addNewDraft(createNewPatchsetLevel(latestPatchNum, '', false));
})
);
this.subscriptions.push(
combineLatest([this.changeViewModel.changeNum$, this.reloadAllComments$])
.pipe(
switchMap(([changeNum, _]) => {
this.changeNum = changeNum;
this.setState({...initialState});
if (!changeNum) return of([undefined, undefined, undefined]);
return forkJoin([
this.restApiService.getDiffComments(changeNum),
this.restApiService.getDiffRobotComments(changeNum),
this.restApiService.getDiffDrafts(changeNum),
]);
})
)
.subscribe(([comments, robotComments, drafts]) => {
this.reportRobotCommentStats(robotComments);
this.modifyState(s => {
s = setComments(s, comments);
s = setRobotComments(s, robotComments);
return setDrafts(s, drafts);
});
})
);
// When the patchset selection changes update information about comments
// ported from earlier patchsets.
this.subscriptions.push(
combineLatest([this.changeModel.changeNum$, this.changeModel.patchNum$])
.pipe(
switchMap(([changeNum, patchNum]) => {
this.changeNum = changeNum;
if (!changeNum) return of([undefined, undefined]);
const revision = patchNum ?? (CURRENT as RevisionId);
return forkJoin([
this.restApiService.getPortedComments(changeNum, revision),
this.restApiService.getPortedDrafts(changeNum, revision),
]);
})
)
.subscribe(([portedComments, portedDrafts]) =>
this.modifyState(s => {
s = setPortedComments(s, portedComments);
return setPortedDrafts(s, portedDrafts);
})
)
);
}
// Note that this does *not* reload ported comments.
reloadAllComments() {
this.reloadAllComments$.next(undefined);
}
// visible for testing
modifyState(reducer: (state: CommentState) => CommentState) {
this.setState(reducer({...this.getState()}));
}
private reportRobotCommentStats(obj?: PathToRobotCommentsInfoMap) {
if (!obj) return;
const comments = Object.values(obj).flat();
if (comments.length === 0) return;
const ids = comments.map(c => c.robot_id);
const latestPatchset = comments.reduce(
(latestPs, comment) =>
Math.max(latestPs, (comment?.patch_set as number) ?? 0),
0
);
const commentsLatest = comments.filter(c => c.patch_set === latestPatchset);
const commentsFixes = comments
.map(c => c.fix_suggestions?.length ?? 0)
.filter(l => l > 0);
const details = {
firstId: ids[0],
ids: [...new Set(ids)],
count: comments.length,
countLatest: commentsLatest.length,
countFixes: commentsFixes.length,
};
this.reporting.reportInteraction(
Interaction.ROBOT_COMMENTS_STATS,
details,
{deduping: Deduping.EVENT_ONCE_PER_CHANGE}
);
}
async restoreDraft(draftId: UrlEncodedCommentId) {
const found = this.discardedDrafts?.find(d => id(d) === draftId);
if (!found) throw new Error('discarded draft not found');
const newDraft: DraftInfo = {
...found,
...createNew(),
};
await this.saveDraft(newDraft);
this.modifyState(s => deleteDiscardedDraft(s, draftId));
}
/**
* Adds a new draft without saving it.
*
* There is no equivalent `removeNewDraft()` method, because
* `discardDraft()` can be used.
*/
addNewDraft(draft: DraftInfo) {
assert(isNew(draft), 'draft must be new');
this.modifyState(s => setDraft(s, draft));
}
/**
* Saves a new or updates an existing draft.
*
* `draft.message` must not be empty: Use `discardDraft()` instead.
*
* Draft must not be in `SAVING` state already.
*/
async saveDraft(draft: DraftInfo, showToast = true): Promise<DraftInfo> {
assertIsDefined(this.changeNum, 'change number');
assertIsDefined(draft.patch_set, 'patchset number of comment draft');
assert(!!draft.message?.trim(), 'cannot save empty draft');
assert(!isSaving(draft), 'saving already in progress');
// optimistic update
const draftSaving: DraftInfo = {...draft, savingState: SavingState.SAVING};
this.modifyState(s => setDraft(s, draftSaving));
// Saving the change number as to make sure that the response is still
// relevant when it comes back. The user maybe have navigated away.
const changeNum = this.changeNum;
this.report(Interaction.SAVE_COMMENT, draft);
if (showToast) this.showStartRequest();
const timing = isNew(draft) ? Timing.DRAFT_CREATE : Timing.DRAFT_UPDATE;
const timer = this.reporting.getTimer(timing);
let savedComment;
try {
const result = await this.restApiService.saveDiffDraft(
changeNum,
draft.patch_set,
convertToCommentInput(draft)
);
if (changeNum !== this.changeNum) return draft;
if (!result.ok) throw new Error('request failed');
savedComment = (await readJSONResponsePayload(result))
.parsed as unknown as CommentInfo;
} catch (error) {
if (showToast) this.handleFailedDraftRequest();
const draftError: DraftInfo = {...draft, savingState: SavingState.ERROR};
this.modifyState(s => setDraft(s, draftError));
return draftError;
}
const draftSaved: DraftInfo = {
...draft,
id: savedComment.id,
updated: savedComment.updated,
savingState: SavingState.OK,
};
timer.end({id: draftSaved.id});
if (showToast) this.showEndRequest();
this.modifyState(s => setDraft(s, draftSaved));
this.report(Interaction.COMMENT_SAVED, draftSaved);
return draftSaved;
}
async discardDraft(draftId: UrlEncodedCommentId) {
const draft = this.lookupDraft(draftId);
assertIsDefined(draft, `draft not found by id ${draftId}`);
assertIsDefined(draft.patch_set, 'patchset number of comment draft');
assert(!isSaving(draft), 'saving already in progress');
// optimistic update
this.modifyState(s => deleteDraft(s, draft));
// For "unsaved" drafts there is nothing to discard on the server side.
if (draft.id) {
if (!draft.message?.trim()) throw new Error('empty draft');
// Saving the change number as to make sure that the response is still
// relevant when it comes back. The user maybe have navigated away.
assertIsDefined(this.changeNum, 'change number');
const changeNum = this.changeNum;
this.report(Interaction.DISCARD_COMMENT, draft);
this.showStartRequest();
const timer = this.reporting.getTimer(Timing.DRAFT_DISCARD);
const result = await this.restApiService.deleteDiffDraft(
changeNum,
draft.patch_set,
{id: draft.id}
);
timer.end({id: draft.id});
if (changeNum !== this.changeNum) throw new Error('change changed');
if (!result.ok) {
this.handleFailedDraftRequest();
await this.restoreDraft(draftId);
throw new Error(
`Failed to discard draft comment: ${JSON.stringify(result)}`
);
}
this.showEndRequest();
}
// We don't store empty discarded drafts and don't need an UNDO then.
if (draft.message?.trim()) {
fire(document, 'show-alert', {
message: 'Draft Discarded',
action: 'Undo',
callback: () => this.restoreDraft(draftId),
});
}
this.report(Interaction.COMMENT_DISCARDED, draft);
}
async deleteComment(
changeNum: NumericChangeId,
comment: Comment,
reason: string
) {
assertIsDefined(comment.patch_set, 'comment.patch_set');
assert(!isDraft(comment), 'Admin deletion is only for published comments.');
const newComment = await this.restApiService.deleteComment(
changeNum,
comment.patch_set,
comment.id,
reason
);
// Don't update state on server error.
if (newComment) {
this.modifyState(s => updateComment(s, newComment));
}
}
private report(interaction: Interaction, comment: Comment) {
const details = reportingDetails(comment);
this.reporting.reportInteraction(interaction, details);
}
private showStartRequest() {
this.numPendingDraftRequests += 1;
this.updateRequestToast();
}
private showEndRequest() {
this.numPendingDraftRequests -= 1;
this.updateRequestToast();
}
private handleFailedDraftRequest() {
this.numPendingDraftRequests -= 1;
this.updateRequestToast(/* requestFailed=*/ true);
}
private updateRequestToast(requestFailed?: boolean) {
if (this.numPendingDraftRequests === 0 && !requestFailed) {
fire(document, 'hide-alert', {});
return;
}
const message = getSavingMessage(
this.numPendingDraftRequests,
requestFailed
);
if (!message) return;
this.draftToastTask = debounce(
this.draftToastTask,
() => fireAlert(document.body, message),
TOAST_DEBOUNCE_INTERVAL
);
}
private lookupDraft(commentId: UrlEncodedCommentId): DraftInfo | undefined {
return Object.values(this.drafts)
.flat()
.find(draft => id(draft) === commentId);
}
}