| /** |
| * @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, |
| PatchSetNumber, |
| CommentThread, |
| } from '../../types/common'; |
| import { |
| addPath, |
| convertToCommentInput, |
| createNew, |
| createNewPatchsetLevel, |
| getFirstComment, |
| hasSuggestion, |
| hasUserSuggestion, |
| id, |
| isDraftThread, |
| isNewThread, |
| isUnresolved, |
| 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/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 threadsWithUnappliedSuggestions$ = select( |
| combineLatest([this.threads$, this.changeModel.latestPatchNum$]), |
| ([threads, latestPs]) => |
| threads.filter( |
| t => |
| isUnresolved(t) && |
| hasSuggestion(t) && |
| getFirstComment(t)?.patch_set === latestPs |
| ) |
| ); |
| |
| 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); |
| }) |
| ) |
| ); |
| this.subscriptions.push( |
| combineLatest([ |
| this.comments$, |
| this.changeModel.latestPatchNum$, |
| ]).subscribe(([comments, latestPatchset]) => { |
| this.reportCommentStats(comments, latestPatchset); |
| }) |
| ); |
| this.subscriptions.push( |
| combineLatest([ |
| this.threadsSaved$, |
| this.changeModel.latestPatchNum$, |
| ]).subscribe(([threads, latestPatchset]) => { |
| this.reportThreadsStats(threads, latestPatchset); |
| }) |
| ); |
| } |
| |
| // 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} |
| ); |
| } |
| |
| private reportCommentStats( |
| obj?: {[path: string]: CommentInfo[]}, |
| latestPatchset?: PatchSetNumber |
| ) { |
| if (!obj || !latestPatchset) return; |
| const comments = Object.values(obj).flat(); |
| if (comments.length === 0) return; |
| |
| const commentsLatest = comments.filter(c => c.patch_set === latestPatchset); |
| const commentsUnresolved = comments.filter(c => c.unresolved); |
| const commentsLatestUnresolved = commentsLatest.filter(c => c.unresolved); |
| |
| const hasFix = (c: CommentInfo) => (c.fix_suggestions?.length ?? 0) > 0; |
| |
| const details = { |
| countLatest: commentsLatest.length, |
| countLatestWithFix: commentsLatest.filter(hasFix).length, |
| countLatestWithUserFix: commentsLatest.filter(hasUserSuggestion).length, |
| countLatestUnresolved: commentsLatestUnresolved.length, |
| countLatestUnresolvedWithFix: |
| commentsLatestUnresolved.filter(hasFix).length, |
| countLatestUnresolvedWithUserFix: |
| commentsLatestUnresolved.filter(hasUserSuggestion).length, |
| countAll: comments.length, |
| countAllUnresolved: commentsUnresolved.length, |
| countAllWithFix: comments.filter(hasFix).length, |
| countAllUnresolvedWithFix: commentsUnresolved.filter(hasFix).length, |
| countAllWithUserFix: comments.filter(hasUserSuggestion).length, |
| countAllUnresolvedWithUserFix: comments.filter( |
| c => c.unresolved && hasUserSuggestion(c) |
| ).length, |
| }; |
| this.reporting.reportInteraction(Interaction.COMMENTS_STATS, details, { |
| deduping: Deduping.EVENT_ONCE_PER_CHANGE, |
| }); |
| } |
| |
| private reportThreadsStats( |
| threads?: CommentThread[], |
| latestPatchset?: PatchSetNumber |
| ) { |
| if (!threads || !latestPatchset) return; |
| if (threads.length === 0) return; |
| |
| const threadsLatest = threads.filter( |
| t => getFirstComment(t)?.patch_set === latestPatchset |
| ); |
| const threadsUnresolved = threads.filter(isUnresolved); |
| const commentsLatestUnresolved = threadsLatest.filter(isUnresolved); |
| |
| const hasFix = (t: CommentThread) => |
| (getFirstComment(t)?.fix_suggestions?.length ?? 0) > 0; |
| |
| const hasUserFix = (t: CommentThread) => { |
| const firstComment = getFirstComment(t); |
| return firstComment && hasUserSuggestion(firstComment); |
| }; |
| |
| const details = { |
| countLatest: threadsLatest.length, |
| countLatestWithFix: threadsLatest.filter(hasFix).length, |
| countLatestWithUserFix: threadsLatest.filter(hasUserFix).length, |
| countLatestUnresolved: commentsLatestUnresolved.length, |
| countLatestUnresolvedWithFix: |
| commentsLatestUnresolved.filter(hasFix).length, |
| countLatestUnresolvedWithUserFix: |
| commentsLatestUnresolved.filter(hasUserFix).length, |
| countAll: threads.length, |
| countAllUnresolved: threadsUnresolved.length, |
| countAllWithFix: threads.filter(hasFix).length, |
| countAllUnresolvedWithFix: threadsUnresolved.filter(hasFix).length, |
| countAllWithUserFix: threads.filter(hasUserFix).length, |
| countAllUnresolvedWithUserFix: |
| threadsUnresolved.filter(hasUserFix).length, |
| }; |
| this.reporting.reportInteraction(Interaction.THREADS_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); |
| } |
| } |