Merge "Format mention suggestions with name + email"
diff --git a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
index 9f8f930..62de868 100644
--- a/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
+++ b/polygerrit-ui/app/elements/admin/gr-admin-view/gr-admin-view.ts
@@ -47,10 +47,19 @@
   adminViewModelToken,
   AdminViewState,
 } from '../../../models/views/admin';
-import {GroupDetailView, GroupViewState} from '../../../models/views/group';
-import {RepoDetailView, RepoViewState} from '../../../models/views/repo';
+import {
+  GroupDetailView,
+  groupViewModelToken,
+  GroupViewState,
+} from '../../../models/views/group';
+import {
+  RepoDetailView,
+  repoViewModelToken,
+  RepoViewState,
+} from '../../../models/views/repo';
 import {resolve} from '../../../models/dependency';
 import {subscribe} from '../../lit/subscription-controller';
+import {merge} from 'rxjs';
 
 const INTERNAL_GROUP_REGEX = /^[\da-f]{40}$/;
 
@@ -123,13 +132,22 @@
 
   private readonly restApiService = getAppContext().restApiService;
 
-  private readonly getViewModel = resolve(this, adminViewModelToken);
+  private readonly getAdminViewModel = resolve(this, adminViewModelToken);
+
+  private readonly getGroupViewModel = resolve(this, groupViewModelToken);
+
+  private readonly getRepoViewModel = resolve(this, repoViewModelToken);
 
   constructor() {
     super();
     subscribe(
       this,
-      () => this.getViewModel().state$,
+      () =>
+        merge(
+          this.getAdminViewModel().state$,
+          this.getGroupViewModel().state$,
+          this.getRepoViewModel().state$
+        ),
       x => {
         this.viewState = x;
         if (this.needsReload()) this.reload();
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
index c20ca1d..d076439 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.ts
@@ -181,7 +181,11 @@
 import {getBaseUrl, prependOrigin} from '../../../utils/url-util';
 import {CopyLink, GrCopyLinks} from '../gr-copy-links/gr-copy-links';
 import {KnownExperimentId} from '../../../services/flags/flags';
-import {ChangeViewState, createChangeUrl} from '../../../models/views/change';
+import {
+  changeViewModelToken,
+  ChangeViewState,
+  createChangeUrl,
+} from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {createEditUrl} from '../../../models/views/edit';
 
@@ -275,23 +279,19 @@
 
   @query('gr-copy-links') private copyLinksDropdown?: GrCopyLinks;
 
-  /**
-   * URL params passed from the router.
-   * Use params getter/setter.
-   */
-  private _params?: ChangeViewState;
+  private _viewState?: ChangeViewState;
 
   @property({type: Object})
-  get params() {
-    return this._params;
+  get viewState() {
+    return this._viewState;
   }
 
-  set params(params: ChangeViewState | undefined) {
-    if (this._params === params) return;
-    const oldParams = this._params;
-    this._params = params;
-    this.paramsChanged();
-    this.requestUpdate('params', oldParams);
+  set viewState(viewState: ChangeViewState | undefined) {
+    if (this._viewState === viewState) return;
+    const oldViewState = this._viewState;
+    this._viewState = viewState;
+    this.viewStateChanged();
+    this.requestUpdate('viewState', oldViewState);
   }
 
   @property({type: String})
@@ -433,11 +433,11 @@
 
   // Private but used in tests.
   getEditMode() {
-    if (!this.patchRange || !this.params) {
+    if (!this.patchRange || !this.viewState) {
       return false;
     }
 
-    if (this.params.edit) {
+    if (this.viewState.edit) {
       return true;
     }
 
@@ -554,6 +554,8 @@
 
   private readonly getFilesModel = resolve(this, filesModelToken);
 
+  private readonly getViewModel = resolve(this, changeViewModelToken);
+
   private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
   private replyRefitTask?: DelayedTask;
@@ -693,6 +695,11 @@
   private setupSubscriptions() {
     subscribe(
       this,
+      () => this.getViewModel().state$,
+      s => (this.viewState = s)
+    );
+    subscribe(
+      this,
       () => this.getChecksModel().aPluginHasRegistered$,
       b => {
         this.showChecksTab = b;
@@ -2094,25 +2101,25 @@
    */
   private isChangeObsolete() {
     // While this.changeNum is undefined the change view is fresh and has just
-    // not updated it to params.changeNum yet. Not obsolete in that case.
+    // not updated it to viewState.changeNum yet. Not obsolete in that case.
     if (this.changeNum === undefined) return false;
-    // this.params reflects the current state of the URL. If this.changeNum
+    // this.viewState reflects the current state of the URL. If this.changeNum
     // does not match it anymore, then this view must be considered obsolete.
-    return this.changeNum !== this.params?.changeNum;
+    return this.changeNum !== this.viewState?.changeNum;
   }
 
   // Private but used in tests.
-  hasPatchRangeChanged(value: ChangeViewState) {
+  hasPatchRangeChanged(viewState: ChangeViewState) {
     if (!this.patchRange) return false;
-    if (this.patchRange.basePatchNum !== value.basePatchNum) return true;
-    return this.hasPatchNumChanged(value);
+    if (this.patchRange.basePatchNum !== viewState.basePatchNum) return true;
+    return this.hasPatchNumChanged(viewState);
   }
 
   // Private but used in tests.
-  hasPatchNumChanged(value: ChangeViewState) {
+  hasPatchNumChanged(viewState: ChangeViewState) {
     if (!this.patchRange) return false;
-    if (value.patchNum !== undefined) {
-      return this.patchRange.patchNum !== value.patchNum;
+    if (viewState.patchNum !== undefined) {
+      return this.patchRange.patchNum !== viewState.patchNum;
     } else {
       // value.patchNum === undefined specifies the latest patchset
       return (
@@ -2122,8 +2129,8 @@
   }
 
   // Private but used in tests.
-  paramsChanged() {
-    if (this.params?.view !== GerritView.CHANGE) {
+  viewStateChanged() {
+    if (this.viewState === undefined) {
       this.initialLoadComplete = false;
       querySelectorAll(this, 'gr-overlay').forEach(overlay =>
         (overlay as GrOverlay).close()
@@ -2138,24 +2145,24 @@
       return;
     }
 
-    if (this.params.changeNum && this.params.project) {
+    if (this.viewState.changeNum && this.viewState.project) {
       this.restApiService.setInProjectLookup(
-        this.params.changeNum,
-        this.params.project
+        this.viewState.changeNum,
+        this.viewState.project
       );
     }
 
-    if (this.params.basePatchNum === undefined)
-      this.params.basePatchNum = PARENT;
+    if (this.viewState.basePatchNum === undefined)
+      this.viewState.basePatchNum = PARENT;
 
-    const patchChanged = this.hasPatchRangeChanged(this.params);
-    let patchNumChanged = this.hasPatchNumChanged(this.params);
+    const patchChanged = this.hasPatchRangeChanged(this.viewState);
+    let patchNumChanged = this.hasPatchNumChanged(this.viewState);
 
     this.patchRange = {
-      patchNum: this.params.patchNum,
-      basePatchNum: this.params.basePatchNum,
+      patchNum: this.viewState.patchNum,
+      basePatchNum: this.viewState.basePatchNum,
     };
-    this.scrollCommentId = this.params.commentId;
+    this.scrollCommentId = this.viewState.commentId;
 
     const patchKnown =
       !this.patchRange.patchNum ||
@@ -2163,7 +2170,7 @@
         ps => ps.num === this.patchRange!.patchNum
       );
     // _allPatchsets does not know value.patchNum so force a reload.
-    const forceReload = this.params.forceReload || !patchKnown;
+    const forceReload = this.viewState.forceReload || !patchKnown;
 
     // If changeNum is defined that means the change has already been
     // rendered once before so a full reload is not required.
@@ -2176,7 +2183,7 @@
         patchNumChanged = true;
       }
       if (patchChanged) {
-        // We need to collapse all diffs when params change so that a non
+        // We need to collapse all diffs when viewState changes so that a non
         // existing diff is not requested. See Issue 125270 for more details.
         this.fileList?.resetFileState();
         this.fileList?.collapseAllDiffs();
@@ -2198,8 +2205,8 @@
       return;
     }
 
-    // We need to collapse all diffs when params change so that a non existing
-    // diff is not requested. See Issue 125270 for more details.
+    // We need to collapse all diffs when viewState changes so that a non
+    // existing diff is not requested. See Issue 125270 for more details.
     this.updateComplete.then(() => {
       assertIsDefined(this.fileList);
       this.fileList?.collapseAllDiffs();
@@ -2218,7 +2225,7 @@
     }
 
     this.initialLoadComplete = false;
-    this.changeNum = this.params.changeNum;
+    this.changeNum = this.viewState.changeNum;
     this.loadData(true).then(() => {
       this.performPostLoadTasks();
     });
@@ -2232,9 +2239,9 @@
 
   private initActiveTab() {
     let tab = Tab.FILES;
-    if (this.params?.tab) {
-      tab = this.params?.tab as Tab;
-    } else if (this.params?.commentId) {
+    if (this.viewState?.tab) {
+      tab = this.viewState?.tab as Tab;
+    } else if (this.viewState?.commentId) {
       tab = Tab.COMMENT_THREADS;
     }
     const detail: SwitchTabEventDetail = {
@@ -2243,8 +2250,8 @@
     if (tab === Tab.CHECKS) {
       const state: ChecksTabState = {};
       detail.tabState = {checksTab: state};
-      if (this.params?.filter) state.filter = this.params.filter;
-      if (this.params?.attempt) state.attempt = this.params.attempt;
+      if (this.viewState?.filter) state.filter = this.viewState.filter;
+      if (this.viewState?.attempt) state.attempt = this.viewState.attempt;
     }
 
     this.setActiveTab(
@@ -2340,7 +2347,7 @@
 
   private maybeShowReplyDialog() {
     if (!this.loggedIn) return;
-    if (this.params?.openReplyDialog) {
+    if (this.viewState?.openReplyDialog) {
       this.openReplyDialog(FocusTarget.ANY);
     }
   }
@@ -2750,7 +2757,7 @@
 
   private async untilModelLoaded() {
     // NOTE: Wait until this page is connected before determining whether the
-    // model is loaded.  This can happen when params are changed when setting up
+    // model is loaded.  This can happen when viewState changes when setting up
     // this view. It's unclear whether this issue is related to Polymer
     // specifically.
     if (!this.isConnected) {
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
index 8bb937f..0e6fb71 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.ts
@@ -367,7 +367,7 @@
     element = await fixture<GrChangeView>(
       html`<gr-change-view></gr-change-view>`
     );
-    element.params = {
+    element.viewState = {
       view: GerritView.CHANGE,
       changeNum: TEST_NUMERIC_CHANGE_ID,
       project: 'gerrit' as RepoName,
@@ -730,9 +730,9 @@
       assert.equal(element.activeTab, Tab.FILES);
       // view is required
       element.changeNum = undefined;
-      element.params = {
+      element.viewState = {
         ...createChangeViewState(),
-        ...element.params,
+        ...element.viewState,
         tab: Tab.COMMENT_THREADS,
       };
       await element.updateComplete;
@@ -742,9 +742,9 @@
     test('invalid param change should not switch primary tab', async () => {
       assert.equal(element.activeTab, Tab.FILES);
       // view is required
-      element.params = {
+      element.viewState = {
         ...createChangeViewState(),
-        ...element.params,
+        ...element.viewState,
         tab: 'random',
       };
       await element.updateComplete;
@@ -1054,8 +1054,8 @@
       ) as GrRelatedChangesList;
       sinon.stub(relatedChanges, 'reload');
       sinon.stub(element, 'loadData').returns(Promise.resolve());
-      sinon.spy(element, 'paramsChanged');
-      element.params = createChangeViewState();
+      sinon.spy(element, 'viewStateChanged');
+      element.viewState = createChangeViewState();
     });
   });
 
@@ -1549,7 +1549,7 @@
     await element.updateComplete;
 
     element.changeNum = 2 as NumericChangeId;
-    element.params = {
+    element.viewState = {
       ...createChangeViewState(),
       changeNum: 2 as NumericChangeId,
     };
@@ -1574,7 +1574,7 @@
       patchNum: 1 as RevisionPatchSetNum,
     };
     element.changeNum = undefined;
-    element.params = value;
+    element.viewState = value;
     await element.updateComplete;
     assert.isTrue(reloadStub.calledOnce);
 
@@ -1590,7 +1590,7 @@
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
-    element.params = {...value};
+    element.viewState = {...value};
     await element.updateComplete;
     await waitEventLoop();
     assert.equal(element.fileList.selectedIndex, 0);
@@ -1619,7 +1619,7 @@
       view: GerritView.CHANGE,
       patchNum: 1 as RevisionPatchSetNum,
     };
-    element.params = value;
+    element.viewState = value;
     await element.updateComplete;
 
     element.initialLoadComplete = true;
@@ -1633,7 +1633,7 @@
 
     value.basePatchNum = 1 as BasePatchSetNum;
     value.patchNum = 2 as RevisionPatchSetNum;
-    element.params = {...value};
+    element.viewState = {...value};
     await element.updateComplete;
     assert.isTrue(reloadPortedCommentsStub.calledOnce);
     assert.isTrue(reloadPortedDraftsStub.calledOnce);
@@ -1646,13 +1646,13 @@
       .callsFake(() => Promise.resolve());
     const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
     const value: ChangeViewState = createChangeViewState();
-    element.params = value;
+    element.viewState = value;
     // change already loaded
     assert.isOk(element.changeNum);
     await element.updateComplete;
     assert.isFalse(reloadStub.calledOnce);
     element.initialLoadComplete = true;
-    element.params = {...value};
+    element.viewState = {...value};
     await element.updateComplete;
     assert.isFalse(reloadStub.calledTwice);
     assert.isFalse(collapseStub.calledTwice);
@@ -1667,7 +1667,7 @@
       .stub(element, 'loadData')
       .callsFake(() => Promise.resolve());
     const collapseStub = sinon.stub(element.fileList, 'collapseAllDiffs');
-    element.params = {...createChangeViewState(), forceReload: true};
+    element.viewState = {...createChangeViewState(), forceReload: true};
     await element.updateComplete;
     assert.isTrue(getChangeStub.called);
     assert.isTrue(loadDataStub.called);
@@ -1681,12 +1681,12 @@
     element.addEventListener('recreate-change-view', recreateSpy);
 
     const value: ChangeViewState = createChangeViewState();
-    element.params = value;
+    element.viewState = value;
     await element.updateComplete;
     assert.isFalse(recreateSpy.calledOnce);
 
     value.changeNum = 555111333 as NumericChangeId;
-    element.params = {...value};
+    element.viewState = {...value};
     await element.updateComplete;
     assert.isTrue(recreateSpy.calledOnce);
   });
@@ -1701,7 +1701,7 @@
       Promise.resolve({...createMergeable(), mergeable: true})
     );
 
-    element.params = createChangeViewState();
+    element.viewState = createChangeViewState();
     element.getChangeModel().setState({
       loadingStatus: LoadingStatus.LOADED,
       change: {
@@ -2019,8 +2019,8 @@
 
   test('header class computation', () => {
     assert.equal(element.computeHeaderClass(), 'header');
-    assertIsDefined(element.params);
-    element.params.edit = true;
+    assertIsDefined(element.viewState);
+    element.viewState.edit = true;
     assert.equal(element.computeHeaderClass(), 'header editMode');
   });
 
@@ -2038,8 +2038,8 @@
   });
 
   test('computeEditMode', async () => {
-    const callCompute = async (params: ChangeViewState) => {
-      element.params = params;
+    const callCompute = async (viewState: ChangeViewState) => {
+      element.viewState = viewState;
       await element.updateComplete;
       return element.getEditMode();
     };
@@ -2256,30 +2256,30 @@
     element.change.current_revision = '1' as CommitId;
     element.change = {...element.change};
 
-    const params = createChangeViewState();
+    const viewState = createChangeViewState();
 
-    assert.isFalse(element.hasPatchRangeChanged(params));
-    assert.isFalse(element.hasPatchNumChanged(params));
+    assert.isFalse(element.hasPatchRangeChanged(viewState));
+    assert.isFalse(element.hasPatchNumChanged(viewState));
 
-    params.basePatchNum = PARENT;
+    viewState.basePatchNum = PARENT;
     // undefined means navigate to latest patchset
-    params.patchNum = undefined;
+    viewState.patchNum = undefined;
 
     element.patchRange = {
       patchNum: 2 as RevisionPatchSetNum,
       basePatchNum: PARENT,
     };
 
-    assert.isTrue(element.hasPatchRangeChanged(params));
-    assert.isTrue(element.hasPatchNumChanged(params));
+    assert.isTrue(element.hasPatchRangeChanged(viewState));
+    assert.isTrue(element.hasPatchNumChanged(viewState));
 
     element.patchRange = {
       patchNum: 4 as RevisionPatchSetNum,
       basePatchNum: PARENT,
     };
 
-    assert.isFalse(element.hasPatchRangeChanged(params));
-    assert.isFalse(element.hasPatchNumChanged(params));
+    assert.isFalse(element.hasPatchRangeChanged(viewState));
+    assert.isFalse(element.hasPatchNumChanged(viewState));
   });
 
   suite('handleEditTap', () => {
@@ -2485,7 +2485,7 @@
       assert.isFalse(changeFullyLoadedStub.called);
     });
 
-    test('report changeDisplayed on paramsChanged', async () => {
+    test('report changeDisplayed on viewStateChanged', async () => {
       stubRestApi('getChangeOrEditFiles').resolves({
         'a-file.js': {},
       });
@@ -2499,7 +2499,7 @@
       );
       // reset so reload is triggered
       element.changeNum = undefined;
-      element.params = {
+      element.viewState = {
         ...createChangeViewState(),
         changeNum: TEST_NUMERIC_CHANGE_ID,
         project: TEST_PROJECT_NAME,
diff --git a/polygerrit-ui/app/elements/gr-app-element.ts b/polygerrit-ui/app/elements/gr-app-element.ts
index a0fec50..354d596 100644
--- a/polygerrit-ui/app/elements/gr-app-element.ts
+++ b/polygerrit-ui/app/elements/gr-app-element.ts
@@ -457,10 +457,7 @@
   // Template as not to create duplicates, for renderChangeView() only.
   private changeViewTemplate() {
     return html`
-      <gr-change-view
-        .params=${this.params}
-        .backPage=${this.lastSearchPage}
-      ></gr-change-view>
+      <gr-change-view .backPage=${this.lastSearchPage}></gr-change-view>
     `;
   }
 
diff --git a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
index 3887ee5b..eab6638 100644
--- a/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
+++ b/polygerrit-ui/app/elements/shared/gr-change-status/gr-change-status.ts
@@ -34,7 +34,7 @@
 
 export const MERGE_CONFLICT_TOOLTIP =
   'This change has merge conflicts. ' +
-  'Download the patch and run "git rebase". ' +
+  'Rebase on the upstream branch (e.g. "git pull --rebase"). ' +
   'Upload a new patchset after resolving all merge conflicts.';
 
 export const GIT_CONFLICT_TOOLTIP =
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
index f5e8a1a..cc76150 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment.ts
@@ -501,6 +501,9 @@
         .draft gr-account-label {
           width: unset;
         }
+        .draft gr-formatted-text.message {
+          margin-bottom: var(--spacing-m);
+        }
         .portedMessage {
           margin: 0 var(--spacing-m);
         }
@@ -723,7 +726,6 @@
         class="message"
         .content=${this.comment?.message}
         .config=${this.commentLinks}
-        ?noTrailingMargin=${!isDraftOrUnsaved(this.comment)}
       ></gr-formatted-text>
     `;
   }
diff --git a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
index 2e4383e..5686c6b 100644
--- a/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
+++ b/polygerrit-ui/app/elements/shared/gr-comment/gr-comment_test.ts
@@ -141,10 +141,7 @@
               </div>
             </div>
             <div class="body">
-              <gr-formatted-text
-                class="message"
-                notrailingmargin=""
-              ></gr-formatted-text>
+              <gr-formatted-text class="message"></gr-formatted-text>
             </div>
           </div>
         `
@@ -178,10 +175,7 @@
             </div>
             <div class="body">
               <div class="robotId"></div>
-              <gr-formatted-text
-                class="message"
-                notrailingmargin=""
-              ></gr-formatted-text>
+              <gr-formatted-text class="message"></gr-formatted-text>
               <div class="robotActions">
                 <gr-icon
                   icon="link"
diff --git a/polygerrit-ui/app/models/views/change.ts b/polygerrit-ui/app/models/views/change.ts
index 51b1862..7f75d83 100644
--- a/polygerrit-ui/app/models/views/change.ts
+++ b/polygerrit-ui/app/models/views/change.ts
@@ -41,10 +41,6 @@
   usp?: string;
 }
 
-const DEFAULT_STATE: ChangeViewState = {
-  view: GerritView.CHANGE,
-};
-
 export function createChangeUrl(state: Omit<ChangeViewState, 'view'>) {
   let range = getPatchRangeExpression(state);
   if (range.length) {
@@ -84,8 +80,8 @@
 export const changeViewModelToken =
   define<ChangeViewModel>('change-view-model');
 
-export class ChangeViewModel extends Model<ChangeViewState> {
+export class ChangeViewModel extends Model<ChangeViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 }
diff --git a/polygerrit-ui/app/models/views/repo.ts b/polygerrit-ui/app/models/views/repo.ts
index 93064fd..02fd17d 100644
--- a/polygerrit-ui/app/models/views/repo.ts
+++ b/polygerrit-ui/app/models/views/repo.ts
@@ -27,10 +27,6 @@
   offset?: number | string;
 }
 
-const DEFAULT_STATE: RepoViewState = {
-  view: GerritView.REPO,
-};
-
 export function createRepoUrl(state: Omit<RepoViewState, 'view'>) {
   let url = `/admin/repos/${encodeURL(`${state.repo}`, true)}`;
   if (state.detail === RepoDetailView.GENERAL) {
@@ -51,8 +47,8 @@
 
 export const repoViewModelToken = define<RepoViewModel>('repo-view-model');
 
-export class RepoViewModel extends Model<RepoViewState> {
+export class RepoViewModel extends Model<RepoViewState | undefined> {
   constructor() {
-    super(DEFAULT_STATE);
+    super(undefined);
   }
 }