Merge "Extract listing of GPG keys into a util class, so it can be reused."
diff --git a/polygerrit-ui/app/constants/reporting.ts b/polygerrit-ui/app/constants/reporting.ts
index 18ee992..ef2f63e 100644
--- a/polygerrit-ui/app/constants/reporting.ts
+++ b/polygerrit-ui/app/constants/reporting.ts
@@ -10,6 +10,7 @@
   STARTED_AS_GUEST = 'Started as guest',
   VISIBILILITY_HIDDEN = 'Visibility changed to hidden',
   VISIBILILITY_VISIBLE = 'Visibility changed to visible',
+  FOCUS = 'Focus changed',
   EXTENSION_DETECTED = 'Extension detected',
   PLUGINS_INSTALLED = 'Plugins installed',
   PLUGINS_FAILED = 'Some plugins failed to load',
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
index 4eee3cb..e0c28bc 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.ts
@@ -42,7 +42,6 @@
   BranchName,
   ChangeActionDialog,
   ChangeInfo,
-  ChangeViewChangeInfo,
   CherryPickInput,
   CommitId,
   InheritedBooleanInfo,
@@ -100,7 +99,7 @@
 import {changeModelToken} from '../../../models/change/change-model';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, css, html, nothing} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
 import {ifDefined} from 'lit/directives/if-defined.js';
 import {assertIsDefined, queryAll, uuid} from '../../../utils/common-util';
 import {Interaction} from '../../../constants/reporting';
@@ -113,6 +112,9 @@
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {modalStyles} from '../../../styles/gr-modal-styles';
 import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
+import {ParsedChangeInfo} from '../../../types/types';
+import {configModelToken} from '../../../models/config/config-model';
 
 const ERR_BRANCH_EMPTY = 'The destination branch can’t be empty.';
 const ERR_COMMIT_EMPTY = 'The commit message can’t be empty.';
@@ -377,76 +379,54 @@
 
   RevisionActions = RevisionActions;
 
-  @property({type: Object})
-  change?: ChangeViewChangeInfo;
+  @state() change?: ParsedChangeInfo;
 
-  @state()
-  actions: ActionNameToActionInfoMap = {};
+  @state() actions: ActionNameToActionInfoMap = {};
 
-  @property({type: Array})
-  primaryActionKeys: PrimaryActionKey[] = [
+  @state() primaryActionKeys: PrimaryActionKey[] = [
     ChangeActions.READY,
     RevisionActions.SUBMIT,
   ];
 
-  @property({type: Boolean})
-  disableEdit = false;
-
-  // private but used in test
   @state() _hideQuickApproveAction = false;
 
-  @property({type: Object})
-  account?: AccountInfo;
+  @state() account?: AccountInfo;
 
-  @property({type: String})
-  changeNum?: NumericChangeId;
+  @state() changeNum?: NumericChangeId;
 
-  @property({type: String})
-  changeStatus?: ChangeStatus;
+  @state() changeStatus?: ChangeStatus;
 
-  @property({type: String})
-  commitNum?: CommitId;
+  @state() commitNum?: CommitId;
 
   @state() latestPatchNum?: PatchSetNumber;
 
-  @property({type: String})
-  commitMessage = '';
+  @state() commitMessage = '';
 
-  @property({type: Object})
-  revisionActions: ActionNameToActionInfoMap = {};
+  @state() revisionActions: ActionNameToActionInfoMap = {};
 
-  @state() private revisionSubmitAction?: ActionInfo | null;
+  @state() revisionSubmitAction?: ActionInfo | null;
 
-  // used as a proprty type so cannot be private
   @state() revisionRebaseAction?: ActionInfo | null;
 
-  @property({type: String})
-  privateByDefault?: InheritedBooleanInfo;
+  @state() privateByDefault?: InheritedBooleanInfo;
 
-  // private but used in test
   @state() loading = true;
 
-  // private but used in test
   @state() actionLoadingMessage = '';
 
-  @state() private inProgressActionKeys = new Set<string>();
+  @state() inProgressActionKeys = new Set<string>();
 
-  // _computeAllActions always returns an array
-  // private but used in test
   @state() allActionValues: UIActionInfo[] = [];
 
-  // private but used in test
   @state() topLevelActions?: UIActionInfo[];
 
-  // private but used in test
   @state() topLevelPrimaryActions?: UIActionInfo[];
 
-  // private but used in test
   @state() topLevelSecondaryActions?: UIActionInfo[];
 
-  @state() private menuActions?: MenuAction[];
+  @state() menuActions?: MenuAction[];
 
-  @state() private overflowActions: OverflowAction[] = [
+  @state() overflowActions: OverflowAction[] = [
     {
       type: ActionType.CHANGE,
       key: ChangeActions.WIP,
@@ -493,29 +473,21 @@
     },
   ];
 
-  @state() private actionPriorityOverrides: ActionPriorityOverride[] = [];
+  @state() actionPriorityOverrides: ActionPriorityOverride[] = [];
 
-  @state() private additionalActions: UIActionInfo[] = [];
+  @state() additionalActions: UIActionInfo[] = [];
 
-  // private but used in test
   @state() hiddenActions: string[] = [];
 
-  // private but used in test
   @state() disabledMenuActions: string[] = [];
 
-  // private but used in test
-  @state()
-  editPatchsetLoaded = false;
+  @state() editPatchsetLoaded = false;
 
-  @property({type: Boolean})
-  editMode = false;
+  @state() editMode = false;
 
-  // private but used in test
-  @state()
-  editBasedOnCurrentPatchSet = true;
+  @state() editBasedOnCurrentPatchSet = true;
 
-  @property({type: Boolean})
-  loggedIn = false;
+  @state() loggedIn = false;
 
   private readonly restApiService = getAppContext().restApiService;
 
@@ -523,6 +495,10 @@
 
   private readonly getPluginLoader = resolve(this, pluginLoaderToken);
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   private readonly getChangeModel = resolve(this, changeModelToken);
 
   private readonly getStorage = resolve(this, storageServiceToken);
@@ -546,6 +522,51 @@
       () => this.getChangeModel().patchNum$,
       x => (this.editPatchsetLoaded = x === 'edit')
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      x => (this.changeNum = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().status$,
+      x => (this.changeStatus = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().editMode$,
+      x => (this.editMode = x)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().revision$,
+      rev => (this.commitNum = rev?.commit?.commit)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().latestRevision$,
+      rev => (this.commitMessage = rev?.commit?.message ?? '')
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      x => (this.account = x)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().loggedIn$,
+      x => (this.loggedIn = x)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().repoConfig$,
+      config => (this.privateByDefault = config?.private_by_default)
+    );
   }
 
   override connectedCallback() {
@@ -865,7 +886,7 @@
 
         this.revisionActions = revisionActions;
         this.sendShowRevisionActions({
-          change,
+          change: change as ChangeInfo,
           revisionActions,
         });
         this.handleLoadingComplete();
@@ -1031,18 +1052,7 @@
   }
 
   private editStatusChanged() {
-    // Hide change edits if not logged in
-    if (this.change === undefined || !this.loggedIn) {
-      return;
-    }
-    if (this.disableEdit) {
-      delete this.actions.rebaseEdit;
-      delete this.actions.publishEdit;
-      delete this.actions.deleteEdit;
-      delete this.actions.stopEdit;
-      delete this.actions.edit;
-      return;
-    }
+    if (!this.change || !this.loggedIn) return;
     if (this.editPatchsetLoaded) {
       // Only show actions that mutate an edit if an actual edit patch set
       // is loaded.
@@ -1121,7 +1131,7 @@
     if (!this.change || !this.change.labels || !this.change.permitted_labels) {
       return null;
     }
-    if (this.change && this.change.status === ChangeStatus.MERGED) {
+    if (this.change?.status === ChangeStatus.MERGED) {
       return null;
     }
     let result;
@@ -1323,18 +1333,18 @@
 
   // private but used in test
   canSubmitChange() {
-    if (!this.change) {
-      return false;
-    }
+    if (!this.change) return false;
+    const change = this.change as ChangeInfo;
+    const revision = this.getRevision(change, this.latestPatchNum);
     return this.getPluginLoader().jsApiService.canSubmitChange(
-      this.change,
-      this.getRevision(this.change, this.latestPatchNum)
+      change,
+      revision
     );
   }
 
   // private but used in test
-  getRevision(change: ChangeViewChangeInfo, patchNum?: PatchSetNumber) {
-    for (const rev of Object.values(change.revisions)) {
+  getRevision(change: ChangeInfo, patchNum?: PatchSetNumber) {
+    for (const rev of Object.values(change.revisions ?? {})) {
       if (rev._number === patchNum) {
         return rev;
       }
@@ -1805,7 +1815,7 @@
     if (dialog.init) dialog.init();
     dialog.hidden = false;
     assertIsDefined(this.actionsModal, 'actionsModal');
-    this.actionsModal.showModal();
+    if (this.actionsModal.isConnected) this.actionsModal.showModal();
     whenVisible(dialog, () => {
       if (dialog.resetFocus) {
         dialog.resetFocus();
@@ -1818,7 +1828,7 @@
   // private but used in test
   setReviewOnRevert(newChangeId: NumericChangeId) {
     const review = this.getPluginLoader().jsApiService.getReviewPostRevert(
-      this.change
+      this.change as ChangeInfo
     );
     if (!review) {
       return Promise.resolve(undefined);
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
index 946191b..b628cc1 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.ts
@@ -129,6 +129,7 @@
       element = await fixture<GrChangeActions>(html`
         <gr-change-actions></gr-change-actions>
       `);
+      element.changeStatus = ChangeStatus.NEW;
       element.change = {
         ...createChangeViewChange(),
         actions: {
@@ -155,7 +156,7 @@
       await element.reload();
     });
 
-    test('render', () => {
+    test('render', async () => {
       assert.shadowDom.equal(
         element,
         /* HTML */ `
@@ -200,6 +201,24 @@
                   Rebase
                 </gr-button>
               </gr-tooltip-content>
+              <gr-tooltip-content
+                has-tooltip=""
+                position-below=""
+                title="Edit this change"
+              >
+                <gr-button
+                  aria-disabled="false"
+                  class="edit"
+                  data-action-key="edit"
+                  data-label="Edit"
+                  link=""
+                  role="button"
+                  tabindex="0"
+                >
+                  <gr-icon filled="" icon="edit"> </gr-icon>
+                  Edit
+                </gr-button>
+              </gr-tooltip-content>
             </section>
             <gr-button
               aria-disabled="false"
@@ -705,29 +724,6 @@
     });
 
     suite('change edits', () => {
-      test('disableEdit', async () => {
-        element.editMode = false;
-        element.editBasedOnCurrentPatchSet = false;
-        element.change = {
-          ...createChangeViewChange(),
-          status: ChangeStatus.NEW,
-        };
-        element.disableEdit = true;
-        await element.updateComplete;
-
-        assert.isNotOk(
-          query(element, 'gr-button[data-action-key="publishEdit"]')
-        );
-        assert.isNotOk(
-          query(element, 'gr-button[data-action-key="rebaseEdit"]')
-        );
-        assert.isNotOk(
-          query(element, 'gr-button[data-action-key="deleteEdit"]')
-        );
-        assert.isNotOk(query(element, 'gr-button[data-action-key="edit"]'));
-        assert.isNotOk(query(element, 'gr-button[data-action-key="stopEdit"]'));
-      });
-
       test('shows confirm dialog for delete edit', async () => {
         element.loggedIn = true;
         element.editMode = true;
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
index b7851a6..1cb25ea 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.ts
@@ -82,6 +82,12 @@
 import {createChangeUrl} from '../../../models/views/change';
 import {getChangeWeblinks} from '../../../utils/weblink-util';
 import {throwingErrorCallback} from '../../shared/gr-rest-api-interface/gr-rest-apis/gr-rest-api-helper';
+import {subscribe} from '../../lit/subscription-controller';
+import {userModelToken} from '../../../models/user/user-model';
+import {resolve} from '../../../models/dependency';
+import {configModelToken} from '../../../models/config/config-model';
+import {changeModelToken} from '../../../models/change/change-model';
+import {relatedChangesModelToken} from '../../../models/change/related-changes-model';
 
 const HASHTAG_ADD_MESSAGE = 'Add Hashtag';
 
@@ -118,54 +124,90 @@
 export class GrChangeMetadata extends LitElement {
   @query('#webLinks') webLinks?: HTMLElement;
 
-  @property({type: Object}) change?: ParsedChangeInfo;
-
-  @property({type: Object}) revertedChange?: ChangeInfo;
-
-  @property({type: Object}) account?: AccountDetailInfo;
-
-  @property({type: Object}) revision?: RevisionInfo | EditRevisionInfo;
-
-  // TODO: Just use `revision.commit` instead.
-  @property({type: Object}) commitInfo?: CommitInfoWithRequiredCommit;
-
-  @property({type: Object}) serverConfig?: ServerInfo;
-
+  // TODO: Convert to @state. That requires the change model to keep track of
+  // current revision actions. Then we can also get rid of the
+  // `revision-actions-changed` event.
   @property({type: Boolean}) parentIsCurrent?: boolean;
 
-  @property({type: Object}) repoConfig?: ConfigInfo;
+  @state() change?: ParsedChangeInfo;
 
-  // private but used in test
+  @state() revertedChange?: ChangeInfo;
+
+  @state() account?: AccountDetailInfo;
+
+  @state() revision?: RevisionInfo | EditRevisionInfo;
+
+  @state() serverConfig?: ServerInfo;
+
+  @state() repoConfig?: ConfigInfo;
+
   @state() mutable = false;
 
-  @state() private readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
+  @state() readonly notCurrentMessage = NOT_CURRENT_MESSAGE;
 
-  // private but used in test
   @state() topicReadOnly = true;
 
-  // private but used in test
   @state() hashtagReadOnly = true;
 
-  @state() private pushCertificateValidation?: PushCertificateValidationInfo;
+  @state() pushCertificateValidation?: PushCertificateValidationInfo;
 
-  // private but used in test
   @state() settingTopic = false;
 
-  // private but used in test
   @state() currentParents: ParentCommitInfo[] = [];
 
-  @state() private showAllSections = false;
+  @state() showAllSections = false;
 
-  @state() private queryTopic?: AutocompleteQuery;
+  @state() queryTopic?: AutocompleteQuery;
 
-  @state() private queryHashtag?: AutocompleteQuery;
+  @state() queryHashtag?: AutocompleteQuery;
 
   private restApiService = getAppContext().restApiService;
 
   private readonly reporting = getAppContext().reportingService;
 
+  private readonly getUserModel = resolve(this, userModelToken);
+
+  private readonly getConfigModel = resolve(this, configModelToken);
+
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
+  private readonly getRelatedChangesModel = resolve(
+    this,
+    relatedChangesModelToken
+  );
+
   constructor() {
     super();
+    subscribe(
+      this,
+      () => this.getConfigModel().serverConfig$,
+      serverConfig => (this.serverConfig = serverConfig)
+    );
+    subscribe(
+      this,
+      () => this.getConfigModel().repoConfig$,
+      repoConfig => (this.repoConfig = repoConfig)
+    );
+    subscribe(
+      this,
+      () => this.getUserModel().account$,
+      account => (this.account = account)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      change => (this.change = change)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().revision$,
+      revision => (this.revision = revision)
+    );
+    subscribe(
+      this,
+      () => this.getRelatedChangesModel().revertingChange$,
+      revertingChange => (this.revertedChange = revertingChange)
+    );
     this.queryTopic = (input: string) => this.getTopicSuggestions(input);
     this.queryHashtag = (input: string) => this.getHashtagSuggestions(input);
   }
@@ -735,7 +777,10 @@
 
   // private but used in test
   computeWebLinks(): WebLinkInfo[] {
-    return getChangeWeblinks(this.commitInfo?.web_links, this.serverConfig);
+    return getChangeWeblinks(
+      this.revision?.commit?.web_links,
+      this.serverConfig
+    );
   }
 
   private computeStrategy() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
index c46fe24..e0e1364 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.ts
@@ -9,7 +9,6 @@
 import {ChangeRole, GrChangeMetadata} from './gr-change-metadata';
 import {
   createServerInfo,
-  createUserConfig,
   createParsedChange,
   createAccountWithId,
   createCommitInfoWithRequiredCommit,
@@ -61,18 +60,9 @@
   let element: GrChangeMetadata;
 
   setup(async () => {
-    stubRestApi('getLoggedIn').returns(Promise.resolve(false));
-    stubRestApi('getConfig').returns(
-      Promise.resolve({
-        ...createServerInfo(),
-        user: {
-          ...createUserConfig(),
-          anonymouscowardname: 'test coward name',
-        },
-      })
-    );
     element = await fixture(html`<gr-change-metadata></gr-change-metadata>`);
     element.change = createParsedChange();
+    element.account = undefined;
     await element.updateComplete;
   });
 
@@ -251,16 +241,22 @@
   });
 
   test('weblinks hidden when no weblinks', async () => {
-    element.commitInfo = createCommitInfoWithRequiredCommit();
+    element.revision = {
+      ...createRevision(),
+      commit: createCommitInfoWithRequiredCommit(),
+    };
     element.serverConfig = createServerInfo();
     await element.updateComplete;
     assert.isNull(element.webLinks);
   });
 
   test('weblinks hidden when only gitiles weblink', async () => {
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [{...createWebLinkInfo(), name: 'gitiles', url: '#'}],
+      },
     };
     element.serverConfig = createServerInfo();
     await element.updateComplete;
@@ -270,9 +266,12 @@
 
   test('weblinks hidden when sole weblink is set as primary', async () => {
     const browser = 'browser';
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [{...createWebLinkInfo(), name: browser, url: '#'}],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [{...createWebLinkInfo(), name: browser, url: '#'}],
+      },
     };
     element.serverConfig = {
       ...createServerInfo(),
@@ -286,9 +285,12 @@
   });
 
   test('weblinks are visible when other weblinks', async () => {
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [{...createWebLinkInfo(), name: 'test', url: '#'}],
+      },
     };
     await element.updateComplete;
     const webLinks = element.webLinks!;
@@ -297,12 +299,15 @@
   });
 
   test('weblinks are visible when gitiles and other weblinks', async () => {
-    element.commitInfo = {
-      ...createCommitInfoWithRequiredCommit(),
-      web_links: [
-        {...createWebLinkInfo(), name: 'test', url: '#'},
-        {...createWebLinkInfo(), name: 'gitiles', url: '#'},
-      ],
+    element.revision = {
+      ...createRevision(),
+      commit: {
+        ...createCommitInfoWithRequiredCommit(),
+        web_links: [
+          {...createWebLinkInfo(), name: 'test', url: '#'},
+          {...createWebLinkInfo(), name: 'gitiles', url: '#'},
+        ],
+      },
     };
     await element.updateComplete;
     const webLinks = element.webLinks!;
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 e1ae35c..2e1a8b9 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
@@ -335,10 +335,7 @@
   @state()
   private updateCheckTimerHandle?: number | null;
 
-  // Private but used in tests.
-  getEditMode(): boolean {
-    return !!this.viewState?.edit || this.patchNum === EDIT;
-  }
+  @state() editMode = false;
 
   isSubmitEnabled(): boolean {
     return !!(
@@ -664,6 +661,11 @@
     );
     subscribe(
       this,
+      () => this.getChangeModel().editMode$,
+      editMode => (this.editMode = editMode)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().patchNum$,
       patchNum => (this.patchNum = patchNum)
     );
@@ -1134,23 +1136,16 @@
         ${this.renderTabHeaders()} ${this.renderTabContent()}
         ${this.renderChangeLog()}
       </div>
-      <gr-apply-fix-dialog
-        id="applyFixDialog"
-        .change=${this.change}
-        .changeNum=${this.changeNum}
-      ></gr-apply-fix-dialog>
+      <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog>
       <dialog id="downloadModal" tabindex="-1">
         <gr-download-dialog
           id="downloadDialog"
-          .change=${this.change}
-          .config=${this.serverConfig?.download}
           @close=${this.handleDownloadDialogClose}
         ></gr-download-dialog>
       </dialog>
       <dialog id="includedInModal" tabindex="-1">
         <gr-included-in-dialog
           id="includedInDialog"
-          .changeNum=${this.changeNum}
           @close=${this.handleIncludedInDialogClose}
         ></gr-included-in-dialog>
       </dialog>
@@ -1287,27 +1282,18 @@
   }
 
   private renderCommitActions() {
-    return html` <div class="commitActions">
-      <!-- always show gr-change-actions regardless if logged in or not -->
-      <gr-change-actions
-        id="actions"
-        .change=${this.change}
-        .disableEdit=${false}
-        .account=${this.account}
-        .changeNum=${this.changeNum}
-        .changeStatus=${this.change?.status}
-        .commitNum=${this.revision?.commit?.commit}
-        .commitMessage=${this.latestCommitMessage}
-        .editMode=${this.getEditMode()}
-        .privateByDefault=${this.projectConfig?.private_by_default}
-        .loggedIn=${this.loggedIn}
-        @edit-tap=${() => this.handleEditTap()}
-        @stop-edit-tap=${() => this.handleStopEditTap()}
-        @download-tap=${() => this.handleOpenDownloadDialog()}
-        @included-tap=${() => this.handleOpenIncludedInDialog()}
-        @revision-actions-changed=${this.handleRevisionActionsChanged}
-      ></gr-change-actions>
-    </div>`;
+    return html`
+      <div class="commitActions">
+        <gr-change-actions
+          id="actions"
+          @edit-tap=${() => this.handleEditTap()}
+          @stop-edit-tap=${() => this.handleStopEditTap()}
+          @download-tap=${() => this.handleOpenDownloadDialog()}
+          @included-tap=${() => this.handleOpenIncludedInDialog()}
+          @revision-actions-changed=${this.handleRevisionActionsChanged}
+        ></gr-change-actions>
+      </div>
+    `;
   }
 
   private renderChangeInfo() {
@@ -1315,20 +1301,13 @@
       this.loggedIn,
       this.editingCommitMessage,
       this.change,
-      this.getEditMode()
+      this.editMode
     );
     return html` <div class="changeInfo">
       <div class="changeInfo-column changeMetadata">
         <gr-change-metadata
           id="metadata"
-          .change=${this.change}
-          .revertedChange=${this.revertingChange}
-          .account=${this.account}
-          .revision=${this.revision}
-          .commitInfo=${this.revision?.commit}
-          .serverConfig=${this.serverConfig}
           .parentIsCurrent=${this.isParentCurrent()}
-          .repoConfig=${this.projectConfig}
           @show-reply-dialog=${this.handleShowReplyDialog}
         >
         </gr-change-metadata>
@@ -1461,7 +1440,7 @@
           .changeNum=${this.changeNum}
           .commitInfo=${this.revision?.commit}
           .changeUrl=${this.computeChangeUrl()}
-          .editMode=${this.getEditMode()}
+          .editMode=${this.editMode}
           .loggedIn=${this.loggedIn}
           .shownFileCount=${this.shownFileCount}
           .filesExpanded=${this.fileList?.filesExpanded}
@@ -1475,7 +1454,7 @@
           id="fileList"
           .change=${this.change}
           .changeNum=${this.changeNum}
-          .editMode=${this.getEditMode()}
+          .editMode=${this.editMode}
           @files-shown-changed=${(e: CustomEvent<{length: number}>) => {
             this.shownFileCount = e.detail.length;
           }}
@@ -1957,7 +1936,7 @@
       change: this.change,
       patchNum: this.patchNum,
       basePatchNum: this.basePatchNum,
-      edit: this.getEditMode(),
+      edit: this.editMode,
       messageHash: hash,
     });
     history.replaceState(null, '', url);
@@ -2405,7 +2384,7 @@
   // Private but used in tests.
   computeHeaderClass() {
     const classes = ['header'];
-    if (this.getEditMode()) {
+    if (this.editMode) {
       classes.push('editMode');
     }
     return classes.join(' ');
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 5331558..ae60449 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
@@ -73,7 +73,7 @@
 import {Modifier} from '../../../utils/dom-util';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {GrCopyLinks} from '../gr-copy-links/gr-copy-links';
-import {ChangeChildView, ChangeViewState} from '../../../models/views/change';
+import {ChangeChildView} from '../../../models/views/change';
 import {rootUrl} from '../../../utils/url-util';
 import {testResolver} from '../../../test/common-test-setup';
 import {UserModel, userModelToken} from '../../../models/user/user-model';
@@ -1318,10 +1318,9 @@
     });
   });
 
-  test('header class computation', () => {
+  test('header class computation', async () => {
     assert.equal(element.computeHeaderClass(), 'header');
-    assertIsDefined(element.viewState);
-    element.viewState.edit = true;
+    element.editMode = true;
     assert.equal(element.computeHeaderClass(), 'header editMode');
   });
 
@@ -1342,38 +1341,6 @@
     assert.equal(scrollStub.lastCall.args[0], 'TEST');
   });
 
-  test('computeEditMode', async () => {
-    const callCompute = async (viewState: ChangeViewState) => {
-      element.viewState = viewState;
-      element.patchNum = viewState.patchNum;
-      element.basePatchNum = viewState.basePatchNum ?? PARENT;
-      await element.updateComplete;
-      return element.getEditMode();
-    };
-    assert.isTrue(
-      await callCompute({
-        ...createChangeViewState(),
-        edit: true,
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      })
-    );
-    assert.isFalse(
-      await callCompute({
-        ...createChangeViewState(),
-        basePatchNum: PARENT,
-        patchNum: 1 as RevisionPatchSetNum,
-      })
-    );
-    assert.isTrue(
-      await callCompute({
-        ...createChangeViewState(),
-        basePatchNum: 1 as BasePatchSetNum,
-        patchNum: EDIT,
-      })
-    );
-  });
-
   test('file-action-tap handling', async () => {
     element.patchNum = 1 as RevisionPatchSetNum;
     element.change = {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
index fedc377..e421c9c 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.ts
@@ -15,6 +15,7 @@
 import {resolve} from '../../../models/dependency';
 import {pluginLoaderToken} from '../../shared/gr-js-api-interface/gr-plugin-loader';
 import {createSearchUrl} from '../../../models/views/search';
+import {ParsedChangeInfo} from '../../../types/types';
 
 const ERR_COMMIT_NOT_FOUND = 'Unable to find the commit hash of this change.';
 const INSERT_REASON_STRING = '<INSERT REASONING HERE>';
@@ -170,15 +171,23 @@
     return this.revertType === RevertType.REVERT_SUBMISSION;
   }
 
-  modifyRevertMsg(change: ChangeInfo, commitMessage: string, message: string) {
+  modifyRevertMsg(
+    change: ParsedChangeInfo,
+    commitMessage: string,
+    message: string
+  ) {
     return this.getPluginLoader().jsApiService.modifyRevertMsg(
-      change,
+      change as ChangeInfo,
       message,
       commitMessage
     );
   }
 
-  populate(change: ChangeInfo, commitMessage: string, changesCount: number) {
+  populate(
+    change: ParsedChangeInfo,
+    commitMessage: string,
+    changesCount: number
+  ) {
     this.changesCount = changesCount;
     // The option to revert a single change is always available
     this.populateRevertSingleChangeMessage(
@@ -190,7 +199,7 @@
   }
 
   populateRevertSingleChangeMessage(
-    change: ChangeInfo,
+    change: ParsedChangeInfo,
     commitMessage: string,
     commitHash?: CommitId
   ) {
@@ -215,18 +224,21 @@
   }
 
   private modifyRevertSubmissionMsg(
-    change: ChangeInfo,
+    change: ParsedChangeInfo,
     msg: string,
     commitMessage: string
   ) {
     return this.getPluginLoader().jsApiService.modifyRevertSubmissionMsg(
-      change,
+      change as ChangeInfo,
       msg,
       commitMessage
     );
   }
 
-  populateRevertSubmissionMessage(change: ChangeInfo, commitMessage: string) {
+  populateRevertSubmissionMessage(
+    change: ParsedChangeInfo,
+    commitMessage: string
+  ) {
     // Follow the same convention of the revert
     const commitHash = change.current_revision;
     if (!commitHash) {
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
index 904285f..8d71e15 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.ts
@@ -5,7 +5,7 @@
  */
 import {fixture, html, assert} from '@open-wc/testing';
 import '../../../test/common-test-setup';
-import {createChange} from '../../../test/test-data-generators';
+import {createParsedChange} from '../../../test/test-data-generators';
 import {ChangeSubmissionId, CommitId} from '../../../types/common';
 import './gr-confirm-revert-dialog';
 import {GrConfirmRevertDialog} from './gr-confirm-revert-dialog';
@@ -48,7 +48,7 @@
     const alertStub = sinon.stub();
     element.addEventListener('show-alert', alertStub);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'not a commitHash in sight',
       undefined
     );
@@ -58,7 +58,7 @@
   test('single line', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'one line commit\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -72,7 +72,7 @@
   test('multi line', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -86,7 +86,7 @@
   test('issue above change id', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -100,7 +100,7 @@
   test('revert a revert', () => {
     assert.isNotOk(element.message);
     element.populateRevertSingleChangeMessage(
-      createChange(),
+      createParsedChange(),
       'Revert "one line commit"\n\nChange-Id: abcdefg\n',
       'abcd123' as CommitId
     );
@@ -115,7 +115,7 @@
     element.changesCount = 3;
     element.populateRevertSubmissionMessage(
       {
-        ...createChange(),
+        ...createParsedChange(),
         submission_id: '5545' as ChangeSubmissionId,
         current_revision: 'abcd123' as CommitId,
       },
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
index 11dc890..e1808bf 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.ts
@@ -5,7 +5,7 @@
  */
 import '../../shared/gr-download-commands/gr-download-commands';
 import {changeBaseURL, getRevisionKey} from '../../../utils/change-util';
-import {ChangeInfo, DownloadInfo, PatchSetNum} from '../../../types/common';
+import {DownloadInfo, PatchSetNum} from '../../../types/common';
 import {GrDownloadCommands} from '../../shared/gr-download-commands/gr-download-commands';
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {copyToClipbard, hasOwnProperty} from '../../../utils/common-util';
@@ -13,13 +13,15 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state, query} from 'lit/decorators.js';
+import {customElement, state, query} from 'lit/decorators.js';
 import {assertIsDefined} from '../../../utils/common-util';
 import {BindValueChangeEvent} from '../../../types/events';
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {subscribe} from '../../lit/subscription-controller';
 import {resolve} from '../../../models/dependency';
 import {changeModelToken} from '../../../models/change/change-model';
+import {ParsedChangeInfo} from '../../../types/types';
+import {configModelToken} from '../../../models/config/config-model';
 
 @customElement('gr-download-dialog')
 export class GrDownloadDialog extends LitElement {
@@ -35,27 +37,37 @@
 
   @query('#closeButton') protected closeButton?: GrButton;
 
-  @property({type: Object})
-  change: ChangeInfo | undefined;
+  @state() change?: ParsedChangeInfo;
 
-  @property({type: Object})
-  config?: DownloadInfo;
+  @state() config?: DownloadInfo;
 
   @state() patchNum?: PatchSetNum;
 
-  @state() private selectedScheme?: string;
+  @state() selectedScheme?: string;
 
   private readonly shortcuts = new ShortcutController(this);
 
   private readonly getChangeModel = resolve(this, changeModelToken);
 
+  private readonly getConfigModel = resolve(this, configModelToken);
+
   constructor() {
     super();
     subscribe(
       this,
+      () => this.getChangeModel().change$,
+      x => (this.change = x)
+    );
+    subscribe(
+      this,
       () => this.getChangeModel().patchNum$,
       x => (this.patchNum = x)
     );
+    subscribe(
+      this,
+      () => this.getConfigModel().download$,
+      x => (this.config = x)
+    );
     for (const key of ['1', '2', '3', '4', '5']) {
       this.shortcuts.addLocal({key}, e => this.handleNumberKey(e));
     }
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
index e5f40a6..73d7618 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.ts
@@ -8,6 +8,7 @@
   createChange,
   createCommit,
   createDownloadInfo,
+  createParsedChange,
   createRevision,
 } from '../../../test/test-data-generators';
 import {
@@ -160,7 +161,7 @@
   suite('gr-download-dialog tests with no fetch options', () => {
     setup(async () => {
       element.change = {
-        ...createChange(),
+        ...createParsedChange(),
         revisions: {
           r1: {
             ...createRevision(),
@@ -204,7 +205,7 @@
 
     test('computed fields', () => {
       element.change = {
-        ...createChange(),
+        ...createParsedChange(),
         project: 'test/project' as RepoName,
         _number: 123 as NumericChangeId,
       };
@@ -233,7 +234,7 @@
     element.patchNum = 1 as PatchSetNum;
 
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       revisions: {
         r1: {...createRevision(), commit: createCommit()},
       },
@@ -241,7 +242,7 @@
     assert.isTrue(element.computeHidePatchFile());
 
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       revisions: {
         r1: {
           ...createRevision(),
@@ -255,7 +256,7 @@
     assert.isFalse(element.computeHidePatchFile());
 
     element.change = {
-      ...createChange(),
+      ...createParsedChange(),
       revisions: {
         r1: {
           ...createRevision(),
diff --git a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
index 33dfe82..32da400 100644
--- a/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
+++ b/polygerrit-ui/app/elements/change/gr-included-in-dialog/gr-included-in-dialog.ts
@@ -10,9 +10,12 @@
 import {fontStyles} from '../../../styles/gr-font-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {LitElement, PropertyValues, html, css} from 'lit';
-import {customElement, property, state} from 'lit/decorators.js';
+import {customElement, state} from 'lit/decorators.js';
 import {BindValueChangeEvent} from '../../../types/events';
 import {fireNoBubble} from '../../../utils/event-util';
+import {resolve} from '../../../models/dependency';
+import {changeModelToken} from '../../../models/change/change-model';
+import {subscribe} from '../../lit/subscription-controller';
 
 interface DisplayGroup {
   title: string;
@@ -27,19 +30,18 @@
    * @event close
    */
 
-  @property({type: Object})
-  changeNum?: NumericChangeId;
+  @state() changeNum?: NumericChangeId;
 
-  // private but used in test
   @state() includedIn?: IncludedInInfo;
 
   @state() private loaded = false;
 
-  // private but used in test
   @state() filterText = '';
 
   private readonly restApiService = getAppContext().restApiService;
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   static override get styles() {
     return [
       fontStyles,
@@ -93,6 +95,15 @@
     ];
   }
 
+  constructor() {
+    super();
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      changeNum => (this.changeNum = changeNum)
+    );
+  }
+
   override render() {
     return html`
       <header>
diff --git a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
index 8bcbb2b..4f1ce7a 100644
--- a/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
+++ b/polygerrit-ui/app/elements/diff/gr-apply-fix-dialog/gr-apply-fix-dialog.ts
@@ -24,7 +24,7 @@
 import {GrButton} from '../../shared/gr-button/gr-button';
 import {TokenHighlightLayer} from '../../../embed/diff/gr-diff-builder/token-highlight-layer';
 import {css, html, LitElement, nothing} from 'lit';
-import {customElement, property, query, state} from 'lit/decorators.js';
+import {customElement, query, state} from 'lit/decorators.js';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {subscribe} from '../../lit/subscription-controller';
 import {assert} from '../../../utils/common-util';
@@ -39,6 +39,7 @@
 import {fireReload} from '../../../utils/event-util';
 import {when} from 'lit/directives/when.js';
 import {Timing} from '../../../constants/reporting';
+import {changeModelToken} from '../../../models/change/change-model';
 
 interface FilePreview {
   filepath: string;
@@ -62,10 +63,10 @@
   @query('#nextFix')
   nextFix?: GrButton;
 
-  @property({type: Object})
+  @state()
   change?: ParsedChangeInfo;
 
-  @property({type: Number})
+  @state()
   changeNum?: NumericChangeId;
 
   @state()
@@ -102,6 +103,8 @@
 
   private readonly getUserModel = resolve(this, userModelToken);
 
+  private readonly getChangeModel = resolve(this, changeModelToken);
+
   private readonly getNavigation = resolve(this, navigationToken);
 
   private readonly reporting = getAppContext().reportingService;
@@ -133,6 +136,16 @@
         this.syntaxLayer.setEnabled(!!this.diffPrefs.syntax_highlighting);
       }
     );
+    subscribe(
+      this,
+      () => this.getChangeModel().change$,
+      change => (this.change = change)
+    );
+    subscribe(
+      this,
+      () => this.getChangeModel().changeNum$,
+      changeNum => (this.changeNum = changeNum)
+    );
   }
 
   static override styles = [
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
index bdb634b..0f54ad1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.ts
@@ -46,7 +46,6 @@
   PreferencesInfo,
   RepoName,
   RevisionPatchSetNum,
-  ServerInfo,
   CommentMap,
 } from '../../../types/common';
 import {DiffInfo, DiffPreferencesInfo, WebLinkInfo} from '../../../types/diff';
@@ -80,7 +79,6 @@
 import {ShortcutController} from '../../lit/shortcut-controller';
 import {subscribe} from '../../lit/subscription-controller';
 import {customElement, property, query, state} from 'lit/decorators.js';
-import {configModelToken} from '../../../models/config/config-model';
 import {a11yStyles} from '../../../styles/gr-a11y-styles';
 import {sharedStyles} from '../../../styles/shared-styles';
 import {ifDefined} from 'lit/directives/if-defined.js';
@@ -194,9 +192,6 @@
   @property({type: Object})
   prefs?: DiffPreferencesInfo;
 
-  @state()
-  private serverConfig?: ServerInfo;
-
   // Private but used in tests.
   @state()
   userPrefs?: PreferencesInfo;
@@ -241,8 +236,6 @@
 
   private readonly getShortcutsService = resolve(this, shortcutsServiceToken);
 
-  private readonly getConfigModel = resolve(this, configModelToken);
-
   private readonly getViewModel = resolve(this, changeViewModelToken);
 
   private throttledToggleFileReviewed?: (e: KeyboardEvent) => void;
@@ -340,13 +333,6 @@
     );
     subscribe(
       this,
-      () => this.getConfigModel().serverConfig$,
-      config => {
-        this.serverConfig = config;
-      }
-    );
-    subscribe(
-      this,
       () => this.getCommentsModel().changeComments$,
       changeComments => {
         this.changeComments = changeComments;
@@ -966,12 +952,8 @@
   }
 
   private renderDialogs() {
-    return html` <gr-apply-fix-dialog
-        id="applyFixDialog"
-        .change=${this.change}
-        .changeNum=${this.changeNum}
-      >
-      </gr-apply-fix-dialog>
+    return html`
+      <gr-apply-fix-dialog id="applyFixDialog"></gr-apply-fix-dialog>
       <gr-diff-preferences-dialog
         id="diffPreferencesDialog"
         @reload-diff-preference=${this.handleReloadingDiffPreference}
@@ -980,12 +962,10 @@
       <dialog id="downloadModal" tabindex="-1">
         <gr-download-dialog
           id="downloadDialog"
-          .change=${this.change}
-          .patchNum=${this.patchNum}
-          .config=${this.serverConfig?.download}
           @close=${this.handleDownloadDialogClose}
         ></gr-download-dialog>
-      </dialog>`;
+      </dialog>
+    `;
   }
 
   /**
diff --git a/polygerrit-ui/app/models/change/change-model.ts b/polygerrit-ui/app/models/change/change-model.ts
index 6d111aa..f5d7fef 100644
--- a/polygerrit-ui/app/models/change/change-model.ts
+++ b/polygerrit-ui/app/models/change/change-model.ts
@@ -281,6 +281,12 @@
         viewModelState?.patchNum || latestPatchN
     );
 
+  /** The user can enter edit mode without an `EDIT` patchset existing yet. */
+  public readonly editMode$ = select(
+    combineLatest([this.viewModel.edit$, this.patchNum$]),
+    ([edit, patchNum]) => !!edit || patchNum === EDIT
+  );
+
   /**
    * Emits the base patchset number. This is identical to the
    * `viewModel.basePatchNum$`, but has some special logic for merges.
diff --git a/polygerrit-ui/app/models/config/config-model.ts b/polygerrit-ui/app/models/config/config-model.ts
index 168e0f4..4c0df60 100644
--- a/polygerrit-ui/app/models/config/config-model.ts
+++ b/polygerrit-ui/app/models/config/config-model.ts
@@ -34,6 +34,11 @@
     configState => configState.serverConfig
   );
 
+  public download$ = select(
+    this.serverConfig$,
+    serverConfig => serverConfig?.download
+  );
+
   public mergeabilityComputationBehavior$ = select(
     this.serverConfig$,
     serverConfig => serverConfig?.change?.mergeability_computation_behavior
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
index 49259b4..9b08a6e 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting.ts
@@ -34,6 +34,7 @@
 
   appStarted(): void;
   onVisibilityChange(): void;
+  onFocusChange(): void;
   beforeLocationChanged(): void;
   locationChanged(page: string): void;
   dashboardDisplayed(): void;
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
index 350f199..b8758e2 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_impl.ts
@@ -185,6 +185,12 @@
   document.addEventListener('visibilitychange', () => {
     reportingService.onVisibilityChange();
   });
+  window.addEventListener('blur', () => {
+    reportingService.onFocusChange();
+  });
+  window.addEventListener('focus', () => {
+    reportingService.onFocusChange();
+  });
 }
 
 export function initClickReporter(reportingService: ReportingService) {
@@ -526,6 +532,20 @@
     this._reportNavResTimes();
   }
 
+  onFocusChange() {
+    this.reporter(
+      LIFECYCLE.TYPE,
+      LIFECYCLE.CATEGORY.VISIBILITY,
+      LifeCycle.FOCUS,
+      undefined,
+      {
+        isVisible: document.visibilityState === 'visible',
+        hasFocus: document.hasFocus(),
+      },
+      false
+    );
+  }
+
   onVisibilityChange() {
     this.hiddenDurationTimer.onVisibilityChange();
     let eventName;
@@ -542,6 +562,8 @@
         undefined,
         {
           hiddenDurationMs: this.hiddenDurationTimer.hiddenDurationMs,
+          isVisible: document.visibilityState === 'visible',
+          hasFocus: document.hasFocus(),
         },
         false
       );
diff --git a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
index d4efbcc..fd1b88c 100644
--- a/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
+++ b/polygerrit-ui/app/services/gr-reporting/gr-reporting_mock.ts
@@ -44,6 +44,9 @@
   onVisibilityChange: () => {
     log('onVisibilityChange');
   },
+  onFocusChange: () => {
+    log('onFocusChange');
+  },
   pluginLoaded: () => {},
   pluginsLoaded: () => {},
   pluginsFailed: () => {},